Files
fxhnt/tests/integration/test_execution.py
Jeroen Grusewski 6783932860 fix(exec): stop the UCITS wash trade — one SSOT for ticker vs broker symbol space
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>
2026-07-22 09:58:56 +00:00

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)"
)