Alpaca Auto Trading With Astronomer Signals
Image credit: ESA/Hubble & NASA

Alpaca Auto Trading With Astronomer Signals

By The astronomer Team

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 places an Alpaca order for each one.

It trades against Alpaca's paper API by default, and runs as a dry run (signals logged, no orders sent) until you add keys — so you can watch the flow before risking a cent.

Get The Example

The full source lives on GitHub in astronomer-app/signal-stream-examples, under alpaca/node. Clone it and install:

git clone https://github.com/astronomer-app/signal-stream-examples.git
cd signal-stream-examples/alpaca/node
npm install
cp .env.example .env

It leans on the standard library on purpose: the built-in fetch calls Alpaca's REST API (no broker SDK) and node:http runs a health/status server (no web framework). The only dependency is the signal client itself.

Configure

Fill in .env:

# Required: your Signal Stream key.
ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_...

# Optional: add Alpaca paper keys to place real paper orders.
# Leave blank to run in dry-run mode.
ALPACA_API_KEY_ID=
ALPACA_API_SECRET_KEY=

# Options data feed used to price the limit: indicative (free) or opra (paid).
ALPACA_OPTIONS_FEED=indicative

# How far above the live ask to set the buy limit, as a fraction (0.02 = 2%).
ORDER_LIMIT_SLIPPAGE=0.02
ORDER_QUANTITY=1

ALPACA_BASE_URL defaults to https://paper-api.alpaca.markets. Only change it if you mean to trade live.

How It Works

The service does three things:

  1. Starts a status server on PORT with GET /health (liveness) and GET /status (counters: signals received, orders placed, orders failed).
  2. Opens the Signal Stream connection and runs a callback for every signal.
  3. Builds the option contract from the signal, looks up its live quote, and submits a marketable limit order to buy it.
const listener = client.signals.listen({
  onOpen() {
    console.log('Signal Stream connected.');
  },
  async onSignal({ signal }) {
    if (!alpaca) {
      console.log(`[dry run] would buy ${signal.symbol}`);
      return;
    }

    await alpaca.placeOptionOrder({
      underlyingSymbol: signal.symbol,
      optionType: signal.optionType,
      strikePrice: signal.strikePrice,
      expirationDate: signal.expirationDate,
      quantity,
    });
  },
  onError(error) {
    console.error('Signal Stream error:', error);
  },
});

Building The Order

A signal gives you symbol, optionType, strikePrice, and expirationDate. Alpaca wants those as a single OCC option symbol, so the example assembles one from the parts:

// SPY + 260618 + C + 00746000 -> "SPY260618C00746000"
function toOccSymbol(req: OptionOrderRequest): string {
  const yymmdd = req.expirationDate.slice(2).replaceAll('-', '');
  const strike = Math.round(req.strikePrice * 1000)
    .toString()
    .padStart(8, '0');
  return `${req.underlyingSymbol.toUpperCase()}${yymmdd}${req.optionType}${strike}`;
}

The expirationDate arrives as an ISO date like 2026-06-18, which drops straight into the 260618 slice — no parsing required.

Pricing The Order

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 example fetches the contract's latest quote and prices the limit just above the ask: marketable enough to fill, without chasing.

Alpaca prices options per share of the underlying, so a 1.20 ask is $120 for the 100-share contract. Quotes come from the options market-data API on the feed you configured (indicative is free and delayed; opra is real-time):

private async getLatestAsk(occSymbol: string): Promise<number> {
  const url = new URL('/v1beta1/options/quotes/latest', this.config.dataBaseUrl);
  url.searchParams.set('symbols', occSymbol);
  url.searchParams.set('feed', this.config.feed);

  const response = await fetch(url, { headers: this.authHeaders });
  if (!response.ok) {
    throw new Error(`Alpaca quote failed (${response.status})`);
  }

  const body = await response.json();
  const ask = body.quotes?.[occSymbol]?.ap;
  if (typeof ask !== 'number' || ask <= 0) {
    throw new Error(`No ask price for ${occSymbol}`);
  }
  return ask;
}

The limit is that ask plus a small cushion (ORDER_LIMIT_SLIPPAGE), rounded up to a valid tick. Options trade in $0.05 increments under $3.00 and $0.10 at or above it, so an off-tick price gets rejected:

function roundUpToTick(price: number): number {
  const tickCents = price < 3 ? 5 : 10;
  const priceCents = Math.round(price * 100);
  return (Math.ceil(priceCents / tickCents) * tickCents) / 100;
}

Placing The Order

With the symbol built and the limit priced off the live ask, the order is a single POST (the full client is in src/alpaca.ts):

async placeOptionOrder(req: OptionOrderRequest): Promise<PlacedOrder> {
  const occSymbol = toOccSymbol(req);
  const ask = await this.getLatestAsk(occSymbol);
  const limitPrice = roundUpToTick(ask * (1 + this.config.limitSlippage));

  const response = await fetch(`${this.config.baseUrl}/v2/orders`, {
    method: 'POST',
    headers: this.authHeaders,
    body: JSON.stringify({
      symbol: occSymbol,
      qty: req.quantity,
      side: 'buy',
      type: 'limit',
      time_in_force: 'day',
      limit_price: limitPrice,
    }),
  });

  if (!response.ok) {
    throw new Error(
      `Alpaca order failed (${response.status}): ${await response.text()}`,
    );
  }

  const order = (await response.json()) as PlacedOrder;
  return { ...order, ask, limitPrice };
}

Run It

npm run dev    # watch mode
# or
npm start

Then probe it from another terminal:

curl localhost:8080/health
curl localhost:8080/status

Signals only arrive during Signal Stream hours (9:30 AM to 3:00 PM Eastern on weekdays), so outside that window the service sits connected with no activity. That's expected.

Going Live

This is an example. Before trading real money, add at least:

  • Idempotency — track processed signal.ids so a reconnect can't double-fill.
  • Position sizing and risk limits — derive ORDER_QUANTITY from account equity and a cap on open positions instead of a constant.
  • Quote quality and guards — the example prices off the free indicative feed, which is delayed and modified. Use the real-time opra feed, and reject orders when the quote is stale or the spread is too wide to trade sanely.

Pointing ALPACA_BASE_URL at the live API places real orders with real money. Keep it on paper until you've watched the flow end to end.

More examples — other languages and brokers — land in the same repo, astronomer-app/signal-stream-examples. We'll cover Interactive Brokers, ETrade, and Thinkorswim integrations in separate posts.