feat(equity): swap IEF->CBU0 (liquid treasury UCITS) + partial-sizing coverage (use as much as tradeable)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-07-16 12:28:25 +02:00
parent 5f520165a4
commit 515117de2c
5 changed files with 114 additions and 40 deletions

View File

@@ -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)})

View File

@@ -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),

View File

@@ -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)

View File

@@ -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"),
}

View File

@@ -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