Skip to main content

Robinhood: System Design #

A retail brokerage platform. Equity, options, and crypto trading with zero-commission execution. Grounded in the FIX Protocol specification (FIX Trading Community), Zipline’s order management and risk primitives, and QuantLib’s options pricing engine.


1. Requirements #

Functional Requirements #

  1. Order placement — market, limit, stop, stop-limit orders for equities and ETFs
  2. Options trading — single-leg and multi-leg options orders; Greeks display
  3. Fractional shares — buy/sell sub-share quantities by dollar amount
  4. Order management — cancel, replace (modify) open orders
  5. Market data — real-time quotes, Level 2 order book, charts
  6. Portfolio — positions, P&L (realized and unrealized), buying power
  7. Account funding — ACH deposit/withdrawal, instant deposit (pre-settlement credit)
  8. Corporate actions — splits, dividends, spinoffs applied to positions
  9. Order history — full audit trail of orders and executions

Non-Functional Requirements #

  1. Availability — 99.99% during market hours (09:30–16:00 ET weekdays); planned maintenance only in off-hours
  2. Order latency — P99 < 200ms from API receipt to FIX NewOrderSingle sent to market maker
  3. Market data throughput — ingest SIP consolidated tape (~1M quote updates/sec at peak) and OPRA options feed (~5M updates/sec)
  4. Consistency — order state is authoritative; buying power and positions must be consistent after each fill
  5. Regulatory compliance — FINRA Rule 4210 (margin), FINRA Rule 4370 (PDT), SEC Rule 15c3-3 (customer fund segregation), SEC Rule 606 (order routing disclosure), FINRA Rule 3110 (supervision)
  6. Auditability — every order event timestamped and immutable; FIX message log retained 3 years (SEC Rule 17a-4)

Capacity Estimation #

MetricEstimate
Active users20M accounts; ~2M daily active traders
Peak order rate~50K orders/min at market open
Market data ingestSIP: ~1M updates/sec; OPRA: ~5M updates/sec
Positions records~200M (20M accounts × avg 10 positions)
Order history storage~500B orders/year × ~500 bytes = ~250GB/year
FIX message log~1TB/year (SEC Rule 17a-4 retention)

Market open (09:30 ET) is the single highest-stress moment — order flow spikes 10–20× the intraday average in the first minute.


2. Core Entities #

Order #

The central entity. Zipline’s Order class captures the canonical fields:

# From zipline-reloaded/src/zipline/finance/order.py
class Order:
    __slots__ = [
        "id",           # UUID: client order ID (ClOrdID in FIX)
        "dt",           # datetime: last state change
        "created",      # datetime: order creation time
        "asset",        # Asset: the security (equity, ETF, option)
        "amount",       # int: shares to buy (>0) or sell (<0)
        "filled",       # int: shares filled so far
        "commission",   # float: accumulated commission
        "_status",      # ORDER_STATUS enum
        "stop",         # float | None: stop price
        "limit",        # float | None: limit price
        "stop_reached", # bool: stop trigger fired
        "limit_reached",# bool: limit trigger fired
        "direction",    # +1 (buy) or -1 (sell)
        "broker_order_id",  # str | None: exchange-assigned OrderID
    ]

ORDER_STATUS = IntEnum("ORDER_STATUS", [
    "OPEN",       # 0 — working at venue
    "FILLED",     # 1 — fully executed
    "CANCELLED",  # 2 — user or system cancelled
    "REJECTED",   # 3 — refused by venue or risk engine
    "HELD",       # 4 — blocked pending risk review; reverts to OPEN on partial fill
])

HELD is Zipline’s internal state for orders that have been submitted but are blocked by a risk check or corporate action. It has no direct FIX equivalent — it maps to the OMS holding the order before sending NewOrderSingle.

Position #

# From zipline-reloaded/src/zipline/finance/ledger.py (PositionTracker)
class Position:
    asset: Asset
    amount: int          # current shares held (negative = short)
    cost_basis: float    # average cost per share (adjusted for splits)
    last_sale_price: float
    last_sale_date: datetime

The PositionTracker holds a dict of positions and updates them atomically via execute_transaction(txn). Every fill produces a Transaction that mutates the position.

Account / Ledger #

The Ledger aggregates PositionTracker + cash + P&L. Key derived quantities:

  • Buying power = cash + marginable securities × margin rate − open order reserves
  • Equity = cash + sum(position.amount × last_sale_price for all positions)
  • Leverage = abs(total_position_value) / equity

