Skip to content

10Legs/schwab-data-proxy

Repository files navigation

schwab-data-proxy

A standalone Docker service that multiplexes Schwab market data and real-time streaming to N downstream consumers over REST and WebSocket.

Why this exists

Schwab's OAuth implementation allows only one registered application per trader account. This creates a hard constraint: if you want to run your-trading-app alongside other market-aware tools (dashboards, alert engines, risk monitors), each cannot maintain independent Schwab credentials.

schwab-data-proxy solves this by becoming the single source of truth for your Schwab connection. The proxy holds the OAuth credentials and token lifecycle. All downstream consumers (your-trading-app, dashboards, etc.) connect to the proxy over REST and WebSocket — no Schwab credentials needed in any individual app.

Schwab API products

Schwab offers two separate API products, each with its own OAuth app credentials:

Product Endpoints Used for
Market Data Production /marketdata/v1/* Quotes, option chains, price history, market hours
Trader API – Individual /trader/v1/* + WebSocket streaming Real-time L1 streaming (equities, options)

The proxy supports one app, two apps, or a mix:

  • Single app — If your Schwab app is subscribed to both products, set only the SCHWAB_DATA_* vars. The proxy uses the same credentials for REST and streaming.
  • Two apps — If you have a dedicated Market Data app and a separate Trader API app (recommended for isolation), set both SCHWAB_DATA_* and SCHWAB_TRADER_* vars. The proxy uses market data credentials for REST calls and trader credentials for streaming.

Core capabilities

  • Reference-counted subscriptions: Upstream symbol subscriptions fire only when the first client requests it (0→1), and drop only when the last client releases it (1→0)
  • TTL + LRU caching: REST responses are cached to avoid rate-limit exhaustion
  • Token lifecycle management: Background refresh loop keeps the OAuth session alive
  • N-way fan-out: One upstream Schwab WebSocket streams L1 ticks to unlimited downstream clients
  • Queue overflow handling: Per-client queues are bounded; oldest ticks drop to ensure the latest data always flows

Architecture

graph LR
    SchwabRest["Schwab REST API"]
    SchwabWS["Schwab WebSocket<br/>(L1 Stream)"]
    
    subgraph Proxy["schwab-data-proxy"]
        Session["SchwabSession<br/>(OAuth + Token Refresh)"]
        SubscriptionMgr["SubscriptionManager<br/>(Refcount Tracking)"]
        StreamRouter["StreamRouter<br/>(Upstream WS)"]
        Cache["REST Cache<br/>(LRU + TTL)"]
        WSServer["WebSocket Server<br/>(N-Client Fan-out)"]
    end
    
    YourApp["your-trading-app<br/>(REST + WS)"]
    Dashboard["Dashboard<br/>(REST + WS)"]
    AlertEngine["Alert Engine<br/>(REST + WS)"]
    
    SchwabRest -->|REST Calls| Session
    Session -->|REST Calls| Cache
    Cache -->|Cached Quotes,<br/>Chains, History| YourApp
    Cache -->|Cached Data| Dashboard
    Cache -->|Cached Data| AlertEngine
    
    SchwabWS -->|L1 Ticks| StreamRouter
    StreamRouter -->|Normalized Ticks| WSServer
    WSServer -->|WebSocket Stream| YourApp
    WSServer -->|WebSocket Stream| Dashboard
    WSServer -->|WebSocket Stream| AlertEngine
    
    SubscriptionMgr -->|Refcount<br/>Subscribe/Unsub| StreamRouter
    Session -.->|Token Lifecycle| StreamRouter
Loading

How it works

REST flow

REST requests (quotes, chains, price history, market hours) follow a cache-first path:

sequenceDiagram
    participant Client as Downstream App
    participant Proxy as REST Endpoint<br/>(FastAPI)
    participant Cache as LRU Cache<br/>(TTL 2s)
    participant Session as SchwabSession<br/>(OAuth)
    participant Schwab as Schwab API
    
    Client->>Proxy: GET /v1/quotes?symbols=AAPL
    Proxy->>Cache: Check cache key
    alt Cache hit (fresh)
        Cache-->>Proxy: Return cached data
        Proxy-->>Client: {data, cached: true}
    else Cache miss or stale
        Proxy->>Session: REST call + client credentials
        Session->>Schwab: GET /quotes
        Schwab-->>Session: Quote payload
        Session-->>Proxy: Quote + timestamp
        Proxy->>Cache: Write with TTL
        Proxy-->>Client: {data, cached: false}
    end
Loading

Key points:

  • Every REST response includes a cached flag and as_of timestamp
  • Default TTL is 2 seconds; configurable via CACHE_TTL_SECONDS
  • Cache key includes all query parameters to avoid false hits
  • If Schwab is unavailable, cached data is returned even if expired (best-effort)

Streaming / subscription lifecycle

WebSocket subscriptions are reference-counted. Upstream subscriptions fire only on the 0→1 transition (first client requests), and drop on the 1→0 transition (last client releases).

sequenceDiagram
    participant ClientA as Client A
    participant ClientB as Client B
    participant Server as Proxy<br/>Subscription<br/>Manager
    participant Upstream as Schwab<br/>StreamClient
    
    Note over Server: refcount[AAPL] = 0
    
    ClientA->>Server: subscribe LEVELONE_EQUITIES [AAPL]
    Note over Server: refcount[AAPL] = 0→1
    Server->>Upstream: request_data("AAPL")
    Note over Upstream: ✓ Upstream sub fires
    Server-->>ClientA: ack
    
    ClientB->>Server: subscribe LEVELONE_EQUITIES [AAPL]
    Note over Server: refcount[AAPL] = 1→2
    Note over Server: No upstream action
    Server-->>ClientB: ack
    
    Note over Server: Both clients receive L1 ticks for AAPL
    
    ClientA->>Server: unsubscribe LEVELONE_EQUITIES [AAPL]
    Note over Server: refcount[AAPL] = 2→1
    Note over Server: No upstream action
    Server-->>ClientA: ack
    
    Note over Server: ClientB still receives ticks
    
    ClientB->>Server: unsubscribe LEVELONE_EQUITIES [AAPL]
    Note over Server: refcount[AAPL] = 1→0
    Server->>Upstream: cancel_data("AAPL")
    Note over Upstream: ✓ Upstream unsub fires
    Server-->>ClientB: ack
    
    Note over Server: No more ticks for AAPL
Loading

Reference counting rules:

  • Per service (LEVELONE_EQUITIES vs LEVELONE_OPTIONS) and per symbol
  • Multiple subscriptions to the same symbol from the same client are deduplicated
  • Unsubscribe must exactly match the original subscribe (service + symbol set)
  • On client disconnect, all subscriptions auto-refcount down

Reconnect behavior

The proxy maintains a WebSocket connection to Schwab. If the connection drops, it reconnects with exponential backoff and resubscribes to all active symbols so downstream clients never lose service:

flowchart TD
    A["Upstream Schwab WS<br/>Connected"] -->|Network error<br/>or timeout| B["Connection closed"]
    B --> C["Wait exponential<br/>backoff<br/>min=1s, max=30s"]
    C --> D{"Reconnect<br/>successful?"}
    D -->|No| C
    D -->|Yes| E["Schwab StreamClient<br/>logged in"]
    E --> F["Resubscribe all<br/>active symbols"]
    F --> G["Query refcounts<br/>AAPL:2, SPY:1, ..."]
    G --> H["Send request_data<br/>for each symbol"]
    H --> A
Loading

Reconnect guarantees:

  • Per-client queues are not cleared on reconnect; clients receive ticks uninterrupted
  • Refcounts are preserved across upstream reconnects
  • If reconnect fails after max retries, /readyz returns 503 and downstream containers fail their health checks

Bootstrap & startup

The Docker Compose setup includes an init service that runs before the proxy starts. It performs OAuth bootstrap and token validation:

flowchart TD
    A["init container<br/>starts"] --> B{"Token file<br/>exists?"}
    
    B -->|No| C["Print Schwab<br/>auth URL"]
    C --> D["Wait for paste:<br/>Redirect URL"]
    D --> E["Extract auth code<br/>from URL"]
    E --> F["POST /token to<br/>Schwab"]
    F --> G{"OAuth<br/>success?"}
    G -->|No| H["Print error<br/>exit 1"]
    H --> I["proxy container<br/>blocked"]
    G -->|Yes| J["Write token.json<br/>mode 0600"]
    J --> K["exit 0"]
    
    B -->|Yes| L["Probe token<br/>GET /v1/quotes SPY"]
    L --> M{"Token<br/>valid?"}
    M -->|No| N["Print re-auth URL<br/>wait for paste"]
    N --> D
    M -->|Yes| K
    
    K --> O["proxy container<br/>starts"]
Loading

Important:

  • Run docker compose up attached (without -d) on first run — the bootstrap prompt appears in the terminal
  • After token is bootstrapped once, subsequent starts are automatic — the init service probes the token and exits immediately
  • Token file is stored in a Docker volume and persists across restarts

Quickstart

cp .env.template .env
# Fill in SCHWAB_DATA_CLIENT_ID, SCHWAB_DATA_CLIENT_SECRET, SCHWAB_DATA_CALLBACK_URL
docker compose up

WARNING: Run docker compose up ATTACHED (without -d) on first run. The bootstrap prompt appears in the terminal — if you run -d, the prompt is buried in logs and the stack will hang waiting for input. After bootstrap succeeds once, -d is safe for subsequent starts (init exits immediately if the token is valid).

First run (token does not exist or has expired)

  1. The init container prints a Schwab auth URL.
  2. Open it in a browser, log in, and authorize the app.
  3. Your browser will redirect to your callback URL — the page may fail to load. That is expected.
  4. Copy the full redirect URL from the address bar and paste it into the terminal.
  5. The token is written to the schwab-token Docker volume; the proxy service starts automatically.

Subsequent runs

docker compose up -d

The init container probes the existing token, finds it valid, and exits 0 immediately. The proxy starts without any user interaction.

Token gate bootstrap (manual re-auth only)

If the proxy starts with a 401 error (expired refresh token), re-run bootstrap:

docker compose run --rm init

Docker run (single container, no compose)

docker build -t schwab-data-proxy .
docker run -d \
  --name schwab-data-proxy \
  -p 127.0.0.1:8080:8080 \
  --env-file .env \
  -v "$(pwd)/token.json:/data/token.json:rw" \
  schwab-data-proxy

Note: docker run does not execute the bootstrap gate. Produce a valid token.json on the host first using docker compose run --rm init, then mount it.

Check health:

curl http://localhost:8080/healthz
curl http://localhost:8080/readyz

/readyz returns 503 until the streaming session has logged in (typically within 2-3 seconds of startup).

Configuration

Market Data app (required)

Variable Default Description
SCHWAB_DATA_CLIENT_ID required Client ID for your Schwab Market Data app
SCHWAB_DATA_CLIENT_SECRET required Client secret for your Schwab Market Data app
SCHWAB_DATA_CALLBACK_URL required OAuth callback URL registered with the Market Data app
SCHWAB_DATA_TOKEN_PATH /data/token.json Token file path inside the container volume

Trader API app (optional — required for streaming)

Leave these unset if your Market Data app also has the Trader API product subscribed (single-app mode). Set them if you have a dedicated Trader API app.

Variable Default Description
SCHWAB_TRADER_CLIENT_ID "" Client ID for your Schwab Trader API app
SCHWAB_TRADER_CLIENT_SECRET "" Client secret for your Schwab Trader API app
SCHWAB_TRADER_CALLBACK_URL "" OAuth callback URL registered with the Trader API app (falls back to SCHWAB_DATA_CALLBACK_URL if unset)
SCHWAB_TRADER_TOKEN_PATH /data/trader_token.json Trader token file path inside the container volume

General

Variable Default Description
PROXY_API_KEY "" API key required by downstream clients. Empty = auth disabled
PORT 8080 HTTP/WebSocket listen port
CACHE_TTL_SECONDS 2 TTL for REST response cache in seconds
LOG_LEVEL INFO Python logging level: DEBUG, INFO, WARNING, ERROR
SCHWAB_SKIP_INIT false Skip Schwab session init (CI/test mode only — disables REST and streaming)

Connecting Your App

REST (Python)

No auth headers are required—the proxy holds all Schwab credentials. Use httpx or requests to make REST calls directly to the proxy endpoints:

import httpx

PROXY = "http://localhost:8080"

# Quotes
resp = httpx.get(f"{PROXY}/v1/quotes", params={"symbols": "AAPL,SPY"})
data = resp.json()["data"]

# Option chain
resp = httpx.get(f"{PROXY}/v1/chains", params={"symbol": "SPY", "contract_type": "CALL"})
chains = resp.json()["data"]

# Price history
resp = httpx.get(f"{PROXY}/v1/pricehistory", params={"symbol": "AAPL", "period_type": "day", "period": 5})
candles = resp.json()["data"]

Trader API (Python)

Trader endpoints let you manage accounts, orders, and transactions:

import httpx

PROXY = "http://localhost:8080"

# Get account hash (needed for all trader calls)
resp = httpx.get(f"{PROXY}/trader/v1/accounts/numbers")
account_hash = resp.json()["data"][0]["hashValue"]

# Place a market order
resp = httpx.post(
    f"{PROXY}/trader/v1/accounts/{account_hash}/orders",
    json={
        "orderType": "MARKET",
        "session": "NORMAL",
        "duration": "DAY",
        "orderStrategyType": "SINGLE",
        "orderLegCollection": [{
            "instruction": "BUY",
            "quantity": 1,
            "instrument": {"symbol": "AAPL", "assetType": "EQUITY"}
        }]
    }
)
order_id = resp.json()["order_id"]

# Cancel it
httpx.delete(f"{PROXY}/trader/v1/accounts/{account_hash}/orders/{order_id}")

WebSocket streaming (Python)

Connect with websockets and maintain a per-symbol state dict to merge delta ticks:

import asyncio, json
import websockets

async def main():
    async with websockets.connect("ws://localhost:8080/stream") as ws:
        # Wait for hello frame
        hello = json.loads(await ws.recv())
        print(f"Connected as {hello['client_id']}")

        # Subscribe to symbols
        await ws.send(json.dumps({
            "type": "subscribe",
            "service": "LEVELONE_EQUITIES",
            "symbols": ["AAPL", "SPY"],
            "ref": "sub-1"
        }))

        # Maintain last-known state (Schwab sends delta ticks)
        last_known = {}

        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] == "tick":
                sym = msg["symbol"]
                last_known.setdefault(sym, {}).update(msg["fields"])
                bid = last_known[sym].get("bid")
                ask = last_known[sym].get("ask")
                print(f"{sym}: bid={bid} ask={ask}")

asyncio.run(main())

Key point: Each tick contains only changed fields. Merge every incoming tick into your last_known state dict to build a complete snapshot.

Docker Compose multi-service setup

Wire the proxy as a service dependency using a bridge network and wait for /readyz:

networks:
  schwab-net:
    driver: bridge

services:
  schwab-data-proxy:
    image: ghcr.io/10legs/schwab-data-proxy:latest
    env_file: .env
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - schwab-token:/data
    networks:
      - schwab-net
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/readyz')"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s

  my-app:
    build: .
    environment:
      PROXY_URL: http://schwab-data-proxy:8080
    networks:
      - schwab-net
    depends_on:
      schwab-data-proxy:
        condition: service_healthy

volumes:
  schwab-token:

Important: Inside the Docker network, use the service name schwab-data-proxy as the hostname (not localhost).

Wait for readiness

Before making requests at startup, poll /readyz (it returns 503 until streaming is logged in):

import time, httpx

def wait_for_proxy(url="http://localhost:8080", timeout=30):
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            if httpx.get(f"{url}/readyz").status_code == 200:
                return
        except httpx.ConnectError:
            pass
        time.sleep(1)
    raise TimeoutError("Proxy did not become ready")

# Usage: call before making any REST or streaming requests
wait_for_proxy()

REST API Reference

All responses use the envelope:

{"data": <schwab payload>, "cached": true|false, "as_of": "2026-06-15T14:30:00.123456+00:00"}

Invalid enum parameter values (e.g. unknown period_type or contract_type) return 400 rather than being passed through to Schwab.

Error envelope:

{"error": {"code": "UPSTREAM_ERROR", "message": "...", "upstream_status": 502}}

GET /v1/quotes

Fetch real-time quotes for one or more symbols.

Parameter Type Required Description
symbols string yes Comma-separated ticker list: AAPL,MSFT
fields string no Comma-separated subset: quote,reference,extended,fundamental,regular
curl "http://localhost:8080/v1/quotes?symbols=AAPL,SPY&fields=quote"

GET /v1/chains

Fetch option chain for a single underlying.

Parameter Type Required Description
symbol string yes Underlying ticker
contract_type string no CALL, PUT, ALL
strike_count int no Number of strikes above/below ATM
include_underlying_quote bool no Include underlying quote in response
strategy string no SINGLE, ANALYTICAL, COVERED, VERTICAL, CALENDAR, STRANGLE, STRADDLE, BUTTERFLY, CONDOR, DIAGONAL, COLLAR, ROLL
interval float no Strike interval
strike float no Specific strike price
strike_range string no ITM, NTM, OTM, SAK, SBK, SNK, ALL
from_date string no ISO date YYYY-MM-DD
to_date string no ISO date YYYY-MM-DD
volatility float no Volatility override for analytical pricing
underlying_price float no Underlying price override
interest_rate float no Interest rate override
days_to_expiration int no Days to expiration override
exp_month string no Expiration month: JAN..DEC, ALL
option_type string no S (standard), NS (non-standard), ALL
entitlement string no PP, NP, PN
curl "http://localhost:8080/v1/chains?symbol=SPY&contract_type=CALL&strike_range=NTM&from_date=2026-06-20&to_date=2026-07-18"

GET /v1/pricehistory

Fetch OHLCV candles.

Parameter Type Required Description
symbol string yes Ticker
period_type string no day, month, year, ytd
period int no Number of periods
frequency_type string no minute, daily, weekly, monthly
frequency int no Frequency within type
start_datetime string no ISO8601 start
end_datetime string no ISO8601 end
need_extended_hours_data bool no Include pre/post market
need_previous_close bool no Include previous close
curl "http://localhost:8080/v1/pricehistory?symbol=AAPL&period_type=day&period=5&frequency_type=minute&frequency=1"

GET /v1/markets

Fetch market hours.

Parameter Type Required Description
markets string yes Comma-separated: equity,option,bond,future,forex
date string no ISO date YYYY-MM-DD (defaults to today)
curl "http://localhost:8080/v1/markets?markets=equity,option&date=2026-06-15"

GET /healthz

Returns 200 {"status": "ok"} always (liveness probe).

GET /readyz

Returns 200 {"status": "ready"} when session started AND streaming logged in. Returns 503 {"status": "degraded", "reason": "..."} otherwise.

Trader API Reference

Trader endpoints are never cached — every response is fresh from Schwab.

Response envelope (no cached field, unlike market data):

{"data": <payload>, "as_of": "ISO8601"}

Order placement returns 201:

{"order_id": "12345678", "as_of": "ISO8601"}

Cancel/replace returns 204 with no body.

Error envelope is identical to market data endpoints.

GET /trader/v1/accounts/numbers

Returns list of {accountNumber, hashValue} pairs. The hashValue is what all other trader endpoints use as account_hash.

curl http://localhost:8080/trader/v1/accounts/numbers

GET /trader/v1/accounts

Parameter Type Required Description
fields string no Comma-separated: positions, orders
curl "http://localhost:8080/trader/v1/accounts?fields=positions"

GET /trader/v1/accounts/{account_hash}

Same fields param as above.

curl "http://localhost:8080/trader/v1/accounts/ABC123HASH?fields=positions,orders"

GET /trader/v1/accounts/{account_hash}/orders

Parameter Type Required Description
from_date string no ISO8601 datetime (default: 60 days ago)
to_date string no ISO8601 datetime (default: now)
max_results int no Cap on number of results
status string no e.g. FILLED, WORKING, CANCELED, REJECTED
curl "http://localhost:8080/trader/v1/accounts/ABC123HASH/orders?status=WORKING"

GET /trader/v1/accounts/{account_hash}/orders/{order_id}

curl http://localhost:8080/trader/v1/accounts/ABC123HASH/orders/12345678

POST /trader/v1/accounts/{account_hash}/orders

Place an order. Body is a Schwab order spec JSON object. Returns 201 with order_id.

curl -X POST http://localhost:8080/trader/v1/accounts/ABC123HASH/orders \
  -H "Content-Type: application/json" \
  -d '{
    "orderType": "MARKET",
    "session": "NORMAL",
    "duration": "DAY",
    "orderStrategyType": "SINGLE",
    "orderLegCollection": [{
      "instruction": "BUY",
      "quantity": 1,
      "instrument": {"symbol": "AAPL", "assetType": "EQUITY"}
    }]
  }'

Response:

{"order_id": "12345678", "as_of": "2026-06-15T14:30:00.000000+00:00"}

PUT /trader/v1/accounts/{account_hash}/orders/{order_id}

Replace an existing order. Body is a complete replacement order spec. Returns 204.

curl -X PUT http://localhost:8080/trader/v1/accounts/ABC123HASH/orders/12345678 \
  -H "Content-Type: application/json" \
  -d '{"orderType": "LIMIT", "price": 195.00, ...}'

DELETE /trader/v1/accounts/{account_hash}/orders/{order_id}

Cancel an order. Returns 204.

curl -X DELETE http://localhost:8080/trader/v1/accounts/ABC123HASH/orders/12345678

GET /trader/v1/accounts/{account_hash}/transactions

Parameter Type Required Description
types string no Comma-separated: TRADE, DIVIDEND_OR_INTEREST, ACH_RECEIPT, ACH_DISBURSEMENT, CASH_RECEIPT, CASH_DISBURSEMENT, ELECTRONIC_FUND
symbol string no Filter by symbol
start_date string no ISO8601 datetime (default: 60 days ago)
end_date string no ISO8601 datetime (default: now)
curl "http://localhost:8080/trader/v1/accounts/ABC123HASH/transactions?types=TRADE&symbol=AAPL"

GET /trader/v1/accounts/{account_hash}/transactions/{transaction_id}

curl http://localhost:8080/trader/v1/accounts/ABC123HASH/transactions/987654321

WebSocket Protocol

Connect to ws://localhost:8080/stream.

Client session lifecycle

sequenceDiagram
    participant Client as Downstream App
    participant Server as WebSocket<br/>Endpoint
    
    Client->>Server: WebSocket connect
    Server-->>Client: hello {client_id, protocol}
    
    Client->>Server: subscribe LEVELONE_EQUITIES [AAPL, SPY]
    Server-->>Client: ack {accepted: [AAPL, SPY]}
    
    loop Every 15 seconds
        Server-->>Client: heartbeat {server_time}
    end
    
    loop Whenever AAPL or SPY ticks
        Server-->>Client: tick {symbol, fields}
    end
    
    Client->>Server: ping
    Server-->>Client: pong {server_time}
    
    Client->>Server: unsubscribe LEVELONE_EQUITIES [AAPL]
    Server-->>Client: ack {accepted: [AAPL]}
    
    Client->>Server: WebSocket close
    Server->>Server: Refcount down [AAPL, SPY]
Loading

Hello frame (server → client, on connect)

{"type": "hello", "client_id": "uuid4", "server_time": "ISO8601", "protocol": 1}

Subscribe

{"type": "subscribe", "service": "LEVELONE_EQUITIES", "symbols": ["AAPL", "SPY"], "ref": "sub-1"}

Valid services: LEVELONE_EQUITIES, LEVELONE_OPTIONS

Ack response:

{"type": "ack", "ref": "sub-1", "service": "LEVELONE_EQUITIES", "accepted": ["AAPL", "SPY"], "rejected": []}

Unsubscribe

{"type": "unsubscribe", "service": "LEVELONE_EQUITIES", "symbols": ["AAPL"], "ref": "unsub-1"}

Same ack shape as subscribe.

Ping / Pong

{"type": "ping"}
{"type": "pong", "server_time": "ISO8601"}

Heartbeat (server → client, every 15s)

{"type": "heartbeat", "server_time": "ISO8601"}

Tick frame (server → client)

{
  "type": "tick",
  "service": "LEVELONE_EQUITIES",
  "symbol": "AAPL",
  "ts": "2026-06-15T14:30:01.234567+00:00",
  "fields": {
    "bid": 198.41,
    "ask": 198.43,
    "last": 198.42,
    "volume": 1234567,
    "netChange": -0.58
  }
}

Error frame

{"type": "error", "code": "BAD_COMMAND", "message": "Unknown service: 'FOO'"}

Reconnect / Resubscribe requirement

The server does not persist client subscription state across WebSocket reconnects. On disconnect:

  1. Reconnect to /stream
  2. Wait for the hello frame
  3. Re-send all subscribe commands

The server will re-establish upstream Schwab subscriptions as clients reconnect.

Partial-tick semantics

Schwab streaming sends delta ticks — only fields that changed since the last update. Consumers must maintain a local last_known state per symbol and merge each incoming tick into it:

last_known = {}

def on_tick(msg):
    sym = msg["symbol"]
    last_known.setdefault(sym, {}).update(msg["fields"])
    process(last_known[sym])

Do not treat any single tick as a complete quote snapshot.

Option symbol format

Schwab streaming uses OSI format for option symbols:

{underlying}{expiration}{side}{strike}

Example: AAPL 260117C00200000

  • Underlying padded to 6 chars: AAPL
  • Expiration YYMMDD: 260117
  • Side: C (call) or P (put)
  • Strike × 1000, zero-padded to 8 digits: 00200000 = $200.00

Example: multi-app integration

Your downstream app connects to the proxy for both REST data and live streaming:

graph LR
    YourApp["your-trading-app<br/>(algo trading harness)"]
    
    subgraph Proxy["schwab-data-proxy"]
        REST["/v1/quotes<br/>/v1/chains<br/>/v1/pricehistory"]
        Stream["/stream<br/>WebSocket"]
    end
    
    YourApp -->|REST queries<br/>no auth needed| REST
    YourApp -->|Subscribe/unsubscribe<br/>LEVELONE_EQUITIES<br/>LEVELONE_OPTIONS| Stream
    REST -->|Cached quotes| YourApp
    Stream -->|L1 ticks| YourApp
Loading

Downstream apps should:

  1. Point REST calls at http://localhost:8080/v1/... — no Schwab credentials needed in your-trading-app
  2. Connect the streaming client to ws://localhost:8080/stream
  3. Subscribe to LEVELONE_EQUITIES for equity scanners and LEVELONE_OPTIONS for Greeks feeds
  4. Maintain per-symbol last_known state dicts and merge delta ticks
  5. Handle 503 from /readyz at startup — retry with backoff before assuming the proxy is healthy
  6. On WebSocket disconnect, re-subscribe all symbols after reconnecting (the proxy does not restore session state)

The proxy's /readyz endpoint is appropriate as a Docker dependency healthcheck for downstream containers:

depends_on:
  schwab-data-proxy:
    condition: service_healthy

Authentication

The proxy supports optional API key authentication. Set PROXY_API_KEY in .env to require a key from all downstream clients. Leave it empty (default) to disable auth — appropriate for fully trusted networks or local development.

REST

Pass the key in the X-API-Key header:

curl -H "X-API-Key: your-key-here" http://localhost:8080/v1/quotes?symbols=AAPL
import httpx
resp = httpx.get(
    "http://localhost:8080/v1/quotes",
    params={"symbols": "AAPL"},
    headers={"X-API-Key": "your-key-here"},
)

WebSocket

Pass the key as a query parameter (WebSocket clients cannot reliably set custom headers):

import websockets
async with websockets.connect("ws://localhost:8080/stream?api_key=your-key-here") as ws:
    ...

Exempt endpoints

/healthz and /readyz are always exempt — they are used by Docker healthchecks which cannot pass headers.

Wrong or missing key

REST: 401 {"error": {"code": "UNAUTHORIZED", "message": "Invalid or missing API key"}}

WebSocket: connection closed immediately with code 4001 before the hello frame is sent.

Security notes

Localhost-only binding: The proxy binds to 127.0.0.1:8080 by default. Do not expose port 8080 to the public internet.

No downstream authentication: The proxy assumes all clients connecting to it are trusted (same host or private network). It does not implement per-client API keys or authentication. Use network isolation to enforce this constraint.

Token file is the crown jewel: The OAuth token (/data/token.json) grants full access to your Schwab account. Protect it with file permissions (mode 0600), restrict Docker volumes, and never commit it to version control.

Credentials in Docker volumes: Ensure the schwab-token volume is mounted to a secure location on the host; use encrypted storage if possible.

Rate limits: Schwab enforces API rate limits. The proxy's REST cache (default 2s TTL) reduces pressure, but many concurrent downstream clients can still hit limits. Monitor /readyz for upstream errors.

About

A FastAPI + asyncio proxy that multiplexes Schwab's OAuth-protected brokerage APIs across N consumer apps, handling token refresh so you don't have to manage one credential per bot.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors