On their first day on the job, every market participant learns that the key to survival is to have a trading edge. Everyone talks about it, but no one really articulates what it boils down to. In this chapter, we will unveil one of the most well-guarded secrets in the finance industry. Behind the curtain is not an old man pulling some levers, it is only a simple formula every middle school student knows. Then, we will decompose this formula into two distinct modules: signal and money management.
Within the signal module, we will explore how to time entries and exits, and the properties of the two strategy types that seem to provide a trading edge: trend following and mean reversion. We will visit a popular mean reversion strategy called pairs trading.
We will continue this discussion in before covering the money management module in more depth in .
Along the way, we will cover the following topics:
You can access color versions of all images in this chapter via the following link: .
You can also retrieve source code for this chapter via the book's GitHub repository. To access the repository link, follow the steps in the Download the example code files section in the Preface.
For this chapter and the rest of the book, we will be working with a few more libraries than usual. If they are not installed on your machine, you know the drill:
pip install –upgrade So, please remember to import them first:
# Import Libraries import pandas as pd import numpy as np import arcticdb as adb from arcticdb import LibraryOptions from dateutil.relativedelta import relativedelta import requests from io import StringIO import yfinance as yf %matplotlib inline import matplotlib.pyplot as plt import mplfinance as mpf import statsmodels.api as sm from statsmodels.tsa.stattools import coint, adfuller from statsmodels.tools.tools import add_constant from statsmodels.regression.linear_model import OLS We have imported the libraries we will be working with. Now, let's define this elusive concept called trading edge.
"Information is not knowledge."
– Albert Einstein
Who said that science fiction hasn't found its way into the austere world of finance? Ask any hedge fund manager about their edge and you will enter a world of crusaders against corporate cabals, financial Sherlock Holmeses patiently piecing the information puzzle together, and visionaries investing in the next new [insert the next disruptive technological buzzword here…].
Everyone in the trading business will say that you need an edge to make money. Yet, they will never tell you how to build a sustainable one, presumably for fear that dissemination could erode theirs. Understandably, the trading edge has been this mysterious secret sauce.
There are broadly three common types of edge: technological, information, and statistical, which will be considered over the next few sections.
Any retail trader today has access to more information and computing power than any top-tier institutional investor 10 years ago. Anyone with a little bit of Python skill can scrape data off the internet and process it via machine learning or artificial intelligence much faster than any traditional research department. A hedge fund-grade cloud infrastructure costs less than 50 dollars per month. Anyone can learn how to code, analyze data, trade, invest, and manage a portfolio for free on YouTube, Quora, or any other learning platform. Technology has brought democracy to the world of finance.
Everyone wants to be Jim Simons, but no one wants to take care of the plumbing. When asked what the number one problem facing programmatic traders is, a third of responses were related to data processing, storage, and server management. Market participants need to be expert system engineers to ensure continuous connectivity. Then, they have to polish their development skills to write code that not only machines but more importantly humans can read.
The first iteration of a strategy is like the stone imprisoning Michelangelo's David. It takes a lot of patient carving to reveal the ephebe. When written poorly, it takes a lot more time to disentangle the other Italian cultural heritage: spaghetti. After all this is taken care of, market participants can finally focus on designing alpha-generating strategies. Then comes a whole other set of difficulties: scaling in/out, risk management, all the juicy stuff this book is about. The transmutation of billion-dollar ideas into bug-free code is still a daunting technological hurdle. Bottom line, it takes enormous amounts of work to make money work for you.
A simple analogy would be the personal computer industry circa Apple 1. Everyone wanted to play Space Invaders. Only a rare die-hard bunch of people were willing to build their own computers to play games. Today, every other Bitcoin enthusiast wants to learn algorithmic trading. Very few people are willing to put in sweat, blood, and tears to build their own platform. This book will give you the building blocks to build your own strategy. AI will considerably speed up the learning process. You cannot however "vibe code" your way to a robust trading edge. You need proper foundations to ask the right questions and come up with a solid architecture. This is what this book intends to do.
The industry has traditionally operated on the belief that information gives an edge. This has been either privileged information, like getting access to top management and better treatment by analysts, or its more sinister cousin, inside information. Any information edge gets arbitraged away fast.
The information edge never really made a difference in the first place. If all it took to beat the Street was a better information edge, then, in theory, the metaphorical animal kingdom of big trading houses with their parliaments of analysts, schools of Ph.D.s, prides of fund managers, murders of traders, drifts of sell-side researchers, herds of brokers, and kaleidoscopes of expert opinions, with corporate access on speed-dial and enough money to bail out half a continent, would consistently outperform the market. In practice, venerable institutions have limped behind low-tech, plain-vanilla index funds Exchange-Traded Funds (ETFs) for every year on record. Information gives only a temporary edge that gets arbitraged increasingly quickly over time. Information wants to be democratized.
A statistical edge does not get arbitraged away easily. It exists and persists by design. It is not "what" but "how" you trade that matters. In the next sections, we will look at ways to build a robust statistical trading edge. We will not try to tell you how to pick stocks differently, but how to squeeze more juice out of those you have already picked.
"What gets measured gets managed."
– Peter Drucker
Trading edge is not a story. A trading edge is a number, and the formula is composed of a few functions:
def expectancy(win_rate,avg_win,avg_loss): return win_rate * avg_win + (1-win_rate) * avg_loss def geometric_expectancy(win_rate,avg_win,avg_loss): return (1+avg_win) **win_rate*(1+avg_loss) **(1-win_rate)-1 def kelly(win_rate,avg_win,avg_loss): return win_rate / np.abs(avg_loss) - (1-win_rate) / avg_win We will take off where we left in , regime definition. We will simply re-use what we built. We calculated the regime across the entire S&P 500 for the absolute and relative series. We stored the data in arcticDB. We will now retrieve this data. First, we recycle the initialization function from the previous chapter to retrieve the data.
def initialise_adb_library_local(uri_path, library_name): uri = f"lmdb://{uri_path}" # this will set up the storage using the local file system ac = adb.Arctic(uri) library = ac.get_library(library_name, create_if_missing=True) return library _adb_library_local(): we construct LMDB URI path from directory and library name, create Arctic connection, and get or create named library with dynamic schema support. Next, this function creates a df for a single field across an investment universe:
def adb_concat_single_column(library, symbols, column_name): symbols_list = [symbol for symbol in symbols if symbol in library.list_symbols()] comp_list_from_adb = [library.read(symbol, columns=[column_name]).data.rename(columns={column_name: symbol}) for symbol in symbols_list] return pd.concat(comp_list_from_adb, axis=1) adb_concat_single_column(): Filter symbol list to only include those present in the library to avoid missing-data errors; read specified column for each valid symbol and rename to ticker name for clarity; concatenate all series horizontally into wide-format DataFrame. Both functions enable efficient data pipelining: initialize once, then retrieve any column(s) across the full S&P 500 universe in a single call. We initialize the S&P 500 library we stored in the previous chapter. We want to retrieve the 'Close' price. We do not need to retrieve the relative series to calculate pairs.
library_SP500 = initialise_adb_library_local('data', 'SP500') print('S&P 500: ',len(library_SP500.list_symbols()[:]), end =', ') px_df = px_df = adb_concat_single_column(library_SP500, library_SP500.list_symbols(), 'Close') print(px_df.shape) We create a mock strategy for demonstration purposes.
This is by no means a production-grade simulation. The objective is simply to explain concepts such as trading edge. At this stage, we do not need the full Michelin star 5-course menu to explain mayonnaise.
Let's use a ticker we saw in a previous chapter and create a df. It is only fitting we use General Electric (GE) to illustrate gain expectancy (GE).
ticker = 'GE' ticker_raw_data = library_SP500.read(ticker).data Next, we will recycle the relative score we calculated in Chapter 4. The score is a composite number made from several regime methods. There was no attempt at optimizing either the parameters, such as breakout or moving average duration, or even weights in the weighted average calculation. If the score is above 0.5, go long. If the score is below -0.5, go short. It doesn't get more rustic than that.
data = ticker_raw_data[['score_rel','rClose', 'Close']].copy() data['longshort'] = np.where(data['score_rel'] > 0.5 , 1, np.where(data['score_rel'] < -0.5, -1, 0)) data['shifted_longshort'] = data['longshort'].shift(+1) data[-400:-50][['longshort','shifted_longshort',]].plot(figsize=(16, 3), style=['k', 'orange'], title=f'{ticker}: Long/Short Signal', grid=True) Converting regime scores into trading signals and calculating daily/cumulative returns for both long-short strategy and buy-and-hold reference positions.
score_rel), relative close price (rClose), and absolute close from raw ticker data; create binary long/short signals where score > 0.5 → long (+1), score < -0.5 → short (-1), else flat (0).This is better illustrated with the following graph:

