"""D2.1 — capture the REAL DU-account fills + snapshot at an `execute-multistrat` run: capture_fills maps ib-async Trade objects to FillDTOs; persist_execution writes them (strategy-tagged) + the account snapshot; both are best-effort (never raise on a broken repo); plan_and_record wires the whole capture end-to-end when an account_repo + broker are supplied (and is a no-op — no AttributeError — when they are not, so existing callers that don't pass them are unaffected).""" from __future__ import annotations import datetime as dt from types import SimpleNamespace import pytest from fxhnt.adapters.broker.ibkr import AccountSnapshot from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo from fxhnt.application.exec_capture import capture_fills, persist_execution class _FakeExecution: def __init__(self, side: str, shares: float, price: float, avg_price: float) -> None: self.side = side self.shares = shares self.price = price self.avgPrice = avg_price class _FakeCommissionReport: def __init__(self, commission: float) -> None: self.commission = commission class _FakeContract: def __init__(self, symbol: str) -> None: self.symbol = symbol class _FakeFill: def __init__(self, symbol: str, side: str, shares: float, price: float, avg_price: float, commission: float) -> None: self.contract = _FakeContract(symbol) self.execution = _FakeExecution(side, shares, price, avg_price) self.commissionReport = _FakeCommissionReport(commission) class _FakeTrade: def __init__(self, fills: list[_FakeFill], status: str = "Filled", lmt_price: float | None = None) -> None: self.fills = fills self.orderStatus = SimpleNamespace(status=status) self.order = SimpleNamespace(lmtPrice=lmt_price) if lmt_price is not None else SimpleNamespace() # ---- capture_fills ----------------------------------------------------------------------------------- def test_capture_fills_maps_trade_fills_to_dtos(): trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)]) out = capture_fills([trade]) assert len(out) == 1 f = out[0] assert f.symbol == "SPY" assert f.side == "BOT" assert f.qty == 10.0 assert f.price == 452.30 assert f.fee == 1.25 assert f.status == "Filled" assert f.shortfall_bps == 0.0 # market order — no intended (limit) price to compare against def test_capture_fills_computes_shortfall_when_intended_price_known(): trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)], lmt_price=452.0) out = capture_fills([trade]) assert out[0].shortfall_bps == pytest.approx((452.30 - 452.0) / 452.0 * 1e4) def test_capture_fills_market_order_sentinel_lmtprice_is_zero_shortfall_not_minus_10000(): """ib-async's `Order.lmtPrice` defaults to the IBAPI sentinel UNSET_DOUBLE (1.7976931348623157e+308, TRUTHY — not None), which every `MarketOrder` carries. A naive `if intended:` treats it as a real intended price and fabricates ~−10000 bps of slippage on EVERY real market fill. Assert the sentinel is honestly treated as "no intended price" → shortfall 0.0. Use a REAL ib_async.MarketOrder so the fake can't drift from the actual sentinel semantics.""" from ib_async import MarketOrder real_order = MarketOrder("BUY", 10) # carries lmtPrice == UNSET_DOUBLE trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)]) trade.order = real_order # the actual sentinel, not a hand-typed value out = capture_fills([trade]) assert out[0].shortfall_bps == 0.0 # NOT ~-10000.0 def test_capture_fills_sentinel_double_literal_is_zero_shortfall(): """Belt-and-braces: a fake carrying the literal UNSET_DOUBLE value (sentinel-faithful) also yields 0.0, so the guard is value-based (not merely 'is a MarketOrder').""" from ib_async.util import UNSET_DOUBLE trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)], lmt_price=UNSET_DOUBLE) out = capture_fills([trade]) assert out[0].shortfall_bps == 0.0 def test_capture_fills_handles_multiple_trades_and_fills(): t1 = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)]) t2 = _FakeTrade([_FakeFill("IEF", "SLD", 5.0, 99.0, 99.0, 0.50), _FakeFill("IEF", "SLD", 3.0, 99.1, 99.1, 0.30)]) out = capture_fills([t1, t2]) assert len(out) == 3 assert [f.symbol for f in out] == ["SPY", "IEF", "IEF"] def test_capture_fills_empty_trades_returns_empty(): assert capture_fills([]) == [] # ---- persist_execution -------------------------------------------------------------------------------- def test_persist_execution_writes_fills_and_snapshot(tmp_path): repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t.db") repo.migrate() trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)]) fills = capture_fills([trade]) snapshot = AccountSnapshot(nlv=1_000_000.0, cash=500.0, gross=999_500.0, positions={"SPY": 10.0}) at = dt.datetime(2026, 7, 14, 15, 0) persist_execution(repo, strategy_id="multistrat", rebalance_id="2026-07-14", fills=fills, snapshot=snapshot, at=at) stored = repo.fills_for("multistrat", limit=10) assert len(stored) == 1 assert stored[0].symbol == "SPY" and stored[0].strategy_id == "multistrat" assert stored[0].rebalance_id == "2026-07-14" assert repo.nav_series() == [("2026-07-14", 1_000_000.0)] assert repo.latest_positions() == {"SPY": 10.0} class _RaisingRepo: def record_fills(self, **kwargs): raise RuntimeError("db is down") def replace_snapshot(self, *args, **kwargs): raise RuntimeError("db is down") def test_persist_execution_is_best_effort_never_raises(): persist_execution(_RaisingRepo(), strategy_id="multistrat", rebalance_id="x", fills=[], snapshot=AccountSnapshot(nlv=0.0, cash=0.0, gross=0.0, positions={}), at=dt.datetime(2026, 7, 14)) # must not raise class _FillsOnlyRaisingRepo: """Fills write raises; the snapshot write must still happen (independent guards).""" def __init__(self) -> None: self.snapshot_calls: list[tuple] = [] def record_fills(self, **kwargs): raise RuntimeError("fills write failed") def replace_snapshot(self, *args, **kwargs): self.snapshot_calls.append((args, kwargs)) def test_persist_execution_snapshot_write_survives_a_fills_failure(): repo = _FillsOnlyRaisingRepo() persist_execution(repo, strategy_id="multistrat", rebalance_id="x", fills=[], snapshot=AccountSnapshot(nlv=1.0, cash=1.0, gross=1.0, positions={}), at=dt.datetime(2026, 7, 14)) assert len(repo.snapshot_calls) == 1 # ---- broker: account_snapshot + trade collection ------------------------------------------------------- def test_ibkr_broker_account_snapshot_reads_summary_and_positions(): from fxhnt.adapters.broker.ibkr import IbkrBroker broker = IbkrBroker() fake_ib = SimpleNamespace( isConnected=lambda: True, accountSummary=lambda: [SimpleNamespace(tag="NetLiquidation", value="1028594.0"), SimpleNamespace(tag="TotalCashValue", value="500.0"), SimpleNamespace(tag="GrossPositionValue", value="1000000.0")], positions=lambda: [SimpleNamespace(contract=SimpleNamespace(symbol="SPY"), position=10.0)]) broker._ib = fake_ib snap = broker.account_snapshot() assert snap.nlv == 1028594.0 assert snap.cash == 500.0 assert snap.gross == 1000000.0 assert snap.positions == {"SPY": 10.0} def test_ibkr_broker_pop_trades_returns_and_clears_placed_trades(): from fxhnt.adapters.broker.ibkr import IbkrBroker broker = IbkrBroker() assert broker.pop_trades() == [] # nothing placed yet fake_trade = _FakeTrade([_FakeFill("SPY", "BOT", 1.0, 100.0, 100.0, 0.1)]) broker._trades.append(fake_trade) popped = broker.pop_trades() assert popped == [fake_trade] assert broker.pop_trades() == [] # cleared after pop # ---- plan_and_record wiring: capture only fires when account_repo + broker are supplied ------------------ class _FakePlanWithTrades: def __init__(self, trades): self.nlv = 100_000.0 self.orders: list = [] self.notes: list = [] self.blocked = False self.executed = True self.trades = trades self.rebalance_id = "" class _FakeExecSvcWithTrades: def __init__(self, trades): self._trades = trades def rebalance_weights(self, name, weights, prices, *, max_gross, execute, contract_specs=None, sizing_capital=None): return _FakePlanWithTrades(self._trades) class _FakeBrokerWithSnapshot: def account_snapshot(self): return AccountSnapshot(nlv=100_000.0, cash=1_000.0, gross=99_000.0, positions={"SPY": 12.0}) def test_plan_and_record_persists_capture_when_repo_and_broker_supplied(tmp_path): from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo from fxhnt.application.multistrat_exec_record import plan_and_record fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/f.db") fwd_repo.migrate() account_repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/a.db") account_repo.migrate() at = dt.datetime(2026, 7, 14, 15, 0) trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)]) plan, env = plan_and_record( repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0}, prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True, account_is_paper=True, account_repo=account_repo, broker=_FakeBrokerWithSnapshot()) stored = account_repo.fills_for("multistrat", limit=10) assert len(stored) == 1 and stored[0].symbol == "SPY" assert account_repo.nav_series() == [("2026-07-14", 100_000.0)] assert not plan.blocked class _RaisingBrokerSnapshot: """A broker whose account_snapshot() raises — exercises plan_and_record's OUTER belt-and-braces guard (the failure happens before persist_execution's own internal try/except can catch it).""" def account_snapshot(self): raise RuntimeError("broker unreachable") def test_plan_and_record_survives_a_raising_account_repo_end_to_end(tmp_path): """Drive a RAISING account_repo (record_fills + replace_snapshot both raise) all the way through plan_and_record — the rebalance must still complete and return normally (capture is best-effort, non-blocking). Proves the outer try/except in plan_and_record is exercised, not just persist_execution.""" from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo from fxhnt.application.multistrat_exec_record import plan_and_record fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/fr.db") fwd_repo.migrate() at = dt.datetime(2026, 7, 14, 15, 0) trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)]) plan, env = plan_and_record( repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0}, prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True, paper_envelope=100_000.0, account_is_paper=True, account_repo=_RaisingRepo(), broker=_FakeBrokerWithSnapshot()) assert not plan.blocked and env == 100_000.0 # rebalance completed normally despite the broken repo # the gate-relevant return basis was still recorded (capture failure is isolated from it) assert fwd_repo.get_book_state("multistrat_exec_pos")["envelope"] == 100_000.0 def test_plan_and_record_survives_a_raising_broker_snapshot_end_to_end(tmp_path): """A broker whose account_snapshot() raises (before persist_execution is ever reached) must ALSO not fail the rebalance — the outer guard covers capture_fills/account_snapshot(), not just persist_execution.""" from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo from fxhnt.application.multistrat_exec_record import plan_and_record fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/fr2.db") fwd_repo.migrate() account_repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/ar2.db") account_repo.migrate() at = dt.datetime(2026, 7, 14, 15, 0) trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)]) plan, env = plan_and_record( repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0}, prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True, account_is_paper=True, account_repo=account_repo, broker=_RaisingBrokerSnapshot()) assert not plan.blocked # rebalance completed assert account_repo.nav_series() == [] # nothing captured (snapshot raised) — clean def test_plan_and_record_skips_capture_when_repo_and_broker_not_supplied(tmp_path): """Existing callers (and `_FakePlan`-style test fakes without a `.trades` attribute) must be unaffected — no capture attempted, no AttributeError, when account_repo/broker are the default None.""" from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo from fxhnt.application.multistrat_exec_record import plan_and_record class _PlanNoTrades: nlv = 100_000.0 orders: list = [] notes: list = [] blocked = False executed = True class _ExecSvcNoTrades: def rebalance_weights(self, name, weights, prices, *, max_gross, execute, contract_specs=None, sizing_capital=None): return _PlanNoTrades() fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/f2.db") fwd_repo.migrate() at = dt.datetime(2026, 7, 14, 15, 0) plan, env = plan_and_record( repo=fwd_repo, exec_svc=_ExecSvcNoTrades(), within_weights={"SPY": 1.0}, prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True, account_is_paper=True) assert not plan.blocked # no AttributeError from touching plan.trades/plan.rebalance_id