Raxx · internal docs

internal · gated

Backtest Engine → Paper-Sim Reuse Audit

Date: 2026-06-11 Author: data-scientist agent Scope: backend_v2/ — backtest engine, MBT fill engine, tax services, strategy templates Purpose: Map what is already built against paper-sim requirements; identify blocking gaps.


1. Codebase orientation

Three distinct systems exist in backend_v2/:

System Entry point Primary purpose
Backtest runner api/services/backtest_runner.py Historical equity-only replay, scripted entry/exit, no user interaction
MBT fill engine api/services/mbt_fill_engine.py In-product paper sim: user-driven orders, persistent DB state, multi-leg support
Paper order service api/services/paper_order_service.py Legacy Alpaca broker-proxy layer (pre-MBT); writes audit rows to paper_orders

The MBT fill engine is the paper-sim core. The backtest runner is a separate historical-replay tool that shares no runtime state with MBT. The paper order service is a thin Alpaca wrapper; it is being superseded by MBT per ADR-0108.


2. Detailed inventory

2a. Fill modeling

Backtest runner (backtest_runner.py lines 128–225): - Uses bar["close"] as the execution price. No bid/ask, no spread. - No slippage model. Zero commissions. - Entry price = close on bar of entry; exit price = close on bar of exit. - Phase 4a equity-only; options backtesting explicitly deferred ("Phase 4b will add options backtesting" — line 414).

MBT fill engine (mbt_fill_engine.py lines 250–292): - Market orders: fill at (high + low) / 2 (bar midpoint). Slippage computed as mid - close (buy) or close - mid (sell) — lines 250–272. - Limit orders: _evaluate_limit checks bar.low <= limit_price (buy) or bar.high >= limit_price (sell) — lines 275–292. Fills at the limit price, not the touch price. Slippage hardcoded to 0 on limit fills (line 1033). - Multi-leg: each leg evaluated independently using market semantics at the most-recent bar midpoint (lines 834–835). - No real bid/ask spread. ADR-0108 §4 spec calls for "buy at ask, sell at bid" for v2; current implementation uses midpoint as the approximation. - No commissions. Commissions are mentioned in mbt-paper-trading-engine.md as future work; they are not wired in either engine.

2b. Position state tracking

Backtest runner (backtest_runner.py lines 143–224): - In-memory only. Single position dict per symbol, per run. No persistence. - FIFO averaging: same-side adds recompute avg_entry_price (lines 1254–1258 in MBT — not in backtest runner; backtest runner uses entry price of the opening bar only). - No multi-position tracking per symbol; only one open position at a time per symbol.

MBT fill engine (mbt_fill_engine.py lines 1132–1289): - Persistent state in Postgres: paper_accounts, paper_positions, paper_orders tables (migration 0022). - _apply_fill_to_position handles: open new, add to same-side (FIFO avg), reduce/close opposing side. Partial close supported. - position_group_id links multi-leg IC legs into a single grouped portfolio row (lines 1166–1178). - get_grouped_portfolio collapses legs by group into combined-P&L IC rows with OCC-symbol-parsed labels (lines 427–495). - Closed positions archived (not deleted) per OQ-4 decision.

2c. Multi-leg order handling (IC, LCC)

Iron condor template (strategy_templates/iron_condor.py): - Compliance validator: checks wing width, min DTE (7), strike ordering, credit min as % of width, same-expiry for all four legs — lines 79–191. - calc_iron_condor_summary: computes spread bid/ask/mid and max-loss per share/contract from per-leg bid/ask quotes — lines 194–264. This is a pricing helper only; it does not execute.

MBT fill engine (mbt_fill_engine.py lines 770–902): - submit_multi_leg: accepts list of legs, validates each, fills each at market midpoint, auto-assigns position_group_id UUID, writes individual paper_orders rows and grouped paper_positions rows, all in a single transaction. - Net credit/debit calculated as sum of sell-leg fill prices minus buy-leg fill prices. - Up to 10 legs supported. Atomic: all commit or all rollback.