Figure 5.1: GE Long/Short signals shifted forward
Signals are in black. Trading dates are shifted by one day. This makes manipulation easier.
Next, let's calculate relative returns using log returns.
data['log_returns_rel_1D'] = np.log(data['rClose'] / data['rClose'].shift()) data['cumul_rel_returns'] = data['log_returns_rel_1D'].cumsum().apply(np.exp) - 1 data[['rClose', 'cumul_rel_returns','score_rel',]].plot(figsize=(16, 4), secondary_y=['rClose'], style=['k', 'grey','y:'], title=f'{ticker}: relative Series, Returns and Score', grid=True) Calculating daily log and cumulative returns.

Figure 5.2: GE Long/Short relative series, returns and score
The simplest technique to simulate Long and Short is to assign positive and negative signs, respectively. We multiply the shifted column by the return, tally them up, and voila!
data['longshort_rel_1D_returns'] = data['log_returns_rel_1D'] * data['shifted_longshort'] data['longshort_cumul_rel_returns'] = data['longshort_rel_1D_returns'].cumsum().apply(np.exp) - 1 data[[ 'cumul_rel_returns', 'longshort_cumul_rel_returns','score_rel', 'shifted_longshort']].plot(figsize=(16, 5), secondary_y= ['shifted_longshort','score_rel'], style=['k', 'grey', 'y:','c:'], title=f'{ticker}: Cumulative Relative Returns, L/S Returns, Score Rel, L/S Score', grid =True) This produces the following chart.

Figure 5.3: GE Cumulative Relative Returns, L/S Returns, Score Rel, L/S Score
This strategy tends to give back a lot of the gains during sideways markets. There are lots of false positives, which erode the equity curve. This is something we will come back to later in this chapter.
Next, let's calculate the gain expectancy. We will first calculate and visualize the daily profits and losses.
def daily_profits(daily_returns): profits = daily_returns.copy() profits[profits < 0] = np.nan return profits def daily_losses(daily_returns): losses = daily_returns.copy() losses[losses > 0] = np.nan return losses winning_days = daily_profits(data['longshort_rel_1D_returns']) losing_days = daily_losses(data['longshort_rel_1D_returns']) plt.figure(figsize=(15, 3)) plt.plot(winning_days, 'g', label='Winning Days') plt.plot(losing_days, 'r', label='Winning Days') plt.title(f'{ticker}: Winning & Losing Days', fontsize=16) plt.ylabel('Daily Profits & Losses', fontsize=14) plt.grid(True, linestyle='-', alpha=0.6) plt.show() We create two series, winning days and losing days. We assign NaN to loss-making days on the winning days series and vice versa for the losing days. This gives the following chart:

Figure 5.4: GE Winning and Losing Days
Decomposing daily strategy returns into winning and losing days, then visualizing profit/loss distribution to assess trade quality.
daily_profits(): we copy daily returns series and mask all losses (set < 0 → NaN) to isolate winning trades for aggregation.daily_losses(): Similarly, we copy daily returns series and mask all gains (set > 0 → NaN) to isolate losing trades for aggregation.This generates a series of profitable versus unprofitable days plotted over time, enabling visual identification of high-consequence loss periods and win streaks.
We will use those two series to calculate the win and loss rate, as well as the average win and loss rate. We will calculate the gain expectancy over a rolling period of 100 days. Percentages look neat. It is easier to grasp. Here is the code:
window = 100 win_rate = winning_days.rolling(window).count() / window loss_rate = losing_days.rolling(window).count() / window avg_win = winning_days.fillna(0).rolling(window).mean() avg_loss = losing_days.fillna(0).rolling(window).mean() plt.figure(figsize=(15, 3)) plt.plot(win_rate, 'g--', label='Winning Days') plt.plot(loss_rate, 'r--', label='Winning Days') plt.title(f'{ticker}: Win & Loss Rates', fontsize=16) plt.ylabel('Win & Loss Rates', fontsize=14) plt.grid(True, linestyle='-', alpha=0.6) plt.show() We are computing rolling-window statistics such as win and loss rate, and average win and loss over a lookback period to visualize the evolution of the gain expectancy over time.
This produces the following chart:

Figure 5.5: GE rolling Win and Loss Rates
A simple way to read the chart is that the strategy wins more often when the green line is higher than the red line. It does not necessarily mean it will be profitable. To know if it makes money, we need to superimpose the average profits and losses over the same period.
Let's also plot the average profits and losses.
plt.figure(figsize=(15, 3)) plt.plot(avg_win, 'g:', label='Winning Days') plt.plot(avg_loss, 'r:', label='Winning Days') plt.title(f'{ticker}: Average Profits & Losses', fontsize=16) plt.ylabel('Average Profits & Losses', fontsize=14) plt.grid(True, linestyle='-', alpha=0.6) plt.show() This generates the following chart:

Figure 5.6: GE Rolling Average Profits and Losses
Average profits are, in general and over time, bigger than average losses. The biggest profits are larger than the largest losses in absolute terms. This indicates that the strategy is right skewed.
In practice, it is advisable to use 2 durations. The long duration establishes that the strategy actually makes money over time. It does not have to make money all the time. It just needs to make money over time. The shorter duration captures cyclicality. It is used for asset allocation purposes. For example, the above strategy gives back profits in sideways markets. It would make sense to reduce exposures accordingly.
After this practitioner interlude, let's do a deep mathematical dive:
data['trading_edge'] = expectancy(win_rate,avg_win, avg_loss).ffill() data['geometric_expectancy'] = geometric_expectancy(win_rate ,avg_win, avg_loss).ffill() data['kelly'] = kelly(win_rate, avg_win,avg_loss).ffill() data[window *3:][['trading_edge', 'geometric_expectancy', 'kelly']].plot(secondary_y = 'kelly', style = ['b-o', 'y-', 'c'], figsize=(16, 3), title=f'{ticker}: Trading Edge, Geometric Expectancy, Kelly Criterion', grid=True) We calculate the trading edge, geometric expectancy, and Kelly criterion
.ffill()..ffill().Here is what the three lines look like.
The strategy is not always active. We therefore need to stitch the parts together using the .ffill() method.

