How to Backtest Options Strategies in Python (Working Code) | FlashAlpha
python · 26 min read

How to Backtest Options Strategies in Python: A Complete Guide With Working Code

A complete, tested options backtester in one Python file. It replays FlashAlpha historical data day by day, reads dealer gamma and the volatility risk premium for your entry and exit rules, fills trades at real historical bid and ask quotes, and writes every trade to CSV. Also in JavaScript, Go, Java and C#.

F
Oct 31, 2026
26 min read
Python Backtesting OptionsAPI HistoricalData GEX VRP Tutorial

What you get: a backtester you can run in five minutes and change in three places. entry_rules() decides when to sell, select_legs() picks the contracts, and exit_rules() decides when to close. All three read the same FlashAlpha endpoint outputs you would use live, so a rule you backtest is a rule you can trade. Every number below comes from running the code in this article against the Historical API.

What this guide covers

What an options backtest needs

A stock backtest can get by with daily closes. An options backtest cannot, for three reasons:

  • Point-in-time signals. A decision at 15:45 can only use what was known at 15:45. Dealer gamma, implied volatility and the volatility risk premium all change during the day, so they have to be replayed as of the decision minute, not taken from an end-of-day file.
  • Real quotes for every contract. The strike you sell, the strike you buy and the daily marks in between all need that minute's bid and ask. Theoretical prices from a model miss the spread, which is often most of the edge in short premium.
  • A cost model. Fills between mid and the far side of the spread, plus commission, on every leg and every side.

The FlashAlpha Historical API serves all three by minute since 2017: the same analytics endpoints as the live API, replayed with an at= timestamp, plus historical option chains with bid, ask and greeks. It is available on Alpha and above plans.

Set up and run it

You need Python 3.10 or newer, the requests package and a FlashAlpha API key on the Alpha plan or higher.

pip install requests
export FLASHALPHA_API_KEY=your_key      # Windows PowerShell: $env:FLASHALPHA_API_KEY="your_key"
python flashalpha_backtest.py --symbol SPY --start 2024-01-02 --end 2024-06-28

Download the script from GitHub or copy it from the end of this guide. The run above takes about three minutes the first time and prints a summary:

{
  "sessions": 124,
  "trades": 2,
  "win_rate": 1.0,
  "total_pnl": 58.3,
  "avg_win": 29.15,
  "avg_loss": null,
  "max_drawdown": 35.5,
  "sharpe": 1.52,
  "exit_reasons": {
    "profit_target": 2
  },
  "open_at_end": 0
}

Every setting in the Config class is also a command-line flag, for example --short-delta 0.16 --width 10 --max-positions 3 --slippage 1. Results go to results/trades.csv and results/equity.csv. Every API response is cached in .fa_cache/, so the second run with changed rules finishes in seconds and uses no API calls for days already fetched.

How the backtester works

For every trading day from --start to --end, at the decision time (15:45 ET by default):

  1. Build the snapshot. Three calls: /v1/stockquote for spot, /v1/exposure/summary for dealer gamma and /v1/vrp for implied vol, realized vol and the volatility risk premium. If the quote is from an earlier date, the market was closed and the day is skipped.
  2. Mark open positions. One /v1/optionquote call per leg gives that minute's bid and ask. The position's profit at mid goes to exit_rules(); if it returns a reason, the position closes at bid and ask with your slippage setting.
  3. Look for an entry. If there is room for a position and entry_rules() returns True, the backtester finds the expiry closest to 35 days out and fetches its chain, and select_legs() picks the contracts. They fill at that minute's quotes.
  4. Record the day. Realized and open profit go to the equity curve.

Decisions and fills use the same minute, and nothing after that minute is read. That is what keeps the test free of look-ahead.

The FlashAlpha snapshot your rules read

build_snapshot() flattens the endpoint responses into one dictionary, so a rule reads snap["vrp_20d"] instead of digging through JSON. A field is None when the API did not return it, so check before comparing.

FieldEndpointWhat it is
spot/v1/stockquoteUnderlying mid price at the decision time
gamma_regime/v1/exposure/summarypositive_gamma when dealers are long gamma (their hedging tends to dampen moves), negative_gamma when short
net_gex/v1/exposure/summaryNet dealer gamma exposure
gamma_flip/v1/exposure/summaryPrice at which net dealer gamma changes sign
atm_iv/v1/vrpAt-the-money implied vol, in vol points
rv_20d/v1/vrp20-day realized vol, in vol points
vrp_20d/v1/vrpImplied minus 20-day realized vol: the premium an option seller collects
vrp_percentile, vrp_z/v1/vrpWhere today's VRP sits in its own history
harvest_score/v1/vrpFlashAlpha's VRP score conditioned on dealer gamma

Rules also receive history, the snapshots of every earlier session, oldest first. That makes streaks and changes easy: history[-1]["gamma_regime"] is yesterday's regime.

Where to put entry rules

entry_rules() returns True to open a position. The default sells premium when implied vol is above realized and dealers have been long gamma for two sessions running, the combination our meta-study of 357,937 backtests tested. Here it is in every language:

