Help us double down on what's working, instead of guessing. Takes 5 seconds, totally optional.
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#.
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.
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.
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):
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.
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.
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.
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.
Field
Endpoint
What it is
spot
/v1/stockquote
Underlying mid price at the decision time
gamma_regime
/v1/exposure/summary
positive_gamma when dealers are long gamma (their hedging tends to dampen moves), negative_gamma when short
net_gex
/v1/exposure/summary
Net dealer gamma exposure
gamma_flip
/v1/exposure/summary
Price at which net dealer gamma changes sign
atm_iv
/v1/vrp
At-the-money implied vol, in vol points
rv_20d
/v1/vrp
20-day realized vol, in vol points
vrp_20d
/v1/vrp
Implied minus 20-day realized vol: the premium an option seller collects
vrp_percentile, vrp_z
/v1/vrp
Where today's VRP sits in its own history
harvest_score
/v1/vrp
FlashAlpha'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 > 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:
Fills
Average credit per spread
Total profit
Sharpe ratio
Mid (--slippage 0)
$58.24
$208.10
0.36
Halfway (--slippage 0.5, the default)
$57.04
$83.60
0.14
Bid and ask (--slippage 1)
$55.85
$6.60
0.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 entry
Trades
Profit ($)
2022
1
+26.40
2023
10
+67.25
2024
9
+111.10
2025
9
+18.10
2026
10
−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_date
exit_date
expiry
legs
credit
pnl
exit_reason
days_held
2022-07-28
2022-08-05
2022-09-02
-P382 +P377
67.95
26.4
negative_gamma_2_sessions
8
2023-02-02
2023-02-09
2023-03-10
-P396 +P391
61.7
-49.6
negative_gamma_2_sessions
7
2023-06-12
2023-06-15
2023-07-14
-P419 +P414
54.45
26.9
profit_target
3
2023-06-13
2023-06-20
2023-07-21
-P420 +P415
51.2
9.4
negative_gamma_2_sessions
7
2023-06-15
2023-06-20
2023-07-21
-P428 +P423
57.7
-19.85
negative_gamma_2_sessions
5
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:
date
realized
unrealized
equity
open_positions
gamma_regime
vrp_20d
2022-01-03
0.0
0.0
0.0
0
positive_gamma
-6.32
2022-01-04
0.0
0.0
0.0
0
positive_gamma
-5.35
2022-01-05
0.0
0.0
0.0
0
negative_gamma
-2.15
2022-01-06
0.0
0.0
0.0
0
negative_gamma
0.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.
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.