How It Works
Every month on options expiry (OPEX), market makers must delta-hedge billions of dollars in options contracts. This creates predictable price behavior:
- Gamma pinning: Prices get "pinned" to max-pain strike prices
- Volatility expansion then crush: Vol rises into OPEX then collapses after
- Mean reversion: Price dislocations during OPEX week revert faster than normal
The strategy: Detect OPEX-week conditions (elevated short-term vol + price dislocation from the 10-day mean) and trade the mean reversion. The gamma exposure forces prices back to equilibrium.
Used by: Citadel, Jane Street, and every options market maker. Now you have the same logic in a bot.
Backtest Results
Backtested against real historical market data from Yahoo Finance.
| Symbol | Period | Return | Sharpe | Max Drawdown | Win Rate | Trades |
|---|---|---|---|---|---|---|
| QQQ | 2025-02-24 — 2026-02-23 | 2.19% | 0.88 | -2.37% | 100% | 3 |
| SPY | 2025-02-24 — 2026-02-23 | 0.78% | 0.95 | -0.34% | 100% | 4 |
Configuration
Source Code
#!/usr/bin/env node
// OPEX Gamma Scalper — JC Trading Bots
// https://trading.jc.holdings/bot/opex-gamma-scalper
// 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 opex-gamma-scalper.js # Print signals
// ALPACA_KEY=x ALPACA_SECRET=y node opex-gamma-scalper.js # Paper trade
// ALPACA_KEY=x ALPACA_SECRET=y LIVE=1 node opex-gamma-scalper.js # REAL money
// ─── Configuration ─────────────────────────────────────────
const CONFIG = {
"lookback": 5,
"volExpansion": 1.5,
"meanReversionTarget": 0.5,
"positionPct": 80
};
const SYMBOLS = ["SPY","QQQ"];
// ─── 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 = "OPEX Gamma Scalper";
const DATA_RANGE = "3mo";
function evaluate(candles, config) {
const lookback = config.lookback || 5;
if (candles.length < lookback + 14) return { action: "hold", reason: "Need more data" };
const currentATR = atr(candles, 14);
const recentSlice = candles.slice(-lookback);
let recentATRSum = 0;
for (let i = 0; i < recentSlice.length; i++) {
const idx = candles.length - lookback + i;
recentATRSum += atr(candles.slice(0, idx + 1), 14);
}
const recentATR = recentATRSum / lookback;
const volExpansion = recentATR / currentATR;
const closes = candles.map(c => c.close);
const mean = sma(closes, 10);
const price = closes[closes.length - 1];
const deviation = mean ? (price - mean) / mean : 0;
// OPEX weeks (3rd Friday) have elevated vol + mean reversion
if (volExpansion > (config.volExpansion || 1.5) && Math.abs(deviation) > 0.01) {
if (deviation < 0) {
return { action: "buy", reason: "OPEX-like conditions: vol expanded " + volExpansion.toFixed(2) + "x, price " + (deviation * 100).toFixed(2) + "% below mean. Gamma pin expected." };
} else {
return { action: "sell", reason: "OPEX-like conditions: vol expanded " + volExpansion.toFixed(2) + "x, price " + (deviation * 100).toFixed(2) + "% above mean. Mean reversion." };
}
}
return { action: "hold", reason: "Vol expansion: " + volExpansion.toFixed(2) + "x | Price deviation: " + (deviation * 100).toFixed(2) + "% from 10d mean" };
}
// ─── 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 →