def entry_rules(snap: dict, history: list[dict], cfg: Config) -> bool:
    """ENTRY RULES. Return True to open a position at this decision time.

    snap is today's snapshot (see build_snapshot); history holds the earlier sessions' snapshots, oldest first.
    Default: implied vol above 20-day realized (VRP > 0) and dealers long gamma for 2 sessions running.
    """
    gamma_streak = [s["gamma_regime"] for s in (history[-1:] + [snap])]
    return (
        snap["vrp_20d"] is not None and snap["vrp_20d"] > 0
        and len(gamma_streak) == 2 and all(g == "positive_gamma" for g in gamma_streak)
    )
/**
 * ENTRY RULES. Return true to open a position at this decision time.
 *
 * snap is today's snapshot (see buildSnapshot); history holds the earlier sessions' snapshots, oldest first.
 * Default: implied vol above 20-day realized (VRP > 0) and dealers long gamma for 2 sessions running.
 */
export function entryRules(snap, history, cfg) {
  const gammaStreak = [...history.slice(-1), snap].map((s) => s.gammaRegime);
  return (
    snap.vrp20d != null && snap.vrp20d > 0
    && gammaStreak.length === 2 && gammaStreak.every((g) => g === "positive_gamma")
  );
}
// entryRules is the ENTRY RULES. Return true to open a position at this decision time.
//
// snap is today's snapshot (see buildSnapshot); history holds the earlier sessions' snapshots, oldest first.
// Default: implied vol above 20-day realized (VRP > 0) and dealers long gamma for 2 sessions running.
func entryRules(snap Snapshot, history []Snapshot, cfg Config) bool {
	if len(history) == 0 {
		return false // need yesterday's regime too
	}
	yesterday := history[len(history)-1]
	return snap.VRP20d != nil && *snap.VRP20d > 0 &&
		yesterday.RegimeIs("positive_gamma") && snap.RegimeIs("positive_gamma")
}
/**
 * ENTRY RULES. Return true to open a position at this decision time.
 *
 * <p>snap is today's snapshot (see buildSnapshot); history holds the earlier sessions' snapshots, oldest first.
 * Default: implied vol above 20-day realized (VRP &gt; 0) and dealers long gamma for 2 sessions running.
 */
static boolean entryRules(Snapshot snap, List<Snapshot> history, Config cfg) {
    Snapshot previous = history.isEmpty() ? null : history.get(history.size() - 1);
    return snap.vrp20d != null && snap.vrp20d > 0
            && previous != null
            && "positive_gamma".equals(previous.gammaRegime)
            && "positive_gamma".equals(snap.gammaRegime);
}
/// <summary>
/// ENTRY RULES. Return true to open a position at this decision time.
///
/// snap is today's snapshot (see BuildSnapshot); history holds the earlier sessions' snapshots, oldest first.
/// Default: implied vol above 20-day realized (VRP > 0) and dealers long gamma for 2 sessions running.
/// </summary>
public static bool EntryRules(Snapshot snap, IReadOnlyList<Snapshot> history, Config cfg)
{
    var gammaStreak = history.TakeLast(1).Append(snap).Select(s => s.GammaRegime).ToList();
    return snap.Vrp20d is not null && snap.Vrp20d > 0
        && gammaStreak.Count == 2 && gammaStreak.All(g => g == "positive_gamma");
}

Swap the return line for any condition on the snapshot.

Four other entry rules to try
# VRP in the top 40% of its own past year (percentile from /v1/vrp)
return snap["vrp_percentile"] is not None and snap["vrp_percentile"] >= 60

# Price above the gamma flip, where dealer hedging tends to dampen moves (/v1/exposure/summary)
return snap["gamma_flip"] is not None and snap["spot"] > snap["gamma_flip"]

# FlashAlpha's GEX-conditioned harvest score (/v1/vrp)
return snap["harvest_score"] is not None and snap["harvest_score"] >= 60

# Only on Mondays, so positions are spread out in time
return dt.date.fromisoformat(snap["date"]).weekday() == 0 and snap["vrp_20d"] > 0

Where to choose contracts

select_legs() receives the full option chain for the chosen expiry, with bid, ask and delta for every strike, and returns the legs to trade. Each leg is a dictionary with type ("P" or "C"), strike, expiry and side (-1 to sell, +1 to buy). The default is a put credit spread; the other tabs show a cash-secured short put (one leg) and an iron condor (two spreads):

def select_legs(chain: list[dict], snap: dict, cfg: Config) -> list[dict] | None:
    """WHICH CONTRACTS. Return legs as dicts {type, strike, expiry, side}, side -1 = sell, +1 = buy.

    Default: put credit spread - sell the put closest to cfg.short_delta, buy the put cfg.width points lower.
    """
    puts = [o for o in chain if o["type"] == "P" and o.get("delta") is not None and (o.get("bid") or 0) > 0]
    if not puts:
        return None
    short = min(puts, key=lambda o: abs(abs(o["delta"]) - cfg.short_delta))
    lower = [o for o in puts if o["strike"] <= short["strike"] - cfg.width]
    if not lower:
        return None
    long = max(lower, key=lambda o: o["strike"])
    return [
        {"type": "P", "strike": short["strike"], "expiry": short["expiry"], "side": -1},
        {"type": "P", "strike": long["strike"], "expiry": long["expiry"], "side": +1},
    ]
def select_legs(chain, snap, cfg):
    """Cash-secured short put: one leg."""
    puts = [o for o in chain if o["type"] == "P" and o.get("delta") is not None and o["bid"] > 0]
    if not puts:
        return None
    short = min(puts, key=lambda o: abs(abs(o["delta"]) - cfg.short_delta))
    return [{"type": "P", "strike": short["strike"], "expiry": short["expiry"], "side": -1}]