These are recomputed on every fill and on every market data tick that updates last_sale_price.

MarketData #

Two tiers:

TierSourceLatencyUse
SIP consolidated tapeCTA Plan (NYSE-listed) + UTP Plan (NASDAQ-listed)~1–5msQuotes, NBBO, last sale — regulatory best execution baseline
Direct exchange feedNYSE Pillar, NASDAQ TotalView, CBOEsub-msOptional for internal SOR; not surfaced to retail UI

OPRA (Options Price Reporting Authority) is the options equivalent of SIP — a consolidated feed from all options exchanges.

ExecutionReport (FIX tag 35=8) #

The message type that drives order state transitions. Every state change on an order at a venue produces an ExecutionReport routed back to the OMS:

ClOrdID     (tag 11)  — our order ID
OrderID     (tag 37)  — venue-assigned ID
ExecID      (tag 17)  — unique execution event ID (idempotency key)
OrdStatus   (tag 39)  — current order status
ExecType    (tag 150) — what happened (New, Fill, Partial Fill, Cancelled, Rejected, ...)
LeavesQty   (tag 151) — shares still open
CumQty      (tag 14)  — total shares filled
AvgPx       (tag 6)   — average fill price
LastShares  (tag 32)  — shares filled in this report
LastPx      (tag 31)  — price of this fill

3. API / System Interface #

Retail API (REST + WebSocket) #

The public-facing API Robinhood exposes to its mobile and web clients. Structurally similar to the Alpaca OpenAPI specification:

Orders

POST   /api/v1/orders/
GET    /api/v1/orders/{order_id}/
DELETE /api/v1/orders/{order_id}/          # cancel
PATCH  /api/v1/orders/{order_id}/          # replace (qty or limit price)
GET    /api/v1/orders/?status=open         # list open orders

Order request body:

{
  "symbol": "AAPL",
  "quantity": 10,
  "side": "buy",
  "type": "limit",
  "limit_price": "185.50",
  "time_in_force": "gtc"
}

For fractional shares, quantity is a decimal (e.g., "0.5") and notional replaces quantity for dollar-denominated orders.

Market Data (WebSocket)

wss://stream.robinhood.com/quotes/{symbol}
wss://stream.robinhood.com/account/updates

Options

GET  /api/v1/options/instruments/?chain_symbol=AAPL&expiration_date=2025-01-17
GET  /api/v1/options/instruments/{id}/
POST /api/v1/options/orders/          # same structure as equity orders, asset_class=option

FIX Protocol (Internal: Broker → Market Maker / Exchange) #

The wire format for routing orders downstream. QuickFIX/J implements this:

// From quickfixj-core/src/main/java/quickfix/Application.java
public interface Application {
    void onCreate(SessionID sessionId);     // new FIX session created
    void onLogon(SessionID sessionId);      // counterparty logged on
    void onLogout(SessionID sessionId);     // session disconnected
    void toApp(Message msg, SessionID sid) throws DoNotSend;   // outbound hook
    void fromApp(Message msg, SessionID sid) throws FieldNotFound, // inbound hook
        IncorrectDataFormat, IncorrectTagValue, UnsupportedMessageType;
}

Outbound: NewOrderSingle (D), OrderCancelRequest (F), OrderCancelReplaceRequest (G) Inbound: ExecutionReport (8), OrderCancelReject (9)

FIX session state is managed by SessionState.java:

// From quickfixj-core/src/main/java/quickfix/SessionState.java
public final class SessionState {
    private final MessageStore messageStore;    // persists all sent/received msgs
    private boolean logonSent;
    private boolean logonReceived;
    private long lastSentTime;
    private long lastReceivedTime;
    private long heartBeatMillis;              // HEARTBT_INT negotiated at logon
    private final AtomicInteger nextExpectedMsgSeqNum;  // gap-fill recovery
    private final ResendRange resendRange;      // seq nums pending resend
}

FIX sequence numbers are the transport-layer idempotency mechanism. Every session has a monotonically increasing MsgSeqNum. If the OMS restarts and reconnects with a NextExpectedMsgSeqNum in the Logon, the counterparty replays any missing messages — preventing lost order events.

Order Execution Styles (FIX OrdType tag 40) #

