- GuruFinance Insights
- Posts
- Can Genetic Algorithms Beat Buy and Hold? A Test on the S&P 500
Can Genetic Algorithms Beat Buy and Hold? A Test on the S&P 500
The Short Answer is Yes, And It Only Took 3 Trades
Inventory Software Made Easy—Now $499 Off
Looking for inventory software that’s actually easy to use?
inFlow helps you manage inventory, orders, and shipping—without the hassle.
It includes built-in barcode scanning to facilitate picking, packing, and stock counts. inFlow also integrates seamlessly with Shopify, Amazon, QuickBooks, UPS, and over 90 other apps you already use
93% of users say inFlow is easy to use—and now you can see for yourself.
Try it free and for a limited time, save $499 with code EASY499 when you upgrade.
Free up hours each week—so you can focus more on growing your business.
✅ Hear from real users in our case studies
🚀 Compare plans on our pricing page
🚀 Your Investing Journey Just Got Better: Premium Subscriptions Are Here! 🚀
It’s been 4 months since we launched our premium subscription plans at GuruFinance Insights, and the results have been phenomenal! Now, we’re making it even better for you to take your investing game to the next level. Whether you’re just starting out or you’re a seasoned trader, our updated plans are designed to give you the tools, insights, and support you need to succeed.
Here’s what you’ll get as a premium member:
Exclusive Trading Strategies: Unlock proven methods to maximize your returns.
In-Depth Research Analysis: Stay ahead with insights from the latest market trends.
Ad-Free Experience: Focus on what matters most—your investments.
Monthly AMA Sessions: Get your questions answered by top industry experts.
Coding Tutorials: Learn how to automate your trading strategies like a pro.
Masterclasses & One-on-One Consultations: Elevate your skills with personalized guidance.
Our three tailored plans—Starter Investor, Pro Trader, and Elite Investor—are designed to fit your unique needs and goals. Whether you’re looking for foundational tools or advanced strategies, we’ve got you covered.
Don’t wait any longer to transform your investment strategy. The last 4 months have shown just how powerful these tools can be—now it’s your turn to experience the difference.

Optimized Moving Averages on the S&P 500 Index Data
There’s something oddly satisfying about teaching a machine to search through a haystack of numbers for a needle that might improve your returns.
And as someone who’s spent years digging into data, building models, and refining intuition, I wanted to see how far I could push a basic moving average crossover strategy using the most classical of techniques: a genetic algorithm.
Could evolutionary computation, often used in academic toy problems, actually help refine a trading idea in a way that’s easy to implement and visually interpret?
Let me show you what I did and what I found.
Marketing ideas for marketers who hate boring
The best marketing ideas come from marketers who live it. That’s what The Marketing Millennials delivers: real insights, fresh takes, and no fluff. Written by Daniel Murray, a marketer who knows what works, this newsletter cuts through the noise so you can stop guessing and start winning. Subscribe and level up your marketing game.
The Setup: S&P 500 Historical Data
The first step is to grab reliable historical market data.
I used yfinance
to download daily closing prices of the S&P 500 index, covering the period from 1990 to the end of 2024.
We’ll also split the data into a training set (1990–2019) and a testing set (2020–2024).
Before diving in, install the necessary libraries:
%pip install yfinance matplotlib pandas deap
Once that’s done, we can import them:
import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import calendar
import random
import time
from deap import base, creator, tools, algorithms
plt.style.use("dark_background")
Now define the symbol and date ranges:
symbol = "^GSPC"
start_date = "1990-01-01"
end_date = "2024-12-31"
train_cutoff_date = "2019-12-31"
Let’s fetch the data and take a peek:
df = yf.download(symbol, start=start_date, end=end_date)
df.columns = df.columns.get_level_values(0)
df = df[['Close']]
df.head()
Date Close
1990-01-02 359.690002
1990-01-03 358.760010
1990-01-04 355.670013
1990-01-05 352.200012
1990-01-08 353.790009
And visualize the full historical series:
plt.figure(figsize=(14, 6))
plt.plot(df['Close'], label=f"{symbol} Closing Price", color='blue')
plt.title(f"{symbol} Closing Price from {start_date} to {end_date}")
plt.xlabel("Date")
plt.ylabel("Price (USD)")
plt.legend()
plt.grid(True, color='gray', linestyle='--', linewidth=0.5)
plt.savefig("closing_price_plot.png", dpi=300, bbox_inches='tight')
plt.show()

