Finally, we’re here! The first article on this blog with actual code to talk about backtesting! Want to run a simple backtest of your strategy without blindly trusting some random guy on Reddit claiming 800% returns or dealing with painfully slow "backtests" on TradingView?We’ll walk you through the backtest step by step, so just follow along as we climb the staircase to success, one step at a time!

Happy guy on a staircase

Let’s cut to the chase! If you haven’t already, I highly recommend starting by reading the article on the basics about backtests. You’ll discover the common pitfalls to avoid and the basic methodology. These solid fundamentals will help you get the most out of what’s coming next.
Now that you have a clear idea of what backtesting is, let’s get practical! In this article, you’ll dive into the code. Try it out, experiment, visualize the results, and understand step by step how the process works. Ready? Let’s go!
 

Retrieve and Prepare Your Data for Effective Analysis

Let's start by importing historical Forex market data, focusing on the EUR/USD pair over a 5-year period. For this, we'll use yfinance, a simple and effective tool for retrieving financial data without complications. Keep in mind that it’s often necessary to check whether your data contains missing values, outliers, or other anomalies. That’s not the case here, but always remember the saying: "garbage in, garbage out"!

import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# Get the historical data
ticker_symbol = "EURUSD=X"
ticker = yf.Ticker(ticker_symbol)
data = ticker.history(period="5y")
 

Create Your Training and Testing Sets

Next, to perform a basic backtest, we’ll split our dataset into two parts using the well-known temporal method. It’s quick, though admittedly less precise. I’ll write another article on the combinatorial method for those interested—in the meantime, you can search “purged combinatorial cross-validation” (commonly referred to as CPCV) on Google for more details.😏

Here, I’m going with a 50/50 split rather than the classic 80/20. Why? Well, I don’t have millions of data points, and since I’m testing a strategy with fixed parameters, there’s no point in reducing the testing interval. There’s no calibration phase, so I can just as easily make decisions based on 50% of the data. Moreover, this approach maximizes the chances that both datasets contain all market conditions, minimizing bias. Of course, I don’t peek at the test dataset to avoid introducing bias.

# Separate into train-test
split = int(len(data) * 0.5)
train, test = data.iloc[:split].copy(), data.iloc[split:].copy()
 

Create a Strategy

Let’s now implement a simple moving average crossover strategy. I’ve chosen 20 and 40 periods pretty randomly, but it works decently (I’ll also write an article about the market logic behind each indicator!). The goal is to create a signal: 1 for buy, -1 for sell. That’s why I test whether the 20-period SMA is above the 40-period SMA. This gives me a Boolean value (true/false), which I then convert into an integer (1 or 0). Next, I adjust the signal from 1/0 to 2/0 (x2) and 1/-1 (-1) to get my final buy/sell signal. Here’s the code:

# Create a simple strategy on the train set
train['fast_sma'] = train.Close.rolling(20).mean()
train['slow_sma'] = train.Close.rolling(40).mean()
train['signal'] = (train['fast_sma'] > train['slow_sma']).astype(int) * 2 - 1
train['signal'] = train['signal'].shift(1)
 
Important detail: I shift the signal by 1 period to avoid look-ahead bias (this prevents predicting the end of the candle at its opening, which would be cheating a bit).
 
Here is a visualization to better see the signal and the dynamic we want to trade on the price:
Price and train signal
Just so we’re clear: This chart is a labor of love and time to prove I can, in fact, make pretty visuals. The rest? Pure "me at 2 AM, in sweatpants, mainlining caffeine while backtesting the 47th strategy this week" energy. Brace for glorious dumpster fire charts! You’re welcome.😊
 

Evaluating the Strategy on the Train Set: Returns and Statistics

The strategy’s returns are calculated by multiplying the trading signal by the underlying asset’s returns. A +1 signal corresponds to a long position (buying the asset), where returns are positively reflected. A -1 signal indicates a short position (selling the asset), where returns are inverted. This inversion allows the strategy to profit even during price declines, as the product of two negatives (e.g., a short signal and a negative asset return) yields a positive gain.

However, transaction costs must be incorporated to ensure realistic backtesting. While Admiral Markets Trade accounts used here charge no explicit commissions, they apply an average spread of 3 pips. The spread—the difference between the bid (sell) and ask (buy) prices—introduces implicit costs: positions are opened at a slightly worse price than the mid-market rate. This friction reduces net returns and must be modeled accurately.

To account for these costs, we calculate turnover—the frequency of position changes—by taking the absolute difference between consecutive trading signals. This metric quantifies how often the strategy flips between long and short stances. Since the trading signal is shifted by one period to avoid lookahead bias, the turnover calculation aligns with this lag to preserve temporal logic.

