From 515117de2ce7ddbda74e6a84f35ee4fd8fdf6fee Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 16 Jul 2026 12:28:25 +0200 Subject: [PATCH] feat(equity): swap IEF->CBU0 (liquid treasury UCITS) + partial-sizing coverage (use as much as tradeable) Co-Authored-By: Claude Opus 4.8 --- .../application/equity_allocation_inputs.py | 61 ++++++++------ src/fxhnt/config.py | 5 +- tests/unit/test_equity_allocation_inputs.py | 82 ++++++++++++++++--- tests/unit/test_ucits_routing.py | 2 +- tests/unit/test_ucits_volume_config.py | 4 +- 5 files changed, 114 insertions(+), 40 deletions(-) diff --git a/src/fxhnt/application/equity_allocation_inputs.py b/src/fxhnt/application/equity_allocation_inputs.py index 4431f25..bfdb562 100644 --- a/src/fxhnt/application/equity_allocation_inputs.py +++ b/src/fxhnt/application/equity_allocation_inputs.py @@ -37,8 +37,10 @@ line, but the PRICE proxy stays the US close (`closes[sym]`, i.e. `repo.yahoo_pi USD-denominated UCITS lines track the US underlying's USD price closely, so `adv_usd` ends up keyed by the US sleeve `sym` with the UCITS ticker's volume. This is a small basis approximation, acceptable for a capacity ESTIMATE; a follow-on could fetch the real UCITS price. A missing/thin UCITS line (empty volume, -e.g. DBMF.L) still flows through `dollar_adv` -> an empty/zero ADV series for that symbol -> the ADV-coverage -guard below trips (no fabricated capacity).""" +e.g. a not-yet-backfilled UCITS listing) still flows through `dollar_adv` -> an empty/zero ADV series for +that symbol -> `_tradeable_weight_fraction` correctly discounts JUST that sleeve's book-weight share (no +fabricated capacity for the dead sleeve, via `equity_honest_returns`'s participation cap), rather than +zeroing the whole book — the guard below only fires when there's essentially NOTHING tradeable.""" from __future__ import annotations from typing import Any @@ -48,14 +50,14 @@ from fxhnt.application.honest_gates import sharpe_of from fxhnt.config import get_settings from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS, book_series, target_weights -# Minimum fraction of TRADED book days (days with a non-empty weights row) that must have a defined, -# positive dollar-ADV for every symbol held that day, before an equity series is trusted at all. Guards -# against `yahoo_pit_volumes` being empty/partial (pre-`backfill-etf-volume`, or a nightly volume snapshot -# that keeps swallow-and-warn failing): with no/partial ADV, `equity_honest_returns` scales every held -# position to 0 and books `-cost` for those days -- a full-length but GARBAGE series that (being non-empty) -# would otherwise be used INSTEAD of the nav fallback. Below this threshold we emit no series at all, so -# `record_allocation`'s `honest_returns.get(sid) or nav_fallback` falls back to the nav record. -_MIN_ADV_COVERAGE = 0.8 +# DEGENERATE guard, NOT a functional cap: below this fraction of average tradeable GROSS WEIGHT, we treat +# `yahoo_pit_volumes` as effectively empty/pre-backfill (or a nightly volume snapshot stuck swallow-and-warn +# failing) and fall back to nav rather than trust the series at all. A book with ONE thin/dead sleeve (e.g. +# DBMF ~20% of weight) is still ~0.8 tradeable and correctly EMITS -- `equity_honest_returns`'s per-sleeve +# participation cap already scales that dead sleeve down to ~0 ("use as much as there is"); this guard only +# catches the case where there's essentially NOTHING tradeable (empty/near-empty volume store), not a single +# thin line dragging down an otherwise-liquid book. +_MIN_TRADEABLE_WEIGHT = 0.5 def first_crossing_ceiling(curve: dict[float, float], min_sharpe: float) -> float | None: @@ -74,17 +76,27 @@ def first_crossing_ceiling(curve: dict[float, float], min_sharpe: float) -> floa return ceiling -def _adv_coverage(book_ret_by_day: dict[str, float], weights_by_day: dict[str, dict[str, float]], - adv: dict[str, dict[str, float]]) -> float: - """Fraction of TRADED book days (non-empty weights row) with a defined, positive dollar-ADV for EVERY - symbol held that day. Days with no weights row (pre-warmup) don't count either way. No traded days at - all -> 0.0 (treat as insufficient, the safe default) rather than vacuously 1.0.""" - traded_days = [d for d in book_ret_by_day if weights_by_day.get(d)] - if not traded_days: +def _tradeable_weight_fraction(weights_by_day: dict[str, dict[str, float]], + adv_usd: dict[str, dict[str, float]]) -> float: + """Average, over TRADED book days (rows with any non-zero weight), of the FRACTION OF GROSS BOOK WEIGHT + that is tradeable that day: `sum(|w| for symbols with positive dollar-ADV that day) / sum(|w| for every + symbol held that day)`. A day with no (or all-zero) weights is skipped -- pre-warmup, doesn't count either + way. No traded days at all -> 0.0 (treat as insufficient, the safe default) rather than vacuously 1.0. + + This is a WEIGHT-based metric, not the prior all-or-nothing "every held symbol must be covered" check: + one thin/dead sleeve (e.g. DBMF at ~20% of book weight) drags this to ~0.8, not to 0 -- see + `_MIN_TRADEABLE_WEIGHT`'s docstring for why that's the correct "use as much as there is" behavior.""" + fractions: list[float] = [] + for d, day_weights in weights_by_day.items(): + gross = sum(abs(w) for w in day_weights.values()) + if gross <= 0.0: + continue + tradeable = sum(abs(w) for sym, w in day_weights.items() + if adv_usd.get(sym, {}).get(d, 0.0) > 0.0) + fractions.append(tradeable / gross) + if not fractions: return 0.0 - covered = sum(1 for d in traded_days - if all(adv.get(sym, {}).get(d, 0.0) > 0.0 for sym in weights_by_day[d])) - return covered / len(traded_days) + return sum(fractions) / len(fractions) def _weights_by_day(closes: dict[str, dict[str, float]], @@ -127,7 +139,9 @@ def equity_allocation_inputs( default) sources ADV from the UCITS-mapped ticker's volume (US close as the price proxy — see module docstring) + spread from that line's `half_spread_bps` (or `ucits_spread_bps` override). `ucits_map` defaults to `get_settings().ucits.map` when `None` (kept injectable for tests). A missing/thin UCITS - line still flows through the SAME `_adv_coverage` guard below — it does not get its own special case.""" + line still flows through the SAME `_tradeable_weight_fraction` guard below — it does not get its own + special case, but (being weight-based, not all-or-nothing) a single thin/dead sleeve no longer drops the + whole book — see `_MIN_TRADEABLE_WEIGHT`'s docstring.""" if regime not in ("ucits", "us"): # Fail loud: `regime` is a plain string, so a typo ("US", "UCITS", "") would otherwise silently fall # into the `else` branch below (the aggressive US-liquidity model) — dangerous in the EU retail @@ -162,8 +176,9 @@ def equity_allocation_inputs( # equivalent — passing None preserves the identical code path, not just an equivalent one. spread = us_spread_bps - if _adv_coverage(book_ret_by_day, weights_by_day, adv) < _MIN_ADV_COVERAGE: - # Empty/partial volume store (any regime) -> don't emit a garbage zero-capacity series; fall back to nav. + if _tradeable_weight_fraction(weights_by_day, adv) < _MIN_TRADEABLE_WEIGHT: + # Degenerate (empty/near-empty) volume store, any regime -> don't emit a garbage near-zero-capacity + # series; fall back to nav. A single thin/dead sleeve does NOT trip this -- see the guard's docstring. return {}, {sid: None for sid in deploy_sids} all_aums = sorted({float(target_aum), *(float(a) for a in aums)}) diff --git a/src/fxhnt/config.py b/src/fxhnt/config.py index 9305fca..8d86dba 100644 --- a/src/fxhnt/config.py +++ b/src/fxhnt/config.py @@ -132,7 +132,8 @@ class UcitsSettings(BaseSettings): ISINs verified 2026-07 (WebSearch), all USD-denominated LSE lines except DBMF (Euronext/LSE, LU-domiciled): SPY -> CSPX IE00B5BMR087 iShares Core S&P 500 UCITS ETF (USD Acc) - IEF -> IDTM IE00B1FZS798 iShares $ Treasury Bond 7-10yr UCITS ETF (USD Dist; IBTM is the GBP line) + IEF -> CBU0 IE00B3VWN518 iShares $ Treasury Bond 7-10yr UCITS ETF (USD Acc; ~$12M ADV vs the thin + Dist IDTM line) GLD -> IGLN IE00B4ND3602 iShares Physical Gold ETC (USD; SGLN is the GBP line) PDBC -> ICOM IE00BDFL4P12 iShares Diversified Commodity Swap UCITS ETF (USD) DBMF -> DBMF LU2951555585 iMGP DBi Managed Futures Fund R USD UCITS ETF (LSE ticker DBMF, USD line; @@ -144,7 +145,7 @@ class UcitsSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="FXHNT_UCITS_") map: dict[str, UcitsListing] = Field(default_factory=lambda: { "SPY": UcitsListing(ticker="CSPX", isin="IE00B5BMR087", half_spread_bps=1.5), - "IEF": UcitsListing(ticker="IDTM", isin="IE00B1FZS798", # USD line = IDTM (IBTM is the GBP line) + "IEF": UcitsListing(ticker="CBU0", isin="IE00B3VWN518", yahoo_ticker="CBU0.L", # Acc line, ~$12M ADV half_spread_bps=2.0), "GLD": UcitsListing(ticker="IGLN", isin="IE00B4ND3602", # USD line = IGLN (SGLN is the GBP line) half_spread_bps=2.0), diff --git a/tests/unit/test_equity_allocation_inputs.py b/tests/unit/test_equity_allocation_inputs.py index 1018850..2aeac02 100644 --- a/tests/unit/test_equity_allocation_inputs.py +++ b/tests/unit/test_equity_allocation_inputs.py @@ -23,6 +23,7 @@ from types import SimpleNamespace import pytest from fxhnt.application.equity_allocation_inputs import ( + _tradeable_weight_fraction, _weights_by_day, equity_allocation_inputs, first_crossing_ceiling, @@ -146,7 +147,10 @@ def test_equity_allocation_inputs_insufficient_adv_coverage_falls_back(): `book_ret_by_day` (derived from closes, never volumes), so an empty ADV store still emitted a full-length series where every day is scaled to zero capacity and books `-cost` -- a GARBAGE series that (being non-empty) would be used INSTEAD of the nav fallback. The fixed provider must instead emit NO series (and - ceiling `None`), so `record_allocation`'s `honest_returns.get(sid) or nav_fallback` falls back to nav.""" + ceiling `None`), so `record_allocation`'s `honest_returns.get(sid) or nav_fallback` falls back to nav. + (Still true under the partial-sizing `_tradeable_weight_fraction` guard: with NO volumes at all, every + sleeve is dead, so tradeable weight is ~0.0 -- well below `_MIN_TRADEABLE_WEIGHT` -- a fully-degenerate + store, not a single-thin-sleeve partial-coverage case.)""" closes, volumes = _synthetic_closes_and_volumes(days=130, with_volumes=False) repo = _FakeRepo(closes, volumes) @@ -237,21 +241,20 @@ def test_equity_allocation_inputs_ucits_ceiling_materially_lower_than_us_when_uc assert 0.0 < ceiling_ucits <= ceiling_us / 10.0 -def test_equity_allocation_inputs_ucits_thin_absent_line_trips_coverage_guard(): - """TASK 2: an ABSENT UCITS line (e.g. DBMF.L -- Yahoo lacks the new/small UCITS listing, or the LSE - volume snapshot hasn't backfilled it yet) must NOT fabricate capacity. `dollar_adv` on an empty volume - dict yields an empty ADV series for that symbol -> every traded day holding that symbol fails - `_adv_coverage`'s all-symbols-covered check -> below `_MIN_ADV_COVERAGE` -> the SAME guard as the - US-side insufficient-coverage test trips: no series, ceiling `None` (nav fallback), not a garbage - zero-capacity series.""" +def test_equity_allocation_inputs_ucits_one_dead_sleeve_still_emits_partial_sizing(): + """PARTIAL-SIZING (weight-based guard): an ABSENT UCITS line for exactly ONE sleeve (e.g. DBMF.L -- Yahoo + lacks the new/small UCITS listing, or the LSE volume snapshot hasn't backfilled it yet) must NOT drop the + whole book. `dollar_adv` on an empty volume dict yields an empty ADV series for DBMF -> every traded day + holding DBMF has that ONE sleeve's weight untradeable, but the other four (most of the book's gross + weight) remain tradeable -> `_tradeable_weight_fraction` averages to ~0.8, comfortably ABOVE + `_MIN_TRADEABLE_WEIGHT` (0.5) -> the provider still EMITS a series (the dead DBMF sleeve gets scaled to + ~0 by `equity_honest_returns`'s per-sleeve participation cap instead — "use as much as there is"), unlike + the prior all-or-nothing guard which would have zeroed the entire book over one dead sleeve.""" closes, us_volumes = _good_book_closes_and_volumes() ucits_map = _ucits_map(half_spread_bps=30.0) repo_volumes = dict(us_volumes) for sym in FUND_INSTRUMENTS: - # every UCITS line present and liquid EXCEPT DBMF, which Yahoo has no data for at all (empty dict) -- - # per-symbol isolation (Task-1-of-the-prior-feature pattern): a thin/absent DBMF-UCITS line alone - # must not silently keep the other four sleeves' capacity, it must fail the WHOLE book's coverage - # guard (weights_by_day rows hold ALL 5 instruments most days -> DBMF's zero ADV poisons those days). + # every UCITS line present and liquid EXCEPT DBMF, which Yahoo has no data for at all (empty dict). repo_volumes[ucits_map[sym].yahoo_ticker] = ( {} if sym == "DBMF" else {d: v / 10.0 for d, v in us_volumes[sym].items()} ) @@ -263,10 +266,65 @@ def test_equity_allocation_inputs_ucits_thin_absent_line_trips_coverage_guard(): min_sharpe=1.0, regime="ucits", ucits_map=ucits_map, ) + assert "multistrat" in returns + assert len(returns["multistrat"]) > 0 + assert ceilings["multistrat"] is None or ceilings["multistrat"] >= 0.0 + + +def test_equity_allocation_inputs_ucits_all_sleeves_dead_falls_back_to_nav(): + """DEGENERATE case preserved: with EVERY UCITS line absent (an empty/pre-backfill volume store), the + tradeable-weight fraction is ~0.0 -- well below `_MIN_TRADEABLE_WEIGHT` -- so the provider must still emit + NO series (ceiling `None`, nav fallback), same as the pre-existing US-side insufficient-coverage test. + This is the genuinely degenerate case the guard exists for, distinct from the one-dead-sleeve partial + case above.""" + closes, us_volumes = _good_book_closes_and_volumes() + ucits_map = _ucits_map(half_spread_bps=30.0) + repo_volumes = dict(us_volumes) + for sym in FUND_INSTRUMENTS: + repo_volumes[ucits_map[sym].yahoo_ticker] = {} # every UCITS line absent + repo = _FakeRepo(closes, repo_volumes) + + returns, ceilings = equity_allocation_inputs( + repo, {"multistrat"}, target_aum=100_000.0, + aums=[100_000, 350_000, 1_000_000, 3_000_000, 10_000_000], + min_sharpe=1.0, regime="ucits", ucits_map=ucits_map, + ) + assert "multistrat" not in returns assert ceilings["multistrat"] is None +def test_tradeable_weight_fraction_one_dead_sleeve_is_about_point_eight(): + """Direct unit test of the weight-based guard's core metric: 5 equally-weighted (20% each) sleeves held + every day, ONE of which (E) has zero ADV every day it's held -- the tradeable fraction must be the + tradeable GROSS WEIGHT share (4/5 = 0.8), not 0 (the prior all-symbols-covered check would have failed + every day outright).""" + weights_by_day = { + "2026-01-01": {"A": 0.2, "B": 0.2, "C": 0.2, "D": 0.2, "E": 0.2}, + "2026-01-02": {"A": 0.2, "B": 0.2, "C": 0.2, "D": 0.2, "E": 0.2}, + } + adv = { + "A": {"2026-01-01": 1_000.0, "2026-01-02": 1_000.0}, + "B": {"2026-01-01": 1_000.0, "2026-01-02": 1_000.0}, + "C": {"2026-01-01": 1_000.0, "2026-01-02": 1_000.0}, + "D": {"2026-01-01": 1_000.0, "2026-01-02": 1_000.0}, + "E": {"2026-01-01": 0.0, "2026-01-02": 0.0}, # dead sleeve, every day + } + + assert _tradeable_weight_fraction(weights_by_day, adv) == pytest.approx(0.8) + + +def test_tradeable_weight_fraction_skips_empty_weight_rows_and_handles_no_traded_days(): + # A day with no weights (pre-warmup) is skipped, not counted as untradeable. + weights_by_day = {"2026-01-01": {}, "2026-01-02": {"A": 0.5, "B": 0.5}} + adv = {"A": {"2026-01-02": 1_000.0}, "B": {"2026-01-02": 1_000.0}} + assert _tradeable_weight_fraction(weights_by_day, adv) == pytest.approx(1.0) + + # No traded days at all -> 0.0 (the safe default), not vacuously 1.0. + assert _tradeable_weight_fraction({}, {}) == 0.0 + assert _tradeable_weight_fraction({"2026-01-01": {}}, {}) == 0.0 + + def test_equity_allocation_inputs_empty_deploy_sids_is_noop(): closes, volumes = _synthetic_closes_and_volumes(days=120) repo = _FakeRepo(closes, volumes) diff --git a/tests/unit/test_ucits_routing.py b/tests/unit/test_ucits_routing.py index f4c0e6c..0537150 100644 --- a/tests/unit/test_ucits_routing.py +++ b/tests/unit/test_ucits_routing.py @@ -91,7 +91,7 @@ def test_default_map_covers_all_fund_instruments_with_verified_isins(): assert route.skipped == {} # every fund instrument maps assert len(route.weights) == len(FUND_INSTRUMENTS) verified = { # ISIN + LSE USD line ticker, probed live on DU9600528 2026-07-15 (LSEETF+ISIN resolves) - "SPY": ("CSPX", "IE00B5BMR087"), "IEF": ("IDTM", "IE00B1FZS798"), + "SPY": ("CSPX", "IE00B5BMR087"), "IEF": ("CBU0", "IE00B3VWN518"), # Acc line, ~$12M ADV (not thin Dist IDTM) "GLD": ("IGLN", "IE00B4ND3602"), "PDBC": ("ICOM", "IE00BDFL4P12"), "DBMF": ("DBMF", "LU2951555585"), } diff --git a/tests/unit/test_ucits_volume_config.py b/tests/unit/test_ucits_volume_config.py index 4af68a6..022c061 100644 --- a/tests/unit/test_ucits_volume_config.py +++ b/tests/unit/test_ucits_volume_config.py @@ -18,7 +18,7 @@ def test_ucits_map_yahoo_tickers(): settings = Settings() m = settings.ucits.map assert m["SPY"].yahoo_ticker == "CSPX.L" - assert m["IEF"].yahoo_ticker == "IDTM.L" + assert m["IEF"].yahoo_ticker == "CBU0.L" assert m["GLD"].yahoo_ticker == "IGLN.L" assert m["PDBC"].yahoo_ticker == "ICOM.L" assert m["DBMF"].yahoo_ticker == "DBMF.L" @@ -28,7 +28,7 @@ def test_ucits_map_half_spread_bps(): settings = Settings() m = settings.ucits.map assert m["SPY"].half_spread_bps == 1.5 # CSPX - assert m["IEF"].half_spread_bps == 2.0 # IDTM + assert m["IEF"].half_spread_bps == 2.0 # CBU0 assert m["GLD"].half_spread_bps == 2.0 # IGLN assert m["PDBC"].half_spread_bps == 8.0 # ICOM assert m["DBMF"].half_spread_bps == 30.0 # DBMF