Raxx · internal docs

internal · gated

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)

Feature-level architectural invariant (hard, this is the whole point)

The Wheel Structure Engine is a user-driven structuring tool, not a recommendation feed.

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

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


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.