LCC tax-lot service (lcc_tax_lot_service.py): - Handles lot selection on assignment events. Integrates with lot_selection_service.py (FIFO/LIFO/HIFO/LowCost/SpecificID). Reads default_tax_lot_method from lcc_strategy_config. Per-trade override path exists.

2d. Assignment-at-expiry, dividends, splits

MBT fill engine (line 783):

Option assignment is out of scope for v1.

Backtest runner: No options, therefore no assignment logic.

ADR-0108 / mbt-paper-trading-engine.md §3 "Ships in v1": lists auto-exercise of expired ITM options and worthless-expiry of OTM options as v1 scope, plus cash dividends (credited on ex-date). These are in the spec but not yet implemented in mbt_fill_engine.py. The process_resting_orders cron job (api/commands/process_resting_orders.py) handles limit-fill scan only; no expiry or dividend sweep exists.

Stock splits: explicitly deferred to v2+ in ADR-0108 §3.

2e. Data source: historical-only vs real-time

Backtest runner (historical_bars_service.py): - EOD daily bars only. DB-first with gap detection + broker fetch + synthetic sine-wave fallback. - No intraday, no tick, no streaming.

MBT fill engine bars provider: - process_resting_orders.py (lines 66–87): uses the same fetch_or_load_bars EOD path as the backtest runner. Comment: "Phase 1 uses EOD bars per ADR-0108 §7 Phase 1 definition. Phase 2 will swap this for the 1-min bar provider from ADR-0110." - The bars_provider parameter is a dependency-injected callable (Callable[[str, datetime, datetime], list[dict]]), so the engine itself is agnostic to bar resolution — but the current injection is EOD. - No WebSocket or streaming bar provider exists in backend_v2/ at this time.

2f. Tax engine integration

Wash-sale service (wash_sale_service.py): pure function, takes raw lot list, returns WashSaleReport. No DB dependency. §1256 exemption correctly excludes index options and futures from wash-sale.

§1256 classifier (section1256_service.py): pure function, OSI-symbol root lookup. Returns 60/40 split flag, mark_to_market, wash_sale_applies.

Lot-selection optimizer (lot_selection_service.py): pure function, FIFO/LIFO/HIFO/LowCost/SpecificID. Holding-period tacking for wash-sale replacements. Commission proration. Retrospective framing per product policy.

LCC lot-selection (lcc_tax_lot_service.py): wires the optimizer to LCC assignment events. Flag-gated (FLAG_LCC_TAX_LOT_INTEGRATION).

Tax holding-period, tax event calendar services: additional pure-function services for §1091/§1222 holding-period classification and calendar event generation.

Integration with MBT: The tax services are currently decoupled from the MBT fill engine. There is no wiring in mbt_fill_engine.py that triggers a wash-sale check or lot-selection call on a fill. The tax services expose REST endpoints (/api/v1/tax/...) and are available to paper sim as analysis-on-demand, but they are not embedded in the fill/close flow.


3. Reuse matrix

