Rules for an agent or developer writing code against this package. Read this before the README; it is what the Hex tarball ships for consumers.
1. This package is EXPERIMENTAL and cannot be proven here
Nothing in it has run against the live API. Every endpoint needs OAuth credentials this repository must never hold, and Schwab publishes no sandbox — its documentation promises Trader API sandboxes "later this year" and neither specification declares a non-production server. There is nowhere to exercise this that is not real money.
Check DpExchange.Schwab.capabilities().endpoints before calling anything. Maturity is per
endpoint.
2. Credentials are arguments, never configuration
Pass them per call. This package never reads a vault, never caches a token, and never logs one.
credentials = %{access_token: "…", refresh_token: "…", client_id: "…", client_secret: "…"}:access_token alone is enough to sign. The rest are needed to refresh.
There is no anonymous surface — market data included. A call without a token is refused
locally with {:error, {:missing_credentials, :schwab}} rather than being sent.
3. Refresh, and persist what you get back
The access token lives 30 minutes. DpExchange.Schwab.Auth.refresh/2 renews it.
The refresh token is one-time use. Every refresh spends the old one and returns a new one carrying a fresh seven days. So:
- Persist the returned credential before using it. Refreshing and then crashing before storing costs the grant, and only a person at a browser can restore it.
- Do not retry a refresh. The package will not, deliberately. If a refresh times out, the token may already have been spent; try again with the credential you still hold, not the one you just sent.
{:refused, {:reauthorization_required, _status, _detail}}is terminal. Seven days elapsed with no refresh, or the user reset their password. Send a person to the login page; do not retry.
Refreshing at least once a week means never needing a person again.
4. A symbol is one instrument, not a pair
"AAPL", not "AAPL-USD". Pair-shaped input is refused, and this matters more than it
looks: BTC, ETH and SOL are real listed equity tickers, so a crypto pair routed here
by mistake has a plausible wrong answer available.
Option symbols are fixed-width and positional — "XYZ 240315C00500000". The padding is
part of the format; do not trim it.
5. Candles: eight widths, and a hard lookback cap
1m 5m 10m 15m 30m 1d 1w 1M.
Minute widths reach at most ten days back. A longer range returns
{:error, {:lookback_exceeds_venue, timeframe, requested_days, max_days}}. It is not
truncated and not downgraded to a coarser width — handle the error; do not assume a series.
An unsupported width returns {:error, {:unsupported_timeframe, width}}.
6. Account calls need a hash, not an account number
get_accounts/2 returns %{account_number: …, hash: …}. Every other account path takes
the hash, passed as :account_hash. Nothing is defaulted — placing an order against a
silently-chosen account is not something this package will do for you.
{:ok, [account]} = DpExchange.Schwab.get_accounts(credentials)
DpExchange.Schwab.get_balances(credentials, account_hash: account.hash)7. Orders: only what Core can name
Order types: :market, :limit, :stop, :stop_limit. Time in force: :day, :gtc,
:fok, :ioc.
:iocand:fokare time-in-force here, not order types. Schwab spells them asduration.:post_onlyand:gtddo not exist on this venue and are refused rather than mapped to something near. Schwab's dated expiries are three fixed horizons, not an arbitrary date.- The venue supports
TRAILING_STOP,MARKET_ON_CLOSEandLIMIT_ON_CLOSE, whichCorehas no vocabulary for. They are not reachable through this facade. - Multi-leg spreads and
OCO/TRIGGERorders are not reachable either —place_order/3takes a flat request.
Schwab publishes which instructions each asset type accepts, and this package enforces it
before sending: BUY/SELL/SELL_SHORT/BUY_TO_COVER are equity-only, and the
_TO_OPEN/_TO_CLOSE forms are option-only. Order writes are throttled and reads are not,
so a locally-catchable rejection is worth catching.
7a. Preview before you place, and replace rather than cancel
Two things this venue can do that no other in the family can. Both are declared —
supports_order_preview and supports_order_replace — so you can branch on capability
rather than on venue name.
{:ok, preview} = DpExchange.Schwab.preview_order(credentials, request, account_hash: hash)Preview is close to free and placing is not. Order writes are throttled here to somewhere between 0 and 120 a minute per account; reads are unthrottled. A rejection found by previewing costs nothing. One found by placing costs a scarce write.
{:ok, new_id} = DpExchange.Schwab.replace_order(credentials, old_id, request, account_hash: hash)replace_order/4 returns a NEW id. Schwab treats a replacement as a new order, so the
id you passed in is dead afterwards — keep the one you get back, or you will be tracking an
order that no longer exists.
Use it instead of cancel-then-place wherever you can. The two are not equivalent: cancel-then-place leaves a window with no order live, and spends two throttled writes rather than one.
7b. Sessions, and the order types Core learned here
Every order carries a session — NORMAL unless you say otherwise. Pass :session in
the request or session: in options. supported_sessions lists what the venue takes;
this is the only venue in the family where the field is non-empty, because it is the only
one whose market closes.
Eight order types, not four: :market, :limit, :stop, :stop_limit,
:trailing_stop, :trailing_stop_limit, :market_on_close, :limit_on_close.
A trailing stop requires :stop_price_offset and is refused locally without one — the
offset is the order. :stop_price_link_basis ("BID") and :stop_price_link_type
("VALUE", "PERCENT", "TICK") ride along under the venue's own names, because Core
names none of the three.
8. The market closes, and silence is usually correct
Call market_status/1 before concluding a quiet feed is broken. This is the only venue in
the family where delivering nothing is the normal overnight state.
coverage/1 reports what has arrived, not what was subscribed. An empty map at 3am is
not a fault.
9. What this venue does not have
get_order_book/2, get_market_overview/1, list_instruments/1, get_fees/2,
get_transfers/2, get_rate_limit_status/2, quantization/1 and get_trade_history/2 all
return {:error, :not_supported}. Route that work elsewhere rather than discovering an
empty result.
get_symbols/1 is not in that list. It works, but requires :query — the venue has no
list-everything projection.
10. Rate limits are yours, not the venue's
The documented ceiling is 0..120 order writes per minute per account, set per
application at registration. Pass :order_limit_per_minute matching your own app's. Zero
is a legal registration value.