
Charles Schwab Auto Trading With Astronomer Signals
Charles Schwab Auto Trading With Astronomer Signals
The Signal Stream Client hands you a fully specified option contract the moment a signal is created. This walkthrough turns that into a small, long-lived Node service that prices and places a Charles Schwab option order for each signal.
Schwab's Trader API is the successor to the TD Ameritrade API, and to the thinkorswim automation that thousands of retail traders built on top of it. When those endpoints were retired, every script written against them had to be rewritten. This example is a working starting point for that migration, and it runs against a taxable brokerage account or an eligible Roth or Traditional IRA without changing a line of code.
One thing to know before anything else: Schwab has no paper trading environment. The Trader API reaches live accounts only. There is nowhere to rehearse an order, which shapes how the whole example is built.
No Paper Account Changes The Design
The Alpaca example defaults to a paper endpoint. The E*TRADE example defaults to a sandbox. Schwab offers neither — its developer portal has a sandbox for validating authentication and response shapes, but it does not simulate trading. Any order this service places is real money in a real account.
So credentials alone are not enough to arm it. Trading requires a second, explicit opt-in:
SCHWAB_TRADING_ENABLED=false
With full credentials and this left at false, the service connects, receives
signals, and logs what it would have bought. That gives you the rehearsal
Schwab doesn't, and it means a stray .env copied from a working machine can't
start trading on its own.
Get The Example
The full source lives on GitHub in
astronomer-app/signal-stream-examples,
under
schwab/node.
Clone it and install:
git clone https://github.com/astronomer-app/signal-stream-examples.git
cd signal-stream-examples/schwab/node
npm install
cp .env.example .env
The service uses:
@astronomer-app/signalsto receive signals- Node's built-in
fetchfor Schwab's Trader and Market Data APIs - Node's built-in
node:httpmodule for health and status endpoints
Schwab publishes no official Node SDK. Rather than pin a third-party wrapper on the path that holds your OAuth credentials, the example calls the REST API directly — it is plain JSON over bearer tokens, and the entire broker client is about 250 lines. The only runtime dependency is the Signal Stream client.
Register The App
Create an app in the Schwab developer portal against both the Accounts and Trading Production and Market Data Production products.
Two details cost people the most time here:
- Approval is not instant. The app sits in
Approved - Pendingfor a few days before it flips toReady For Use. Only the second status works. Start this before you write any code. - The callback URL must be HTTPS. No certificate authority will sign a
certificate for
127.0.0.1, so a local HTTPS listener means self-signing one. The example avoids that entirely, as described below, andhttps://127.0.0.1is a fine value to register.
Authorize
Add the app credentials to .env and run the authorization helper:
npm run auth
It prints Schwab's authorization URL. Sign in, approve, and the browser redirects to your callback and shows an error page — nothing is listening there. That error page is the point: the address bar now holds the authorization code.
Copy the entire address and paste it back at the prompt. The helper parses it rather than asking for the code alone, which matters more than it looks:
function extractCode(pasted: string): string {
const url = new URL(pasted.trim());
const code = url.searchParams.get('code');
if (!code) {
throw new Error(`No "code" parameter found in ${url.origin}${url.pathname}`);
}
return code;
}
Every Schwab authorization code ends in @, which arrives in the address bar
percent-encoded as %40. Parsing the URL decodes it; hand-copying just the
code value tends not to, and the token exchange then fails with an unhelpful
error.
The helper exchanges the code for tokens and prints the refresh token plus the accounts available to you:
SCHWAB_REFRESH_TOKEN=...
SCHWAB_ACCOUNT_HASH=...
Schwab addresses accounts by an opaque hash from
GET /trader/v1/accounts/accountNumbers, never by the display account number —
plain account numbers are rejected outside of request bodies.
Pointing It At An IRA
npm run auth lists every account on the login, taxable and retirement alike,
and the service trades whichever hash you configure. Moving from a brokerage
account to a Roth or Traditional IRA is a one-line change to
SCHWAB_ACCOUNT_HASH — the order code doesn't change at all.
That is worth calling out because of what Astronomer signals actually are. Schwab permits options in traditional and Roth IRAs when the account is approved for options trading, and the IRA restrictions land on strategies this example never uses:
- Prohibited in an IRA — borrowing money to trade, shorting stock, and naked short calls. Any options trade that requires margin is rejected.
- Permitted — buying calls and puts outright. Schwab describes buying long calls as a stock-replacement strategy available in retirement accounts.
Every order here is a single-leg BUY_TO_OPEN, paid for in full. That is a
debit rather than a borrow, which is the category Schwab allows. Schwab's own
risk software is designed to reject a trade that would violate IRA rules, so a
misconfigured account fails at the broker rather than quietly doing something
impermissible.
Two things to confirm before relying on it:
- The IRA needs its own options approval. Approval is per-account — approval on your taxable account does not carry over. Schwab emails the status of an application within about three business days.
- Check the account is actually reachable. Run
npm run authand confirm the IRA appears in the printed list before configuring its hash.
Roth IRA earnings may qualify for tax-free withdrawal, and Traditional IRA investments generally grow tax-deferred. Consult a tax professional about your circumstances.
The Seven-Day Clock
Schwab runs two token lifetimes, and only one of them can be automated:
- Access tokens last 30 minutes. The client refreshes these itself.
- Refresh tokens last 7 days. Nothing can extend one. When it lapses, a
human has to run
npm run authand click through the login again.
The in-process half is straightforward — renew a minute early, and let concurrent signals share one refresh instead of racing to replace it:
private getAccessToken(): Promise<string> {
// Refresh a minute early so a token can't expire mid-flight.
if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 60_000) {
return Promise.resolve(this.accessToken);
}
// Concurrent signals share one refresh instead of racing to replace it.
this.pendingRefresh ??= this.refreshAccessToken().finally(() => {
this.pendingRefresh = null;
});
return this.pendingRefresh;
}
Every request also retries once on a 401, because a token can be rejected
before its advertised expiry and a single stale token shouldn't cost you a
signal.
The weekly half is an operational problem, not a code problem. A service left running past day seven stops trading, and it stops quietly. Alert on it.
Building The Symbol
A signal includes symbol, optionType, strikePrice, and
expirationDate. Schwab wants a fixed-width 21-character option symbol:
[underlying padded to 6][YYMMDD][C|P][strike * 1000 padded to 8]
The padding is significant, and it is the most common way to get a
Symbol not found back from an otherwise correct request. SPY has to become
SPY followed by three spaces:
const yymmdd = `${year.slice(2)}${month}${day}`;
const strike = Math.round(req.strikePrice * 1000)
.toString()
.padStart(8, '0');
return `${underlying.padEnd(6, ' ')}${yymmdd}${req.optionType}${strike}`;
The SPY June 18, 2026 $746 call becomes:
"SPY 260618C00746000"
The expirationDate arrives as an ISO date like 2026-06-18, so YYMMDD is a
slice rather than a parse. The example still validates the date and strike
before any broker request.
Pricing The Order
Those padding spaces have to survive the query string, and this is a place
where the obvious code is wrong. URLSearchParams form-encodes a space as +:
symbols=SPY+++260618C00746000&fields=quote # wrong
symbols=SPY%20%20%20260618C00746000&fields=quote # right
So the example builds that one query manually with encodeURIComponent:
const query = `symbols=${encodeURIComponent(symbol)}&fields=quote`;
const response = await this.authed(`/marketdata/v1/quotes?${query}`, {
method: 'GET',
});
// Quotes come back as an object keyed by the requested symbol.
const body = (await response.json()) as Record<string, OptionQuote>;
const quote = body[symbol];
The response is keyed by symbol, with the prices under a nested quote object
and a realtime flag reflecting your account's market-data agreements.
A single fixed limit can't work across contracts — one trading at $0.30 and one at $5.00 need very different numbers — so the limit is priced just above the live ask. Options trade in $0.05 increments below $3.00 and $0.10 at or above it, and an off-tick price is rejected:
export function roundUpToTick(price: number): number {
const tickCents = price < 3 ? 5 : 10;
const priceCents = Math.round(price * 100);
return (Math.ceil(priceCents / tickCents) * tickCents) / 100;
}
Rounding up keeps the slippage cushion instead of rounding it away. Schwab
prices options per share of the underlying, so a 1.20 ask is $120 for a
standard 100-share contract.
Placing The Order
With the symbol built and the limit priced, the order is a single POST:
const order = {
orderType: 'LIMIT',
session: 'NORMAL',
duration: 'DAY',
orderStrategyType: 'SINGLE',
price: limitPrice.toFixed(2),
orderLegCollection: [
{
instruction: 'BUY_TO_OPEN',
quantity: req.quantity,
instrument: {
symbol,
assetType: 'OPTION',
},
},
],
};
const response = await this.authed(
`/trader/v1/accounts/${encodeURIComponent(this.config.accountHash)}/orders`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
},
);
A success returns 201 with an empty body. The new order's ID is in the
Location header, as the last path segment:
function parseOrderId(location: string | null): string {
const id = location?.split('/').pop()?.trim();
return id || 'unknown';
}
Note what is missing: Schwab's order endpoint accepts no client-supplied order
ID. The E*TRADE example can derive a clientOrderId from the signal ID and let
the broker recognize a duplicate; here there is no such safety net. Retrying an
ambiguous failure can open a second position, which makes reconciliation your
responsibility rather than the broker's.
Wiring It To Signals
The listener is the same shape as the other examples:
const listener = client.signals.listen({
onOpen() {
console.log('Signal Stream connected.');
},
async onSignal({ signal }) {
if (!schwab) {
console.log(`[dry run] would buy ${signal.symbol}`);
return;
}
await schwab.placeOptionOrder({
underlyingSymbol: signal.symbol,
optionType: signal.optionType,
strikePrice: signal.strikePrice,
expirationDate: signal.expirationDate,
quantity,
});
},
onError(error) {
console.error('Signal Stream error:', error);
},
});
Run It
npm run dev # watch mode
# or
npm start
Probe the status server from another terminal:
curl localhost:8080/health
curl localhost:8080/status
Signals only arrive during Signal Stream hours, so outside that window the service remains connected with no activity. That is expected.
Going Live
This is an example. Because there is no paper account to graduate from, add at least the following before turning it on:
- Durable idempotency and reconciliation — with no client order ID,
persist processed signal IDs yourself and reconcile against
GET /accounts/{hash}/ordersbefore retrying anything ambiguous. - Position sizing and risk limits — derive quantity from account equity and enforce caps on open positions and daily losses.
- IRA suitability checks — if the configured hash is a retirement account, confirm it carries options approval and that the strategy is permitted there rather than discovering it from a rejected order.
- Quote quality guards — honor the
realtimeflag. Reject delayed or stale quotes and wide spreads instead of pricing a limit off them. - Refresh token operations — alert before the 7-day expiry, because the service stops trading the moment it lapses.
- Order status follow-up — a
201means accepted, not filled. Poll or stream order status to learn what actually happened.
Then set:
SCHWAB_TRADING_ENABLED=true
only when you intend to place orders in the configured Schwab account with real money. There is no sandbox to fall back on.
More broker and language examples live in
astronomer-app/signal-stream-examples.