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:
@@ -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)})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user