← All Bots

Pairs Spread Trader

FREEOpen SourcestockMIT License

Statistical arbitrage on correlated assets. When the spread widens, bet on convergence.

1Downloads
3Views
11.2%Best Return
1.33Sharpe Ratio

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.

Deploy Pairs Spread Trader Live

Download the bot, connect TradingView for charts, and use Alpaca or IBKR to execute trades.

Free charts · Paper trading available · Takes 5 minutes

Backtest Results

Backtested against real historical market data from Yahoo Finance.

SymbolPeriodReturnSharpeMax DrawdownWin RateTrades
NVDA2025-02-24 — 2026-02-235.26%0.53-6.6%100%2
AAPL2025-02-24 — 2026-02-2311.2%1.33-8.22%66.67%3
SPY2025-02-24 — 2026-02-2311.01%2.19-1.82%80%5

Configuration

period30
entryStdDev2
exitStdDev0.5
positionPct80
Symbols: SPY

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); });
Download .js File

Full source code. MIT License. Free forever. Modify and deploy however you want.

Where to Run This Bot

Compare All Brokers →

Deploy this bot →Get TradingView Free