Raxx · internal docs

internal · gated

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

Negative / risks

Neutral


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


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.