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)
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.
From auth.md §2 and ADR 0002/0003:
audit_log row.If any piece of this design reads as violating an invariant, treat it as a bug in the doc.
Target: parity-with-Alpaca-paper for the 90% options-forward retail use case (Persona: Weekly-Income Pat). Honest split:
Ships in v1:
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.
Single-book-per-user simulator — orders do not match against other users. Orders match against real Alpaca market-data quotes in simulated time.
ask <= limit buy; bid >= limit sell). Atomic full-fill only in v1; partial fills are v2.last_trade crossing stop, executes as market at next tick. Stop-limit becomes a limit order on trigger.mbt-investor-profiles.md §5).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").
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.
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.
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):
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.
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:
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).
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.
Current state: one shared Alpaca paper account keyed to operator's env credentials; positions + fills live in Alpaca, not our DB.
FLAG_MBT_PAPER_TRADING=off. One-shot importer built: pulls each current user's Alpaca paper state into MBT.FLAG_MBT_PAPER_TRADING=shadow globally. Every Alpaca paper write also writes MBT. Reads still Alpaca. Nightly mbt.verify_parity job diffs computed position/equity vs Alpaca's; discrepancies page ops.on for allowlisted users. Reads + writes go to MBT; Alpaca paper stops being written for that user. Prior Alpaca history imported once into mbt_eod_snapshots.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.
Standard dark → shadow → beta → GA gated by FLAG_MBT_PAPER_TRADING:
shadow globally; ≥ 2 weeks; parity diff < 0.1% across 10k orders.on for allowlisted users (Kris + self-selected early adopters); ≥ 2 weeks; active feedback on UI, fill realism, P&L.on globally; 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).
mbt_* tables cascade on users.id delete; 30-day cooling; audit rows persist with hashed actor id for 2 years (ADR 0003).audit_log; 7-year retention for trade-affecting rows.MBT_TRADING_DISABLED, MBT_NEW_ORDERS_DISABLED, ALPACA_MARKET_DATA_DISABLED (falls back to last-known-good cache + user banner).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.
Flag to user: engage securities counsel before any commitment past "user-directed simulation + educational content." v1 MBT stays in the safest lane deliberately.
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:
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.