Backtest Results Revealed: Is the London Breakout Strategy Worth It?

The London Breakout Strategy is a popular intraday trading method designed to capitalize on the high liquidity and volatility of the FOREX market during the London session. As the London market opens, it overlaps with the Asian session’s close and the upcoming U.S. session, creating significant price movements. This strategy focuses on identifying a trading range formed during the quieter Asian session and entering trades when the price breaks above or below this range.

How does it work

Since this strategy relies on high activity, the pairs that should be used are the most liquid ones. GBPUSD, EURUSD, and EURGBP are preferable, while other GBP pairs can also give us profits like GBPAUD or GBPCAD.

To understand the strategy better, let’s take a look at the below chart:

TradingView with Indicator Trading Sessions

The blue background is the Asia Session (Sydney and Tokyo markets are open), while the yellow is the London Session.

  • At point 1 (red circle), you will notice that the price goes below the lower price of the Asian Session. This is the London Breakout!
  • At this point, we Sell (short)
  • We put the stop loss to the most recent pivot high and take profit to a risk-reward of around 1.5
  • The price has reached point 2 (red circle), our take profit

In case the breakout is upwards, we go long and the SL is the last pivot low.

Needless to say, this is not always the case, and this is what we will backtest in this article to see the success rate of the strategy.

Let’s Code

For our back test, I will use the backtesting.py library. I consider this library the best backtesting library when you want to try out your idea and don’t mind the performance of large computations. If you have a decent knowledge of Python but vector logic is a bit out of your league, you can easily use backtesting.py and get quick results.

First, let’s do our imports:

import pandas as pd
import numpy as np
from enum import Enum
import pytz
from datetime import datetime, time

from backtesting import Backtest, Strategy
from backtesting import set_bokeh_output
set_bokeh_output(notebook=False)




Then, we will get our dataset of 1-minute candles for EURUSD from April 2024 till the 10th of February 2025, which is the day I started writing this article. I got the 1 minute ones, so we are as close as possible to a live price action. The data come from Metatrader5 with a few clicks. If you are interested in how you get so much data for free, you can check out my below article:

6 Million Candles and Counting: Harnessing the Power of Historical Data with MT5

Learn how to download financial data manually or with Python using MetaTrader 5 — ideal for backtesting, analysis, and…

wire.insiderfinance.io

df = pd.read_csv('EURUSD_M1.csv', delimiter='\t')
df.rename(columns={'<DATE>': 'date', '<TIME>': 'time', '<OPEN>': 'Open', '<HIGH>': 'High', '<LOW>': 'Low', '<CLOSE>':'Close', '<TICKVOL>':'tick_volume', '<VOL>':'volume', '<SPREAD>': 'spread'}, inplace=True)
# Combine 'date' and 'time' columns into a single datetime column
df['time'] = pd.to_datetime(df['date'] + ' ' + df['time'])
df['time'] = df['time'].dt.tz_localize('GMT')
# Set the new datetime column as the index
df.set_index('time', inplace=True)
# Drop the original 'date' and 'time' columns
df.drop(columns=['date','tick_volume','spread','volume'], inplace=True)
df

As you can see, we have more than 320K 1-minute candles for that almost 10 months.

It is time to enhance our OHLC dataframe with the information we will need during the backtesting. First, we will add boolean columns for when each market is open. For example, the column “LondonOpen” is True when the London is open. Note here that because of the strategy, we will have a column “AsiaOpen”, which will be True when Sydney and Tokyo are open but False during the overlapping period of Tokyo and London being open since we need to calculate our highs and lows when London Opens and not before.

# Create an enum for the forex markets
class ForexMarket(Enum):
    LONDON = "London"
    TOKYO = "Tokyo"
    SYDNEY = "Sydney"
    NEW_YORK = "New York"

