Merge: UCITS live-exec 478/10349 + false-EXECUTED fix + China/India Stage-1 kill

- fix/ucits-exec: Error 478 (strip ISIN/localSymbol, route by conId), Error
  10349 (explicit TIF=DAY), false-EXECUTED (executed requires all legs filled;
  degraded exits 1 + exec-record skips booking so the gate stays WAIT).
- research(em-asia): China/India CNYA.L/NDIA.L KILLED at the Stage-1 LOO
  marginal-Sharpe gate (co-crashing EM beta); reusable em_asia_marginal_eval.py.

523 exec/forward/gate tests green, mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 09:46:27 +00:00
8 changed files with 214 additions and 7 deletions

View File

@@ -204,7 +204,23 @@ class IbkrBroker:
# (probed 2026-07-15), so smart-routing the qualified conId fills without the direct-route penalty.
# conId pins the exact instrument, so SMART cannot mis-route to the wrong (e.g. US grey-market) line.
contract.exchange = "SMART"
# Bug fix (Error 478, live 2026-07-20): the ISIN-qualified UCITS contract can retain a `secId`/
# `localSymbol` that CONFLICTS with the conId IBKR pinned (e.g. requested localSymbol CBU0 vs the
# canonical CSBGU0 for ISIN IE00B3VWN518) -> "Parameters in request conflicts with contract parameters"
# -> the order is REJECTED and the leg gets 0 fills. The conId alone is unambiguous, so strip the
# ambiguous identity fields after resolution; IBKR resolves purely on conId + SMART. Guarded getattr:
# these fields may be absent on the plain-Stock US path.
conid = getattr(contract, "conId", 0)
if conid:
for _f in ("secId", "secIdType", "localSymbol"):
if getattr(contract, _f, None):
setattr(contract, _f, "")
market_order = MarketOrder(order.side, int(round(order.quantity))) # whole shares
# Bug fix (Error 10349, live 2026-07-20): a MarketOrder with NO explicit TIF let the account's order
# preset force TIF=DAY and cancel-then-reconfirm each order (a wasted round-trip; DBMF hung on
# 'Submitted'). Set TIF=DAY explicitly so the order already matches the account preset — no override,
# no cancel. DAY is correct for a market-hours rebalance (the run fires at the UCITS open).
market_order.tif = "DAY"
accts = ib.managedAccounts()
if accts:
market_order.account = accts[0]

View File

@@ -15,6 +15,16 @@ from fxhnt.domain.portfolio import Book, compute_target_weights
from fxhnt.ports.broker import Broker, Order
from fxhnt.ports.data import DataProvider
# IBKR order statuses that represent a REAL fill this run. A market order at the UCITS open fills within the
# `place_order` sleep, so the terminal status is 'Filled'. Everything else — 'Cancelled' (Error 478/10349),
# 'Submitted'/'PreSubmitted'/'PendingSubmit' (accepted but not yet filled), 'Inactive' — is NOT a fill and
# marks the run degraded. Compared case-insensitively; 'PartiallyFilled' also counts (some shares executed).
_FILL_STATUSES = {"filled", "partiallyfilled"}
def _is_fill(status: str) -> bool:
return str(status).strip().lower() in _FILL_STATUSES
class PlannedOrder(BaseModel):
symbol: str
@@ -147,6 +157,7 @@ class ExecutionService:
elif execute:
specs = contract_specs or {}
placed = 0
unfilled: list[str] = []
for o in orders:
try:
spec = specs.get(o.symbol, {})
@@ -156,9 +167,20 @@ class ExecutionService:
sec_id_type=spec.get("sec_id_type"), sec_id=spec.get("sec_id")))
notes.append(f"{o.side} {o.quantity:g} {o.symbol}: {status}")
placed += 1
if not _is_fill(status):
unfilled.append(f"{o.symbol}({status})")
except Exception as e: # one bad order must not abort the rest
notes.append(f"order failed {o.symbol}: {str(e)[:80]}")
plan.executed = placed > 0
unfilled.append(f"{o.symbol}(error)")
# Real money errs false-WAIT: the plan is EXECUTED only when EVERY attempted leg actually FILLED.
# A Cancelled/Submitted leg (Error 478/10349, or a broker that raised) is a DEGRADED run — reporting
# EXECUTED there would let the executed forward track + reconciliation gate book a rebalance that
# did not happen (live 2026-07-20: CBU0 Cancelled + DBMF stuck Submitted, yet the run said
# EXECUTED). `placed > 0` is NOT sufficient; all placed legs must be fills.
plan.executed = placed > 0 and not unfilled
if unfilled:
notes.append(f"DEGRADED — {len(unfilled)} leg(s) did not fill: {', '.join(unfilled)} "
f"(NOT reported EXECUTED)")
plan.notes = notes
pop_trades = getattr(self._broker, "pop_trades", None) # only IbkrBroker exposes this (D2.1)
if callable(pop_trades):