def select_legs(chain, snap, cfg):
    """Iron condor: a put credit spread and a call credit spread at the same delta and width."""
    legs = []
    for t, below in (("P", True), ("C", False)):
        side = [o for o in chain if o["type"] == t and o.get("delta") is not None and o["bid"] > 0]
        if not side:
            return None
        short = min(side, key=lambda o: abs(abs(o["delta"]) - cfg.short_delta))
        wing = [o for o in side if (o["strike"] <= short["strike"] - cfg.width if below
                                    else o["strike"] >= short["strike"] + cfg.width)]
        if not wing:
            return None
        far = max(wing, key=lambda o: o["strike"]) if below else min(wing, key=lambda o: o["strike"])
        legs += [{"type": t, "strike": short["strike"], "expiry": short["expiry"], "side": -1},
                 {"type": t, "strike": far["strike"], "expiry": far["expiry"], "side": +1}]
    return legs

The expiry comes from --target-dte, --min-dte and --max-dte: the backtester tries Fridays closest to the target, and the Thursday before when the Friday is a market holiday.

Where to put exit rules

exit_rules() runs every session for every open position and returns a reason string to close it, or None to hold. pos.pnl_mid is the open profit in dollars at mid, and pos.credit is the net credit taken in. The default takes half the credit, stops at twice the credit, closes with 21 days left, and closes after two sessions of negative dealer gamma:

def exit_rules(pos: "Position", snap: dict, history: list[dict], cfg: Config) -> str | None:
    """EXIT RULES. Return a reason string to close the position at this decision time, or None to hold.

    pos.pnl_mid is the open profit of the whole position in dollars at mid. pos.credit is the net premium taken in,
    after commission: positive for a credit, negative for a debit, so the targets below use its size.
    Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
    of negative dealer gamma.
    """
    if pos.pnl_mid >= cfg.profit_target * abs(pos.credit):
        return "profit_target"
    if pos.pnl_mid <= -cfg.stop_loss * abs(pos.credit):
        return "stop_loss"
    if pos.dte(snap["date"]) <= cfg.exit_dte:
        return "time_exit"
    if history and history[-1]["gamma_regime"] == "negative_gamma" and snap["gamma_regime"] == "negative_gamma":
        return "negative_gamma_2_sessions"
    return None
/**
 * EXIT RULES. Return a reason string to close the position at this decision time, or null to hold.
 *
 * pos.pnlMid is the open profit of the whole position in dollars at mid. pos.credit is the net premium taken in,
 * after commission: positive for a credit, negative for a debit, so the targets below use its size.
 * Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
 * of negative dealer gamma.
 */
export function exitRules(pos, snap, history, cfg) {
  if (pos.pnlMid >= cfg.profitTarget * Math.abs(pos.credit)) return "profit_target";
  if (pos.pnlMid <= -cfg.stopLoss * Math.abs(pos.credit)) return "stop_loss";
  if (pos.dte(snap.date) <= cfg.exitDte) return "time_exit";
  if (history.length > 0 && history.at(-1).gammaRegime === "negative_gamma" && snap.gammaRegime === "negative_gamma") {
    return "negative_gamma_2_sessions";
  }
  return null;
}
// exitRules is the EXIT RULES. Return a reason string to close the position at this decision time, or "" to hold.
//
// pos.PnLMid is the open profit of the whole position in dollars at mid. pos.Credit is the net premium taken in,
// after commission: positive for a credit, negative for a debit, so the targets below use its size.
// Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
// of negative dealer gamma.
func exitRules(pos *Position, snap Snapshot, history []Snapshot, cfg Config) string {
	if pos.PnLMid >= cfg.ProfitTarget*math.Abs(pos.Credit) {
		return "profit_target"
	}
	if pos.PnLMid <= -cfg.StopLoss*math.Abs(pos.Credit) {
		return "stop_loss"
	}
	if pos.DTE(snap.Date) <= cfg.ExitDTE {
		return "time_exit"
	}
	if len(history) > 0 && history[len(history)-1].RegimeIs("negative_gamma") && snap.RegimeIs("negative_gamma") {
		return "negative_gamma_2_sessions"
	}
	return ""
}
/**
 * EXIT RULES. Return a reason string to close the position at this decision time, or null to hold.
 *
 * <p>pos.pnlMid is the open profit of the whole position in dollars at mid. pos.credit is the net premium taken
 * in, after commission: positive for a credit, negative for a debit, so the targets below use its size.
 * Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
 * of negative dealer gamma.
 */
