245 lines
9.6 KiB
Python
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
|