Skip to main content
Glama
Ademscodeisnotsobad

Quant Companion MCP

optionsBacktest.ts26.4 kB
/** * 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, }, }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ademscodeisnotsobad/Quant-Companion-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server