static String exitRules(Position pos, Snapshot snap, List<Snapshot> history, Config cfg) {
    if (pos.pnlMid >= cfg.profitTarget * Math.abs(pos.credit)) {
        return "profit_target";
    }
    if (pos.pnlMid <= -cfg.stopLoss * Math.abs(pos.credit)) {
        return "stop_loss";
    }
    if (pos.dte(snap.date) <= cfg.exitDte) {
        return "time_exit";
    }
    Snapshot previous = history.isEmpty() ? null : history.get(history.size() - 1);
    if (previous != null && "negative_gamma".equals(previous.gammaRegime)
            && "negative_gamma".equals(snap.gammaRegime)) {
        return "negative_gamma_2_sessions";
    }
    return null;
}
/// <summary>
/// EXIT RULES. Return a reason string to close the position at this decision time, or null to hold.
///
/// pos.PnlMid is the open profit of the whole position in dollars at mid. pos.Credit is the net premium taken in,
/// after commission: positive for a credit, negative for a debit, so the targets below use its size.
/// Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
/// of negative dealer gamma.
/// </summary>
public static string? ExitRules(Position pos, Snapshot snap, IReadOnlyList<Snapshot> history, Config cfg)
{
    if (pos.PnlMid >= cfg.ProfitTarget * Math.Abs(pos.Credit))
        return "profit_target";
    if (pos.PnlMid <= -cfg.StopLoss * Math.Abs(pos.Credit))
        return "stop_loss";
    if (pos.Dte(snap.Date) <= cfg.ExitDte)
        return "time_exit";
    if (history.Count > 0 && history[^1].GammaRegime == "negative_gamma" && snap.GammaRegime == "negative_gamma")
        return "negative_gamma_2_sessions";
    return null;
}

Add any condition before the final return None.

Three other exit rules to try
# Close when the volatility risk premium turns negative (/v1/vrp)
if snap["vrp_20d"] is not None and snap["vrp_20d"] < 0:
    return "vrp_negative"

# Close when price falls below the gamma flip (/v1/exposure/summary)
if snap["gamma_flip"] is not None and snap["spot"] < snap["gamma_flip"]:
    return "below_gamma_flip"

# Close after 10 calendar days whatever happens
if (dt.date.fromisoformat(snap["date"]) - dt.date.fromisoformat(pos.entry_date)).days >= 10:
    return "max_hold"

To hold to expiry, set --exit-dte 0. On expiry day the position settles at intrinsic value against spot at the decision time.

Adding another endpoint

Any FlashAlpha historical endpoint can feed your rules: add one call in build_snapshot() and put the fields you need into the snapshot. For example, the call and put walls from /v1/exposure/levels:

# In build_snapshot(), before the return: add the call and put walls from /v1/exposure/levels
lv = fa.get(f"/v1/exposure/levels/{symbol}", at=at) or {}
levels = lv.get("levels") or {}
snapshot = {...}                      # the dict build_snapshot already returns
snapshot["put_wall"] = levels.get("put_wall")
snapshot["call_wall"] = levels.get("call_wall")

# Then use it in a rule, for example in select_legs(): only sell puts below the put wall
puts = [o for o in puts if snap["put_wall"] is None or o["strike"] < snap["put_wall"]]

The same pattern works for /v1/exposure/narrative, /v1/maxpain, /v1/exposure/zero-dte, /v1/volatility and the rest of the API. Each added endpoint is one more call per session, cached like the others.

Fills and costs

Every fill uses the quotes at the decision minute. --slippage sets where you fill between mid and the far side: 0 fills at mid, 1 sells at the bid and buys at the ask, and the default 0.5 fills halfway. Commission is --commission per contract, per leg, per side, $0.65 by default.

Fill assumptions matter more in options than anywhere else. Here are the same 39 SPY trades from the results below, rerun from the cache at three slippage settings:

FillsAverage credit per spreadTotal profitSharpe ratio
Mid (--slippage 0)$58.24$208.100.36
Halfway (--slippage 0.5, the default)$57.04$83.600.14
Bid and ask (--slippage 1)$55.85$6.600.01

The credit barely changes, but paying the spread on every entry and exit took almost all of the profit. Run your strategy at --slippage 0 and --slippage 1 before trusting it: for short premium, the fill model is often the edge.

Reading the results

Here are the default rules on SPY from January 2022 to September 2026, with up to two positions open at once and halfway fills (3,186 API calls on the first run):

39trades, 54% winners
$83.60total profit for one spread per trade
$250.95largest fall from a high in the equity curve
0.14Sharpe ratio of daily profit
Year of entryTradesProfit ($)
20221+26.40
202310+67.25
20249+111.10
20259+18.10
202610−139.25
A starting point, not an edge

The default rules roughly broke even over nearly five years, and they lost money in 2026. That is the useful part. You now have a loop you can change in three places and rerun in seconds from the cache, with every trade on file to see why it won or lost.

trades.csv has one row per closed trade:

entry_dateexit_dateexpirylegscreditpnlexit_reasondays_held
2022-07-282022-08-052022-09-02-P382 +P37767.9526.4negative_gamma_2_sessions8
2023-02-022023-02-092023-03-10-P396 +P39161.7-49.6negative_gamma_2_sessions7
2023-06-122023-06-152023-07-14-P419 +P41454.4526.9profit_target3
2023-06-132023-06-202023-07-21-P420 +P41551.29.4negative_gamma_2_sessions7
2023-06-152023-06-202023-07-21-P428 +P42357.7-19.85negative_gamma_2_sessions5

equity.csv has one row per session, with the signals beside the equity, so you can line up profit with the regime it was made in:

daterealizedunrealizedequityopen_positionsgamma_regimevrp_20d
2022-01-030.00.00.00positive_gamma-6.32
2022-01-040.00.00.00positive_gamma-5.35
2022-01-050.00.00.00negative_gamma-2.15
2022-01-060.00.00.00negative_gamma0.09