Component Backtest has it? Paper-sim needs it? Gap
Market-order fill (midpoint) No Yes — REUSE from MBT MBT has it; backtest uses close
Limit-order fill (price touch) No Yes — REUSE from MBT MBT has it
Multi-leg atomic fill (IC, LCC) No Yes — REUSE from MBT MBT has it
Bid/ask spread fill (buy at ask, sell at bid) No Yes GAP — both engines use midpoint
Option bid/ask quote feed No Yes GAP — no quote provider wired
Commissions / per-leg fees No Yes (realistic sim) GAP — not modeled in either engine
Persistent account state (Postgres) No Yes — REUSE from MBT MBT paper_accounts / paper_positions
Per-user multi-session state No Yes — REUSE from MBT MBT Postgres tables survive restarts
IC position grouping + label No Yes — REUSE from MBT MBT position_group_id + _build_ic_label
Strategy compliance validator (IC rules) No Yes — REUSE from iron_condor.py Exists, not wired to MBT order entry yet
Wash-sale detector No Yes (post-close) — REUSE Pure function; needs wiring trigger
§1256 classifier No Yes (on order entry or tax report) — REUSE Pure function; needs wiring trigger
Lot-selection optimizer (HIFO/FIFO/etc.) No Yes (pre-close) — REUSE Pure function; needs wiring trigger
LCC assignment lot-selection No Yes — REUSE Flag-gated; needs MBT close-event integration
Assignment-at-expiry (auto-exercise ITM) No Yes (v1 spec) GAP — in spec, not implemented
Worthless-expiry (OTM option) No Yes (v1 spec) GAP — in spec, not implemented
Cash dividend crediting No Yes (v1 spec) GAP — in spec, not implemented
EOD daily bar provider Yes (backtest) Yes — REUSE Shared fetch_or_load_bars; 5-min cron wired
Real-time / intraday bar provider No Yes (fills need current price) GAP — ADR-0110 Phase 2 only
NBBO tick (buy at ask, sell at bid) No Yes (for realistic options sim) GAP — no NBBO feed wired
User-driven order entry (REST) No Yes — REUSE MBT routes already exist
Resting-order cron (limit fills) No Yes — REUSE process_resting_orders cron exists (5-min)
Historical-only bars (backtest replay) Yes REUSE Shared service
Equity-curve + stats compute Yes (backtest) Partial — needs MTM on open positions Backtest MTM is simplified (Phase 4b note); MBT equity not MTM'd continuously
OCC symbol parsing No Yes — REUSE from MBT _build_ic_label does OCC parsing; used for grouping
Holding-period tacking (wash-sale) No Yes — REUSE lot_selection_service.py supports tacked_holding_days
Stock splits / mergers No Deferred to v2+ Out of scope per ADR-0108

4. The 5 most-blocking gaps, ordered by criticality

GAP-1 — No real-time / sub-daily bar provider

Why it blocks: The MBT fill engine's process_resting_orders cron uses EOD bars, polled every 5 minutes. A user submitting a market order gets the previous day's close as the effective fill price until fresh bars arrive. For options, EOD close prices are meaningless intraday — premium moves with the underlying tick-by-tick. Without a sub-daily (ideally 1-min) bar feed, fill prices are stale enough to invalidate the simulation for any user trading options intraday.

What needs building: ADR-0110 (mentioned in process_resting_orders.py line 72) apparently designs the 1-min bar provider. That provider needs to be wired into MbtFillEngine.__init__ as the bars_provider parameter. The engine itself is already provider-agnostic; only the injection site in process_resting_orders.py needs to change to switch from EOD to 1-min.

Files: backend_v2/api/commands/process_resting_orders.py lines 66–87, backend_v2/api/services/mbt_fill_engine.py line 306 (contract).


GAP-2 — No options bid/ask quote feed; fill model uses midpoint only

Why it blocks: Options spreads are wide. The MBT fill model currently fills all orders (including options legs) at (high + low) / 2 — bar midpoint of whatever symbol is passed. For equity, this is a reasonable approximation. For options, high and low on a daily bar span the intraday range of the option premium, which can be extremely wide; filling at that midpoint is not realistic. The ADR-0108 spec §4 explicitly calls for "buy at ask, sell at bid" and "options top-of-book" fills. A user entering a $5-wide iron condor expecting a $1.50 credit may see a $2.50 mid fill that is unattainable in practice.

What needs building: A quote provider that supplies per-contract bid/ask (not just OHLC). _fill_market at mbt_fill_engine.py line 250 needs to be updated to use ask (buy) / bid (sell) when bid/ask data is available, falling back to midpoint when only OHLC exists. calc_iron_condor_summary in iron_condor.py already implements the bid/ask spread math (lines 226–260) — reuse that pattern for fill execution.

Files: backend_v2/api/services/mbt_fill_engine.py lines 250–272, backend_v2/api/services/strategy_templates/iron_condor.py lines 194–264.


GAP-3 — Assignment-at-expiry and worthless-expiry not implemented

