Charles Schwab's Trader API, behind the family's shared facade.
EXPERIMENTAL. Nothing here has run in production, and on this venue that is
structural rather than temporary: every endpoint needs OAuth credentials this repository
must never hold, and Schwab publishes no sandbox — its own documentation says Trader
API sandboxes "will be available later this year", and neither specification declares a
non-production server. There is nowhere to exercise this package that is not somebody's
real money, so maturity is :experimental throughout and moves only when a consumer
trades live (D15).
What is different about this venue
Five things, and each shows up in the contract rather than being smoothed over:
A symbol is one instrument, not a pair. Every other venue in the family addresses
BASE-QUOTE. Here AAPL names a single security and what you pay with is USD because
the venue is a US broker. SymbolFormat.validate/1 therefore refuses pair-shaped input
instead of splitting it — BTC, ETH and SOL are all real listed equity tickers, so a
misrouted crypto pair has a plausible wrong answer waiting for it.
The market closes. market_status/1 is answered from /markets, not assumed. A feed
delivering nothing at 3am is correct, and a consumer that alarms on silence would alarm
every night — making a real outage indistinguishable from a Saturday.
The host authenticates; this package signs and refreshes. The initial grant is
three-legged OAuth through a browser and a person, which no library can do. Everything
after is mechanical: the access token lives 30 minutes and Auth.refresh/2 renews it,
minting a new refresh token each time with a fresh seven days. A host that keeps
refreshing never needs a person again.
There is no order book and no socket. Neither specification describes depth or
streaming, so get_order_book/2 is :unsupported and the feed is a poll. Schwab
publishes a separate Thinkorswim product where a streaming surface would live; it is out
of scope here and named so a reader knows where to look.
The catalogue cannot be enumerated. /instruments has no list-everything projection
— all six of its projections search against a term — so get_symbols/1 requires a
:query and returns {:error, {:query_required, :schwab}} without one. That is
deliberately not :not_supported: the endpoint works, and a caller must be able to
tell "needs a term" from "has no endpoint". Returning some arbitrary search instead would
hand back a short list that looks like a catalogue.
Credentials
Passed per call, never read from a vault and never cached here (§6.0, invariant #2):
credentials = %{
access_token: "…",
refresh_token: "…",
client_id: "…",
client_secret: "…"
}Only :access_token is needed to sign. The rest are needed to refresh, and the result
of a refresh must be persisted by the host — the refresh token is one-time use, so the
one returned is the only way to refresh again.
Summary
Functions
Balances for one account.
Not supported. Schwab publishes no fee-schedule endpoint. previewOrder returns an
estimated commission for one order, which the contract cannot express and which is not
a fee schedule.
Not supported. Schwab publishes no rate-limit status endpoint, and its documented order ceiling is a property of the application's registration rather than something queryable at runtime.
A pull here requires a query, and that is the venue's shape rather than a gap.
Not supported yet. /transactions carries fills, but mapping a Schwab transaction
onto Core.Types.Fill needs a live response to check against and this repository holds
no credential. Declared :experimental and returning :not_supported would be a
declaration disagreeing with itself, so it is neither — see capabilities/0.
Not supported. Money movement is not part of the Trader API.
Validate an order without placing it.
Canonical candle widths this venue serves.
Replace an open order atomically.
Refusals reach the subscriber through the same mailbox as quotes, so a caller registering here receives them.
Endpoints the venue does not serve, as distinct from ones not yet written.
Functions
Balances for one account.
Requires :account_hash — Schwab addresses accounts by an encrypted hash, and
get_accounts/2 is the only place to get one. That makes it a prerequisite for the
whole trading surface rather than a convenience.
Not supported. Schwab publishes no fee-schedule endpoint. previewOrder returns an
estimated commission for one order, which the contract cannot express and which is not
a fee schedule.
Not supported. Schwab publishes no rate-limit status endpoint, and its documented order ceiling is a property of the application's registration rather than something queryable at runtime.
A pull here requires a query, and that is the venue's shape rather than a gap.
GET /instruments has no "list everything" projection — every lookup is a search
against a term — so the catalogue cannot be enumerated at all, only queried. Pass
:query; without one this returns {:error, {:query_required, :schwab}}, which is
deliberately not :not_supported. Returning some arbitrary search instead would hand
back a short list that looks like a catalogue.
Not supported yet. /transactions carries fills, but mapping a Schwab transaction
onto Core.Types.Fill needs a live response to check against and this repository holds
no credential. Declared :experimental and returning :not_supported would be a
declaration disagreeing with itself, so it is neither — see capabilities/0.
Not supported. Money movement is not part of the Trader API.
Validate an order without placing it.
The only endpoint in the family that checks an order against the venue's own rules before committing, and it earns its keep here specifically: order writes are throttled on this venue and reads are not, so a rejection found by previewing costs nothing while one found by placing costs a scarce write.
Builds the same payload place_order/3 would, so a preview that passes describes the
order that would actually be sent.
@spec quotes() :: [String.t()]
Canonical candle widths this venue serves.
Replace an open order atomically.
Schwab amends in one call. Every other venue in the family cancels and re-places, and those are not equivalent here: cancel-then-place opens a window in which no order is live, and it spends two throttled writes rather than one.
Returns the new order id. Schwab treats a replacement as a new order, so the old id is dead afterwards and a caller still holding it would be tracking something that no longer exists.
Refusals reach the subscriber through the same mailbox as quotes, so a caller registering here receives them.
Endpoints the venue does not serve, as distinct from ones not yet written.