Mistakes that make backtests lie

  • Deciding on data from after the decision. Using the day's close to decide a trade filled at 15:45 is look-ahead. The backtester reads every input at the decision minute.
  • Filling at mid. Mid fills overstate short-premium results. Test at halfway and at the bid and ask.
  • Too few trades. One position at a time on one symbol gives a few dozen trades over several years. Raise --max-positions, add symbols, or test the rule across many designs before believing a Sharpe ratio.
  • Tuning on the whole sample. Pick rules on one period or symbol set and check them once on another. Our meta-study uses SPY, QQQ and IWM to choose and eight other symbols to confirm.
  • Ignoring holidays and early closes. The backtester skips days whose quote is from an earlier date. On 13:00 early closes the 15:45 snapshot returns the last quotes of the session.

JavaScript, Go, Java and C# versions

The same backtester is published in five languages. Each port has the same settings, the same three rule functions, the same fill math and the same CSV output, and each was run against the Historical API and reproduces the Python trades and equity curve exactly for the two test windows in its README.

The full Python script

One file, 377 lines including comments. The three functions you edit are in the STRATEGY section. Download it from GitHub, or open it here to read or copy.

Show flashalpha_backtest.py (377 lines)
"""FlashAlpha options backtester: a complete, runnable template.

Replays the FlashAlpha Historical API day by day, decides entries and exits from the same endpoint outputs you
would read live, fills trades on real historical bid/ask quotes, and writes trades.csv and equity.csv.

Edit the three marked functions to change the strategy:
    entry_rules()  - when to open a position (reads FlashAlpha signals)
    select_legs()  - which option contracts to trade (reads the option chain)
    exit_rules()   - when to close (reads the position's marks and FlashAlpha signals)

Requirements: Python 3.10+, `pip install requests`. An Alpha plan or higher (Historical API).

    export FLASHALPHA_API_KEY=your_key
    python flashalpha_backtest.py --symbol SPY --start 2024-01-02 --end 2024-06-28

Every API response is cached in .fa_cache/, so a rerun with changed rules costs no API calls for days already fetched.
"""
from __future__ import annotations

import argparse
import csv
import datetime as dt
import hashlib
import json
import math
import os
import time
from dataclasses import dataclass, field
from pathlib import Path

import requests

BASE_URL = "https://historical.flashalpha.com"


# ============================================================================================================
# Settings
# ============================================================================================================
@dataclass
class Config:
    symbol: str = "SPY"
    start: str = "2024-01-02"
    end: str = "2024-06-28"
    decision_time: str = "15:45"   # ET; every decision and fill uses quotes at this minute
    # Trade selection (a put credit spread by default)
    target_dte: int = 35           # pick the expiry closest to this many calendar days out...
    min_dte: int = 25              # ...within this window
    max_dte: int = 50
    short_delta: float = 0.20      # sell the put closest to this absolute delta
    width: float = 5.0             # buy the put this many points lower
    contracts: int = 1
    max_positions: int = 1         # open positions allowed at once
    # Exits
    profit_target: float = 0.50    # close when 50% of the entry credit is captured
    stop_loss: float = 2.00        # close when the loss reaches 2x the entry credit
    exit_dte: int = 21             # close with this many calendar days left
    # Costs
    slippage: float = 0.5          # 0 = fill at mid, 1 = fill at the bid or ask, 0.5 = halfway
    commission: float = 0.65       # per contract per leg, each side


# ============================================================================================================
# FlashAlpha data: a small cached client for the Historical API
# ============================================================================================================
class FlashAlphaData:
    def __init__(self, api_key: str, cache_dir: str = ".fa_cache", base_url: str = BASE_URL):
        if not api_key:
            raise SystemExit("Set FLASHALPHA_API_KEY (Alpha plan or higher).")
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.headers["X-Api-Key"] = api_key
        self.cache = Path(cache_dir)
        self.cache.mkdir(exist_ok=True)
        self.calls = 0

    def get(self, path: str, **params):
        """GET with a disk cache. Returns parsed JSON, or None when the API has no data (404)."""
        params = {k: v for k, v in params.items() if v is not None}
        key = hashlib.sha1(json.dumps([path, sorted(params.items())]).encode()).hexdigest()
        hit = self.cache / f"{key}.json"
        if hit.exists():
            return json.loads(hit.read_text())
        for attempt in range(5):
            try:
                r = self.session.get(self.base_url + path, params=params, timeout=60)
            except (requests.ConnectionError, requests.Timeout):
                time.sleep(2 ** attempt)
                continue
            self.calls += 1
            if r.status_code == 200:
                data = r.json()
                break
            if r.status_code == 404:
                data = None
                break
            if r.status_code in (429, 500, 502, 503, 504):
                wait = r.headers.get("Retry-After", "")
                time.sleep(int(wait) if wait.isdigit() else 2 ** attempt)
                continue
            raise SystemExit(f"FlashAlpha API error {r.status_code} on {path}: {r.text[:200]}")
        else:
            raise SystemExit(f"FlashAlpha API kept failing on {path}")
        hit.write_text(json.dumps(data))
        return data

    def stock_quote(self, symbol, at):
        return self.get(f"/v1/stockquote/{symbol}", at=at)

    def exposure_summary(self, symbol, at):
        return self.get(f"/v1/exposure/summary/{symbol}", at=at)

    def vrp(self, symbol, at):
        return self.get(f"/v1/vrp/{symbol}", at=at)

    def chain(self, symbol, at, expiry):
        return self.get(f"/v1/optionquote/{symbol}", at=at, expiry=expiry) or []

    def option(self, symbol, at, expiry, strike, opt_type):
        return self.get(f"/v1/optionquote/{symbol}", at=at, expiry=expiry, strike=strike_text(strike), type=opt_type)