Why it blocks: Options expire. A user holding a short put into expiry needs to know whether they got assigned (ITM) or the contract expired worthless (OTM). The ADR-0108 spec lists both as v1 scope. Without this, positions in expired contracts remain open in paper_positions indefinitely, the account equity is wrong, and LCC lot-selection logic (which handles the assignment event) never fires.

What needs building: An expiry sweep job (daily, after 17:00 ET = 21:00 UTC) that: 1. Queries paper_positions for option-class positions where OCC expiry date <= today. 2. Fetches final underlying close to determine ITM/OTM. 3. For ITM short options: triggers assignment (buys/sells 100 shares of underlying at strike price, credits/debits cash, closes option position, fires lcc_tax_lot_service for lot selection). 4. For OTM options: closes position at $0, records zero-value fill, realizes loss. 5. Writes activity log entries for each event.

No framework for this sweep exists yet in backend_v2/api/commands/.

Files: Gap in backend_v2/api/commands/ (new file needed), backend_v2/api/services/mbt_fill_engine.py (needs close_expired_options method), backend_v2/api/services/lcc_tax_lot_service.py (already handles assignment lot selection once wired).


GAP-4 — Tax engine not wired into fill / close flow

Why it blocks: The wash-sale detector, §1256 classifier, and lot-selection optimizer are pure-function services accessible via REST endpoints. They are not called from within the MBT fill engine on order submission or position close. This means: - A user can inadvertently create a wash-sale in paper sim and see no flag. - Lot-selection method (FIFO vs HIFO vs SpecificID) is not applied when paper positions are closed; all closes are implicitly FIFO by position order in the DB. - The LCC assignment path (lcc_tax_lot_service.py) is flag-gated but has no event trigger from the fill engine.

For paper sim to function as a realistic practice environment for Kristerpher's workflow (which is tax-aware — holding-period sensitivity, §1256 treatment, wash-sale avoidance), these need to fire automatically on close events, not just when the user navigates to the tax tab.

What needs building: A close-event hook in _apply_fill_to_position (or a post-fill callback pattern) that: 1. On position close: calls run_wash_sale_check against the user's recent lot history. 2. On partial/full close: calls LotSelectionOptimizer.evaluate with the configured method, surfaces the result in the fill response. 3. On option position close: calls Section1256Identifier.classify_full_osi to tag the realized gain with the correct treatment.

Files: backend_v2/api/services/mbt_fill_engine.py lines 1132–1289, backend_v2/api/services/wash_sale_service.py, backend_v2/api/services/lot_selection_service.py, backend_v2/api/services/section1256_service.py.


GAP-5 — Equity-curve MTM uses simplified approximation; no intraday P&L

Why it blocks (lower criticality than 1–4, but still product-blocking): The backtest runner's _build_equity_curve (lines 232–308) uses only entry and exit prices from the trade list — it does not pull daily bar closes for MTM of open positions. Comment at line 275: "Full MTM would require bar prices for each date — see Phase 4b for that." The MBT fill engine's _apply_fill_to_account (lines 1291–1324) updates equity only as a cash-delta approximation: "equity is recalculated from cash + open position market values. (Simple approximation: just cash delta for now; equity mark-to-market happens on next account fetch when bar prices are refreshed.)"

This means a user's displayed equity does not reflect the current value of open options positions, only their entry-price cost. For a short IC that has moved against the user, the account equity overstates by the unrealized loss. The paper_positions.unrealized_pl and current_price columns exist in the schema (migration 0022 lines 112–114) but are not updated by the fill engine — they are nullable/null after initial fill.

What needs building: A position mark-to-market updater — called by the resting-order cron or a dedicated EOD job — that fetches the latest bar price for each open position symbol and updates paper_positions.current_price, market_value, unrealized_pl, and paper_accounts.equity.

Files: backend_v2/api/services/mbt_fill_engine.py lines 1291–1324, backend_v2/alembic/versions/0022_mbt_paper_tables.py (schema already supports it), backend_v2/api/commands/process_resting_orders.py (natural place to add MTM sweep).


5. What is cleanly reusable today

The following components require no modification to be used directly by paper-sim:


6. Scope not verified (UNKNOWN)