# Function to check if a market is open
def is_market_open(market: ForexMarket, gmt_time: datetime) -> bool:

    # Ensure gmt_time is timezone-aware
    if gmt_time.tzinfo is None:  # If it's a naive datetime
        gmt_time = pytz.UTC.localize(gmt_time)

    # Define DST start and end dates in UTC
    dst_start = datetime(gmt_time.year, 3, 31 - (datetime(gmt_time.year, 3, 31).weekday() + 1) % 7, 1, tzinfo=pytz.UTC)
    dst_end = datetime(gmt_time.year, 10, 31 - (datetime(gmt_time.year, 10, 31).weekday() + 1) % 7, 1, tzinfo=pytz.UTC)

    # Define market hours in GMT depending on DST
    if dst_start <= gmt_time < dst_end:
        # SUMMER TIME
        market_hours = {
            ForexMarket.LONDON: (time(7, 0), time(16, 0)),  
            ForexMarket.TOKYO: (time(23, 0), time(8, 0)),   
            ForexMarket.SYDNEY: (time(22, 0), time(7, 0)),  
            ForexMarket.NEW_YORK: (time(12, 0), time(21, 0))
        }
    else:
        # WINTER TIME
        market_hours = {
            ForexMarket.LONDON: (time(8, 0), time(17, 0)),  
            ForexMarket.TOKYO: (time(0, 0), time(9, 0)),    
            ForexMarket.SYDNEY: (time(21, 0), time(6, 0)),  
            ForexMarket.NEW_YORK: (time(13, 0), time(22, 0))
        }

    # Check if it's a weekend
    if gmt_time.weekday() >= 5:  # Saturday or Sunday
        return False
    # Get the market's open and close times
    open_time, close_time = market_hours[market]

    # Handle markets with overnight hours (e.g., Sydney)
    if open_time < close_time:
        return open_time <= gmt_time.time() < close_time
    else:
        return gmt_time.time() >= open_time or gmt_time.time() < close_time

# Create boolean columns for each Forex market
df['LondonOpen'] = df.index.map(lambda x: is_market_open(ForexMarket.LONDON, x))
df['TokyoOpen'] = df.index.map(lambda x: is_market_open(ForexMarket.TOKYO, x))
df['SydneyOpen'] = df.index.map(lambda x: is_market_open(ForexMarket.SYDNEY, x))
df['NewYorkOpen'] = df.index.map(lambda x: is_market_open(ForexMarket.NEW_YORK, x))
# Asian Session is considered Tokyo and Sydney
df['AsiaOpen'] = df['TokyoOpen'] | df['SydneyOpen']
# In our case we will need the Asia Open but excluding London
df['AsiaOpen'] = df['AsiaOpen'] & ~df['LondonOpen']
df




We will create columns to keep the market open for each session.

# Function to compute continuous True indices
def identity_market_sessions(df, boolean_column):
    continuous_true = []
    current_index = None

    for index, value in df[boolean_column].items():  # Use items() instead of iteritems()
        if value:  # If the value is True
            if current_index is None:  # Start of a new group
                current_index = index
            continuous_true.append(current_index)
        else:  # Reset for False values
            current_index = None
            continuous_true.append(None)

    return continuous_true

# Add the new column to the DataFrame
df['TokyoSession'] = identity_market_sessions(df, 'TokyoOpen')
df['SydneySession'] = identity_market_sessions(df, 'SydneyOpen')
df['NewYorkSession'] = identity_market_sessions(df, 'NewYorkOpen')
df['AsianSession'] = identity_market_sessions(df, 'AsiaOpen')
df['LondonSession'] = identity_market_sessions(df, 'LondonOpen')
df




The column “AsianSession”, is essential to compute the high and low of the Asian session in 2 separate columns:

# I am grouping at the Asian Session to get the highs and lows,
# then I forward fill the values till the next session so i have this information when Asia is closed
# For safety I keep those values only for Asia and London for this strategy
df['HighAsianSession'] = df.groupby('AsianSession')['High'].transform('max').where(df['AsiaOpen'])
df["HighAsianSession"] = df["HighAsianSession"].fillna(method='ffill')
df.loc[(df["LondonOpen"] == False) & (df["AsiaOpen"] == False), "HighAsianSession"] = np.nan
df['LowAsianSession'] = df.groupby('AsianSession')['Low'].transform('min').where(df['AsiaOpen'])
df['LowAsianSession'] = df["LowAsianSession"].fillna(method='ffill')
df.loc[(df["LondonOpen"] == False) & (df["AsiaOpen"] == False), "LowAsianSession"] = np.nan
df