def strike_text(strike: float) -> str:
    """Strike as the API and the trade log write it: 512 -> "512", 517.5 -> "517.5", 21012.25 -> "21012.25"."""
    return format(strike, ".10g")


def build_snapshot(fa: FlashAlphaData, symbol: str, at: str) -> dict | None:
    """Everything the rules can read for one decision time, as one flat dict. Returns None on a non-trading day.

    Add fields from any other FlashAlpha endpoint here (exposure levels, narrative, max pain, 0DTE, surface...).
    """
    quote = fa.stock_quote(symbol, at)
    if not quote or not str(quote.get("lastUpdate", "")).startswith(at[:10]):
        return None  # weekend or market holiday: the archive returned an earlier session's quote
    ex = fa.exposure_summary(symbol, at) or {}
    v = fa.vrp(symbol, at) or {}
    core = v.get("vrp") or {}
    return {
        "date": at[:10],
        "at": at,
        "spot": quote["mid"],
        # /v1/exposure/summary
        "gamma_regime": ex.get("regime"),                 # "positive_gamma" or "negative_gamma"
        "net_gex": (ex.get("exposures") or {}).get("net_gex"),
        "gamma_flip": ex.get("gamma_flip"),
        # /v1/vrp
        "atm_iv": core.get("atm_iv"),                     # vol points
        "rv_20d": core.get("rv_20d"),
        "vrp_20d": core.get("vrp_20d"),                   # implied minus 20-day realized, vol points
        "vrp_percentile": core.get("percentile"),
        "vrp_z": core.get("z_score"),
        "harvest_score": (v.get("gex_conditioned") or {}).get("harvest_score"),
    }


# ============================================================================================================
# STRATEGY: edit these three functions
# ============================================================================================================
def entry_rules(snap: dict, history: list[dict], cfg: Config) -> bool:
    """ENTRY RULES. Return True to open a position at this decision time.

    snap is today's snapshot (see build_snapshot); history holds the earlier sessions' snapshots, oldest first.
    Default: implied vol above 20-day realized (VRP > 0) and dealers long gamma for 2 sessions running.
    """
    gamma_streak = [s["gamma_regime"] for s in (history[-1:] + [snap])]
    return (
        snap["vrp_20d"] is not None and snap["vrp_20d"] > 0
        and len(gamma_streak) == 2 and all(g == "positive_gamma" for g in gamma_streak)
    )


def select_legs(chain: list[dict], snap: dict, cfg: Config) -> list[dict] | None:
    """WHICH CONTRACTS. Return legs as dicts {type, strike, expiry, side}, side -1 = sell, +1 = buy.

    Default: put credit spread - sell the put closest to cfg.short_delta, buy the put cfg.width points lower.
    """
    puts = [o for o in chain if o["type"] == "P" and o.get("delta") is not None and (o.get("bid") or 0) > 0]
    if not puts:
        return None
    short = min(puts, key=lambda o: abs(abs(o["delta"]) - cfg.short_delta))
    lower = [o for o in puts if o["strike"] <= short["strike"] - cfg.width]
    if not lower:
        return None
    long = max(lower, key=lambda o: o["strike"])
    return [
        {"type": "P", "strike": short["strike"], "expiry": short["expiry"], "side": -1},
        {"type": "P", "strike": long["strike"], "expiry": long["expiry"], "side": +1},
    ]


def exit_rules(pos: "Position", snap: dict, history: list[dict], cfg: Config) -> str | None:
    """EXIT RULES. Return a reason string to close the position at this decision time, or None to hold.

    pos.pnl_mid is the open profit of the whole position in dollars at mid. pos.credit is the net premium taken in,
    after commission: positive for a credit, negative for a debit, so the targets below use its size.
    Default: take 50% of the credit, stop at 2x the credit, close at 21 days left, or close after 2 sessions
    of negative dealer gamma.
    """
    if pos.pnl_mid >= cfg.profit_target * abs(pos.credit):
        return "profit_target"
    if pos.pnl_mid <= -cfg.stop_loss * abs(pos.credit):
        return "stop_loss"
    if pos.dte(snap["date"]) <= cfg.exit_dte:
        return "time_exit"
    if history and history[-1]["gamma_regime"] == "negative_gamma" and snap["gamma_regime"] == "negative_gamma":
        return "negative_gamma_2_sessions"
    return None


# ============================================================================================================
# Engine (no changes needed below to run a new strategy)
# ============================================================================================================
@dataclass
class Position:
    legs: list[dict]
    entry_date: str
    credit: float                  # net premium for the whole position in dollars after commission (negative = debit)
    marks: dict = field(default_factory=dict)  # last quote per leg
    pnl_mid: float = 0.0

    @property
    def expiry(self) -> str:
        return min(leg["expiry"] for leg in self.legs)

    def dte(self, date: str) -> int:
        return (dt.date.fromisoformat(self.expiry) - dt.date.fromisoformat(date)).days


