Raxx · internal docs

internal · gated

Paper Trading v1 — Alpaca-Emulation + Options Reconciliation

Status: Draft — reconciliation + decomposition, awaiting operator decisions on open questions before sub-cards are filed Date: 2026-07-29 UTC Owner: software-architect Parent epic: #3483 — Paper Trading v1 (close MBT engine gaps + ship unified Simulate surface) Cross-cutting with: #4286 — Epic: Wheel Structure Engine (user-driven options-income structuring) Refs: ADR-0108 (fill engine), ADR-0110 (intraday bars), ADR-0138 (idempotency), ADR-0144 (Wheel architecture), mbt-paper-trading-engine.md, paper-trading-v1-decomposition.md, wheel-structure-engine.md Operator directives driving this doc: (A) emulate Alpaca paper-trading semantics so paper→live-Alpaca is a swap not a rewrite; (B) options trading — single + multi-leg, OCC symbols, options positions, options buying power, expiration/assignment — must work, and must be the substrate the Wheel runs on.


1. Context

Epic #3483 shipped 20 cards (SC-1..SC-15, GAP-1..GAP-5) plus PAPER-21/22 (closed) and PAPER-23 (#4186, open). The MBT engine is real: paper_orders / paper_positions / paper_fills / paper_accounts are live Postgres tables, mbt_fill_engine.py (3,055 lines) executes market and limit orders, multi-leg (iron-condor-shaped) orders, resting-order evaluation, day-order expiry, MTM, and an assignment/worthless-expiry sweep at options expiry. This is genuinely built, not stubbed.

Two things are true at once: the engine is further along than a cold read of the epic suggests, and it diverges from Alpaca's paper semantics in ways a real trader would notice immediately, and it does not yet support options as a first-class, user-reachable instrument. This doc is the gap map and the decomposition for closing both, and it resolves one undocumented architectural collision with the in-flight Wheel epic before Wheel-4 gets built on the wrong foundation.

2. Invariants (inherited, no changes)

Same as paper-trading-v1-decomposition.md §2: no stored credentials, paper-first gating, GDPR-by-default retention/erasure on all paper_* and wheel_* tables, audit trail on every state change, credentials in env/SSM only, per-fetch market_data_access_log row for the Alpaca redistribution audit trail, idempotency (ADR-0138) on every money-path route. Two additions this doc introduces:

3. Directive A — Alpaca capability table

