ADR 0147 — Wheel Structure Engine executes through MBT, not a parallel ledger
Status: Accepted (operator decision 2026-07-30 — recorded on epic #3483)
Date: 2026-07-29 UTC
Deciders: software-architect, operator (Kristerpher) — confirmation required before Wheel-4 is filed/built
Scope: Raptor (backend_v2/) — mbt_fill_engine.py, new WheelPositionService bridge methods, wheel_csp_legs/wheel_cc_legs consumers
Context
ADR-0144 designed the Wheel Structure Engine as its own table family (wheel_positions, wheel_csp_legs, wheel_cc_legs) with a manually-entered premium_collected field and a phase state machine advanced by API calls the design doc does not tie to any execution mechanism. Independently, epic #3483 built a real options paper-execution engine (mbt_fill_engine.py) that already fills single- and multi-leg option orders and already simulates ITM assignment / OTM worthless-expiry (close_expired_options(), GAP-3) with real paper_fills and paper_positions rows. These two systems do not reference each other anywhere in code or design docs.
Operator directive B states options paper trading is "the substrate the Wheel runs on" and requires the assignment/expiration lifecycle to "serve both, not two." Left as designed, a user would have two disconnected books: a Wheel journal they fill in by hand (with no real fill price, no real assignment simulation, no real buying-power interaction) and — if they separately use the options order-entry surface — a real MBT position that the Wheel UI knows nothing about. This is exactly the kind of double-bookkeeping risk the "audit trail for every state change" invariant exists to prevent: two sources of truth for the same money-adjacent state is a design bug, not a minor inconsistency, and it gets more expensive to unwind the longer Wheel-4/5 build against the current (execution-disconnected) contract.
Decision
Wheel CSP and CC legs are created by submitting a real MBT paper order, not by a bare data-entry INSERT. POST /api/wheel/positions/{id}/csp-legs and the equivalent CC endpoint call a new MbtFillEngine.submit_single_leg_option() method (itself required by directive B independent of Wheel — see the reconciliation doc §4) with wheel_leg_id / wheel_leg_type set on the resulting paper_orders / paper_positions rows. The engine's real fill price becomes wheel_csp_legs.premium_collected — it is never a manually-typed field. wheel_positions.phase remains the Wheel UI's narrative state machine, but it is now driven by the MBT engine's lifecycle events rather than by user-initiated "mark as assigned" actions: close_expired_options() and the manual-close path each check for a non-null wheel_leg_id on the position they're closing and, if present, call WheelPositionService.on_option_lifecycle_event(wheel_leg_id, event_type, ...) to advance phase (e.g. ITM short-put assignment → ASSIGNED_PENDING_CC; OTM expiry → CYCLE_COMPLETE).
wheel_leg_id is deliberately not a DB-level foreign key on paper_orders/paper_positions — it's paired with a wheel_leg_type discriminator (csp/cc) because the two Wheel leg tables (wheel_csp_legs, wheel_cc_legs) don't share a physical ID space and a single FK can't target either. The bridge does application-level lookups keyed on (wheel_leg_id, wheel_leg_type); both leg tables already have wheel_position_id for the reverse direction, so Wheel's own queries are unaffected.
Consequences
Positive
- Exactly one system computes fill price, collateral, assignment, and P&L for every option position on the platform, whether it originated from raw order entry or from a Wheel leg. The invariant "audit trail for every state change that affects money" now genuinely covers Wheel, instead of covering a hand-entered shadow of it.
- Wheel automatically inherits every Alpaca-emulation fix in the reconciliation doc — buying-power checks, the options-multiplier cash-settlement fix, OCC validation — for free, because it goes through the same
submit_single_leg_option()path rather than needing its own copy of that logic. manage_by_date/earnings_before_expiry(Wheel's differentiated value, per ADR-0144) are untouched — this bridge only changes how a leg gets its fill and lifecycle events, not the earnings-aware structuring logic that makes Wheel useful.
Negative / risks
- This is a real, if small, redesign of the Wheel-4/5 API contract relative to what
wheel-structure-engine.md§4 currently documents ({strike, expiry_date, contracts, underlying_price_at_entry}as a pure data POST). Anyone reading that doc in isolation would build the wrong thing;wheel-structure-engine.mdneeds a cross-reference addition pointing here once this ADR is accepted. - Coupling Wheel's leg creation to
FLAG_PAPER_TRADING_V1/FLAG_OPTIONS_PAPER_TRADING(the engine's flags) means Wheel's own rollout can no longer be fully independent of paper-trading's rollout state — Wheel-4/5 effectively require both flags on. This needs to be reflected in Wheel's rollout plan, not just MBT's. - If a user's Wheel-originated option position is closed through the raw order-entry surface (not the Wheel UI) — e.g. they close it from the Simulate positions table — the bridge still fires correctly (the
wheel_leg_idcheck is on the position, not on which UI initiated the close), but the Wheel UI needs to poll/refresh to reflect the phase change; this is a UI freshness concern for Wheel-8/9, not an engine gap.
Neutral
paper_collateral_reservations(ADR-0146) applies identically to Wheel-originated CSP/CC legs — no Wheel-specific collateral logic needed.
Alternatives considered
Alternative A — Keep Wheel as a manual journal; sync one-way from MBT
Let users hand-enter Wheel legs as originally designed, and have a background reconciliation job try to match them against real paper_positions by symbol/strike/expiry heuristically. Rejected: heuristic matching is fragile (ambiguous when a user has multiple similar positions), doesn't give a real fill price at entry (defeats the point of "paper trading" being realistic), and still leaves two sources of truth that can drift — it treats the symptom, not the cause.
Alternative B — Wheel becomes a view layer with no distinct tables
Drop wheel_csp_legs/wheel_cc_legs entirely; compute Wheel's phase and manage-by-date purely from paper_positions plus a lightweight tag. Rejected: Wheel's earnings-aware manage_by_date / earnings_before_expiry computation (ADR-0144's actual differentiated value) is naturally leg-scoped data computed at structuring time, before a position necessarily exists yet (STRUCTURING phase has no open leg by design). Collapsing the tables would lose the ability to represent "user is planning a CSP but hasn't submitted it" — a real, intentional Wheel state that has no MBT equivalent.
Security / GDPR checklist
- PII collected: None beyond existing
wheel_*/paper_*financial data. - Retention period: Unchanged —
wheel_*tables keep ADR-0144's retention;paper_*tables keep their existing tier-based retention. Thewheel_leg_idlinkage does not extend either table's retention window. - Deletion on DSR: No change — both table families already CASCADE DELETE on
users.idindependently; the linkage is an ID reference, not a new cross-table dependency that blocks either delete path. - Audit trail: New
audit_logactions:wheel_leg_submitted_to_mbt,wheel_phase_advanced_by_mbt_event— both additive to the existing per-fill / per-assignment audit rows this ADR routes through. - Stored credentials: None.
- Breach notification path: No new sensitive-data category; standard ADR-0003 path.
- Secrets location + rotation: N/A.
- Kill-switch: Inherits both
FLAG_PAPER_TRADING_V1/FLAG_OPTIONS_PAPER_TRADING(MBT side) andFLAG_WHEEL_STRUCTURE_ENGINE(Wheel side, per ADR-0144) — both must be on for Wheel-4/5 to function, which is intentional per the Negative/risks note above.
Revisit when
Live-Alpaca cutover design (post-v1) needs to decide whether Wheel legs submit to the live broker through the same bridge pattern — this ADR's submit_single_leg_option() / wheel_leg_id shape should be checked for live-mode portability at that time, not redesigned from scratch.