def fill_price(quote: dict, side: int, slippage: float) -> float:
    """Price for buying (side +1) or selling (side -1) one contract: mid moved toward the ask or bid."""
    mid = (quote["bid"] + quote["ask"]) / 2
    return mid + slippage * (quote["ask"] - mid) if side > 0 else mid - slippage * (mid - quote["bid"])


def find_expiry(fa: FlashAlphaData, symbol: str, at: str, cfg: Config) -> tuple[str, list] | None:
    """Expiry closest to cfg.target_dte inside [min_dte, max_dte] that has a chain (Friday, else Thursday)."""
    today = dt.date.fromisoformat(at[:10])
    fridays = [today + dt.timedelta(days=d) for d in range(cfg.min_dte, cfg.max_dte + 1)
               if (today + dt.timedelta(days=d)).weekday() == 4]
    for friday in sorted(fridays, key=lambda d: abs((d - today).days - cfg.target_dte)):
        for day in (friday, friday - dt.timedelta(days=1)):  # Thursday when the Friday is a holiday
            chain = fa.chain(symbol, at, day.isoformat())
            if chain:
                return day.isoformat(), chain
    return None


def trading_days(start: str, end: str):
    day, stop = dt.date.fromisoformat(start), dt.date.fromisoformat(end)
    while day <= stop:
        if day.weekday() < 5:
            yield day.isoformat()
        day += dt.timedelta(days=1)


def run(cfg: Config, fa: FlashAlphaData, out_dir: Path) -> dict:
    out_dir.mkdir(parents=True, exist_ok=True)
    open_positions: list[Position] = []
    trades, equity, history = [], [], []
    realized = 0.0
    for date in trading_days(cfg.start, cfg.end):
        at = f"{date}T{cfg.decision_time}:00"
        snap = build_snapshot(fa, cfg.symbol, at)
        if snap is None:
            continue

        # 1. Mark and exit open positions
        for pos in list(open_positions):
            value_mid = 0.0
            on_expiry = pos.dte(date) <= 0  # expiry day, or the first session after it when that day had no data
            for i, leg in enumerate(pos.legs):
                q = None if on_expiry else fa.option(cfg.symbol, at, leg["expiry"], leg["strike"], leg["type"])
                if q and q.get("bid") is not None and q.get("ask") is not None and q["ask"] > 0:
                    pos.marks[i] = q
                mid = (pos.marks[i]["bid"] + pos.marks[i]["ask"]) / 2
                value_mid += leg["side"] * mid * 100 * cfg.contracts
            pos.pnl_mid = pos.credit + value_mid  # credit received plus the (negative) value of what we hold
            reason = "expiry" if on_expiry else exit_rules(pos, snap, history, cfg)
            if reason:
                if on_expiry:  # settle at intrinsic value against the spot at the decision time
                    close_value = sum(leg["side"] * max(0.0, (leg["strike"] - snap["spot"]) if leg["type"] == "P"
                                                        else (snap["spot"] - leg["strike"])) for leg in pos.legs)
                else:
                    close_value = sum(leg["side"] * fill_price(pos.marks[i], -leg["side"], cfg.slippage)
                                      for i, leg in enumerate(pos.legs))
                costs = 0.0 if on_expiry else cfg.commission * len(pos.legs) * cfg.contracts
                pnl = pos.credit + close_value * 100 * cfg.contracts - costs
                realized += pnl
                open_positions.remove(pos)
                trades.append({"entry_date": pos.entry_date, "exit_date": date, "expiry": pos.expiry,
                               "legs": " ".join(f"{'+' if l['side'] > 0 else '-'}{l['type']}{strike_text(l['strike'])}" for l in pos.legs),
                               "credit": round(pos.credit, 2), "pnl": round(pnl, 2), "exit_reason": reason,
                               "days_held": (dt.date.fromisoformat(date) - dt.date.fromisoformat(pos.entry_date)).days})

        # 2. Entries
        if len(open_positions) < cfg.max_positions and entry_rules(snap, history, cfg):
            found = find_expiry(fa, cfg.symbol, at, cfg)
            legs = select_legs(found[1], snap, cfg) if found else None
            if legs:
                by_key = {(o["type"], o["strike"], o["expiry"]): o for o in found[1]}
                missing = [leg for leg in legs if (leg["type"], leg["strike"], leg["expiry"]) not in by_key]
                if missing:
                    raise RuntimeError(f"select_legs must pick contracts from the chain it receives: {missing}")
                quotes = {i: by_key[(leg["type"], leg["strike"], leg["expiry"])] for i, leg in enumerate(legs)}
                open_value = sum(leg["side"] * fill_price(quotes[i], leg["side"], cfg.slippage) for i, leg in enumerate(legs))
                credit = -open_value * 100 * cfg.contracts - cfg.commission * len(legs) * cfg.contracts
                pos = Position(legs=legs, entry_date=date, credit=credit, marks=quotes)
                pos.pnl_mid = credit + sum(leg["side"] * (quotes[i]["bid"] + quotes[i]["ask"]) / 2
                                           for i, leg in enumerate(legs)) * 100 * cfg.contracts
                open_positions.append(pos)

        unrealized = sum((p.pnl_mid for p in open_positions), 0.0)
        equity.append({"date": date, "realized": round(realized, 2), "unrealized": round(unrealized, 2),
                       "equity": round(realized + unrealized, 2), "open_positions": len(open_positions),
                       "gamma_regime": snap["gamma_regime"], "vrp_20d": snap["vrp_20d"]})
        history.append(snap)

    write_csv(out_dir / "trades.csv", trades)
    write_csv(out_dir / "equity.csv", equity)
    return summarize(trades, equity)