S&P 500 Closing Price Chart
Split the dataset:
df_train = df.loc[start_date:train_cutoff_date].copy()
df_test = df.loc[train_cutoff_date:end_date].copy()
The Strategy: Double Moving Average Crossover
This is one of the simplest strategies out there. The Double Moving Average Crossover (DMAC) strategy works in the following way:
Buy when the short-term moving average crosses above the long-term one.
Sell when the opposite happens.
Here’s the backtest function:
def backtest_strategy_double_ma(data, short_window, long_window, initial_capital):
df = data.copy()
# Compute short and long moving averages
df['SMA_Short'] = df['Close'].rolling(window=short_window).mean()
df['SMA_Long'] = df['Close'].rolling(window=long_window).mean()
# Generate buy signal when short MA crosses above long MA
df['Signal'] = 0
df.loc[df['SMA_Short'] > df['SMA_Long'], 'Signal'] = 1
# Lag position by 1 day
df['Position'] = df['Signal'].shift(1)
# Calculate daily returns
df['Return'] = df['Close'].pct_change()
df['Strategy Return'] = df['Position'] * df['Return']
# Equity curve
df['Equity Curve'] = (1 + df['Strategy Return']).cumprod() * initial_capital
# Metrics
final_value = df['Equity Curve'].iloc[-1]
num_trades = df['Position'].diff().abs().sum()
return final_value, num_trades, df
We’ll use an initial capital of $10:
initial_capital = 10
The Optimization: Genetic Algorithm
The goal is to find the best combination of short and long windows using the Genetic Algorithm.
We define an evaluation function that penalizes invalid pairs and returns the final portfolio value:
# Set seed for reproducibility
random.seed(42)
# Define evaluation function to maximize (final equity)
def eval_strategy(individual):
short_window, long_window = individual
# Constraint: short_window < long_window
if short_window >= long_window:
return -np.inf, # Penalize invalid individuals
final_value, trades, _ = backtest_strategy_double_ma(df_train, short_window, long_window, initial_capital)
return final_value, # DEAP expects a tuple
Define the DEAP genetic algorithm setup:
# Create fitness and individual classes
creator.create("FitnessMax", base.Fitness, weights=(1.0,)) # maximize final equity
creator.create("Individual", list, fitness=creator.FitnessMax)
toolbox = base.Toolbox()
# Define attribute generators for short and long windows
toolbox.register("short_window_attr", random.randint, 5, 50)
toolbox.register("long_window_attr", random.randint, 55, 200)
# Structure initializers
toolbox.register("individual", tools.initCycle, creator.Individual,
(toolbox.short_window_attr, toolbox.long_window_attr), n=1)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)
# Register the evaluation function
toolbox.register("evaluate", eval_strategy)
# Register the crossover operator (uniform crossover)
toolbox.register("mate", tools.cxUniform, indpb=0.5)
We also define a custom mutation function:
# Register a mutation operator (mutate one window by adding/subtracting 1-3 days)
def mutate_individual(individual):
if random.random() < 0.5:
individual[0] += random.choice([-3, -2, -1, 1, 2, 3])
individual[0] = max(5, min(individual[0], 50))
else:
individual[1] += random.choice([-15, -10, -5, 5, 10, 15])
individual[1] = max(55, min(individual[1], 200))
return individual,
toolbox.register("mutate", mutate_individual)
# Register the selection operator (tournament selection)
toolbox.register("select", tools.selTournament, tournsize=3)
Now run the optimization multiple times and track the best outcome:
# GA parameters
population_size = 40
num_generations = 20
cx_prob = 0.7 # crossover probability
mut_prob = 0.2 # mutation probability
num_runs = 10
times = []
best_final_value = -float('inf')
best_params_overall = None
best_num_trades = None
for run in range(num_runs):
print(f"Genetic Algorithm Run {run+1}/{num_runs}")
# Re-create population and hall of fame fresh each run
pop = toolbox.population(n=population_size)
hof = tools.HallOfFame(1)
stats = tools.Statistics(lambda ind: ind.fitness.values)
stats.register("avg", np.mean)
stats.register("max", np.max)
stats.register("min", np.min)
start_time = time.time()
pop, log = algorithms.eaSimple(
pop, toolbox,
cxpb=cx_prob,
mutpb=mut_prob,
ngen=num_generations,
stats=stats,
halloffame=hof,
verbose=True
)
end_time = time.time()
times.append(end_time - start_time)
candidate = hof[0]
candidate_final_value, candidate_num_trades, _ = backtest_strategy_double_ma(df_train, candidate[0], candidate[1], initial_capital)
if candidate_final_value > best_final_value:
best_final_value = candidate_final_value
best_params_overall = candidate
best_num_trades = candidate_num_trades
average_time = sum(times) / num_runs
best_short_window, best_long_window = best_params_overall
print(f"\nAverage GA runtime over {num_runs} runs: {average_time:.2f} seconds ({average_time/60:.2f} minutes).")
print("\nBest Strategy Parameters Found (best of all runs):")
print(f"Short MA Window : {best_short_window}")
print(f"Long MA Window : {best_long_window}")
print(f"Final Value : ${best_final_value:,.2f}")
print(f"Number of Trades: {int(best_num_trades)}")
Average GA runtime over 10 runs: 3.59 seconds (0.06 minutes).
Best Strategy Parameters Found (best of all runs):
Short MA Window : 46
Long MA Window : 166
Final Value : $111.11
Number of Trades: 35
Results: Visualizing the Strategy
Let’s visualize the moving averages and trades on the training set:
# Run backtest on training data with best short and long MA windows
_, _, df_train_result = backtest_strategy_double_ma(df_train, best_short_window, best_long_window, initial_capital)
# Plot Close price and the two moving averages (full dataset)
plt.figure(figsize=(14, 6))
plt.plot(df_train_result['Close'], label='Close Price', alpha=0.6, color='blue')
plt.plot(df_train_result['SMA_Short'], label=f'{best_short_window}-day SMA', color='orange', linestyle='-')
plt.plot(df_train_result['SMA_Long'], label=f'{best_long_window}-day SMA', color='green', linestyle='--')
# Chart formatting
plt.title(f"Training Set: {symbol} Close Price with {best_short_window}-day and {best_long_window}-day SMAs")
plt.xlabel("Date")
plt.ylabel("Price (USD)")
plt.legend()
plt.grid(True, color='gray', linestyle='--', linewidth=0.5)
plt.tight_layout()
plt.savefig("training_set_sma_plot.png", dpi=300, bbox_inches='tight')
plt.show()

