Raxx · internal docs

internal · gated

ADR-0139 — Multi-Broker Platform Support for v1 Live Trading

Status: Proposed Date: 2026-07-13 UTC Deciders: Kristerpher (operator); software-architect (design) Scope: Raptor (backend_v2/), Velvet (token lifecycle), Antlers (frontend/raxx-next/), onboarding wizard (BrokerConnectView) Supersedes (in part): ADR-0052 (expands interface contract), ADR-0014 (extends token table pattern to all Tier A brokers) Extends: ADR-0109 (BYOB tier model), ADR-0052 (adapter interface pattern) Refs: Epic #495, Research doc #2627 (BLR), parent card driving this design


1. Context

Raxx enters v1 live trading after the MBT paper-beta soak. The operator requirement is a minimum of two active trading platforms at v1-live launch:

ADR-0109 established the broker tier model (Tier A direct, Tier B aggregator, Tier C read-only), the Velvet token storage extension, and the phased rollout roadmap but deferred the v1-live implementation specifics — the interface contract, the Alpaca migration path, and the platform-selection sub-step in the onboarding wizard — to this ADR.

Paper trading (MBT, ADR-0108) is permanently broker-independent. This ADR governs the live execution path only.


2. Invariants

These constraints are non-negotiable. Any sub-card that conflicts with them must surface the conflict before work begins.


3. Decision

3.1 Three platforms for v1-live

Platform Tier Auth mechanism Current state
Alpaca Tier A direct OAuth 2.0 (ADR-0014) Exists; needs adapter wrap
Tradier Tier A direct OAuth 2.0 (3-legged; same pattern as Alpaca) New
SnapTrade Tier B aggregator SnapTrade connection ID; downstream broker token held by SnapTrade Stub → complete (Phase 2)

Tradier is the recommended second direct broker. See §4 for evaluation against tastytrade. The operator may override; the interface contract below applies unchanged to either choice.

SnapTrade is Phase 2 (post Tradier soak). Sub-cards are filed now so card-groomer can sequence them against the SnapTrade contract timeline.

3.2 BrokerAdapter interface contract

The interface expands ADR-0052's stub to cover the full live-execution surface, with explicit support for multi-leg options orders required by Raxx's iron-condor and spread workflows.

All adapters implement this contract. Location: backend_v2/api/services/broker/base.py.

# Stub types only — feature-developer chooses concrete library and dataclass shapes.

class BrokerAdapter(ABC):

    # ── Identity ─────────────────────────────────────────────────────────────
    broker_id: ClassVar[str]          # slug: "alpaca" | "tradier" | "snaptrade"
    display_name: ClassVar[str]       # brand name for Settings detail view only
    supports_options: ClassVar[bool]  # True: Alpaca, Tradier; check per-broker for SnapTrade

    # ── Auth / connection lifecycle ───────────────────────────────────────────
    @abstractmethod
    def get_auth_url(self, user_id: str, redirect_uri: str) -> str: ...
    # Returns the OAuth redirect URL the user follows to authorize Raxx.
    # For SnapTrade: returns the SnapTrade-hosted connection widget URL.

    @abstractmethod
    def exchange_code(self, code: str, state: str) -> ConnectionArtifact: ...
    # Exchanges the OAuth authorization code for a token artifact.
    # Returns an opaque artifact that Velvet stores.
    # Raptor never persists the plaintext token after this call returns.

    @abstractmethod
    def revoke(self, velvet_token_id: str) -> None: ...
    # Calls the upstream revoke endpoint, then marks the Velvet row revoked_at.
    # Must be idempotent — safe to call on an already-revoked token.

    # ── Account state ─────────────────────────────────────────────────────────
    @abstractmethod
    def get_account(self, velvet_token_id: str) -> AccountState: ...
    # Returns: cash, buying_power, account_id, currency, account_type.

    @abstractmethod
    def get_positions(self, velvet_token_id: str) -> list[Position]: ...
    # Returns open positions: symbol, qty, avg_cost, market_value, asset_class.

    # ── Order management ──────────────────────────────────────────────────────
    @abstractmethod
    def submit_order(self, velvet_token_id: str, order: OrderSpec) -> OrderResult: ...

    @abstractmethod
    def cancel_order(self, velvet_token_id: str, order_id: str) -> CancelResult: ...

    @abstractmethod
    def replace_order(
        self, velvet_token_id: str, order_id: str, patch: OrderPatch,
    ) -> OrderResult: ...

    @abstractmethod
    def get_order_status(self, velvet_token_id: str, order_id: str) -> OrderStatus: ...

    # ── Market context ────────────────────────────────────────────────────────
    @abstractmethod
    def is_market_open(self) -> bool: ...
    # Adapter may delegate to the shared MarketDataHub (Alpaca data account)
    # for market hours rather than calling the broker API separately.

    @abstractmethod
    def health_check(self) -> HealthResult: ...
    # Returns {ok: bool, latency_ms: int, message: str}.
    # Feeds the status-page surface poller ([ADR-0030](https://internal-docs.raxx.app/architecture/adr/0030-status-state-machine.html)/0121) without custom-casing
    # each broker.