# From zipline-reloaded/src/zipline/finance/execution.py
class MarketOrder(ExecutionStyle):     # OrdType=1: fill at current price
class LimitOrder(ExecutionStyle):      # OrdType=2: fill at limit_price or better
class StopOrder(ExecutionStyle):       # OrdType=3: becomes market when stop_price hit
class StopLimitOrder(ExecutionStyle):  # OrdType=4: becomes limit when stop_price hit

LimitOrder uses asymmetric rounding — buy limits round down, sell limits round up, to the asset’s tick size (typically $0.01). This is a regulatory requirement: the posted limit must improve the price for the customer.

Standards Reference #

StandardBodyScope
FIX Protocol 4.2 / 4.4 / 5.0SP2FIX Trading CommunityOrder routing wire format between broker and venue
FINRA Rule 4370FINRAPattern Day Trader (PDT) rule enforcement
FINRA Rule 4210FINRAMargin requirements; RegT (50% initial, 25% maintenance)
SEC Rule 15c3-3SECCustomer fund segregation; cash reserve formula
SEC Rule 606SECOrder routing disclosure; quarterly reports on PFOF
SEC Rule 17a-4SEC3-year retention of order and communication records
DTCC / NSCC rulesDTCCT+1 equity settlement; netting; fail handling
OCC rulesOCCOptions clearing; expiration processing; exercise
OPRA feed specificationOPRAOptions market data consolidated feed format
SIP (CTA/UTP) plansCTA/UTPEquity consolidated tape; NBBO computation
Alpaca OpenAPI specAlpaca MarketsDe facto REST standard for broker APIs

4. Data Flow #

Order Submission Path #

Mobile/Web client
    │  POST /api/v1/orders/
    ▼
API Gateway
    │  auth, rate limit, parse
    ▼
Order Service (OMS)
    │  1. validate parameters
    │  2. run risk checks (TradingControl)
    │  3. reserve buying power
    │  4. persist order (status=OPEN)
    │  5. route to Smart Order Router
    ▼
Smart Order Router
    │  1. compute NBBO from SIP feed
    │  2. select venue (PFOF market maker or exchange)
    │  3. send FIX NewOrderSingle (D)
    ▼
Market Maker (Citadel, Virtu) or Exchange (NYSE, NASDAQ)
    │  FIX ExecutionReport (8) on fill
    ▼
OMS ExecutionReport handler
    │  1. deduplicate by ExecID
    │  2. update order (filled += LastShares, status if complete)
    │  3. release / adjust buying power
    │  4. create Transaction record
    │  5. update PositionTracker
    │  6. push update to client via WebSocket

Market Data Path #

Exchange feeds → SIP processors (CTA/UTP) → Normalized quote stream
                                                    │
                          ┌─────────────────────────┤
                          │                         │
                   Quote cache (Redis)         NBBO engine
                   (for REST queries)          (for SOR, risk)
                          │
                   WebSocket fanout
                   (to connected clients)

5. High Level Design #

┌─────────────────────────────────────────────────────────┐
│                     Client Layer                        │
│         iOS App / Android App / Web (React)             │
└────────────────┬─────────────────────┬──────────────────┘
                 │ REST                │ WebSocket
┌────────────────▼─────────────────────▼──────────────────┐
│                   API Gateway                           │
│   Auth (OAuth2/JWT) · Rate Limiting · Request Routing   │
└────────┬──────────────────┬──────────────────┬──────────┘
         │                  │                  │
┌────────▼───────┐ ┌────────▼───────┐ ┌────────▼────────┐
│  Order Service │ │ Market Data    │ │ Account Service │
│  (OMS)         │ │ Service        │ │                 │
│                │ │                │ │ positions       │
│ Blotter        │ │ SIP ingest     │ │ buying power    │
│ TradingControl │ │ OPRA ingest    │ │ margin calc     │
│ order state    │ │ quote cache    │ │ ACH/funding     │
└────────┬───────┘ └────────┬───────┘ └─────────────────┘
         │ FIX              │ WebSocket fanout
┌────────▼───────┐ ┌────────▼───────┐
│ Smart Order    │ │ Streaming      │
│ Router (SOR)   │ │ Gateway        │
│                │ │                │
│ NBBO check     │ │ per-symbol     │
│ venue select   │ │ subscription   │
│ PFOF / exch.   │ │ management     │
└────────┬───────┘ └────────────────┘
         │ FIX (QuickFIX/J)