The Moving Averages on the S&P 500 Index Data
Now test the strategy on out-of-sample data:
final_value, trades, df_test_result = backtest_strategy_double_ma(
df_test,
best_short_window,
best_long_window,
initial_capital
)
Plot the signals:
plt.figure(figsize=(14, 6))
plt.plot(df_test_result['Close'], label='Close Price', alpha=0.6, color='blue')
plt.plot(df_test_result['SMA_Short'], label=f'{best_short_window}-day SMA', color='orange')
plt.plot(df_test_result['SMA_Long'], label=f'{best_long_window}-day SMA', color='green')
# Identify buy signals: when short MA crosses above long MA
buy_signals = df_test_result[(df_test_result['SMA_Short'].shift(1) <= df_test_result['SMA_Long'].shift(1)) &
(df_test_result['SMA_Short'] > df_test_result['SMA_Long'])]
# Identify sell signals: when short MA crosses below long MA
sell_signals = df_test_result[(df_test_result['SMA_Short'].shift(1) >= df_test_result['SMA_Long'].shift(1)) &
(df_test_result['SMA_Short'] < df_test_result['SMA_Long'])]
# Plot buy/sell signals with enhancements
plt.scatter(buy_signals.index, buy_signals['Close'], marker='^', color='lime', s=120, label='Buy', zorder=5)
plt.scatter(sell_signals.index, sell_signals['Close'], marker='v', color='red', s=120, label='Sell', zorder=5)
plt.title(f"Out-of-Sample Signals: {symbol} ({best_short_window}/{best_long_window}-day SMA)")
plt.xlabel("Date")
plt.ylabel("Price (USD)")
plt.legend()
plt.grid(True, color='gray', linestyle='--', linewidth=0.5)
plt.savefig("out_of_sample_signals_plot.png", dpi=300, bbox_inches='tight')
plt.show()