3.3 Options order support (multi-leg)

OrderSpec is the cross-broker order description. It must support single-leg and multi-leg options. This is the primary reason Tradier is preferred at Tier A — its REST API handles multi-leg options natively in a single call.

@dataclass
class Leg:
    symbol: str     # OCC option symbol (e.g. "SPY230120C00400000") or equity ticker
    action: str     # "buy_to_open" | "sell_to_open" | "buy_to_close" | "sell_to_close"
    qty: int

@dataclass
class OrderSpec:
    order_type: str         # "market" | "limit" | "net_debit" | "net_credit"
    tif: str                # "day" | "gtc" | "ioc"
    legs: list[Leg]         # 1 leg = single; 2 legs = vertical/spread; 4 legs = iron condor
    limit_price: Decimal | None
    # Each adapter translates this to the broker's native multi-leg format.
    # For SnapTrade Tier B: if the downstream broker does not support multi-leg
    # via SnapTrade, the adapter raises UnsupportedOrderError — never silently
    # splits a multi-leg order into single-leg submissions.

3.4 Alpaca migration path (incremental, no big-bang rewrite)

The 429 Alpaca references must not be broken by a single migration. The approach is adapter-wraps-existing, proven in three passes, each independently shippable:

Pass 1 — Wrap (SC-2): Create AlpacaBrokerAdapter(BrokerAdapter) in backend_v2/api/services/broker/adapters/alpaca.py. It delegates to the existing alpaca_integration.py functions unchanged. Register in BrokerAdapterRegistry. Trading routes that submit orders call registry.get_adapter_for_user(user_id). No existing function in alpaca_integration.py is deleted or modified.

Pass 2 — Route (SC-2, same card): Replace direct imports of alpaca_integration.py in live-execution routes (trading.py, options.py) with registry calls. The underlying functions stay untouched. Paper-trading paths are untouched (MBT, not Alpaca).

Pass 3 — Internalize (SC-tech-debt, filed separately, not a v1-live blocker): Move Alpaca-specific logic from alpaca_integration.py into the adapter methods. Only after Pass 2 has soaked in staging.

BrokerAdapterRegistry is a dict-backed service locator populated at app startup, consistent with the existing Flask pattern. No DI container is introduced.

3.5 Per-user credential storage

ADR-0014 established alpaca_live_connections for Alpaca. This ADR generalizes it to a single broker_live_connections table covering all Tier A and Tier B brokers. Velvet holds the ciphertext; Raptor holds the reference ID (velvet_token_id).