To understand (and test) what we have done so far, I will plot the dataframe with the highs and lows of Asia.

import mplfinance as mpf
import matplotlib.pyplot as plt

# Define start and end datetimes
start_datetime = pd.to_datetime('2025-01-13 00:00:00').tz_localize('GMT')
end_datetime = pd.to_datetime('2025-01-17 18:00:00').tz_localize('GMT')
data = df.loc[start_datetime:end_datetime].copy()


# Prepare the data for mplfinance
mpf_data = data[['Open', 'High', 'Low', 'Close']]


high_asian = data.loc[data['AsiaOpen'], 'HighAsianSession'].astype(float)

low_asian = data[data['AsiaOpen']]['LowAsianSession'].dropna().astype(float)
high_asian_during_London = data[data['LondonOpen']]['HighAsianSession'].dropna().astype(float)
low_asian_during_London = data[data['LondonOpen']]['LowAsianSession'].dropna().astype(float)

# Ensure indices match between additional plots and candlestick data, leaving unmatched as null
high_asian = high_asian.reindex(mpf_data.index)
low_asian = low_asian.reindex(mpf_data.index)
high_asian_during_London = high_asian_during_London.reindex(mpf_data.index)
low_asian_during_London = low_asian_during_London.reindex(mpf_data.index)

# # Create additional lines for HighAsianSession and LowAsianSession
add_plots = [
    mpf.make_addplot(high_asian, color='green', linestyle='-', width=2),
    mpf.make_addplot(low_asian, color='red', linestyle='-', width=2),
    mpf.make_addplot(high_asian_during_London, color='green', linestyle=':', width=1),
    mpf.make_addplot(low_asian_during_London, color='red', linestyle=':', width=1)
]

# Plot the candlestick chart with additional lines
mpf.plot(mpf_data, type='candle', style='charles', addplot=add_plots,
         title='EURUSD Candlestick with Asian Session High/Low',
         ylabel='Price', volume=False)

plt.show()




I plotted only for 5 days in January (for the plot to be more straightforward to see) the High (green) and Low (red) prices for each Asian Session. The solid line is during the Asian session, while with the dotted line, I extend the line into the London Session.

Let’s develop our strategy:

def calculate_pip_difference(from_rate, to_rate, pip_point = 4):
    # Calculate the pip size based on the pip point
    pip_size = 10 ** -pip_point

    # Calculate the difference in pips
    pip_difference = (to_rate - from_rate) / pip_size
    return pip_difference

def calculate_new_rate(rate, pips, pip_point=4):
    # Calculate the pip size based on the pip point
    pip_size = 10 ** -pip_point

    # Calculate the new rate
    new_rate = rate + (pips * pip_size)
    return new_rate