Figure 5.7: GE Trading Edge, Geometric Expectancy, Kelly Criterion
This Flintstone pre-historic strategy seems to be working fine for GE. It does not have any of the refinements that we will slap on in the next chapters. Experienced market participants often use a partial Kelly. This is a fraction of what Kelly suggests, something like one third or half Kelly. What may be the mathematically optimal position sizing is often beyond the psychological tolerance of market participants. Those three lines on the chart are a variation on the same theme. The ingredients are the same, but the cooking is a bit different. The key takeaway is: nothing happens until the gain expectancy turns positive. Sharpe, Sortino, Jensen, Treynor, and information ratios are all well and good, but they come after the gain expectancy is positive.
All of these formulae can be decomposed into two modules:
This demystifies the trading edge as something that can be engineered from the ground up. Market participants pretend they have superior knowledge of the future thanks to their analytical superpowers and machine learning crystal balls. Meanwhile, casinos publicly advertise randomness. Still, the former have lumpy returns, and the latter consistently print money day-in, day-out. This is not a coincidence. The former believe knowledge is an edge, while casinos engineer their edge. The job of every market participant is therefore to optimize their trading edge. Over the next few chapters, we will consider how to engineer these modules and maximize the trading edge, starting with the signal module, before moving on to the money management module and position sizing.
So, without further ado, let's start with the signal module.
"You've got to know when to hold 'em,
know when to fold 'em,
know when to walk away,
and know when to run."
– Kenny Rogers, The Gambler
Let's start gently by strangling a classical myth once and for all: "you will make money as long as you are right 51% of the time." Wrong. By their own admission, the industry's top performers with decades-long track records often claim unimpressive long-term win rates. Conversely, Long-Term Capital Management (LTCM) boasted an exceptionally high win rate until its blowup.
You will make money so long as you have a positive trading edge. The first thing we need to engineer is the signal module. As we saw in , false positives cannot be eradicated. Randomness is a feature, not a bug. Every basket comes with a few bad apples. The key is to design policies to spot those bad apples, deal with them before they spoil the basket, and move on.
The stock market is the only competitive sport where people like to hand out medals before the race starts. The industry is built on the cult of the stock picker. Everyone loves to talk about their top stock picks. Ninety percent of the energy of market participants is focused on making the right calls. Unfortunately, the same 90% of market participants will fail to beat their benchmark 5 years in a row.
In quants trader jargon, this is called correlation. When correlation endures year after year for every year on record, r-squared—causality in quants vernacular—goes to 1. In execution trader English, insanity is when the average frustrated market participant puts more and more energy and resources into stock picking, yet consistently fails year after year. Bottom line, 90% of market participants predictably fail because they consistently focus on the wrong thing: entry. Maybe it is time we entertained the possibility of a different approach.
According to Professor Jeremy Siegel, the long-term average return of the stock market is +7%. That leaves little room for pleasantries on the short side. Ideas are cheap and plentiful. Every single human being has a bag full of million-dollar ideas. The difference between ideas and profits is called execution. As we saw earlier, many market participants fail on the short side because they enter when ideas germinate, not when they are fully ripe for the picking. Underperformance is the financial equivalent of the stomach ache we get when we eat fruits that have not ripened yet.
This book will not give you a silver bullet methodology. The purpose of this book is to introduce a probabilistic view of the markets so that you can become a statistically better version of yourself. This book is merely a cookbook for the ingredients you bring to the table. Fundamental, technical, and quantitative short sellers have all made money, but they could not have succeeded without getting probabilities on their side first.
There are two times when you need to be cognizant of probabilities: stock selection and entry. In execution trader English, this means there are two times when you should listen to what the markets have to say before pulling the trigger.
The best time to enter a short position is when a bear market rally rolls over. We saw earlier a visual representation of gain expectancy. In the following chapters, we will look at techniques designed to increase your trading edge. They extend beyond short selling. Some may not apply to your style or strategy.
Let's start with the exits.
Some market participants put a lot of emphasis on entry while neglecting exit. Let's take a real life analogy to illustrate the importance of exits. The only soldiers who walk into battle without an exit strategy are called "kamikaze", literally "divine wind" in reference to Japanese pilots during world war 2. They do not expect to come back alive. Since you probably expect your trading to thrive, the best time to develop an exit strategy is before you "get boxed in," by entering a position.
In the financial services industry, the only thing cheaper than toilet paper is back-tests and simulation print-outs. This is just paper money. No one gets hurt. Real money is the only thing that matters. Let's take the idea one step further. The only time when you know how much "real money" was made, or lost, is after closing a trade. Anything before that is called paper profit. Bottom line: exits matter.
We have all been conditioned to believe that our initial choices are what matter the most. If we have the right internship, graduate from the right university, or choose the right first job, then our careers will be smooth. Life, however, is what happens to us when we have other plans. We often underestimate the role of luck in our lives. Being at the right place at the right time has made a lot of lucky people rich. Those individuals, however, rarely succeed on their first attempt. They try, fail, and pivot until something finally clicks.
Since market participants like to buy and hold, marriage is arguably a sensible metaphor. Every marriage starts with an optimistic "happily ever after," yet roughly half of them end up as a divorce statistic. Getting married is easy. Getting divorced is a life-altering event. If you have not considered the eventuality before getting married, then it will be emotionally and financially devastating. Bad marriages can be saved and sometimes turned around. Bad divorces can't. Bad entries can be salvaged. Bad exits can't. This is when the P&L gets printed.
As you will see in the coming chapters, 5 of the 7 steps to increase your trading edge deal with losses. Another good analogy is personal finance. There are two ways you can increase your savings. Either you maintain your current spending and go for a better-paying job, or you restrict your expenses and save the difference. The former works well if you have a stable high salary.
If your earnings are entirely variable, then you would be wiser to maintain a low fixed cost base and pocket whatever difference you glean from the markets. You will get the same big paydays as the other players, but the difference is you will not need them to stay afloat. In financial creole, this focus on expenses is called "cutting your losses short."
Let's look at the only two types of return distributions.
Jack Schwager often points out that there is no universal holy grail. Market wizards come in all shapes and forms, sometimes even with contradictory strategies. They have one thing in common though. They excel at managing risk and controlling losses. They consistently focus on the downside. Winning positions take care of themselves. Market participants' job is to take care of losers.
Market participants usually define themselves by "what" they trade (asset class, markets, time horizon), rarely "how" they trade. Regardless of the asset class, there are only two types of strategies: trend following and mean reversion. The reason why there are only two strategies is not entries but exits. How you choose to close a trade determines your dominant trading style. The mean reversion camp closes early when inefficiencies are corrected. The trend-following crowd loves to ride their winners. A classic example is value versus growth. Value investors buy undervalued stocks and then pass the baton to growth investors who ride them into the sunset.
For example, a mean reversion market participant would buy a stock at a Price to Book Ratio (PBR) of 0.5, close the position when PBR reverts back to 1, and move on. A trend follower would buy the same name at the same valuation but would ride it deep into euphoric territory. They would eventually close the position after this once-obscure, under-researched name finally made it to the "Strong Buy" lists of tier 1 investment banks and predictably underperformed thereafter.
Some readers may argue that value investors are more the mean reversion kind and growth managers the trend following type. That is often true but not always the case. The best counterexample and probably the ultimate value trend follower is Warren Buffett. He invests in undervalued businesses that he intends to hold ad perpetuum. Your positions are not Philippe Patek watches. You do not hold them for future generations. At some point, you will close your positions. Only three things can happen: the price goes up, down, or nowhere. In execution trader English, You either lose, make, or waste money. You need a scenario for each case. You need a plan to realize profits, mitigate losses, and deal with freeloaders. Our objective is to engineer a better statistical trading edge. If there are only two strategy types, then it is important to spend time understanding how they behave, their payoffs, and risks, and whether they are mutually exclusive or compatible.
"Money is made in the sitting and waiting."
– Jesse Livermore
Trend following strategies rely on the capital appreciation of a few big winners. Systematic Commodities Trading Advisors (CTAs) look for breakouts and place protective trailing stop losses. Technical analysts look for entry points and ride the upside. Even fundamental stock pickers qualify as trend followers. Instead of price action, they follow improvement in fundamentals, earnings momentum, or even news flow. Bottom line, whether they consciously admit it or not, the default mode for market participants is trend following. Since trend following is the dominant model for market participants, we use a rudimentary trend following strategy in this book.
This is what the P&L distribution of trend followers looks like:

Figure 5.8: Trend Following distribution of returns
We can observe the following properties:
Trend followers kiss a lot of frogs. The risk is that the cumulative smudge of all the delicious batrachians may not outweigh the fairness of a few princesses. The risk with trend-following strategies is in the aggregate weight of losses over profits.
The most pertinent risk metric for trend-following strategies is to compare cumulative profits and losses. This is the gain-to-pain ratio, also commonly known as the profit factor or profitability factor, favored by the prophet Jack Schwager. It states that when cumulative profits in the numerator exceed losses, the ratio is above 1, and vice versa for loss-making strategies. This is yet another version of the arithmetic gain expectancy, as a ratio instead of a delta.
def profit_ratio(profits, losses): pr = profits.ffill() / abs(losses.ffill()) return pr rolling_profit_ratio = profit_ratio(winning_days.fillna(0).rolling(window).sum(), losing_days.fillna(0).rolling(window).sum()).ffill() cumulative_profit_ratio = profit_ratio(winning_days.fillna(0).cumsum(), losing_days.fillna(0).cumsum()).ffill() plt.figure(figsize=(15, 3)) plt.plot(rolling_profit_ratio[window * 3:], 'b', label='Rolling Profit Ratio') plt.plot(cumulative_profit_ratio[window * 3:], 'r', label='Cumulative Profit Ratio') plt.title(f'{ticker}: Rolling & Cumulative Profit Ratio', fontsize=16) plt.ylabel('Profit Ratio', fontsize=14) plt.grid(True, linestyle='-', alpha=0.6) plt.show() Calculating profit ratio metrics comparing total winning amounts to total losing amounts, measured both over rolling windows and cumulatively, to assess strategy profitability consistency.
profit_ratio(): We define helper function dividing cumulative/rolling profits (sum of winning trades) by absolute cumulative/rolling losses (sum of losing trades) with forward-fill to handle NaN edges.This time series of profit ratios enables identification of periods where average winning trade magnitude exceeds average losing magnitude, signaling sustainable strategy edge.

Figure 5.9: GE Cumulative and Rolling Profit Ratios
Profit ratio needs to be above 1 for a strategy to be viable long-term. It doesn't need to make money all the time. It just needs to make money over time. Trend-following strategies post impressive but volatile returns. They go through long periods of drawdowns. The rolling profit ratio can dip for extended periods of time such as 2019 through 2020. Their main challenge is to keep cumulative losses small. Profits only look big to the extent that losses are kept small.
Do not underestimate the mental toll. It will take courage to step up and take another trade, knowing it will probably be yet another loser. In a nutshell, trend followers get paid handsomely for the mental discomfort of maintaining discipline, while watching their capital erode.
After all, the turtle traders amassed hundreds of millions of dollars despite a 30% win rate. They took trades expecting them to fail. They satisfied their need to be right not by becoming better stock pickers but by being exceptional risk managers.
Next, let's look at mean reversion strategies.
"The most important of these rules is the first one: the eternal law of reversion to the mean (RTM) in the financial markets."
– John C. Bogle
Mean reversion strategies compound numerous small profits. They rely on the premise that extremes eventually revert to the mean. They arbitrage market inefficiencies. Mean reversion strategies essentially capture the time it takes for inefficiencies to correct. For example, the price of a warrant may look cheap compared to its underlying stock. Over time, prices will converge, and the gap will close. Mean reversion strategies post low-volatility, consistent returns. They perform well during stable market regimes: bull, bear, or sideways. Stationarity is a key assumption in mean reversion strategies. They rely on the stability of the relationship between their own historical price, otherwise known as autocorrelation in industry jargon.
The markets of predilection for mean reversion strategies are typically sideways phases where prices oscillate in semi-predictable fashions. They may find bull or bear phases more challenging, but there is ample empirical evidence of talented managers performing in those markets. Again, risk management is what separates the pros from the "tourists".
Mean reversion strategies perform poorly during regime changes. For example, long high beta (high sensitivity to the markets), short low beta (defensive stocks) will do wonders in a bull market but will give back a lot of performance as the regime transitions to sideways and bear. The darlings of the last bull market often lead the way down in the ensuing bear market. They also perform poorly during tail events, one of those extreme market moves that lie in the "tail" of a probability distribution. Short gamma funds performed well for years until they spectacularly blew up in three weeks during the 2008 GFC.
This is what a distribution of returns looks like for a mean reversion strategy.