┌────────▼───────────────────────────┐
│  Venues                            │
│  Market Makers: Citadel, Virtu     │
│  Exchanges: NYSE, NASDAQ, CBOE     │
│  Dark pools (ATS): IEX, Liquidnet  │
└────────────────────────────────────┘
         │ FIX ExecutionReports back
         ▼
┌────────────────────────────────────┐
│  Settlement Layer                  │
│  DTCC/NSCC (T+1 netting)           │
│  OCC (options clearing)            │
└────────────────────────────────────┘

Persistence layer:

StoreTechnologyData
Order storePostgreSQLOrders, executions — ACID writes per fill
Position cacheRedisCurrent positions per account, buying power
Quote cacheRedisLatest NBBO per symbol (TTL: 5s)
Market data historyTimescaleDB / column storeOHLCV, tick history
FIX message logappend-only file storeSEC Rule 17a-4 retention

6. Deep Dives #

Deep Dive 1: Order State Machine #

The order state machine maps directly onto FIX OrdStatus (tag 39) values returned in ExecutionReport (35=8).

                    ┌─────────┐
                    │  HELD   │  (OMS-internal: risk hold)
                    └────┬────┘
                         │ risk clears
                         ▼
[order()] ──────► OPEN (OrdStatus=NEW)
                         │
           ┌─────────────┼──────────────┐
           │             │              │
           ▼             ▼              ▼
     PARTIALLY       CANCELLED       REJECTED
      FILLED         (OrdStatus=4)  (OrdStatus=8)
    (OrdStatus=1)
           │
           ▼
        FILLED
      (OrdStatus=2)

From BanzaiApplication.java (the buy-side OMS in QuickFIX/J examples):

// From quickfixj-examples/banzai/src/main/java/quickfix/examples/banzai/BanzaiApplication.java
private void executionReport(Message message, SessionID sessionID) throws FieldNotFound {
    ExecID execID = (ExecID) message.getField(new ExecID());
    if (alreadyProcessed(execID, sessionID)) return;   // idempotency: skip duplicate ExecIDs

    Order order = orderTableModel.getOrder(message.getField(new ClOrdID()).getValue());

    // partial fill accounting
    LeavesQty leavesQty = new LeavesQty();
    message.getField(leavesQty);
    BigDecimal fillSize = new BigDecimal(order.getQuantity())
        .subtract(new BigDecimal("" + leavesQty.getValue()));

    if (fillSize.compareTo(BigDecimal.ZERO) > 0) {
        order.setOpen(order.getOpen() - fillSize.intValue());
        order.setExecuted(Double.parseDouble(message.getString(CumQty.FIELD)));
        order.setAvgPx(Double.parseDouble(message.getString(AvgPx.FIELD)));
    }

    OrdStatus ordStatus = (OrdStatus) message.getField(new OrdStatus());
    if (ordStatus.valueEquals(OrdStatus.REJECTED)) {
        order.setRejected(true);
        order.setOpen(0);
    } else if (ordStatus.valueEquals(OrdStatus.CANCELED)
            || ordStatus.valueEquals(OrdStatus.DONE_FOR_DAY)) {
        order.setCanceled(true);
        order.setOpen(0);
    }
    // ... update order model
}

Key invariant: ExecID is the idempotency key. The OMS deduplicates by (SessionID, ExecID) — if the same ExecutionReport is delivered twice (e.g., after a FIX session gap-fill), the second copy is discarded. This is the FIX-layer equivalent of Hyperswitch’s Idempotency-Key cache.

Replace (Order Cancel/Replace Request — tag 35=G): Modifying an open order sends OrderCancelReplaceRequest with OrigClOrdID referencing the order to modify. The venue responds with an ExecutionReport of ExecType=REPLACED (OrdStatus=5). If the replace races with a fill, the venue rejects the replace with OrderCancelReject (35=9) — the OMS must check CxlRejResponseTo to determine whether the reject is for a cancel or a replace.

Deep Dive 2: Smart Order Router #

The Smart Order Router (SOR) decides where to send each order. Three factors govern the decision:

1. NBBO compliance (SEC Rule 611 — Order Protection Rule)

No order can be executed at a price worse than the National Best Bid and Offer. The NBBO is computed from the SIP consolidated tape:

NBBO = (best_bid across all exchanges, best_ask across all exchanges)

A market buy must fill at or below the NBO. A market sell must fill at or above the NBB. Routing to a venue that would execute outside the NBBO triggers a trade-through violation. The SOR checks the live NBBO before sending each order.

