DpExchange.Schwab.Rest (DpExchangeSchwab v0.1.2)

Copy Markdown View Source

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

Account numbers and their encrypted hashes.

Balances for one account, as Core.Types.Balance values.

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

cancel_order(credentials, account_hash, order_id, opts)

@spec cancel_order(map(), String.t(), String.t(), keyword()) ::
  :ok | {:error, term()} | {:refused, term()}

Cancel an order.

get_accounts(credentials, opts)

@spec get_accounts(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

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.

get_balances(credentials, account_hash, opts)

@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.

get_historical_prices(symbol, timeframe, range, credentials, opts)

@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.

get_order(credentials, account_hash, order_id, opts)

@spec get_order(map(), String.t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

One order, as the venue reports it.

get_orders(credentials, account_hash, opts)

@spec get_orders(map(), String.t(), keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

Orders for one account within a window the venue requires.

get_price(symbol, credentials, opts)

@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.

get_symbols(credentials, opts)

@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 projectionsymbol-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_url(opts)

@spec market_data_url(keyword()) :: String.t()

Market-data base URL, overridable for tests.

market_status(credentials, opts)

@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".

max_lookback_days(timeframe)

@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.

place_order(credentials, account_hash, payload, opts)

@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.

timeframes()

@spec timeframes() :: [String.t()]

Canonical candle widths this venue serves, shortest first.

trader_url(opts)

@spec trader_url(keyword()) :: String.t()

Trader base URL, overridable for tests.