fix(ucits): validate --venue, require ibkr for ucits, reject non-injective map + cover default map

Robustness guards for the SUSPENDED UCITS routing: reject an unknown --venue
value instead of silently treating a typo as 'us'; refuse --venue ucits
without --broker ibkr (UCITS ETFs are LSE/Euronext-listed, IBKR-only);
apply_ucits_map now raises on a non-injective map (two sleeves mapping to
the same ticker would otherwise silently drop one via dict-key collision).
Adds coverage that the shipped default map maps every FUND_INSTRUMENTS
sleeve, so a future sleeve added without a UCITS mapping fails loudly.
This commit is contained in:
jgrusewski
2026-07-13 01:08:06 +02:00
parent ccfa40c3fe
commit ab4e6d4c42
3 changed files with 28 additions and 0 deletions

View File

@@ -10,5 +10,13 @@ def apply_ucits_map(weights: dict[str, float], prices: dict[str, float],
if missing:
raise ValueError(f"unmapped sleeve(s) for UCITS routing: {missing} — refusing to route")
w = {mapping[s]: v for s, v in weights.items()}
if len(w) < len(weights):
targets: dict[str, list[str]] = {}
for s in weights:
targets.setdefault(mapping[s], []).append(s)
colliding = {t: ss for t, ss in targets.items() if len(ss) > 1}
raise ValueError(
f"UCITS map collision(s): multiple sleeves route to the same ticker {colliding} "
"— refusing to silently drop a sleeve")
p = {mapping[s]: prices[s] for s in weights}
return w, p

View File

@@ -276,6 +276,11 @@ def execute_multistrat(
from fxhnt.application.multistrat_exec_record import plan_and_record
from fxhnt.domain.strategies import multistrat
if venue_kind not in ("us", "ucits"):
raise typer.BadParameter("--venue must be 'us' or 'ucits'")
if venue_kind == "ucits" and broker_name != "ibkr":
raise typer.BadParameter("--venue ucits requires --broker ibkr (UCITS ETFs are IBKR-only)")
settings = get_settings()
if live:
settings.execution.allow_live = True

View File

@@ -17,6 +17,21 @@ def test_unmapped_sleeve_refuses():
apply_ucits_map({"SPY": 0.5, "QQQ": 0.5}, {"SPY": 1.0, "QQQ": 1.0}, MAP)
def test_non_injective_map_raises():
with pytest.raises(ValueError, match="collision"):
apply_ucits_map({"SPY": 0.5, "VOO": 0.5}, {"SPY": 1.0, "VOO": 1.0}, {"SPY": "CSPX", "VOO": "CSPX"})
def test_default_map_covers_all_fund_instruments():
"""The shipped default UCITS map must cover every multistrat sleeve — a sleeve added later without a
UCITS mapping would fail this."""
from fxhnt.config import UcitsSettings
from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS
default_map = UcitsSettings().map
apply_ucits_map({s: 1.0 for s in FUND_INSTRUMENTS}, {s: 100.0 for s in FUND_INSTRUMENTS}, default_map)
def test_ucits_rebalancer_cronjob_yaml_suspended():
"""The UCITS CronJob ships SUSPENDED (paper-envelope routing wiring, no armed schedule yet)."""
docs = list(yaml.safe_load_all(open("infra/k8s/jobs/fxhnt-ucits-rebalancer.yaml")))