Verified against a live account before and after writing it. CHART_FUTURES
delivers one true-OHLCV minute bar per symbol per minute, LEVEL_ONE_FUTURES
reports delayed: false, and consecutive bars arrived sixty seconds apart through
the production code path.
Yahoo stays. Schwab serves no futures history whatever, so seed_source resolves
to Yahoo even when SEED_SOURCE=schwab is asked for — the pairing is the intended
configuration rather than a fallback. The symbols differ, ES=F against /ES, so
Settings.live_symbol picks the live one while seeding always uses Yahoo's.
Three findings worth keeping, each of which cost a round trip:
- get_quote() singular returns the wrong instrument entirely. It puts the symbol
in the URL path, where the leading slash is normalised away, so /ES resolves to
Eversource Energy at $72 and returns HTTP 200 with a populated body. Only
get_quotes() plural, which passes symbols as a query parameter, returns the
future. A 200 is not evidence; assetMainType is.
- Streaming requires the Accounts and Trading product. StreamClient.login() reads
/trader/v1/userPreference for its socket URL, and that path does not exist in
Market Data Production.
- /ES resolves to the active contract on Schwab's side, so the contract roll
handling the plan left open needs no code.
The stream drops the oldest queued message rather than stalling the socket, and
surfaces a dead pump task instead of waiting forever on a queue nothing fills.
schwab-py moves into requirements.txt, imported only when LIVE_SOURCE=schwab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first attempt failed with invalid_client and no way to tell why: the key,
the secret, the callback, or an app not yet propagated all look identical from
the browser, which shows raw JSON. It turned out to be a key clipped by one
character on paste.
Two preflight checks now say which. The authorize endpoint is asked whether it
recognises the key. The token endpoint is asked to exchange a deliberately
invalid code, which separates bad credentials from a bad grant — it
authenticates the key and secret over HTTP Basic before it looks at the code, so
invalid_client means the pair is wrong and invalid_grant means the pair is fine.
That second check matters more than it sounds. The secret is not used at all
during login, so a truncated one survives the whole browser round trip and only
surfaces at the exchange, by which point the authorisation code has been spent
and the flow has to start over.
Neither check can tell a wrong value from an app that is not live yet — an
invented key produces the identical response, verified — and both say so rather
than guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config.py had no Schwab fields at all, so the keys listed in .env.example were
being silently dropped by extra="ignore". They exist now, blank, and nothing
reads them while live_source is yahoo.
The token path moves under data/, which is the Coolify persistent volume. Left
at the repository root it would vanish on every rebuild, and re-authenticating
is an interactive browser flow, not something a deploy can do for itself.
scripts/check_schwab.py answers empirically what the app is entitled to rather
than inferring it from documentation: whether the credentials authenticate,
whether /ES quotes return (futures market data is a separate entitlement from
equities), and whether the streamer bootstrap responds.
That last one is the decision. StreamClient.login() reads
/trader/v1/userPreference for its socket URL and credentials, and that path
belongs to the Accounts and Trading product — so an app registered for Market
Data Production alone cannot stream, and CHART_FUTURES is unreachable until the
app adds it. The script reports which of the two paths is open instead of
leaving it to be discovered halfway through an implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>