Tradier Auto Trading With Astronomer Signals
Tradier 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 previews and places a Tradier option order for each signal.
Tradier is the broker to start with if the other examples put you off. The
Schwab example needs a developer app that
waits days for approval and a refresh token that dies every seven days. The
E*TRADE example needs an interactive OAuth 1.0
flow repeated every trading day. Tradier needs a token you paste into .env
once.
That is the headline: there is no authorization flow. Tradier authenticates with a static bearer token, takes form-encoded request bodies, and returns JSON. The broker client is the smallest of the four examples, and it has no token refresh logic at all.
A Real Paper Environment
Tradier runs a full sandbox at sandbox.tradier.com that accepts the complete
trading API against paper money. Unlike Schwab, which has no paper environment
at all, you can rehearse the entire flow before risking anything. The example
defaults there:
TRADIER_ENVIRONMENT=sandbox
Sandbox and production issue different tokens, and they are not
interchangeable — a paper token sent to a production endpoint fails with
invalid api call as no apiproduct match found. Whichever you use, make sure
it matches the environment you configured.
Get The Example
The full source lives on GitHub in
astronomer-app/signal-stream-examples,
under
tradier/node.
Clone it and install:
git clone https://github.com/astronomer-app/signal-stream-examples.git
cd signal-stream-examples/tradier/node
npm install
cp .env.example .env
The service uses:
@astronomer-app/signalsto receive signals- Node's built-in
fetchfor Tradier's Brokerage API - Node's built-in
node:httpmodule for health and status endpoints
Tradier publishes no official Node SDK, and the API does not need one. The only runtime dependency is the Signal Stream client itself.
Configure
Get a token from
dash.tradier.com/settings/api and add
it to .env along with your Signal Stream key:
# Required: your Signal Stream key.
ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_...
# "sandbox" (paper money, delayed quotes) or "live" (real money).
TRADIER_ENVIRONMENT=sandbox
# Leave both blank to run as a dry run.
TRADIER_ACCESS_TOKEN=
TRADIER_ACCOUNT_ID=
ORDER_QUANTITY=1
ORDER_LIMIT_SLIPPAGE=0.02
ORDER_PREVIEW=true
PORT=8080
Leave both Tradier variables blank and the service runs as a dry run: signals are logged and no broker requests are made. A partial configuration fails fast, so a missing account ID cannot silently disable trading.
Find Your Account
Tradier addresses accounts by an account number like VA000001, not by your
login. The example ships a helper that lists them:
npm run accounts
TRADIER_ACCOUNT_ID=VA000001
type: margin
status: active
option level: 3
That last line matters more than it looks. Buying calls and puts to open requires option level 2 or higher. A lower level authenticates perfectly well and then rejects every order you send, which is a confusing way to find out. The helper warns you when none of your accounts qualify.
Building The Symbol
A signal includes symbol, optionType, strikePrice, and
expirationDate. Tradier identifies a contract by its OCC symbol:
[underlying][YYMMDD][C|P][strike * 1000 padded to 8]
The SPY June 18, 2026 $746 call becomes:
SPY260618C00746000
If you are coming from the Schwab example, note what is missing: Schwab wants
a fixed-width 21-character symbol with the underlying padded to six characters,
so SPY becomes SPY plus three spaces. Tradier does not pad. The example
still validates the date and strike before any broker request:
const yymmdd = `${year.slice(2)}${month}${day}`;
const strike = Math.round(req.strikePrice * 1000)
.toString()
.padStart(8, '0');
return `${underlying}${yymmdd}${req.optionType}${strike}`;
The JSON Quirk That Will Bite You
Tradier's JSON mirrors the XML it grew out of. A collection holding a single
member serializes as a bare object, and only becomes an array at two or more.
Ask for one symbol and quotes.quote is an object, not a one-element array:
{ "quotes": { "quote": { "symbol": "SPY260618C00746000", "ask": 2.48 } } }
Ask for two and it is an array. The same applies to accounts, orders, and positions. Every list has to be normalized before it can be indexed:
function toArray<T>(value: T | T[] | undefined | null): T[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? value : [value];
}
Code that works against a two-symbol response and breaks against a one-symbol response is almost always this.
Pricing The Order
A single fixed limit cannot 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. Unknown symbols come back under unmatched_symbols rather than
as an error, so the example matches on the symbol instead of trusting the first
entry:
const quote = toArray(body.quotes?.quote).find(
(candidate) => candidate.symbol === occSymbol,
);
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. Tradier
prices options per share of the underlying, so a 1.20 ask is $120 for a
standard 100-share contract.
Sandbox Quotes Are Delayed, Production Quotes Are Not
Sandbox market data is delayed the industry-standard 15 minutes. Production is real-time for equities and options, free to any Tradier Brokerage account holder — see Tradier's realtime versus delayed table.
The delay is a property of the token, not the endpoint, so no market data route avoids it in the sandbox. Because the limit here is priced off the ask, a sandbox fill is not a realistic rehearsal of the live one. Use the sandbox to validate authentication, symbol construction, and request shape — not execution behavior.
Previewing And Placing The Order
Tradier takes orders as form-encoded parameters rather than a JSON body, and
wants both the underlying and the contract — symbol carries the underlying,
option_symbol the OCC contract:
return new URLSearchParams({
class: 'option',
symbol: req.underlyingSymbol.toUpperCase(),
option_symbol: occSymbol,
side: 'buy_to_open',
quantity: String(req.quantity),
type: 'limit',
duration: 'day',
price: limitPrice.toFixed(2),
});
Post that same body with preview=true and Tradier validates the order without
sending it. The preview runs the real checks — buying power, option level,
contract validity — and returns the estimated cost, commission, and margin
impact.
E*TRADE makes previewing mandatory and hands back a preview ID you must use within three minutes. Tradier's is optional and stateless: the same body twice, once with the flag on and once with it off. The example previews by default and logs what came back:
Signal received: SPY 746C exp 2026-06-18
Preview: cost $253.65, commission $0.00, fees $0.65
Order 20258740 placed (SPY260618C00746000, limit 2.55 vs ask 2.48, status: ok)
It costs one extra API call per signal. Set ORDER_PREVIEW=false to skip it.
One thing to watch: Tradier can answer 200 with a non-ok status in the
body, so the body decides whether an order succeeded, not the HTTP code.
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 (!tradier) {
console.log(`[dry run] would buy ${signal.symbol}`);
return;
}
await tradier.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.
Watch your request budget while testing. Market data endpoints allow 60 requests per minute in the sandbox versus 120 in production, per access token, and each signal costs one quote call plus one or two order calls.
Going Live
This is an example. Before trading real money, add at least:
- Durable idempotency and reconciliation — persist processed signal IDs and
reconcile against
GET /accounts/{id}/ordersafter reconnects or ambiguous API failures. - Position sizing and risk limits — derive quantity from account equity and enforce caps on open positions and daily losses.
- Quote quality guards — reject stale quotes and spreads too wide to trade sanely instead of pricing a limit off them. The example takes any ask it gets.
- Preview policy — act on the preview rather than logging it. Reject orders whose cost or margin impact exceeds a threshold.
- Order status follow-up — a placed limit order is not a fill. Track order status and decide what to do with day orders that never fill.
Then set:
TRADIER_ENVIRONMENT=live
only when you intend to place orders in the configured Tradier account with real money. Sandbox remains the default.
More broker and language examples live in
astronomer-app/signal-stream-examples.