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