N-day Breakout Strategy

quant
trading
Author

Ray Lai

Published

April 4, 2025

Modified

April 7, 2025

/Users/raylai/Library/Caches/org.R-project.R/R/reticulate/uv/cache/archive-v0/n27kI_pu2kgd7cyXmF2Fh/lib/python3.11/site-packages/backtesting/_plotting.py:55: UserWarning: Jupyter Notebook detected. Setting Bokeh output to notebook. This may not work in Jupyter clients without JavaScript support, such as old IDEs. Reset with `backtesting.set_bokeh_output(notebook=False)`.
  warnings.warn('Jupyter Notebook detected. '
Loading BokehJS ...
3.11.11 (main, Mar 17 2025, 21:33:08) [Clang 20.1.0 ]
/Users/raylai/Desktop/github/raylai-co-quant-blog/posts/breakout-strategy

Breakout Strategy and its variations

Breakout Strategy is a simple but effective trend-following / swing trade strategy. The rule is simple, entry when the price moves above the high or low of the past N days.

Although the strategy looks simple, there can be many variations:

  • Using Close price vs using High/Low price

  • The length of the window

  • Volatility measure

The most important thing to understand the breakout strategy is same as understanding other trend strategies.

Breakout strategy is a trend-following strategy, which means the underlying trend of an asset is the most important. The variations are only refinement to create different risk profile. For example, adding volatility filter may improve successful rate, but the risk per trade could be higher. There are always trade-offs between different variations.

Breakout strategy can also considered as “Event-driven” strategy, as if the price breaks out from the top or from the bottom, there must be something happened.

The simpliest Breakout strategy

We will first test the simpliest Breakout rule:

  • Buy when today’s high moves above the high of the past N days.

  • Sell when today’s low moves below the low of the past N days. Enter into short immediately.

  • No Stop Loss. No Profit Taking.

  • Position Sizing: 1 unit or 1 contract

Code
from importlib import reload
import strategy.BreakoutStrategy

reload(strategy.BreakoutStrategy)

from strategy.BreakoutStrategy import BreakoutStrategy
Code
start = "2015-01-01"
end = "2025-03-30"

results = []
bt_instances = []

for ticker in universe:
  
  data = tickers.tickers[ticker].history(start=start, end=end)
  
  bt = Backtest(data,
                BreakoutStrategy,
                cash=10_000)
                
  stats = bt.run(n=20)
  bt_instances.append(bt)
  
  results.append(stats)
Show the code
noise = pd.read_csv("./data/noise-40-day-2015-2025.csv")
noise.set_index("symbol", inplace=True)

returns_trend = list(map(lambda x: x["Return [%]"], results))
trades_trend = list(map(lambda x: x._trades, results))

returns_df = pd.DataFrame({'Universe': universe, 'Returns': returns_trend})
returns_df.set_index("Universe", inplace=True)
returns_df = pd.merge(returns_df, noise, left_index=True, right_index=True)
returns_df.sort_values("efficiency_ratio", ascending=False, inplace=True)

returns_df.reset_index(inplace=True)
Show the code
fig, ax = plt.subplots()
sns.barplot(returns_df, x="index", y="Returns", ax=ax)

ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Returns from 2000 to 2025 by using 20-day Breakout")
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Code
show(returns_df,
    ordering=False,
    classes="display compact cell-border",
    style="width:100%;margin:auto;font-size:12px")
index Returns asset_class efficiency_ratio
Loading ITables v2.2.5 from the internet... (need help?)

We can see that the strategy works well for low-lose assets, however, as expected, doesn’t work well for noisy market. There are 2 assets that stand out, which is Corn (ZC=F) and Gold (GC=F) - Not sure why is that but just keep it for future exploration.

Comparison with Moving averages

There are a lot of questions about to what extent the trend tracking method matters. We can have a quick comparison between different lengths of breakout and moving average trends towards our testable assets.

Testing Plan

Breakout Moving Average
Entry Rule Buy when today’s high moves above the high of the past N days. Buy when Moving Averages turns up \(\text{MA}_t > \text{MA}_{t-1} > \text{MA}_{t-2}\)
Exit / Selling Rule Sell when today’s low moves below the low of the past N days. Enter into short immediately. Sell when Moving Averages turns down \(MA_t < MA_{t-1} < MA_{t-2}\)
Position Sizing 1 unit or 1 contract 1 unit or 1 contract
Stop Loss No Stop Loss No Stop Loss
Profit Taking No Profit Taking No Profit Taking
Code
from importlib import reload
import strategy.MovingAverageStrategy

reload(strategy.MovingAverageStrategy)

from strategy.MovingAverageStrategy import MovingAverageStrategy
Code
ma_lengths = [5, 10, 20, 40, 80, 120, 200]
bo_results = []
ma_results = []

start = "2015-01-01"
end = "2025-03-30"

for ticker in universe:
  
  ticker_bo_result = []
  ticker_ma_result = []
  
  for ma in ma_lengths:
  
    data = tickers.tickers[ticker].history(start=start, end=end)
    
    bt_bo = Backtest(data,
                  BreakoutStrategy,
                  cash=10_000)
    stats_bo = bt_bo.run(n=ma)
    ticker_bo_result.append(stats_bo)
  
    bt_ma = Backtest(data,
                  MovingAverageStrategy,
                  cash=10_000)
    stats_ma = bt_ma.run(n=ma)
    ticker_ma_result.append(stats_ma)
  
  bo_results.append(ticker_bo_result)
  ma_results.append(ticker_ma_result)
Code
bo_all_performances = []
ma_all_performances = []

for i, symbol in enumerate(universe):

  n = []
  return_pcts = []
  win_rates = []
  profit_factors = []
  num_trades = []
  sharpe_ratios = []
  expectancies = []
  average_holding_period = []
  
  for result in bo_results[i]:
    n.append(result._strategy.n)
    return_pcts.append(result["Return [%]"])
    win_rates.append(result["Win Rate [%]"])
    profit_factors.append(result["Profit Factor"])
    num_trades.append(result["# Trades"])
    sharpe_ratios.append(result["Sharpe Ratio"])
    expectancies.append(result["Expectancy [%]"])
    average_holding_period.append(result["Avg. Trade Duration"])
    
  result_df = pd.DataFrame({
    "Length": n,
    "Return %": return_pcts,
    "Win Rate": win_rates,
    "Profit Factors": profit_factors,
    "Number of Trades": num_trades,
    "Sharpe Ratio": sharpe_ratios,
    "Avg. Holding Period": average_holding_period
    })
    
  result_df["Symbol"] = symbol
  result_df["Avg. Holding Period"] = result_df["Avg. Holding Period"].dt.days
  
  bo_all_performances.append(result_df)
  
  n = []
  return_pcts = []
  win_rates = []
  profit_factors = []
  num_trades = []
  sharpe_ratios = []
  expectancies = []
  avg_holding_period = []
  
  
  # MA
  for result in ma_results[i]:
    n.append(result._strategy.n)
    return_pcts.append(result["Return [%]"])
    win_rates.append(result["Win Rate [%]"])
    profit_factors.append(result["Profit Factor"])
    num_trades.append(result["# Trades"])
    sharpe_ratios.append(result["Sharpe Ratio"])
    expectancies.append(result["Expectancy [%]"])
    avg_holding_period.append(result["Avg. Trade Duration"])
    
  result_df = pd.DataFrame({
    "Length": n,
    "Return %": return_pcts,
    "Win Rate": win_rates,
    "Profit Factors": profit_factors,
    "Number of Trades": num_trades,
    "Sharpe Ratio": sharpe_ratios,
    "Avg. Holding Period": avg_holding_period
    })
    
  result_df["Symbol"] = symbol
  result_df["Avg. Holding Period"] = result_df["Avg. Holding Period"].dt.days
  
  ma_all_performances.append(result_df)
  
bo_all_performances = pd.concat(bo_all_performances).set_index("Symbol")
bo_all_performances = pd.merge(bo_all_performances, noise, left_index=True, right_index=True)
bo_all_performances["Strategy"] = "Breakout"

ma_all_performances = pd.concat(ma_all_performances).set_index("Symbol")
ma_all_performances = pd.merge(ma_all_performances, noise, left_index=True, right_index=True)
ma_all_performances["Strategy"] = "MA Trend"

# Sort by Noise
bo_all_performances.sort_values("efficiency_ratio", ascending=False, inplace=True)
ma_all_performances.sort_values("efficiency_ratio", ascending=False, inplace=True)

all_performances = pd.concat([bo_all_performances, ma_all_performances]).reset_index(names="Universe")

Returns

Code
# Plot Chart
n = 5

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 10

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 20

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 40

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 80

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 120

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 120

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs 20-day MA" % n)
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs 20-day MA" % n)
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Returns

Code
# Plot Chart
n = 200

fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Return %", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Return Percent from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Number of Trades

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Number of Trades", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Avg. Holding Period

Code
# Plot Chart
fig, ax = plt.subplots()
sns.barplot(all_performances.query('Length == %d' % n), x="Universe", y="Avg. Holding Period", hue="Strategy", ax=ax)


ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_title("Number of trades from 2015 to 2025 by using %s-day Breakout vs %s-day MA" % (n, n))
ax.set_xlabel("Assets (Ranked by efficiency ratio from high to low)")

plt.show()

Learnings

In terms of the return %, both Breakout and Moving Averages strategy they don’t differ a lot. However, they have a very distinctive risk profile.

Similarities Differences
The returns of both systems go with a similar way. For assets that are not profitable for one strategy, it is likely to be not profitable for another strategy as well Breakout strategy has a lot less number of trades than Moving Average strategy
For both strategies, in general (ignore ZS and GC), longer the trend, the better the performance Breakout strategy holds a lot more longer on the assets, especially on the longer moving average, than MA trend strategy.
They both work well for trending assets, in general, and do poorly for noisy assets
Code
melted_performances = pd.melt(all_performances,
                        id_vars=["Universe", "Length", "Strategy"],
                        value_vars=['Profit Factors',
                                    'Win Rate',
                                    'Number of Trades',
                                    'Avg. Holding Period'],
                        value_name='metric')

with pd.option_context("display.float_format", "{:,.2f}".format):
  show(melted_performances
    .query('Universe == "IYY" | Universe == "IWM"')
    .pivot_table(
      index=["Length"],
      columns=["Universe", "Strategy", "variable"],
      values="metric"
    ),
    ordering=False,
    classes="display compact cell-border",
    style="width:100%;margin:auto;font-size:12px")
Universe IWM IYY
Strategy Breakout MA Trend Breakout MA Trend
variable Avg. Holding Period Number of Trades Profit Factors Win Rate Avg. Holding Period Number of Trades Profit Factors Win Rate Avg. Holding Period Number of Trades Profit Factors Win Rate Avg. Holding Period Number of Trades Profit Factors Win Rate
Length
Loading ITables v2.2.5 from the internet... (need help?)