← All Bots

Volatility Crush Harvester

FREEOpen SourcestockMIT License

Buys after volatility spikes, harvests the inevitable crush back to normal.

1Downloads
3Views
3.08%Best Return
0.67Sharpe Ratio

How It Works

Volatility is the one thing in markets that is truly mean-reverting. After every spike (earnings, Fed meetings, geopolitical events), vol always crushes back down.

The pattern: VIX spikes to 30+ then crushes back to 15 within weeks. This bot detects ATR spikes above 1.8x the average and buys the underlying, expecting prices to stabilize and recover as vol compresses.

The edge: This is essentially what options sellers do — harvest the vol premium. But instead of selling options (risky), this bot buys the asset itself when vol is elevated (much safer).

Deploy Volatility Crush Harvester 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
SPY2025-02-24 — 2026-02-233.08%0.67-4.34%50%2

Configuration

atrPeriod14
lookback50
spikeThreshold1.4
crushThreshold0.7
positionPct85
Symbols: SPY

Source Code

#!/usr/bin/env node
// Volatility Crush Harvester — JC Trading Bots
// https://trading.jc.holdings/bot/volatility-crush-harvester
// 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 volatility-crush-harvester.js                    # Print signals
//   ALPACA_KEY=x ALPACA_SECRET=y node volatility-crush-harvester.js  # Paper trade
//   ALPACA_KEY=x ALPACA_SECRET=y LIVE=1 node volatility-crush-harvester.js  # REAL money

// ─── Configuration ─────────────────────────────────────────
const CONFIG = {
  "atrPeriod": 14,
  "lookback": 50,
  "spikeThreshold": 1.4,
  "crushThreshold": 0.7,
  "positionPct": 85
};
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 = "Volatility Crush Harvester";
const DATA_RANGE = "1y";

function evaluate(candles, config) {
  const period = config.atrPeriod || 14;
  const lookback = config.lookback || 50;
  if (candles.length < lookback + period) return { action: "hold", reason: "Need more data" };

  const currentATR = atr(candles, period);
  // Average ATR over lookback
  let atrSum = 0;
  for (let i = candles.length - lookback; i < candles.length; i++) {
    atrSum += atr(candles.slice(0, i + 1), period);
  }
  const avgATR = atrSum / lookback;
  const ratio = currentATR / avgATR;
  const price = candles[candles.length - 1].close;

  if (ratio > (config.spikeThreshold || 1.4)) {
    return { action: "buy", reason: "Vol SPIKE detected. ATR ratio: " + ratio.toFixed(2) + "x avg. Buy the fear, harvest the crush. Price: $" + price.toFixed(2) };
  }
  if (ratio < (config.crushThreshold || 0.6)) {
    return { action: "sell", reason: "Vol CRUSHED to " + ratio.toFixed(2) + "x avg. Take profits. Price: $" + price.toFixed(2) };
  }
  return { action: "hold", reason: "ATR ratio: " + ratio.toFixed(2) + "x avg — normal volatility. 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