DpExchangeSchwab
EXPERIMENTAL. Charles Schwab's Trader API behind the shared DpExchange.Core.Venue
facade.
Part of the DpExchange family: every venue package exposes the same facade, returns the
same Core.Types.* values, and hides its own transport. A consumer cannot tell from the
facade how data reaches the package, and must not be able to.
def deps do
[{:dp_exchange_schwab, "~> 0.1.0"}]
end
Status
Nothing here has run against the live API, and on this venue that is structural rather than temporary:
- Every endpoint requires OAuth credentials this repository must never hold.
- 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.
So there is nowhere to exercise this package that is not somebody's real money. Every
endpoint is declared :experimental and none can become :proven here — that happens when
a consumer trades live. See capabilities/0; maturity is per endpoint, not per package.
The declaration was derived from Schwab's two OpenAPI documents before any provider code
was written. Both are committed under docs/reference/schwab/, because the portal returns
403 to an anonymous reader and publishes no spec anywhere — this reference cannot be
re-fetched, so it travels with the code.
What is different about this venue
A symbol is one instrument, not a pair. Every other venue in the family addresses
BASE-QUOTE. Here AAPL names a single security. SymbolFormat.validate/1refuses
pair-shaped input rather than splitting it: BTC, ETH and SOL are all real listed
equity tickers, so a misrouted crypto pair has a plausible wrong answer waiting — an ETF
holding nothing like the coin, quoted in dollars, indistinguishable downstream from a real
price.
The market closes.market_status/1 is answered from /markets, never 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.
There is no order book and no socket. Neither specification describes depth or
streaming. get_order_book/2 is :unsupported and the feed is a REST poll behind
Core.PollingFeed. Schwab publishes a separate Thinkorswim product where a streaming
surface would live; it is out of scope here.
The catalogue cannot be enumerated./instruments has no list-everything projection —
every lookup is a search. get_symbols/1 therefore requires a :query and returns
{:error, {:query_required, :schwab}} without one. That is deliberately not:not_supported: the endpoint works, the caller has to say what it wants.
Authentication
The host authenticates. This package signs, and refreshes.
The initial grant is three-legged OAuth: a browser, a person, and a redirect through Schwab's login site. No library can do that. Everything after is mechanical, and per §6.0 credential use — signing, session refresh, token rotation — belongs here.
| Lifetime | Renewed by | |
|---|---|---|
access_token | 30 minutes | Auth.refresh/2 |
refresh_token | 7 days from its own creation | Auth.refresh/2 — every call mints a new one, and the seven days restart |
The refresh token is one-time use. A refresh spends the token it was given and returns its replacement. So there is no weekly ceiling on unattended operation: a host refreshing every half hour rolls the window forward every half hour and never needs a person again.
Persist the result of every refresh before using it. Losing the returned token costs the
grant, and recovering costs a person at a browser. refresh/2 is never retried internally —
it is at-most-once, because a retry after a timeout re-sends a token that may already have
been spent.
credentials = %{
access_token: "…",
refresh_token: "…",
client_id: "…",
client_secret: "…"
}
{:ok, quote} = DpExchange.Schwab.get_price("AAPL", credentials: credentials)
Supervision
A library does not start itself. Supervise it:
children = [
{DpExchange.Schwab, symbols: ["AAPL", "MSFT"], credentials: credentials}
]
The order ceiling is not declared in capabilities/0, because Schwab has none to
declare: the documented limit is 0..120 order writes per minute per account, set per
application at registration. Pass :order_limit_per_minute matching your own app's
registration.
Testing against it
DpExchange.Schwab.Fake is an in-process stand-in that refuses what the real venue
refuses — a pair-shaped symbol, a missing credential, a year of one-minute candles, an
instruction that does not match the asset type. It is also the only place in the family
where the closed-market path can be exercised:
DpExchange.Schwab.Fake.market_status(credentials: creds, market_status: :closed)
Licence
MIT.