2. Payment for Order Flow (PFOF)

Robinhood’s primary routing is to wholesalers (Citadel Securities, Virtu Financial, Jane Street) who pay for the privilege of executing retail flow. Under SEC Rule 606, Robinhood must report quarterly how orders are routed and the compensation received.

PFOF routing is legal provided the customer receives “best execution” — fills at or better than NBBO. Wholesalers typically provide price improvement: filling at prices between the bid and ask (e.g., NBBO is $185.00/$185.02, wholesaler fills at $185.01).

3. Venue selection logic

For each order:
    nbbo = fetch_nbbo(symbol)              # from SIP quote cache

    if order.type == MARKET:
        # Route to preferred wholesaler (PFOF)
        # Wholesaler guarantees NBBO or better
        send_fix(wholesaler_session, NewOrderSingle)

    elif order.type == LIMIT and order.limit improves NBBO:
        # Limit price is inside the spread — can add liquidity
        # Route to exchange for potential maker rebate
        send_fix(exchange_session, NewOrderSingle)

    elif order.type == LIMIT and order.limit == NBBO:
        # Take liquidity — route to wholesaler or exchange
        # based on current fill probability estimate
        send_fix(best_venue, NewOrderSingle)

4. Fragmented fills

A single order can be routed to multiple venues simultaneously (a sweep). Each venue returns its own ExecutionReport. The OMS aggregates fills using CumQty and LeavesQty until LeavesQty == 0 (fully filled) or a cancel is issued.

5. Dark pools (ATS)

Alternative Trading Systems (IEX, Liquidnet) offer no market-impact fills for large orders. Not relevant for typical retail order sizes, but used for institutional-scale positions.

Deep Dive 3: Market Data Pipeline #

Two distinct tiers with different latency and cost profiles:

SIP consolidated tape

The regulatory baseline. CTA (Consolidated Tape Association) handles NYSE-listed securities; UTP (Unlisted Trading Privileges) handles NASDAQ-listed. The SIP aggregates quotes and trades from all exchanges into a single feed. Latency: 1–5ms from trade to SIP publication.

The SIP is used for:

  • NBBO computation (regulatory)
  • Quote display in the Robinhood UI
  • Last sale price for P&L calculations

At peak (market open), the SIP emits ~1M updates/second. The ingest pipeline:

SIP multicast feed
    │
    ▼
Feed handler (low-latency UDP consumer)
    │  normalize to internal quote format
    ▼
Quote normalizer
    │  symbol lookup (CUSIP → internal ID)
    │  NBBO update (track best bid/ask per symbol)
    ▼
Redis quote cache (TTL: 5 seconds)
    │
    ├──► SOR (NBBO checks)
    ├──► Risk engine (position mark-to-market)
    └──► WebSocket fanout (client quote streams)

OPRA options feed

Options market data from all options exchanges (CBOE, ISE, ARCA, etc.) consolidated by OPRA. Much higher volume than equity SIP (~5M updates/sec) because each underlying has hundreds of strikes × expiries × call/put. OPRA data drives the options chain display and real-time Greeks computation.

WebSocket fanout

Client subscriptions are per-symbol. The streaming gateway maintains a subscription registry and fans out quote updates only to sessions subscribed to that symbol. At 20M accounts with ~10 positions each, the fan-out multiplier is bounded by the number of unique symbols held, not the number of accounts.

Deep Dive 4: Risk Engine #

The risk engine runs synchronously in the order path — an order is not sent to the SOR until all risk checks pass. Zipline’s TradingControl classes model these checks:

# From zipline-reloaded/src/zipline/finance/controls.py
class MaxOrderSize(TradingControl):
    def validate(self, asset, amount, portfolio, algo_datetime, algo_current_data):
        if self.max_shares and abs(amount) > self.max_shares:
            self.handle_violation(asset, amount, algo_datetime)
        current_price = algo_current_data.current(asset, "price")
        order_value = amount * current_price
        if self.max_notional and abs(order_value) > self.max_notional:
            self.handle_violation(asset, amount, algo_datetime)

class MaxPositionSize(TradingControl):
    def validate(self, asset, amount, portfolio, algo_datetime, algo_current_data):
        shares_post_order = portfolio.positions[asset].amount + amount
        if self.max_shares and abs(shares_post_order) > self.max_shares:
            self.handle_violation(asset, amount, algo_datetime)

