The IGLN gold-sleeve 'priced but no order' bug. scaled_weights sizes orders to the paper envelope (envelope*within_weight), but _plan's entry-floor/hysteresis threshold used entry_floor*nlv (the full $1M account NLV) — so the effective floor was ~entry_floor*(nlv/envelope) of the envelope (~5%), silently dropping every sleeve below it. A 3% gold sleeve at a $100k envelope on a $1M account skipped. FIX: rebalance_weights takes sizing_capital (the envelope); the floor is entry_floor*min(sizing_capital,nlv). Sizing + recording unchanged. Regression test: a 3% sleeve survives WITH the envelope basis, drops without it. 2005 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
14 KiB
Python
320 lines
14 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_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
|