🧩 MosAIc β€” Multi-Source AI ETF Basket Advisor

MosAIc builds ETF basket recommendations by assembling many data pieces into one coherent picture β€” structured fund data, quantitative analysis, macro regime indicators, prediction market odds, and multi-source news β€” orchestrated through a two-program architecture that separates data gathering from AI reasoning.

Program 1 scrapes and computes: fetches ETF data from web sources, extracts structured fields, computes correlation matrices, momentum indicators, and currency exposure, then saves everything to a MariaDB database. No AI needed.

Program 2 reasons and reports: reads the database, fetches fresh news, runs multi-model AI analysis, and produces a final report with basket allocation, scenario analysis, risk assessment, and self-critique.

The Web Viewer is a read-only browser interface over the same database. It complements the report rather than replacing it: the report is the strategist's output, the viewer is the analyst's lookup tool β€” type an ISIN or RIC, see every scraped field, every correlation, and the latest macro snapshot for that ETF. A separate /browse route lets you page through the full universe with filters and sorts.


What It Does

MosAIc splits its work into two CLI programs that share a MariaDB database, plus a web viewer that reads the same database. This separation means data can be refreshed independently of analysis, multiple reports can be generated from the same data snapshot, anyone with browser access can explore the data without running anything, and the database grows incrementally over time.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   PROGRAM 1: mosaic-db.js    β”‚
  β”‚   Scraper (no AI needed)     β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚       DATA GATHERING         β”‚
  β”‚                              β”‚
  β”‚  Per-ETF pages (justETF,     β”‚
  β”‚  Yahoo Finance, Borsa IT)    β”‚
  β”‚  FRED macro indicators       β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚    STRUCTURED EXTRACTION     β”‚
  β”‚                              β”‚
  β”‚  Performance (1M–5Y, YTD)    β”‚
  β”‚  TER, AUM, distribution      β”‚
  β”‚  Sectors, countries          β”‚
  β”‚  Holdings (top 5)            β”‚
  β”‚  Bond/commodity/gold profilesβ”‚
  β”‚  Fund currency, hedging      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚     QUANTITATIVE ENGINE      β”‚
  β”‚                              β”‚
  β”‚  Correlation matrix          β”‚
  β”‚  (intra-market only)         β”‚
  β”‚  Holdings overlap            β”‚
  β”‚  Momentum indicators         β”‚
  β”‚  Currency exposure           β”‚
  β”‚  FRED regime signals         β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚       MariaDB (16 tables)    β”‚ ───────►│   WEB VIEWER (Astro) β”‚
  β”‚  ETF + child tables          β”‚  reads  β”‚   read-only browser  β”‚
  β”‚  Correlation | Fred          β”‚         β”‚   search, browse,    β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚   detail             β”‚
                 β”‚                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   PROGRAM 2: mosaic-report.jsβ”‚
  β”‚   Reporter (AI + fresh news) β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚     AI REASONING LAYER       β”‚
  β”‚                              β”‚
  β”‚  DB data + fresh news per ETFβ”‚
  β”‚  Multi-model consensus       β”‚
  β”‚  Cross-correlation check     β”‚
  β”‚  Deep analysis (top picks)   β”‚
  β”‚  Final report + scenarios    β”‚
  β”‚  Self-critique (red team)    β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚          OUTPUT              β”‚
  β”‚                              β”‚
  β”‚  Basket composition (%)      β”‚
  β”‚  Buy/Sell/Hold per ETF       β”‚
  β”‚  Risk assessment             β”‚
  β”‚  Scenario analysis           β”‚
  β”‚  Rebalancing actions         β”‚
  β”‚  Signal drift vs last        β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Pipeline

Program 1: Data Gathering & Computation

Runs without AI. Designed to be scheduled (daily/weekly cron) or run manually before generating a report.

Step 1 β€” Read ETF Universe from Database

Reads all ETFs from the ETF table in MariaDB. For Program 1, the database is the source of truth for the ETF universe β€” watchlist.json is a Program 2 concept and is not consulted here. Each row has at minimum an ISIN, market, and RIC, plus optional description and benchmark area. New ETFs are added by inserting a row directly:

mysql --user=$DB_USER --password=$DB_RAINBOW $DB \
  -e "INSERT INTO ETF (isin, market, ric)
      VALUES ('IE00B4L5Y983', 'BIT', 'SWDA')"

The --only RIC1,RIC2 flag restricts the run to specific tickers when you only need to refresh part of the universe. It matches all cross-listings of each named RIC β€” a single RIC may live on multiple exchanges (e.g. SWDA is listed on both Borsa Italiana and XETRA), and all such rows are refreshed together.

