note: debit spread alpha
Following up on our previous discussion about monitoring existing positions and scanning for new ones, the natural next question for an options trader is "Does this strategy actually work over time?" The scripts we looked at before are excellent for real-time decision-making, but they are silent on the most critical element. Statistical significance.
This led me to build a Realistic Options Strategy Backtester. Its purpose is not to find a single good trade for next week, but to determine if a specific trade idea has a genuine, repeatable edge, or alpha, across hundreds of past events.
While the scanner from our last talk evaluates what's available in the market now, the backtester asks a more profound question. If we had systematically applied this strategy over the last several years, would we have made money, and was that profit more than just luck or market exposure?
The core of the new script is a simulation that replays history, focusing on predictable event cycles like earnings reports. For a stock like Apple (AAPL), it fetches years of price data and historical earnings dates. Then, for each earnings announcement in the past, it executes a defined strategy, in this case, selling a call spread just before the event. Crucially, the script operates with "amnesia" at each step; it only uses data that would have been available before the trade was placed, meticulously avoiding the fatal flaw of look-ahead bias.
But a simple profit calculation is not enough. The market is noisy. A strategy can be profitable by random chance. To separate signal from noise, the backtester incorporates several layers of statistical rigor that go far beyond a simple P/L statement:
Benchmarking & Alpha Detection. it doesn't just measure absolute profit. It compares the strategy's returns to a benchmark like the SPY ETF. Using a CAPM regression, it calculates alpha, the excess return not explained by the market's movement. A positive, statistically significant alpha (with a p-value < 0.05) is the holy grail, suggesting a genuine edge. A high return with zero alpha likely means your strategy is just a complicated way of buying the market.
Reality Checks with Costs: Every trade is burdened with realistic friction. The model deducts commissions and, more importantly, accounts for the bid-ask spread by giving you a worse fill price. This often turns theoretically profitable strategies into net losers.
Robustness Testing: A strategy that only works with one perfect set of parameters is probably a historical fluke. This backtester stress-tests the idea by running it through different scenarios, "Ultra-Conservative," "Aggressive," etc., varying the spread width, days to expiration, and costs. If the edge evaporates with slightly different parameters, it wasn't a robust edge to begin with.
So, does selling call spreads around earnings generate alpha? The answer, as with most things in finance, is "it depends." The backtester's value is in giving you a definitive, data-driven answer for a specific stock and strategy. You might find that for a stable, mega-cap stock, the strategy has a slightly positive but insignificant alpha, not worth the risk. Conversely, you might discover that for a certain type of high-volatility stock, there is a statistically significant, repeatable inefficiency to exploit.
The final output is more than just a report; it's an actionable trading plan. Based on the historical analysis, it tells you the optimal strike selection, position size, and target premium for the next earnings event, translating years of data into a concrete ticket you could place on Robinhood.
In essence, this backtester completes the loop. The scanner tells you what you could do today. The monitor tells you how your existing position is doing. But the backtester is what allows you to answer the ultimate question: "Should I be placing this trade at all?" It moves you from trading on gut feeling and isolated examples to making decisions grounded in statistical evidence.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from scipy.stats import norm, gaussian_kde
import statsmodels.api as sm
"""
REALISTIC OPTIONS STRATEGY BACKTESTER WITH ALPHA DETECTION
WHAT THIS CODE DOES:
- Tests a simple call spread strategy around earnings dates
- Uses ONLY past data for trade decisions (no lookahead bias)
- Includes realistic transaction costs (commissions + bid-ask spreads)
- Provides statistical significance testing and alpha detection
- Tests parameter robustness across different scenarios
- Generates actionable trading instructions for Robinhood
"""
# Global configuration
SYMBOL = "AAPL"
START_DATE = "2020-01-01"
END_DATE = "2024-01-01"
# Trading parameters
TRADING_PARAMS = {
'commission_per_contract': 0.50,
'bid_ask_spread_pct': 0.10,
'min_history_days': 60,
'spread_width': 5,
'days_to_expiration': 7,
'slippage_pct': 0.02,
'max_position_size_pct': 0.02,
}
# Global variables
price_data = None
events = None
def fetch_price_data(ticker, start, end):
"""Fetch and clean price data from Yahoo Finance"""
raw = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=False)
if raw is None or len(raw) == 0:
raise ValueError("No data returned from yfinance")
# Extract price series - handle different column structures
px = None
if 'Adj Close' in raw.columns:
px = raw['Adj Close']
elif 'Close' in raw.columns:
px = raw['Close']
else:
# Fallback to first numeric column
for col in raw.columns:
if pd.api.types.is_numeric_dtype(raw[col]):
px = raw[col]
break
if px is None:
raise KeyError(f"Could not extract price series for {ticker}")
# Ensure we have a Series, not DataFrame
if isinstance(px, pd.DataFrame):
px = px.iloc[:, 0]
px = pd.Series(px.values, index=px.index, name='price').dropna()
if px.empty:
raise ValueError("Price series is empty after extraction")
# Create DataFrame with price and returns
df = pd.DataFrame({'price': px})
df['returns'] = np.log(df['price']).diff()
return df.dropna()
def get_earnings_dates(ticker, start, end):
"""Fetch earnings dates or generate synthetic dates if unavailable"""
try:
sym = yf.Ticker(ticker)
cal = sym.get_earnings_dates()
if cal is None or len(cal) == 0:
return generate_synthetic_dates(ticker, start, end)
cal = cal.dropna(subset=['EPS Estimate'])
cal_dates = cal.index.tz_localize(None)
start_dt, end_dt = pd.to_datetime(start), pd.to_datetime(end)
cal = cal[(cal_dates >= start_dt) & (cal_dates <= end_dt)]
if len(cal) == 0:
return generate_synthetic_dates(ticker, start, end)
return [d.strftime('%Y-%m-%d') for d in cal.index]
except Exception as e:
print(f"Warning: Could not fetch earnings dates: {e}")
return generate_synthetic_dates(ticker, start, end)
def generate_synthetic_dates(ticker, start, end):
"""Generate quarterly dates when earnings data is unavailable"""
px_data = fetch_price_data(ticker, start, end)
dates = px_data.index[::21] # Roughly quarterly
return [d.strftime('%Y-%m-%d') for d in dates]
def get_next_earnings_date(ticker):
"""Get the next upcoming earnings date"""
try:
sym = yf.Ticker(ticker)
earnings_cal = sym.get_earnings_dates()
if earnings_cal is None or earnings_cal.empty:
return "No earnings data available"
today = pd.Timestamp.now().normalize().tz_localize(None)
earnings_dates = earnings_cal.index.tz_localize(None)
future_earnings = earnings_cal[earnings_dates > today]
if not future_earnings.empty:
return future_earnings.index[0].strftime('%Y-%m-%d')
else:
return "No upcoming earnings dates found"
except Exception as e:
return f"Error fetching earnings date: {e}"
def bs_call(S, K, T, r, vol):
"""Black-Scholes call option pricing"""
if T <= 0:
return max(S - K, 0)
d1 = (np.log(S/K) + (r + 0.5*vol*vol)*T) / (vol*np.sqrt(T))
d2 = d1 - vol*np.sqrt(T)
return S*norm.cdf(d1) - K*np.exp(-r*T)*norm.cdf(d2)
def price_call_spread(S, K1, K2, T, r, vol):
"""Price a call spread (buy K1, sell K2)"""
premium = bs_call(S, K1, T, r, vol) - bs_call(S, K2, T, r, vol)
return max(premium, 0.01)
def plot_pl_density(pl_array, symbol):
"""Plot probability density and cumulative distribution of P/L"""
if len(pl_array) < 2:
print("Not enough data for density plot")
return
kde = gaussian_kde(pl_array)
pl_min, pl_max = pl_array.min(), pl_array.max()
pl_range = pl_max - pl_min
x_range = np.linspace(pl_min - 0.1*pl_range, pl_max + 0.1*pl_range, 1000)
density = kde(x_range)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
# PDF plot
ax1.plot(x_range, density, 'b-', linewidth=2, label='KDE Density')
ax1.fill_between(x_range, density, alpha=0.3, color='blue')
ax1.set_xlabel("P/L per contract ($)")
ax1.set_ylabel("Probability Density")
ax1.set_title(f"Probability Density Function of P/L - {symbol}")
ax1.grid(True, alpha=0.3)
ax1.legend()
# CDF plot
sorted_pl = np.sort(pl_array)
cdf = np.arange(1, len(sorted_pl) + 1) / len(sorted_pl)
ax2.plot(sorted_pl, cdf, 'r-', linewidth=2, label='Empirical CDF')
ax2.set_xlabel("P/L per contract ($)")
ax2.set_ylabel("Cumulative Probability")
ax2.set_title(f"Cumulative Distribution Function of P/L - {symbol}")
ax2.grid(True, alpha=0.3)
ax2.legend()
# Statistics text
stats_text = f"""Statistics:
Mean P/L: ${pl_array.mean():.2f}
Std Dev: ${pl_array.std():.2f}
Min: ${pl_array.min():.2f}
Max: ${pl_array.max():.2f}
Win Rate: {(pl_array > 0).mean():.1%}
Sharpe: {pl_array.mean() / pl_array.std():.2f}"""
ax1.text(0.02, 0.98, stats_text, transform=ax1.transAxes, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8), fontfamily='monospace')
plt.tight_layout()
plt.show()
def calculate_benchmark_returns(results_df, price_data, benchmark_ticker='SPY'):
"""Calculate benchmark returns for the same periods as trades"""
try:
start_date, end_date = price_data.index[0], price_data.index[-1]
benchmark_data = yf.download(benchmark_ticker, start=start_date, end=end_date,
progress=False, auto_adjust=False)
if benchmark_data.empty:
return None
benchmark_returns = []
bench_prices = benchmark_data['Close']
for _, trade in results_df.iterrows():
event_date = pd.to_datetime(trade['event'])
exp_date = event_date + pd.Timedelta(days=TRADING_PARAMS['days_to_expiration'])
bench_start = bench_prices[bench_prices.index <= event_date]
bench_end = bench_prices[bench_prices.index >= exp_date]
if len(bench_start) > 0 and len(bench_end) > 0:
start_price = bench_start.iloc[-1]
end_price = bench_end.iloc[0]
bench_return = (end_price - start_price) / start_price
benchmark_returns.append(bench_return * trade['S0'])
return np.array(benchmark_returns)
except Exception as e:
print(f"Error calculating benchmark returns: {e}")
return None
def calculate_capm_alpha(strategy_returns, benchmark_returns):
"""Calculate CAPM alpha and beta"""
if len(strategy_returns) != len(benchmark_returns) or len(strategy_returns) < 5:
return 0, 0, 0, 1.0, 1.0
try:
X = sm.add_constant(benchmark_returns)
model = sm.OLS(strategy_returns, X).fit()
alpha, beta = model.params
r_squared = model.rsquared
alpha_pvalue, beta_pvalue = model.pvalues
return alpha, beta, r_squared, alpha_pvalue, beta_pvalue
except:
return 0, 0, 0, 1.0, 1.0
def analyze_strategy_significance(results_df, price_data, symbol, benchmark_ticker='SPY'):
"""Comprehensive strategy analysis with statistical significance testing"""
if results_df.empty or len(results_df) < 10:
print("Insufficient data for significance testing")
return
returns = results_df['pl'].values
win_rate = (returns > 0).mean()
print("\n" + "="*60)
print(f"STRATEGY ANALYSIS - {symbol}")
print("="*60)
print(f"\n1. BASIC PERFORMANCE:")
print(f"Trades: {len(returns)}")
print(f"Win Rate: {win_rate:.1%}")
print(f"Avg P/L: ${returns.mean():.2f}")
print(f"Std Dev: ${returns.std():.2f}")
print(f"\n2. ALPHA DETECTION vs {benchmark_ticker}:")
benchmark_returns = calculate_benchmark_returns(results_df, price_data, benchmark_ticker)
if benchmark_returns is not None:
alpha, beta, r_squared, alpha_pvalue, beta_pvalue = calculate_capm_alpha(returns, benchmark_returns)
print(f"Alpha (excess return): {alpha:.4f} (p={alpha_pvalue:.4f})")
print(f"Beta (market exposure): {beta:.3f} (p={beta_pvalue:.4f})")
print(f"R-squared: {r_squared:.3f}")
if alpha_pvalue < 0.05:
print("SIGNIFICANT POSITIVE alpha - strategy has genuine edge" if alpha > 0
else "SIGNIFICANT NEGATIVE alpha - strategy underperforms market")
else:
print("INSIGNIFICANT alpha - results could be random")
print(f"\n3. REALISTIC ANNUALIZED METRICS:")
avg_capital = results_df['capital_at_risk'].mean()
avg_return = returns.mean()
roc_per_trade = avg_return / avg_capital if avg_capital > 0 else 0
realistic_annual_roc = roc_per_trade * 0.02 * 12 # 2% position sizing, 12 trades/year
print(f"Return on Capital per trade: {roc_per_trade:.1%}")
print(f"Realistic Annual ROC: {realistic_annual_roc:.1%}")
def test_parameter_scenarios(symbol, base_results):
"""Test strategy robustness across different parameter sets"""
scenarios = {
'CONSERVATIVE': {
'spread_width': 3, 'days_to_expiration': 10, 'commission_per_contract': 0.65,
'bid_ask_spread_pct': 0.08, 'min_history_days': 75,
},
'REALISTIC': TRADING_PARAMS,
'AGGRESSIVE': {
'spread_width': 7, 'days_to_expiration': 3, 'commission_per_contract': 0.35,
'bid_ask_spread_pct': 0.03, 'min_history_days': 30,
}
}
print("\n" + "="*70)
print("PARAMETER ROBUSTNESS TESTING")
print("="*70)
base_avg_pl = base_results['pl'].mean() if not base_results.empty else 0
for scenario_name, params in scenarios.items():
scenario_results = run_scenario_backtest(params)
if not scenario_results.empty:
avg_pl = scenario_results['pl'].mean()
win_rate = (scenario_results['pl'] > 0).mean()
n_trades = len(scenario_results)
print(f"\n{scenario_name}: {n_trades} trades, Avg P/L: ${avg_pl:.2f}, Win Rate: {win_rate:.1%}")
def run_scenario_backtest(params):
"""Run backtest with given parameters"""
global price_data, events
S = price_data['price']
results = []
for evt in events:
try:
evt_dt = pd.to_datetime(evt)
except:
continue
# Find closest trading day to event
if evt_dt in S.index:
idx = S.index.get_loc(evt_dt)
else:
days_diff = np.abs(S.index - evt_dt)
idx = days_diff.argmin()
evt_dt = S.index[idx]
# Check data availability
if idx < params['min_history_days'] or idx + params['days_to_expiration'] >= len(S):
continue
# Calculate volatility from historical data
hist = price_data.iloc[idx-params['min_history_days']:idx]
S0 = S.iloc[idx]
S_exp = S.iloc[idx + params['days_to_expiration']]
vol_estimate = np.sqrt(hist['returns'].var() * 252)
vol_estimate = max(min(vol_estimate, 1.0), 0.15)
# Initialize strikes
K1 = round(S0)
K2 = K1 + params['spread_width']
T = params['days_to_expiration'] / 252
# Calculate premium with cost adjustment
theoretical_premium = price_call_spread(S0, K1, K2, T, 0, vol_estimate)
premium_after_costs = theoretical_premium * (1 - params['bid_ask_spread_pct']) - (2 * params['commission_per_contract'])
# Adjust strikes if premium too low
min_acceptable_premium = 0.10
strike_adjustment = 0
while premium_after_costs < min_acceptable_premium and strike_adjustment < 10:
strike_adjustment += 1
K1 = round(S0 * (1 + 0.01 * strike_adjustment))
K2 = K1 + params['spread_width']
theoretical_premium = price_call_spread(S0, K1, K2, T, 0, vol_estimate)
premium_after_costs = theoretical_premium * (1 - params['bid_ask_spread_pct']) - (2 * params['commission_per_contract'])
if premium_after_costs < min_acceptable_premium:
continue
# Calculate final P/L
premium_after_spread = max(theoretical_premium * (1 - params['bid_ask_spread_pct']), 0.01)
commission_cost = 2 * params['commission_per_contract']
net_premium_received = premium_after_spread - commission_cost
payoff = min(max(S_exp - K1, 0), params['spread_width'])
pl = payoff - net_premium_received
capital_at_risk = params['spread_width'] - net_premium_received
results.append({
'event': evt_dt.strftime('%Y-%m-%d'),
'S0': S0, 'S_exp': S_exp, 'K1': K1, 'K2': K2,
'premium_received': net_premium_received, 'payoff': payoff, 'pl': pl,
'capital_at_risk': capital_at_risk, 'vol_estimate': vol_estimate,
'price_move_pct': (S_exp - S0) / S0, 'strike_otm_pct': (K1 - S0) / S0
})
return pd.DataFrame(results)
def generate_trading_instructions(results_df, symbol):
"""Generate actionable trading instructions"""
if results_df.empty:
print("No results to generate trading instructions")
return
next_earnings = get_next_earnings_date(symbol)
recent_trades = results_df.tail(5)
avg_premium = recent_trades['premium_received'].mean()
avg_strike_otm = recent_trades['strike_otm_pct'].mean() * 100
avg_capital = results_df['capital_at_risk'].mean()
position_size = int(222 / avg_capital) if avg_capital > 0 else 1
print("\n" + "="*70)
print("TRADING INSTRUCTIONS")
print("="*70)
print(f"\nNEXT EARNINGS: {next_earnings}")
print(f"RECOMMENDED: Sell {TRADING_PARAMS['spread_width']}$ wide call spread")
print(f"EXPIRATION: {TRADING_PARAMS['days_to_expiration']} days after earnings")
print(f"STRIKE: {avg_strike_otm:.1f}% OTM from current price")
print(f"TARGET PREMIUM: ${avg_premium:.2f} per spread")
print(f"POSITION SIZE: {position_size} spreads")
print(f"CAPITAL AT RISK: ${position_size * avg_capital:.2f}")
def walk_forward_backtest(symbol=SYMBOL):
"""Main backtest function"""
global price_data, events
print(f"Analyzing: {symbol}")
print(f"Parameters: ${TRADING_PARAMS['spread_width']} spread, {TRADING_PARAMS['days_to_expiration']} DTE")
print(f"Events: {len(events)}")
# Run backtest
results = run_scenario_backtest(TRADING_PARAMS)
print(f"\nTRADE SUMMARY:")
print(f"Trades executed: {len(results)}")
if results.empty:
print("No trades met criteria")
return results
# Display results
returns = results['pl'].values
print(f"Avg P/L: ${returns.mean():.2f}")
print(f"Win Rate: {(returns > 0).mean():.1%}")
print(f"Total EV: ${returns.sum():.2f}")
# Generate analysis and plots
plot_pl_density(returns, symbol)
analyze_strategy_significance(results, price_data, symbol)
test_parameter_scenarios(symbol, results)
generate_trading_instructions(results, symbol)
return results
def adjust_trading_params(new_params):
"""Update trading parameters"""
global TRADING_PARAMS
TRADING_PARAMS.update(new_params)
print("Updated parameters:")
for key, value in TRADING_PARAMS.items():
print(f" {key}: {value}")
def quick_trading_plan(symbol):
"""Quick trading plan without full backtest"""
next_earnings = get_next_earnings_date(symbol)
print(f"\nQUICK TRADING PLAN FOR {symbol}:")
print(f"Next earnings: {next_earnings}")
print(f"Strategy: Sell {TRADING_PARAMS['spread_width']}$ wide call spread")
print(f"Expiration: {TRADING_PARAMS['days_to_expiration']} days after earnings")
# Main execution
if __name__ == "__main__":
print(f"Starting analysis for {SYMBOL}...")
# Fetch data
price_data = fetch_price_data(SYMBOL, START_DATE, END_DATE)
events = get_earnings_dates(SYMBOL, START_DATE, END_DATE)
print(f"Data: {len(price_data)} bars from {price_data.index[0].strftime('%Y-%m-%d')} to {price_data.index[-1].strftime('%Y-%m-%d')}")
print(f"Events: {len(events)} earnings dates")
# Run backtest
results = walk_forward_backtest(symbol=SYMBOL)
print(f"\nAnalysis complete for {SYMBOL}!")
Starting analysis for AAPL... Data: 1005 bars from 2020-01-03 to 2023-12-29 Events: 16 earnings dates Analyzing: AAPL Parameters: $5 spread, 7 DTE Events: 16 TRADE SUMMARY: Trades executed: 15 Avg P/L: $1.75 Win Rate: 60.0% Total EV: $26.20
============================================================ STRATEGY ANALYSIS - AAPL ============================================================ 1. BASIC PERFORMANCE: Trades: 15 Win Rate: 60.0% Avg P/L: $1.75 Std Dev: $2.34 2. ALPHA DETECTION vs SPY: Alpha (excess return): 1.4930 (p=0.0336) Beta (market exposure): 0.266 (p=0.1725) R-squared: 0.138 SIGNIFICANT POSITIVE alpha - strategy has genuine edge 3. REALISTIC ANNUALIZED METRICS: Return on Capital per trade: 39.4% Realistic Annual ROC: 9.4% ====================================================================== PARAMETER ROBUSTNESS TESTING ====================================================================== REALISTIC: 15 trades, Avg P/L: $1.75, Win Rate: 60.0% AGGRESSIVE: 15 trades, Avg P/L: $1.62, Win Rate: 53.3% ====================================================================== TRADING INSTRUCTIONS ====================================================================== NEXT EARNINGS: 2026-01-29 RECOMMENDED: Sell 5$ wide call spread EXPIRATION: 7 days after earnings STRIKE: -0.2% OTM from current price TARGET PREMIUM: $0.60 per spread POSITION SIZE: 50 spreads CAPITAL AT RISK: $221.87 Analysis complete for AAPL!
- ← Previous
note: monitor debit spread - Next →
note: affine variety