Wheel Structure Engine
Status: Design spike — awaiting sub-card filing after operator review
Date: 2026-07-20 UTC
Owner: software-architect
Parent epic: see Refs in PR body
Related ADR: 0144
Related primitives: LCC engine (#3313), Iron Condor builder, options-roll analytics (#3995), strategy library (ADR-0107)
1. Context
A user comes to Raxx already knowing which stock they own — or are genuinely prepared to own — at a lower price. They want Raxx to structure a disciplined income cycle around that input: sell a cash-secured put at a strike they chose, track assignment state, plan the covered-call phase, see the roll ladder, and have earnings-aware manage-by dates enforce the "close before earnings" rule they pre-committed to.
The competitor newsletter that inspired this feature is a forward-looking "what to trade this week" recommendation feed. That posture is incompatible with Raxx's strategic position. The engine described here is its structural inverse.
2. Invariants
Platform invariants (non-negotiable, inherited from auth.md §2)
- No stored credentials. Wheel position rows contain user-authored trade structure data. No broker token, API key, or any replayable credential is stored alongside Wheel data.
- Passkeys / WebAuthn only. Auth is outside this feature's scope; this feature inherits the platform session model.
- Paper-first gating. The Wheel UI and structuring math are available in paper mode. Any live order flow from the Wheel follows the standard paper-first gate or per-flow override (open question §9.5).
- GDPR by default. All
wheel_*rows are user PII: exportable, erasable (30-day cooling), retention-bounded per tier. Cascade deletes onusers.id. - Audit trail for every state change that affects position lifecycle, leg creation, leg close, or abandon.
- Credentials into infra. Market-data API key for earnings-date lookup and options chain access lives in the secret store — same key LCC and IC already use.
Feature-level architectural invariant (hard, this is the whole point)
The Wheel Structure Engine is a user-driven structuring tool, not a recommendation feed.
- The user supplies the underlying symbol. Raxx structures the Wheel around that input.
- There is no market-screening surface. No "15 best setups this week" feed. No ranked buy-list. No predictive scoring of what to trade.
- Raxx never names a ticker to the user unprompted. The user names it first; Raxx structures around it.
- No forward-looking framing. The scenario table describes what would happen under each price outcome using the structure the user defined — it does not forecast which outcome is likely.
- Disclaimer language comes from BLR's parallel output. The UI displays BLR-authored disclaimers; this document owns the architecture, not the legal wording.
This invariant is violated if any implementation adds a ticker-suggestion surface, a "top wheels" leaderboard, a "you might also like" prompt, or any mechanism by which Raxx proposes an underlying symbol to trade. Escalate to the operator if a product card proposes any such surface.
3. Data Model
All tables are new, additive. No existing tables are modified. Schema in migration 0064_wheel_positions.py (Alembic revision 0064, down_revision="0063").
3.1 wheel_positions — top-level cycle container
wheel_positions
id BIGINT PK autoincrement
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
underlying_symbol TEXT NOT NULL -- ticker the user supplied
phase TEXT NOT NULL DEFAULT 'STRUCTURING'
CHECK (phase IN (
'STRUCTURING', -- user is exploring; no open leg
'CSP_OPEN', -- put leg is live
'ASSIGNED_PENDING_CC',-- put assigned; shares held; awaiting CC setup
'CC_OPEN', -- covered call is live
'CYCLE_COMPLETE', -- cycle ended (put expired or shares called away)
'ABANDONED' -- user closed the position early
))
target_contracts INTEGER NOT NULL DEFAULT 1 CHECK (target_contracts >= 1)
notes TEXT NULL -- user's own notes on why they chose this underlying
opened_at TIMESTAMPTZ NOT NULL DEFAULT now()
closed_at TIMESTAMPTZ NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
INDEX (user_id)
INDEX (underlying_symbol)
One Wheel position per user-per-underlying is not enforced at the DB level — a user may run multiple simultaneous Wheel cycles on the same underlying (e.g. different expiry tranches). The UI can warn about overlapping cycles but not prevent them.
3.2 wheel_csp_legs — cash-secured put phase
wheel_csp_legs
id BIGINT PK autoincrement
wheel_position_id BIGINT NOT NULL REFERENCES wheel_positions(id) ON DELETE CASCADE
option_symbol TEXT NULL -- OCC symbol; NULL until user verifies against live chain
strike NUMERIC(10,2) NOT NULL CHECK (strike > 0)
expiry_date DATE NOT NULL
contracts INTEGER NOT NULL DEFAULT 1 CHECK (contracts >= 1)
premium_collected NUMERIC(10,4) NULL -- per-share premium; NULL until filled
underlying_price_at_entry NUMERIC(10,4) NULL -- snapshot for math reference; NOT authoritative
manage_by_date DATE NULL -- computed: MIN(expiry-1, next_earnings-1)
earnings_before_expiry BOOLEAN NULL -- true if earnings date falls between today and expiry
earnings_date_ref DATE NULL -- the earnings date used to compute manage_by_date
opened_at TIMESTAMPTZ NULL -- set when fill is confirmed
closed_at TIMESTAMPTZ NULL
close_reason TEXT NULL
CHECK (close_reason IS NULL OR close_reason IN (
'EXPIRED_WORTHLESS', -- put expired OTM; premium kept
'CLOSED_EARLY', -- user bought back before expiry
'ASSIGNED', -- shares delivered; transition to CC phase
'ROLLED' -- rolled to a new CSP leg
))
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
INDEX (wheel_position_id)
INDEX (expiry_date)
INDEX (manage_by_date)
underlying_price_at_entry is a user-supplied reference snapshot used only for the structuring math display. It is explicitly labeled in the UI as "price at time of structuring — verify against live chain before placing any order." It is never treated as authoritative or real-time.
3.3 wheel_cc_legs — covered call phase (post-assignment)
wheel_cc_legs
id BIGINT PK autoincrement
wheel_position_id BIGINT NOT NULL REFERENCES wheel_positions(id) ON DELETE CASCADE
csp_leg_id BIGINT NULL REFERENCES wheel_csp_legs(id) ON DELETE SET NULL
-- which CSP assignment generated this CC; NULL if opened directly
option_symbol TEXT NULL
strike NUMERIC(10,2) NOT NULL CHECK (strike > 0)
expiry_date DATE NOT NULL
contracts INTEGER NOT NULL DEFAULT 1 CHECK (contracts >= 1)
premium_collected NUMERIC(10,4) NULL
assignment_cost_basis NUMERIC(10,4) NOT NULL
-- CSP strike − CSP premium_collected; the effective share cost
cumulative_premium NUMERIC(10,4) NULL
-- total premium across this cycle: CSP premium + all CC premiums
manage_by_date DATE NULL
earnings_before_expiry BOOLEAN NULL
earnings_date_ref DATE NULL
opened_at TIMESTAMPTZ NULL
closed_at TIMESTAMPTZ NULL
close_reason TEXT NULL
CHECK (close_reason IS NULL OR close_reason IN (
'EXPIRED_WORTHLESS',
'CLOSED_EARLY',
'CALLED_AWAY', -- shares sold at call strike
'ROLLED'
))
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
INDEX (wheel_position_id)
INDEX (csp_leg_id)
INDEX (expiry_date)
3.4 wheel_roll_ladder — roll candidate snapshots (ephemeral, append-only)
wheel_roll_ladder
id BIGINT PK autoincrement
leg_ref_type TEXT NOT NULL CHECK (leg_ref_type IN ('CSP', 'CC'))
csp_leg_id BIGINT NULL REFERENCES wheel_csp_legs(id) ON DELETE CASCADE
cc_leg_id BIGINT NULL REFERENCES wheel_cc_legs(id) ON DELETE CASCADE
candidate_symbol TEXT NOT NULL
candidate_strike NUMERIC(10,2) NOT NULL
candidate_expiry DATE NOT NULL
candidate_dte INTEGER NOT NULL
bid_price NUMERIC(10,4) NOT NULL
ask_price NUMERIC(10,4) NOT NULL
mid_price NUMERIC(10,4) NOT NULL
net_credit_or_debit NUMERIC(10,4) NOT NULL -- positive = credit, negative = debit
pricing_snapshot_note TEXT NOT NULL
DEFAULT 'Snapshot price only. Verify against the live chain before placing any order.'
analyzed_at TIMESTAMPTZ NOT NULL DEFAULT now()
INDEX (csp_leg_id)
INDEX (cc_leg_id)
INDEX (analyzed_at)
Roll ladder rows are snapshots. They are NOT the authoritative source for order placement. The pricing freshness caveat is stored in the row itself so it travels with any export or API response — not just the UI.
4. APIs / Contracts
All routes live under /api/wheel/, session-authenticated (same middleware as /api/mbt/, /api/strategies/). All routes return 404 when FLAG_WHEEL_STRUCTURE_ENGINE is off (hide-don't-gray, per feedback_hide_dont_gray_unavailable_features).
4.1 Position management
POST /api/wheel/positions
body: { underlying_symbol, target_contracts?, notes? }
→ 201 { id, phase, underlying_symbol, ... }
GET /api/wheel/positions
→ 200 [ { id, phase, underlying_symbol, opened_at, ... } ]
paginated, filterable by phase
GET /api/wheel/positions/{id}
→ 200 { position, csp_legs: [...], cc_legs: [...] }
ownership-checked
PATCH /api/wheel/positions/{id}
body: { notes?, phase?: 'ABANDONED' }
→ 200 updated position
phase can only be set to ABANDONED via this route (other transitions are system-driven)
4.2 CSP leg management
POST /api/wheel/positions/{id}/csp-legs
body: { strike, expiry_date, contracts, underlying_price_at_entry? }
→ 201 { leg + computed manage_by_date + earnings_before_expiry }
triggers manage_by_date computation (§5 earnings join)
transitions position phase to CSP_OPEN
PATCH /api/wheel/csp-legs/{leg_id}
body: { premium_collected?, opened_at?, option_symbol? }
→ 200 updated leg
POST /api/wheel/csp-legs/{leg_id}/close
body: { close_reason, closed_at? }
→ 200 updated leg + parent position with updated phase
EXPIRED_WORTHLESS / CLOSED_EARLY → phase = CYCLE_COMPLETE
ASSIGNED → phase = ASSIGNED_PENDING_CC (creates transition event)
ROLLED → expects a new CSP leg to follow in a separate request
4.3 CC leg management
POST /api/wheel/positions/{id}/cc-legs
body: { strike, expiry_date, contracts, assignment_cost_basis }
→ 201 { leg + manage_by_date + earnings_before_expiry }
transitions position phase to CC_OPEN
csp_leg_id is resolved from the last ASSIGNED CSP leg automatically
PATCH /api/wheel/cc-legs/{leg_id}
body: { premium_collected?, opened_at?, option_symbol? }
→ 200 updated leg
POST /api/wheel/cc-legs/{leg_id}/close
body: { close_reason, closed_at? }
→ 200 updated leg + parent position with updated phase
EXPIRED_WORTHLESS / CLOSED_EARLY → phase returns to ASSIGNED_PENDING_CC
CALLED_AWAY → phase = CYCLE_COMPLETE
ROLLED → new CC leg follows
4.4 Structure preview (pure math, no write)
GET /api/wheel/positions/{id}/structure-preview
query: strike, expiry_date, underlying_price, contracts, leg_type=('CSP'|'CC'),
cost_basis? (required when leg_type=CC)
→ 200 {
downside_cushion_pct, -- (underlying_price - strike) / underlying_price
breakeven, -- strike - premium (NULL if premium not yet known)
collateral_required, -- strike * 100 * contracts
annualized_yield, -- (premium / strike) * (365 / dte) [NULL without premium]
upside_to_strike, -- (cc_strike - cost_basis) / cost_basis [CC only]
dte,
manage_by_date, -- from earnings calendar join
earnings_before_expiry,
scenario_table: {
drop_3_5_pct: { outcome, detail },
drop_gt_5_pct: { outcome, detail },
rally: { outcome, detail }
},
pricing_caveat: "Verify the live chain before placing any order."
}
All numeric fields are Decimal-backed strings in the JSON response
(never float) to prevent rounding in transit.
This endpoint performs no writes. It calls wheel_math.compute_structure() and the earnings-calendar lookup. Safe to call repeatedly during structuring exploration.
4.5 Roll candidates
GET /api/wheel/positions/{id}/roll-candidates
query: leg_type=('CSP'|'CC'), leg_id
→ 200 {
candidates: [ { symbol, strike, expiry, dte, mid_price, net_credit_or_debit } ],
analyzed_at,
pricing_caveat: "Snapshot price only. Verify against the live chain before placing any order."
}
BLOCKED on options-roll analytics (#3995) landing (§9.1)
Saves snapshot rows to wheel_roll_ladder for audit.
5. State Machine
5.1 Wheel position phase transitions
stateDiagram-v2
[*] --> STRUCTURING : POST /api/wheel/positions
STRUCTURING --> CSP_OPEN : POST csp-legs
CSP_OPEN --> CYCLE_COMPLETE : close(EXPIRED_WORTHLESS | CLOSED_EARLY)
CSP_OPEN --> CSP_OPEN : close(ROLLED) → new CSP leg created
CSP_OPEN --> ASSIGNED_PENDING_CC : close(ASSIGNED)
ASSIGNED_PENDING_CC --> CC_OPEN : POST cc-legs
CC_OPEN --> ASSIGNED_PENDING_CC : close(EXPIRED_WORTHLESS | CLOSED_EARLY)
CC_OPEN --> CC_OPEN : close(ROLLED) → new CC leg created
CC_OPEN --> CYCLE_COMPLETE : close(CALLED_AWAY)
STRUCTURING --> ABANDONED : PATCH phase=ABANDONED
CSP_OPEN --> ABANDONED : PATCH phase=ABANDONED
ASSIGNED_PENDING_CC --> ABANDONED : PATCH phase=ABANDONED
CC_OPEN --> ABANDONED : PATCH phase=ABANDONED
CYCLE_COMPLETE --> [*]
ABANDONED --> [*]
Illegal transitions (server returns HTTP 409 with error code WHEEL_INVALID_PHASE_TRANSITION):
- Cannot open a CC leg from STRUCTURING or CSP_OPEN (must be ASSIGNED_PENDING_CC)
- Cannot open a CSP leg from CC_OPEN (must close CC first or abandon)
- Cannot set phase to anything other than ABANDONED via the PATCH route (all other transitions are system-driven)
5.2 Earnings join sequence (manage_by_date computation)
sequenceDiagram
participant Client as Antlers / API caller
participant WheelAPI as /api/wheel/positions/{id}/csp-legs (POST)
participant EarningsSvc as EarningsCalendarService
participant MarketData as Options/market-data API (existing)
participant DB as Postgres
Client->>WheelAPI: { strike, expiry_date, contracts, underlying_price_at_entry }
WheelAPI->>EarningsSvc: get_next_earnings(symbol, before=expiry_date)
EarningsSvc->>MarketData: fetch earnings dates for symbol
MarketData-->>EarningsSvc: [ { date, confirmed } ] or []
EarningsSvc-->>WheelAPI: { earnings_date, is_confirmed } or None
alt No earnings before expiry
WheelAPI->>WheelAPI: manage_by_date = expiry_date - 1 day
WheelAPI->>WheelAPI: earnings_before_expiry = False
else Earnings found before expiry
WheelAPI->>WheelAPI: manage_by_date = MIN(expiry_date - 1, earnings_date - 1)
WheelAPI->>WheelAPI: earnings_before_expiry = True
WheelAPI->>WheelAPI: earnings_date_ref = earnings_date
end
WheelAPI->>DB: INSERT wheel_csp_legs (... manage_by_date, earnings_before_expiry, earnings_date_ref)
WheelAPI->>DB: write_audit(wheel.csp_leg.created)
WheelAPI-->>Client: 201 { leg with manage_by_date, earnings_before_expiry }
If the earnings-date lookup fails (market-data API timeout, no data for symbol), the service logs the error, sets manage_by_date = expiry_date - 1, earnings_before_expiry = NULL (unknown), and returns with a warnings array in the response body indicating the earnings date could not be confirmed. The leg is still created. The UI shows "Earnings date could not be confirmed — verify independently before managing this leg."
6. Reuse Map
This section is the explict accounting of what is new versus what composes existing primitives.
| Primitive | Ref | What the Wheel reuses | What is new |
|---|---|---|---|
| LCC engine | #3313 | Position-state machine design pattern (phase enum + CHECK constraint). Tax-lot selection (LotMethod) on CC assignment. Roll-refused alert pattern. |
New tables (wheel_*). CSP phase has no LCC equivalent. phase column is a new enum, not position_state_enum (different lifecycle). |
| Iron Condor builder | options.py | Options chain data layer: same /api/options/chain and /api/options/contracts endpoints for strike selection and roll candidates. |
None. IC's multi-leg math is not used. |
| Options-roll analytics | #3995 | Roll-for-credit evaluation logic. The roll-candidates endpoint delegates to #3995's roll evaluation service once it ships. | wheel_roll_ladder persistence layer (snapshots from #3995 output). |
| Strategy templates | ADR-0107 | covered_call and cash_secured_put compliance templates (collateral_required check, otm_only check) can be reused as a pre-flight validation step when a user submits a CSP or CC leg. |
The Wheel is not a strategy template. It is a stateful position tracker with multi-phase lifecycle that templates cannot represent. |
| Tax + earnings calendar | Data science | "Excluded periods: Within 5 calendar days of confirmed earnings date" rule (docs/data-science/2026-05-05-layered-covered-call-strategy.md §167). | EarningsCalendarService wrapper (new thin service layer over the existing market-data API call). manage_by_date and earnings_before_expiry columns (new). |
| Options chain data source | options.py | The single Alpaca Market Data API key already used by MBT, LCC, and IC. No new data vendor. | None. |
| Unified audit log | ADR-0058 | write_audit() in same DB transaction as every state change. |
New event types: wheel.*. |
| Feature flag system | ADR-0026 | FLAG_WHEEL_STRUCTURE_ENGINE in feature_flags.yaml. |
New flag entry + B1 console migration (sub-card Wheel-2). |
What is genuinely new:
- wheel_positions, wheel_csp_legs, wheel_cc_legs, wheel_roll_ladder tables and all CRUD
- WheelMathService — pure structuring functions (CSP + CC math, scenario table)
- EarningsCalendarService — thin wrapper over market-data earnings endpoint
- Phase state machine with explicit illegal-transition enforcement
- Structure-preview endpoint (pure math, no write, call-safe for exploration)
- Antlers WheelStructurePanel and WheelManagementView components
7. Structuring Math Surface
All math is performed in Decimal, never float. Computed by backend_v2/api/services/wheel_math.py as pure functions with no DB access.
7.1 CSP structuring outputs
Given: underlying_price: Decimal, strike: Decimal, premium: Decimal | None, dte: int, contracts: int
| Output | Formula | Notes |
|---|---|---|
downside_cushion_pct |
(underlying_price - strike) / underlying_price |
Express as %; e.g. 0.0523 → "5.2% cushion" |
breakeven |
strike - premium |
NULL when premium not yet known |
collateral_required |
strike × 100 × contracts |
Always computable from inputs |
annualized_yield |
(premium / strike) × (365 / dte) |
NULL without premium |
scenario_drop_3_5_pct |
assumes underlying_price × 0.965 |
outcome: "Put expires worthless if stock stays above {strike}; premium retained" |
scenario_drop_gt_5_pct |
assumes underlying_price × 0.945 |
outcome: "Assigned at {strike}; adjusted cost basis: {breakeven}" |
scenario_rally |
assumes underlying_price × 1.03 |
outcome: "Put expires worthless; premium retained" |
7.2 CC structuring outputs
Given: cc_strike: Decimal, cc_premium: Decimal | None, cost_basis: Decimal, dte: int, contracts: int
| Output | Formula | Notes |
|---|---|---|
upside_to_strike |
(cc_strike - cost_basis) / cost_basis |
Upside if shares called away |
total_return_if_called |
(cc_strike - cost_basis + cc_premium) / cost_basis |
NULL without premium |
annualized_yield_on_cost |
(cc_premium / cost_basis) × (365 / dte) |
NULL without premium |
scenario_called_away |
cc_strike >= cost_basis |
outcome: "Shares called at {cc_strike}; total return: {total_return_if_called}" |
scenario_not_called |
CC expires OTM | outcome: "Call expires worthless; premium retained; shares held at {cost_basis}" |
7.3 Scenario table design
The scenario table presents three outcomes in structural, non-predictive language. The scenario trigger prices (e.g. "drop 3-5%") are illustrative ranges supplied by the structuring math, not price targets or forecasts. The BLR output governs the exact framing copy.
8. Migrations
8.1 New migration: 0064_wheel_positions.py
down_revision = "0063"(freescout_ticket_cache_crm) — single Alembic head maintained- Creates all four
wheel_*tables in one migration with explicit Postgres GRANTs - Uses
-- POSTGRES-ONLYsentinel (not required for this migration — no native ENUMs; all states use TEXT + CHECK constraints, which are cross-dialect safe) - Rollback
downgrade(): drops all four tables in reverse dependency order
8.2 No modifications to existing tables
LCC tables (lcc_strategy_config, lcc_roll_alerts), strategies, mbt_*, and all other existing tables are untouched by this migration.
8.3 Rollback
alembic downgrade 0063 drops all four wheel_* tables. Because the tables are append-only new (no modifications to existing schema), rollback restores the exact prior state. Wheel positions created between upgrade and rollback are lost; operators must be warned before applying downgrade in prod. No data migration required on re-upgrade (the tables are re-created empty).
9. Rollout Plan
Flag: wheel_structure_engine (default: false)
This flag must be added to feature_flags.yaml and a corresponding B1 console promotion migration (console/migrations/versions/0240_promote_wheel_structure_engine.py) must ship in the same PR as the YAML entry (per feedback_new_flag_needs_b1_migration_same_pr). This constraint is called out explicitly in sub-card Wheel-2.
| Phase | Gate | What's live |
|---|---|---|
| Dark | Migration 0064 applied; flag off | Tables created, no routes accessible |
| Flag-on-staging | wheel_structure_engine = true on staging only |
Full API + Antlers panel accessible; no live trading paths |
| Beta | Operator + selected users; paper mode only | End-to-end paper Wheel cycles; earnings-gate validation; roll ladder (if #3995 landed) |
| GA | wheel_structure_engine = true globally |
All users; paper-first gate enforced for any live order flow |
Kill switch: FLAG_WHEEL_STRUCTURE_ENGINE = false on console flags page returns all Wheel API routes to 404 within 30s (flag cache TTL). No live order flow is initiated by the Wheel engine directly — orders are submitted via existing MBT / broker adapter routes, so the kill switch blocks structuring access without orphaning open orders.
10. Security Considerations
- PII collected:
underlying_symbol(ticker user chose),notes(free text). All inwheel_positions. CSP/CC legs contain strike prices and premium amounts — trading preferences, not financial account data. Moderate sensitivity: export under GDPR SAR; erasable. - Retention period:
wheel_*rows follow user account retention tier (Free 90d, Pro 3yr, Pro+ unlimited). Nightly Celery purge via existingmbt.purge_expiredpattern extended to wheel tables. - Deletion on DSR:
ON DELETE CASCADEonuser_idinwheel_positionspropagates to all child legs. After 30-day cooling (ADR-0003), hard delete.audit_logrows persist with hashedactor_idfor 2 years. - Audit trail:
wheel.position.created,wheel.csp_leg.created,wheel.csp_leg.closed,wheel.cc_leg.created,wheel.cc_leg.closed,wheel.position.abandonedevents written to unified audit log (ADR-0058) in the same DB transaction as each mutation. - Stored credentials: None. No broker credential, API key, or access token is stored in any
wheel_*table. ADR-0002 CI grep coversbackend_v2/wholesale. - Breach notification path:
wheel_*exposure reveals trading preferences (strike choices, cycle notes). Standard GDPR 72-hour notification path (ADR-0003). Not the high-severity credential-exposure path. - Secrets location + rotation: No Wheel-specific secrets. Market-data API key is in the secret store (existing;
ALPACA_MARKET_DATA_KEY). Rotatable without redeploy. - Kill-switch:
FLAG_WHEEL_STRUCTURE_ENGINE = falsedisables all Wheel API endpoints within cache TTL. No live execution path belongs to the Wheel engine — orders flow through MBT / broker adapter. - No predictive output stored:
wheel_roll_ladder.pricing_snapshot_notecarries the disclaimer at the DB row level, not just the UI. Any export of roll ladder data includes the caveat. Scenario table outputs are labelled as structural illustrations, not forecasts.
11. Open Questions (operator decisions required)
These block downstream sub-card claiming until resolved.
OQ-1 — Roll ladder dependency on #3995 (open)
Sub-card Wheel-7 (roll-candidates endpoint) cannot ship until options-roll analytics (#3995) lands on staging. Is #3995 on the near-term sprint radar, or should Wheel ship without roll-ladder initially and add it as a follow-on? If deferred, Wheel-7 is filed but labeled blocked:dependency.
OQ-2 — Earnings calendar source (open)
The intake brief lists "tax + earnings calendar" as an existing primitive to reuse. No dedicated Raptor earnings-calendar API or DB table is visible in the current codebase. The design proposes EarningsCalendarService as a thin wrapper over the existing market-data API (same Alpaca key, same source LCC/IC use). Is this correct, or is there a separate earnings-calendar data source already instrumented elsewhere?
OQ-3 — CC leg relationship to LCC engine (open)
The Wheel's CC phase is structurally similar to a single LCC cycle. Should the Wheel's CC leg create a linked lcc_strategy_config row (making the Wheel an entry path into the LCC engine's position state machine) or own its lifecycle independently? Recommendation: own independently, add a nullable lcc_strategy_config_id FK for future integration without requiring it from day one. Confirm before Wheel-5 is claimed.
OQ-4 — Holdings input source (open) The intake brief defers whether a "watchlist of stocks I already own" input reuses an existing holdings input or is new. The current design accepts the underlying symbol as free text from the user. If an existing holdings/watchlist data source should be integrated at entry, the Antlers sub-cards (Wheel-8, Wheel-9) need a dependency on that surface. Confirm scope before Antlers sub-cards are claimed.
OQ-5 — Paper-first gate posture for Wheel (open) The Wheel is a structuring tool; it does not autonomously execute orders. When a user uses a Wheel-structured put in live trading (via the standard order flow), does the standard paper-first N-cycles gate apply, or does the per-flow override apply given that the user is explicitly directing the structure? Confirm the intended posture before beta.
12. Sub-Card Decomposition
Sub-cards are listed in implementation dependency order. product-manager files them after operator review of this document and resolution of open questions.
| # | Title | Scope (one line) | Labels | Depends on |
|---|---|---|---|---|
| Wheel-1 | Schema migration: wheel_* tables | Alembic 0064 with all four tables, Postgres grants, rollback | area:backend, type:migration |
None |
| Wheel-2 | Feature flag + B1 console migration | wheel_structure_engine flag in feature_flags.yaml + 0240_promote_wheel_structure_engine.py in same PR |
area:backend, area:console, type:feature-flag |
Wheel-1 |
| Wheel-3 | Wheel math service (pure functions) | wheel_math.py: CSP + CC structuring math, scenario table, Decimal-only, fully tested |
area:backend, type:service |
Wheel-1 |
| Wheel-4 | Wheel REST API: position CRUD + structure-preview | All position + CSP-leg routes; structure-preview (read-only math); flag-gated 404 when off | area:backend, type:feature |
Wheel-1, Wheel-2, Wheel-3 |
| Wheel-5 | CC leg API + assignment state transition | CC leg CRUD; ASSIGNED → ASSIGNED_PENDING_CC → CC_OPEN phase transitions; audit events | area:backend, type:feature |
Wheel-4; OQ-3 resolved |
| Wheel-6 | EarningsCalendarService + manage-by-date compute | Thin service wrapper over market-data API; manage_by_date + earnings_before_expiry stored at leg creation; graceful fallback on lookup failure | area:backend, type:service |
Wheel-4; OQ-2 resolved |
| Wheel-7 | Roll-candidates endpoint + wheel_roll_ladder persistence | Delegates to #3995; stores snapshots with pricing caveat | area:backend, type:feature, blocked:dependency |
Wheel-4, #3995 on staging; OQ-1 resolved |
| Wheel-8 | Antlers: Wheel structuring panel (CSP entry + math display) | WheelStructurePanel component: symbol input, strike/expiry, math outputs, manage-by-date, earnings warning, BLR disclaimer | area:frontend, type:feature |
Wheel-4, BLR output; OQ-4 resolved |
| Wheel-9 | Antlers: assignment view + CC management panel | Assignment state card, CC structuring view, roll ladder view (conditionally gated on Wheel-7) | area:frontend, type:feature |
Wheel-5, Wheel-8 |
| Wheel-10 | Audit trail: wheel events to unified audit log | All wheel.* event types wired to write_audit() in same transaction as each mutation; test coverage |
area:backend, type:audit |
Wheel-4, Wheel-5 |
End of doc. See ADR-0144 for the architectural decision record. BLR output governs disclaimer copy; this document owns architecture only.