/**
* Synthetic Options Backtesting Module
*
* Uses historical stock prices + Black-Scholes to simulate options strategies.
* This is an approximation - real IV may differ from historical vol.
*/
import { Candle, RiskMetrics } from "./types";
import { priceBlackScholes, greeksBlackScholes } from "./blackScholes";
import { computeRiskMetrics } from "./risk";
// ============================================
// TYPES
// ============================================
export interface OptionsBacktestConfig {
/** Starting capital */
initialCapital: number;
/** Risk-free rate (annualized) */
riskFreeRate: number;
/** Days to expiration for options */
daysToExpiration: number;
/** Delta target for strike selection (e.g., 0.30 for 30-delta) */
targetDelta: number;
/** Slippage on option trades (as fraction of premium, e.g., 0.02 = 2%) */
optionSlippagePct: number;
/** Commission per contract */
commissionPerContract: number;
/** Contracts per trade (each contract = 100 shares) */
contractsPerTrade?: number;
/** Use fixed contracts or percentage of capital */
positionSizing: "fixed" | "percent_of_capital";
/** If percent_of_capital, what percentage (e.g., 0.20 = 20%) */
capitalPercentage?: number;
}
export interface OptionsTrade {
tradeNum: number;
date: string;
action: "SELL_PUT" | "SELL_CALL" | "ASSIGNED" | "EXPIRED" | "BUY_STOCK" | "SELL_STOCK";
strike: number;
premium: number;
contracts: number;
stockPrice: number;
iv: number;
delta: number;
daysToExp: number;
pnl: number;
equityAfter: number;
}
export interface OptionsBacktestResult {
strategy: string;
equityCurve: number[];
timestamps: number[];
trades: OptionsTrade[];
riskMetrics: RiskMetrics;
stats: {
totalTrades: number;
winningTrades: number;
losingTrades: number;
winRate: number;
totalPremiumCollected: number;
totalAssignments: number;
avgDaysHeld: number;
avgPremiumPerTrade: number;
};
}
// ============================================
// HELPERS
// ============================================
/**
* Estimate IV from recent historical volatility.
* In real markets, IV often trades at a premium to HV.
*/
function estimateIV(candles: Candle[], index: number, window: number = 20): number {
if (index < window) return 0.20; // Default 20%
const returns: number[] = [];
for (let i = index - window + 1; i <= index; i++) {
returns.push(Math.log(candles[i].close / candles[i - 1].close));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / returns.length;
const dailyVol = Math.sqrt(variance);
const annualizedVol = dailyVol * Math.sqrt(252);
// IV typically trades at 10-20% premium to HV
return annualizedVol * 1.15;
}
/**
* Find strike price for target delta using Black-Scholes.
*/
function findStrikeForDelta(
spot: number,
targetDelta: number,
timeToMaturity: number,
vol: number,
rate: number,
optionType: "call" | "put"
): number {
// Binary search for strike
let low = spot * 0.7;
let high = spot * 1.3;
for (let i = 0; i < 50; i++) {
const mid = (low + high) / 2;
const greeks = greeksBlackScholes({
spot,
strike: mid,
rate,
vol,
timeToMaturity,
optionType,
});
const absDelta = Math.abs(greeks.delta);
if (Math.abs(absDelta - targetDelta) < 0.01) {
return mid;
}
if (optionType === "put") {
// For puts, higher strike = higher (less negative) delta
if (absDelta > targetDelta) {
low = mid;
} else {
high = mid;
}
} else {
// For calls, higher strike = lower delta
if (absDelta > targetDelta) {
high = mid;
} else {
low = mid;
}
}
}
return (low + high) / 2;
}
/**
* Price an option using Black-Scholes.
*/
function priceOption(
spot: number,
strike: number,
timeToMaturity: number,
vol: number,
rate: number,
optionType: "call" | "put"
): { price: number; delta: number } {
const result = priceBlackScholes({
spot,
strike,
rate,
vol,
timeToMaturity,
optionType,
});
const greeks = greeksBlackScholes({
spot,
strike,
rate,
vol,
timeToMaturity,
optionType,
});
return { price: result.price, delta: greeks.delta };
}
// ============================================
// WHEEL STRATEGY
// ============================================
/**
* Run Wheel Strategy Backtest
*
* The Wheel:
* 1. Sell cash-secured puts at target delta
* 2. If assigned (stock drops below strike), take the shares
* 3. Sell covered calls on the shares
* 4. If called away, go back to selling puts
* 5. Repeat
*/
export function runWheelBacktest(
candles: Candle[],
config: OptionsBacktestConfig
): OptionsBacktestResult {
const {
initialCapital,
riskFreeRate,
daysToExpiration,
targetDelta,
optionSlippagePct,
commissionPerContract,
positionSizing,
capitalPercentage = 0.20,
} = config;
let cash = initialCapital;
let shares = 0;
let avgCostBasis = 0;
const equityCurve: number[] = [];
const timestamps: number[] = [];
const trades: OptionsTrade[] = [];
// Current option position
let currentOption: {
type: "put" | "call";
strike: number;
premium: number;
contracts: number;
expirationIdx: number;
entryIdx: number;
} | null = null;
let tradeNum = 0;
let totalPremiumCollected = 0;
let totalAssignments = 0;
let totalDaysHeld = 0;
// Need enough data for vol calculation
const startIdx = 30;
for (let i = startIdx; i < candles.length; i++) {
const candle = candles[i];
// Use PREVIOUS day's close for decision-making to avoid lookahead bias
// Trade execution happens at today's open (approximated by previous close)
const decisionPrice = candles[i - 1].close;
const currentPrice = candle.close; // For portfolio valuation only
const dateStr = new Date(candle.timestamp).toISOString().split("T")[0];
// Calculate portfolio value using current price (for reporting)
const portfolioValue = cash + shares * currentPrice;
equityCurve.push(portfolioValue);
timestamps.push(candle.timestamp);
// Estimate IV using PREVIOUS day's data (no lookahead)
const iv = estimateIV(candles, i - 1);
const timeToMaturity = daysToExpiration / 365;
// Check if current option expired or needs handling
// Use CURRENT price for expiration check (this is correct - settlement is at expiration close)
if (currentOption && i >= currentOption.expirationIdx) {
const daysHeld = i - currentOption.entryIdx;
totalDaysHeld += daysHeld;
if (currentOption.type === "put") {
// Check if put is ITM (assigned) using expiration day's price
if (currentPrice < currentOption.strike) {
// Assigned - buy shares at strike
const assignmentCost = currentOption.strike * 100 * currentOption.contracts;
cash -= assignmentCost;
shares += 100 * currentOption.contracts;
avgCostBasis = currentOption.strike;
totalAssignments++;
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "ASSIGNED",
strike: currentOption.strike,
premium: 0,
contracts: currentOption.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: -(currentOption.strike - currentPrice) * 100 * currentOption.contracts, // Unrealized loss
equityAfter: cash + shares * currentPrice,
});
} else {
// Expired worthless - keep premium
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "EXPIRED",
strike: currentOption.strike,
premium: currentOption.premium,
contracts: currentOption.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: currentOption.premium * 100 * currentOption.contracts,
equityAfter: cash + shares * currentPrice,
});
}
} else if (currentOption.type === "call") {
// Check if call is ITM (called away) using expiration day's price
if (currentPrice > currentOption.strike) {
// Called away - sell shares at strike
const saleProceeds = currentOption.strike * 100 * currentOption.contracts;
cash += saleProceeds;
const pnl = (currentOption.strike - avgCostBasis) * 100 * currentOption.contracts;
shares -= 100 * currentOption.contracts;
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "SELL_STOCK",
strike: currentOption.strike,
premium: currentOption.premium,
contracts: currentOption.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: pnl + currentOption.premium * 100 * currentOption.contracts,
equityAfter: cash + shares * currentPrice,
});
} else {
// Expired worthless - keep premium and shares
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "EXPIRED",
strike: currentOption.strike,
premium: currentOption.premium,
contracts: currentOption.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: currentOption.premium * 100 * currentOption.contracts,
equityAfter: cash + shares * currentPrice,
});
}
}
currentOption = null;
}
// Open new position if none exists
// Use DECISION PRICE (previous day's close) for new trades - NO LOOKAHEAD
if (!currentOption) {
// Determine number of contracts based on position sizing
let contracts: number;
if (positionSizing === "fixed") {
contracts = config.contractsPerTrade || 1;
} else {
// Percent of capital
const targetNotional = portfolioValue * capitalPercentage;
contracts = Math.floor(targetNotional / (decisionPrice * 100));
}
if (contracts < 1) contracts = 1;
if (shares === 0) {
// No shares - sell cash-secured puts using DECISION price
const strike = findStrikeForDelta(decisionPrice, targetDelta, timeToMaturity, iv, riskFreeRate, "put");
const { price: premium, delta } = priceOption(decisionPrice, strike, timeToMaturity, iv, riskFreeRate, "put");
// Check if we have enough cash to secure the puts
const cashRequired = strike * 100 * contracts;
if (cash >= cashRequired) {
// Apply slippage (we receive less premium)
const netPremium = premium * (1 - optionSlippagePct);
const totalPremium = netPremium * 100 * contracts;
const commission = commissionPerContract * contracts;
cash += totalPremium - commission;
totalPremiumCollected += totalPremium;
currentOption = {
type: "put",
strike,
premium: netPremium,
contracts,
expirationIdx: Math.min(i + daysToExpiration, candles.length - 1),
entryIdx: i,
};
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "SELL_PUT",
strike,
premium: netPremium,
contracts,
stockPrice: decisionPrice,
iv,
delta,
daysToExp: daysToExpiration,
pnl: 0, // PnL realized at expiration
equityAfter: cash + shares * currentPrice,
});
}
} else {
// Have shares - sell covered calls using DECISION price
const availableContracts = Math.floor(shares / 100);
contracts = Math.min(contracts, availableContracts);
if (contracts > 0) {
const strike = findStrikeForDelta(decisionPrice, targetDelta, timeToMaturity, iv, riskFreeRate, "call");
const { price: premium, delta } = priceOption(decisionPrice, strike, timeToMaturity, iv, riskFreeRate, "call");
// Apply slippage
const netPremium = premium * (1 - optionSlippagePct);
const totalPremium = netPremium * 100 * contracts;
const commission = commissionPerContract * contracts;
cash += totalPremium - commission;
totalPremiumCollected += totalPremium;
currentOption = {
type: "call",
strike,
premium: netPremium,
contracts,
expirationIdx: Math.min(i + daysToExpiration, candles.length - 1),
entryIdx: i,
};
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "SELL_CALL",
strike,
premium: netPremium,
contracts,
stockPrice: decisionPrice,
iv,
delta,
daysToExp: daysToExpiration,
pnl: 0,
equityAfter: cash + shares * currentPrice,
});
}
}
}
}
// Final portfolio value
const finalValue = equityCurve[equityCurve.length - 1];
// Calculate risk metrics
const riskMetrics = computeRiskMetrics(equityCurve);
// Trade statistics
const winningTrades = trades.filter(t => t.pnl > 0).length;
const losingTrades = trades.filter(t => t.pnl < 0).length;
return {
strategy: "wheel",
equityCurve,
timestamps,
trades,
riskMetrics,
stats: {
totalTrades: trades.length,
winningTrades,
losingTrades,
winRate: trades.length > 0 ? winningTrades / trades.length : 0,
totalPremiumCollected,
totalAssignments,
avgDaysHeld: trades.length > 0 ? totalDaysHeld / trades.length : 0,
avgPremiumPerTrade: trades.length > 0 ? totalPremiumCollected / trades.length : 0,
},
};
}
// ============================================
// COVERED CALL STRATEGY (Always Long Stock)
// ============================================
/**
* Run Covered Call Strategy Backtest
*
* Always hold stock, continuously sell covered calls.
*/
export function runCoveredCallBacktest(
candles: Candle[],
config: OptionsBacktestConfig
): OptionsBacktestResult {
const {
initialCapital,
riskFreeRate,
daysToExpiration,
targetDelta,
optionSlippagePct,
commissionPerContract,
} = config;
// Buy initial stock position using day 29's close (decision) to buy at day 30
const initialPrice = candles[29].close;
let shares = Math.floor(initialCapital / initialPrice);
let cash = initialCapital - shares * initialPrice;
const equityCurve: number[] = [];
const timestamps: number[] = [];
const trades: OptionsTrade[] = [];
// Current call position
let currentCall: {
strike: number;
premium: number;
contracts: number;
expirationIdx: number;
entryIdx: number;
} | null = null;
let tradeNum = 0;
let totalPremiumCollected = 0;
let totalDaysHeld = 0;
let timesCalledAway = 0;
// Record initial purchase
tradeNum++;
trades.push({
tradeNum,
date: new Date(candles[30].timestamp).toISOString().split("T")[0],
action: "BUY_STOCK",
strike: 0,
premium: 0,
contracts: 0,
stockPrice: initialPrice,
iv: 0,
delta: 1,
daysToExp: 0,
pnl: 0,
equityAfter: initialCapital,
});
const startIdx = 31;
for (let i = startIdx; i < candles.length; i++) {
const candle = candles[i];
// Use previous day's close for decisions (no lookahead)
const decisionPrice = candles[i - 1].close;
const currentPrice = candle.close; // For portfolio valuation and expiration
const dateStr = new Date(candle.timestamp).toISOString().split("T")[0];
// Calculate portfolio value using current price
const portfolioValue = cash + shares * currentPrice;
equityCurve.push(portfolioValue);
timestamps.push(candle.timestamp);
// IV from previous day (no lookahead)
const iv = estimateIV(candles, i - 1);
const timeToMaturity = daysToExpiration / 365;
// Check if current call expired (use current price for settlement)
if (currentCall && i >= currentCall.expirationIdx) {
const daysHeld = i - currentCall.entryIdx;
totalDaysHeld += daysHeld;
if (currentPrice > currentCall.strike) {
// Called away - sell shares at strike, then buy back
const saleProceeds = currentCall.strike * 100 * currentCall.contracts;
const sharesToSell = 100 * currentCall.contracts;
cash += saleProceeds;
shares -= sharesToSell;
timesCalledAway++;
// Buy shares back at market price (current price)
const buybackCost = currentPrice * sharesToSell;
cash -= buybackCost;
shares += sharesToSell;
const callPnl = currentCall.premium * 100 * currentCall.contracts;
const stockPnl = (currentCall.strike - currentPrice) * sharesToSell; // Loss from being called
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "EXPIRED",
strike: currentCall.strike,
premium: currentCall.premium,
contracts: currentCall.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: callPnl + stockPnl,
equityAfter: cash + shares * currentPrice,
});
} else {
// Expired worthless - keep premium
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "EXPIRED",
strike: currentCall.strike,
premium: currentCall.premium,
contracts: currentCall.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: currentCall.premium * 100 * currentCall.contracts,
equityAfter: cash + shares * currentPrice,
});
}
currentCall = null;
}
// Sell new call if none exists - use DECISION price (no lookahead)
if (!currentCall && shares >= 100) {
const contracts = Math.floor(shares / 100);
const strike = findStrikeForDelta(decisionPrice, targetDelta, timeToMaturity, iv, riskFreeRate, "call");
const { price: premium, delta } = priceOption(decisionPrice, strike, timeToMaturity, iv, riskFreeRate, "call");
const netPremium = premium * (1 - optionSlippagePct);
const totalPremium = netPremium * 100 * contracts;
const commission = commissionPerContract * contracts;
cash += totalPremium - commission;
totalPremiumCollected += totalPremium;
currentCall = {
strike,
premium: netPremium,
contracts,
expirationIdx: Math.min(i + daysToExpiration, candles.length - 1),
entryIdx: i,
};
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "SELL_CALL",
strike,
premium: netPremium,
contracts,
stockPrice: decisionPrice,
iv,
delta,
daysToExp: daysToExpiration,
pnl: 0,
equityAfter: cash + shares * currentPrice,
});
}
}
const riskMetrics = computeRiskMetrics(equityCurve);
const winningTrades = trades.filter(t => t.pnl > 0).length;
const losingTrades = trades.filter(t => t.pnl < 0).length;
return {
strategy: "covered_call",
equityCurve,
timestamps,
trades,
riskMetrics,
stats: {
totalTrades: trades.length,
winningTrades,
losingTrades,
winRate: trades.length > 0 ? winningTrades / trades.length : 0,
totalPremiumCollected,
totalAssignments: timesCalledAway,
avgDaysHeld: trades.length > 0 ? totalDaysHeld / trades.length : 0,
avgPremiumPerTrade: trades.length > 0 ? totalPremiumCollected / trades.length : 0,
},
};
}
// ============================================
// PUT SELLING STRATEGY (Cash-Secured Puts Only)
// ============================================
/**
* Run Put Selling Strategy Backtest
*
* Continuously sell cash-secured puts. If assigned, immediately sell shares.
*/
export function runPutSellingBacktest(
candles: Candle[],
config: OptionsBacktestConfig
): OptionsBacktestResult {
const {
initialCapital,
riskFreeRate,
daysToExpiration,
targetDelta,
optionSlippagePct,
commissionPerContract,
} = config;
let cash = initialCapital;
// Track base capital separately to avoid over-leveraging
let baseCapital = initialCapital;
const equityCurve: number[] = [];
const timestamps: number[] = [];
const trades: OptionsTrade[] = [];
let currentPut: {
strike: number;
premium: number;
contracts: number;
expirationIdx: number;
entryIdx: number;
} | null = null;
let tradeNum = 0;
let totalPremiumCollected = 0;
let totalAssignments = 0;
let totalDaysHeld = 0;
const startIdx = 30;
for (let i = startIdx; i < candles.length; i++) {
const candle = candles[i];
// Use previous day's close for decisions (no lookahead)
const decisionPrice = candles[i - 1].close;
const currentPrice = candle.close; // For expiration settlement
const dateStr = new Date(candle.timestamp).toISOString().split("T")[0];
equityCurve.push(cash);
timestamps.push(candle.timestamp);
// IV from previous day (no lookahead)
const iv = estimateIV(candles, i - 1);
const timeToMaturity = daysToExpiration / 365;
// Check expiration (use current price for settlement)
if (currentPut && i >= currentPut.expirationIdx) {
const daysHeld = i - currentPut.entryIdx;
totalDaysHeld += daysHeld;
if (currentPrice < currentPut.strike) {
// Assigned - buy and immediately sell (at current price)
totalAssignments++;
const assignmentLoss = (currentPut.strike - currentPrice) * 100 * currentPut.contracts;
const netPnl = currentPut.premium * 100 * currentPut.contracts - assignmentLoss;
cash += netPnl;
// Update base capital (allow slow growth)
if (netPnl > 0) baseCapital += netPnl * 0.5;
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "ASSIGNED",
strike: currentPut.strike,
premium: currentPut.premium,
contracts: currentPut.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl: netPnl,
equityAfter: cash,
});
} else {
// Expired worthless - keep premium (already collected)
const pnl = currentPut.premium * 100 * currentPut.contracts;
// Slow growth on base capital
baseCapital += pnl * 0.25;
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "EXPIRED",
strike: currentPut.strike,
premium: currentPut.premium,
contracts: currentPut.contracts,
stockPrice: currentPrice,
iv,
delta: 0,
daysToExp: 0,
pnl,
equityAfter: cash,
});
}
currentPut = null;
}
// Sell new put - use DECISION price (no lookahead)
if (!currentPut) {
// Use conservative position sizing: 25% of BASE capital (not compounded cash)
const targetNotional = baseCapital * 0.25;
let contracts = Math.floor(targetNotional / (decisionPrice * 100));
if (contracts < 1) contracts = 1;
// Cap at what we can actually secure
const maxContracts = Math.floor(cash / (decisionPrice * 100));
contracts = Math.min(contracts, maxContracts);
if (contracts < 1) continue; // Not enough cash
const strike = findStrikeForDelta(decisionPrice, targetDelta, timeToMaturity, iv, riskFreeRate, "put");
const cashRequired = strike * 100 * contracts;
if (cash >= cashRequired) {
const { price: premium, delta } = priceOption(decisionPrice, strike, timeToMaturity, iv, riskFreeRate, "put");
const netPremium = premium * (1 - optionSlippagePct);
const totalPremium = netPremium * 100 * contracts;
const commission = commissionPerContract * contracts;
cash += totalPremium - commission;
totalPremiumCollected += totalPremium;
currentPut = {
strike,
premium: netPremium,
contracts,
expirationIdx: Math.min(i + daysToExpiration, candles.length - 1),
entryIdx: i,
};
tradeNum++;
trades.push({
tradeNum,
date: dateStr,
action: "SELL_PUT",
strike,
premium: netPremium,
contracts,
stockPrice: decisionPrice,
iv,
delta,
daysToExp: daysToExpiration,
pnl: 0,
equityAfter: cash,
});
}
}
}
const riskMetrics = computeRiskMetrics(equityCurve);
const winningTrades = trades.filter(t => t.pnl > 0).length;
const losingTrades = trades.filter(t => t.pnl < 0).length;
return {
strategy: "put_selling",
equityCurve,
timestamps,
trades,
riskMetrics,
stats: {
totalTrades: trades.length,
winningTrades,
losingTrades,
winRate: trades.length > 0 ? winningTrades / trades.length : 0,
totalPremiumCollected,
totalAssignments,
avgDaysHeld: trades.length > 0 ? totalDaysHeld / trades.length : 0,
avgPremiumPerTrade: trades.length > 0 ? totalPremiumCollected / trades.length : 0,
},
};
}