class LongOnly(TradingControl):
    def validate(self, asset, amount, portfolio, algo_datetime, algo_current_data):
        if portfolio.positions[asset].amount + amount < 0:
            self.handle_violation(asset, amount, algo_datetime)  # no short selling

Buying power check

Before any order is accepted:

available_buying_power = cash_balance
    + margin_eligible_positions × margin_rate
    - sum(open_order_reserves)

if order.notional > available_buying_power:
    reject(INSUFFICIENT_FUNDS)
else:
    reserve(order.notional)  # held until fill or cancel

Instant Deposit (Robinhood’s differentiating feature) grants up to $1000 of buying power before ACH settlement clears. This is a credit risk Robinhood bears — they’re lending against unsettled cash.

Pattern Day Trader (PDT) rule — FINRA Rule 4370

Applies to margin accounts with equity < $25,000:

  • A “day trade” = opening and closing the same security on the same trading day
  • More than 3 day trades in a rolling 5-business-day window → PDT flag
  • PDT-flagged account with equity < $25K → day trading blocked until equity restored

The OMS tracks day trade counts per account per rolling 5-day window. Before accepting an order that would close a same-day position, it checks the count:

day_trade_count = count_day_trades(account_id, last_5_business_days)
if day_trade_count >= 3 and account.equity < 25_000 and account.is_margin:
    reject(PDT_LIMIT_REACHED)

Margin calls (FINRA Rule 4210)

  • Initial margin: 50% of purchase price (RegT)
  • Maintenance margin: 25% of current market value
  • If equity falls below maintenance margin → margin call → forced liquidation if not resolved in T+3

The margin engine runs continuously against mark-to-market prices, not just at order time. A position that was safe at purchase can trigger a margin call as prices move.

RestrictedListOrder

# From zipline-reloaded/src/zipline/finance/controls.py
class RestrictedListOrder(TradingControl):
    def validate(self, asset, amount, portfolio, algo_datetime, algo_current_data):
        if self.restrictions.is_restricted(asset, algo_datetime):
            self.handle_violation(asset, amount, algo_datetime)

Securities can be restricted for regulatory reasons (e.g., the GameStop / meme stock halts in January 2021 — Robinhood restricted buying due to DTCC collateral deposit requirements, not SEC order). The Restrictions object is a pluggable policy: it can be loaded from a static list or updated in real time.

Deep Dive 5: Options Pricing and Greeks #

Options orders require real-time pricing and Greeks for display. QuantLib implements Black-Scholes-Merton:

// From QuantLib/ql/pricingengines/blackscholescalculator.hpp
class BlackScholesCalculator : public BlackCalculator {
  public:
    BlackScholesCalculator(
        Option::Type optionType,  // Call or Put
        Real strike,              // K
        Real spot,                // S (current underlying price)
        DiscountFactor growth,    // e^(q*T): continuous dividend yield
        Real stdDev,              // σ√T: volatility × sqrt(time to expiry)
        DiscountFactor discount   // e^(-r*T): risk-free discount factor
    );

    // Greeks (from BlackCalculator base class)
    Real delta() const;          // ∂V/∂S  — sensitivity to spot price
    Real gamma() const;          // ∂²V/∂S² — rate of delta change
    Real theta(Time maturity) const;   // -∂V/∂t — time decay per year
    Real thetaPerDay(Time maturity) const;  // theta / 365
};

// Also available from BlackCalculator:
// Real vega(Time maturity)      — ∂V/∂σ: sensitivity to implied vol
// Real rho(Time maturity)       — ∂V/∂r: sensitivity to interest rate
// Real dividendRho(Time maturity) — sensitivity to dividend yield

The option price is BlackScholesCalculator::value() — the BSM closed-form solution. Greeks are analytic derivatives of the same formula.

Implied Volatility

The market price of an option implies a volatility (IV). QuantLib solves for IV by inverting the BSM formula via Brent’s method:

// Invert: given market_price, find σ such that BSM(σ) = market_price
Volatility impliedVolatility = option.impliedVolatility(
    market_price, process, accuracy=1e-4, max_evaluations=100
);

IV is displayed per contract and aggregated into the IV rank (IVR) and IV percentile shown in the options chain UI.

Pin Risk at Expiration

At expiration, options near the strike (“pinned”) are behaviorally unstable: small price moves flip whether the option expires in-the-money or out-of-the-money, creating sudden delta swings. The OCC requires brokers to handle expiration processing:

  • Options in-the-money by ≥ $0.01 auto-exercise (unless customer requests do-not-exercise)
  • Options out-of-the-money expire worthless
  • Assignment risk for short options: the OCC assigns exercises randomly to short option holders