Out-of-Sample Signals Using the Optimal Window Sizes
Seasonal Behavior: Monthly Patterns
Let’s see how the strategy performs on average by calendar month:
# Calculate monthly returns for strategy and buy & hold
strategy_returns = df_test_result['Strategy Return'].resample('M').apply(lambda x: (1 + x).prod() - 1)
buyhold_returns = df_test_result['Return'].resample('M').apply(lambda x: (1 + x).prod() - 1)
# Convert to DataFrame with Year and Month for strategy
strategy_df = strategy_returns.to_frame(name='Strategy Return')
strategy_df['Year'] = strategy_df.index.year
strategy_df['Month'] = strategy_df.index.month_name()
# Convert to DataFrame with Year and Month for buy & hold
buyhold_df = buyhold_returns.to_frame(name='Buy & Hold Return')
buyhold_df['Year'] = buyhold_df.index.year
buyhold_df['Month'] = buyhold_df.index.month_name()
# Compute average monthly returns across years
avg_strategy = strategy_df.groupby('Month')['Strategy Return'].mean()
avg_buyhold = buyhold_df.groupby('Month')['Buy & Hold Return'].mean()
# Order months Jan to Dec
month_order = list(calendar.month_name)[1:]
avg_strategy = avg_strategy.reindex(month_order) * 100 # convert to percentage
avg_buyhold = avg_buyhold.reindex(month_order) * 100 # convert to percentage
# Bar positions and width
x = np.arange(len(month_order))
width = 0.35
plt.figure(figsize=(14, 7))
# Plot bars for strategy and buy & hold returns
bars1 = plt.bar(x - width/2, avg_strategy, width, label='Strategy', color='green')
bars2 = plt.bar(x + width/2, avg_buyhold, width, label='Buy & Hold', color='orange')
# Horizontal zero line at y=0
plt.axhline(0, color='white', linewidth=1.2)
# Add both horizontal and vertical grid lines with light gray dashed style and opacity
plt.grid(axis='y', color='lightgray', linestyle='--', alpha=0.7)
plt.grid(axis='x', color='lightgray', linestyle='--', alpha=0.7)
# Annotate each bar with its value
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
if height >= 0:
plt.text(bar.get_x() + bar.get_width() / 2, height + 0.6, f"{height:.1f}%",
ha='center', va='bottom', fontsize=12)
else:
plt.text(bar.get_x() + bar.get_width() / 2, height - 0.6, f"{height:.1f}%",
ha='center', va='top', fontsize=12)
# Set x-axis labels and rotation for readability
plt.xticks(x, month_order, rotation=45)
plt.ylabel('Average Monthly Return (%)')
plt.title('Average Monthly Returns (%) - Strategy vs Buy & Hold')
plt.legend()
plt.tight_layout()
plt.savefig("average_monthly_returns.png", dpi=300, bbox_inches='tight')
plt.show()

Average Monthly Returns between Buy and Hold and the Optimized DMAC Strategy
Performance Comparison
Compare the strategy to a simple buy-and-hold benchmark:
# Calculate Buy & Hold cumulative portfolio value
df_test_result['Buy & Hold'] = (1 + df_test_result['Return']).cumprod() * initial_capital
# Plot strategy equity curve vs buy & hold
plt.figure(figsize=(12, 6))
plt.plot(df_test_result['Equity Curve'], label='Strategy (Double MA)', color='green')
plt.plot(df_test_result['Buy & Hold'], label='Buy & Hold', linestyle='--', color='orange')
plt.title(f"Out-of-Sample Equity Curve: {symbol}")
plt.xlabel("Date")
plt.ylabel("Portfolio Value (USD)")
plt.legend()
plt.grid(True, color='gray', linestyle='--', linewidth=0.5)
plt.savefig("equity_curve_plot.png", dpi=300, bbox_inches='tight')
plt.show()

