feat(D2.1): capture real IBKR fills->exec_fills (strategy-tagged) + ibkr_account_nav snapshot (best-effort, paper-only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Bakes in hard-won lessons: paper detection via the DU* account prefix; whole-sha
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +16,16 @@ from fxhnt.ports.broker import AccountState, Order
|
||||
_SUMMARY_TAGS = ("NetLiquidation", "TotalCashValue", "GrossPositionValue")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountSnapshot:
|
||||
"""The REAL DU-paper-account state at a rebalance (D2.1) — captured additively alongside the existing
|
||||
intended-book return basis, for the `ibkr_account_nav` table."""
|
||||
nlv: float
|
||||
cash: float
|
||||
gross: float
|
||||
positions: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
class IbkrBroker:
|
||||
name = "ibkr"
|
||||
supports_fractional = False # IBKR rejects fractional market orders (error 10243) — whole shares only
|
||||
@@ -22,6 +33,7 @@ class IbkrBroker:
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 4002, client_id: int = 17, timeout: int = 15) -> None:
|
||||
self._host, self._port, self._cid, self._timeout = host, port, client_id, timeout
|
||||
self._ib: Any = None
|
||||
self._trades: list[Any] = [] # ib-async Trade objects from place_order, popped by pop_trades()
|
||||
|
||||
def _ensure(self) -> Any:
|
||||
from ib_async import IB
|
||||
@@ -58,8 +70,24 @@ class IbkrBroker:
|
||||
market_order.account = accts[0]
|
||||
trade = ib.placeOrder(contract, market_order)
|
||||
ib.sleep(2)
|
||||
self._trades.append(trade) # collected by pop_trades() so fills are capturable (D2.1)
|
||||
return str(trade.orderStatus.status)
|
||||
|
||||
def pop_trades(self) -> list[Any]:
|
||||
"""Return and clear the ib-async `Trade` objects placed since the last pop (one rebalance's worth).
|
||||
`exec_capture.capture_fills` reads each trade's `.fills` to persist the realized executions."""
|
||||
trades, self._trades = self._trades, []
|
||||
return trades
|
||||
|
||||
def account_snapshot(self) -> AccountSnapshot:
|
||||
"""The REAL account state right now (NLV/cash/gross + positions), reusing the same `accountSummary`/
|
||||
`positions` reads as `account_state` (D2.1 — captured additively, not the gate-relevant basis)."""
|
||||
ib = self._ensure()
|
||||
summ = {v.tag: float(v.value) for v in ib.accountSummary() if v.tag in _SUMMARY_TAGS}
|
||||
positions = {p.contract.symbol: float(p.position) for p in ib.positions()}
|
||||
return AccountSnapshot(nlv=summ.get("NetLiquidation", 0.0), cash=summ.get("TotalCashValue", 0.0),
|
||||
gross=summ.get("GrossPositionValue", 0.0), positions=positions)
|
||||
|
||||
def place_combo(self, legs: list[tuple[str, str, int]], net_limit: float) -> str:
|
||||
"""Atomic XSP put-credit-spread as a BAG combo. legs = (osi_symbol, side, qty); net_limit<0 = credit.
|
||||
Requires options trading permission (account currently lacks it -> this path ships suspended)."""
|
||||
|
||||
@@ -108,6 +108,7 @@ class ExecFillRow(CockpitBase):
|
||||
__tablename__ = "exec_fills"
|
||||
client_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
rebalance_id: Mapped[str] = mapped_column(String(48), index=True)
|
||||
strategy_id: Mapped[str] = mapped_column(String(64), index=True, default="") # "" = back-compat/untagged
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
side: Mapped[str] = mapped_column(String(4))
|
||||
qty: Mapped[float] = mapped_column(Float)
|
||||
@@ -118,6 +119,21 @@ class ExecFillRow(CockpitBase):
|
||||
at: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class IbkrAccountNavRow(CockpitBase):
|
||||
"""Per-run REAL DU-paper-account snapshot (D2.1) — the actual NLV/cash/gross + positions IBKR reports at
|
||||
each `execute-multistrat` run, captured ADDITIVELY alongside the existing intended-book return basis
|
||||
(`multistrat_exec_pos`/`forward_record`, unchanged). `run_date` PK — idempotent replace (a same-day re-run
|
||||
overwrites, never appends). `positions_json` mirrors `ForwardBookStateRow.extra_json`'s JSON-as-text
|
||||
approach so this table works identically on SQLite (tests) and Postgres."""
|
||||
__tablename__ = "ibkr_account_nav"
|
||||
run_date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
|
||||
nlv: Mapped[float] = mapped_column(Float)
|
||||
cash: Mapped[float] = mapped_column(Float)
|
||||
gross: Mapped[float] = mapped_column(Float)
|
||||
positions_json: Mapped[str] = mapped_column(Text)
|
||||
at: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class PaperPositionRow(CockpitBase):
|
||||
__tablename__ = "paper_positions"
|
||||
run_date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
|
||||
|
||||
94
src/fxhnt/adapters/persistence/ibkr_account_repo.py
Normal file
94
src/fxhnt/adapters/persistence/ibkr_account_repo.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Repository for the D2.1 REAL DU-paper-account capture: the per-run `ibkr_account_nav` snapshot (NLV/cash/
|
||||
gross/positions) plus strategy-tagged reads of the (pre-existing, previously-empty) `exec_fills` table.
|
||||
Mirrors `ForwardNavRepo`'s engine/StaticPool construction and its guarded idempotent ALTER pattern
|
||||
(`_ensure_summary_policy_columns`) — `migrate()` never runs heavy DDL on every startup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from fxhnt.adapters.persistence.cockpit_models import CockpitBase, ExecFillRow, IbkrAccountNavRow
|
||||
from fxhnt.application.exec_capture import FillDTO
|
||||
|
||||
|
||||
class IbkrAccountRepo:
|
||||
def __init__(self, dsn: str) -> None:
|
||||
# StaticPool keeps a single in-memory SQLite connection alive across sessions; only applied for the
|
||||
# in-memory SQLite case (tests). StaticPool is NOT used for Postgres.
|
||||
kw: dict[str, Any] = {"future": True}
|
||||
if dsn == "sqlite://" or (dsn.startswith("sqlite://") and ":memory:" in dsn):
|
||||
kw |= {"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
|
||||
self._engine = create_engine(dsn, **kw)
|
||||
self._is_pg = self._engine.dialect.name == "postgresql"
|
||||
|
||||
def migrate(self) -> None:
|
||||
CockpitBase.metadata.create_all(self._engine)
|
||||
self._ensure_exec_fills_strategy_column()
|
||||
|
||||
def _ensure_exec_fills_strategy_column(self) -> None:
|
||||
"""Idempotently add `exec_fills.strategy_id` to a table that pre-dates this task (`create_all` does
|
||||
NOT add columns to an already-existing table on prod Postgres). Postgres has `ADD COLUMN IF NOT
|
||||
EXISTS`; sqlite does not, so PRAGMA-check the columns first. A brand-new table already has the
|
||||
column from `create_all` — this is then a no-op on both dialects."""
|
||||
with self._engine.begin() as c:
|
||||
if self._is_pg:
|
||||
c.execute(text(
|
||||
"ALTER TABLE exec_fills ADD COLUMN IF NOT EXISTS strategy_id VARCHAR(64) DEFAULT ''"))
|
||||
else:
|
||||
cols = {row[1] for row in c.execute(text("PRAGMA table_info(exec_fills)"))}
|
||||
if "strategy_id" not in cols:
|
||||
c.execute(text(
|
||||
"ALTER TABLE exec_fills ADD COLUMN strategy_id VARCHAR(64) DEFAULT ''"))
|
||||
|
||||
def replace_snapshot(self, run_date: str, *, nlv: float, cash: float, gross: float,
|
||||
positions: dict[str, float], at: dt.datetime) -> None:
|
||||
"""Idempotent per run_date: delete-then-insert in one transaction, so a same-day re-run overwrites
|
||||
rather than double-books (mirrors `ForwardNavRepo.replace_nav`)."""
|
||||
rd = dt.date.fromisoformat(run_date)
|
||||
positions_json = json.dumps({str(k): float(v) for k, v in positions.items()})
|
||||
with Session(self._engine) as s:
|
||||
s.query(IbkrAccountNavRow).filter(IbkrAccountNavRow.run_date == rd).delete()
|
||||
s.add(IbkrAccountNavRow(run_date=rd, nlv=float(nlv), cash=float(cash), gross=float(gross),
|
||||
positions_json=positions_json, at=at))
|
||||
s.commit()
|
||||
|
||||
def nav_series(self) -> list[tuple[str, float]]:
|
||||
"""The account's (date, NLV) series ascending — the real-account equivalent of `ForwardNavRepo`'s
|
||||
nav history."""
|
||||
with Session(self._engine) as s:
|
||||
rows = s.scalars(select(IbkrAccountNavRow).order_by(IbkrAccountNavRow.run_date.asc()))
|
||||
return [(r.run_date.isoformat(), r.nlv) for r in rows]
|
||||
|
||||
def latest_positions(self) -> dict[str, float]:
|
||||
"""The most recently snapshotted real positions, or {} if none captured yet."""
|
||||
with Session(self._engine) as s:
|
||||
row = s.scalars(select(IbkrAccountNavRow)
|
||||
.order_by(IbkrAccountNavRow.run_date.desc()).limit(1)).first()
|
||||
if row is None:
|
||||
return {}
|
||||
return {str(k): float(v) for k, v in json.loads(row.positions_json).items()}
|
||||
|
||||
def record_fills(self, *, strategy_id: str, rebalance_id: str, fills: list[FillDTO],
|
||||
at: dt.datetime) -> None:
|
||||
"""Append each captured fill as an `exec_fills` row tagged `strategy_id`. `client_id` (the table's
|
||||
PK) is synthesized per fill since `FillDTO` carries no execution id of its own."""
|
||||
with Session(self._engine) as s:
|
||||
for f in fills:
|
||||
s.add(ExecFillRow(client_id=str(uuid.uuid4()), rebalance_id=rebalance_id,
|
||||
strategy_id=strategy_id, symbol=f.symbol, side=f.side, qty=f.qty,
|
||||
price=f.price, fee=f.fee, status=f.status,
|
||||
shortfall_bps=f.shortfall_bps, at=at))
|
||||
s.commit()
|
||||
|
||||
def fills_for(self, strategy_id: str, limit: int = 20) -> list[ExecFillRow]:
|
||||
"""The most recent `limit` fills tagged `strategy_id`, newest first. Read-only."""
|
||||
stmt = (select(ExecFillRow).where(ExecFillRow.strategy_id == strategy_id)
|
||||
.order_by(ExecFillRow.at.desc()).limit(limit))
|
||||
with Session(self._engine) as s:
|
||||
return list(s.scalars(stmt))
|
||||
70
src/fxhnt/application/exec_capture.py
Normal file
70
src/fxhnt/application/exec_capture.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""D2.1 — capture the REAL DU-paper-account state produced by an `execute-multistrat` run: the fills that
|
||||
actually got placed (persisted into the existing `exec_fills` table, now strategy-tagged) and the
|
||||
post-rebalance account snapshot (`ibkr_account_nav`). This is ADDITIVE to the existing intended-book return
|
||||
basis (`multistrat_exec_pos`/`forward_record` via `record_exec_track`, unchanged) — a data-layer record of
|
||||
what the real account actually did, not the gate-relevant return basis.
|
||||
|
||||
BEST-EFFORT throughout: a persistence failure logs and never raises, so a capture bug can never fail or
|
||||
block an otherwise-successful rebalance (mirrors the existing best-effort ref-persist pattern elsewhere in
|
||||
the codebase)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FillDTO:
|
||||
symbol: str
|
||||
side: str
|
||||
qty: float
|
||||
price: float
|
||||
fee: float
|
||||
status: str
|
||||
shortfall_bps: float
|
||||
|
||||
|
||||
def capture_fills(trades: Iterable[Any]) -> list[FillDTO]:
|
||||
"""Map ib-async `Trade` objects (each carrying `.orderStatus`, `.order`, and `.fills` — one per partial/
|
||||
full execution) to `FillDTO`s. `shortfall_bps` = (fill price − intended price) / intended price · 1e4,
|
||||
where the intended price is the order's limit price (`order.lmtPrice`) when a real one was set; whole-
|
||||
share market orders (the current multistrat execution path) carry none, so `shortfall_bps` is 0.0 for
|
||||
them today — this is forward-looking for any future limit/combo order path."""
|
||||
out: list[FillDTO] = []
|
||||
for trade in trades:
|
||||
status = str(getattr(getattr(trade, "orderStatus", None), "status", ""))
|
||||
intended = getattr(getattr(trade, "order", None), "lmtPrice", None)
|
||||
intended_price = float(intended) if intended else None
|
||||
for fill in getattr(trade, "fills", []):
|
||||
execution = fill.execution
|
||||
price = float(execution.price)
|
||||
report = getattr(fill, "commissionReport", None)
|
||||
fee = float(getattr(report, "commission", 0.0)) if report is not None else 0.0
|
||||
shortfall_bps = ((price - intended_price) / intended_price) * 1e4 if intended_price else 0.0
|
||||
out.append(FillDTO(symbol=fill.contract.symbol, side=execution.side, qty=float(execution.shares),
|
||||
price=price, fee=fee, status=status, shortfall_bps=shortfall_bps))
|
||||
return out
|
||||
|
||||
|
||||
def persist_execution(repo: Any, *, strategy_id: str, rebalance_id: str, fills: list[FillDTO],
|
||||
snapshot: Any, at: dt.datetime) -> None:
|
||||
"""Best-effort write of `fills` (tagged `strategy_id`, into `exec_fills`) and `snapshot` (into
|
||||
`ibkr_account_nav`, keyed by `at`'s date). The two writes are independently guarded — a failure in one
|
||||
never blocks the other, and NEITHER ever raises: a persistence bug here must never fail an already-placed
|
||||
rebalance."""
|
||||
try:
|
||||
repo.record_fills(strategy_id=strategy_id, rebalance_id=rebalance_id, fills=fills, at=at)
|
||||
except Exception:
|
||||
log.warning("exec capture: failed to persist fills for %s (best-effort, non-blocking)",
|
||||
strategy_id, exc_info=True)
|
||||
try:
|
||||
repo.replace_snapshot(at.date().isoformat(), nlv=snapshot.nlv, cash=snapshot.cash,
|
||||
gross=snapshot.gross, positions=snapshot.positions, at=at)
|
||||
except Exception:
|
||||
log.warning("exec capture: failed to persist account snapshot for %s (best-effort, non-blocking)",
|
||||
strategy_id, exc_info=True)
|
||||
@@ -6,6 +6,8 @@ The safety gates are hard-won from live paper trading.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fxhnt.config import Settings
|
||||
@@ -29,6 +31,11 @@ class RebalancePlan(BaseModel):
|
||||
executed: bool = False
|
||||
blocked: bool = False
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
rebalance_id: str = "" # caller-assigned; "" = unset (D2.1)
|
||||
# Raw broker trade objects (e.g. ib-async Trade) placed this run, when the broker exposes `pop_trades()`
|
||||
# — read by `exec_capture.capture_fills` to persist the REAL fills (D2.1). Empty for any broker that
|
||||
# doesn't support trade collection (e.g. the test FakeBroker / a fractional broker) or on a dry run.
|
||||
trades: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExecutionService:
|
||||
@@ -130,4 +137,7 @@ class ExecutionService:
|
||||
notes.append(f"order failed {o.symbol}: {str(e)[:80]}")
|
||||
plan.executed = placed > 0
|
||||
plan.notes = notes
|
||||
pop_trades = getattr(self._broker, "pop_trades", None) # only IbkrBroker exposes this (D2.1)
|
||||
if callable(pop_trades):
|
||||
plan.trades = pop_trades()
|
||||
return plan
|
||||
|
||||
@@ -8,8 +8,11 @@ C1 task (Task 3). No new execution engine, no new return store."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scaled_weights(within_weights: dict[str, float], envelope: float, nlv: float) -> dict[str, float]:
|
||||
if envelope <= 0.0 or nlv <= 0.0:
|
||||
@@ -66,7 +69,7 @@ def record_exec_track(repo: Any, exec_return: float | None, new_positions: dict[
|
||||
def plan_and_record(*, repo: Any, exec_svc: Any, within_weights: dict[str, float], prices: dict[str, float],
|
||||
nlv: float, today: str, at: dt.datetime, max_gross: float, execute: bool,
|
||||
paper_envelope: float = 0.0, account_is_paper: bool = True,
|
||||
venue: str = "") -> tuple[Any, float]:
|
||||
venue: str = "", account_repo: Any = None, broker: Any = None) -> tuple[Any, float]:
|
||||
"""Compute the B2a envelope, size the book to it (flatten on $0), reconcile via exec_svc, and on
|
||||
`execute` (when the plan was NOT blocked by a safety gate) record the observed multistrat_exec return.
|
||||
Returns (plan, envelope). The next run's return basis is the TARGET book we rebalance TO
|
||||
@@ -77,7 +80,12 @@ def plan_and_record(*, repo: Any, exec_svc: Any, within_weights: dict[str, float
|
||||
diverge the executed track from the real account.
|
||||
|
||||
`paper_envelope > 0` sizes the book to min(paper_envelope, nlv) and does NOT read B2a — a PAPER run that
|
||||
bypasses the real-capital gate, hard-refused (no orders) unless `account_is_paper`. `venue` is recorded."""
|
||||
bypasses the real-capital gate, hard-refused (no orders) unless `account_is_paper`. `venue` is recorded.
|
||||
|
||||
`account_repo` + `broker` (both optional, default None) additionally capture the REAL DU-account state
|
||||
(D2.1): when both are supplied AND `account_is_paper`, the fills actually placed this run + a fresh
|
||||
account snapshot are persisted via `exec_capture`. This is ADDITIVE and BEST-EFFORT — it never changes
|
||||
`positions_after` (the gate-relevant return basis, unchanged above) and a capture failure only logs."""
|
||||
if paper_envelope > 0.0:
|
||||
if not account_is_paper:
|
||||
raise ValueError("paper-envelope refused: account is not paper (real-capital exposure guard)")
|
||||
@@ -95,4 +103,12 @@ def plan_and_record(*, repo: Any, exec_svc: Any, within_weights: dict[str, float
|
||||
positions_after = {s: (w * float(nlv)) / prices[s] # the target book just placed
|
||||
for s, w in weights.items() if prices.get(s, 0.0) > 0.0}
|
||||
record_exec_track(repo, exec_return, positions_after, prices, envelope, today, at, venue)
|
||||
if account_is_paper and account_repo is not None and broker is not None:
|
||||
try:
|
||||
from fxhnt.application.exec_capture import capture_fills, persist_execution
|
||||
persist_execution(account_repo, strategy_id="multistrat",
|
||||
rebalance_id=(plan.rebalance_id or today), fills=capture_fills(plan.trades),
|
||||
snapshot=broker.account_snapshot(), at=at)
|
||||
except Exception: # capture is ADDITIVE — never let it fail an already-placed rebalance
|
||||
log.warning("multistrat exec capture failed (best-effort, non-blocking)", exc_info=True)
|
||||
return plan, envelope
|
||||
|
||||
@@ -261,6 +261,7 @@ def execute_multistrat(
|
||||
import datetime as _dt
|
||||
|
||||
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
||||
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
|
||||
from fxhnt.application.multistrat_exec_record import plan_and_record
|
||||
from fxhnt.domain.strategies import multistrat
|
||||
|
||||
@@ -294,6 +295,8 @@ def execute_multistrat(
|
||||
|
||||
repo = ForwardNavRepo(settings.operational_dsn)
|
||||
repo.migrate()
|
||||
account_repo = IbkrAccountRepo(settings.operational_dsn) # D2.1 — real fills + NAV snapshot capture
|
||||
account_repo.migrate()
|
||||
today = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d")
|
||||
at = _dt.datetime.now(_dt.UTC).replace(tzinfo=None)
|
||||
|
||||
@@ -312,7 +315,8 @@ def execute_multistrat(
|
||||
plan, envelope = plan_and_record(
|
||||
repo=repo, exec_svc=svc, within_weights=bweights, prices=bprices, nlv=acct.nlv,
|
||||
today=today, at=at, max_gross=max_gross, execute=do_execute,
|
||||
paper_envelope=paper_env, account_is_paper=acct.is_paper, venue=venue)
|
||||
paper_envelope=paper_env, account_is_paper=acct.is_paper, venue=venue,
|
||||
account_repo=account_repo, broker=brk)
|
||||
except ValueError as e: # a deliberate safety refusal (e.g. paper-envelope on a non-paper account) —
|
||||
typer.echo(f"paper-envelope refused: {e}") # distinct from a connectivity error, keep it that way
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
244
tests/integration/test_exec_capture.py
Normal file
244
tests/integration/test_exec_capture.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""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
|
||||
71
tests/unit/test_ibkr_account_repo.py
Normal file
71
tests/unit/test_ibkr_account_repo.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""D2.1 — IbkrAccountRepo: per-run REAL DU-account NAV/positions snapshot (idempotent replace) + strategy-
|
||||
tagged exec_fills reads. Mirrors ForwardNavRepo's migrate/engine construction, tested against SQLite."""
|
||||
import datetime as dt
|
||||
|
||||
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
|
||||
|
||||
|
||||
def test_snapshot_roundtrip(tmp_path):
|
||||
repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t.db")
|
||||
repo.migrate()
|
||||
at = dt.datetime(2026, 7, 14, 14, 35)
|
||||
repo.replace_snapshot("2026-07-14", nlv=1_028_594.0, cash=500.0, gross=1_000_000.0,
|
||||
positions={"SPY": 10.0}, at=at)
|
||||
assert repo.nav_series() == [("2026-07-14", 1_028_594.0)]
|
||||
assert repo.latest_positions() == {"SPY": 10.0}
|
||||
# idempotent replace
|
||||
repo.replace_snapshot("2026-07-14", nlv=1_029_000.0, cash=1.0, gross=1.0, positions={"SPY": 11.0}, at=at)
|
||||
assert repo.nav_series() == [("2026-07-14", 1_029_000.0)]
|
||||
|
||||
|
||||
def test_nav_series_multi_date_ascending(tmp_path):
|
||||
repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t2.db")
|
||||
repo.migrate()
|
||||
at = dt.datetime(2026, 7, 13, 10, 0)
|
||||
repo.replace_snapshot("2026-07-13", nlv=1_000_000.0, cash=0.0, gross=1_000_000.0, positions={}, at=at)
|
||||
repo.replace_snapshot("2026-07-14", nlv=1_010_000.0, cash=0.0, gross=1_010_000.0, positions={}, at=at)
|
||||
assert repo.nav_series() == [("2026-07-13", 1_000_000.0), ("2026-07-14", 1_010_000.0)]
|
||||
|
||||
|
||||
def test_latest_positions_empty_when_no_snapshot(tmp_path):
|
||||
repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t3.db")
|
||||
repo.migrate()
|
||||
assert repo.nav_series() == []
|
||||
assert repo.latest_positions() == {}
|
||||
|
||||
|
||||
def test_fills_for_reads_strategy_tagged_exec_fills(tmp_path):
|
||||
repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t4.db")
|
||||
repo.migrate()
|
||||
at = dt.datetime(2026, 7, 14, 15, 0)
|
||||
from fxhnt.application.exec_capture import FillDTO
|
||||
fills = [FillDTO(symbol="SPY", side="BOT", qty=10.0, price=452.3, fee=1.25, status="Filled",
|
||||
shortfall_bps=0.0)]
|
||||
repo.record_fills(strategy_id="multistrat", rebalance_id="2026-07-14", fills=fills, at=at)
|
||||
out = repo.fills_for("multistrat", limit=10)
|
||||
assert len(out) == 1
|
||||
assert out[0].symbol == "SPY" and out[0].strategy_id == "multistrat"
|
||||
assert repo.fills_for("other_strategy", limit=10) == []
|
||||
|
||||
|
||||
def test_migrate_is_idempotent_and_adds_strategy_id_to_existing_exec_fills(tmp_path):
|
||||
"""A pre-existing exec_fills table (created before this task, no strategy_id column) must gain the
|
||||
column on migrate without losing data — the guarded ALTER path."""
|
||||
dsn = f"sqlite:///{tmp_path}/t5.db"
|
||||
from sqlalchemy import create_engine, text
|
||||
engine = create_engine(dsn)
|
||||
with engine.begin() as c:
|
||||
c.execute(text(
|
||||
"CREATE TABLE exec_fills (client_id VARCHAR(64) PRIMARY KEY, rebalance_id VARCHAR(48), "
|
||||
"symbol VARCHAR(32), side VARCHAR(4), qty FLOAT, price FLOAT, fee FLOAT, status VARCHAR(12), "
|
||||
"shortfall_bps FLOAT, at DATETIME)"))
|
||||
c.execute(text(
|
||||
"INSERT INTO exec_fills VALUES ('c1', 'r1', 'SPY', 'BOT', 1.0, 2.0, 0.0, 'Filled', 0.0, "
|
||||
"'2026-07-14 00:00:00')"))
|
||||
engine.dispose()
|
||||
|
||||
repo = IbkrAccountRepo(dsn)
|
||||
repo.migrate() # must not raise, and must add strategy_id without dropping the pre-existing row
|
||||
assert repo.fills_for("", limit=10)[0].symbol == "SPY" # pre-existing row defaults to ""
|
||||
|
||||
repo.migrate() # second migrate is a no-op
|
||||
Reference in New Issue
Block a user