Returns decomposition metrics (l*_cfr / l*_fr / l*_rr)

ERM3 can sync nine optional daily metrics (l1_cfrl3_rr, including incremental l*_fr) into the V3 pipeline: wide columns on security_history_latest when present, and historical slices served from consolidated Zarr via the public API (the legacy long-form security_history table is not used by this API). They describe actual simple returns from a returns-decomposition pipeline step, not hedge notionals and not variance fractions.

What the keys mean

Wire keyMeaning
l1_cfrCombined factor return through L1 (market)
l1_frIncremental L1 (market-only) factor return
l1_rrResidual return at L1
l2_cfrCombined factor return through L2 (sector)
l2_frIncremental L2 (sector) factor return on top of L1
l2_rrResidual return at L2
l3_cfrCombined factor return through L3 (subsector)
l3_frIncremental L3 (subsector) factor return on top of L1+L2
l3_rrResidual return at L3
lstar_rrResidual return at the L* level the model dispatched to — see "Which residual?" below
lstar_levelWhich level Lstar picked: 1 = L1, 2 = L2, 3 = L3; null = no recommendation (both L2 and L3 ER missing)

Units: daily simple returns as decimals (same convention as returns_gross), e.g. 0.01 ≈ 1%.

Naming: *_cfr = combined factor return; *_rr = residual return at that level.

Which residual should I use?

l3_rr is the residual after market + sector + subsector — fixed at L3 regardless of whether the subsector layer is statistically warranted. lstar_rr is the residual at the level the cascade actually picks for that name: L3 when the subsector ETF adds material explanatory power (subsector marginal ER ≥ 1%), L2 when only the sector does, L1 otherwise.

  • Stat-arb / panel queries ("best residual per name"): use lstar_rr — you get a comparable signal across the universe, with lstar_level telling you which hedge depth was assumed.
  • Fixed-depth attribution ("show me what's left after subsector"): use l3_rr.
  • Custom threshold: hit GET /api/lstar?threshold=…lstar_rr in MetricsV3 is materialized at the canonical 1% threshold; SDK callers wanting a different θ should round-trip through /api/lstar.

For names with weak subsector signal (lstar_level = 1 or 2), l3_rr overstates the cleanness of the residual because it subtracts a layer the model wouldn't have prescribed.

Not the same as ER or hedge ratios

  • l*_res_er (and other *_er fields) are explained risk — variance shares in [0, 1] from hedge-weight regressions.
  • l*_mkt_hr, l*_sec_hr, l*_sub_hr are hedge ratios (dollars of ETF per 1 of stock).
  • The l*_cfr / l*_rr series come from ds_erm3_returns_* (returns decomposition), not from ds_erm3_hedge_weights.

Do not confuse l3_rr (residual return for one day) with informal “residual risk” language around l3_residual_er (idiosyncratic variance share). See SEMANTIC_ALIASES.md for the full wire ↔ SDK name table.

Where they appear in the API

  • GET /api/metrics/{ticker} — optional fields under metrics when populated on the latest row.
  • Daily historyGET /api/ticker-returns and data-plane history routes return Zarr-backed series; _metadata.data_source / _metadata.range describe provenance on JSON (see Response metadata).
  • security_history_latest — optional wide columns when the ERM3 sync has materialized them (supabase/migrations/ in this repo).

Sync progress uses erm3_sync_state_v3 with table_name = security_history_returns_decomp.

Populating data (ERM3 sync)

This repository does not ship the ERM3 sync CLI. In the ERM3 repo, V3 sync supports the returns-decomposition dataset (often included in dataset set all) and a ticker filter:

  • CLI: --returns-decomp-tickers with values such as mag7, all, or a comma-separated ticker list.
  • Python: run_sync(..., returns_decomp_tickers="mag7" | "all" | "A,B,C").

See the propagation note ERM3_V3_RETURNS_DECOMP_SUPABASE_PROPAGATION.md for zarr variables, metric_key table, and checklist.

Returns decomposition metrics (CFR / FR / RR) | RiskModels API