Files
fxhnt/tests/integration/test_exec_capture.py
jgrusewski bcf58e703d fix(exec): entry-floor threshold on the sizing envelope, not the raw NLV
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>
2026-07-15 16:14:06 +02:00

320 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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