-- Raptor-side reference table (token ciphertext lives in Velvet)
CREATE TABLE broker_live_connections (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id              UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    broker_slug          TEXT NOT NULL,
    broker_tier          TEXT NOT NULL CHECK (broker_tier IN ('tier_a','tier_b')),
    velvet_token_id      UUID NOT NULL,
    scopes               TEXT NOT NULL DEFAULT '',
    account_id_at_broker TEXT NULL,
    connection_state     TEXT NOT NULL DEFAULT 'active'
        CHECK (connection_state IN ('active','needs_reauth','revoked','suspended')),
    connected_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at         TIMESTAMPTZ NULL,
    revoked_at           TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX broker_live_connections_active_uniq
    ON broker_live_connections (user_id, broker_slug) WHERE revoked_at IS NULL;
-- One active connection per broker per user in v1.
-- Relaxed in Phase 4+ for multi-broker households ([ADR-0109](https://internal-docs.raxx.app/architecture/adr/0109-byob-roadmap.html) §4).

alpaca_live_connections rows (ADR-0014) are migrated to this table as part of SC-3. Existing encrypted Alpaca tokens in Velvet are backfilled with broker_slug = 'alpaca' and broker_tier = 'tier_a' after the Velvet schema extension (SC-4) ships.

3.6 BrokerConnectView extension

BrokerConnectView.tsx currently presents only a paper | live binary choice. Its BrokerMode type and onNext({ mode }) contract are preserved.

When the user selects live, a new BrokerPlatformSelectView sub-step is rendered. It presents only the brokers available in the user's subscription tier:

Copy rules (invariant): Broker brand names appear in the platform-select list items only. The wizard headline and body copy remain generic ("Connect your brokerage account"). SnapTrade is never mentioned; its downstream brokers appear by their own brand names in the aggregator-powered list. The aggregator is invisible to the user at every UX layer.

3.7 Live-vs-paper gating

The paper-first gate reads MBT cycle history. It is broker-agnostic: a user graduating from MBT paper to live Tradier follows the same logic as a user graduating to live Alpaca. The adapter layer is not involved until the gate clears.

Two paths through the gate: 1. Standard: N profitable MBT cycles (configurable via PAPER_GRAD_CYCLES, default 5). 2. Override: operator sets FLAG_LIVE_TRADING_GATE_OVERRIDE=true per-user via the console. Override is audited (actor, reason, timestamp). Requires step-up WebAuthn from the operator performing the override.

Live order submission call sequence (in this order, no reordering permitted): 1. Kill-switch check (BYOB_LIVE_DISABLED, BYOB_BROKER_<SLUG>_DISABLED). 2. Paper-first gate state verified (live check against DB; not trusted from session). 3. Trade-window compliance check (existing trade_window_compliance_enforcement.py). 4. Step-up WebAuthn token verified. 5. Pre-submit audit_log row written (actor, broker_slug, order_spec, timestamp). 6. Adapter submit_order() called. 7. audit_log row updated with broker-issued order ID and result status.


Tradier

Criterion Assessment
Options Full multi-leg via REST API (equity + options in one endpoint). Required for iron-condor workflow.
Sandbox Public sandbox at sandbox.tradier.com. No funded account required. Available immediately.
OAuth Standard 3-legged OAuth 2.0 auth-code flow. AlpacaBrokerAdapter is the template.
API maturity Production since 2012. Stable and well-documented.
Developer TOS Public developer program. BLR review required before production (OQ-1).
FINRA / SIPC Yes — options levels 1-4.
Integration effort ~2 engineering weeks (adapter + OAuth + options order mapping + sandbox QA).
Criterion Assessment
Options Excellent — built for options traders, multi-leg native.
Sandbox Partner program required (api.support@tastytrade.com). Approval adds 2-4 weeks before engineering can start.
OAuth OAuth 2.0 — same once access is granted.
Developer TOS BLR review required before production (same scope as Alpaca #932).
Integration effort ~3 engineering weeks (partner approval + adapter, same methods).

Recommendation: Tradier. The self-serve sandbox eliminates the 2-4 week partnership-approval gate that blocks tastytrade from starting. The OAuth and options-order API patterns are virtually identical to Alpaca, minimizing novelty. Tastytrade remains a strong Phase 3 Tier A candidate; it is not ruled out.

Hard regulatory dependency (OQ-1): BLR must review Tradier's developer API agreement under the same scope as Alpaca #932 before Tradier orders go to production. The Tradier staging/sandbox sub-cards can proceed; the production-deploy sub-card (SC-7) is blocked on BLR sign-off.


5. Migrations

Migration A — broker_live_connections table

-- File: backend_v2/alembic/versions/0040_broker_live_connections.py
-- POSTGRES-ONLY

CREATE TABLE broker_live_connections (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id              UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    broker_slug          TEXT NOT NULL,
    broker_tier          TEXT NOT NULL CHECK (broker_tier IN ('tier_a','tier_b')),
    velvet_token_id      UUID NOT NULL,
    scopes               TEXT NOT NULL DEFAULT '',
    account_id_at_broker TEXT NULL,
    connection_state     TEXT NOT NULL DEFAULT 'active'
        CHECK (connection_state IN ('active','needs_reauth','revoked','suspended')),
    connected_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at         TIMESTAMPTZ NULL,
    revoked_at           TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX broker_live_connections_active_uniq
    ON broker_live_connections (user_id, broker_slug) WHERE revoked_at IS NULL;
CREATE INDEX broker_live_connections_user_id ON broker_live_connections (user_id);
CREATE INDEX broker_live_connections_velvet_id ON broker_live_connections (velvet_token_id);

Rollback: DROP TABLE broker_live_connections; — safe until Alpaca row migration (Migration B) ships.

Migration B — Alpaca row migration (SC-3, after adapter wrap is stable)

-- File: backend_v2/alembic/versions/0041_alpaca_rows_to_broker_live_connections.py
-- POSTGRES-ONLY

INSERT INTO broker_live_connections (
    id, user_id, broker_slug, broker_tier, velvet_token_id,
    scopes, connection_state, connected_at, last_used_at, revoked_at
)
SELECT
    id, user_id, 'alpaca', 'tier_a', id,
    -- velvet_token_id uses the same UUID as the alpaca_live_connections row
    -- until Velvet SC-4 backfill adds the canonical Velvet token UUID.
    scopes, CASE WHEN revoked_at IS NULL THEN 'active' ELSE 'revoked' END,
    issued_at, last_used_at, revoked_at
FROM alpaca_live_connections;
-- alpaca_live_connections retained (not dropped) for 1 sprint verification window.
-- Separate follow-on migration drops the old table.

Rollback: Repoint AlpacaBrokerAdapter to alpaca_live_connections directly (one config switch); broker_live_connections Alpaca rows can be truncated safely during the verification window.

Migration C — Velvet schema extension (SC-4, delegated to Velvet sub-card)

Add broker_tier TEXT NOT NULL DEFAULT 'tier_a', broker_slug TEXT NOT NULL DEFAULT 'alpaca', and no_order_write BOOLEAN NOT NULL DEFAULT FALSE to Velvet's tokens table. Backfill existing rows. no_order_write = TRUE enforces read-only at the Velvet service layer for future Tier C connections, independent of application logic.


6. Rollout Plan

Phase Flag Scope Duration
Dark FLAG_MULTI_BROKER_ADAPTER=off SC-1 through SC-4 merged; adapter registry not activated in routes During remaining beta soak
Alpha FLAG_MULTI_BROKER_ADAPTER=on (operator only) Tradier live orders available to operator in staging; Alpaca default unaffected 2 weeks
Beta FLAG_TRADIER_LIVE=on (selected Pro+ users) Tradier live orders to hand-picked beta cohort; Alpaca default unaffected 2 weeks minimum
GA FLAG_TRADIER_LIVE=on (all eligible users) Tradier available to all Pro+ users
SnapTrade FLAG_SNAPTRADE_LIVE=on SnapTrade integration promoted after GA soak + contract signed (OQ-3) TBD

Kill-switch: BYOB_BROKER_TRADIER_DISABLED=1 reverts Tradier orders to an error without touching Alpaca. BYOB_LIVE_DISABLED=1 disables all BYOB live orders. MBT paper trading is unaffected by all kill-switches.


7. Security Considerations

All live order submission paths are security-critical surfaces because real money moves.


8. Security / GDPR Checklist


9. Language Choice Rationale

This ADR does not introduce a new service. All new modules are additions to existing services:

Service: Raptor (modified, not new) Language tier: - [x] Tier 2 — Python: broker adapter modules, registry, migration.

Broker adapter modules in Raptor do not trigger Tier 1 promotion criteria: broker API round-trips are 100-500ms regardless of the adapter implementation language (no sub-5ms p99 budget applies here); no cryptographic key material is handled in the adapter (Velvet holds all ciphertext); throughput at v1 post-launch scale does not stress a Python adapter. The credential-handling hot path is already Tier 1 (Velvet, Rust, operator-designated). Reference: docs/architecture/language-tier-policy.md.

API contract portability (Tier 2): The BrokerAdapter ABC method signatures are portable to a future Rust/C++ implementation without redesign. The one portability risk is Python exception types in error paths — these should be codified as enum error codes in OrderResult rather than exception class names to preserve language neutrality.

Antlers (BrokerPlatformSelectView): Tier 2 — TypeScript/Next.js, consistent with all Antlers components.

Velvet (schema extension only, SC-4): Tier 1 — Rust, operator-designated. The schema migration is an ALTER TABLE; no new Velvet service logic is introduced by this ADR beyond the no_order_write enforcement already described in ADR-0109.


10. Alternatives Considered

SnapTrade as the only integration (no named direct broker)

Rejected. Operator explicitly required "both — aggregator + a named direct broker." Additionally, multi-leg options support via SnapTrade is downstream-broker-dependent and cannot be guaranteed for the brokers most relevant to iron-condor traders. Tradier Tier A gives Raxx a reliable, Raxx-controlled options order path.

Tastytrade as the second direct broker instead of Tradier

Not rejected as a future target. Deferred for v1-live because the partner-program approval gate (2-4 weeks to obtain sandbox access) delays the start of engineering work. Tradier's self-serve sandbox allows work to start immediately after BLR TOS review clears. Tastytrade is filed as open question OQ-2.

Big-bang refactor: rewrite all 429 Alpaca references at once

Rejected. A single large PR touching trading.py, options.py, trading_runtime.py, historical_bars_service.py, and related routes simultaneously creates high regression risk across paper trading, market data, and live-execution paths. The incremental adapter-wraps-existing approach (§3.4) preserves all existing behavior through each pass and allows each pass to be reviewed, tested, and soaked independently.

Extend alpaca_integration.py with Tradier branches

Rejected per ADR-0052 — the file grows unbounded with each new broker and cannot be tested cleanly. Adapter isolation is required for independent testing and for the health_check() feed to the status page.

Fidelity as the second direct broker

Rejected. Fidelity's direct API (WIX / FDX per ADR-0050/0123) is enterprise-gated and access approval is slow and uncertain. Fidelity is available via SnapTrade (Tier B) once Phase 2 ships. Tradier unblocks work now.


11. Open Questions (operator decisions required)

OQ-1 (hard blocker for SC-7 — Tradier production deploy): BLR must review Tradier's developer API agreement and third-party platform terms under the same scope as the Alpaca TOS review (#932) before Tradier orders can reach production. BLR engagement must be initiated in parallel with this ADR. The SC-7 sub-card carries the blocked label until BLR sign-off is received.

OQ-2 (Phase 3 input, not a v1-live blocker): After Tradier GA soak: does the operator want tastytrade as a second named Tier A direct broker, or defer tastytrade to the SnapTrade Tier B integration (which may route tastytrade accounts naturally via the aggregator)?

OQ-3 (hard blocker for SC-8, SC-9 — SnapTrade integration): SnapTrade contract and pricing tier. SnapTrade sub-cards are blocked until contract is signed. Operator action: initiate SnapTrade contract discussion. Last known pricing from BLR research doc #2627: ~$2/user/month for real-time + trading access.

OQ-4 (Tradier scope, affects SC-5 acceptance criteria): Tradier supports extended-hours orders and certain margin products. For v1-live, are these in scope, or should the adapter restrict to regular-hours equity and options orders only? Recommendation: restrict to regular-hours in v1; extended-hours is a follow-on tech card.


12. References


Revisit when