Most backtests lie to you.
Not intentionally. But they lie.
You design a strategy, run it on historical data, and watch the returns look incredible. Then you run it live — and it underperforms a simple buy-and-hold from day one. The math wasn't wrong.
The data was.
If you're:
- testing momentum or mean-reversion strategies in Python,
- building quant tools for personal or professional use,
- or tired of backtests that collapse the moment real execution begins,
This changes how you work.
TL;DR
- What this covers: Backtesting trading strategies in Python using EODHD's historical OHLCV data API
-
Stack:
requests,pandas,numpy— no heavy frameworks (no backtrader, no vectorbt) -
Scripts included:
- Script 1 — Fetch adjusted historical price data from EODHD
- Script 2 — SMA crossover strategy (20/50-day)
- Script 3 — RSI mean-reversion strategy
- Script 4 — Performance metrics: Sharpe ratio, max drawdown, win rate
- EODHD pricing: Free tier available; full access from $19.99/month
- Best for: Developers and analysts who need reliable, split/dividend-adjusted data without scraping
The Problem with Free Data
Most developers start with Yahoo Finance or a scraped CSV.
That works fine for a quick prototype. It stops working the moment your strategy includes anything that happened around a stock split, dividend payment, or ticker change.
Non-adjusted price data creates ghost signals. A stock "drops 50%" when it actually split 2:1. Your moving average calculates a crossover that never happened in real life. Your strategy looks profitable because it's trading on a data artifact.
The free path costs you accuracy. And in backtesting, accuracy is the whole point.
The Fix Is Simpler Than You Think
The real bottleneck isn't the strategy logic. It's the data source.
Use split- and dividend-adjusted closing prices from a reliable provider, and half your backtest reliability problems disappear before you write a single signal.
EODHD APIs provides exactly this. Their historical data endpoint returns adjusted OHLCV data for 70,000+ tickers across 50+ exchanges, via a simple REST API. No scraping. No undocumented endpoints that break on weekends.
EODHD Financial Data API
Adjusted historical prices, fundamentals, and real-time data for 70,000+ tickers.
→ Start free at eodhd.com
Setup
Install the required libraries:
pip install requests pandas numpy
Set your API token:
API_TOKEN = "your_eodhd_api_token_here"
You can get a free token at eodhd.com. The free tier includes end-of-day data for US tickers with a 1-year delay — enough to test strategies.
Script 1: Fetch Historical OHLCV Data from EODHD
The foundation of every backtest is the raw price series. This function pulls adjusted daily OHLCV data for any ticker and returns a clean pandas DataFrame.
import requests
import pandas as pd
API_TOKEN = "your_eodhd_api_token_here"
def get_historical_data(symbol: str, exchange: str = "US",
start: str = "2020-01-01", end: str = "2024-12-31") -> pd.DataFrame:
"""
Fetch adjusted EOD OHLCV data from EODHD for a given symbol.
Returns a DataFrame indexed by date.
"""
url = f"https://eodhd.com/api/eod/{symbol}.{exchange}"
params = {
"api_token": API_TOKEN,
"fmt": "json",
"from": start,
"to": end
}
response = requests.get(url, params=params)
response.raise_for_status()
df = pd.DataFrame(response.json())
df["date"] = pd.to_datetime(df["date"])
df.set_index("date", inplace=True)
return df[["open", "high", "low", "close", "adjusted_close", "volume"]]
# Example usage
df = get_historical_data("AAPL", start="2020-01-01", end="2024-12-31")
print(df.tail())
Sample output:
open high low close adjusted_close volume
date
2024-12-24 255.5 258.4 254.2 257.9 257.9 32145600
2024-12-26 258.1 261.0 257.3 259.3 259.3 28761300
2024-12-27 258.8 259.4 254.6 255.6 255.6 41232100
2024-12-30 253.1 254.3 250.7 251.8 251.8 39814700
2024-12-31 250.4 252.1 248.9 250.4 250.4 44021900
Note the adjusted_close column. That's what you backtest on — not raw close.
Script 2: SMA Crossover Strategy
The 20/50-day SMA crossover is the classic momentum signal: go long when the short-term average crosses above the long-term, exit when it crosses below.
Simple in theory. The implementation details matter.
def sma_crossover_backtest(df: pd.DataFrame,
short_window: int = 20,
long_window: int = 50) -> pd.DataFrame:
"""
SMA crossover strategy using adjusted closing prices.
Signal: 1 = long, -1 = short, 0 = flat
Position is shifted by 1 day to prevent look-ahead bias.
"""
df = df.copy()
df["sma_short"] = df["adjusted_close"].rolling(short_window).mean()
df["sma_long"] = df["adjusted_close"].rolling(long_window).mean()
# Raw signal
df["signal"] = 0
df.loc[df["sma_short"] > df["sma_long"], "signal"] = 1
df.loc[df["sma_short"] < df["sma_long"], "signal"] = -1
# Shift by 1 to trade on the *next* day's open — no look-ahead
df["position"] = df["signal"].shift(1)
# Returns
df["market_return"] = df["adjusted_close"].pct_change()
df["strategy_return"] = df["position"] * df["market_return"]
# Cumulative performance
df["cumulative_market"] = (1 + df["market_return"]).cumprod()
df["cumulative_strategy"] = (1 + df["strategy_return"]).cumprod()
return df
# Run it
df = get_historical_data("AAPL", start="2020-01-01", end="2024-12-31")
result_sma = sma_crossover_backtest(df)
final_market = result_sma["cumulative_market"].iloc[-1]
final_strategy = result_sma["cumulative_strategy"].iloc[-1]
print(f"Buy & Hold return: {(final_market - 1):.2%}")
print(f"SMA Strategy return: {(final_strategy - 1):.2%}")
The shift(1) on line 16 is the single most important detail. Without it, you're using today's signal to trade today's close — which is impossible in real life and produces inflated results.
Script 3: RSI Mean-Reversion Strategy
RSI (Relative Strength Index) measures the speed of price changes on a 0–100 scale. Values below 30 signal oversold conditions; above 70 signals overbought.
The mean-reversion hypothesis: when a stock is oversold, it tends to recover. When overbought, it tends to pull back.
import numpy as np
def calculate_rsi(series: pd.Series, period: int = 14) -> pd.Series:
"""Wilder's RSI using simple moving averages of gains and losses."""
delta = series.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(period).mean()
avg_loss = loss.rolling(period).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def rsi_strategy_backtest(df: pd.DataFrame,
rsi_period: int = 14,
oversold: int = 30,
overbought: int = 70) -> pd.DataFrame:
"""
RSI mean-reversion strategy.
Enter long when RSI drops below 'oversold'.
Exit (go flat) when RSI rises above 'overbought'.
"""
df = df.copy()
df["rsi"] = calculate_rsi(df["adjusted_close"], rsi_period)
df["signal"] = 0
df.loc[df["rsi"] < oversold, "signal"] = 1 # Buy oversold
df.loc[df["rsi"] > overbought, "signal"] = -1 # Sell overbought
# Hold position between signals (forward-fill non-zero values)
df["position"] = (
df["signal"]
.replace(0, np.nan)
.ffill()
.fillna(0)
.shift(1) # Again: no look-ahead
)
df["market_return"] = df["adjusted_close"].pct_change()
df["strategy_return"] = df["position"] * df["market_return"]
df["cumulative_market"] = (1 + df["market_return"]).cumprod()
df["cumulative_strategy"] = (1 + df["strategy_return"]).cumprod()
return df
# Run it
result_rsi = rsi_strategy_backtest(df)
print(f"Buy & Hold return: {(result_rsi['cumulative_market'].iloc[-1] - 1):.2%}")
print(f"RSI Strategy return: {(result_rsi['cumulative_strategy'].iloc[-1] - 1):.2%}")
Script 4: Performance Metrics (Sharpe, Max Drawdown, Win Rate)
Return alone means nothing. A strategy returning 40% with -60% max drawdown is not a good strategy.
This function calculates the three metrics that matter most for evaluating any backtest:
def calculate_performance(df: pd.DataFrame,
risk_free_rate: float = 0.04) -> dict:
"""
Compute annualized return, Sharpe ratio, max drawdown, and win rate
for a backtested strategy DataFrame.
"""
strategy_returns = df["strategy_return"].dropna()
# Annualized return
total_days = len(strategy_returns)
annual_factor = 252 / total_days
strategy_ann = (df["cumulative_strategy"].iloc[-1] ** annual_factor) - 1
market_ann = (df["cumulative_market"].iloc[-1] ** annual_factor) - 1
# Sharpe ratio (annualized)
daily_rf = risk_free_rate / 252
excess = strategy_returns - daily_rf
sharpe = np.sqrt(252) * excess.mean() / excess.std()
# Maximum drawdown
cumulative = df["cumulative_strategy"].dropna()
rolling_max = cumulative.cummax()
drawdown = (cumulative - rolling_max) / rolling_max
max_dd = drawdown.min()
# Win rate (percentage of profitable trading days)
active = strategy_returns[strategy_returns != 0]
win_rate = (active > 0).sum() / len(active)
return {
"Strategy Annualized Return": f"{strategy_ann:.2%}",
"Market Annualized Return": f"{market_ann:.2%}",
"Sharpe Ratio": f"{sharpe:.2f}",
"Max Drawdown": f"{max_dd:.2%}",
"Win Rate": f"{win_rate:.2%}",
}
# Evaluate both strategies
print("=== SMA Crossover ===")
for k, v in calculate_performance(result_sma).items():
print(f" {k}: {v}")
print("\n=== RSI Mean-Reversion ===")
for k, v in calculate_performance(result_rsi).items():
print(f" {k}: {v}")
Sample output (AAPL, 2020–2024):
=== SMA Crossover ===
Strategy Annualized Return: 18.4%
Market Annualized Return: 22.1%
Sharpe Ratio: 0.91
Max Drawdown: -14.3%
Win Rate: 53.2%
=== RSI Mean-Reversion ===
Strategy Annualized Return: 15.7%
Market Annualized Return: 22.1%
Sharpe Ratio: 0.78
Max Drawdown: -18.6%
Win Rate: 51.8%
In this case, buy-and-hold wins on raw return. But look at the max drawdown: the SMA strategy cuts the worst-case scenario from -30%+ to -14%. That's the real value — risk-adjusted performance, not just raw returns.
Putting It All Together
Here's the full pipeline: fetch data, run both strategies, compare metrics.
# Full backtest pipeline
symbols = ["AAPL", "MSFT", "NVDA"]
for ticker in symbols:
print(f"\n{'='*40}")
print(f" {ticker}")
print(f"{'='*40}")
data = get_historical_data(ticker, start="2021-01-01", end="2024-12-31")
sma_result = sma_crossover_backtest(data)
rsi_result = rsi_strategy_backtest(data)
print(" SMA Crossover:")
for k, v in calculate_performance(sma_result).items():
print(f" {k}: {v}")
print(" RSI Mean-Reversion:")
for k, v in calculate_performance(rsi_result).items():
print(f" {k}: {v}")
From here you can build:
- a parameter optimization loop (walk-forward testing)
- a multi-ticker portfolio backtest with position sizing
- a live signal generator using EODHD's real-time endpoints
- a dashboard to visualize equity curves and drawdown periods
FAQs
❓ Is EODHD data adjusted for stock splits and dividends?
✅ Yes. The adjusted_close field in the API response accounts for both splits and dividends. Always use this field for backtesting — raw close prices will produce misleading signals around corporate actions.
❓ Can I backtest strategies on non-US markets with EODHD?
✅ EODHD covers 50+ exchanges including LSE, TSX, ASX, Euronext, and major Asian markets. Just change the exchange parameter in the API call (e.g., "LSE" for London, "TO" for Toronto).
❓ Does this approach suffer from survivorship bias?
✅ Potentially yes, if you only test on stocks that still exist today. To minimize it, include delisted tickers in your universe. EODHD provides data for delisted stocks — you can query them using their historical ticker symbols.
❓ What's the difference between a good Sharpe ratio and a bad one?
✅ As a general benchmark: below 0.5 is weak, 0.5–1.0 is acceptable, above 1.0 is considered good. Above 2.0 in a backtest should raise suspicion — it often signals overfitting to historical noise.
❓ Is there a free tier on EODHD to try this?
✅ Yes. EODHD offers a free API key that includes EOD data for US tickers. You can run all the scripts in this article with the free tier. Paid plans start at $19.99/month and unlock real-time data, fundamentals, and full global coverage.
Before You Ship Your Strategy Live
Two things to check before treating any backtest as real:
Transaction costs. Every trade has a spread and a commission. Add a -0.001 cost per trade (0.1%) to your strategy_return calculation and see if the edge survives.
Overfitting. If you tuned your parameters (RSI period, SMA windows) on the same data you're testing on, your results are optimistic. Use a walk-forward split: train on 70% of the data, test on the remaining 30%.
Bad data tells you the strategy works. Good data tells you the truth.
EODHD Financial Data API
Adjusted historical prices for 70,000+ tickers across 50+ global exchanges. REST API, JSON responses, Python-friendly.
→ Start free — no credit card required
Looking for technical content for your company? I can help — LinkedIn · kevinmenesesgonzalez@gmail.com
Top comments (0)