Tradier Auto Trading With Astronomer Signals

By The Astronomer Team

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/signals to receive signals
  • Node's built-in fetch for Tradier's Brokerage API
  • Node's built-in node:http module 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}/orders after 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.