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.
It can preview and replace, and nothing else in the family can.previewOrder
validates an order and estimates its cost without placing it; PUT .../orders/{id} amends
atomically. Both matter more here than they would elsewhere: order writes are throttled to
0..120 a minute per account and reads are not, so previewing a rejection is free while
placing one is not — and cancel-then-place spends two writes and opens a window in which
no order is live.
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.