Raxx · internal docs

internal · gated

In-House Paper Trading — Alpaca Data API Simulation

Status: Proposed Date: 2026-06-11 UTC Owner: software-architect Related ADRs: ADR-0002 (no stored credentials), ADR-0003 (GDPR), ADR-0013 (MBT decision), ADR-0014 (Alpaca scope reframe), ADR-0108 (MBT engine design), ADR-0110 (intraday bar feed) Supersedes: nothing — extends the ADR-0108 spec with tax-engine integration, multi-leg wiring, and daily batch design


1. Context

ADR-0108 established MBT as Raptor's native paper-trading engine and the fill model in detail. That ADR left four areas underspecified that block feature-developer from starting:

  1. How simulated fills feed the existing tax engine (wash-sale, §1256, lot-selection, holding-period tables that landed in migrations 0146–0157).
  2. How multi-leg structures (IC builder, LCC roll engine) reach the paper submission path.
  3. What the daily batch job set looks like for corporate actions, dividends, and end-of-day tax recalc.
  4. The complete sub-card breakdown for a "Paper Trading v1" epic.

This doc resolves those four areas. The Alpaca paper brokerage TOS conflict that forced the original MBT design (shared SaaS paper accounts violate multi-user terms) does not affect the Alpaca Data API — that API is a market data product, not a brokerage product. BLR is confirming the redistribution clause in parallel; all paper trading traffic remains behind FLAG_PAPER_TRADING_V1=off until BLR clears.

2. Invariants

All invariants from ADR-0108 apply without exception:

3. Architecture in One Paragraph

User clicks "trade" in Antlers → Raptor receives order intent at POST /api/trading/orderscheck_trade_compliance runs (engine-agnostic, from ADR-0107) → mbt_fill_engine.fill() evaluates the order against the latest Alpaca Data API quote (bid/ask for equities, mid for options legs) → fill result is persisted to paper_orders and paper_fills → a post-fill hook calls the tax engine: lot assignment writes to tax_lots, wash-sale detection updates wash_sale_lots, holding-period classifier updates holding_period_classifications, and §1256 tagger marks any options that qualify → the paper portfolio view in Antlers reflects updated positions, P&L, and tax metadata. All of this is synchronous on the order-submit path for market orders; limit orders use the bar-tick polling loop described in ADR-0108 §5.

4. Data Model

New Tables

Three tables are new. All use TIMESTAMPTZ for timestamps and NUMERIC(18,6) for money (no FLOAT). All cascade-delete on users.id.

paper_positions

-- POSTGRES-ONLY
CREATE TABLE paper_positions (
    id               BIGSERIAL PRIMARY KEY,
    user_id          BIGINT        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    symbol           TEXT          NOT NULL,
    side             TEXT          NOT NULL CHECK (side IN ('long', 'short')),
    qty              NUMERIC(18,6) NOT NULL,
    avg_cost         NUMERIC(18,6) NOT NULL,
    opened_at        TIMESTAMPTZ   NOT NULL DEFAULT now(),
    closed_at        TIMESTAMPTZ,
    last_fill_id     BIGINT,       -- FK added after paper_fills exists
    asset_class      TEXT          NOT NULL DEFAULT 'us_equity'
                     CHECK (asset_class IN ('us_equity', 'option')),
    wash_sale_lot_id BIGINT        REFERENCES wash_sale_lots(id) ON DELETE SET NULL
);
CREATE INDEX pp_user_open   ON paper_positions(user_id) WHERE closed_at IS NULL;
CREATE INDEX pp_user_symbol ON paper_positions(user_id, symbol);

wash_sale_lot_id is NULL for positions not yet subject to a wash-sale flag; populated by the wash-sale detector on the post-fill hook.

paper_orders

Extends the schema defined in ADR-0108 §3. The migration that ships this table renames the existing paper_orders (migration 0002) to broker_paper_orders in the same Alembic revision. No data loss.

