ADR 0144 — Wheel Structure Engine: own tables + user-driven invariant
Status: Proposed
Date: 2026-07-20 UTC
Deciders: software-architect, operator (Kristerpher)
Scope: Raptor (backend_v2/), Antlers (frontend/raxx-next/); extends existing options strategy surface
Context
A Wheel options-income strategy (cash-secured put → assignment → covered call → repeat) requires a multi-phase lifecycle tracker that does not fit cleanly into any existing data model. The existing strategies table and lcc_strategy_config extension cover single-phase rule storage and long-dated CC income cycles respectively. The Wheel adds a CSP acquisition phase before shares are held and requires explicit state transitions between phases across potentially multiple legs and roll events.
A competing design newsletter delivered this capability as a "what to trade this week" recommendation feed. Raxx's strategic position prohibits that posture (see project_strategic_position, feedback_no_forward_looking_framing). The question this ADR answers is not whether to build a Wheel — it is how to scope and bound it so the architecture enforces the user-driven constraint at the data and API layer, not just the UI layer.
Three meaningful choices required decisions:
- Whether to bolt the Wheel onto existing tables (
strategies,lcc_strategy_config) or give it its own table family. - Whether the Wheel's CC phase should reuse
lcc_strategy_configrows directly. - Where the structuring math should live and what form it should take.
Decision
The Wheel Structure Engine is implemented as a new set of tables (wheel_positions, wheel_csp_legs, wheel_cc_legs, wheel_roll_ladder) within the existing Raptor (backend_v2/) Tier 2 Python service. No new standalone service is introduced. Structuring math is a pure-function service module (wheel_math.py). The CC phase owns its lifecycle data independently from lcc_strategy_config with a nullable FK (lcc_strategy_config_id) reserved for future integration.
The architectural invariant — the user supplies the underlying symbol; the engine structures around that input; no ticker is proposed to the user by the system — is enforced at three layers:
- API layer:
POST /api/wheel/positionsrequiresunderlying_symbolfrom the client. No endpoint accepts a symbol-suggestion request or returns a ranked list of symbols. - Data layer: No column in any
wheel_*table stores a "suggested" or "screened" symbol. All symbol columns are user-supplied inputs or OCC option symbols derived from them. - Flag layer:
FLAG_WHEEL_STRUCTURE_ENGINE = falsereturns 404 on all Wheel routes (hide-don't-gray). A kill switch at the feature-flag level prevents any Wheel surface from being accidentally exposed.
The roll-candidates endpoint (/api/wheel/positions/{id}/roll-candidates) is gated on options-roll analytics (#3995) shipping. It presents candidate strikes from the live chain for a position the user already has open — it does not suggest new positions to open.
Language choice rationale
Service: Wheel Structure Engine
The Wheel Structure Engine is not a new standalone service. It is a feature addition to Raptor, the existing Tier 2 Python service (backend_v2/).
Language tier: Tier 2 — Python (existing service; no new service boundary)
Rationale for this classification: The Wheel is a user-facing structuring and lifecycle-tracking feature. Its computation profile (Decimal arithmetic, DB writes, options chain API calls) is identical to the existing LCC and IC features in Raptor. None of the Tier 1 criteria (C-1 through C-6) in docs/architecture/language-tier-policy.md apply: this is not an auth hot path, not latency-critical (p99 budget is the same as the rest of Raptor), and does not handle cryptographic key material. No Tier 1 promotion is anticipated.
API contract portability (Tier 2 only): The Wheel REST API uses standard JSON request/response shapes compatible with OpenAPI 3.1. A future Tier 1 port (if ever warranted) would require no redesign of the contract. Portability risk: none identified.
Consequences
Positive
- Wheel data is fully owned in Raptor's Postgres schema with standard GDPR cascade, audit trail, and retention tooling. No new operational surface to maintain.
- Structuring math as pure functions makes the computation independently testable without DB fixtures.
- The
EarningsCalendarServicethin wrapper over the existing market-data API means no new data vendor, no new API key, no new secret-store entry. - Pricing freshness caveat is stored at the row level in
wheel_roll_ladder, not just the UI — it travels with any export or API response. - The
FLAG_WHEEL_STRUCTURE_ENGINEkill switch blocks all Wheel structuring access without touching live order flow (orders remain on MBT / broker adapter). lcc_strategy_config_idNULL FK onwheel_cc_legsis forward-compatible with LCC-engine integration without requiring it from day one.
Negative / risks
- Wheel-7 (roll candidates) is blocked on #3995 landing. If #3995 slips, the roll ladder is unavailable at Wheel GA. The design ships without it behind the same feature flag rather than delaying the core structuring surface.
- The
EarningsCalendarServicedepends on the existing market-data API returning earnings dates reliably. If the earnings date is unavailable or unconfirmed,manage_by_datefalls back toexpiry_date - 1with a warning. Users must be told to verify earnings dates independently when this fallback fires. - Two CC lifecycle trackers now exist in the codebase (
lcc_strategy_configfor LCC,wheel_cc_legsfor Wheel). This is intentional but creates a future consolidation obligation if the two are never linked via the nullable FK.
Neutral
- No migration to or from existing tables. Additive-only schema change.
- The strategy-templates
covered_callandcash_secured_putentries (ADR-0107) remain independent. They validate one-leg compliance; the Wheel is a multi-phase lifecycle tracker. The two systems can coexist without conflict.
Alternatives considered
Alternative A: Extend strategies + lcc_strategy_config for the Wheel
Model the Wheel as a special strategy.type = 'wheel' row, with a new wheel_phase column on lcc_strategy_config for the CSP phase.
Rejected because: lcc_strategy_config assumes shares are already held. The CSP phase — where no shares are held yet — has no representation in the LCC model. Forcing it in would add nullable columns with complex conditional logic and make the position_state_enum ambiguous (is NO_POSITION a pre-CSP state or a pre-LCC state?). Separate tables are cleaner and have no migration cost at this stage.
Alternative B: Reuse lcc_strategy_config for the CC phase only; new table for CSP
Model the CSP phase in a new wheel_csp_legs table, then on assignment create an lcc_strategy_config row and hand off CC lifecycle tracking to the LCC engine.
Rejected because: the LCC engine's position_state machine is tightly coupled to the LCC roll-alert and uncovered-shares-guard features. Injecting Wheel-assigned positions into that machine creates cross-feature coupling that makes both harder to reason about and test. The nullable lcc_strategy_config_id FK on wheel_cc_legs preserves optionality for a future integration without forcing it now.
Alternative C: Dedicated "Wheel service" (new Raptor sub-process or sidecar)
Spin the Wheel off as a separately deployable service.
Rejected because: no Tier 1 criterion is met. The Wheel's computation is Decimal arithmetic and DB writes — the same profile as every other Raptor route. A new service would add operational overhead (new dyno, new DB connection pool, new health checks, new CF Access policy) with no benefit. Raptor's existing blueprint pattern handles new route families without a service split.
Security / GDPR checklist
- PII collected:
underlying_symbol(ticker user chose),notes(free text on why they chose it). Both inwheel_positions. CSP/CC legs contain strike prices and premium amounts. Moderate sensitivity — trading preference data, not financial account data. - Retention period:
wheel_*rows follow user account retention tier (Free 90d, Pro 3yr, Pro+ unlimited). Nightly Celery purge via extension ofmbt.purge_expiredpattern. - Deletion on DSR:
ON DELETE CASCADEonuser_id → wheel_positions, propagating to all child legs. 30-day cooling per ADR-0003. - Audit trail:
wheel.position.created,wheel.csp_leg.created,wheel.csp_leg.closed,wheel.cc_leg.created,wheel.cc_leg.closed,wheel.position.abandonedto unified audit log (ADR-0058) in same DB transaction as each mutation. 2-year retention on audit rows. - Stored credentials: None. No broker credential, API key, or replayable credential in any
wheel_*column. ADR-0002 CI grep coversbackend_v2/wholesale. - Breach notification path: Exposure of
wheel_*rows reveals trading preferences (strike choices, underlying symbols, notes). Standard GDPR 72-hour notification (ADR-0003). Not the high-severity credential path. - Secrets location + rotation: No Wheel-specific secrets. Existing
ALPACA_MARKET_DATA_KEYin secret store is the only dependency. Rotatable without redeploy per existing Velvet rotation procedures. - Kill-switch:
FLAG_WHEEL_STRUCTURE_ENGINE = false(console flags page) → all Wheel API routes return 404 within 30s cache TTL. No Wheel-initiated live order flow exists; MBT/broker adapter kill switches are independent.
Revisit when
- Options-roll analytics (#3995) ships — unblock Wheel-7 (roll-candidates) and verify the
wheel_roll_ladderschema fits #3995's output shape without a follow-on migration. - The nullable
lcc_strategy_config_idonwheel_cc_legsis used for the first time — at that point a migration to backfill existing rows and a design decision on CC lifecycle co-ownership should be documented. - The Wheel reaches GA and earnings-calendar lookup failures become frequent — evaluate whether a dedicated earnings-calendar DB table (loaded on schedule) is preferable to on-demand market-data API calls.
- Any product card proposes adding a ticker-suggestion, screening, or ranked-results surface — stop and escalate; that violates the architectural invariant in §Decision.