Step 2 β€” Page Fetching

Fetches the justETF profile page for each ETF via MCP. Uses a larger character limit (20,000) to capture performance data deep in the page.

Step 3 β€” Structured Extraction

Parses fetched pages to extract clean numbers: performance across all time periods, total expense ratio, fund size, distribution policy, replication method, sector/country allocations, top holdings, fund currency, hedging status, and 1-year volatility. For bond ETFs: rating, maturity, yield. For commodity/gold ETFs: categories, backing, domicile.

Step 4 β€” Quantitative Analysis

Three computations from the structured data, no AI involved:

Step 5 β€” Macro Regime (FRED)

Optionally fetches Treasury yields, VIX, breakeven inflation, EUR/USD, and Euro HY spread from the FRED API. The 6 series are fetched in parallel via Promise.all (with per-series fallback chains preserved), and three regime signals are computed: yield curve shape, VIX regime, and real yield.

Step 5b β€” Currency Impact

Computes per-ETF currency exposure from country allocations, fund currency, and hedging status. Gold/commodities are automatically HIGH (USD-priced). Hedged funds are forced NONE.

Step 6 β€” Save to Database

Persists everything to MariaDB via upsert. Each ETF runs in its own transaction; each child table gets a DELETE + batch INSERT. Numeric coercion happens at the boundary: scraped strings like "115,407 m", "4.5 bn", or "€1.2 t" are coerced to BIGINT bytes; bad inputs drop to NULL with a console warning rather than truncating silently. Re-running updates existing rows, new ETFs are added, nothing is deleted unless you explicitly drop the database.

Program 2: AI Analysis & Report

Requires at least one LLM endpoint. Fetches fresh news to complement the database.

Step 1 β€” Load Basket & Join with Database

Loads watchlist.json (path supplied via the mandatory --watchlist flag), then joins each (isin, market) reference against the ETF table. The basket file is thin β€” only isin, market, and an optional currentWeight per entry. Everything else (RIC, description, benchmark area, scraped data, computed indicators) comes from the database join.

If any basket entry is not present in the database, Program 2 stops immediately and lists the missing ETFs. Run npm run db first to populate the universe.

Step 2 β€” Fresh News

Fetches per-ETF pages from justETF, Yahoo Finance, and Borsa Italiana. News is keyed by (isin, market) not just ISIN: cross-listed funds (same ISIN, different markets) get genuinely different content from Yahoo and Borsa Italiana because their URL templates substitute market suffix and exchange code.

Step 3 β€” Macro Context

Fetches ECB press conferences, monetary decisions, EUR/USD rate, and Polymarket prediction market odds for economy and geopolitics.

Step 4 β€” Per-ETF AI Analysis

Each ETF gets an AI call with two layers of input: structured data from the database (clean numbers the AI can cite directly) and fresh news text (current narrative context). The AI produces a signal, confidence, drivers, and a summary that must reference specific numbers.

Step 4b β€” Multi-Model Consensus

When multiple AI providers are configured, all of them independently analyze every ETF. Signals are merged by composite (ric, market) key β€” the same RIC on different markets is never silently consolidated. A local resolver merges signals: unanimous = HIGH confidence, disagreement = cautious resolution toward HOLD.

Step 5 β€” Cross-Correlation Check

Reviews all signals together with the computed correlation matrix to find contradictions, reinforcing patterns, and concentration warnings. The prompt mandates RIC (MARKET) notation in the output so cross-listings stay distinguishable in the narrative.

Step 6 β€” Scoring & Drift

Scores and ranks ETFs by conviction. Compares against the previous run to detect signal changes and demand explanations for any flips. Signal matching uses composite (ric, market) keys; querying for one market never returns another market's data β€” a critical guard against feeding the AI the wrong rationale for a cross-listed fund.

Confidence levels are HIGH / MEDIUM / LOW / UNKNOWN. UNKNOWN is preserved end-to-end (rather than silently downgraded to LOW) so the display surfaces parsing failures explicitly.

Step 7 β€” Deep Analysis

Top BUY/SELL candidates get additional news fetches and a deeper AI analysis covering fund mechanics, quantified risks with probabilities, specific timing, and basket interactions.

Step 8 β€” Macro News

Fetches European ETF market news for the final report's macro section.

Step 9 β€” Final Report

The most comprehensive AI call. Produces basket composition (constrained by risk level), recommendations, macro view, key risks, and two opposing scenarios. The allocation table identifier column is RIC (Market) so two listings of the same RIC remain distinguishable in the output.

Step 10 β€” Self-Critique