-- POSTGRES-ONLY
CREATE TABLE paper_orders (
    id                 BIGSERIAL PRIMARY KEY,
    user_id            BIGINT        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    symbol             TEXT          NOT NULL,
    side               TEXT          NOT NULL CHECK (side IN ('buy', 'sell')),
    qty                NUMERIC(18,6) NOT NULL CHECK (qty > 0),
    order_type         TEXT          NOT NULL
                       CHECK (order_type IN ('market', 'limit')),
    time_in_force      TEXT          NOT NULL DEFAULT 'day'
                       CHECK (time_in_force IN ('day', 'gtc', 'ioc')),
    limit_price        NUMERIC(18,6),
    status             TEXT          NOT NULL DEFAULT 'new'
                       CHECK (status IN
                           ('new', 'accepted', 'filled', 'canceled',
                            'rejected', 'expired')),
    submitted_at       TIMESTAMPTZ   NOT NULL DEFAULT now(),
    filled_at          TIMESTAMPTZ,
    fill_price         NUMERIC(18,6),
    slippage_basis     NUMERIC(18,6),
    strategy_id        BIGINT        REFERENCES strategies(id) ON DELETE SET NULL,
    parent_order_id    BIGINT        REFERENCES paper_orders(id) ON DELETE SET NULL,
    related_leg_orders JSONB,        -- [{id, symbol, side, qty}] for multi-leg parent
    asset_class        TEXT          NOT NULL DEFAULT 'us_equity'
                       CHECK (asset_class IN ('us_equity', 'option')),
    reject_reason      TEXT
);
CREATE INDEX po_user_status    ON paper_orders(user_id, status);
CREATE INDEX po_user_submitted ON paper_orders(user_id, submitted_at DESC);
CREATE INDEX po_resting        ON paper_orders(user_id, symbol)
    WHERE status IN ('new', 'accepted');

parent_order_id links each leg to its multi-leg parent order. related_leg_orders on the parent row caches the leg list for fast retrieval without a join.

paper_fills

-- POSTGRES-ONLY
CREATE TABLE paper_fills (
    id              BIGSERIAL PRIMARY KEY,
    paper_order_id  BIGINT        NOT NULL REFERENCES paper_orders(id) ON DELETE CASCADE,
    fill_qty        NUMERIC(18,6) NOT NULL,
    fill_price      NUMERIC(18,6) NOT NULL,
    ts              TIMESTAMPTZ   NOT NULL DEFAULT now(),
    slippage_basis  NUMERIC(18,6) NOT NULL DEFAULT 0,
    -- NBBO snapshot at fill time — supports deterministic replay and audit
    nbbo_bid        NUMERIC(18,6),
    nbbo_ask        NUMERIC(18,6)
);
CREATE INDEX pf_order ON paper_fills(paper_order_id);

market_data_access_log

-- POSTGRES-ONLY
CREATE TABLE market_data_access_log (
    id          BIGSERIAL    PRIMARY KEY,
    user_id     BIGINT       NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    symbol      TEXT         NOT NULL,
    data_type   TEXT         NOT NULL CHECK (data_type IN ('quote','bar','chain')),
    fetched_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    source      TEXT         NOT NULL DEFAULT 'alpaca'
);
CREATE INDEX mdal_user_ts ON market_data_access_log(user_id, fetched_at DESC);

Retention: 13 months (regulatory minimum for data-access records). Not covered by the standard tier purge; a separate nightly Celery task handles this.

Reused Tables (tax engine, no changes)

The post-fill hook calls the existing service layer for each of these tables. No schema changes to the tax engine tables.

Migration Notes

5. Fill Modeling

v1

Fill simulation follows ADR-0108 §4 (bar-based) extended with live quote fallback:

v2 (out of scope)

Depth-weighted slippage based on order size vs displayed bid/ask size. Maker/taker rebates, exchange routing simulation, and dark-pool simulation are explicitly deferred and not mentioned in the UI.

6. Multi-Leg Order Handling

The IC builder and LCC roll engine both produce an OrderIntent object today. They do not call the broker directly — they hand the intent to paper_order_service.py. With MBT enabled, the same OrderIntent path routes to mbt_service.submit_order().

