Live 2026-07-21/22 the UCITS rebalancer sold and re-bought the SAME 108 shares every run: $0 position change, ~$23/day in fees+spread (~$5.9k/yr on a $100k envelope), and a churning pattern that would be a compliance problem live. Root cause — TWO symbol spaces were compared directly. IBKR reports the IEF line under its canonical symbol CSBGU0; our weights/prices/targets key on the ticker CBU0. Two independent sites got this wrong and together formed the loop: 1. execution.py:_plan — `current = acct.positions.get(sym)` read a ticker key out of an IBKR-keyed dict, so it saw 0 held while 108 shares existed. delta became the FULL target instead of ~0 -> re-BUY every run, and the weight-0 exit path could never fire. (The more severe half: it mis-sizes every UCITS rebalance, not just this line.) 2. multistrat_exec_record.superseded_holdings — `sym not in current_targets` classified the legitimate CSBGU0 holding as an orphan -> SELL every run. Fix: a single translation SSOT on UcitsSettings (config.py), next to the map that already owns both symbols — ibkr_symbol_for / ticker_for / to_ticker_space. Both sites now translate broker-reported data into ticker space before any comparison, mirroring the pattern dashboard_service._expected_book_symbols already used for the display-only stray check (which was correct, and is why this stayed hidden). Regression tests encode the live case and were verified to FAIL on the old code: - test_ucits_holding_reported_under_broker_symbol_is_recognised_as_on_target (unchanged holding reported as CSBGU0 vs CBU0 target -> ZERO orders) - test_superseded_does_not_flag_the_ibkr_reported_name_of_a_current_target - test_superseded_still_flags_a_genuinely_retired_line_alongside_the_remapped_one (the self-clean must keep working — IDTM is still liquidated) - test_ucits_symbol_space_helpers_round_trip 65 tests green (execution, exec helpers, b2a, exec_capture, reconcile, cli). No new lint (17 ruff findings before and after — all pre-existing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
13 KiB
Python
249 lines
13 KiB
Python
"""Execution engine end-to-end with a FAKE data provider + FAKE broker (no network, no IBKR).
|
|
Proves the multi-strategy rebalance: netting → reconcile vs broker → whole-share orders → gates."""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from fxhnt.application import ExecutionService
|
|
from fxhnt.config import ExecutionSettings, Settings
|
|
from fxhnt.domain.models import AssetClass, Market, PriceSeries, StrategySpec
|
|
from fxhnt.domain.portfolio import Book, StrategyAllocation
|
|
from fxhnt.ports.broker import AccountState, Order
|
|
|
|
SPY = Market(symbol="SPY", asset_class=AssetClass.ETF)
|
|
|
|
|
|
class FakeData:
|
|
name = "fake"
|
|
|
|
def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
|
|
close = 100.0 * np.cumprod(1.0 + np.full(400, 0.001)) # uptrend -> trend long
|
|
return PriceSeries(market=market, dates=tuple(str(i) for i in range(400)), close=close)
|
|
|
|
|
|
class FakeBroker:
|
|
name = "fake"
|
|
|
|
def __init__(self, state: AccountState) -> None:
|
|
self.state = state
|
|
self.placed: list[Order] = []
|
|
|
|
def account_state(self) -> AccountState:
|
|
return self.state
|
|
|
|
def place_order(self, order: Order) -> str:
|
|
self.placed.append(order)
|
|
return "Filled"
|
|
|
|
|
|
def _book() -> Book:
|
|
return Book(name="b", max_gross_leverage=1.0, allocations=[
|
|
StrategyAllocation(spec=StrategySpec(kind="trend", params={"window": 50}), market=SPY, weight=1.0),
|
|
])
|
|
|
|
|
|
def _settings() -> Settings:
|
|
return Settings(execution=ExecutionSettings(allow_live=False))
|
|
|
|
|
|
def test_rebalance_from_flat_places_buy() -> None:
|
|
broker = FakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance(_book(), execute=True)
|
|
assert not plan.blocked
|
|
assert plan.executed and len(broker.placed) == 1
|
|
o = broker.placed[0]
|
|
assert o.symbol == "SPY" and o.side == "BUY"
|
|
# ~100% of 100k at price ~149 (100*1.001^399) -> ~670 shares
|
|
assert 600 < o.quantity < 750
|
|
|
|
|
|
def test_in_band_when_already_on_target_no_orders() -> None:
|
|
# already holding ~full target -> hysteresis says do nothing
|
|
broker = FakeBroker(AccountState(nlv=100_000, cash=0, positions={"SPY": 670}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance(_book(), execute=True)
|
|
assert not plan.executed and not broker.placed
|
|
assert any("in band" in n for n in plan.notes)
|
|
|
|
|
|
def test_live_account_blocked_without_allow_live() -> None:
|
|
broker = FakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=False))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance(_book(), execute=True)
|
|
assert plan.blocked and not broker.placed
|
|
|
|
|
|
def test_inconsistent_account_data_blocks() -> None:
|
|
# NLV says 100k but cash+positions says 60k -> 40% gap > data_tol -> refuse
|
|
broker = FakeBroker(AccountState(nlv=100_000, cash=10_000, positions={}, is_paper=True, gross_position_value=50_000))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance(_book(), execute=True)
|
|
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
|
|
|
|
|
|
def test_fractional_broker_places_precise_fractional_quantity() -> None:
|
|
# A broker that supports fractional shares gets the EXACT target quantity (not whole-share rounded),
|
|
# so the book implements its weights precisely — the whole point of the Alpaca leg.
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance(_book(), execute=True)
|
|
assert plan.executed and len(broker.placed) == 1
|
|
q = broker.placed[0].quantity
|
|
assert 600.0 < q < 750.0
|
|
assert q != int(q) # FRACTIONAL — has a decimal part, not the whole-share integer
|
|
assert q == round(q, 6) # sized to the fractional precision, not arbitrary float noise
|
|
|
|
|
|
def test_rebalance_weights_places_from_precomputed_targets() -> None:
|
|
# The multistrat bridge: rebalance to PRE-COMPUTED weights + caller-supplied prices (no book/data fetch),
|
|
# reusing the same gates + fractional sizing. 60/40 of 100k at $100 → 600 / 400 shares.
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
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 not plan.blocked and plan.executed
|
|
placed = {o.symbol: o.quantity for o in broker.placed}
|
|
assert placed == {"SPY": 600.0, "IEF": 400.0}
|
|
|
|
|
|
def test_entry_floor_uses_sizing_capital_not_full_nlv() -> None:
|
|
# Regression (the IGLN gold-sleeve drop): the book is sized to a $100k envelope on a $1M paper account, so
|
|
# weights come pre-scaled by envelope/nlv (=0.1). A 3% sleeve = $3k of the envelope. entry_floor is 0.5%:
|
|
# on the envelope that's $500 (place), but on the raw $1M NLV it's $5k (would silently drop the sleeve).
|
|
scaled = {"SPY": 0.097 * 0.1, "GLD": 0.030 * 0.1} # envelope-scaled (scale = 100k/1M)
|
|
prices = {"SPY": 800.0, "GLD": 80.0} # GLD order ≈ $3,000 (between $500 and $5,000)
|
|
# WITH sizing_capital (the fix): the 3% sleeve survives the envelope-based floor.
|
|
b1 = FractionalFakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={}, is_paper=True))
|
|
ExecutionService(FakeData(), b1, _settings()).rebalance_weights(
|
|
"multistrat", scaled, prices, max_gross=1.5, execute=True, sizing_capital=100_000)
|
|
assert "GLD" in {o.symbol for o in b1.placed}
|
|
# WITHOUT it: the same sleeve is dropped by the NLV-based floor — the bug the fix addresses.
|
|
b2 = FractionalFakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={}, is_paper=True))
|
|
ExecutionService(FakeData(), b2, _settings()).rebalance_weights(
|
|
"multistrat", scaled, prices, max_gross=1.5, execute=True)
|
|
assert "GLD" not in {o.symbol for o in b2.placed}
|
|
|
|
|
|
def test_rebalance_weights_blocks_when_gross_exceeds_max() -> None:
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance_weights("x", {"SPY": 1.2, "IEF": 0.5},
|
|
{"SPY": 100.0, "IEF": 100.0}, max_gross=1.5, execute=True)
|
|
assert plan.blocked and not broker.placed # gross 1.7 > 1.5 → refuse
|
|
|
|
|
|
def test_rebalance_weights_us_path_order_uses_default_contract_fields() -> None:
|
|
"""--venue us (no contract_specs): the placed Order carries the SMART/USD defaults and NO secId —
|
|
the US path is byte-unchanged."""
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance_weights("multistrat", {"SPY": 0.6}, {"SPY": 100.0},
|
|
max_gross=1.5, execute=True)
|
|
assert plan.executed
|
|
o = broker.placed[0]
|
|
assert o.exchange == "SMART" and o.currency == "USD"
|
|
assert o.sec_id_type is None and o.sec_id is None
|
|
|
|
|
|
def test_rebalance_weights_ucits_path_enriches_order_with_contract_spec() -> None:
|
|
"""--venue ucits: contract_specs enrich the placed Order with the resolving ISIN/exchange/currency,
|
|
and sizing uses the caller-supplied (REAL UCITS) price."""
|
|
broker = FractionalFakeBroker(AccountState(nlv=100_000, cash=100_000, positions={}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
specs = {"CSPX": {"exchange": "SMART", "currency": "USD",
|
|
"sec_id_type": "ISIN", "sec_id": "IE00B5BMR087"}}
|
|
plan = svc.rebalance_weights("multistrat", {"CSPX": 0.6}, {"CSPX": 620.0},
|
|
max_gross=1.5, execute=True, contract_specs=specs)
|
|
assert plan.executed
|
|
o = broker.placed[0]
|
|
assert o.symbol == "CSPX"
|
|
assert o.sec_id_type == "ISIN" and o.sec_id == "IE00B5BMR087"
|
|
assert o.currency == "USD"
|
|
# sized from the REAL UCITS price 620 (60% of 100k / 620 ≈ 96.77), NOT a US price
|
|
assert abs(o.quantity - (0.6 * 100_000 / 620.0)) < 1e-6
|
|
|
|
|
|
def test_weight_zero_exit_always_closes_a_held_line() -> None:
|
|
# A held line taken to weight 0 (a superseded UCITS line being closed) ALWAYS sells — even a tiny holding
|
|
# that hysteresis would otherwise strand. This is what makes the self-cleaning of legacy lines (IDTM after
|
|
# the IEF->CBU0 remap) actually liquidate instead of lingering.
|
|
broker = FakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={"IDTM": 1.0}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance_weights("multistrat", {"IDTM": 0.0}, {"IDTM": 96.0}, execute=True,
|
|
sizing_capital=1_000_000)
|
|
assert not plan.blocked
|
|
sells = [o for o in broker.placed if o.symbol == "IDTM" and o.side == "SELL"]
|
|
assert len(sells) == 1 and sells[0].quantity == 1
|
|
|
|
|
|
def test_held_line_at_tiny_nonzero_weight_is_left_in_band() -> None:
|
|
# Contrast: the SAME tiny holding at a small NONZERO target weight is within hysteresis -> no order (only a
|
|
# deliberate weight-0 EXIT bypasses the band, not every small drift).
|
|
broker = FakeBroker(AccountState(nlv=1_000_000, cash=1_000_000, positions={"IDTM": 1.0}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance_weights("multistrat", {"IDTM": 0.0000001}, {"IDTM": 96.0}, execute=True,
|
|
sizing_capital=1_000_000)
|
|
assert not broker.placed
|
|
|
|
|
|
def test_ucits_holding_reported_under_broker_symbol_is_recognised_as_on_target() -> None:
|
|
# WASH-TRADE REGRESSION (live 2026-07-21/22). IBKR reports the IEF UCITS line under its canonical symbol
|
|
# `CSBGU0`; our target/price keys use the ticker `CBU0`. `_plan` must translate the broker's positions into
|
|
# ticker space before reading `current`, otherwise it reads 0 held, computes delta = the FULL target, and
|
|
# re-buys ~108 shares EVERY run — which (with superseded_holdings selling the same "orphan") round-tripped
|
|
# the position daily for a $0 exposure change and ~$23/day in fees+spread.
|
|
held, px = 108.0, 153.10
|
|
nlv = 1_000_000.0
|
|
weight = (held * px) / nlv # exactly on target -> the correct delta is ~0
|
|
broker = FakeBroker(AccountState(nlv=nlv, cash=nlv, positions={"CSBGU0": held}, is_paper=True))
|
|
svc = ExecutionService(FakeData(), broker, _settings())
|
|
plan = svc.rebalance_weights("multistrat", {"CBU0": weight}, {"CBU0": px}, execute=True,
|
|
sizing_capital=nlv)
|
|
assert not plan.blocked
|
|
assert not broker.placed, (
|
|
"an unchanged UCITS holding must produce NO order — the broker reports it as CSBGU0 while the target "
|
|
"is keyed CBU0; comparing the two symbol spaces directly re-buys the full line every run (wash trade)"
|
|
)
|