diff --git a/src/fxhnt/application/ucits_map.py b/src/fxhnt/application/ucits_map.py index 04ee4a7..d72b11c 100644 --- a/src/fxhnt/application/ucits_map.py +++ b/src/fxhnt/application/ucits_map.py @@ -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 diff --git a/src/fxhnt/cli.py b/src/fxhnt/cli.py index 6c209ae..4fe5c7c 100644 --- a/src/fxhnt/cli.py +++ b/src/fxhnt/cli.py @@ -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 diff --git a/tests/unit/test_ucits_routing.py b/tests/unit/test_ucits_routing.py index f7632c4..61f50bd 100644 --- a/tests/unit/test_ucits_routing.py +++ b/tests/unit/test_ucits_routing.py @@ -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")))