MBT — Raxx-native paper-trading engine
Status: Draft
Owner: software-architect
Last updated: 2026-04-22
Parent epic: #183 — Multi-tenant architecture (reframed)
Supersedes: PR #184 (multi-tenant Alpaca OAuth-for-everyone architecture)
Related ADRs: 0002, 0003, 0013, 0008 (Superseded), 0009 (Superseded), 0010 (Superseded), 0011 (Superseded)
1. Context + goal
Raxx (backend Raptor, frontend Antlers, algo layer MQ-A) is moving from single-operator tool to multi-tenant SaaS. The prior design (PR #184) routed every user through Alpaca OAuth for paper + live. That over-depends on Alpaca: their paper trading is a simulation against their market data, and we can run the simulation ourselves. Per-user broker OAuth as a paper-trading pre-requisite adds credential-storage tension and onboarding friction for the majority of users who never need a live broker.
This doc specifies MBT, the Raxx-native paper-trading engine — an internal codename alongside Raptor / Antlers / MQ-A; do not expand the acronym. MBT owns accounts, positions, orders, fills, and activity history for every free and Pro user. Alpaca narrows to: (a) shared server-side market-data provider, and (b) optional per-user live-broker handoff for Pro+ users graduating from paper. Most users never touch Alpaca credentials.
The shift restores the "no stored credentials" invariant for the majority, makes tier-gated paper-history retention trivial (our DB), gives us data sovereignty, and keeps Raxx permanently out of BD / Broker-API territory.
2. Invariants that apply
From auth.md §2 and ADR 0002/0003:
- No stored credentials. Restored fully for MBT users — no broker token to store. The ADR 0009 exception narrows to the Pro+ live-handoff subset in ADR 0014.
- Passkeys / WebAuthn only. Step-up required for live-handoff enrollment and every live order submit.
- Paper-first gating. MBT is paper-only. Graduation to live Alpaca requires N-cycle paper-profitability or explicit audited override.
- GDPR by default. MBT account, position, order, and fill rows are user PII — exportable, erasable (30-day cooling), retention-bounded per tier.
- Audit trail for every state change — every submit, modify, cancel, fill, corporate action writes an
audit_logrow. - Credentials into infra. Market Data key + live-handoff OAuth client secret live in the secret store.
If any piece of this design reads as violating an invariant, treat it as a bug in the doc.
3. v1 scope + v2+ roadmap
Target: parity-with-Alpaca-paper for the 90% options-forward retail use case (Persona: Weekly-Income Pat). Honest split:
Ships in v1:
- Orders: market, limit, stop, stop-limit. DAY, GTC, IOC, FOK time-in-force.
- Equities: fractional shares, cash account, basic margin (Reg T 2:1, no portfolio margin).
- Options single-leg: long/short calls + puts, covered call; naked shorts margin-gated.
- Options multi-leg: vertical credit/debit spreads, iron condors, strangles, long butterflies, bracket orders (entry + OCO profit-target / stop-loss).
- Corporate actions: cash dividends (credited on ex-date), auto-exercise of expired ITM options, worthless-expiry of OTM options.
- Position + P&L: realized + unrealized, intraday + end-of-day snapshots.
- Activities: fills, cancels, modifications, dividends, expirations, interest accrual.
- Cash: starting balance driven by investor profile (see §14 +
mbt-investor-profiles.md), simulated interest on cash.
Deferred to v2+: trailing stops, PDT detection, stock splits / mergers / symbol changes, DRIP, short-borrow fees, calendar + diagonal spreads, portfolio margin, extended-hours, partial fills, futures, crypto.
Users are told upfront what's simulated. "Simulated paper trade with realistic but not perfect fills" is the honest frame.
4. Order-matching model
Single-book-per-user simulator — orders do not match against other users. Orders match against real Alpaca market-data quotes in simulated time.
- Market: fills at next NBBO tick after submission; buy at ask, sell at bid (worst-of-top-of-book slippage). Multi-leg atomic: all legs fill at same tick or none.
- Limit: fills when NBBO crosses (
ask <= limitbuy;bid >= limitsell). Atomic full-fill only in v1; partial fills are v2. - Stop: triggers on
last_tradecrossing stop, executes as market at next tick. Stop-limit becomes a limit order on trigger. - Options fills: options top-of-book. Multi-leg prices at combined-NBBO mid with user-configurable aggressiveness (default per investor profile; see
mbt-investor-profiles.md§5). - After-hours / pre-market: v1 RTH only; orders outside RTH queue to next open.
- Rejects: insufficient buying power, margin violation, invalid symbol, expired underlying. Every reject writes an audit row.
Fill simulation is a pure function of (order, nbbo_tick_history, account_state) — deterministic replay for backtests and audit ("why did this fill at $X").
5. Data model
New tables in backend_v2/db/; schemas are sketches, final migration in the implementation sub-card.
mbt_accounts
id PK, user_id FK -> users.id ON DELETE CASCADE, account_type ('cash'|'margin'),
cash_balance, buying_power (derived), equity (derived),
status ('active'|'restricted'|'closed'), created_at
UNIQUE (user_id) -- one MBT account per user in v1
mbt_positions
id PK, account_id FK, symbol, asset_class ('equity'|'option'),
qty (signed; negative = short), avg_entry_price, opened_at, closed_at NULL
mbt_orders
id PK, account_id FK, client_order_id (idempotency key), parent_order_id (bracket/OCO),
symbol, asset_class, side ('buy'|'sell'), qty,
order_type ('market'|'limit'|'stop'|'stop_limit'), limit_price NULL, stop_price NULL,
time_in_force ('day'|'gtc'|'ioc'|'fok'),
status ('new'|'accepted'|'partially_filled'|'filled'|'canceled'|'rejected'|'expired'),
submitted_at, accepted_at NULL, filled_at NULL, canceled_at NULL, reject_reason NULL,
legs JSONB NULL -- multi-leg: [{symbol, side, qty, ratio}]
mbt_fills
id PK, order_id FK, symbol, qty, price, filled_at,
nbbo_bid, nbbo_ask -- snapshot at fill time for replay / audit
mbt_activities
id PK, account_id FK,
activity_type ('fill'|'dividend'|'expiration'|'adjustment'|'interest'|'system_nudge'),
symbol NULL, amount, ref_id NULL, occurred_at
mbt_eod_snapshots
id PK, account_id FK, as_of_date DATE, equity, cash, positions_json JSONB
UNIQUE (account_id, as_of_date)
Retention per docs/marketing/pricing.md: Free 90d / Pro 3yr / Pro+ unlimited. Nightly Celery purge. Orders, fills, positions follow the same tier cap (they're the source of activities).
GDPR erasure: users.id delete cascades through MBT tables. After 30-day cooling (ADR 0003), hard-deleted. audit_log rows persist with hashed actor id for 2 years.
Absent by design: no broker-credential column, no api_key, no access_token. ADR 0002 CI grep covers backend_v2/ wholesale.
6. APIs
REST endpoints under /api/mbt/*, session-cookie authenticated. Contracts mirror common trading-API shapes (Alpaca, IBKR) so future iOS + console surfaces reuse without re-designing.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/mbt/account |
Cash, buying_power, equity, status. |
| GET | /api/mbt/positions |
List open positions. |
| GET | /api/mbt/positions/{symbol} |
Single position. |
| GET | /api/mbt/pnl?from=...&to=... |
Realized + unrealized P&L for window. |
| POST | /api/mbt/orders |
Submit. Body: {symbol, side, qty, order_type, limit_price?, stop_price?, time_in_force, legs?, client_order_id?}. |
| GET | /api/mbt/orders?status=... |
List (paginated, filterable). |
| GET | /api/mbt/orders/{id} |
Single order. |
| DELETE | /api/mbt/orders/{id} |
Cancel (idempotent). |
| PATCH | /api/mbt/orders/{id} |
Replace (change limit/stop/qty). |
| GET | /api/mbt/activities?type=...&from=...&to=... |
Paginated activity stream. |
| GET | /api/mbt/activities/{id} |
Single activity. |
Envelope: {"data": ..., "meta": {...}}. Errors: {"error": {"code": "...", "message": "...", "request_id": "..."}}. Rate limits per tier (§9). client_order_id unique per account — duplicate submit returns original order.
7. Market data integration
One server-side Alpaca Market Data API key in the secret store (ALPACA_MARKET_DATA_KEY / ..._SECRET). Raxx's account, not per-user. Consumed by MBT fills, charts, MQ-A bar-close callbacks, and backtests.
Tier gating (enforced at the market-data service boundary, not Antlers):
- Free: 15-min delayed quotes + chains, in-process cache (5-min TTL quotes, 1-hr chains).
- Pro: real-time quotes + chains + bars.
- Pro+: real-time + historical chains archive (2+ yr) + full depth where available.
Scaling note: Alpaca caps one WebSocket per API key; at scale, add a second key or entitlement upgrade. MBT itself does not need per-user streams — it shares the top-of-book charts consume.
8. Async jobs — Celery + Redis
MBT background work: EOD mark-to-market, retention purges, dividend crediting, option expirations, fill notifications, backtests, GDPR exports.
v1 choice: Celery 5 + Redis 7 (broker + result backend). Temporal / Cadence deferred to v2 for stateful long-running workflows.
Why Celery + Redis over Temporal for v1:
- Operational maturity. Celery is the default Python job queue; every Flask dev knows it. Temporal needs a dedicated cluster and a new mental model (workflow vs activity, deterministic replay) — bigger ops footprint than Raxx runs.
- v1 jobs are fire-and-forget or short-lived. EOD marks, emails, purges, exports all fit Celery tasks with retries. No mid-flight durable state yet needed.
- Redis pays twice. Already needed for rate-limiter state, session store, and market-data hot cache; reusing it as Celery broker is free.
- Upgrade path is real. When stateful workflows arrive (multi-day orchestration, saga corporate actions), we move those specific workflows to Temporal without rewriting the rest. Celery and Temporal coexist.
Task inventory (indicative): mbt.eod_mark_all (nightly), mbt.purge_expired (hourly), mbt.credit_dividends (daily ex-date), mbt.expire_options (market close), mbt.send_fill_notification (per fill), mbt.run_backtest (user-triggered), mbt.export_user_data (GDPR), mbt.accrue_margin_interest (nightly), mbt.evaluate_goal_streak (per cadence close).
9. Session engine
Full design in session-engine.md. Summary: REST endpoints POST /api/sessions, GET /api/sessions/current, DELETE /api/sessions/current, POST /api/sessions/refresh, POST /api/sessions/step-up. JWT + HttpOnly; Secure; SameSite=Strict cookie; server-side session row in Redis for instant revocation; per-tier rate limits (Free 60 / Pro 600 / Pro+ 3000 req/min per docs/marketing/pricing.md). MBT consumes sessions via standard middleware.
10. Migration path (current state → MBT)
Current state: one shared Alpaca paper account keyed to operator's env credentials; positions + fills live in Alpaca, not our DB.
- Phase 0 — prep. MBT schema migration lands (tables created, no writes). Service layer, REST endpoints, Celery tasks land behind
FLAG_MBT_PAPER_TRADING=off. One-shot importer built: pulls each current user's Alpaca paper state into MBT. - Phase 1 — shadow double-write.
FLAG_MBT_PAPER_TRADING=shadowglobally. Every Alpaca paper write also writes MBT. Reads still Alpaca. Nightlymbt.verify_parityjob diffs computed position/equity vs Alpaca's; discrepancies page ops. - Phase 2 — flagged cutover. Per-user flag flip to
onfor allowlisted users. Reads + writes go to MBT; Alpaca paper stops being written for that user. Prior Alpaca history imported once intombt_eod_snapshots. - Phase 3 — GA. Global flip. Alpaca paper writes disabled; paper credentials removed from env. Alpaca Trading API integration narrows to Market Data (server-side) + Pro+ live-handoff (ADR 0014).
Rollback: per-user flag flips back to Alpaca-paper reads through Phase 2 (MBT continues shadow-writing so no state is lost). After Phase 3, rollback is a partial-restore from MBT backups.
11. Rollout plan
Standard dark → shadow → beta → GA gated by FLAG_MBT_PAPER_TRADING:
- Dark — migrations + code landed; flag off; CI covers code paths; no user traffic.
- Shadow — flag=
shadowglobally; ≥ 2 weeks; parity diff < 0.1% across 10k orders. - Beta — flag=
onfor allowlisted users (Kris + self-selected early adopters); ≥ 2 weeks; active feedback on UI, fill realism, P&L. - GA — flag=
onglobally; Alpaca paper writes disabled.
Flag is flippable per-user or global. Kill switches: MBT_TRADING_DISABLED=1 (503 on all order submits, read-only mode), MBT_NEW_ORDERS_DISABLED=1 (position views + cancels still work, new submits blocked — useful if market data goes stale).
12. Security considerations
- GDPR erasure wipes MBT data. All
mbt_*tables cascade onusers.iddelete; 30-day cooling; audit rows persist with hashed actor id for 2 years (ADR 0003). - Audit trail for every state change. Submit/accept/reject/modify/cancel/fill/corporate-action/admin-adjustment rows in
audit_log; 7-year retention for trade-affecting rows. - Per-tier rate limits enforced at middleware (not per-route). See session-engine.md §6.
- Kill switches:
MBT_TRADING_DISABLED,MBT_NEW_ORDERS_DISABLED,ALPACA_MARKET_DATA_DISABLED(falls back to last-known-good cache + user banner). - No credentials stored for MBT users. Invariant #1 fully satisfied for users who don't enroll Pro+ live-handoff. ADR 0009 exception narrows to ADR 0014's scope.
- Deterministic replay. Every fill carries the NBBO snapshot that produced it — auditable "why did this fill at $X."
- Input validation at submit. Symbol format, OCC option symbol, qty sanity, limit-vs-stop coherence, buying-power check, multi-leg ratio consistency. Reject fast with specific error codes.
13. Regulatory posture (flags, not solutions)
Longer-term vision includes a "platform where users funnel money and follow Kris's trading methodology." That raises Investment Advisers Act §202(a)(11) + copy-trading questions. Flagging now; do not design product around this without securities counsel.
- MBT as education + user-directed simulation → safest. Users author/select rules; Raxx simulates. Not advisory.
- MBT strategies subscription (Raxx-authored strategies users subscribe to and run on paper) → borderline. Individualized publication can trigger adviser-like obligations under §202(a)(11) even without real money.
- Copy-trading via live-handoff (user's real Alpaca mirrors Kris's trades) → likely RIA required. SEC has been explicit; broker-partner arrangements don't eliminate the obligation.
Flag to user: engage securities counsel before any commitment past "user-directed simulation + educational content." v1 MBT stays in the safest lane deliberately.
14. Open questions
Qs 1–3 RESOLVED via the investor-profile model. See mbt-investor-profiles.md and ADR 0015.
Q1 — Starting cash balance default: RESOLVED. Not a single fixed value. Starting cash is profile-driven: Trial = $10k, Income Builder = $50k, Diversifier = $100k. User-overridable within tier cap. Existing users without a profile default to Diversifier ($100k) on
FLAG_MBT_INVESTOR_PROFILESactivation.Q2 — Multi-leg fill aggressiveness: RESOLVED. Defaults are profile-driven: Trial = conservative (worse-of-mid/offer), Income Builder = mid, Diversifier = aggressive (mid + attempt on touch). Surfaced as a per-order "Fill preference" toggle in Antlers; visibility scaled by profile.
Q3 — Interest rate on cash / margin-interest model: RESOLVED. Margin interest is a teaching moment, not a config knob. When MBT accrues simulated margin interest, Antlers surfaces a narrative card: the interest amount, a plain-language "why this exists" explanation, and a tax-strategy note (deductibility; consult CPA). Narrative verbosity is profile-tuned. See
mbt-investor-profiles.md§7.
Qs 4–7 remain open:
- Backtest parity. Unify existing backtest engine with MBT (backtest = MBT against historical feed) in v1 or v2? Scope-defining.
- Live-handoff scope. Pro+ only, or Pro too? Pricing doc implies Pro gets "Alpaca live + 1 other broker."
- Market-data capacity ceiling. At what user count does one Alpaca data account become insufficient? Alpaca-partnerships inquiry.
- Options chain archive source for Pro+ (2+ yr). Alpaca's options history is thin; ORATS / Polygon / CBOE candidates. Blocks Pro+ GA, not MBT v1.
product-manager may now file implementation sub-cards for account creation, order flow, and profile onboarding. Qs 4–7 do not block v1 core sub-cards.
End of doc. See ADR 0013 for the MBT decision record, ADR 0014 for the Alpaca scope reframe, ADR 0015 for the investor-profile decision, and docs/architecture/session-engine.md for the session + rate-limit spec.