def write_csv(path: Path, rows: list[dict]):
    if not rows:
        path.write_text("")
        return
    with path.open("w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)


def summarize(trades: list[dict], equity: list[dict]) -> dict:
    pnls = [t["pnl"] for t in trades]
    curve = [e["equity"] for e in equity]
    daily = [b - a for a, b in zip([0.0] + curve[:-1], curve)]
    sd = (sum((x - sum(daily) / len(daily)) ** 2 for x in daily) / (len(daily) - 1)) ** 0.5 if len(daily) > 1 else 0
    peak, max_dd = 0.0, 0.0
    for v in curve:
        peak = max(peak, v)
        max_dd = max(max_dd, peak - v)
    wins = [p for p in pnls if p > 0]
    losses = [p for p in pnls if p <= 0]  # break-even trades count as losses
    reasons = {}
    for t in trades:
        reasons[t["exit_reason"]] = reasons.get(t["exit_reason"], 0) + 1
    return {
        "sessions": len(equity),
        "trades": len(trades),
        "win_rate": round(len(wins) / len(pnls), 3) if pnls else None,
        "total_pnl": round(sum(pnls, 0.0), 2),
        "avg_win": round(sum(wins) / len(wins), 2) if wins else None,
        "avg_loss": round(sum(losses) / len(losses), 2) if losses else None,
        "max_drawdown": round(max_dd, 2),
        "sharpe": round(sum(daily) / len(daily) / sd * math.sqrt(252), 2) if sd > 0 else None,
        "exit_reasons": reasons,
        "open_at_end": equity[-1]["open_positions"] if equity else 0,
    }


def main():
    p = argparse.ArgumentParser(description="FlashAlpha options backtester")
    for name, value in vars(Config()).items():
        p.add_argument(f"--{name.replace('_', '-')}", type=type(value), default=value)
    p.add_argument("--out", default="results")
    p.add_argument("--cache", default=".fa_cache")
    args = vars(p.parse_args())
    out, cache = Path(args.pop("out")), args.pop("cache")
    cfg = Config(**args)
    fa = FlashAlphaData(os.environ.get("FLASHALPHA_API_KEY", ""), cache_dir=cache)
    summary = run(cfg, fa, out)
    print(json.dumps(summary, indent=2))
    print(f"API calls this run: {fa.calls}. Trades: {out / 'trades.csv'}. Equity: {out / 'equity.csv'}")


if __name__ == "__main__":
    main()

Frequently asked questions

How do I backtest an options strategy in Python?

Replay each trading day at a fixed decision time, read your signals from point-in-time data, pick contracts from that minute's option chain, fill at that minute's bid and ask, mark open positions every day and close them by rule. The FlashAlpha backtester in this guide does all of that in one Python file; you edit three functions for entry rules, contract selection and exit rules.

Where do I put my entry and exit rules?

In entry_rules() and exit_rules(). Both receive a snapshot of FlashAlpha endpoint outputs for the decision time (gamma regime, gamma flip, net GEX, VRP, implied and realized vol, VRP percentile, harvest score and spot) and the snapshots of earlier sessions. exit_rules() also receives the open position with its profit at mid.

What data does an options backtest need?

Point-in-time signals, so a decision only uses what was known then; real historical bid and ask quotes for every contract you trade, for fills and daily marks; and a cost model. The FlashAlpha Historical API serves all three by minute since 2017.

How many API calls does a backtest use?

About three per session for the signals and spot, one or two per open leg for daily marks, and one to three option chains per entry. Half a year of SPY with the default rules took about 450 calls. Every response is cached on disk, so rerunning with changed rules costs nothing for days already fetched.

Is the backtester code free to use?

Yes. The code is MIT-licensed on GitHub, so you can copy, change and use it in your own projects. Running a backtest needs a FlashAlpha API key with Historical API access (Alpha and above).

Does the backtester avoid look-ahead bias?

Yes. Every decision and fill uses the signals and quotes at the decision minute, 15:45 ET by default, and nothing after that minute is read. Dealer gamma, VRP and option quotes are all replayed as of that minute, not taken from an end-of-day file.

Is the backtester available in other languages?

Yes. The same backtester is published in Python, JavaScript, Go, Java and C# on GitHub, and every version reproduces the Python results trade for trade.

An options backtest needs point-in-time signals, real quotes for every contract and an honest cost model. This backtester gives you all three in one file, with entry rules, contract selection and exit rules in three functions that read FlashAlpha endpoint outputs. Change the rules, rerun from the cache, and read every trade in the CSV.

Live Market Pulse

Get fast visibility into market shifts with full-chain analytics over low-latency REST and MCP polling.

Intelligent Screening

Screen millions of option pairs per second using your custom EV rules, filters, and setups.

Export-Ready

Export structured signals to your own execution stack or broker integration - FlashAlpha delivers the analytics, you keep control of order routing.

Join the Community

Discord

Engage in real time conversations with us!

Twitter / X

Follow us for real-time updates and insights!

GitHub

Explore our open-source SDK, examples, and analytics resources!