The complete report goes back to the AI as a "red team" review, producing a structured critique with an overall verdict. UNKNOWN-confidence signals are flagged here as parser-failure red flags requiring manual review.


The Database

Program 1 saves all data to MariaDB β€” 16 normalized InnoDB tables, utf8mb4 collation. The full DDL lives in mosaic-schema.sql at the repo root; one-shot migrations live in migrations/.

Table family Purpose
ETF One row per (isin, market): identity + scraped/computed core fields
ETFPerformance, ETFSector, ETFCountry, ETFHolding Per-ETF children with rank ordering
ETFProfileBond, ETFProfileBondType Bond-specific data (rating, types, maturity, yield)
ETFProfileCommodity, ETFProfileCommodityCategory Commodity weighting + categories with excluded flag
ETFProfileGold Gold backing, metal, domicile
ETFMomentum, ETFCurrency Computed quantitative signals
BenchmarkArea Lookup table seeded with ~55 canonical benchmark areas
Correlation Pairwise correlation + holdings overlap (intra-market only)
Fred, FredDerived FRED macro snapshot

The database is incremental β€” re-running upserts existing data without dropping tables. To rebuild from scratch, drop the database and reload the schema.

Program 2 reads from the database and reconstructs the same data structures that Program 1 computed, so all existing formatting functions work unchanged.

A note on identity

The ETF table has TWO unique keys: (isin, market) and (ric, market). Both are legal β€” the same ISIN listed on different markets gets separate rows, AND the same RIC listed on multiple markets is also legal (e.g. SWDA on XETR and on BIT are independent rows with independent signals). Every per-ETF map across the codebase is keyed by composite ${ric}|${market} (or ${isin}|${market} when ISIN is the available identifier). RIC-only or ISIN-only keying anywhere would silently collide cross-listings β€” this has been audited end-to-end and is covered by regression tests.


The Watchlist

watchlist.json is the basket descriptor for a single report. It selects which ETFs from the universe make it into the analysis and, optionally, what their current portfolio weights are. Program 2 takes the path via the mandatory --watchlist flag.

The file is a thin JSON array β€” every other field comes from the database join:

[
  { "isin": "IE00B4L5Y983", "market": "BIT", "currentWeight": 25 },
  { "isin": "IE00BKM4GZ66", "market": "BIT", "currentWeight": 15 },
  { "isin": "IE00B3VWN518", "market": "BIT" }
]
Field Required Notes
isin βœ“ Used together with market to look up the ETF row
market βœ“ Exchange code; same value as in the ETF table
currentWeight 0–100. When present on at least one entry, the report includes a Rebalancing Actions section

Extra keys (your own notes, descriptions, broker IDs, anything) are silently ignored, so the basket file can also serve as a lightweight notebook for the position.

Because the basket is decoupled from the universe, multiple basket files can coexist (conservative.json, aggressive.json, …) and produce different reports from the same database snapshot.

If any basket entry references an ETF that is not in the database, the program stops with a clear error β€” there is no soft fallback.


The Web Viewer

The viewer is an Astro app that opens MariaDB read-only and serves two routes: a search-and-detail page (/) and a browseable universe view (/browse). It exists for the moments when you don't need a report β€” you just want to know what the database knows about this one ETF, or to scroll through the entire universe with filters.

The search page (/)

Three input fields at the top:

You can search by ISIN, by RIC, or by either combined with a market filter. If the identifier is ambiguous (same RIC on two exchanges, for example), the viewer asks which one you meant and offers them as clickable links.

The result panel below the form shows everything the database has on the chosen ETF, in a flow that mirrors how an analyst reads a fund:

  1. Identity and high-level profile (ISIN, market, RIC, asset class, currency, TER, fund size, distribution, replication, latest scrape time). currencyHedged renders as "yes / no / unknown" β€” the NULL distinction is preserved end-to-end so missing data isn't conflated with explicit "unhedged".
  2. Performance across the standard horizons (1M through 5Y, plus YTD), with green/red coloring for direction.
  3. Top sectors and countries, side by side.
  4. Top holdings, with weights.
  5. Asset-class-specific blocks: bond profile (rating, types, maturity), commodity profile (categories, weighting, exclusions), or gold profile (backing, metal, domicile) β€” only the relevant one is shown.
  6. Computed quantitative signals: momentum and currency exposure.
  7. Every correlation pair the ETF participates in, sorted by combined score. Each row's "β†’" navigation carries explicit ISIN+market so clicking a counterpart never accidentally lands on the wrong cross-listing.
  8. The latest FRED macro snapshot stored in the database β€” series and derived signals.