class LondonBreakOutStrategy(Strategy):

    # # params
    pivot_high_low_bars = 60
    ratio_profit_loss = 1.4
    total_candles = 0
    # if SL is less than some pips, abort or reset the SL
    min_SL_pips_trade = 25
    set_sl_to_min = True
    # if TP is more than some pips, reset TP
    max_TP_pips_trade = 75
    set_tp_to_max = True
    # check Asian Session Range to trade or not
    trade_in_range_of_asian_session = True
    min_asian_session_size = 10
    max_asian_session_size = 50

    # other parameters
    pair_pip_point = 4 # change to 2 if JPY is in the pair
    initial_equity = 0


    def did_not_do_a_trade_today(self):
        if self.position.size != 0:
            return False
        for trade in self.closed_trades:
            if trade.entry_time.date() == self.data.index[-1].date():
                return False
        return True

    def init(self):

        self.total_candles = len(self.data)

    def next(self):

        # ==============================
        # FILTERS
        # ==============================

        filter_ok = True # Will move to False if I should filter out the below

        # Check Asian Session Size and return if needed
        asian_session_size = (self.data.HighAsianSession[-1] - self.data.LowAsianSession[-1]) * (10**self.pair_pip_point)
        if self.trade_in_range_of_asian_session:
            if (asian_session_size < self.min_asian_session_size) or (asian_session_size > self.max_asian_session_size):
                filter_ok = False

        # ==============================
        # TRADE LOGIC
        # ==============================

        if filter_ok and self.position.size == 0:

            # when london session opens and close is above the Asian session high, we buy
            if self.data.LondonSession[-1] and self.data.Close[-1] > self.data.HighAsianSession[-1] and self.position.size == 0 and self.did_not_do_a_trade_today():
                # Identify the last pivot low (minimum within the last bars)
                SL_rate = min(self.data.Low[-self.pivot_high_low_bars:])
                TP_rate = self.data.Close[-1] + self.ratio_profit_loss * (self.data.Close[-1] - SL_rate)

                # calculate pips
                SL_pips = calculate_pip_difference(self.data.Close[-1], SL_rate)
                TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)

                # check SL
                if abs(SL_pips) < self.min_SL_pips_trade:
                    # check if abord or set new SL TP
                    if self.set_sl_to_min:
                        SL_rate = calculate_new_rate(self.data.Close[-1],-self.min_SL_pips_trade)
                        TP_rate = self.data.Close[-1] + self.ratio_profit_loss * (self.data.Close[-1] - SL_rate)
                        SL_pips = calculate_pip_difference(self.data.Close[-1], SL_rate)
                        TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)
                    else:
                        pass

                # check TP
                if abs(TP_pips) > self.max_TP_pips_trade:
                    # check if I should change the TP - otherwise leave it as is
                    if self.set_tp_to_max:
                        TP_rate = calculate_new_rate(self.data.Close[-1], self.max_TP_pips_trade)
                        TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)

                try:
                    self.buy(sl=SL_rate, tp=TP_rate)
                except Exception as e:
                    print(e)
                    pass
            elif self.data.LondonSession[-1] and self.data.Close[-1] < self.data.LowAsianSession[-1] and self.position.size == 0 and self.did_not_do_a_trade_today():
                # Identify the last pivot high (maximum within the last bars)
                SL_rate = max(self.data.High[-self.pivot_high_low_bars:])
                TP_rate = self.data.Close[-1] - self.ratio_profit_loss * (SL_rate - self.data.Close[-1])

                # calculate pips
                SL_pips = calculate_pip_difference(self.data.Close[-1], SL_rate)
                TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)

                # check SL TP
                if abs(SL_pips) < self.min_SL_pips_trade:
                    # check if abord or set new SL TP
                    if self.set_sl_to_min:
                        SL_rate = calculate_new_rate(self.data.Close[-1],self.min_SL_pips_trade)
                        TP_rate = self.data.Close[-1] - self.ratio_profit_loss * (SL_rate - self.data.Close[-1])
                        SL_pips = calculate_pip_difference(self.data.Close[-1], SL_rate)
                        TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)
                    else:
                        pass

                # check TP
                if abs(TP_pips) > self.max_TP_pips_trade:
                    # check if I should change the TP - otherwise leave it as is
                    if self.set_tp_to_max:
                        TP_rate = calculate_new_rate(self.data.Close[-1], -self.max_TP_pips_trade)
                        TP_pips = calculate_pip_difference(self.data.Close[-1], TP_rate)


                try:
                    self.sell(sl=SL_rate, tp=TP_rate)
                except Exception as e:
                    print(e)
                    pass

        #check for last candles
        if len(self.data) == self.total_candles:
            # if there is a position close it
            if self.position.size > 0:
                self.position.close()




Let me explain now what the strategy does in detail:

Function next() runs for each and every candle of the dataframe.

  • First, it calculates the pips between the low and high of the Asian session. I have set to trade between 10 and 50 pips. The reason is that the whole premise of the strategy is that the Asian Market is “quiet”, and the action will start with London opening. So, if there was a significant change in the price during the Asian session, I don’t want to trade!
  • Also, we checked that there is no open position, so we don’t trade again.
  • Then we go long in case the price of the previous candle’s close is above the highest point of Asia or short in case it is lower than the Asian Low.
  • Some note on the above. For the SL, I do not calculate the pivot point but the lower or higher point in the last hour. The take profit is calculated with a ratio of 1.4. Also, you will notice in the code that I check not to trade in the same day twice. This will prevent a position from being closed immediately and trade again on the same day.
  • Also, if the price is very flat during the Asian Session and the stop loss is too small, such as 25 pips, I don’t trade.
  • Additionally, if the take profit is too high, more than 75 pips, I set it to 75 pips.
  • At the end, I close any remaining position, if applicable, during the last processed candle.

Finally, the time has come! Let’s run the back test.

Note: There is no slipage or commission calculated. This is a mock backtesting to check if there is any reason to continue developing this strategy

# run single back test
bt = Backtest(df, LondonBreakOutStrategy, cash=10_000)
stats = bt.run()
stats

#######################################################################
Start                     2024-04-01 00:00...
End                       2025-02-10 06:09...
Duration                    315 days 06:09:00
Exposure Time [%]                    45.56185
Equity Final [$]                   10747.4198
Equity Peak [$]                   10810.19735
Return [%]                             7.4742
Buy & Hold Return [%]                -4.39418
Return (Ann.) [%]                     8.01625
Volatility (Ann.) [%]                 4.34506
CAGR [%]                              5.93099
Sharpe Ratio                          1.84491
Sortino Ratio                         3.16134
Calmar Ratio                          4.65741
Max. Drawdown [%]                    -1.72118
Avg. Drawdown [%]                    -0.07471
Max. Drawdown Duration       59 days 20:01:00
Avg. Drawdown Duration        0 days 16:17:00
# Trades                                  165
Win Rate [%]                         49.09091
Best Trade [%]                        0.69025
Worst Trade [%]                      -0.62355
Avg. Trade [%]                         0.0437
Max. Trade Duration          10 days 04:59:00
Avg. Trade Duration           0 days 19:11:00
Profit Factor                         1.35299
Expectancy [%]                        0.04415
SQN                                   1.86436
Kelly Criterion                       0.12803




This library provides many metrics, so let’s see what is going on.

  • The return is 7.47% for the 10 months of backtesting — Usually, FOREX is traded with leverage, so with decent leverage like 10x, this return can be translated to 74.7% return
  • The win rate is 49%, which is reasonable considering the risk-reward of 1:1.4
  • Max drawdown is suitable. It is a bit more than 1.72% — but, with a 10x leverage, it will also be translated to 17.2%
  • Volatility is 4.34%, indicating a relatively stable performance without wild swings.
  • Sharpe Ratio of 1.84 indicates good risk-adjusted performance, with returns exceeding volatility-related risks.
  • Sortino Ratio: 3.16 suggests that the strategy avoids significant negative returns while delivering consistent gains.

Let’s now plot our back test results.

bt.plot()

Backtesting amazingly uses the Bokeh library since you have a complete overview of your backtest while you can zoom in and inspect each and every trade autonomously, as shown below.

Key Insights

  • A simple backtest of the London Breakout Strategy looks very promising. The premise of the strategy is legit and not a myth 😉
  • The strategy shows promise with a stable performance and limited drawdowns, especially when leveraged appropriately.
  • It avoids overtrading by implementing strict filters and limiting trades to once a day.
  • The Sharpe and Sortino ratios suggest good risk-adjusted returns, making it suitable for further refinement.

Next Steps

  • Consider incorporating slippage and commission costs for more realistic results.
  • Explore optimization of SL/TP levels and Asian session range thresholds.
  • Test on additional currency pairs to assess robustness across markets.
  • Add additional logic and parameters like confirmation candle before trade
  • Do some forward-back testing with the optimised results

Thank you for reading, and I hope you enjoyed it. If you did, I would propose to add it in your lists for future reference.

You can find the jupyter notebook with the Python code here.