Raxx · internal docs

internal · gated

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:

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)

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-ONLY sentinel 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)

  1. 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.~~

  1. Per-user MBT flag gate. Should Phase 3 use a customer_feature_overrides table (new), extend customer_trading_mode_overrides with a column, or rely solely on a global flag? Affects how granularly the operator can onboard specific customers to MBT.

  2. 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.

  3. Closed-position retention policy. paper_positions rows with closed_at IS NOT NULL accumulate over time. Proposed: archive (soft-delete or move to paper_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


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


13. Consequences

Positive

Negative

Neutral


Revisit when