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:

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.

LifetimeRenewed by
access_token30 minutesAuth.refresh/2
refresh_token7 days from its own creationAuth.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.