For a 4-leg IC:

  1. IC builder calls mbt_service.submit_order(intent) with legs populated.
  2. mbt_service creates one parent paper_orders row with related_leg_orders = the 4 legs in JSONB.
  3. Four child paper_orders rows are created (one per leg) with parent_order_id set.
  4. mbt_fill_engine evaluates all four legs against current quotes. If all four have a valid fill, commits all four paper_fills rows and marks all four child orders + the parent as filled in a single DB transaction.
  5. If any leg can't fill (no quote, buying-power violation), the entire transaction rolls back and the parent order status is set to rejected with reject_reason = the failing leg.
  6. Post-fill hook runs once per parent order (not per leg) to update tax state.

LCC roll engine follows the same path; a roll is two multi-leg parent orders (close + open) submitted in sequence.

7. Daily Batch Jobs

All jobs run as Celery tasks, scheduled via Celery Beat (already the Raptor pattern per ADR-0108 §8).

Task Schedule Description
paper.assignment_at_expiry 16:15 UTC Mon–Fri For every user with open short option positions expiring today: if ITM, generate assignment fill (convert to ±100 shares stock per contract at strike price). If OTM, expire worthless. Writes paper_fills + paper_orders rows. Post-fill hook updates tax engine.
paper.dividend_adjustment 09:00 UTC daily Query corporate actions feed for ex-dates matching today. Credit cash to paper_accounts.cash_balance. DRIP is off by default (v1); cash sweep only. Writes audit_log.
paper.stock_split_adjustment 09:00 UTC daily Dependency: corporate actions feed. Adjust paper_positions.qty and avg_cost for declared splits. Flag: depends on a corporate actions data feed not yet wired. Sub-card SC-10 carries this dependency as a blocker.
paper.eod_tax_recalc 21:30 UTC Mon–Fri Re-run tax_event_calendar_service.recalc_for_date(today) for all users with paper fills on the current date. Updates tax_lots, holding_period_classifications, wash_sale_lots.
paper.eod_snapshot 21:00 UTC Mon–Fri Write mbt_eod_snapshots row per user with equity, cash, positions_json.
paper.retention_purge 02:00 UTC daily Soft-delete closed positions + orders older than tier retention limit (Free 90d / Pro 3yr / Pro+ skip).

Rollback for batch jobs: Each task is idempotent (re-running produces the same result). If a batch fails mid-run, re-trigger manually; no compensating transaction needed for v1.

8. Sub-Cards (Paper Trading v1)

All sub-cards are gated behind FLAG_PAPER_TRADING_V1. SC-15 (the flag itself) ships first.


SC-1 — paper_positions schema + migration

AC: - Alembic migration creates paper_positions with the schema above - -- POSTGRES-ONLY sentinel present - Migration includes index creation - Real-Postgres smoke test passes (per feedback_postgres_enum_migrations_need_real_pg_test) - op.drop_table rollback defined


SC-2 — paper_orders schema + migration (includes broker_paper_orders rename)

AC: - Single Alembic revision renames paper_ordersbroker_paper_orders and creates new paper_orders with schema above - paper_order_service.py references updated to broker_paper_orders - Comprehensive grep confirms no remaining paper_orders references outside the new MBT service layer - Rollback restores broker_paper_orderspaper_orders and drops new paper_orders


SC-3 — paper_fills + market_data_access_log schemas + migrations

AC: - Alembic migration creates both tables - last_fill_id FK on paper_positions added as a separate migration step (after paper_fills exists) - market_data_access_log retention: 13-month Celery purge task added - -- POSTGRES-ONLY sentinel present


SC-4 — Alpaca Data API client wrapper in Raptor

AC: - AlpacaMarketDataClient wraps the Alpaca Data API v2 REST endpoints for quotes and bars - URL in code-block documentation: https://data.alpaca.markets/v2/ - Rate-limit aware: reads the X-RateLimit-Remaining response header; backs off when < 10 - ALPACA_MARKET_DATA_KEY and ALPACA_MARKET_DATA_SECRET read from env/secret store, never hardcoded - Every quote fetch writes a row to market_data_access_log with the triggering user_id - Unit tests mock the HTTP calls; no live Alpaca calls in CI


SC-5 — Market order submission endpoint