Deep Dive 6: T+1 Settlement #

Settlement cycle

Equity trades settle T+1 (trade date plus one business day) under SEC Rule 15c6-1 (amended 2024 from T+2). Options settle T+1 as well. The settlement counterparty is the DTCC/NSCC.

NSCC netting

Rather than settling each trade bilaterally, the NSCC nets all of a broker’s buy and sell obligations across all customers:

End of day:
    NSCC computes net obligation per symbol per broker-dealer:
        net_shares(AAPL) = total_buys(AAPL) - total_sells(AAPL)
        net_cash(AAPL)   = total_sell_proceeds(AAPL) - total_buy_cost(AAPL)

    NSCC sends settlement instructions to DTC (Depository Trust Company)
    DTC moves shares and cash between member accounts on T+1

Netting dramatically reduces the number of actual DTC movements: 10,000 individual AAPL trades between Robinhood customers and counterparties collapse into one net share movement and one net cash movement.

Collateral (the GameStop incident)

The NSCC charges a daily collateral deposit based on the settlement risk of the broker’s open positions. The formula:

VaR component   = statistical loss estimate at 99% confidence
Mark-to-market  = unrealized loss on net open positions
Special charge  = extra charge for concentrated or volatile positions

On January 28, 2021, Robinhood’s NSCC collateral requirement spiked to ~$3B due to concentrated meme stock positions. Robinhood could not fund the full deposit and restricted buying in those securities to reduce settlement exposure. This was a liquidity/capital constraint, not an SEC order.

Buying Power and Unsettled Funds

Cash from a stock sale is immediately available as buying power in Robinhood, but it doesn’t settle (arrive in the account as cleared cash) until T+1. If a customer sells stock and uses the proceeds to buy new stock before settlement, they’re using unsettled funds. Selling the new stock before the original sale settles is a “good faith violation” — prohibited under Regulation T for cash accounts.

SEC Rule 15c3-3: Customer Fund Segregation

Robinhood must hold customer cash and securities separate from firm assets. The “customer reserve” formula:

Reserve required = max(0, credits_to_customers - debits_from_customers)
credits = customer cash + customer credit balances + margin loans payable
debits  = margin loans receivable + customer securities borrowed

Excess reserve must be held in a special reserve bank account. This is computed weekly and audited by FINRA. Shortfall in the reserve account is a serious regulatory violation (see the 2021 FINRA fine against Robinhood for a $57M settlement).

Deep Dive 7: Fractional Shares #

Fractional shares are not natively supported by exchanges. Robinhood implements them via an internal book:

Problem: Exchanges only accept whole-share orders. A customer ordering $10 of AAPL at $185/share needs 0.054 shares.

Solution: Robinhood aggregates fractional demand across all customers into round-lot orders:

Customer A: +0.054 shares AAPL ($10)
Customer B: +0.108 shares AAPL ($20)
Customer C: -0.027 shares AAPL ($5)
─────────────────────────────────
Net:        +0.135 shares AAPL
            → send 1 whole-share buy to market maker
            → Robinhood retains the residual 0.865 shares as inventory

This means Robinhood acts as a dealer for fractional shares — taking the other side of some fractional trades from its own inventory. This requires Robinhood to register as a broker-dealer with market-making capabilities, and creates inventory risk on the fractional positions.

Order representation: Fractional orders have amount as a Decimal rather than int. The Position must also track fractional quantities. Splits are handled by adjusting cost_basis and amount proportionally (Zipline’s handle_split(ratio) in PositionTracker).


7. What If: Durable Execution for Complex Brokerage Flows #

Class A vs Class B Async Work #

Same classification as the payment gateway analysis applies here:

ClassDefinitionMechanism
ASingle operation, linear retry, short durationDatabase-backed retry queue (similar to Hyperswitch ProcessTracker)
BMulti-step, long-running, human signal, compensationDurable execution engine

Most brokerage operations are Class A: order routing, fill processing, buying power update. But several flows are Class B.

What ProcessTracker handles well #

  • ACH transfer status polling — poll NACHA status codes every few hours until settled or failed
  • Corporate action notification retries — retry sending push notification until delivered
  • Settlement confirmation — poll NSCC status until T+1 settlement confirmed

