Files
fxhnt/tests/integration/test_exec_capture.py

245 lines
9.6 KiB
Python

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