ADR 0108 — MBT Engine Design: Raxx-native paper-trading simulator
Status: Proposed
Date: 2026-05-28 UTC
Deciders: product owner (Kristerpher), software-architect
Scope: Raptor (backend_v2/), Antlers (frontend/), MQ-A (backtest integration)
Refs: ADR-0013 (superseding), ADR-0014 (Alpaca scope reframe), ADR-0107 (Strategy Library)
1. Context
Why now
ADR-0013 was marked Deferred on 2026-05-29 with three re-evaluation triggers. The operator unblocked the post-launch lane on 2026-05-29. Trigger #2 applies: launch posture is moving from personal-use-only toward real customers and multi-broker BYOB, at which point per-user Alpaca paper complexity and data sovereignty concerns return. This ADR resumes where ADR-0013 left off.
Current state
paper_order_service.py routes all paper orders to the Alpaca paper endpoint.
Account state, positions, and fills are read back via fetch_alpaca_account,
fetch_alpaca_positions, and fetch_alpaca_orders in trading.py. The local
paper_orders table (migration 0002) is an audit mirror — Alpaca is the source
of truth for fills and positions. Structure enforcement (check_trade_compliance,
PR #3024) runs on the request path today but is engine-agnostic.
Consequences of the current design:
- Fill semantics are opaque and not reproducible by Raxx; replay and audit are limited to the broker payload snapshot.
- Paper-first graduation gate (PR #3021) reads performance history from Alpaca's paper endpoint, not from a Raxx-owned store.
- A shared server-side Alpaca credential gates all paper trading. This does not violate invariant #1 today (no per-user stored credentials), but it creates single-point dependency on Alpaca paper availability and policy changes.
- Adding a second broker for BYOB live trading does not naturally extend to offering neutral paper trading — each broker's paper endpoint has different fidelity, data formats, and rate limits.
ADR-0013's original framing
ADR-0013 proposed a Raxx-native simulator that owns fills, positions, and account state. Its three re-evaluation triggers are in the status block of that ADR. This ADR implements that design with v1 scoping refined to the post-launch window priorities: equities-forward, limit/market fills, structure enforcement at submission, and BYOB-neutral paper layer.
Relationship to BYOB strategy
Per project_byob_hybrid_strategy: Raptor supports Alpaca-default live trading
plus future aggregator/multi-broker BYOB. MBT is the paper layer that sits in
front of any broker. A user papers with MBT regardless of which live broker
they will eventually use. This decoupling is the durable value of the
investment.
2. Decision
Raptor builds MBT — a deterministic event-driven paper-trading engine — and
routes all paper trading through it when FLAG_MBT_ENGINE=1. MBT owns account
state, positions, orders, and fills in Raptor's Postgres. Alpaca narrows to
(a) a shared server-side market-data account and (b) optional per-user live
broker for Pro+ graduation, per ADR-0014.
Engine type
Deterministic event-driven replay simulator. Order submission triggers fill
evaluation against bar data. Not Monte Carlo, not probabilistic, not AI-driven.
The fill outcome for a given (order, bar) is deterministic and reproducible.
This is the same property the backtest runner requires; the MBT fill engine is
shared with the backtest runner (not forked).
Fill model (v1 scope)
-
Market order: fills at submission time using the last available bar's midpoint (
(bar.high + bar.low) / 2). If the last bar is outside the current trading session (weekend, after-hours), the fill executes at the next trading-session open. Slippage is recorded asfill_price − ask(buys) orbid − fill_price(sells) based on the bar-close spread estimate. -
Limit order: enters resting state. On each new bar, evaluated as: buy fills if
bar.low <= limit_price; sell fills ifbar.high >= limit_price. Fill price islimit_price(price-at-touch). Resting orders expire pertime_in_force(DAY = current session; GTC = open until canceled). -
Multi-leg options (v1): each leg evaluated independently using the same market/limit semantics applied to the option's last-known mid. Net P&L aggregated at the strategy level. Option assignment is out of scope for v1. ITM options at expiration are auto-closed at intrinsic value; OTM options are expired as worthless.
v1 out-of-scope
Partial fills, iceberg/GTC-complex/OCO/bracket orders (OCO deferred to v2), borrow/short-locate semantics, margin/leverage tracking, dividend/split adjustments mid-position, extended-hours fills, PDT detection, trailing stops.
Account state
One MBT account per user in Raptor's Postgres. Starting balance: $100,000 (USD) unless operator-configured per investor profile (see ADR-0015). Reset endpoint available to operator/admin initially; promoted to customer-facing in a later sub-card.
Position close semantics
Closing orders match against existing open positions in FIFO order. Entry cost
basis is preserved on the paper_positions row. Realized P&L = (fill_price −
avg_entry_price) × qty × side_sign. Position row is archived (not deleted)
when qty reaches zero.
Market hours / calendar
Simulator advances on NYSE trading days only (09:30–16:00 ET / 14:30–21:00 UTC). Off-hours order submissions are accepted and queue to the next session open. Business day calendar is per ADR-0019.
Structure enforcement
Every MBT order submission runs check_trade_compliance (the same function
called on the live/broker paper path today). Compliance is engine-agnostic.
ADR-0107 strategy rules apply identically to MBT-routed orders.
3. Schema
New tables in Raptor's Postgres. All timestamps are TIMESTAMPTZ (UTC).
All money values are NUMERIC(18,6) — no FLOAT or DOUBLE for amounts.
Migrations are out of scope for this ADR. Feature-developer sub-cards carry the Alembic migration +
-- POSTGRES-ONLYsentinel for PL/pgSQL blocks. Rollback =op.drop_table(...)for each table.
paper_accounts
CREATE TABLE paper_accounts (
user_id BIGINT PRIMARY KEY
REFERENCES users(id) ON DELETE CASCADE,
cash_balance NUMERIC(18,6) NOT NULL DEFAULT 100000,
equity NUMERIC(18,6) NOT NULL DEFAULT 100000,
total_pl NUMERIC(18,6) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'reset')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One account per user enforced by PRIMARY KEY on user_id.
paper_positions
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_entry_price NUMERIC(18,6) NOT NULL,
current_price NUMERIC(18,6),
market_value NUMERIC(18,6),
unrealized_pl NUMERIC(18,6),
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ, -- NULL = open position
asset_class TEXT NOT NULL DEFAULT 'us_equity'
CHECK (asset_class IN ('us_equity', 'option'))
);
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);
paper_orders
This table is distinct from the existing broker-mirror paper_orders table
(migration 0002). That table is renamed broker_paper_orders in the migration
that ships this schema (no data loss; the column set is different). The new
paper_orders table is MBT-owned.
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), -- NULL for market orders
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 NUMERIC(18,6), -- fill_price minus mid estimate
strategy_id BIGINT
REFERENCES strategies(id) ON DELETE SET NULL,
reject_reason TEXT,
asset_class TEXT NOT NULL DEFAULT 'us_equity'
CHECK (asset_class IN ('us_equity', 'option')),
legs JSONB -- NULL for single-leg
);
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');
Indexes:
- pp_user_open — primary read pattern for open positions list.
- po_resting — partial index; fill engine scans only resting orders per bar tick.
Foreign keys summary: user_id → users.id CASCADE DELETE on all three
tables. GDPR cascade delete is satisfied at the DB level without application
logic for erasure.
4. API Contract
Routes inspect FLAG_MBT_ENGINE at request time. When the flag is off, all
routes delegate to the existing paper_order_service.py / Alpaca path
(unchanged). When the flag is on, routes delegate to the new MBT service layer.
Response shapes are kept compatible with the current Alpaca-proxied shapes so Antlers requires no frontend changes at flag flip.
Existing routes rerouted when FLAG_MBT_ENGINE=1
GET /api/trading/account
{
"cash": "100000.00",
"equity": "100000.00",
"buying_power": "100000.00",
"total_pl": "0.00",
"currency": "USD",
"status": "active",
"trading_mode": "paper",
"engine": "mbt"
}
GET /api/trading/positions
[
{
"symbol": "AAPL",
"side": "long",
"qty": "10",
"avg_entry_price": "180.00",
"current_price": "185.00",
"market_value": "1850.00",
"unrealized_pl": "50.00",
"asset_class": "us_equity",
"trading_mode": "paper",
"engine": "mbt"
}
]
GET /api/trading/orders — query params: status (open|closed|all),
limit (default 50).
[
{
"id": "123",
"symbol": "AAPL",
"side": "buy",
"qty": "10",
"order_type": "limit",
"limit_price": "180.00",
"status": "accepted",
"submitted_at": "2026-05-28T14:30:00Z",
"filled_at": null,
"fill_price": null,
"trading_mode": "paper",
"engine": "mbt"
}
]
POST /api/trading/orders — body unchanged; response adds engine: "mbt".
DELETE /api/trading/orders/<id> — cancels resting MBT order; 404 if not
found or already terminal.
New endpoint
POST /api/trading/paper/reset
Permission: operator role or admin initially; promoted to customer-facing in
a follow-on sub-card.
Request body: {} (empty)
Response:
{
"status": "ok",
"new_cash_balance": "100000.00",
"message": "Paper account reset to starting balance."
}
Behavior: sets paper_accounts.cash_balance and equity to the configured
starting balance ($100,000 default or investor-profile value from ADR-0015),
archives all open positions (sets closed_at = now()), cancels all resting
orders, writes an audit_log row with action = "paper_account_reset".
5. Fill Engine Spec
The fill engine is a pure function: fill(order, bar) → fill_result | None.
No side effects inside the function; callers persist results.
# Market order fill (triggered at submission)
def fill_market(order, last_bar):
if not is_trading_session(last_bar.timestamp):
return FillResult(status='queued_to_open')
mid = (last_bar.high + last_bar.low) / 2
if order.side == 'buy':
fill_price = mid
slippage = fill_price - last_bar.close
else:
fill_price = mid
slippage = last_bar.close - fill_price
return FillResult(
status='filled',
fill_price=fill_price,
slippage=slippage,
filled_at=now_utc(),
)
# Limit order evaluation (called per bar tick for resting orders)
def evaluate_limit(order, bar):
if order.side == 'buy' and bar.low <= order.limit_price:
return FillResult(status='filled', fill_price=order.limit_price,
slippage=Decimal('0'), filled_at=bar.timestamp)
if order.side == 'sell' and bar.high >= order.limit_price:
return FillResult(status='filled', fill_price=order.limit_price,
slippage=Decimal('0'), filled_at=bar.timestamp)
if order.time_in_force == 'day' and bar_closes_session(bar):
return FillResult(status='expired')
return None # still resting
# Multi-leg options: call fill_market or evaluate_limit per leg
# Net P&L = sum(leg.fill_price * leg.qty * leg.side_sign)
# Expiration: if option expires ITM, auto-close at intrinsic value
# Expiration: if option expires OTM, write fill_price=0, status='expired'
Bar data source: historical_bars table (populated by historical_bars_service.py,
PR #3023). On submission, the fill engine queries the most recent bar for the
symbol. Bar latency is end-of-day for v1 (acceptable for equity paper trading;
documented clearly in the UI).
Compliance gate position: check_trade_compliance is called before the fill
engine. A compliance rejection results in status='rejected' with
reject_reason set to the first violation. The fill engine is never reached for
rejected orders.
Audit: every fill (including rejections and expirations) writes an
audit_log row via customer_audit_writer_service.py.
6. Integration Plan
Flag routing
FLAG_MBT_ENGINE is the primary gate. Routes in trading.py inspect this flag
at request time:
if feature_flags.is_enabled("MBT_ENGINE"):
return mbt_service.handle_account(user_id)
return alpaca_path()
A future per-user gate uses customer_trading_mode_overrides (migration 0013)
extended with a mbt_engine_opt_in column, or a separate
customer_feature_overrides table (Phase 2).
Coexistence
The broker-mirror paper_orders table (migration 0002) is renamed
broker_paper_orders in the MBT schema migration. Existing data is preserved.
paper_order_service.py is updated to reference broker_paper_orders. No
customer state is lost.
The new MBT tables coexist with customer_trading_mode_overrides,
order_strategy_links, backtest_runs, and strategies.
Backtest reuse
The fill engine module (mbt_fill_engine.py) accepts a mode parameter:
'live' (request-time evaluation) or 'replay' (batch bar-by-bar iteration
for backtests). backtest_runner.py (PR #3023) is updated to import and call
mbt_fill_engine rather than maintaining a separate matching implementation.
This is a refactor sub-card; backtest behavior is not changed.
Existing paper_order_service.py
Remains as the fallback path when FLAG_MBT_ENGINE=0. Deprecated in Phase 5
(sunset). No changes to this file until then.
7. Rollout and Cutover
| Phase | Gate | Description |
|---|---|---|
| 1 | FLAG_MBT_ENGINE=0 (default off) |
Ship tables, service, routes. No customer impact. CI covers all new code paths. |
| 2 | Operator enables flag for self | 7-day dogfood. Compare MBT fills against Alpaca paper fills for the same symbols/orders. Surface fill-drift report. |
| 3 | FLAG_MBT_PUBLIC=1 (early access) |
Open to opted-in customers. Monitor fill rejection rates and account-reset usage. |
| 4 | Default on for new signups | Existing customers stay on broker paper unless they opt in via explicit toggle. |
| 5 | Sunset broker paper | Deprecate paper_order_service.py. Alpaca paper credentials removed from env. |
Rollback per phase:
- Phase 1–3: flip FLAG_MBT_ENGINE=0; all routes revert to Alpaca path instantly. No data migration on rollback; MBT tables remain unpopulated.
- Phase 4: per-user flag rollback restores Alpaca paper for that user; MBT tables retain their state for re-enable.
- Phase 5: no rollback path — this is the sunset. Documented in the Phase 5 sub-card as a hard cutover.
Kill switches:
- MBT_TRADING_DISABLED=1 — 503 on all order submits; reads still work.
- MBT_NEW_ORDERS_DISABLED=1 — new submits blocked; cancels and reads work.
8. Risks and Open Questions
Risks
Fill fidelity vs broker paper. MBT fills from bar midpoints will diverge
from Alpaca paper fills (which use NBBO tick data). Phase 2 comparison harness:
submit identical orders to both paths for 7 days; measure |mbt_fill_price −
alpaca_fill_price| / alpaca_fill_price. Target: p90 drift < 0.5%. If drift
exceeds threshold, re-evaluate NBBO tick integration before Phase 3.
Bar data latency. historical_bars is populated end-of-day in v1. Market
orders submitted during the trading day fill against the previous close bar.
This is disclosed in the UI ("fills use most recent bar data; real-time fills
available in Phase 2"). Intraday bar feed is a Phase 2 concern.
broker_paper_orders rename. Renaming the existing paper_orders table
requires care: any un-migrated code or direct SQL outside Alembic references the
old name and will break. Feature-developer sub-card must include a
comprehensive grep + update step.
Regulatory framing. Paper trading is low-risk regulatory territory. Adding margin/leverage simulation in a future version changes the risk profile — note for future BLR review before any such feature ships.
Open questions (need operator decision before sub-cards are claimed)
- ✅ OQ-1 — Intraday bar fidelity for Phase 2 — RESOLVED 2026-05-28: intraday 1-minute bars. See ADR-0110.
~~Should MBT Phase 2 fetch intraday bars (1-min or 5-min) from Alpaca market data for fill evaluation, or is end-of-day acceptable for launch? End-of-day is simpler; intraday is more realistic but adds bar-cache complexity and Alpaca API cost.~~
-
Per-user MBT flag gate. Should Phase 3 use a
customer_feature_overridestable (new), extendcustomer_trading_mode_overrideswith a column, or rely solely on a global flag? Affects how granularly the operator can onboard specific customers to MBT. -
Multi-broker BYOB paper parity. When SnapTrade / Schwab live broker connections ship, MBT remains the paper-side simulator and live trading goes to the user's chosen broker. Confirm this is the intended permanent split so sub-cards can be scoped accordingly.
-
Closed-position retention policy.
paper_positionsrows withclosed_at IS NOT NULLaccumulate over time. Proposed: archive (soft-delete or move topaper_positions_archive) closed positions after 90 days. Confirm retention matches tier policy (Free 90d / Pro 3yr / Pro+ unlimited, per ADR-0003).
9. Security and GDPR Checklist
-
PII collected:
user_id,symbol, trade amounts, P&L figures. All classified as financial PII. No names, addresses, or contact data in MBT tables. -
Retention period: follows tier policy — Free 90d / Pro 3yr / Pro+ unlimited. Nightly Celery purge (already pattern from ADR-0013). Closed positions: proposed 90d archive (open question #4 above).
-
Deletion on DSR:
users.idCASCADE DELETE covers all three MBT tables at the DB level. After 30-day cooling period (ADR-0003), hard-deleted.audit_logrows persist with hashed actor ID for 2 years (ADR-0003 §4). -
Audit trail: every order submit, fill, rejection, expiration, and account-reset writes to
audit_logviacustomer_audit_writer_service.py. Retained 7 years for trade-affecting rows per ADR-0003. -
Stored credentials: none. MBT has no broker token. ADR-0002 invariant fully satisfied for MBT users. The existing ADR-0009 exception remains scoped to Pro+ live-handoff only.
-
Breach notification path: financial PII breach triggers the standard ADR-0003 path. MBT tables contain no credentials; breach impact is limited to simulated trade history. Notification timeline: 72h to supervisor authority per GDPR Art. 33.
-
Secrets location and rotation: none specific to MBT. Alpaca market-data key (
ALPACA_MARKET_DATA_KEY) lives in the secret store, not in code. Rotatable without redeploy. -
Kill-switch:
MBT_TRADING_DISABLED=1andMBT_NEW_ORDERS_DISABLED=1(see Rollout §7).FLAG_MBT_ENGINE=0reverts all routes to the Alpaca paper path.
10. Language Choice Rationale
MBT is not a new standalone service. It is a new module within Raptor
(backend_v2/), which is Tier 2 (Python) per docs/architecture/language-tier-policy.md.
Service: MBT fill engine and service layer within Raptor
Language tier: Tier 2 — Python
Rationale: The fill engine is a domain-logic module, not a latency-critical hot path. At v1 paper-trading scale (operator + early-access customers), the engine executes on order submission and on EOD batch jobs — neither path has a p99 < 5ms requirement. Memory-safety requirements do not apply (no credentials, no auth material). Domain logic changes frequently during early iteration (fill model, slippage, options semantics), which favors Python's iteration speed. Criterion C-1 through C-6 from the language tier policy are not met.
API contract portability: The MBT service layer exposes the same HTTP contract as the existing trading routes. If a future Tier 1 promotion is warranted (e.g., intraday bar evaluation at scale triggers C-2/C-3), the HTTP contract requires no redesign — the Tier 2 implementation is the contract specification.
11. Alternatives Considered
Keep Alpaca paper permanently
Rejected. Multi-broker BYOB means users whose live broker is Schwab or SnapTrade would paper-trade through Alpaca — different fills, different account model, different credential surface. Data sovereignty and fill auditability are product requirements at BYOB scale.
Third-party paper-trading library or service
Rejected. Same reasoning as ADR-0013 §Alternatives. No mature library covers multi-leg options, compliance integration, and tier-gated retention. Commercial options (QuantConnect-as-backend) cede too much product control.
Separate MBT microservice (not embedded in Raptor)
Rejected for v1. Raptor already owns the DB, the compliance checker, the trading routes, and the session auth. A separate service adds network hops, operational overhead, and a new deployment unit without a latency or isolation benefit at current scale. Revisit if MBT order volume triggers C-2/C-3 criteria (see language tier policy).
12. References
- [ADR-0013](https://internal-docs.raxx.app/architecture/adr/0013-mbt-paper-trading-engine.html) — original MBT decision, Deferred 2026-05-29
- [ADR-0014](https://internal-docs.raxx.app/architecture/adr/0014-alpaca-scope-reframe.html) — Alpaca narrows to market data + live handoff
- [ADR-0015](https://internal-docs.raxx.app/architecture/adr/0015-mbt-investor-profile-model.html) — starting-balance investor profiles
- [ADR-0019](https://internal-docs.raxx.app/architecture/adr/0019-business-day-calendar.html) — NYSE calendar for session gating
- [ADR-0107](https://internal-docs.raxx.app/architecture/adr/0107-strategy-library.html) — strategy rule grammar; MBT compliance integration
docs/architecture/mbt-paper-trading-engine.md— extended design doc (draft, 2026-04-22)- Project memory:
project_strategic_position,project_structure_gap_thesis,feedback_deterministic_execution_ai_augments,project_byob_hybrid_strategy
13. Consequences
Positive
- Structure enforcement at paper execution is engine-owned, not broker-proxied. Fill audit trail is fully reproducible ("why did this fill at $X").
- Alpaca paper credential removed from the critical path; MBT users have no broker token. ADR-0002 invariant fully satisfied.
- Graduation gate (PR #3021) reads from Raptor's Postgres rather than an Alpaca API call — faster, cheaper, more reliable.
- Backtest runner and live fill engine share code; no fork to maintain.
- BYOB live-broker expansion does not require changing the paper layer.
Negative
- Feature-developer builds the fill engine, service layer, migrations, and comparison harness — multi-sprint effort.
broker_paper_ordersrename touchespaper_order_service.pyand any direct SQL references; requires careful grep + update.- Fill fidelity is lower than NBBO-tick fills until Phase 2 intraday bar integration. Must be disclosed clearly in the UI.
Neutral
- Celery + Redis async jobs (EOD mark, retention purge) are already the Raptor pattern; MBT adds to the existing task inventory without new infrastructure.
Revisit when
- Phase 2 fill-drift report shows p90 drift > 0.5% — re-evaluate intraday bar feed integration.
- MBT order volume sustains p99 > 100ms for 7 days (triggers C-2 Tier 1 review per language tier policy).
- Multi-broker BYOB ships a second live connector — confirm MBT remains the neutral paper layer and no broker-specific paper path is added.
- Regulatory counsel engages on paper trading scope — may widen or narrow features (e.g., simulated margin) before they ship.