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:
- How simulated fills feed the existing tax engine (wash-sale, §1256, lot-selection, holding-period tables that landed in migrations 0146–0157).
- How multi-leg structures (IC builder, LCC roll engine) reach the paper submission path.
- What the daily batch job set looks like for corporate actions, dividends, and end-of-day tax recalc.
- 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:
- No stored credentials. MBT users hold zero broker tokens. The single server-side
ALPACA_MARKET_DATA_KEYlives in the secret store, not in the DB. ADR-0002 CI grep coversbackend_v2/wholesale. - Paper-first gating. Live trading remains unreachable until paper-profitable-for-N-cycles gate clears or the operator records an explicit audited override per ADR-0108 §Rollout.
- GDPR by default. Every
paper_*table row is financial PII. Retention is tier-bound (Free 90d / Pro 3yr / Pro+ unlimited).users.idCASCADE DELETE covers all new tables. DSR export and erasure run through existing GDPR machinery. - Audit trail for every state change that touches money, permissions, or data access. Every fill, rejection, expiration, account reset, and corporate action writes an
audit_logrow viacustomer_audit_writer_service.py. - Credentials into infra.
ALPACA_MARKET_DATA_KEYis env/SSM only. No inline secrets. - Data licensing. Every market-data fetch triggered by a user action logs the
user_idandsymbolto amarket_data_access_logtable for Alpaca compliance reporting. This is required even if BLR confirms redistribution is permitted — the log demonstrates per-user access accountability.
3. Architecture in One Paragraph
User clicks "trade" in Antlers → Raptor receives order intent at POST /api/trading/orders → check_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)
tax_lots— one row per opened lot; populated by post-fill hookwash_sale_lots— flagged lots fromwash_sale_service.pyholding_period_classifications— short/long-term per lot, fromholding_period_service.pylcc_strategy_config— already storesdefault_tax_lot_method
The post-fill hook calls the existing service layer for each of these tables. No schema changes to the tax engine tables.
Migration Notes
- New migrations must carry the
-- POSTGRES-ONLYsentinel (perfeedback_postgres_only_migration_sentinel) on any PL/pgSQL blocks. - The
broker_paper_ordersrename and the newpaper_orderstable ship in a single Alembic revision so no intermediate state references a missing table. - Rollback =
op.drop_table+op.rename_table(restorebroker_paper_orders→paper_orders).
5. Fill Modeling
v1
Fill simulation follows ADR-0108 §4 (bar-based) extended with live quote fallback:
- Market orders (equities): fill at
ask(buy) orbid(sell) from the most recent Alpaca Data API snapshot quote. If no real-time quote (outside RTH), fill at previous bar close.slippage_basis = fill_price − midwheremid = (bid + ask) / 2. - Market orders (options): fill at the option's quoted
ask(buy) orbid(sell) from the options chain snapshot. - Limit orders: resting evaluation per ADR-0108 §5 —
evaluate_limit(order, bar). Fills atlimit_priceon touch. - IC orders (4-leg iron condor): all four legs submitted atomically as child orders under a single parent. All four must fill at their respective bid/ask in the same bar tick, or none fill (all-or-none semantics). Net credit recorded on the parent row.
slippage_basisis recorded on everypaper_fillsrow for audit and drift comparison.
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:
- IC builder calls
mbt_service.submit_order(intent)withlegspopulated. mbt_servicecreates one parentpaper_ordersrow withrelated_leg_orders= the 4 legs in JSONB.- Four child
paper_ordersrows are created (one per leg) withparent_order_idset. mbt_fill_engineevaluates all four legs against current quotes. If all four have a valid fill, commits all fourpaper_fillsrows and marks all four child orders + the parent asfilledin a single DB transaction.- If any leg can't fill (no quote, buying-power violation), the entire transaction rolls back and the parent order status is set to
rejectedwithreject_reason= the failing leg. - 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_orders → broker_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_orders → paper_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:
- PII collected:
user_id,symbol, fill prices, lot cost basis. All financial PII. No names or contact data in paper trading tables. - Retention: tier-bound (Free 90d / Pro 3yr / Pro+ unlimited) enforced by nightly Celery purge.
market_data_access_loghas separate 13-month retention. - DSR deletion:
users.idCASCADE DELETE on allpaper_*tables. 30-day cooling period per ADR-0003 before hard delete.audit_logrows persist with hashed actor ID for 2 years. - Audit logging: every fill, rejection, expiry, dividend credit, assignment, account reset →
audit_logrow. 7-year retention for trade-affecting rows. - Stored credentials: none.
ALPACA_MARKET_DATA_KEYis env/SSM only; never in DB or files. ADR-0002 CI grep enforces. - Breach: standard ADR-0003 path. MBT tables contain no credentials; breach impact is simulated trade history only. 72h notification to supervisory authority per GDPR Art. 33.
- Secrets rotation:
ALPACA_MARKET_DATA_KEYrotatable without redeploy (env/SSM). - Kill switches:
MBT_TRADING_DISABLED=1(503 on order submits),MBT_NEW_ORDERS_DISABLED=1(new submits blocked, reads/cancels still work),FLAG_PAPER_TRADING_V1=off(full revert to Alpaca paper path).
12. Open Questions
-
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_V1enable affects all signup tiers simultaneously and whether the portfolio view tile appears for Free users. -
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.
-
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.