A standalone Docker service that multiplexes Schwab market data and real-time streaming to N downstream consumers over REST and WebSocket.
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 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_*andSCHWAB_TRADER_*vars. The proxy uses market data credentials for REST calls and trader credentials for streaming.
- 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
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
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
Key points:
- Every REST response includes a
cachedflag andas_oftimestamp - 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)
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
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
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
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,
/readyzreturns 503 and downstream containers fail their health checks
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"]
Important:
- Run
docker compose upattached (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
cp .env.template .env
# Fill in SCHWAB_DATA_CLIENT_ID, SCHWAB_DATA_CLIENT_SECRET, SCHWAB_DATA_CALLBACK_URL
docker compose upWARNING: Run
docker compose upATTACHED (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,-dis safe for subsequent starts (init exits immediately if the token is valid).
- The
initcontainer prints a Schwab auth URL. - Open it in a browser, log in, and authorize the app.
- Your browser will redirect to your callback URL — the page may fail to load. That is expected.
- Copy the full redirect URL from the address bar and paste it into the terminal.
- The token is written to the
schwab-tokenDocker volume; theproxyservice starts automatically.
docker compose up -dThe init container probes the existing token, finds it valid, and exits 0 immediately. The proxy starts without any user interaction.
If the proxy starts with a 401 error (expired refresh token), re-run bootstrap:
docker compose run --rm initdocker 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-proxyNote: 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).
| 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 |
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 |
| 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) |
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 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}")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.
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).
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()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}}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"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"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"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"Returns 200 {"status": "ok"} always (liveness probe).
Returns 200 {"status": "ready"} when session started AND streaming logged in.
Returns 503 {"status": "degraded", "reason": "..."} otherwise.
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.
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| Parameter | Type | Required | Description |
|---|---|---|---|
fields |
string | no | Comma-separated: positions, orders |
curl "http://localhost:8080/trader/v1/accounts?fields=positions"Same fields param as above.
curl "http://localhost:8080/trader/v1/accounts/ABC123HASH?fields=positions,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"curl http://localhost:8080/trader/v1/accounts/ABC123HASH/orders/12345678Place 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"}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, ...}'Cancel an order. Returns 204.
curl -X DELETE http://localhost:8080/trader/v1/accounts/ABC123HASH/orders/12345678| 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"curl http://localhost:8080/trader/v1/accounts/ABC123HASH/transactions/987654321Connect to ws://localhost:8080/stream.
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]
{"type": "hello", "client_id": "uuid4", "server_time": "ISO8601", "protocol": 1}{"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": []}{"type": "unsubscribe", "service": "LEVELONE_EQUITIES", "symbols": ["AAPL"], "ref": "unsub-1"}Same ack shape as subscribe.
{"type": "ping"}{"type": "pong", "server_time": "ISO8601"}{"type": "heartbeat", "server_time": "ISO8601"}{
"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
}
}{"type": "error", "code": "BAD_COMMAND", "message": "Unknown service: 'FOO'"}The server does not persist client subscription state across WebSocket reconnects. On disconnect:
- Reconnect to
/stream - Wait for the
helloframe - Re-send all
subscribecommands
The server will re-establish upstream Schwab subscriptions as clients reconnect.
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.
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) orP(put) - Strike × 1000, zero-padded to 8 digits:
00200000= $200.00
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
Downstream apps should:
- Point REST calls at
http://localhost:8080/v1/...— no Schwab credentials needed in your-trading-app - Connect the streaming client to
ws://localhost:8080/stream - Subscribe to
LEVELONE_EQUITIESfor equity scanners andLEVELONE_OPTIONSfor Greeks feeds - Maintain per-symbol
last_knownstate dicts and merge delta ticks - Handle
503from/readyzat startup — retry with backoff before assuming the proxy is healthy - 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_healthyThe 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.
Pass the key in the X-API-Key header:
curl -H "X-API-Key: your-key-here" http://localhost:8080/v1/quotes?symbols=AAPLimport httpx
resp = httpx.get(
"http://localhost:8080/v1/quotes",
params={"symbols": "AAPL"},
headers={"X-API-Key": "your-key-here"},
)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:
.../healthz and /readyz are always exempt — they are used by Docker healthchecks which cannot pass headers.
REST: 401 {"error": {"code": "UNAUTHORIZED", "message": "Invalid or missing API key"}}
WebSocket: connection closed immediately with code 4001 before the hello frame is sent.
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.