AC: - POST /api/trading/orders with order_type=market routes through mbt_service when FLAG_PAPER_TRADING_V1=1 - check_trade_compliance runs before fill engine - Market order fills synchronously at bid/ask from AlpacaMarketDataClient - Fill persisted to paper_orders + paper_fills; paper_positions upserted - Post-fill hook triggers tax engine (tax_lots, holding_period, wash_sale, 1256 tagger) - Audit log row written - Response shape matches ADR-0108 §4 contract


SC-6 — Limit order submission + resting order evaluation

AC: - POST /api/trading/orders with order_type=limit creates resting order - DELETE /api/trading/orders/{id} cancels resting order - PATCH /api/trading/orders/{id} replaces limit/qty on resting order - mbt.evaluate_resting_orders Celery task runs per bar tick (1-min bars per ADR-0110) - evaluate_limit(order, bar) fill logic per ADR-0108 §5 - DAY orders expire at session close; GTC stay open - Partial fills deferred (single-fill-only in v1; documented in UI)


SC-7 — IC builder → paper submit path wiring

AC: - IC builder OrderIntent with 4 legs routes to mbt_service.submit_order() when FLAG_PAPER_TRADING_V1=1 - Atomic 4-leg fill semantics: all legs fill or none (single DB transaction) - Parent + 4 child paper_orders rows created - related_leg_orders JSONB populated on parent - Net credit recorded; post-fill hook runs once on parent - Existing IC builder tests still pass (no regression)


SC-8 — LCC roll engine → paper submit path wiring

AC: - LCC roll OrderIntent (close + open legs) routes to MBT paper path - Two parent orders submitted sequentially: close first, open second - If close fails, open is not submitted; error returned to caller - Tax lot selection from lcc_strategy_config.default_tax_lot_method applied at close - Existing LCC roll tests still pass


SC-9 — Daily assignment-at-expiry batch (paper.assignment_at_expiry)

AC: - Celery task scheduled at 16:15 UTC Mon–Fri - Scans paper_positions for option rows with closed_at IS NULL and expiry = today - ITM short calls: generate -100 shares per contract at strike; ITM long puts: generate +100 shares - OTM positions: create paper_fills row at price=0, mark paper_orders expired - Post-fill hook updates tax engine for each assignment - Audit log row per assignment/expiry event - Idempotent: re-running for the same date produces no additional rows


SC-10 — Daily dividend + stock-split adjustment batches

AC: - paper.dividend_adjustment: credits cash for ex-date positions; DRIP off (v1); audit log - paper.stock_split_adjustment: adjusts qty and avg_cost; blocked until corporate actions feed is wired (document blocker in PR body) - Corporate actions data source is an open dependency — this sub-card ships dividend adjustment now; split adjustment ships when the feed lands


SC-11 — Paper portfolio view UI (raxx-next dashboard tile)

AC: - Dashboard tile shows: cash balance, equity, total P&L (realized + unrealized), open positions list with symbol / qty / avg cost / current price / unrealized P&L - Powered by GET /api/trading/account + GET /api/trading/positions - Uses Confidence Engine look and feel (per feedback_all_surfaces_confidence_engine) - Gated behind FLAG_PAPER_TRADING_V1 - Fills update via polling (5s interval in v1; SSE upgrade is post-v1)


SC-12 — Paper trade history UI

AC: - Trade history table: date / symbol / side / qty / fill price / P&L / strategy name - Filterable by date range and status (filled / canceled / rejected / expired) - Powered by GET /api/trading/orders (paginated) - Tax lot summary section: lot method applied, holding period, wash-sale flags (read from tax engine tables) - CE-skinned; gated behind FLAG_PAPER_TRADING_V1


SC-13 — Reset paper portfolio endpoint + UI confirm dialog

AC: - POST /api/trading/paper/reset resets paper_accounts to starting balance - Archives all open positions (closed_at = now()), cancels resting orders - Writes audit_log row with action = 'paper_account_reset' - Initially operator/admin only; a separate sub-card (post-v1) promotes to customer-facing with a confirmation dialog - UI confirm dialog in Antlers (CE-skinned, two-step confirm)


SC-14 — Switch-to-live-broker CTA (placeholder)

