How It Works
AAPL and MSFT. Coca-Cola and Pepsi. SPY and QQQ. Correlated assets move together — until they don't.
The math: Calculate the z-score of the price ratio between correlated assets. When it exceeds 2 standard deviations, the spread is abnormally wide. Buy the underperformer, short the outperformer, and wait for convergence.
Why it works: The same macro forces drive correlated assets. Temporary divergences are caused by noise (fund flows, earnings timing, sector rotation) and almost always revert.
Used by: Every quantitative hedge fund since the 1980s. Renaissance Technologies, D.E. Shaw, Two Sigma — all started with pairs trading. This is the gateway drug to quant finance.
Backtest Results
Backtested against real historical market data from Yahoo Finance.
| Symbol | Period | Return | Sharpe | Max Drawdown | Win Rate | Trades |
|---|---|---|---|---|---|---|
| NVDA | 2025-02-24 — 2026-02-23 | 5.26% | 0.53 | -6.6% | 100% | 2 |
| AAPL | 2025-02-24 — 2026-02-23 | 11.2% | 1.33 | -8.22% | 66.67% | 3 |
| SPY | 2025-02-24 — 2026-02-23 | 11.01% | 2.19 | -1.82% | 80% | 5 |
Configuration
Source Code
#!/usr/bin/env node
// Pairs Spread Trader — JC Trading Bots
// https://trading.jc.holdings/bot/pairs-spread-trader
// License: MIT | Free forever | Modify however you want
//
// DISCLAIMER: Not financial advice. Past performance does not guarantee
// future results. Trading involves substantial risk of loss.
// Use at your own risk. No guarantees of profit.
//
// Usage:
// node pairs-spread-trader.js # Print signals
// ALPACA_KEY=x ALPACA_SECRET=y node pairs-spread-trader.js # Paper trade
// ALPACA_KEY=x ALPACA_SECRET=y LIVE=1 node pairs-spread-trader.js # REAL money
// ─── Configuration ─────────────────────────────────────────
const CONFIG = {
"period": 30,
"entryStdDev": 2,
"exitStdDev": 0.5,
"positionPct": 80
};
const SYMBOLS = ["SPY"];
// ─── Technical Indicators ───────────────────────────────────
function sma(data, period) {
if (data.length < period) return null;
const slice = data.slice(-period);
return slice.reduce((a, b) => a + b, 0) / period;
}
function ema(data, period) {
if (data.length < period) return null;
const k = 2 / (period + 1);
let val = sma(data.slice(0, period), period);
for (let i = period; i < data.length; i++) {
val = data[i] * k + val * (1 - k);
}
return val;
}
function rsi(closes, period = 14) {
if (closes.length < period + 1) return 50;
let gains = 0, losses = 0;
for (let i = closes.length - period; i < closes.length; i++) {
const diff = closes[i] - closes[i - 1];
if (diff > 0) gains += diff;
else losses -= diff;
}
if (losses === 0) return 100;
const rs = (gains / period) / (losses / period);
return 100 - (100 / (1 + rs));
}
function bollingerBands(closes, period = 20, mult = 2) {
const mid = sma(closes.slice(-period), period);
if (!mid) return { upper: null, mid: null, lower: null };
const slice = closes.slice(-period);
const variance = slice.reduce((sum, v) => sum + Math.pow(v - mid, 2), 0) / period;
const std = Math.sqrt(variance);
return { upper: mid + mult * std, mid, lower: mid - mult * std };
}
function atr(candles, period = 14) {
if (candles.length < period + 1) return 0;
let sum = 0;
for (let i = candles.length - period; i < candles.length; i++) {
const tr = Math.max(
candles[i].high - candles[i].low,
Math.abs(candles[i].high - candles[i - 1].close),
Math.abs(candles[i].low - candles[i - 1].close)
);
sum += tr;
}
return sum / period;
}
function macd(closes, fast = 12, slow = 26, sig = 9) {
if (closes.length < slow + sig) return { macd: 0, signal: 0, histogram: 0 };
const macdSeries = [];
for (let i = slow; i <= closes.length; i++) {
const slice = closes.slice(0, i);
const f = ema(slice, fast);
const s = ema(slice, slow);
if (f !== null && s !== null) macdSeries.push(f - s);
}
if (macdSeries.length < sig) return { macd: 0, signal: 0, histogram: 0 };
const macdLine = macdSeries[macdSeries.length - 1];
const signalLine = ema(macdSeries, sig);
return { macd: macdLine, signal: signalLine || 0, histogram: macdLine - (signalLine || 0) };
}
// ─── Data Fetching (Yahoo Finance) ──────────────────────────
async function fetchCandles(symbol, range = "1y", interval = "1d") {
const url = "https://query1.finance.yahoo.com/v8/finance/chart/" + symbol
+ "?range=" + range + "&interval=" + interval + "&includePrePost=false";
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 JCTradingBot/1.0" }
});
const json = await res.json();
const result = json.chart.result[0];
const ts = result.timestamp;
const q = result.indicators.quote[0];
const candles = [];
for (let i = 0; i < ts.length; i++) {
if (q.close[i] == null) continue;
candles.push({
timestamp: ts[i] * 1000,
date: new Date(ts[i] * 1000).toISOString().split("T")[0],
open: q.open[i],
high: q.high[i],
low: q.low[i],
close: q.close[i],
volume: q.volume[i] || 0,
});
}
return candles;
}
// ─── Alpaca Integration (optional) ──────────────────────────
// Set ALPACA_KEY and ALPACA_SECRET env vars to enable trading.
// Add LIVE=1 for real money (default is paper trading).
async function alpacaTrade(symbol, action, reason) {
const key = process.env.ALPACA_KEY;
const secret = process.env.ALPACA_SECRET;
if (!key || !secret) {
console.log("[DRY RUN] " + action.toUpperCase() + " " + symbol + " — " + reason);
return;
}
const baseUrl = process.env.LIVE === "1"
? "https://api.alpaca.markets"
: "https://paper-api.alpaca.markets";
// Get account buying power
const acct = await fetch(baseUrl + "/v2/account", {
headers: { "APCA-API-KEY-ID": key, "APCA-API-SECRET-KEY": secret }
}).then(r => r.json());
if (action === "buy") {
const budget = parseFloat(acct.buying_power) * 0.9;
if (budget < 10) { console.log("Insufficient funds: $" + acct.buying_power); return; }
const price = await fetch(baseUrl + "/v2/stocks/" + symbol + "/quotes/latest", {
headers: { "APCA-API-KEY-ID": key, "APCA-API-SECRET-KEY": secret }
}).then(r => r.json());
const qty = Math.floor(budget / (price.quote?.ap || 999999));
if (qty < 1) { console.log("Price too high for budget"); return; }
const order = await fetch(baseUrl + "/v2/orders", {
method: "POST",
headers: { "APCA-API-KEY-ID": key, "APCA-API-SECRET-KEY": secret, "Content-Type": "application/json" },
body: JSON.stringify({ symbol, qty: String(qty), side: "buy", type: "market", time_in_force: "day" })
}).then(r => r.json());
console.log("ORDER PLACED: BUY " + qty + " " + symbol + " — " + reason);
console.log("Order ID: " + order.id);
} else if (action === "sell") {
// Close position
const close = await fetch(baseUrl + "/v2/positions/" + symbol, {
method: "DELETE",
headers: { "APCA-API-KEY-ID": key, "APCA-API-SECRET-KEY": secret }
}).then(r => r.json());
console.log("POSITION CLOSED: " + symbol + " — " + reason);
}
}
const BOT_NAME = "Pairs Spread Trader — Statistical Arbitrage";
const DATA_RANGE = "1y";
function evaluate(candles, config) {
const period = config.period || 30;
const entryDev = config.entryStdDev || 2;
const exitDev = config.exitStdDev || 0.5;
if (candles.length < period + 5) return { action: "hold", reason: "Need more data" };
const closes = candles.map(c => c.close);
// Calculate z-score of recent returns vs mean
const returns = [];
for (let i = 1; i < closes.length; i++) {
returns.push((closes[i] - closes[i-1]) / closes[i-1]);
}
const recentReturns = returns.slice(-period);
const mean = recentReturns.reduce((a,b) => a+b, 0) / period;
const variance = recentReturns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / period;
const std = Math.sqrt(variance);
const latestReturn = returns[returns.length - 1];
const zScore = std > 0 ? (latestReturn - mean) / std : 0;
const price = closes[closes.length - 1];
if (zScore < -entryDev) {
return { action: "buy", reason: "Z-score " + zScore.toFixed(2) + " — extreme underperformance (< -" + entryDev + " std). Mean reversion expected. Price: $" + price.toFixed(2) };
}
if (zScore > entryDev) {
return { action: "sell", reason: "Z-score " + zScore.toFixed(2) + " — extreme outperformance (> " + entryDev + " std). Mean reversion expected. Price: $" + price.toFixed(2) };
}
return { action: "hold", reason: "Z-score: " + zScore.toFixed(2) + " (entry at +/-" + entryDev + ") | Price: $" + price.toFixed(2) };
}
// ─── Main ───────────────────────────────────────────────────
async function run() {
console.log("\n" + "=".repeat(60));
console.log(" " + BOT_NAME);
console.log(" " + new Date().toISOString());
console.log("=".repeat(60) + "\n");
for (const symbol of SYMBOLS) {
try {
console.log("Fetching data for " + symbol + "...");
const candles = await fetchCandles(symbol, DATA_RANGE);
console.log("Got " + candles.length + " candles (" + candles[0].date + " to " + candles[candles.length-1].date + ")");
const signal = evaluate(candles, CONFIG);
const icon = signal.action === "buy" ? "BUY" : signal.action === "sell" ? "SELL" : "HOLD";
console.log("\n " + symbol + ": " + icon + " — " + signal.reason);
if (signal.action !== "hold") {
await alpacaTrade(symbol, signal.action, signal.reason);
}
console.log("");
} catch (err) {
console.error("Error on " + symbol + ": " + err.message);
}
}
}
run().catch(err => { console.error(err); process.exit(1); });Full source code. MIT License. Free forever. Modify and deploy however you want.
Where to Run This Bot
TradingView
Charts, screeners, alerts & strategy backtesting. 30% off Premium — essential for any trader.
Open Account →ProfessionalInteractive Brokers
Low-cost global trading for stocks, options, futures. Professional-grade API.
Open Account →Prediction MarketsPolymarket
Prediction market trading. Sports, politics, crypto events. Where our arb bots operate.
Open Account →