What needs a durable execution engine #

1. Margin Call Workflow

workflow MarginCallWorkflow(account_id):
    call = activity(calculate_margin_deficiency, account_id)
    activity(notify_customer_margin_call, account_id, call)

    resolution = race(
        on signal("deposit_received")         => receive deposit,
        on signal("positions_liquidated")     => receive liquidation,
        on sleep(3 business days)             => resolution = { type: forced_liquidation }
    )

    if resolution.type == forced_liquidation:
        positions = activity(get_marginable_positions, account_id)
        for position in positions:
            activity(submit_liquidation_order, account_id, position)
            // each order goes through normal OMS path

    activity(recalculate_margin, account_id)
    activity(send_margin_call_resolution_notice, account_id, resolution)

The 3-day window is a regulatory constraint (FINRA Rule 4210). A ProcessTracker job can’t model the signal-or-timeout race cleanly.

2. Options Expiration Workflow

workflow OptionsExpirationWorkflow(account_id, expiry_date):
    expiring = activity(get_expiring_options, account_id, expiry_date)

    for contract in expiring:
        final_price = activity(get_closing_price, contract.underlying)

        if contract.is_itm(final_price) and contract.is_long:
            // auto-exercise unless customer opted out
            if not contract.do_not_exercise:
                result = activity(submit_exercise_to_occ, contract)
                activity(update_position_for_exercise, account_id, contract, result)

        elif contract.is_short and contract.is_assigned:
            // OCC assignment — random selection among short holders
            activity(process_assignment, account_id, contract)
            // may trigger margin call if account lacks sufficient equity
            child_workflow(MarginCallWorkflow, account_id)

    activity(close_expired_positions, account_id, expiry_date)

Pin risk (options near strike at close) requires the workflow to query final settlement prices that are only published after market close (the OCC uses the official closing price from the primary listing exchange). The workflow must wait for that event before determining exercise/assignment.

3. PDT Reset Workflow

workflow PDTResetWorkflow(account_id):
    // Customer flagged as PDT; has requested reset (allowed once per 90 days)
    activity(validate_reset_eligibility, account_id)
    activity(send_pdt_warning_disclosure, account_id)

    acknowledgment = race(
        on signal("customer_acknowledged_pdt_risk") => receive ack,
        on sleep(72 hours)                          => ack = { timed_out: true }
    )

    if not acknowledgment.timed_out:
        activity(remove_pdt_flag, account_id)
        activity(set_next_eligible_reset_date, account_id, 90 days from now)
        activity(notify_customer_pdt_cleared, account_id)
    else:
        activity(notify_customer_reset_expired, account_id)

4. ACH Dispute Workflow

workflow ACHDisputeWorkflow(transfer_id, dispute_reason):
    transfer = activity(fetch_transfer, transfer_id)
    activity(freeze_funds, transfer.account_id, transfer.amount)
    activity(submit_return_to_nacha, transfer_id, dispute_reason)

    // NACHA return window: 2 business days for unauthorized (R10)
    //                       60 days for consumer disputes (R07)
    return_deadline = transfer.settlement_date + return_window(dispute_reason)

    decision = race(
        on signal("return_accepted") => receive decision,
        on signal("return_rejected") => receive decision,
        on sleep(until return_deadline) => decision = { outcome: timed_out }
    )

    if decision.outcome == accepted:
        activity(credit_customer, transfer.account_id, transfer.amount)
        activity(close_dispute, transfer_id, decision)
    else:
        activity(release_frozen_funds, transfer.account_id, transfer.amount)
        activity(notify_customer_dispute_outcome, transfer.account_id, decision)

NACHA return codes have different windows (R01–R85); modeling these time bounds as sleep() primitives is cleaner than building a custom scheduler.

Summary: ProcessTracker vs Durable Workflow #

FlowDurationHuman SignalCompensationMechanism
Order routingmsNoNoSynchronous OMS path
Fill processingmsNoNoSynchronous + FIX
ACH status poll1–3 daysNoNoProcessTracker
Corporate action notificationHoursNoNoProcessTracker
Margin call resolutionUp to 3 business daysYes (deposit / liquidate)Partial (unwind fills)Durable workflow
Options expirationUntil OCC publishes settlementNoYes (assignment unwind)Durable workflow
ACH dispute2–60 daysNoYes (credit/release)Durable workflow
PDT resetUp to 72 hoursYes (customer ack)NoDurable workflow