Alpaca concept Status Evidence Notes
Market orders DONE mbt_fill_engine.submit_market_order() Buy-at-ask/sell-at-bid per GAP-2.
Limit orders DONE submit_limit_order(), process_resting_orders.py Full-fill only, no partials (documented deferral).
Stop / stop-limit orders DONE (#4360) submit_stop_order(), submit_single_leg_option() (stop/stop_limit branch), _resolve_resting_order_action()/_fill_or_reject_resting_order() in process_resting_orders.py's evaluation loop, migration 0068 (stop_price column + widened CHECKs) Buy-stop triggers on bar.high >= stop_price; sell-stop on bar.low <= stop_price (same touch-based model as limit orders, GAP-2). Plain stop converts to a market fill same-bar; stop_limit transitions to status='triggered' and fills as a resting limit order at limit_price on a later bar. Works for equities and single-leg options (#4354 routing). Integrates with #4359's buying-power check at both submission (worst-case price) and fill time (fill-time re-check, mirrors the resting-limit pattern).
Trailing-stop orders MISSING (by design) Was explicitly deferred in the original design doc; still deferred. Fine to keep deferred — flag it as a known, intentional Alpaca divergence rather than a silent gap.
Time-in-force: day, gtc, ioc DONE _VALID_TIF = {"day", "gtc", "ioc", "fok"}
Time-in-force: fok DONE (#4361) submit_market_order()/submit_limit_order()/submit_single_leg_option() fok branches Fills completely and immediately against the current bar, or cancels outright (reject_reason='fok_unfillable') — never rests. Market/limit, equity and single-leg options. Not identical to IOC: IOC market orders with no bar available still rest as accepted (like DAY/GTC) and IOC is rejected outright for limit orders — a pre-existing, documented limitation that #4361 does not touch or fix (see mbt-paper-trading-engine.md §4).
Time-in-force: opg, cls MISSING (by design) Alpaca-specific auction TIFs; reasonable v1 deferral, not silently dropped — call it out.
Order lifecycle (new→accepted→filled/canceled/rejected/expired) DONE paper_orders.status CHECK constraint, _expire_day_orders() Matches Alpaca's terminal states.
Partial fills MISSING (by design) Documented deferral in both design docs Consistent, intentional.
Reject on insufficient buying power DONE (#4359) MbtInsufficientBuyingPowerError, pre-fill check in every submit_*() path Shipped unflagged (correctness fix). cash_balance >= 0 CHECK added NOT VALID (migration 0067) as a DB-level second line of defense; paper_negative_cash_reconciliation.py sweeps any pre-fix negative rows.
Buying power / Reg-T margin math DONE (#4357) paper_accounts.account_type/buying_power_multiplier/options_buying_power (migration 0065); get_account() returns real initial_margin/maintenance_margin/multiplier ADR-0146 status changed Proposed → Accepted: Reg-T 2:1 (operator decision 2026-07-30). 2.0x multiplier for account_type='margin', 50%/25% Reg-T initial/maintenance margin on open long equity. Margin-call/forced-liquidation enforcement stays out of scope.
Options buying power (CSP reserve, CC collateral) DONE (#4358) paper_collateral_reservations table (migration 0066); MbtFillEngine._reserve_collateral/_release_collateral CSP reserves strike * 100 * contracts; covered call reserves $0 when >=100 shares held; uncovered/naked short calls are rejected outright (no reservation formula defined for that shape in this pass).
PDT / day-trade tracking MISSING (by design, acceptable) No day_trade_count field Single-book-per-user paper sim; PDT modeling adds no real value pre-live-cutover. Keep deferred, note it explicitly so nobody expects it to gate anything.
Account object shape (GET /account) DONE (#4366) mbt_fill_engine.get_account() Full Alpaca-parity field set now returned: buying_power, options_buying_power, initial_margin, maintenance_margin, multiplier, long_market_value, short_market_value (aggregated from open paper_positions.market_value by side, short_market_value negated per Alpaca convention), plus collateral_reserved (additive, not an Alpaca field — surfaces the reservation-sum figure for the Simulate tile's "$X reserved" line item per ADR-0146 §Positive). daytrade_count/pattern_day_trader remain explicit stubs (#4357). Rendered on the Simulate account/positions tile.
Positions object shape DONE (equities), PARTIAL (options) get_open_positions() avg_entry_price, qty (signed), market_value, unrealized_pl, current_price all present via GAP-5 MTM sweep. Options carry the 100x multiplier correctly in the P&L calc (mbt_fill_engine.py:2109-2114) but — see §4 — not in the account cash-settlement path, which is the actual bug.
Orders/positions REST shape DONE (matches Alpaca-flavored contours) /api/trading/{account,positions,portfolio,orders} Envelope differs from Alpaca's raw array responses ({"data":...,"meta":...} per original design doc, though several routes today return bare arrays/objects — worth a consistency pass but not user-visible risk).
Corporate actions: dividends DONE SC-10, paper_dividend_adjustment.py
Corporate actions: stock splits PARTIAL (dividend-only shipped) paper_stock_split_adjustment.py exists as a file but SC-10's shipped scope was "dividend only... split blocked on corporate actions feed" per decomposition doc Verify before claiming DONE; treat as PARTIAL pending confirmation the split command is wired to a live cron, not just present as a file.
Real-time quote in synchronous fill path PARTIAL — tracked PAPER-23 / #4186, still OPEN EOD-bar midpoint fallback in the synchronous market-order path remains a known, already-carded gap. Not re-cutting; folding into rollout gate below.

Where Alpaca-familiar users got surprised before #4357/#4358/#4359/#4360/#4361 (now fixed): ~~(1) you can never get a buying-power rejection no matter how large the order~~ — fixed by #4359; ~~(2) selling options collects premium but reserves nothing~~ — fixed by #4358; ~~(3) stop orders silently 400 with "order_type not supported" rather than being a visible order-type option~~ — fixed by #4360 (stop/stop-limit) and #4361 (FOK); ~~(4) account_type (cash/margin) exists as a column but does nothing~~ — fixed by #4357, margin accounts now get 2:1 buying power and real Reg-T margin fields.

4. Directive B — options gap

OCC symbol handling. A regex parser exists but is buried and single-purpose (_build_ic_label's private _OCC_RE, mbt_fill_engine.py:194-196) — it decodes strike/expiry/type for IC labeling only. There is no shared, testable OccSymbol parse/validate/format utility, and no validation at order-submit time that a symbol claimed as asset_class="option" is actually well-formed OCC. MISSING as a first-class utility.

Single-leg option order submission — MISSING, not PARTIAL. This is the sharpest finding in this reconciliation. POST /api/trading/orders without a legs array always calls submit_market_order() / submit_limit_order(), and both hardcode asset_class = 'us_equity' in their INSERT statements (mbt_fill_engine.py market-order INSERTs, both the no-bar and filled branches). There is no code path today where a user submits one option contract as a normal order and gets asset_class='option' treatment (100x MTM multiplier, OCC-aware assignment eligibility). The only way to create an asset_class='option' row is through submit_multi_leg() with a one-element legs array — an accidental side door with zero test coverage for the single-leg case (all asset_class="option" test fixtures use 2- or 4-leg arrays). Directive B explicitly requires single-leg options to work; today it does not, through any documented or tested path.

Options cash-settlement multiplier bug. _apply_fill_to_account() computes trade_value = qty * fill_price with no options multiplier, for every fill regardless of asset_class. Compare to the P&L formula two call sites away (unrealized_pl) which correctly applies multiplier = Decimal("100") if is_option else Decimal("1"). Net effect: selling 1 CSP contract at $2.00 premium credits the account $2, not the real-money-equivalent $200. This is a correctness bug, not a design gap, and it compounds with the missing single-leg path (§ above) and the missing options buying-power check (§3) — together they mean options economics in the current engine are simply wrong whenever they're reachable at all.

Options chain data. AlpacaMarketDataService (alpaca_market_data_service.py) has get_latest_quote, get_bars, get_corporate_actions — no chain, no options quote, no Greeks. MISSING entirely. Alpaca's options data is a separate, separately-entitled API (OPRA-sourced) from the equities feed the epic already licensed; this needs its own TOS/entitlement check — do not assume ALPACA_MARKET_DATA_KEY covers it. Operator decision needed — see Open Questions.

Multi-leg beyond IC. submit_multi_leg() is genuinely generic (1-10 legs, any asset_class mix, market-fill only) — verticals, straddles, strangles all work mechanically today; only the labeling helper (_build_ic_label) is IC-specific and gracefully falls back to a generic label for other shapes. DONE at the engine level, MISSING at the UI level (no order-entry surface constructs a multi-leg payload today — legs arrays are produced programmatically by IC builder / LCC roll, never hand-built by a user).

Expiration + assignment lifecycle. close_expired_options() (GAP-3) is real: parses OCC, determines ITM/OTM, assigns ITM short calls/long puts into a ±100-share equity position at strike, writes paper_fills + audit_log rows, expires OTM worthless. DONE for the execution side. But — critical cross-cutting finding below — it has no linkage to the Wheel epic's parallel lifecycle tracker.

4.1 The Wheel collision (must resolve before Wheel-4)

wheel_positions / wheel_csp_legs / wheel_cc_legs (ADR-0144, Wheel-1..3, closed) are a separate, manually-driven bookkeeping schema with their own phase state machine (STRUCTURING → CSP_OPEN → ASSIGNED_PENDING_CC → CC_OPEN → CYCLE_COMPLETE). Nothing in the Wheel schema, wheel_math.py, or ADR-0144 references paper_orders, paper_positions, or mbt_fill_engine. The Wheel API sketch (wheel-structure-engine.md §4) takes {strike, expiry_date, contracts, ...} as a data-entry POST, with premium_collected staying NULL "until filled" — filled by what mechanism is never specified, because no execution call exists in the design.

Operator directive B is explicit: "options paper trading is the substrate the Wheel runs on... design the assignment/expiration lifecycle to serve both." Today it serves neither jointly — Wheel would ship as a journal a user fills in by hand, disconnected from the actual paper engine that already knows how to fill options orders and simulate assignment. Left alone, this produces two independently-wrong option books for the same user. This must be resolved architecturally before Wheel-4 (CSP leg API) is built, or Wheel-4 gets built against the wrong contract and someone has to redo it. Decision + bridge design in ADR-0147 (§6 below). Wheel-1/2/3 (schema, flag, math) are unaffected — the bridge is additive (one nullable FK), not a schema rewrite.

5. Data model deltas

-- paper_accounts: buying-power + margin fields (Alpaca account-object parity)
ALTER TABLE paper_accounts ADD COLUMN buying_power_multiplier NUMERIC(3,1) NOT NULL DEFAULT 1.0;
  -- 1.0 for cash accounts, 2.0 for margin accounts (Reg T) — see [ADR-0146](https://internal-docs.raxx.app/architecture/adr/0146-options-buying-power-and-margin-model.html) for whether v1 ships margin at all
ALTER TABLE paper_accounts ADD COLUMN options_buying_power NUMERIC(14,2) NOT NULL DEFAULT 0;
  -- cash minus sum of open collateral reservations; derived column, recomputed on every fill + MTM sweep
ALTER TABLE paper_accounts ADD CONSTRAINT paper_accounts_cash_non_negative CHECK (cash_balance >= 0) NOT VALID;
  -- NOT VALID initially: existing rows may already be negative from the pre-fix bug; validate in a follow-up
  -- migration after a one-time reconciliation sweep (see Migrations §7)

-- new: per-position collateral reservation, so a CSP/CC hold is visible and reversible on close
CREATE TABLE paper_collateral_reservations (
  id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  account_id BIGINT NOT NULL REFERENCES paper_accounts(user_id) ON DELETE CASCADE,
  position_id BIGINT NOT NULL REFERENCES paper_positions(id) ON DELETE CASCADE,
  reservation_type TEXT NOT NULL CHECK (reservation_type IN ('cash_secured_put', 'covered_call_shares')),
  reserved_amount NUMERIC(14,2) NOT NULL,   -- cash reserved (CSP) or 0 for share-collateralized CC (tracked by position linkage instead)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  released_at TIMESTAMPTZ NULL
);

-- bridge: link a paper_orders / paper_positions row to the Wheel leg that requested it (nullable, additive)
ALTER TABLE paper_orders ADD COLUMN wheel_leg_id BIGINT NULL;
ALTER TABLE paper_positions ADD COLUMN wheel_leg_id BIGINT NULL;
  -- FK intentionally omitted at DB level (wheel_csp_legs / wheel_cc_legs share the ID space via a
  -- discriminated wheel_leg_type column instead of one physical FK target) — see [ADR-0147](https://internal-docs.raxx.app/architecture/adr/0147-wheel-mbt-options-lifecycle-bridge.html) §Data model
ALTER TABLE paper_orders ADD COLUMN wheel_leg_type TEXT NULL CHECK (wheel_leg_type IN ('csp', 'cc'));
ALTER TABLE paper_positions ADD COLUMN wheel_leg_type TEXT NULL CHECK (wheel_leg_type IN ('csp', 'cc'));

All migrations require the -- POSTGRES-ONLY sentinel (CHECK constraints via NOT VALID are PL/pgSQL-adjacent) and a real-Postgres smoke test per project policy. The cash_balance >= 0 constraint ships NOT VALID deliberately — flipping it to VALIDATE CONSTRAINT is a separate, later migration gated on confirming no live accounts are already negative from the pre-fix window.

6. APIs / contracts (deltas)

Method Path Change
POST /api/trading/orders Add OCC-symbol auto-detection: if symbol parses as valid OCC, route to a true single-leg options path (asset_class='option', 100x multiplier) instead of silently mislabeling as equity. Add order_type values stop, stop_limit. Add time_in_force=fok.
POST /api/trading/orders (all types) Add pre-fill buying-power check: reject 400 {"reject_reason": "insufficient_buying_power"} before any DB write when trade cost (equity) or collateral requirement (options) exceeds options_buying_power.
GET /api/trading/account Add long_market_value, short_market_value, initial_margin, maintenance_margin, options_buying_power, multiplier, daytrade_count (stub 0), pattern_day_trader (stub false).
POST /api/wheel/positions/{id}/csp-legs (Wheel-4, not yet built) Must call MbtFillEngine.submit_single_leg_option() (new method, §4) rather than pure INSERT — premium_collected becomes the real fill price from the engine, not a manually-typed field.
POST /api/wheel/positions/{id}/cc-legs (Wheel-5, not yet built) Same bridge pattern for the CC leg.
close_expired_options() On assignment/expiry of a position carrying wheel_leg_id, additionally fire a Wheel phase-transition call (WheelPositionService.on_option_lifecycle_event(...)) so wheel_positions.phase advances automatically instead of requiring manual user confirmation.

7. State machine — unified option lifecycle (serves both direct paper orders and Wheel)

stateDiagram-v2
    [*] --> new: submit_single_leg_option() / submit_multi_leg()
    new --> rejected: insufficient buying power / invalid OCC
    new --> filled: bar available, fill computed
    new --> accepted: no bar yet (resting)
    accepted --> filled: resting-order cron fills
    accepted --> expired: DAY TIF, session close
    filled --> open_position: paper_positions row (+ wheel_leg_id if Wheel-originated)
    open_position --> assigned: close_expired_options(), ITM at expiry
    open_position --> expired_worthless: close_expired_options(), OTM at expiry
    open_position --> closed_early: user-submitted closing order
    assigned --> equity_position: plus-or-minus 100 shares per contract at strike
    equity_position --> [*]: user manages normally (wheel_leg_id set moves phase to ASSIGNED_PENDING_CC)
    expired_worthless --> [*]: wheel_leg_id set moves phase to CYCLE_COMPLETE
    closed_early --> [*]: wheel_leg_id set moves phase to ABANDONED or CYCLE_COMPLETE

The bridge is a single call site in close_expired_options() and in the existing manual-close path — not a rewrite of either system. Wheel keeps its own phase enum for UI narrative; MBT keeps owning the actual money/position truth.

8. Migrations

  1. cash_balance >= 0 CHECK ships NOT VALID. Before validating, run a one-time reconciliation command (paper_negative_cash_reconciliation.py, new) that zeroes any pre-fix negative balances on paper (not real-money) accounts and logs an audit_log row per account touched — this is paper data, so a reset-to-zero is acceptable and disclosed via the existing reset-cooldown UX pattern, not a silent adjustment.
  2. paper_collateral_reservations — additive table, no backfill needed (no historical options positions had reservations; nothing to reconcile).
  3. wheel_leg_id / wheel_leg_type columns on paper_orders/paper_positions — additive, nullable, NOT VALID-free (plain ALTER TABLE ADD COLUMN).
  4. Rollback for all three: standard op.drop_column / op.drop_table / op.drop_constraint. No PL/pgSQL blocks beyond the NOT VALID CHECK, which itself needs the -- POSTGRES-ONLY sentinel.

9. Rollout plan

Everything in this doc ships behind the existing FLAG_PAPER_TRADING_V1 (already dark; B1 migration 0195_promote_paper_trading_v1.py already exists) plus a new sub-flag FLAG_OPTIONS_PAPER_TRADING (default off) so equities-only paper trading can go to beta without waiting on the full options build — dark → shadow (internal dogfood, options chain data cost/entitlement resolved) → beta (Wheel-4/5 land behind the same sub-flag) → GA. The buying-power-check fix (§3, §6) is NOT flag-gated — it's a correctness fix that should ship as soon as it's built regardless of FLAG_PAPER_TRADING_V1 state, since the flag is already off in prod and this is dark-period hardening.

10. Security considerations

11. Open questions (blocking sub-card filing until resolved)

  1. Alpaca options-data entitlement. Is ALPACA_MARKET_DATA_KEY's current tier options-inclusive, or does options chain/quote data require a separate paid entitlement and a second TOS/redistribution review (mirroring the equities redistribution flag already open on #3483)? This blocks the chain-data client card and should route through the same BLR path as the existing equities redistribution question — do not assume it's free or already covered.
  2. Margin account fidelity. Ship real Reg-T 2:1 margin math for account_type='margin' in this pass, or keep v1 paper accounts cash-only (buying_power_multiplier always 1.0) and defer margin entirely? The original design doc promised Reg-T margin; the code never built it. Building it now is meaningfully more engine work than the CSP/CC collateral fix alone — worth an explicit go/no-go rather than silently inheriting the old doc's scope.
  3. Wheel execution bridge, confirmed? This doc proposes Wheel-4/5 submit real MBT paper orders (via the new submit_single_leg_option()) rather than being a manual data-entry journal. This is a real product-shape change from what ADR-0144/wheel-structure-engine.md currently implies (manual premium_collected entry). Confirm before Wheel-4 is filed/built — reversing this after Wheel-4 ships is expensive; deciding now is free.
  4. ~~Stop/stop-limit/FOK — build now or re-scope out of v1 explicitly?~~ Resolved: built now — operator decision 2026-07-30 (#4360/#4361, this pass). mbt-paper-trading-engine.md §3/§4 updated to mark them shipped.

12. Ordered sub-card list

See final response for the full table with agent assignment, UI-bearing flags, and dependency order. ADR-0146 (options buying-power + margin fidelity model) and ADR-0147 (Wheel/MBT single-lifecycle bridge) are the two load-bearing decision records for this pass.


End of doc. See ADR-0146 for the buying-power/margin decision and ADR-0147 for the Wheel/MBT bridge decision.