# Calculate the strategy's returns
train['return'] = train.Close.pct_change()
train['strategy_return'] = train['return'] * train['signal']
train[['return','strategy_return']].cumsum().plot(figsize=(14,10))
plt.show()

# Including fees
train['turnover'] = abs(train['signal'].shift(-1).diff()).fillna(0)
train['strategy_return_fees'] = train['return'] * train['signal'] - 3e-4*train['turnover']
train[['strategy_return_fees','strategy_return']].cumsum().plot(figsize=(14,10))
plt.show()
 
Behold, my 2 “Masterpieces” you’ve been waiting for

Train results vs B&H
Train results with/without fees

The purpose of the first graph is to compare the strategy to a simple buy & hold to see if the strategy at least outperforms the basic investment. As you can see, until October 2021, the strategy is strongly correlated with the asset and performs almost identically. Then, it starts to outperform and gains an edge over EURUSD.

The second graph compares the strategy with and without spread costs, and as we can see, there’s not much difference! That’s a good sign, but we know the number of trades is low, so it’s not all that surprising.😑

Well... to be honest, the results on the graphs aren’t fantastic... but let’s press on with the method anyway.

 

Time to put numbers on Intuitions

To get a more quantitative view of this backtest, I often calculate the following metrics: number of trades, final return, arithmetic mean, geometric mean, win rate, Sharpe ratio, Sortino ratio, maximum drawdown, VaR, and CVaR at the 1% level. To calculate the number of trades, I cumulatively sum the turnover, divide by 2 and add 1 (since I'm in position from the start!). For the rest, I’ll let you review the code.

def evaluate_strategy_metrics(strategy_returns):
    print("------ Statistics ------")

    # Final Return
    final_return = strategy_returns.cumsum().iloc[-1] * 100
    print(f"Final Return: {final_return:.2f}%")

    # Annualized Arithmetic Mean Return
    annualized_mean_return = strategy_returns.mean() * 252
    print(f"Annualized Arithmetic Mean Return: {annualized_mean_return * 100:.2f}%")

    # Annualized Geometric Mean Return
    geometric_mean = ((1 + strategy_returns).prod()) ** (1 / len(strategy_returns)) - 1
    annualized_geometric_mean = (1 + geometric_mean) ** 252 - 1
    print(f"Annualized Geometric Mean Return: {annualized_geometric_mean * 100:.2f}%")

    # Win Rate
    win_rate = (strategy_returns > 0).mean()
    print(f"Win Rate: {win_rate * 100:.2f}%")

    # Sharpe Ratio
    std_dev = strategy_returns.std() * np.sqrt(252)
    sharpe_ratio = (annualized_mean_return - 0.04) / std_dev
    print(f"Sharpe Ratio: {sharpe_ratio:.2f}")

    # Sortino Ratio
    downside_std_dev = strategy_returns[strategy_returns < 0].std() * np.sqrt(252)
    sortino_ratio = (annualized_mean_return - 0.04) / downside_std_dev
    print(f"Sortino Ratio: {sortino_ratio:.2f}")

    # Maximum Drawdown
    cumulative_returns = (1 + strategy_returns).cumprod()
    peak = cumulative_returns.cummax()
    drawdown = (cumulative_returns - peak) / peak
    max_drawdown = drawdown.min() * 100
    print(f"Maximum Drawdown: {max_drawdown:.2f}%")

    # Maximum Drawdown Duration
    drawdown_duration = (drawdown != 0).astype(int).groupby((drawdown == 0).astype(int).cumsum()).cumsum()
    max_drawdown_duration = drawdown_duration.max()
    print(f"Maximum Drawdown Duration: {max_drawdown_duration} days")

    # Value at Risk (1%)
    var_1_percent = np.percentile(strategy_returns, 1) * 100
    print(f"Value at Risk (1%): {var_1_percent:.2f}%")

    # Conditional Value at Risk (1%)
    cvar_1_percent = strategy_returns[strategy_returns <= np.percentile(strategy_returns, 1)].mean() * 100
    print(f"Conditional Value at Risk (1%): {cvar_1_percent:.2f}%")

    print("--------------------------------")

print(train['turnover'].cumsum().iloc[-1]/2 +1 )
evaluate_strategy_metrics(train['strategy_return_fees'].dropna())

Here are the results after calculations:

19.0 ------ Statistics ------ Final Return: 16.41% Annualized Arithmetic Mean Return: 6.35% Annualized Geometric Mean Return: 6.28% Win Rate: 53.15% Sharpe Ratio: 0.33 Sortino Ratio: 0.53 Maximum Drawdown: -6.37% Maximum Drawdown Duration: 278 days Value at Risk (1%): -1.02% Conditional Value at Risk (1%): -1.21% --------------------------------

The annual Sharpe Ratio isn’t great, and more importantly, the final return equals approximatly the maximum drawdown, which is indicative of an extremely risky strategy. Moreover, the drawdown periods are long — I think one could rethink their entire life several times over during those 278 days. But let’s imagine that a blind investor stumbles upon this equity curve and decides to invest anyway.😅

 

Testing the Strategy: Performance Analysis on the Test Set

Once the training is ready, we simply replicate the same process on the test dataset to see if the strategy performs as well on "new" data (data not used during training). The process is identical—just a quick copy-paste with the test set (and for the clever ones out there, you could encapsulate everything into a function 😏).

# Create the same strategy on the test set
test['fast_sma'] = test.Close.rolling(20).mean()
test['slow_sma'] = test.Close.rolling(40).mean()
test['signal'] = (test['fast_sma'] > test['slow_sma']).astype(int) * 2 - 1
test['signal'] = test['signal'].shift(1) 

# Calculate the strategy's returns
test['return'] = test.Close.pct_change()
test['strategy_return'] = test['return'] * test['signal']
test[['return','strategy_return']].cumsum().plot(figsize=(14,10))
plt.show()

# Including fees
test['turnover'] = abs(test['signal'].shift(-1).diff()).fillna(0)
test['strategy_return_fees'] = test['return'] * test['signal'] - 3e-4*test['turnover']
test[['strategy_return_fees','strategy_return']].cumsum().plot(figsize=(14,10))
plt.show()

print(test['turnover'].cumsum().iloc[-1]/2 +1 )
evaluate_strategy_metrics(test['strategy_return_fees'].dropna())

 

Here come two ugly graphs, the sequel nobody asked for (but you’re getting anyway):

Test results vs B&H
Test results with/without fees

Well, if we had invested in the strategy, we would have had a lot of money... at the beginning😅. Because after that, we would have spent 2 years losing it all, only to regain hope in the final months. Anyway, my peacemaker would have given up long before that hope returned.

As for transaction fees, we don’t have many trades, so the curves are generally quite similar. To put some numbers on the problems:

15.0 ------ Statistics ------ Final Return: 5.40% Annualized Arithmetic Mean Return: 2.09% Annualized Geometric Mean Return: 1.81% Win Rate: 46.17% Sharpe Ratio: -0.25 Sortino Ratio: -0.42 Maximum Drawdown: -11.35% Maximum Drawdown Duration: 517 days Value at Risk (1%): -1.13% Conditional Value at Risk (1%): -1.39% --------------------------------

In the end, pulling off a 5.4% return over 2.5 years isn’t exactly setting the world on fire—especially when US Treasury Bonds are offering around 4% annually, risk-free. At this point, you might remember the article "What is a Trading Bot?" where we discussed the importance of relevance in trading bots 😉. So, unless you’re a seasoned trader or a full-blown risk junkie with a penchant for gambling, you might want to rethink your strategy a little...

And just to drive the point home: I hope I’ve managed to show you that an SMA cross strategy isn’t exactly going to outshine the market (not even with those perfect periods or Fibonacci golden ratio timeframes). All those people promising golden returns with EMA techniques, golden triangles, or EMA crosses on TikTok—well, you might want to take that with a grain of salt!


Final Tips and Next Steps

Before we wrap up, a solemn vow: For future backtests, I’ll try really hard to make the graphs less… interpretive art and more actual financial analysis. (Yes, even if it means sacrificing my 2 AM caffeine-fueled “creative” charting liberties. Progress, not perfection. 😅)

Now, to recap the key steps you definitely shouldn’t skip:

  1. Import and clean the data
  2. Split the data into training and test sets for backtesting
  3. Create a strategy and convert it into buy and sell positions
  4. Test the strategy on the training set
  5. Analyze the results to evaluate the strategy's potential and think about improvements
  6. Test on the test set to validate the backtest

Remember: backtesting is primarily a validation process, not a tool for blind exploration. It’s crucial to thoroughly analyze all metrics and justify the viability of a trading bot. Sometimes, a simpler or less risky solution may prove to be a better fit.

Once the backtest is validated, the next step is deploying the strategy into production. This involves mastering the API of a broker or trading platform. If you’re ready to take this step, feel free to check out the dedicated article (or hang tight—it’s coming soon! 😉).

 

Don’t hesitate to comment, share, and most importantly, code!
I wish you an excellent day and lots of success in your trading projects!
La Bise et à très vite! ✌️

 

P.S. Next article’s graphs might even have labels. Revolutionary, I know. 🎉
P.P.S. This blog remains a no-ego zone—equal parts deep dives, dumpster fires, and sometimes dad jokes. Stay tuned for chaos, clarity, and the occasional casual post !