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:
- MBT fill engine —
submit_market_order,submit_limit_order,submit_multi_leg,cancel_order,get_account,get_open_positions,get_grouped_portfolio,get_orders,reset_account,ensure_account. All ship, all tested. - Iron condor compliance validator —
validate_iron_condoriniron_condor.py. Pure function. - IC summary calculator —
calc_iron_condor_summary. Pure function. - Wash-sale detector —
run_wash_sale_check. Pure function, REST-accessible. - §1256 classifier —
Section1256Identifier.classify/classify_full_osi. Pure function. - Lot-selection optimizer —
LotSelectionOptimizer.evaluate. Pure function. - LCC lot-selection service —
lcc_tax_lot_service.py. Flag-gated but ready. - Tax holding-period service —
tax_holding_period_service.py(not audited in detail but referenced by lot-selection). - Tax event calendar —
tax_event_calendar_service.py(exposes wash-sale window expiry events, useful for real-time paper display). - Paper account DB schema —
paper_accounts,paper_positions,paper_orderstables (migration 0022). Includesposition_group_idfor IC grouping andasset_classfor options. - Resting-order cron —
process_resting_orders.py. Operational, 5-min cadence. - Strategy template registry —
strategy_templates/registry.py. All four templates (IC, vertical credit spread, CSP, covered call) registered. - OCC symbol parsing —
_build_ic_labelinmbt_fill_engine.pylines 156–243. Parses full OCC symbols to extract underlying, expiry, type, strike. Reusable for expiry sweep and §1256 classification.
6. Scope not verified (UNKNOWN)
- Whether
[ADR-0110](https://internal-docs.raxx.app/architecture/adr/0110-mbt-intraday-bar-feed.html)(1-min bar provider) has any implementation beyond the architecture doc reference inprocess_resting_orders.py. - Whether there is a GTC order expiry sweep (TIF enforcement for resting limit orders that survive past their day TIF).
- Whether
paper_positions.unrealized_plis updated anywhere outside of the fill path (e.g., a separate price-refresh job). - The
order_router.pyservice referenced in the file listing — its role relative to MBT vs. broker-proxy was not audited.