
Alpaca 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 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:
- Starts a status server on
PORTwithGET /health(liveness) andGET /status(counters: signals received, orders placed, orders failed). - Opens the Signal Stream connection and runs a callback for every signal.
- 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_QUANTITYfrom account equity and a cap on open positions instead of a constant. - Quote quality and guards — the example prices off the free
indicativefeed, which is delayed and modified. Use the real-timeoprafeed, 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.