The browse page (/browse)

A paginated, filterable, sortable table over the entire universe. Server-renders the initial page from URL params (refresh-safe, shareable). Client-side hydration handles:

The URL stays in sync with the active filters and sort, so sharing or bookmarking a specific view works as expected.

Universe vs basket, again

The viewer is bound to the universe β€” every ETF the database knows about, not just those in the current basket. This matters because the universe is intentionally broader: it can hold candidates you're considering, alternates you've previously held, or comparison ETFs included only for correlation context. The viewer is the only place where the universe is visible without a basket file in the loop.

Read-only by design

The web viewer cannot write to the database. It opens its own MariaDB connection pool (separate from the CLI's pool, with a smaller connection limit) and exposes only query helpers β€” no upsert function is even imported. This isn't a security claim, it's a reliability one: if the viewer crashes, hangs, or is accessed concurrently with a running scrape, the data on disk stays consistent.

The two CLI programs (which write) and the viewer (which only reads) can all run at the same time; MariaDB's transaction isolation handles the concurrency.

Canonical URL identity

The canonical UI identifier is (isin, market). ISIN is immutable and regulator-issued; RIC is a market-data-vendor convention that can change. The user can still TYPE a RIC into the search field β€” the API resolves it β€” but every URL the UI emits is ?isin=…&market=…. URL construction is centralised in src/lib/links.js, so any new route added later automatically inherits the same ISIN+market contract.

Tooltips

Most labels in the result panel β€” TER, Distribution, Replication, the correlation column headers, momentum signals, the FRED snapshot β€” carry a small i icon. Hover (or tap on mobile) to see a short explanation: what the field actually means, what units it's in, what range is considered normal. The text lives in data/ui-tips.json, a versioned plain-text file you can edit and commit like any source. No build step, no rebuild required for the dev server to pick up changes.

The icon only renders when a tip exists for that key. Missing keys silently produce no icon, so adding or removing tips is a one-line edit.

What the viewer is not

A few limitations are deliberate, not pending:

The viewer exists to answer two questions quickly β€” "what do we know about this ETF?" and "what's in the universe?" β€” without becoming a portfolio management application.

Responsive

The page works down to phone-portrait widths. Wide tables (correlations: 8 columns; FRED series: 6; browse: 12) hide their less essential columns on narrow screens rather than scrolling sideways. Sectors and countries collapse into a single column on phones; the form stacks vertically; padding tightens.


Data Sources

Per-ETF (fetched for every ETF)

Source What It Provides
justETF Performance, TER, fund size, holdings, sectors, countries
Yahoo Finance Quote, key stats, related news (market-aware suffix)
Borsa Italiana Real-time quote, benchmark data

Macro Context (fetched once)

Source What It Provides
ECB Press Conferences Rate decisions, forward guidance
ECB Monetary Decisions Policy changes, asset purchases
EUR/USD (Google Finance) Currency trend
Polymarket Economy Crowd-sourced odds: recession, Fed rates, inflation
Polymarket Geopolitics Crowd-sourced odds: conflicts, ceasefires

FRED Economic Data (optional, fetched once)

Indicator What It Tells You
10-Year Treasury Yield Bond direction β€” rising = bonds fall
2-Year Treasury Yield Short-term rate expectations
Yield Curve (10Y-2Y) Recession indicator β€” inverted = warning
Breakeven Inflation Inflation expectations β€” rising favors inflation-linked ETFs
VIX Risk sentiment β€” calm (<15), normal (15-25), elevated (>25), crisis (>35)
Euro High Yield Spread Credit risk appetite β€” rising = risk-off

The 6 series are fetched in parallel via Promise.all, with per-series fallback chains preserved when the daily series has no recent observation (e.g. DGS10 falls back to GS10).

Deep Analysis (fetched for top candidates only)

Source What It Provides
Yahoo News ETF-specific headlines
Google Finance Price chart, related news
MarketScreener Analysis, ratings

Macro News (fetched once for final report)

Source What It Provides
etfworld.it Italian ETF news
Milano Finanza ETF Italian market analysis
ETF Trends Global ETF sector analysis
ETF Express European institutional flows
Bloomberg ETFs Global market overview

The Report

The final output is a structured Markdown document containing:

  1. Signal Summary β€” count per signal type across the basket
  2. Multi-LLM Consensus β€” how different AI models agree/disagree per ETF
  3. Suggested Basket Composition β€” percentage allocation table for all ETFs; identifier column shows RIC (Market) so cross-listings stay distinguishable
  4. Full Ranking β€” every ETF with signal, confidence, score
  5. Final Report β€” executive summary, buy/sell/hold recommendations, macro view, currency impact, risks, scenarios, rebalancing actions
  6. Cross-Correlation Analysis β€” contradictions, patterns, insights
  7. Per-ETF Analysis β€” individual signal details for every ETF; each section header carries RIC (Market)
  8. Deep Analysis β€” detailed breakdown of top candidates
  9. Current Portfolio Weights β€” existing holdings (if provided)
  10. Self-Critique β€” the AI's own assessment of report quality

Risk Levels

The basket composition adjusts to five risk profiles:

Risk Level Equity Bonds Alternatives
Low 20-30% 55-70% 5-15%
Medium-Low 30-40% 45-55% 10-15%
Medium 40-55% 30-45% 10-20%
Medium-High 55-70% 20-30% 10-20%
High 70-85% 5-20% 10-20%

Within each tier, the AI overweights ETFs with BUY signals and underweights those with SELL signals, while respecting correlation constraints (don't stack correlated assets) and concentration limits (2-25% per ETF). High-correlation pairs flagged by the quant engine should not both be near maximum weight; the prompt notes that correlations are intra-market only, so cross-listings of the same RIC on different markets count as independent positions.


Signal Drift

Each run is saved to a history folder. Subsequent runs compare signals against the previous run for the same AI model:

Signal matching uses composite (ric, market) keys, so a query for SWDA-XETR will never silently match against SWDA-BIT in the previous run β€” feeding the AI the wrong rationale for a cross-listed fund would cause it to hallucinate explanations for a "change" that never happened.

The AI is shown its previous signal and rationale for each ETF and must explain any changes. This prevents random flip-flopping and creates accountability across runs.


Multi-Model Consensus

When multiple AI providers are available, MosAIc automatically uses them all:

Agreement levels:

Level Meaning Confidence Effect
🟒 Unanimous All models agree Upgraded to HIGH
🟑 Majority Most agree, some dissent Unchanged
πŸ”΄ Split No agreement Downgraded to LOW

This catches single-model blind spots and provides a natural confidence calibration. UNKNOWN confidence (when the parser can't extract a value from a model's response) is preserved as its own category, not silently collapsed to LOW β€” so the operator can spot extraction failures rather than mistakenly trust a fake "low confidence" signal.


Resilience

MosAIc is designed to run unattended:


Quantitative Layer

MosAIc computes these indicators locally, without AI involvement:

Pearson Correlation β€” pairwise correlation on shared performance periods (1M, 3M, 6M, 1Y, 3Y, 5Y, YTD), partitioned by market. Combined with an asset class similarity matrix: 60% empirical data, 40% structural expectation.

Holdings Overlap β€” normalized name matching across top-10 holdings to detect hidden concentration. "SWDA and TRET share 2 holdings worth 1.2% combined weight."

Momentum Score β€” three-dimensional signal per ETF:

FRED Derived Signals β€” yield curve spread (recession indicator), VIX regime classification (calm/normal/elevated/crisis), real yield (bond attractiveness vs inflation).

Currency Exposure β€” per-ETF breakdown of currency sensitivity using country allocation data. EUR-denominated bonds score NONE, gold/commodities score HIGH (USD-priced), equity ETFs are computed from country weights. Combined with FRED EUR/USD rate for directional context: "SWDA has 66% USD exposure; at EUR/USD 1.17, a further 3% EUR appreciation would cost ~2% in EUR-denominated returns."

These computed facts anchor the AI's reasoning. The prompts explicitly tell the AI: "these are computed data β€” cite them directly, do not contradict them."


Tests

Regression tests live in __tests__/ as standalone Node scripts β€” no test framework, just plain console.log assertions. Six test files cover the most dangerous classes of bugs that the migration addressed:

A small runner (__tests__/run-all.js) spawns each test in its own Node process to prevent state-mutation tests (HTTP monkey-patching, config mutation) from leaking between runs.


Future Enhancements

Enhancement Description Impact
Sector rotation Detect aggregate sector concentration across the whole basket Medium
Event calendar ECB dates, FOMC dates, ex-dividend dates for timing Medium
Bulk scraping Scrape 2000+ ETFs into the database for universe-wide analysis High
Database aging Flag stale data (>7 days) and auto-refresh on Program 2 run Medium
Viewer charts Correlation heatmap, performance sparklines, holdings overlap diagram Medium
Viewer compare mode Side-by-side comparison of two ETFs from the universe Low
Viewer report archive Browse past report markdowns and signal-drift history through the UI Low
Rebalance simulator New /rebalance route that lets you tweak weights and see the basket impact live Medium