Figure 5.10: Distribution of returns for mean reversion strategies
The characteristics of mean reversion strategies are:
As we saw earlier, mean reversion strategies post consistent small profits but suffer rare but game-ending setbacks. The risk for mean reversion strategies is in the tail. A few devastating losses have the power to sink the ship. After all, the captain of the Titanic had a 99% win rate. The most relevant measure of risk is therefore the ratio of the biggest profits to the worst losses, or tail ratio:
def rolling_tail_ratio(cumul_returns, window, percentile,limit): left_tail = np.abs(cumul_returns.rolling(window).quantile(percentile)) right_tail = cumul_returns.rolling(window).quantile(1-percentile) np.seterr(all='ignore') tail = (right_tail / left_tail).clip(-limit, limit) return tail def expanding_tail_ratio(cumul_returns, percentile,limit): left_tail = np.abs(cumul_returns.expanding().quantile(percentile)) right_tail = cumul_returns.expanding().quantile(1 - percentile) np.seterr(all='ignore') tail = (right_tail / left_tail).clip(-limit, limit) return tail Defining functions to measure return distribution skewness by comparing right-tail (gains) to left-tail (losses) magnitudes, quantifying strategy bias toward large winners versus large losers.
Let's explain those functions:
rolling_tail_ratio(): We calculate rolling window quantiles at specified percentile (e.g., 5th percentile for left tail, 95th percentile for right tail). We divide the right-tail value by absolute left-tail value to create ratio metric. We apply min/max limits to cap extreme outliers and suppress numpy warnings for division operations.expanding_tail_ratio(): Same calculation using expanding window instead of rolling window; tracks tail ratio evolution from inception to present, capturing long-term distribution asymmetry trends.clip to constrain tail ratio within [‑limit, +limit] bounds, preventing inf/nan from division by near-zero values.Next, we will explore a classic mean reversion strategy: pairs trading.
Pairs trading is conceptually simple to understand. Pick two issues that move in tandem in a predictable fashion. Whack them back to the mean every time they deviate too far.
Numerous scripts on the internet suggest testing vast swaths of data to uncover hidden pairs. While the autocorrelation may work for some time, the relationship may break over time. We will take a much simpler approach. We will look for pairs within the same sector. After all, nothing resembles a utility stock more than another utility stock. Goldman Sachs (GS) may have a different culture and different revenue streams than JP Morgan (JPM), but at the end of the day, those are still bank stocks. Flocks of the same feather fly together as we saw earlier in .
Let's start with the investment universe:
url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies' headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)}'} response = requests.get(url, headers=headers) df_SP500 = pd.read_html(StringIO(response.text))[0] df_SP500 = df_SP500.rename(columns={'Symbol':'ticker', 'Security': 'name', 'GICS Sector':'sector','GICS Sub-Industry':'sub-industry'}) df_SP500['ticker'] = df_SP500['ticker'].str.replace('.', '-', regex =False) df_SP500 = df_SP500.sort_values(by = ['sector','name']).set_index('ticker') df_SP500 = df_SP500[df_SP500.index.isin(library_SP500.list_symbols())] bm_ticker = '^GSPC' ; bm = 'SP500' ; ccy = 'local' ; dgt = 2 df_SP500.groupby('sector').count() Downloading S&P 500 constituents list with sector and company metadata, filtered to only include tickers available in the Arctic database.
Next, let's define the co-integration functions we will be using across the S&P 500 constituents.
def cointegration_test(df, cutoff): n = df.shape[1] score_matrix = np.zeros((n, n)) pvalue_matrix = np.ones((n, n)) colname = df.keys() pairs = [] for i in range(n): for j in range(i+1, n): p1 = df[colname[i]] p2 = df[colname[j]] cointegration_matrices = coint(p1, p2) score = cointegration_matrices[0] pvalue = cointegration_matrices[1] score_matrix[i, j] = score pvalue_matrix[i, j] = pvalue if pvalue < cutoff: pairs.append((colname[i], colname[j])) return pairs, pvalue_matrix, score_matrix This function performs pairwise Engle-Granger cointegration tests across all asset combinations computes the t-statistic (ADF test on residuals) and p-value for each pair populates symmetric matrices and filters pairs below the p-value cutoff (e.g., 0.05) to identify statistically significant long-run equilibrium relationships.
This function is the engine that will look for pairs across a population of tickers. Cointegration simply means that pairs are correlated and do not diverge over time. The relationship between pairs is stable over time. Deviations predictably revert to the mean.
Overextension is measured either by subtracting or dividing one stock by the other. We then calculate the distance to the mean expressed in standard deviations, commonly referred to as z-score.
def historical_spread(df, pair): pair_1 = df[pair[0]] pair_2 = df[pair[1]] pair_1_with_const = add_constant(pair_1) # Add constant without overwriting results = OLS(pair_2, pair_1_with_const).fit() coeff = results.params[pair[0]] # Extract the non-constant coefficient spread = pair_2 - (coeff * pair_1) return spread def price_ratio(df,pair): return df[pair[0]] / df[pair[1]] def price_spread(df,pair): return df[pair[0]] - df[pair[1]] def rolling_Z_score(df, t): Z_score = (df - df.rolling(t).mean()) / df.rolling(t).std(ddof=0) return Z_score There is a difference between historical spread and price spread. The former tests for stationarity. It assumes the historical spread is constant and applies a fixed coefficient. A trading signal appears when it deviates from historical norms. Price spread is the actual price difference between one asset and the other.
Next, we will process the entire S&P 500 by sectors. We will test cointegration over 3 years.
cutoff = 0.05 ; lookback_years = 3 coint_window = px_df.index.max() - relativedelta(years=lookback_years) df = px_df[coint_window:].copy() sector_list = list(set(df_SP500['sector'])) sector_pairs_dict = {} ; bm_pairs_list = [] for sctr in sector_list: sector_tickers = [bm_ticker] + list(df_SP500[df_SP500['sector'] == sctr].index) print(f'{sctr}: {len(sector_tickers)} stocks, ',end="") pairs, pvalue_matrix, score_matrix = cointegration_test(df.loc[:,sector_tickers].fillna(0), cutoff) sector_pairs_dict.update({sctr: pairs}) bm_pairs_list += [bm_pair for bm_pair in pairs if bm_ticker in bm_pair] print(f'{len(pairs)} pairs,') print('bm_pairs_list:', len(bm_pairs_list), bm_pairs_list) sector_pairs_df = pd.DataFrame.from_dict(sector_pairs_dict, orient='index').T sector_pairs_df Screening for cointegrated pairs within each GICS sector and against the S&P 500 benchmark, building a dataset of statistically significant long-run equilibrium relationships for pairs trading.
cointegration_test() function.bm_pairs_list for market-hedged strategies.sector_pairs_dict from a dictionary to a DataFrame with sectors as columns and pairs as rows (pivot structure). This enables efficient pair lookup and aggregation across sectors.This produces a sector-indexed pairs discovery with cointegrated relationships identified for 11+ GICS sectors benchmark pairs enabling market-neutral strategy pairs and a foundation for spread-based mean-reversion signals across sector portfolios.
Let's see how many pairs were generated:
sector_pairs_count = pd.concat([df_SP500.groupby('sector').count()['name'], sector_pairs_df.count()],axis=1, keys=['#Stocks', '#Pairs']) sector_pairs_count Here is the output:
| sector | #Stocks | #Pairs |
|---|---|---|
| Communication Services | 23 | 9 |
| Consumer Discretionary | 47 | 101 |
| Consumer Staples | 36 | 16 |
| Energy | 22 | 77 |
| Financials | 76 | 123 |
| Health Care | 60 | 109 |
| Industrials | 79 | 166 |
| Information Technology | 70 | 45 |
| Materials | 26 | 15 |
| Real Estate | 31 | 6 |
| Utilities | 31 | 21 |
| Total | 501 | 688 |
Table 5.1: Sector indexed pairs
This gives 688 pairs, bigger than the size of the index. This is obviously an investment universe too big to be monitored. We need to perform additional tests to reduce this raw data sample.
Next, we want to test the stability of the relationship, or stationarity. Not only do we want pairs to move in tandem over time, we want to make sure they move together by the same measure. We can either calculate the spread or the ratio.
historical_spread_dict = {} ; price_spread_dict = {} ; price_ratio_dict = {} ; pairs_list = [] ; failed_list = [] df_clean = df.ffill().fillna(0) pairs_valid_list = [] x= 0 whilex< len(sector_pairs_df.columns): print(x+1,sector_pairs_df.columns[x],sector_pairs_df.iloc[:,x].count(),end = ' pairs:') y= 0 while(y<len(sector_pairs_df)) & (pd.notnull(sector_pairs_df.iloc[min(y,len(sector_pairs_df)-1),x])) : raw = sector_pairs_df.iloc[y,x] pair = eval(raw) if isinstance(raw, str) else raw try: spread = abs(historical_spread(df_clean,pair)) adf_spread = adfuller(spread)[1] ratio = price_ratio(df_clean,pair) adf_ratio = adfuller(ratio)[1] adf_avg = (adf_spread + adf_ratio) / 2 ifadf_avg<= cutoff: pairs_valid_list.append(pair) pair_dict = {'pair':pair, 'sector':sector_pairs_df.columns[x], 'adf_spread': adf_spread, 'adf_ratio': adf_ratio, 'adf_avg': adf_avg} pairs_list += [pair_dict] historical_spread_dict.update({pair: spread}) price_ratio_dict.update({pair:ratio}) price_spread_dict.update({pair: price_spread(df_clean,pair)}) print(pair,end = ',') except Exception as e: failed_list.append(pair) y +=1 x += 1 print('') historical_spread_df = pd.DataFrame.from_dict(historical_spread_dict).round(3) price_ratio_df = pd.DataFrame.from_dict(price_ratio_dict).round(3) price_spread_df = pd.DataFrame.from_dict(price_spread_dict).round(3) pairs_df = pd.DataFrame.from_dict(pairs_list).round(4) if not pairs_df.empty and 'pair' in pairs_df.columns: pairs_df = pairs_df.set_index('pair') else: print('Warning: no pairs found - pairs_df is empty') print('price_spread_df:' ,price_spread_df.shape ,'price_ratio_df:',price_ratio_df.shape,'pairs_df:', pairs_df.shape) print(f'Failed list: {len(failed_list)}, {failed_list}') For each cointegrated pair, we compute regression-based and simple spreads, verify stationarity using ADF tests on both representations, filter pairs meeting statistical criteria, and construct final spread DataFrames for trading signal generation. Let's go through the code:
df_clean) to handle missing values.sector_pairs_df using a double while loop: 1) outer loop over sectors (columns), 2) inner loop over pairs within each sector (rows). Pairs are stored in a 2D DataFrame indexed by sector.This produces three aligned wide-format DataFrames (spreads/ratios as columns, dates as rows): historical_spread_df (regression residuals), price_ratio_df (ratios), price_spread_df (differences); pairs_df metadata with sector and ADF statistics; all pairs meet stationarity threshold, enabling mean-reversion trading.
We have reduced the investment universe to 160+ pairs from the original 500+ long list.
If we assume reversion to the mean, then we need to measure how pairs oscillate around it. This is measured in standard deviations around the mean. This is called a z-score. Since we a priori do not know which time frame best captures the oscillation, we will first calculate rolling z-scores over multiple durations.
duration_list = list(range(20, 101, 20)) z_df_dict = {} for t in duration_list: z_df_dict[f'z_spread{t}'] = round(rolling_Z_score(price_spread_df, t),2) z_df_dict[f'z_ratio{t}'] = round(rolling_Z_score(price_ratio_df, t),2) Computing rolling z-score on spread and ratio DataFrames across three distinct timeframes (short, medium, long) to generate scale-independent entry/exit signals.
[20, 40, 60, 80, 100] days using range(20, 101, 20) to span short-term reactive signals through longer-term trend filters.duration_list, we apply the rolling_Z_score() function to price_spread_df, producing z-normalized spreads. We repeat for price_ratio_df, producing z-normalized ratios.z_df_dict with keys naming convention (e.g., 'z_spread20', 'z_ratio60', 'z_ratio100') for easy retrieval. All z-scores are rounded to 2 decimals for signal clarity.This produces a dictionary{XE"pairstrading:across,sectorsofS&P500"}ofmultiplez-scoreDataFramesindexedbydurationandspreadtype; all spreads/ratios are converted to a standardized normal distribution, enabling consistent threshold-based entry triggers (e.g., z >= 2.0 for overbought, z <= -2.0 for oversold) across different spread magnitudes.
We then build a df of the last values across all the durations:
z_last_list = [] for z in z_df_dict: z_last_list += [z_df_dict[z].tail(1).T.rename(columns={z_df_dict[z].index.max(): z})] z_last_df = pd.concat(z_last_list,axis=1) z_last_df.index.name = "pair" if not pairs_df.empty and 'adf_avg' in pairs_df.columns: pairs_df = pd.concat([pairs_df, z_last_df], axis=1).dropna(subset=['adf_avg']) pairs_df = pairs_df.loc[:, ~pairs_df.columns.duplicated(keep='last')] else: print('Warning: no pairs found - skipping z-score merge') pairs_df.head() Extracting the most recent z-score values across all timeframes and spread types, merging with pair metadata to create an enriched screening DataFrame showing current pair positioning.
z_last_list to accumulate transposed z-score snapshots for concatenation.z_df_dict containing 10 z-score DataFrames (5 windows × 2 spread types: z_spread20/40/60/80/100, z_ratio20/40/60/80/100)..tail(1). We transpose (.T) to convert pairs from columns to rows. We rename the single column from the date timestamp to the z-score identifier (e.g., 'z_spread20', 'z_ratio100').z_last_list. After the loop completes, we concatenate all horizontally using pd.concat(z_last_list, axis=1) to create z_last_df with pairs as rows and 10 z-score columns.z_last_df with existing pairs_df metadata (sector, adf_spread, adf_ratio, adf_avg) using horizontal concatenation on pair index.dropna(subset=['adf_avg'])) to retain only qualified cointegrated pairs; remove duplicate columns (.loc[:, ~pairs_df.columns.duplicated(keep='last')]) from any prior merge collisions.This produces a consolidated pairs_df DataFrame with sector classification, stationarity metrics (ADF p-values), and latest z-scores across all 10 timeframe/spread-type combinations. This enables immediate screening for extreme positioning (e.g., |z| >= 2.0 on multiple timeframes) signaling high-confidence mean-reversion entry candidates.
Now that we have a lot of pairs to choose from, let's pick one and run with it. If you trade for thrills, you are in the wrong business. Good trading is supposed to be boring. So, let's pick the most stable pair in the most boring sector: utilities.
utilities_pairs_df = pairs_df[pairs_df['sector'] == 'Utilities'].sort_values(by='adf_avg') utilities_pairs_df Ranking pairs within a sector:
pairs_df to a single sector (Utilities).adf_avg ascending (smaller p-values → stronger stationarity).This produces a table like this:
| pair | sector | adf_spread | adf_ratio | adf_avg |
|---|---|---|---|---|
| ('CMS', 'PPL') | Utilities | 0.0 | 0.2051 | 0.0031 |
| ('DTE', 'DUK') | Utilities | 0.0017 | 0.0158 | 0.0051 |
| ('^GSPC', 'CEG') | Utilities | 0.0001 | 0.8116 | 0.01 |
| ('CMS', 'DTE') | Utilities | 0.011 | 0.0132 | 0.0121 |
| ('CMS', 'DUK') | Utilities | 0.0025 | 0.0805 | 0.014 |
| ('DTE', 'PPL') | Utilities | 0.0032 | 0.132 | 0.0204 |
| ('CEG', 'NRG') | Utilities | 0.0138 | 0.0478 | 0.0257 |
| ('CNP', 'WEC') | Utilities | 0.2309 | 0.0043 | 0.0313 |
| ('PPL', 'VST') | Utilities | 0.0023 | 0.4369 | 0.0317 |
| ('ATO', 'NI') | Utilities | 0.0072 | 0.2546 | 0.0427 |
Table 5.2: Utilities p-values
Let's extract the pair with the lowest average adf score. ('CMS', 'PPL') has the lowest adf average at the time of writing. The code has built-in flexibility to allow whichever pair comes up to run smoothly. That should be as uneventful as it gets. We will plot the price of each constituent of the pair and then the spread to visually verify they move together.
pair = utilities_pairs_df[utilities_pairs_df['adf_avg']>0].index[0] px_df[[pair[0], pair[1]]].plot(figsize=(15, 3), style=['k', 'orange'], secondary_y = pair[0], title=f'{pair[0]} & {pair[1]}: Price Series', grid=True) df_pair = px_df[[pair[0], pair[1]]].ffill().copy() df_pair['spread'] = round(df_pair[pair[1]] - df_pair[pair[0]],5) df_pair['spread_daily_log'] = np.log(df_pair['spread']/df_pair['spread'].shift()).round(5) df_pair[[pair[0], pair[1], 'spread']].plot(figsize=(15, 3), style=['k', 'orange', 'y:'],secondary_y=['spread'], title=f'{pair[0]} & {pair[1]}: Price Series & Spread', grid=True) Visualizing raw price series for both assets in a selected pair to assess co-integration quality, relative price divergence, and mean-reversion opportunity identification.
utilities_pairs_df (filter adf_avg > 0 to exclude edge cases). We extract both ticker price columns from px_df universe-wide price DataFrame.df_pair DataFrame with forward-filled prices for both legs; compute simple difference spread (leg2 - leg1) rounded to 5 decimals.This dual-axis time series chart displays raw prices for both pair components, enabling visual confirmation of mean-reverting behavior and identification of current spread positioning relative to historical norms. Let's have a look at the chart:

Figure 5.11: (CMS, PPL) prices
Stocks appear correlated over the years. Next, let's take a closer look at the historical spread between the prices.

Figure 5.12: (CMS, PPL) prices and spread
The historical spread seems stable over time. The whole game of mean reversion is about identifying when the spread moves too far out of whack and arbitraging it back, with the expectation that it will revert to the mean. It comes down to choosing a duration for the z-score.
Next, let's take a look at all the z-scores in the z_df_dict
z_pair_dict = {} z_pair_dict = {f'{z}': z_df_dict[z][pair] for z in z_df_dict if pair in z_df_dict[z].columns z_pair_df = pd.concat(z_pair_dict, axis=1) z_pair_df.plot(figsize=(15, 4), title=f'{pair[0]} & {pair[1]}: Z-Score Spread & Ratio', grid=True) z_pair_df.columns Visualizing all six z-score time series (3 windows × 2 spread types) for the selected pair to assess mean-reversion signals across multiple timeframes.
z_pair_dict by filtering z_df_dict for the current pair. We extract the pair column from each of the six z-score DataFrames (z_spread20/60/100, z_ratio20/60/100) if the pair exists.z_pair_df with standardized column names|z| > 2) on multiple timeframes signal high-confidence entry opportunities.This produces a multi-timeframe z-score chart for the selected pair, showing standardized spread positioning across 20/60/100-day windows. This enables the identification of mean-reversion entry signals when multiple timeframes align at extreme z-score thresholds.