View File

@@ -146,7 +146,14 @@ def plan_and_record(*, repo: Any, exec_svc: Any, within_weights: dict[str, float
log.warning("multistrat superseded-line scan failed (skipped)", exc_info=True)
plan = exec_svc.rebalance_weights("multistrat", weights, prices, max_gross=max_gross, execute=execute,
contract_specs=specs, sizing_capital=envelope)
if execute and not plan.blocked:
# A DEGRADED run (orders attempted but a leg did not fill — Error 478/10349 live 2026-07-20) is NOT
# blocked, but recording it would advance the basis to `positions_after` (the INTENDED target book) that
# the account does NOT actually hold (e.g. the un-filled CBU0 leg), permanently diverging the executed
# track — the SAME failure the blocked-skip guards against, just partial. So skip recording here too and
# let the next CLEAN run re-establish the basis (err false-WAIT: a skipped record keeps the gate WAITing;
# a wrong record silently corrupts it). `degraded` = orders were placed but the plan is not executed.
degraded = bool(plan.orders) and not plan.executed
if execute and not plan.blocked and not degraded:
pos_prior = repo.get_book_state("multistrat_exec_pos")
exec_return = exec_return_for_run(pos_prior, prices, venue)
# positions_after is the INTENDED target book (weights·nlv/price) used as the next-run return basis —

View File

@@ -403,8 +403,15 @@ def execute_multistrat(
typer.echo(f" - {n}")
for o in plan.orders:
typer.echo(f" {o.side} {o.quantity:g} {o.symbol} (~${o.est_value:,.0f})")
status = "BLOCKED" if plan.blocked else ("EXECUTED" if plan.executed else "DRY-RUN")
# A DEGRADED run: execution was requested and orders were attempted, but a leg did not fill (Error
# 478/10349, or a broker raise) so the plan is NOT executed and NOT a clean dry-run. It MUST exit non-zero
# — a silent exit 0 makes the CronJob report Complete, and the executed forward track + reconciliation gate
# would book a rebalance that only partially happened (real money errs false-WAIT). Live 2026-07-20.
degraded = do_execute and bool(plan.orders) and not plan.executed and not plan.blocked
status = "BLOCKED" if plan.blocked else ("EXECUTED" if plan.executed else ("DEGRADED" if degraded else "DRY-RUN"))
typer.echo(f"-> {status}")
if degraded:
raise typer.Exit(1)
@app.command("execute-bybit")

View File

@@ -11,17 +11,22 @@ class _FakePlan:
self.notes = []
self.nlv = 100_000.0
self.blocked = False
self.executed = True
class _FakeExecSvc:
def __init__(self, blocked=False):
def __init__(self, blocked=False, degraded=False):
self.last = None
self._blocked = blocked
self._degraded = degraded # orders attempted but a leg didn't fill -> executed False
def rebalance_weights(self, name, weights, prices, *, max_gross, execute, contract_specs=None, sizing_capital=None):
self.last = dict(weights)
plan = _FakePlan()
plan.blocked = self._blocked
if self._degraded:
plan.orders = [object()] # an order WAS placed
plan.executed = False # ...but it did not fill
return plan
@@ -77,3 +82,22 @@ def test_blocked_plan_records_nothing(tmp_path):
assert repo.get_book_state("multistrat_exec_pos") == {} # unchanged — still empty
assert repo.nav_history("multistrat_exec") == [] # no forward_record row
assert not any(s.strategy_id == "multistrat_exec" for s in repo.all_summaries())
def test_degraded_plan_records_nothing(tmp_path):
"""A DEGRADED plan (orders placed but a leg did NOT fill — Error 478/10349 live 2026-07-20) must NOT be
recorded: recording would advance the basis to the INTENDED target book, which the account does not hold
(the un-filled leg), permanently diverging the executed track. Same skip as a blocked plan — err
false-WAIT."""
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/d.db")
repo.migrate()
at = dt.datetime(2026, 7, 10, 23, 30)
_seed_alloc(repo, 10_000.0, at)
svc = _FakeExecSvc(degraded=True)
plan, env = plan_and_record(repo=repo, exec_svc=svc, within_weights={"SPY": 0.6, "IEF": 0.4},
prices={"SPY": 500.0, "IEF": 100.0}, nlv=100_000.0,
today="2026-07-10", at=at, max_gross=2.0, execute=True)
assert plan.executed is False and plan.orders # degraded: placed but unfilled
assert repo.get_book_state("multistrat_exec_pos") == {} # NOT advanced
assert repo.nav_history("multistrat_exec") == [] # no phantom forward_record
assert not any(s.strategy_id == "multistrat_exec" for s in repo.all_summaries())

View File

@@ -82,6 +82,43 @@ def test_inconsistent_account_data_blocks() -> None:
assert plan.blocked and not broker.placed
class StatusFakeBroker(FakeBroker):
"""A broker that returns a per-symbol status (default 'Filled'), so a test can simulate a leg that was
Cancelled/Submitted (unfilled) the way the live 2026-07-20 UCITS run saw CBU0 Cancelled + DBMF Submitted."""
def __init__(self, state: AccountState, statuses: dict[str, str]) -> None:
super().__init__(state)
self._statuses = statuses
def place_order(self, order: Order) -> str:
self.placed.append(order)
return self._statuses.get(order.symbol, "Filled")
def test_bug3_unfilled_leg_marks_plan_not_executed() -> None:
"""A leg that comes back Cancelled/Submitted (not a fill) must NOT let the plan report EXECUTED — the
executed forward track + reconciliation gate must err false-WAIT, never book a phantom rebalance."""
broker = StatusFakeBroker(
AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True),
statuses={"SPY": "Filled", "IEF": "Cancelled"}) # IEF leg fails, like CBU0 live
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance_weights("multistrat", {"SPY": 0.6, "IEF": 0.4},
{"SPY": 100.0, "IEF": 100.0}, max_gross=1.5, execute=True)
assert len(broker.placed) == 2 # both were attempted
assert not plan.executed # BUT a leg didn't fill -> NOT executed
assert any("Cancelled" in n or "unfilled" in n.lower() or "degraded" in n.lower() for n in plan.notes)
def test_bug3_all_filled_still_executes() -> None:
"""Control: when every leg fills, the plan reports EXECUTED as before (no regression)."""
broker = StatusFakeBroker(
AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True),
statuses={"SPY": "Filled", "IEF": "Filled"})
svc = ExecutionService(FakeData(), broker, _settings())
plan = svc.rebalance_weights("multistrat", {"SPY": 0.6, "IEF": 0.4},
{"SPY": 100.0, "IEF": 100.0}, max_gross=1.5, execute=True)
assert plan.executed and len(broker.placed) == 2
class FractionalFakeBroker(FakeBroker):
supports_fractional = True # Alpaca-style: the execution layer must size fractional shares

View File

@@ -73,16 +73,23 @@ def test_us_path_places_stock_smart_usd_unchanged():
def test_ucits_path_places_isin_contract_usd():
"""--venue ucits: an Order carrying ISIN builds a Contract(secIdType=ISIN, secId, currency=USD)."""
"""--venue ucits: an Order carrying ISIN resolves the Contract by ISIN, then places pinned by the resolved
conId on SMART. The ambiguous ISIN/localSymbol is STRIPPED before placing (fix for Error 478, 2026-07-20)
— the conId alone is unambiguous, and a residual conflicting identity field gets the order rejected."""
broker = IbkrBroker()
broker._ib = _FakeIB(resolves=True)
order = Order(symbol="CSPX", side="BUY", quantity=5,
exchange="SMART", currency="USD", sec_id_type="ISIN", sec_id="IE00B5BMR087")
# Before placing, the order carried the ISIN (that is how resolution keys the contract).
assert order.sec_id == "IE00B5BMR087" and order.sec_id_type == "ISIN"
status = broker.place_order(order)
assert status == "Submitted"
# The PLACED contract is pinned by the resolved conId on SMART, with the ambiguous ISIN stripped so IBKR
# cannot raise Error 478 (a residual identity field conflicting with the conId).
contract = broker._ib.placed[0][0]
assert contract.secIdType == "ISIN"
assert contract.secId == "IE00B5BMR087"
assert getattr(contract, "conId", 0) == 12345
assert contract.exchange == "SMART"
assert not getattr(contract, "secId", None)
assert contract.currency == "USD"

View File

@@ -0,0 +1,87 @@
"""Regression tests for the 3 bugs in the Monday 2026-07-20 live UCITS paper rebalance:
Bug 1 (Error 478): the ISIN-qualified UCITS contract retained a conflicting `localSymbol`/`secId` so the
order was rejected ('requested ibLocalSymbol CBU0, from contract CSBGU0') and the IEF leg got 0 fills.
Fix: after qualify, route by the resolved `conId` + SMART and STRIP the ambiguous identity fields
(localSymbol / secId / secIdType / symbol) so nothing conflicts with the conId IBKR already pinned.
Bug 2 (Error 10349): the bare MarketOrder carried no TIF, so the account's order preset forced TIF=DAY and
canceled-then-reconfirmed each order. Fix: set TIF explicitly on the MarketOrder so it matches the preset.
Bug 3 (false-success): `plan.executed = placed > 0` counted a Cancelled/Submitted (unfilled) order as
'placed', so a run where a whole leg didn't fill still reported EXECUTED and exited 0 — the executed
forward track + reconciliation gate would book a rebalance that didn't happen. Fix: a leg whose status is
not a fill marks the plan degraded; the plan is executed only if EVERY placed leg actually filled, and the
CLI exits non-zero on a degraded run (errs false-WAIT, never phantom-EXECUTED).
"""
from __future__ import annotations
from types import SimpleNamespace
from fxhnt.adapters.broker.ibkr import IbkrBroker
from fxhnt.ports.broker import Order
class _FakeIB478:
"""Simulates IBKR: qualify populates the canonical localSymbol/conId; placeOrder REJECTS if the contract
still carries a *different* localSymbol than the qualified one (the real Error 478)."""
def __init__(self, *, canonical_local: str = "CSBGU0", con_id: int = 79000139):
self._canon = canonical_local
self._con = con_id
self.placed: list = []
def isConnected(self):
return True
def qualifyContracts(self, contract):
# IBKR fills in the canonical identity for the ISIN.
contract.conId = self._con
contract.localSymbol = self._canon
return [contract]
def sleep(self, _s):
return None
def managedAccounts(self):
return ["DU9600528"]
def placeOrder(self, contract, order):
local = getattr(contract, "localSymbol", "") or ""
secid = getattr(contract, "secId", "") or ""
# Error 478: any residual identity field that isn't the canonical localSymbol conflicts with the conId.
if (local and local != self._canon) or secid:
self.placed.append((contract, order, "Cancelled"))
return SimpleNamespace(orderStatus=SimpleNamespace(status="Cancelled"), order=order, fills=[])
self.placed.append((contract, order, "Filled"))
return SimpleNamespace(orderStatus=SimpleNamespace(status="Filled"), order=order, fills=[object()])
def test_bug1_ucits_order_routes_by_conid_no_localsymbol_conflict():
"""The ISIN-resolved UCITS order must place clean: after qualify, the placed contract carries the conId
and NO conflicting localSymbol/secId, so IBKR does not raise Error 478."""
broker = IbkrBroker()
fake = _FakeIB478(canonical_local="CSBGU0", con_id=79000139)
broker._ib = fake
# This is the CBU0 (IEF->Treasury) order that failed live: request localSymbol differs from canonical.
order = Order(symbol="CBU0", side="BUY", quantity=55,
exchange="SMART", currency="USD", sec_id_type="ISIN", sec_id="IE00B3VWN518")
status = broker.place_order(order)
assert status == "Filled" # BEFORE fix: "Cancelled" (Error 478)
contract, _placed_order, _ = fake.placed[0]
assert getattr(contract, "conId", 0) == 79000139 # pinned by conId
assert contract.exchange == "SMART"
assert not getattr(contract, "secId", None) # ambiguous ISIN stripped after resolution
# localSymbol either cleared or equal to canonical — never a conflicting value.
assert getattr(contract, "localSymbol", "") in ("", None, "CSBGU0")
def test_bug2_market_order_sets_explicit_tif():
"""The placed MarketOrder must carry an explicit TIF so the account order-preset cannot force one
(Error 10349). We assert the order object has a non-empty tif set by place_order."""
broker = IbkrBroker()
fake = _FakeIB478()
broker._ib = fake
broker.place_order(Order(symbol="CBU0", side="BUY", quantity=5,
sec_id_type="ISIN", sec_id="IE00B3VWN518"))
_contract, placed_order, _ = fake.placed[0]
assert getattr(placed_order, "tif", "") == "DAY" # explicit; matches the account DAY preset