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

```elixir
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/1` *refuses*
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.

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

```elixir
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:

```elixir
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:

```elixir
DpExchange.Schwab.Fake.market_status(credentials: creds, market_status: :closed)
```

## Licence

MIT.
