Schwab's REST surface — internal.
Every call is signed, market data included
There is no anonymous endpoint on this venue. A quote needs the same bearer token an
order does, which is why credential_benefit is :required and why every function
here takes credentials.
Two servers, one package
Market data lives at /marketdata/v1 and accounts and trading at /trader/v1. They
are separate API products on Schwab's portal with separate specifications, and a
consumer sees neither — which is the facade doing its job (D12).
The candle triple
/pricehistory does not take a width. It takes periodType, period, frequencyType
and frequency, and the legal combinations are constrained in both directions. This
module owns the translation from a canonical width like "5m" to that quadruple, and
refuses a width the venue cannot serve rather than picking the nearest one — see
timeframes/0.
The refusal that matters is not the unknown width. It is the reachable one: minute
candles require periodType=day, which caps the lookback at ten days. A caller asking
for a year of one-minute data is asking for something this venue does not have, and
answering with ten days of it, or with daily candles, would be a plausible wrong answer
of exactly the kind this family exists to refuse.
The market closes
market_status/1 is answered from /markets, not assumed. This is the first venue in
the family where a feed delivering nothing is usually correct rather than broken, and
guessing :open would make a real outage indistinguishable from a Saturday.
A pull requires a query
get_symbols/1 takes a :query, because /instruments has no list-everything
projection — all six of its projections search against a term. Without one it returns
{:error, {:query_required, :schwab}} and sends nothing.
Accounts are addressed by a hash, and getting one is a prerequisite
/accounts/accountNumbers returns account numbers and their encrypted hashes, and
every other account path takes the hash. So get_accounts/2 is not a convenience here;
it is the first call any trading flow has to make.
Balances read the account's own declared type. A MarginAccount and a CashAccount
carry entirely different fields, and an account whose type the venue did not state is
{:error, :unexpected_response_shape} rather than assumed to be either — reading a
margin account as cash would report no buying power for an account that has one.
Placing an order returns an id from a header, or fails
Schwab answers a placed order with 201 and an empty body; the id is in Location.
A 201 with no Location returns {:error, :order_id_not_returned} rather than
success, because a caller that cannot name the order it just placed cannot cancel it.
Summary
Functions
Cancel an order.
Account numbers and their encrypted hashes.
Balances for one account, as Core.Types.Balance values.
Candles for symbol at timeframe.
One order, as the venue reports it.
Orders for one account within a window the venue requires.
A quote for one symbol.
Symbols matching a search term.
Market-data base URL, overridable for tests.
Whether the equity market is open, from the venue rather than from a guess.
The deepest lookback a width can reach, in days.
Place an order. Returns the venue's order id, read from the Location header.
Canonical candle widths this venue serves, shortest first.
Trader base URL, overridable for tests.
Functions
@spec cancel_order(map(), String.t(), String.t(), keyword()) :: :ok | {:error, term()} | {:refused, term()}
Cancel an order.
Account numbers and their encrypted hashes.
Every other account endpoint takes the hash, not the number. Schwab returns both from this one endpoint, which makes it a prerequisite for the whole trading surface rather than a convenience. A caller holding an account number and no hash cannot address anything.
Returned as a list of %{account_number: ..., hash: ...} rather than the venue's own
key names, because the hash is the part a caller uses and the naming should say so.
@spec get_balances(map(), String.t(), keyword()) :: {:ok, [DpExchange.Core.Types.Balance.t()]} | {:error, term()} | {:refused, term()}
Balances for one account, as Core.Types.Balance values.
A Schwab account is a discriminated union: a MarginAccount and a CashAccount carry
different balance fields entirely, and the response says which through type. Both are
read; neither is guessed at from the other.
Cash is reported as a USD balance. available_balance is the account's own
buying power for a margin account and its cash available for trading for a cash
account — which are the same question answered by different fields, and the venue
answers only the one that applies.
@spec get_historical_prices(String.t(), String.t(), keyword(), map(), keyword()) :: {:ok, [DpExchange.Core.Types.Quote.t()]} | {:error, term()} | {:refused, term()}
Candles for symbol at timeframe.
Refuses rather than substitutes, in two distinct ways that mean different things:
{:error, {:unsupported_timeframe, timeframe}}— the venue does not serve this width at all.{:error, {:lookback_exceeds_venue, timeframe, requested_days, max_days}}— the width exists but cannot reach that far back. Minute candles cap at ten days. This is the refusal that matters: answering with ten days, or with daily candles, would be a plausible wrong answer.
@spec get_order(map(), String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
One order, as the venue reports it.
@spec get_orders(map(), String.t(), keyword()) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Orders for one account within a window the venue requires.
@spec get_price(String.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.Quote.t()} | {:error, term()} | {:refused, term()}
A quote for one symbol.
Timestamped from the venue's own quoteTime. A quote the venue did not date returns
{:error, :missing_venue_timestamp} — the local clock is never substituted.
@spec get_symbols( map(), keyword() ) :: {:ok, [String.t()]} | {:error, term()} | {:refused, term()}
Symbols matching a search term.
This venue has no catalogue endpoint. /instruments takes a projection —
symbol-search, symbol-regex, desc-search, desc-regex, search, fundamental —
and every one of them searches against a term. There is no "list everything", and there
could not sensibly be: the catalogue is every US-listed equity and option.
So a pull here requires a query, and one is refused by name when absent:
{:error, {:query_required, :schwab}}. That is deliberately not :not_supported —
the endpoint works, and the caller has to say what it wants. Returning the result of
some arbitrary search instead would hand back a short list that looks like a catalogue,
which is the plausible wrong answer this venue makes available.
Pass :query, and optionally :projection (default symbol-search).
Market-data base URL, overridable for tests.
@spec market_status( map(), keyword() ) :: {:ok, :open | :closed} | {:error, term()} | {:refused, term()}
Whether the equity market is open, from the venue rather than from a guess.
/markets answers isOpen directly. A venue that says nothing about a market returns
{:ok, :closed} only when it explicitly says so — an absent market is
{:error, :unexpected_response_shape}, because "the venue did not mention it" is not
the same as "it is shut".
@spec max_lookback_days(String.t()) :: {:ok, pos_integer()} | :error
The deepest lookback a width can reach, in days.
Minute widths are only reachable through periodType=day, whose largest legal period
is 10. This is the number a caller needs before asking for a year of one-minute data,
and get_historical_prices/4 refuses rather than truncating silently.
@spec place_order(map(), String.t(), map(), keyword()) :: {:ok, String.t()} | {:error, term()} | {:refused, term()}
Place an order. Returns the venue's order id, read from the Location header.
@spec timeframes() :: [String.t()]
Canonical candle widths this venue serves, shortest first.
Trader base URL, overridable for tests.