AC: - Antlers renders a "Ready to go live?" CTA card in the paper portfolio view once the paper-profitable gate condition is met - CTA is a static placeholder; BYOB broker connect flow is post-v1 - Paper-profitable gate reads from the existing graduation-gate logic (PR #3021) - Gated behind FLAG_PAPER_TRADING_V1; CTA hidden if gate not met


SC-15 — FLAG_PAPER_TRADING_V1 feature flag + B1 promotion migration

Ships first. All other SCs gate behind this.

AC: - Add FLAG_PAPER_TRADING_V1 to feature_flags.yaml with risk: high (customer-facing per project_flag_risk_classification) - B1 promotion migration in the same PR (per feedback_new_flag_needs_b1_migration_same_pr) - Flag defaults to off on both staging and prod - Flag flipped on only after BLR clears the Alpaca Data API redistribution clause - Kill switches documented: MBT_TRADING_DISABLED=1, MBT_NEW_ORDERS_DISABLED=1


9. Risk Callouts

Tax-engine integration: unsettled-fill handling

The existing tax engine (tax_event_calendar_service.py, lcc_tax_lot_service.py) was built against the backtest runner, which processes settled lots. Paper fills are simulated as settled immediately (T+0) in v1. Before the post-fill hook calls the tax engine, the fill hook must pass settlement_date = fill_ts (same day) rather than fill_ts + 2. Feature-developer must verify that wash_sale_service.py and holding_period_service.py handle same-day settlement without producing erroneous wash-sale windows. A unit test covering a same-day buy → sell → repurchase sequence is a required deliverable on SC-5.

State drift on app-offline

If Raptor is offline during RTH (trading session hours), resting limit orders are not evaluated against incoming bars. On restart, mbt.evaluate_resting_orders will catch up by iterating over bars that arrived during the downtime and evaluating in chronological order. Feature-developer must implement the catch-up loop and add a reconcile-on-reopen smoke test. The UI should show a "some fills may have been delayed" banner if the catch-up loop processes more than N missed bars (threshold TBD in SC-6).

Feature-flag gating: BLR dependency

FLAG_PAPER_TRADING_V1 is off on both staging and prod until BLR confirms the Alpaca Data API redistribution clause. All sub-cards can be built and tested behind the flag. No user-visible traffic routes to the new paths until the flag is enabled. The flag flip is an operator action via heroku config:set, not a code ship.

Data licensing: per-user access logging

Even if BLR confirms redistribution is allowed, every market-data fetch must log (user_id, symbol, data_type, fetched_at) to market_data_access_log. This is the audit trail Alpaca would need if they ever audit SaaS redistribution usage. SC-4 carries this as a hard acceptance criterion — the client wrapper is not shippable without the access log.

10. Rollout Plan

Follows the dark → shadow → beta → GA pattern established in ADR-0108 §11:

Phase Gate Duration
Dark FLAG_PAPER_TRADING_V1=off SCs 1–15 land; CI covers all paths; no user traffic
Dogfood Operator enables flag for self 7-day fill-drift comparison vs current Alpaca paper path
Beta Flag on for allowlisted beta users ≥2 weeks; BLR must be clear before this phase
GA Flag on globally Alpaca paper writes disabled (current paper_order_service.py deprecated)

11. Security Considerations

GDPR checklist:

12. Open Questions

  1. Pricing tier for paper trading. Is paper trading available on Free, or locked to Founders/Pro and above? This determines whether the FLAG_PAPER_TRADING_V1 enable affects all signup tiers simultaneously and whether the portfolio view tile appears for Free users.

  2. Reset frequency policy. Is paper portfolio reset operator-initiated only (admin panel), or should users eventually self-serve a reset? If self-serve, is there a cooldown (e.g., once per 30 days) or a drawdown trigger (e.g., auto-reset offer at 50% drawdown)? This affects SC-13 scope.

  3. Paper trading vs backtest — replacement or coexistence? For new users, does the paper trading experience eventually replace the existing backtest engine as the primary "try before you go live" surface, or do they sit alongside with distinct UX? ADR-0108 OQ-4 noted this as unresolved. The answer shapes how SC-11/SC-12 (portfolio UI) and the existing backtest UI are surfaced in the nav.


See ADR-0108 for the fill engine spec, ADR-0013 for the original MBT decision record, ADR-0014 for the Alpaca scope reframe, and ADR-0110 for the intraday bar feed design.