Figure 5.13: Z-scores of spread and ratio
We have tested for stationarity. The relationship seems stable over time. Things revert to the mean for all durations tested. The shorter the duration, the higher the frequency. Let's add the cumulative returns of the pair spread to see how signals fit with actual returns.
Let's calculate the daily and cumulative returns of the spread.
df_pair = px_df[[pair[0], pair[1]]].ffill().copy() df_pair['spread'] = round(df_pair[pair[0]] - df_pair[pair[1]],5) df_pair['spread_daily_log'] = np.log(df_pair['spread']/df_pair['spread'].shift()).round(5) df_pair['cumul_spread_returns'] = df_pair['spread_daily_log'].cumsum().apply(np.exp) - 1 df_pair = pd.concat([df_pair, z_pair_df], axis=1) plot_cols = [ 'cumul_spread_returns'] + list(z_pair_df.columns) df_pair[plot_cols][z_pair_df.index[0]:].plot(figsize=(15, 3), grid = True, secondary_y = list(z_pair_df.columns), title=f'{pair[0]} & {pair[1]}: Cumulative Spread Returns & Z-Scores', style = ['k'] ) Merging cumulative spread returns with multi-timeframe z-scores into a unified DataFrame:
ln(spread_t / spread_t−1), rounded to 5 decimals; compound into cumulative returns via np.exp(cumsum) − 1 to track spread performance.z_pair_df (six z-score series) horizontally into unified df_pair; align all data to z_pair_df start date.The visual overlay shows: spread performance trending versus z-score extremes; identify entry candidates where |z| >= 2.0 across multiple timeframes coincides with spread inflection points.

Figure 5.14: Z-scores of spread and ratio and cumulative returns
Now that we have verified that mean reversion seems to work, the next step is to simulate pair trading. Next, we will build a function that simulates pairs trading.
def pairs_trading_simulator(z_score, entry_threshold, exit_threshold): """ Parameters: z_score (pd.Series): The z-score series for the pair. entry_threshold (float): The z-score threshold to enter a trade. exit_threshold (float): The z-score threshold to exit a trade. """ position = 0 ; trades = [] for date, z in z_score.items(): if position == 0: if z > entry_threshold: # Short entry position = -1 trades.append([date, position]) elif z < -entry_threshold: # Long entry position = 1 trades.append([date, position]) elif position == 1: # Long position if z > -exit_threshold: # Exit long position = 0 trades.append([date, position]) elif position == -1: # Short position if z < exit_threshold: # Exit sho position = 0 trades.append([date, position]) trades_df = pd.DataFrame(trades, columns=["Date","position"]) return trades_df This function enters a long when the deviation has gone below the negative threshold and vice versa for a short. Positions are closed when the spread reverts to an exit threshold. Let's go through the code:
Converting a z-score signal into discrete long/short/flat positions using symmetric entry/exit thresholds:
position=0 and an empty trades log. z > entry_threshold | enter short (-1); if z < ‑entry_threshold | enter long (+1); log the trade.z > ‑exit_threshold | exit to flat (0); log the exit.z < exit_threshold | exit to flat (0); log the exit.trades_df with Date and Position columns.This produces a DataFrame of dated position flips (long, short, flat) driven by z-score threshold crossings, ready for PnL backtesting.
So, we have multiple durations for z-scores. We will next test when to enter and exit positions based on values of the z-score across all durations.
entry_thresholds = np.arange(1.7, 3.1, 0.1) exit_thresholds = np.arange(0.1, 1.6, 0.1) results_list = [] cum_returns_dict = {} z_score_cols = [col for col in z_pair_df.columns if col.startswith('z_')] for entry_threshold in entry_thresholds: for exit_threshold in exit_thresholds: z_pair_df_temp = z_pair_df[z_score_cols].copy() pos_list_temp = [] for z in z_score_cols: pos_df = pairs_trading_simulator(z_pair_df_temp[z], entry_threshold, exit_threshold) z_pair_df_temp = pd.concat([z_pair_df_temp, pos_df.set_index('Date')], axis=1) z_pair_df_temp.rename(columns={'position': f'pos_{z}'}, inplace=True) pos_list_temp += [f'pos_{z}'] z_pair_df_temp = z_pair_df_temp.loc[:, ~z_pair_df_temp.columns.duplicated(keep='last')] z_pair_df_temp[pos_list_temp] = z_pair_df_temp[pos_list_temp].shift(+1).ffill() for pos_col in pos_list_temp: df_pair_temp = df_pair[coint_window:].copy() position_series = z_pair_df_temp[pos_col].astype(float).fillna(0) df_pair_temp[f'{pos_col}_daily_log_rets'] = df_pair_temp['spread_daily_log'] * position_series df_pair_temp[f'{pos_col}_cum_log_rets'] = df_pair_temp[f'{pos_col}_daily_log_rets'].cumsum() df_pair_temp[f'{pos_col}_cumul_returns'] = np.exp(df_pair_temp[f'{pos_col}_cum_log_rets']) - 1 daily_rets = df_pair_temp[f'{pos_col}_daily_log_rets'] final_return = df_pair_temp[f'{pos_col}_cumul_returns'].iloc[-1] max_drawdown = (df_pair_temp[f'{pos_col}_cumul_returns'] / df_pair_temp[f'{pos_col}_cumul_returns'].cummax() - 1).min() mean_ret = daily_rets.mean() std_ret = daily_rets.std() sharpe = (mean_ret / std_ret * np.sqrt(252)) if std_ret > 1e-10 else np.nan results_list.append({'entry_threshold': round(entry_threshold, 2), 'exit_threshold': round(exit_threshold, 2), 'z_score': pos_col, 'sharpe_ratio': sharpe, 'final_return': final_return, 'max_drawdown': max_drawdown}) results_df = pd.DataFrame(results_list) results_df = results_df.sort_values('sharpe_ratio', ascending=False).round(4) Systematically testing all combinations of entry and exit z-score thresholds across multiple z-score durations. It takes some time to process: we test of entry/exit combinations. We want to identify the optimal parameter sets that maximize the Sharpe ratio) while minimizing drawdowns. Here is how we do it step by step:
z_pair_df (all series starting with 'z_'). We filter only standardized spread/ratio signals for position generation.pairs_trading_simulator() to generate position series with current thresholds. We concatenate position columns to temporary z_pair_df_temp. We shift positions forward +1 day and forward-fill to align with trading delays.cumsum(). We compound into cumulative percentage returns using np.exp() ‑ 1.cummax() comparison, Sharpe ratio = (mean_daily_return / std_daily_return) × √252 with division-by-zero protection.results_list; iterate through all 210 × 6 = 1,260 strategy combinations.results_list to results_df. We sort by Sharpe ratio descending (highest risk-adjusted returns first). We create a pivot table by entry/exit thresholds with mean Sharpe as the aggregation metric.This produces a results_df with 1,260 rows (all threshold × z-score combinations) ranked by Sharpe ratio. We generate a heatmap visualization revealing optimal entry/exit threshold combinations with highest risk-adjusted returns. This identifies parameter regions where strategy consistently outperforms regardless of z-score window selection.
Let's publish the code before plotting the heatmap.
pivot_sharpe = results_df.pivot_table(values='sharpe_ratio', index='entry_threshold', columns='exit_threshold', aggfunc='mean') plt.figure(figsize=(12, 5)) import seaborn as sns sns.heatmap(pivot_sharpe, annot=True, fmt='.2f', cmap='RdYlGn', center=0) plt.title(f'{pair[0]} & {pair[1]}: Sharpe Ratio Heatmap by Threshold Combination', fontsize= 15, fontweight='bold') plt.ylabel('Entry Threshold', fontsize= 15, fontweight='bold') plt.xlabel('Exit Threshold', fontsize= 15, fontweight='bold') plt.tight_layout() plt.show() Next, let's visualize the heatmap:

Figure 5.15: Heatmap of Sharpe ratio by entry and exit threshold combination
This shows the optimal region for entry and exit regardless of the duration and type (spread or ratio) of z-scores. This is really interesting. Rather than extremes, it seems like entering around 1.7 standard deviations, and closing when it dips below 1.2, produces a high Sharpe ratio.
When Z-scores reach extreme territory (>2 standard deviations), the Sharpe ratio declines. Moral of the story: arbitrage frequent small inefficiencies and close early.
Next, let's look at the top and bottom 10 Sharpe ratios. We will calculate the cumulative returns and plot the results on a chart.
n = 10 top_n = results_df.head(n) bottom_n = results_df.tail(n) fig, ax = plt.subplots(figsize=(15, 5)) for topbottom in ['Top','Bottom']: if topbottom == 'Top': colors = plt.cm.Greens(np.linspace(0.5, 0.9, n)) data = top_n else: colors = plt.cm.Reds(np.linspace(0.5, 0.9, n)) data = bottom_n for idx, (_, row) in enumerate(data.iterrows()): cum_rets = cum_returns_dict[int(row['i'])] label = f"{topbottom.capitalize()} {idx+1}: {row['z_score'].replace('pos_','')}; E={row['entry_threshold']}, X={row['exit_threshold']}; Sharpe={row['sharpe_ratio']:.2f}, Ret={row['final_return']:.2%}, DD={row['max_drawdown']:.2%}" ax.plot(cum_rets, linewidth=2, label=label, color=colors[idx], linestyle='-' if topbottom=='Top' else ':') ax.set_xlim(left=coint_window, right=px_df.index.max()) ax.set_title(f'{pair[0]} & {pair[1]}: Top {n} vs Bottom {n} Equity Curves', fontsize=15, fontweight='bold') ax.set_ylabel('Cumulative Returns', fontsize= 15, fontweight='bold') ax.set_xlabel('Date', fontsize= 15, fontweight='bold') ax.legend(loc='upper left', fontsize= 8, framealpha=0) ax.grid(True, alpha=0.6) ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}')) plt.tight_layout() plt.show() Let's explain this bit of code. We compare the equity curves of the top versus bottom-performing parameter combinations.
Objective: Visualize and compare equity curves for the top and bottom 10 parameter combinations ranked by Sharpe ratio.
Now let's look at the steps:
results_df (already sorted by Sharpe descending) into top_n (best 10) and bottom_n (worst 10).cum_returns_dict using the integer index i. There is no recomputation, guaranteeing consistency with the Sharpe/return metrics.This produces an equity curve chart with 20 strategies (10 green top performers + 10 red bottom performers).

Figure 5.16: CMS/PPL Top versus Bottom 10 equity curves
The tight clustering of top curves versus divergent bottom curves reveals parameter robustness. The legend provides complete strategy specifications for each curve.
Now, this chart is too optimistic for production. We ignored slippage, transaction costs, and dividends received or paid. More importantly, we optimized for one pair. The same set of parameters will probably not produce the same results for other pairs. In financial creole, we are guilty of the crime of overfitting. The classic solution would be to run the same test across other pairs and compare. We would perform what is called out-of-sample data analysis. This is what every sensible financial analyst should do: optimize across a sample, test for robustness on out-of-sample data.
Mean reversion strategies produce small and consistent gains, with some rare but ulcer-provoking losses along the way. Stop losses are not effective for mean reversion strategies. Positions are entered around peak efficiency and expected to revert to the mean. Yet, sometimes they go a little closer to the sun like Icarus, before their wings melt away back to the mean. This is why the stationarity test is critical. As soon as stationarity breaks down, stop trading the same pair. No one wants to be caught long MySpace and short Facebook, hoping for a reversion to the mean.
Overfitting may however be the right approach in the specific case of pairs trading. We want to trade a behavior that we have observed on two separate issues. This behavior is pair specific. It may not translate well across other pairs. There may be commonalities among pairs, yet there may not be universality. The important part is the stability of the relationship.
Let's recap what we have learned in this chapter. The mysterious, mystical, mythical, magical trading edge is nothing but a little formula we learned back in school called gain expectancy. The trading edge is expressed through three related formulas: arithmetic expectancy measures average profit per trade. Geometric expectancy assesses compounding and long-term robustness. The Kelly criterion optimizes the geometric growth through position sizing. All three rely on the same inputs: win rate, average win, and average loss.
Regardless of asset class, timeframe, or instrument, all strategies ever traded fall into two buckets: trend following or mean reversion. Trend following strategies have low win rates (<50%). They rely on a few large winners to offset many small losses. They are right skewed: tail events are profitable. Profits come from keeping losses small. Mean reversion strategies have high win rates and frequent small gains. Prices are expected to misbehave before reverting to the mean, thereby rendering stop-losses counterproductive. They exhibit left-skewed return distributions: they suffer rare but potentially catastrophic losses. They perform poorly during regime changes. They depend critically on stationarity and the stability of the relationship between securities. Trend following and mean reversion strategies have opposite payoffs and risk profiles.
Pairs trading was introduced as a staple mean‑reversion strategy. Pairs are selected within sectors to improve stability. Cointegration ensures long‑term relationship stability. ADF tests confirm stationarity of spreads and ratios. Z‑scores standardize deviations, define systematic entry and exit rules. Parameter testing showed that moderate deviations, not extremes, often produce better risk‑adjusted returns. The core lesson: trading success comes from engineering probabilities, managing risk, and keeping losses small.
In the next chapter, we will look in detail at the money management module and one of the most underrated topics in fund management: position sizing.
Scan the QR code (or go to ). Search for this book by name, confirm the edition, and then follow the steps on the page.


Note: Have your invoice handy. Purchases made directly from the Packt website don't require an invoice.