Out-of-Sample Equity Curve Plot
Create a performance summary:
final_strategy_value = df_test_result['Equity Curve'].iloc[-1]
final_bh_value = df_test_result['Buy & Hold'].iloc[-1]
strategy_return_pct = ((final_strategy_value / initial_capital) - 1) * 100
bh_return_pct = ((final_bh_value / initial_capital) - 1) * 100
summary = pd.DataFrame({
"Metric": [
"Optimized Short MA Window",
"Optimized Long MA Window",
"Final Strategy Value (Test)",
"Final Buy & Hold Value (Test)",
"Strategy Return (%)",
"Buy & Hold Return (%)",
"Number of Trades (Test)",
"Average Time per Run (seconds)"
],
"Value": [
best_short_window,
best_long_window,
final_strategy_value,
final_bh_value,
strategy_return_pct,
bh_return_pct,
int(trades),
average_time
]
}).set_index("Metric")
# Save summary to CSV
summary.to_csv("strategy_summary.csv")
summary
Metric Value
Optimized Short MA Window : 46
Optimized Long MA Window : 166
Final Strategy Value (Test) : 18.453874
Final Buy & Hold Value (Test) : 18.283324
Strategy Return (%) : 84.538742
Buy & Hold Return (%) : 82.833244
Number of Trades (Test) : 3.000000
Average Time per Run (seconds) : 3.590756
Analysis
To evaluate the effectiveness of the optimized strategy, I compared it to a basic buy-and-hold benchmark over the same out-of-sample test period.
Both approaches started with an initial portfolio value of $10.
The buy-and-hold strategy, which passively tracks the S&P 500, grew to approximately $18.28.
In comparison, the double moving average strategy — tuned using the genetic algorithm — ended with a slightly higher final value of $18.45.
Although the difference in returns is modest (84.5% vs 82.8%), the Genetic Algorithm–optimized strategy slightly outperformed buy-and-hold. What’s more, it did so with just 3 trades during the entire test period.
The strategy also remained interpretable, using a 46-day short window and a 166-day long window — parameters that were discovered through multiple runs of the genetic algorithm, each averaging under 4 seconds.
This test doesn’t prove the strategy is superior in all environments, but it does demonstrate how a simple rule-based system, when tuned effectively, can compete with passive investing over long periods while maintaining discipline and avoiding overfitting.
Seasonal Behavior: Monthly Patterns
Let’s see how the strategy performs on average by calendar month:
# Calculate monthly returns for strategy and buy & hold
strategy_returns = df_test_result['Strategy Return'].resample('M').apply(lambda x: (1 + x).prod() - 1)
buyhold_returns = df_test_result['Return'].resample('M').apply(lambda x: (1 + x).prod() - 1)
# Convert to DataFrame with Year and Month for strategy
strategy_df = strategy_returns.to_frame(name='Strategy Return')
strategy_df['Year'] = strategy_df.index.year
strategy_df['Month'] = strategy_df.index.month_name()
# Convert to DataFrame with Year and Month for buy & hold
buyhold_df = buyhold_returns.to_frame(name='Buy & Hold Return')
buyhold_df['Year'] = buyhold_df.index.year
buyhold_df['Month'] = buyhold_df.index.month_name()
# Compute average monthly returns across years
avg_strategy = strategy_df.groupby('Month')['Strategy Return'].mean()
avg_buyhold = buyhold_df.groupby('Month')['Buy & Hold Return'].mean()
# Order months Jan to Dec
month_order = list(calendar.month_name)[1:]
avg_strategy = avg_strategy.reindex(month_order) * 100 # convert to percentage
avg_buyhold = avg_buyhold.reindex(month_order) * 100 # convert to percentage
# Bar positions and width
x = np.arange(len(month_order))
width = 0.35
plt.figure(figsize=(14, 7))
# Plot bars for strategy and buy & hold returns
bars1 = plt.bar(x - width/2, avg_strategy, width, label='Strategy', color='green')
bars2 = plt.bar(x + width/2, avg_buyhold, width, label='Buy & Hold', color='orange')
# Horizontal zero line at y=0
plt.axhline(0, color='white', linewidth=1.2)
# Add both horizontal and vertical grid lines with light gray dashed style and opacity
plt.grid(axis='y', color='lightgray', linestyle='--', alpha=0.7)
plt.grid(axis='x', color='lightgray', linestyle='--', alpha=0.7)
# Annotate each bar with its value
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
if height >= 0:
plt.text(bar.get_x() + bar.get_width() / 2, height + 0.6, f"{height:.1f}%",
ha='center', va='bottom', fontsize=12)
else:
plt.text(bar.get_x() + bar.get_width() / 2, height - 0.6, f"{height:.1f}%",
ha='center', va='top', fontsize=12)
# Set x-axis labels and rotation for readability
plt.xticks(x, month_order, rotation=45)
plt.ylabel('Average Monthly Return (%)')
plt.title('Average Monthly Returns (%) - Strategy vs Buy & Hold')
plt.legend()
plt.tight_layout()
plt.savefig("average_monthly_returns.png", dpi=300, bbox_inches='tight')
plt.show()

Average Monthly Returns between Buy and Hold and the Optimized DMAC Strategy
There are no guarantees in financial markets. This strategy doesn’t promise outperformance — but it illustrates a workflow that’s highly adaptable and transparent.
Optimizing parameters using evolutionary logic is far from new, but it remains underused in applied trading experimentation.
If nothing else, it’s a great way to test, tune, and trust your process.