During the Napoleonic wars, field surgeons with limited resources had to make quick decisions as to who would need surgery, who could survive without it, and the unfortunate ones for whom nothing could be done. Triage was born out of necessity to allocate limited time and resources in the most efficient and humane way possible. In the stock market, regime is another word for triage. Some are bullish, some are bearish, and some are inconclusive.
Markets tend to stay wrong a lot longer than investors tend to stick with you. Segregating stocks into different regime buckets—triaging them—before performing in-depth analysis is an efficient allocation of resources. The objective of this initial triage is not to predict where stocks could, would, or should be headed, but to practice the long-lost art of actively listening to what the market has to say.
Some market participants like to spend time and resources building bearish cases for bullish stocks that insolently defy the gravity of reason. This is not efficient for two reasons. First, they expect reversion to the mean. On the long side, they trade trends and ride outperformers, expecting them to continue to do well. Meanwhile, on the short side, they trade mean reversion, expecting expensive stocks to choke on humble pie and come back down to cheap prices again.
As a different approach, establishing a market regime is something that could really help fundamental short-sellers. They often show up too early. They place their bets long before the broader market starts to discount the information. The difference between a short selling guru and the dreaded tap on the shoulder is 6 months. Short internet stocks in 1999, and you'll be teaching math to bored university students in 2000. Short the same stocks as early as late January 2000, and a new short selling star is born.
We will go through classic regime definition methodologies. We will define breakouts, turtle traders, and moving average crossovers. We will then calculate fractals and use them to define higher highs and lows, as well as floor and ceiling methodologies. We will plot each regime definition to highlight the differences between each method. We will process the entire S&P 500 and display the sector rotation we talked about in the previous chapter. Finally, we will calculate fractals and regimes across various timeframes, from 5 minutes to daily bars.
In the following sections, we will look at various regime definition methods before blending them:
You can access color versions of all images in this chapter via the following link: .
You can also retrieve the 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 the pandas, numpy, yfinance, and matplotlib libraries. We will also be working with Mplfinance, a charting library built on matplotlib.
import pandas as pdimport numpy as npimport datetime as dtimport timeimport pathlibimport arcticdb as adbfrom arcticdb import LibraryOptionsimport requests from io import StringIO import yfinance as yf%matplotlib inlineimport matplotlib.pyplot as pltimport mplfinance as mpf If you do not have any of those libraries installed, you know the drill with pip or conda install:
pip install –upgrade library_name We will pick up where we left off in . First, we will import the necessary utilities functions.
Here is a list of the utilities functions we will be using, followed by their respective source code:
def path_folder(folder, home = False): save and retrieve files in a folder: def path_folder(folder, home = False): if home == True: directory = pathlib.Path.home() / folder else: directory = pathlib.Path.cwd() / folder if not directory.exists(): directory.mkdir(parents=True, exist_ok=True) return directory .home() stands for home directory. Use it if you have a specified place where you want to park your data. If you want to store you data on top of your current working directory, use .cwd().def batch_px_df(tickers_list,batch_size,start,end,show_batch=False): download historical close prices using yfinance library: def batch_px_df(tickers_list,batch_size, start, end,show_batch = False): px_df = pd.DataFrame() loop_size = int(len(tickers_list) // batch_size) + 2 for t in range(1,loop_size): # Batch download try: m = (t - 1) * batch_size n = t * batch_size batch_list = tickers_list[m:n] if show_batch: print(batch_list,m,n) batch_download = yf.download(tickers= batch_list,start= start, end = end, interval = "1d", group_by = 'column',auto_adjust = True, prepost = True)['Close'] px_df = px_df.join(batch_download, how='outer') except: pass return px_df This batch_px_df() function downloads historical ['Close'] prices from a ticker_list. The show_batch Boolean gives you the option to see the tickers as they download.
def def_from_dict(batch_size,dct,start,end): uses previous function to download historical prices from a dictionary {ticker: name}: def df_from_dict(batch_size,dct,start,end): tickers_list = list(dct.keys()) df = batch_px_df(tickers_list,batch_size, start, end,show_batch = False) df = df.rename(columns = dct) df = df.tz_localize(None).ffill() return df This function uses the previous batch_px_df to download historical ['Close'] prices from a dictionary. keys.() are tickers, and .values() are corresponding names. The df is renamed to display names instead of tickers. For instance, ^GSPC becomes SP500.
def batch_df(tickers_list,batch_size, start, end,show_batch = False): download historical OHLCV prices: def batch_df(tickers_list,batch_size, start, end,show_batch = False): px_df = pd.DataFrame() loop_size = int(len(tickers_list) // batch_size) + 2 for t in range(1,loop_size): # Batch download try: m = (t - 1) * batch_size n = t * batch_size batch_list = tickers_list[m:n] if show_batch: print(batch_list,m,n) batch_download = yf.download(tickers= batch_list,start= start, end = end, interval = "1d", group_by = 'column',auto_adjust = True, prepost = True) px_df = pd.concat([px_df, batch_download], axis = 1) except: pass # px_df = px_df.tz_localize(None).ffill() return px_dfThis batch_df() function downloads OHLCV historical prices for a tickers_list in batches. This generates a multi-index dataframe.
def yf_droplevel(batch_download,ticker): get single security OHLCV from multiindex dataframe: def yf_droplevel(batch_download,ticker): df = batch_download.iloc[:, batch_download.columns.get_level_values(1) == ticker] df.columns = df.columns.droplevel(1) df = df.dropna() return df In order to extract data for a single security OHLCV from the multiindex dataframe, we need to get the values at level 1.
def multiindex_from_dict(df_dict, yf_format = False): creates a multiindex dataframe from a dictionary {ticker:df}: def multiindex_from_dict(df_dict, yf_format=False): multi_df = pd.concat(df_dict, axis=1) multi_df = multi_df.reindex(multi_df.columns, axis=1) if yf_format: multi_df.columns = multi_df.columns.reorder_levels([1, 0]) return multi_df This is the reverse process. multiindex_from_dict() generates a multiindex dataframe from a dictionary {ticker:df}. The default is level = 0 for ticker and level =1 for data. This is the inverse of the yfinance multi-index dataframe. If you want to have the same format as yfinance, then set the boolean to True. The current format makes it easy to download single security data. The yfinance format makes it easy to download data for a single field across the entire population of stocks.
def rohlc(df,relative = False): instantiate absolute/relative OHLC: def rohlc(df,relative = False): if relative==True: rel = 'r' else: rel= '' if 'Open' in df.columns: _o,_h,_l,_c = f'{rel}Open',f'{rel}High',f'{rel}Low',f'{rel}Close' elif 'open' in df.columns: _o,_h,_l,_c = f'{rel}open',f'{rel}high',f'{rel}low',f'{rel}close' else: _o=_h=_l=_c= np.nan return _o,_h,_l,_c rohlc() instantiates _o,_h,_l,_c variables. rohlc(df,relative = False): instantiates _o,_h,_l,_c in lowercase or title case, absolute or relative.
def rel_fx(df,_o,_h,_l,_c, bm_df, bm, ccy_df, ccy, start, end,rebase = False, mult = 1000): calculate relative series benchmarked, currency adjusted: def rel_fx(df,_o,_h,_l,_c, bm_df, bm, ccy_df, ccy, start, end,rebase = False, mult = 1000): df[ccy] = ccy_df.loc[start:end,ccy].copy().ffill() df[bm] = bm_df.loc[start:end,bm].copy().ffill() for i in [_o,_h,_l,_c]: df[f'r{i}'] = df[i].div(df[ccy]) if rebase == True: df[f'r{i}'] = df[f'r{i}'].div(df[bm]).mul(df[df[bm].notna()].iloc[0,list(df.columns).index(bm)]) else: df[f'r{i}'] = df[f'r{i}'].div(df[bm]) * mult return df rel_fx(df,_o,_h,_l,_c, bm_df, bm, ccy_df, ccy, start, end,rebase=True): returns price series adjusted for currency and benchmark.
Rebase boolean flag rebases prices to the beginning of the series or uses continuous series. The default is set to true. If you work with a rolling window, you might want to set the flag to False and use a larger multiplier (currently set at mult=1).
def last_row_cols_dict(df,ticker,df_cols): creates a dictionary of {ticker: last_value}: def last_row_cols_dict(df,ticker,df_cols): last_row_idx = df.index.max() col_dict = {'ticker':str.upper(ticker),'date':df.index.max()} for col in df_cols: if pd.isnull(df.loc[last_row_idx,col]): try: last_idx = df[pd.notnull(df.loc[:,col])].index[-1] col_dict.update({f'{col}_dt': last_idx}) col_dict.update({col : df.loc[last_idx,col]}) except: col_dict.update({f'{col}_dt': np.nan}) col_dict.update({col : np.nan}) else: col_dict.update({col : df.loc[last_row_idx,col]}) return col_dictlast_row_col_dict() creates a dictionary for the latest not null values across a subset of columns. When the last row is null, it searches for the last not null value and prints the date in the preceding column.
def col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ' ): prefix for relative and absolute series. def col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel'): if rel == False: col_name = name_abs elif rel == True: col_name = name_rel return col_name col_name_abs_rel() function names the prefix depending on whether we work with the absolute or relative series. def sector_avg_df(df_table, target_col, df_historical):def sector_avg_df(df_table, target_col, df_historical) """ Calculate the average absolute bullish/bearish count for each sector. """ df_historical = df_historical.copy().dropna(how='all', axis=1) df_historical.columns.names = ['ticker'] sector_df = pd.concat([df_table[[target_col]], df_historical.T], axis=1) sector_avg_df = pd.pivot_table(sector_df, values=list(df_historical.index), index=[target_col], aggfunc="mean").T return sector_avg_df Calculates sector historical average: We inherit this sector_avg_df() function from . It is used to calculate historical averages on multiindex dataframes.
Next, we will build a few charting functions using the library mpf. These are subplot functions. Think of them as add-on features to the mpf chart.
def mpf_score(data, score_list, alfa, color_bull, color_bear, pane, vlabel): plots and fills absolute and relative regimesdef mpf_score(data, score_list, alfa, color_bull, color_bear, pane, vlabel): score_fill = [] for r,scr in enumerate(score_list): if (scr in data.columns): if (data[scr].sum() != 0): score_bull = dict(y1= 0, y2= data[scr].values , alpha= alfa / (r+1), color= color_bull, where = (data[scr].values >= 0)) score_fill.append(score_bull) score_bear = dict(y1= data[scr].values, y2= 0 , alpha= alfa / (r+1), color= color_bear, where = (data[scr].values<= 0)) score_fill.append(score_bear) score = mpf.make_addplot(data[scr], fill_between = score_fill,color='blue', alpha = alfa/(r+1), panel= pane, ylabel = vlabel) subplots.append(score) mpf_score() function fills colors depending on the regime for the absolute, relative, and composite bullish/bearish scores. def mpf_relative(data, rel, sma_list, pane, color= 'grey'):def mpf_relative(data, rel, sma_list, pane, color= 'grey'): if (rel == True): _o,_h,_l,_c = rohlc(data,relative = rel) if (_c in data.columns): rel_plot = mpf.make_addplot(data[_c],type='line', color = color, ylabel = str(_c),alpha =1, mav = tuple(sorted(sma_list)), panel = pane) subplots.append(rel_plot) this function plots a relative line. This mpf_relative() function creates a separate pane for the relative series. Note that the relative 'Close' price is displayed as a line, not as an OHLC bar. It is essentially treated as an indicator.
def mpf_fill(data,_min, _max, color_bull, color_bear, alfa, pane, field): fills bullish and bearish areas: def mpf_fill(data,_min, _max, color_bull, color_bear, alfa, pane, field): fill_mpf = [] bull_profit = dict(y1= _min.values, y2= data[_c].values, alpha= alfa, color= color_bull, where = (data[field].values > 0) & (_min.values < data[_c].values)) fill_mpf.append(bull_profit) bull_loss = dict(y1= _min.values, y2= data[_c].values, alpha= alfa/2, color= color_bear, where = (data[field].values > 0) & (_min.values > data[_c].values)) fill_mpf.append(bull_loss) bear_profit = dict(y1= _max.values, y2= data[_c].values, alpha= alfa, color= color_bear, where = (data[field].values < 0) & (_max.values > data[_c].values)) fill_mpf.append(bear_profit) bear_loss = dict(y1= _max.values, y2= data[_c].values, alpha= alfa/2, color= color_bull, where = (data[field].values < 0) & (_max.values < data[_c].values)) fill_mpf.append(bear_loss) plot_fill = mpf.make_addplot(data[_c],fill_between = fill_mpf,color='blue', alpha=0 , panel= pane) subplots.append(plot_fill) This mpf_fill() function fills the space between a line and the 'Close' price. There are four categories:
bull_profit colored in color_bull when the regime is bull and the price is above the line.bull_loss colored in color_bear when the regime is bull and the price is below the line.bear_profit colored in color_bull when the regime is bear and the price is below the line.bull_loss colored in color_bear when the regime is bear and the price is above the line.Next, we will plot the fractals as ascending and descending triangles. Both opacity and size will change as fractals move higher up the levels of abstraction.
def mpf_fractals(data,col_name, fractals_list, color_up, color_down, size, pane): plots markers at swing highs and lows: def mpf_fractals(data,col_name, fractals_list, color_up, color_down, size, pane): for f in fractals_list: if (f'{col_name}Hi{f}' in data.columns): if (data[f'{col_name}Hi{f}'].count()>0): fractal_down = mpf.make_addplot(data[f'{col_name}Hi{f}'], type= 'scatter', markersize= int(size*f/max(fractals_list)) , marker= 'v', color= color_down, alpha = f/max(fractals_list),panel = pane) subplots.append(fractal_down) if (f'{col_name}Lo{f}' in data.columns): if (data[f'{col_name}Lo{f}'].count() >0): fractal_up = mpf.make_addplot(data[f'{col_name}Lo{f}'], type= 'scatter', markersize= int(size*f/max(fractals_list)) , marker= '^', color= color_up, alpha = f/max(fractals_list),panel = pane) subplots.append(fractal_up) if not (f'{col_name}Hi{f}' in data.columns) and not (f'{col_name}Lo{f}' in data.columns): break This function draws up and down triangle markers at swing highs and lows. The size and opacity of the triangles vary based on the level of abstraction. Level 2 markers are smaller and lighter than level 3 and 4, and so on. Fractals have multiple levels. Level 1 is calculated from the average price. Level 2 is calculated from level 1. Level 3 is reduced from level 2, and so on.
Before we explore various regime definition methodologies, let's build the benchmark (bm_df) and currency (ccy_df) dataframes. They will be used to calculate relative series. We will rebase every absolute series in US dollars, benchmarked to the S&P 500 index.
First, we instantiate all the variables, lists, and dictionaries that we will be using throughout this chapter.
batch_size = 51 ; start= '2015-01-01' ; end = Nonebm_ticker = 'SP500' ; ccy = 'local'st,lt, xlt = 20,50,100 ; bo_list = [st,lt,xlt] ; sma_list = [lt,st] ; n = 30rel_list = [True,False] ; score_list = ['score','score_rel','score_abs']start_mpf= -550 ; alfa = 0.6 ; color_bull = 'lightblue' ; color_bear = 'lightpink' ; color_down = 'r' ; color_up = 'g' ; size = 24 In a nutshell, we download data in batches of 51 securities at a time. We start from January 2015. We will benchmark the series to the S&P in USD ('local'). We have 3 durations: 20, 50, and 100 days. We will process the relative series first, and the absolute ones second. We will calculate scores for the absolute and relative series, as well as a composite of both.
start_mpf truncates the df to display the latest 550 days.Mpf display OHLC bars for limited durationsNext, let's build the currency (ccy_df) and benchmark (bm_df) dataframes.
dict_bm = { '^GSPC':'SP500','^DJI':'DOW30','^IXIC':'Nasdaq100','^NYA':'NYSE ', '^XAX':'Nyse_Amex','^RUT':'Russell2000','^VIX':'VIX', '^BUK100P':'CBOEUK100','^FTSE':'FTSE100','^GDAXI':'DAX','^FCHI':'CAC40', '^STOXX50E':'EuroStocks50','^N100':'Euronext100','^BFX':'BEL20', '^N225':'Nikkei225','^HSI':'HangSeng','000001.SS':'SSE','399001.SZ':'Shenzhen'}dict_ccy = { 'EURUSD=X':'EURUSD','JPY=X':'USDJPY','GBPUSD=X':'GBPUSD', 'AUDUSD=X':'AUDUSD', 'NZDUSD=X':'NZDUSD', 'USDCAD=X': 'USDCAD', 'EURJPY=X':'EURJPY','GBPJPY=X':'GBPJPY','EURGBP=X':'EURGB, 'EURCAD=X':'EURCAD','EURSEK=X':'EURSEK','EURCHF=X':'EURCHF', 'CNY=X':'USDCNY','HKD=X':'USDHKD','SGD=X':'USDSGD','INR=X':'USDINR', 'KRW=X' : 'USDKRW', 'TWD=X': 'USDTWD',}ccy_df = df_from_dict(batch_size,dict_ccy,start,end)ccy_df['local'] = 1bm_df = df_from_dict(batch_size,dict_bm,start,end).round(2)bm_df['absolute'] = 1print(bm_df.shape, ccy_df.shape) Next, let's download the major automakers' historical OHLCV data. In order of appearance, we have Toyota (7203.T), Nissan (7201.T), BYD (1211.HK), Hyundai Motors (005380.KS), Volkswagen (VOW3.DE), Renault (RNO.PA), Ford (F), Tesla (TSLA), and General Motors (GM).
bm = 'SP500'tickers_fx_dict = { '7203.T': 'USDJPY', '7201.T': 'USDJPY', '1211.HK':'USDHKD','005380.KS':'USDKRW','VOW3.DE' :'EURUSD', 'RNO.PA':'EURUSD','F':'local' ,'TSLA':'local' ,'GM':'local' }tickers_list = list(tickers_fx_dict.keys())multiIndex_raw_data = batch_df(tickers_list,batch_size, start, end,show_batch = False).round(2)multiIndex_raw_data.tail() We have created a multiIndex_raw_datadf that contains the OHLC prices for all the tickers named above.
In order to visually illustrate how various regimes look in practice; we will pick one stock from the list and visualize each regime. And the winner is the Rolls Royce (pun intended) of short-sellers, Nissan (7201.T).
ticker = '7201.T'raw_df = yf_droplevel(multiIndex_raw_data,ticker)_o,_h,_l,_c = rohlc(raw_df,relative = False)df = rel_fx(raw_df,_o,_h,_l,_c, bm_df, bm, ccy_df, tickers_fx_dict[ticker], start, end,rebase=True, mult=1)df.shape Now that the supporting code and functions are out of the way, let's proceed to the next stage: regime definition, starting with range breakout.
We will proceed to define regime using various methodologies. We will start with the oldest methodology, breakout.
"Kites rise highest against the wind—not with it."
– John Neal, posthumously attributed to Sir Winston Churchill
Breakout is the oldest and simplest trend-following method. It works for both bull and bear markets. If the price makes a new high over x number of periods, the regime is bullish. If the price makes a fresh low over x number of periods, the regime is bearish. This method is computationally easy to implement.
Popular durations are 252 trading days (one business year), 100 trading days, and 50 trading days. The following code is a simple rendition of this regime methodology:
def regime_breakout(df,_h,_l,n): hl = np.where(df[_h] == df[_h].rolling(n).max(),1, np.where(df[_l] == df[_l].rolling(n).min(), -1, np.nan)) roll_hl = pd.Series(index= df.index, data= hl).ffill() return roll_hl The way this code functions is simple:
hl is 1.hl is ‑1.hl is N/A.ffill() method. First, we convert the numpy array to a pandas series.ffill() forward fill method.In , we displayed the currency-adjusted relative series for various auto manufacturers around the world. In this chapter, we will take this example one step further. We will display various regime definition methodologies for both absolute and relative series.
One picture is worth a thousand words. We will therefore illustrate each regime methodology with its shadows. We will repeat the same block of code with different methodologies and plot the result. We will publish the code once.
fields_list = [] ; fields_list_rel = [] ; subplots = [] ; pane = 1mpf_relative(df[start_mpf:], rel=True, sma_list= sma_list, pane=1, color= 'k')for rel in rel_list: _o,_h,_l,_c = rohlc(df,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') for t, slt in enumerate(sorted(bo_list)): bo_field = f'{col_name}BO{slt}' df[bo_field] = regime_breakout(df,_h,_l,slt) _min = df[start_mpf:][_l].rolling(slt).min() _max = df[start_mpf:][_h].rolling(slt).max() mpf_fill(df[start_mpf:], _min, _max, color_bull, color_bear, alfa/(t+1), pane, field= bo_field) fields_list.append(bo_field) if rel: fields_list_rel += fields_list pane = 0 fields_list_abs = list(set(fields_list) - set(fields_list_rel)) df['score_abs'] = round(df[fields_list_abs].mean(axis=1),2).valuesdf['score_rel'] = round(df[fields_list_rel].mean(axis=1),2).valuesdf['score'] = round(df[fields_list].mean(axis=1),2).valuesmpf_score(df[start_mpf:], score_list, alfa, color_bull, color_bear, pane= 2, vlabel = 'score')mpf.plot(df[start_mpf:], type= 'ohlc', style= 'charles', figsize= (16,4), title = f'{ticker} {fields_list}', mav = tuple(sorted(sma_list)), addplot= subplots, volume= True, volume_panel = 3, panel_ratios = (2,2,0.5,0.5)) This block of code will print every regime methodology for every stock. This will rapidly become repetitive without much marginal contribution. We will go through the process once:
regime_breakout.bo_list [20, 50, 100]. The longer the duration, the lighter the shade of color_bull or color_bear.And this is the result:

Figure 4.1: Nissan breakout regime definition
Nissan fits the definition of a structural short. This is a low-volatility, long-way-down. Very few shorts are like this example.
This range of breakout strategy works wonders when the price breaks out after consolidation or a sideways market. Sideways markets are the interim periods between upward or downward trends, when the old regime agonizes, and the new has yet to flourish. In a sideways, undulating market, price oscillates in a range.
Bulls fight bears in a Gilgamesh epic battle. When prices break out from the upper or lower bound, this signals one side has thrown in the towel. Pent-up energy is released. The price moves effortlessly along the line of least resistance. The main drawback of this method is its built-in lag, which comes from the lookback period. In financial revisionist jargon, the waiting period is called confirmation. Jittery market participants rarely have the serene, enlightened equanimity to twiddle their thumbs for 50 days, 100 days, or even a year to finally find some resolution. Time is money. Stocks late on their rent should either be reduced or kicked out. Market participants who use this method with a long duration may want to re-introduce a partial time exit into their strategy. For example, halve size with positions that have failed to make new highs/lows after half the duration. Are resources better deployed on a slow burning low volatility or explosive market?
The main advantages of this method are computational simplicity and stability. The major drawbacks are its inherent lag and the discomfort of giving back large amounts of profits. This leads us to the next iteration: the asymmetrical range breakout strategy.
Further refinements to the breakout regime definition method include dissociated periods for entries and exits. For example, the legendary Chicago Turtle Traders entered on 50-day highs and closed on 20-day lows. This asymmetrical duration is a subset of the breakout methodology.
The turtle strategy is a rudimentary script inspired by the legendary Turtle Traders. It is composed of two range breakout regimes. The slower duration is used for entries. The faster duration is used for exits. This asymmetrical duration for entry and exit relies on a time-honored principle: be prudent and deliberate to confirm trends, but quick and decisive to cut losses and protect profits. This last regime will be renamed simplified Turtle from now on. It is a rudimentary script, yet it still works.
We will be recycling this basic strategy throughout this book to illustrate examples, purely for educational purposes. However, do not do this at home—it is realistic enough for educational purposes, but too simplistic to be deployed in real-money production.
def turtle_trader(df, _h, _l, st, lt): ''' _lt: Long/Short direction _st: trailing stop loss ''' _lt = regime_breakout(df,_h,_l,lt) _st = regime_breakout(df,_h,_l,st) turtle = pd. Series(index= df.index, data = np.where(_lt == 1,np.where(_st == 1,1,0), np.where(_lt == -1, np.where(_st ==-1,-1,0),0))) return turtle This script builds on the regime_breakout function. When the _lt does not match the _st, go neutral.
The longer duration gives the direction: long or short. The shorter duration is the stop loss. We will discuss stop losses in later chapters. The shorter duration protects profits by narrowing the range. The flipside of this is an elevated trading frequency, higher transaction costs, and turnover. This set of parameters for the Turtle strategy does not work well in sideways, choppy markets. We will skip the code and directly plot the corresponding graphs. This produces the following chart:

Figure 4.2: Nissan Simplified Turtle
This asymmetrical duration enables traders to capture small profits in nimble markets. The drawback of the turtle strategy is the increase in trading frequency. Even though the general direction is correct, you may get stopped a few times in volatile markets. This will increase transaction costs and slippage and reduce the efficacy of the strategy.
Next, let's go over the classic moving average crossover strategy.
Moving average crossover is another classic regime definition method. This method is so simple and prevalent that even the most hardcore fundamental analysts who claim never to look at charts still like to have a 200-day simple moving average. This method is also computationally easy. There may be further refinements as to the type of moving averages, from simple to exponential, weighted, triangular, or adaptive. Yet, the principle remains the same. When the faster moving average is above the slower one, the regime is bullish. When it is below the slower one, the regime is bearish. The following code shows how to calculate the regime with two moving averages using simple and exponential moving averages (SMA and EMA respectively):
def regime_sma(df,_c,st,lt): sma_lt = df[_c].rolling(lt).mean() sma_st = df[_c].rolling(st).mean() rg_sma = np.sign(sma_st - sma_lt) return rg_smadef regime_ema(df,_c,st,lt): ema_st = df[_c].ewm(span=st,min_periods = st).mean() ema_lt = df[_c].ewm(span=lt,min_periods = lt).mean() rg_ema = np.sign(ema_st - ema_lt) return rg_ema This regime_sma() function is as simple as it gets. Regime is the sign difference between the short-term and long-term moving averages. Next, we will plot the regime_sma().
We recycle the mpf_fill function. Instead of calculating a minimum and maximum, we repeat the sma function.

Figure 4.3: Nissan simplified turtle
The lighter shade is the envelope between the short- and long-term moving averages. The darker shade is the space between the price and the shorter duration. Here, we can also observe the bull, _loss, and bear_loss. The longer moving average has a lighter shade.
Next, we will calculate fractals.
"Talk is cheap. Show me the code."
– Linus Torvalds
Fractals are inspired by the seminal work of the Belgian mathematician Benoit Mandelbrot. The concept makes intuitive sense. A fractal low is a low surrounded by two higher lows. A fractal high is a high surrounded by two lower highs. In technical analyst creole, fractals are usually referred to as swings. For the rest of the discussion, think of fractals as swing highs and swing lows. Swings alternate: a fractal high follows a fractal low.
Here is a visual example of a succession of fractals: low, high, and low. Let's take a look.

Figure 4.4: GM fractal low
The first fractal low level 1, symbolized by an upward triangle marker, is printed on the second bar from the left. The low of this bar is surrounded by two higher lows. The third bar from the left prints a swing low level 1, symbolized by a downward triangle marker. The high of bar 3 is higher than the highs on bars 2 and 4. The fourth bar prints another fractal low level 1, symbolized by an upward triangle marker. Bars 3 and 5 print higher lows than bar 4. So, bar 4 qualifies as a fractal low level 1. Let's recapitulate: bars 2 and 4 are fractal lows level 1. Bar 3 is a fractal high level 1. Level 1 stands for first level of abstraction. The source data is HLC price series.
The second fractal low on bar 4 is higher than the first one on bar 2. We have a fractal high level 1 between those fractal lows level 1. Since the bar preceding the left fractal also closes higher, this means the first fractal low level 1 on bar 2 also qualifies as level 2. We have a lower low surrounded by two higher lows. Level 2 stands for the second level of abstraction. The source data is fractals level 1 series. Level 3 will use level 2 data, and so on and so forth.
We have a rapid succession of fractal lows and highs. Note that the fractals are neither at the low nor at the high of their bar. Markets are noisy. Markets sometimes print a local low and a local high on the same day. However, the market cannot simultaneously print a fractal high and a fractal low. There has to be a succession of fractal highs and lows. We must determine which local high or low will become a fractal. There are essentially two ways to solve this simultaneous high/low occurrence. This boils down to which price series we choose to use to calculate fractals.
Either we choose a single point like the "Close" price, or we calculate the average of HLC price. We tried both. It quickly became apparent that the highest/lowest "Close" did not coincide with the true local "High/Low". The fractal based on the "Close" price would be a few bars away from the actual fractal. Therefore, by convention, we base our calculation on the average of the "High", "Low", "Close". Markets may err on the way up and down during a session, but where it chooses to end gives us a clue as to how things are going next.
Fractals are recursive, complex, two-dimensional series. In execution trader English, this means we calculate the current numbers from the previous series:
_h, _l, _c will determine Hi1, Lo1Hi1, Lo1 will be used to calculate Hi2, Lo2Hi2, Lo2 will be used to calculate Hi3, Lo3 and so onFractals have three major benefits:
Fractals may not need parameters, but they return levels of abstraction. For example, level 1 is derived from the price series. Those series are still very noisy. Level 2 series have filtered the noise. Level 3 gives inflections in trends, etc. In practical terms, swings level 1 are based on prices. Swings level 2 are based on swings level 1. Level 2 daily swings are predominantly used in swing trading. Swings level 3 are the bullish or bearish trends used in trend following trading.
Bottom line: In theory, we could potentially trace the inception of a bear market back to a specific 1-minute bar that triggered an avalanche. Hold that thought…
This method is a departure from the first edition of this book. This methodology is much faster, more accurate, and more computationally friendly.
We break it down into four functions:
def avg_px(df, _h, _l, _c): Calculates the average price using HLC bars. We prepare the data into a single series that will be used in the fractals_df.def fractal(px, lvl): This function identifies fractals at multiple levels.def fractals_df(df, col, col_name): This function calculates fractal swing Highs and Lows at multiple levels of abstraction.def fractals_dates(df, col, col_name): This function calculates the dates when fractals at each level are discovered.The first function calculates the average price from OLH. The second function identifies fractals.
def avg_px(df, _h, _l, _c): return df[[_h,_l,_c]].mean(axis=1)def fractal(px, lvl): max_lvl = np.minimum(2,lvl) fractal = px[(px<= px.shift(-1))&(px< px.shift(+1)) & (px<=px.shift(-max_lvl))&(px< px.shift(+ max_lvl))] return fractal Next, let's go into more detail with the fractals_df function:
def fractals_df(df, col, col_name): ffill_list = [] hilo = df[col].copy() n = 1 temp_cols = {} # Dictionary to store new columns while len(hilo) >= 3: lows = fractal(hilo, n) if not lows.empty: last_low = lows.iloc[-1:].copy() else: last_low = pd.Series() highs = -fractal(-hilo, n) if not highs.empty: last_high = highs.iloc[-1:].copy() else: last_high = pd.Series() hilo = lows.add(highs, fill_value=0).dropna() level_low_col = f'{col_name}Lo{n}' level_high_col = f'{col_name}Hi{n}' hilo_col = f'{col_name}HiLo{n}' if (not lows.empty) and (not highs.empty): if (not last_low.empty) and (not last_high.empty) and (last_low.index[-1] > last_high.index[-1]): lows = pd.concat([fractal(hilo, 1), last_low]) highs = -fractal(-hilo, 1) elif (not last_low.empty) and (not last_high.empty) and (last_low.index[-1] < last_high.index[-1]): lows = fractal(hilo, 1) highs = pd.concat([-fractal(-hilo, 1), last_high]) hilo = lows.add(highs, fill_value=0).dropna() temp_cols[level_low_col] = lows temp_cols[level_high_col] = highs temp_cols[hilo_col] = hilo elif not lows.empty: temp_cols[level_low_col] = lows temp_cols[hilo_col] = lows elif not highs.empty: temp_cols[level_high_col] = highs temp_cols[hilo_col] = highs ffill_list.append(hilo_col) n += 1 if hilo.empty: break # Concatenate all columns in temp_cols with df in one operation if temp_cols: # Check if temp_cols is not empty temp_df = pd.DataFrame(temp_cols) df = pd.concat([df, temp_df], axis=1) df.dropna(how='all', axis=1, inplace=True) df = df.loc[:, ~df.columns.duplicated(keep='last')] list_ffill = [col for col in ffill_list if col in df.columns] if list_ffill: # propagate HiLo values forward df[list_ffill] = df[list_ffill].ffill() return df This was a relatively long function. However, considering we solved Benoit Mandelbrot's conjecture in fewer than 100 lines, this is not bad.
hilo series from a specific column in the main df.hilo_df is longer than 3. Separate lows and highs by assigning a negative value to the series.last_low and last_high. If last_low happens later than last_high, concatenate last_low with hilo; else concatenate last_high with hilo.temp_cols dictionary with lows, highs, and hilo.ffill_list with hilo_col.temp_df from the temp_cols.temp_df.DropNA and duplicate columns, saving the last one.hilo values in the list_ffill using the .ffill() method.This fractals_df() function finds historical swings. Next, let's try to identify when those fractals happen. If we want to find when a level 4 fractal happens, we need to identify the price bar that triggered a level 1, 2, and 3 fractal. There are essentially two ways to find those bars. The hard way is to loop through the dataframe and recalculate bar after bar.
The other way is to slice and reduce the dataframe. The following fractals_dates() function may be short, but it packs a serious punch.
def fractals_dates(df, col, col_name): """ Identifies when fractal patterns are discovered and records: 1. The fractal value at discovery time (_fc) 2. The price at the next data point (_chg) """ # Initialize a dictionary to hold temporary columns temp_cols_dict = {} for hl in ['Hi', 'Lo']: if (hl == 'Lo'): high_cols_df = pd.DataFrame.from_dict(temp_cols_dict) hilos = df.copy() n = 1 ; s = 1 while f'{col_name}{hl}{n}' in hilos.columns: hilos = hilos[ (pd.notnull(hilos[f'{col_name}{hl}{n}'])) | (pd.notnull(hilos[f'{col_name}{hl}{n}'].shift(s))) ] temp_cols_dict.update({f'{col_name}HiLo{n}_chg': hilos.loc[ (pd.isnull(hilos[f'{col_name}{hl}{n}'])), col], f'{col_name}HiLo{n}_fc': hilos.loc[ (pd.isnull(hilos[f'{col_name}{hl}{n}'])), f'{col_name}HiLo{n}'] }) n +=1 ; s = 3 low_cols_df = pd.DataFrame.from_dict(temp_cols_dict) hilo_cols_df = high_cols_df.add(low_cols_df, fill_value =0) df = pd.concat([df, hilo_cols_df], axis=1) df = df.loc[:, ~df.columns.duplicated(keep='last')] df[hilo_cols_df.columns] = df[hilo_cols_df.columns].ffill() return df This function identifies the bar at which a higher-level fractal is detected. In theory, it can identify the 1-minute bar at which a daily bar market turned bullish or bearish. This is a bit like a Matryoshka, that is, a Russian nested doll. The df is reduced from level 1 to 2, 2 to 3, and so on. The logic may be intuitive, yet the computation is anything but trivial. So, let's go through the logic first, then we will go to the code implementation:
Our job is to find the bar that preceded the fractal 1 that ended up at higher levels. So, for a level 5 to be detected, we need all the lower levels to be discovered, all the way down to the price action that triggered the level 1. This function identifies the bar after the peak/trough HLC series that will trigger a fractal 1. This will produce a fractal 2, which will generate a fractal 3, and so on. It is a gradual process of elimination of all the intermediate fractals leading to higher fractals. This is why level 3, 4, and 5 fractals are discovered months later. The logic is relatively simple. Now, let's go through the code:
['Hi', 'Lo']. We create a hilosdf by copying the main df.'Hi', we generate a dataframe from the dictionary temp_cols_dict.'Hi{n}' are found in the hilos dataframe.s =1. For every subsequent level, s = 3.rel == "Lo", we create another df from a dictionary.hilo_cols_df by adding both low_cols_df and high_cols_df.This gives us two continuous series with the following suffixes:
'_chg': the price at the time the fractal was discovered'_fc': the fractal price at the time the fractal was discoveredLet's show what it looks like in practice:
t = 2df[-500:][['Close',f'abs Lo{t}', f'abs Hi{t}', f'abs HiLo{t}',f'abs HiLo{t}_fc', f'abs HiLo{t}_chg' ]].plot(figsize=(15,5), style= ['k','g.','r.','b:','m','c--'], title = f'{ticker} fractals {t}', grid=True) This produces the following chart:

Figure 4.5: Nissan fractals values at the time of discovery
This fractals method is not entirely foolproof. There are a few cases where the change seems off, especially when working with intraday short-term resolution where data quality sometimes leaves something to be desired. It does, however, do a quick and "easy" job.
The fractals_dates(df, col, col_name) is not useful when working with continuous data in production. We will look at a more efficient way to update the df later in this chapter.
Fractals do not confer regime. Fractals are merely swing highs and lows. As we will see in the next paragraph, combinations of those swings define regime. So, we replaced the single methodology score panel with volume. This produces the following chart:

Figure 4.6: Nissan fractals highs and lows
Let's look at the code that produced the above chart:
subplots = [] ; pane = 1fractals_list = [2,3,4,5,6]mpf_relative(df[start_mpf:], rel=True, sma_list= sma_list, pane=1, color= 'k')for rel in rel_list: _o,_h,_l,_c = rohlc(df,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') col = f'avg_{col_name}' df[col] = avg_px(df, _h, _l, _c) df = fractals_df(df, col, col_name) print('fractals_df',df.shape) df = fractals_dates(df,col,col_name) mpf_fractals(df[start_mpf:],col_name, fractals_list, color_up, color_down, size, pane) pane = 0mpf.plot(df[start_mpf:], type= 'ohlc', style= 'charles', figsize= (15,5), title = f'{ticker} {fields_list}', mav = tuple(sorted(sma_list)), addplot= subplots, volume= True, volume_panel = 2, panel_ratios = (2,2,0.5,)) This is a little departure from the charts that precede and follow. We do not print the regime, so we need to reduce the subplots accordingly panel_ratios = (2,2,0.5,). Plotting all the fractals would create noisy charts. We select the levels we want to plot through the fractals_list.
The higher the level of abstraction, the more information fractals carry. We reflect this preeminence on the chart in two ways: size and opacity (alpha). Markers become bigger and darker as fractal levels increase.
Those fractals are very powerful. They constitute the basis for a lot of technical analysis. We can identify support, resistance, channels, trendlines, pennants, wedges, chart patterns, and pretty much the entire lexicon of technical analysis patterns using only level 1 or 2 fractals.
For now, we will focus on a classic pattern called higher highs, higher lows.
This is another popular method. Stocks that trend up print higher highs and higher lows. A bullish market pulls both swing lows and swing highs upward. Conversely, stocks trending down make lower lows and lower highs in that order and therefore suggest sustained weakness. A bearish market pushes swing highs and lows downward. The theory makes intuitive sense. In practice, things are a little more complicated.
Markets sometimes print lower or higher lows or highs that throw calculations off before resuming their journey. Secondly, this method requires three conditions to be simultaneously met:
Those three conditions must be met consecutively in that precise order for the regime to turn bearish. Markets are more random and noisier than people generally assume. Let's explore a few scenarios and how we choose to deal with them:
There is no solution, only trade-offs. Tighter stops translate into higher transaction costs. Looser rules mean giving back a larger portion of profits. So, we decided to keep the code flexible and let readers choose. The variable shft is set at 2 by default for the penultimate ([n‑1]) swing.
In practice, we opted to give this regime definition a little more wiggle room to accommodate noise and randomness. Patterns can be disrupted and interrupted. The regime will not change until conditions on the other side are met. Without further ado, let's publish the code:
def higherhighs_df(df,_c,col_name,shft= 2): v = 1 while (f'{col_name}Lo{v}' in df.columns) & (f'{col_name}Hi{v}' in df.columns): lh = pd.DataFrame() hl = f'{col_name}HiLo{v}_hl' lhv = f'{col_name}LoHi{v}' lh[lhv] = df[f'{col_name}Lo{v}'].sub(df[f'{col_name}Hi{v}'],fill_value =0).dropna() lh.loc[(np.sign(lh[lhv]) < 0) & (np.sign(lh[lhv] * lh[lhv].shift()) < 0) & (-lh[lhv] < -lh[lhv].shift(2)) & (lh[lhv].shift(1) < lh[lhv].shift(3)), hl] = -lh[lhv].shift(shft) lh.loc[(np.sign(lh[lhv])>0) & (np.sign(lh[lhv] * lh[lhv].shift()) < 0) & (lh[lhv] > lh[lhv].shift(2)) & (-lh[lhv].shift(1) > -lh[lhv].shift(3)), hl] = lh[lhv].shift(shft) lh = lh.dropna(subset = [hl]) df[hl] = lh[hl] df[hl] = df[hl].ffill() df[ f'{col_name}HiLo_HH{v}' ] = np.sign(df[_c].sub(df[hl],fill_value=0)) v += 1 df = df.loc[:, ~df.columns.duplicated(keep='last')] return df This higherhighs_df() function uses the fractals to find higher highs, higher lows, lower lows, and lower highs. Let's break down the code:
lh for lows and highs.Dropna.lh df, drop one column.lh with the main df, propagate values using forward .ffill().hilo values. The main advantages of this method are entries and exits. On the long side, buy long on a low and exit on a high. On the short side, sell short on a high and exit on a low. These counter-trend entries and exits enable market participants to capture profits. Furthermore, stop losses are objectively defined at the higher low on the long side and at the lower high on the short side.
Next, let's plot the higherhighs_df() method:
mpf_fill. This function uses the score to assess whether the regime is in bullish or bearish territory. Every fractal contains some information. Rather than sticking with one level, such as 2 or 3, we create a weighted average df[f'{col_name}HiLo_HH'].fractals_list [2, 3, 4, 5, 6]. The higher the level of abstraction, the lighter the shade.This produces the following chart:

Figure 4.7: Nissan higher highs
The preceding chart in absolute terms shows the lag occurring at higher levels of abstraction. While the regime had already turned bearish at levels 2 and 3, level 4 stayed bullish deep into a bearish phase. The simplest way to overcome the lag is to assign heavier weight to lower levels in the weighted average calculation.
Next, let's explore the cousin of the higher highs: the floor and ceiling. The following method uses the same swing highs and swing lows to define the regime in a much more powerful way. It is simple and statistically robust.
The floor and ceiling method is originally a variation on the higher high/higher low methodology. Everyone has intuitively used it. And yet it is so obvious that no one has apparently bothered to formalize it. The floor and ceiling methodology is intuitive, yet computationally taxing.
Anyone who has spent some time on the markets has heard of the head and shoulders pattern. This looks like a peak (the head) surrounded by two lower highs (the shoulders). The floor and ceiling methodology focuses exclusively on the right shoulder. After all, there would not be a head if there were no left shoulder to begin with. Unlike the higher high/higher low method, only one of the two following conditions must be fulfilled for the regime to change:
The classic definition is always valid regardless of the time frame and the asset class. There will be a trough followed by higher lows at the onset of every bull market. There will be a peak followed by lower highs to mark the beginning of a bear market. Those conditions are sufficient and necessary to qualify a regime change.
Randomness triggers exceptions that are handled in a simple, elegant way. There are two methods:
This floor and ceiling method has only two regimes: bull or bear. A sideways regime is a pause within a broader bull or bear context. This method brings stability to regime definition. In practice, nothing is more costly than flip-flopping around a moving average. Stability enables market participants to better manage their positions.
The following function considers the desire for accuracy and the need for speed. In production, prices are refreshed continuously. The previous fractals_dates function becomes irrelevant. Fractals are recalculated every time a new bar appears. The regime will be the sign difference between the 'Close' price and the fractal value. If price penetrates the fractal, then this was not a valid swing high or low to begin with. The logic is sound and simple.
def floorceiling_df(df,_c,col_name): ''' Regime is the sign difference between the close price and the base price. It can be the floor and ceiling price or the average price at the time the fractal discovered later. 1. _fc (default when '_fc' is in the df.columns): Floor and ceiling price at the time the Floor and ceiling is discovered later (Built-in lag) 2. _chg: Average price at the time the fractal is discovered later (Built-in lag). Write 'chg' in the setting of the function to use this condition 3. else: Floor and ceiling price at the time of the floor and ceiling fractal. If none of the above conditions are met, the default is the floor and ceiling price at the time of the fractal ''' rg_FC_dict = {} v= 2 while f'{col_name}HiLo{v}' in df.columns: if (f'{col_name}HiLo{v}_chg' in df.columns): rg_FC_dict[f'{col_name}HiLo_FC{v}'] = np.sign(df[_c].rolling(2).mean().sub( df[f'{col_name}HiLo{v}_chg'],fill_value = 0)) elif (f'{col_name}HiLo{v}_fc' in df.columns): rg_FC_dict[f'{col_name}HiLo_FC{v}'] = np.sign(df[_c].rolling(2).mean().sub( df[f'{col_name}HiLo{v}_fc'],fill_value = 0)) else: rg_FC_dict[f'{col_name}HiLo_FC{v}'] = np.sign(df[_c].rolling(2).mean().sub( df[f'{col_name}HiLo{v}'], fill_value = 0)) v+=1 df = pd.concat([df,pd.DataFrame(rg_FC_dict)], axis =1) df = df.loc[:, ~df.columns.duplicated(keep='last')] return df This floorceiling_df() function has three levels of calculation. They are the following:
Columns with the suffix '_chg' show the price at the time the fractal was discovered. This lags the fractal by a fair amount of time and price discount. As a result, it is much closer to the current 'Close' price. Regime is therefore a lot more nervous. This configuration is an attempt at making the regime closer to the reality of trading the markets. Market participants act with imperfect information under stress. Note that the suffix '_chg' is given by the function fractals_dates(df, col, col_name).
If the suffix '_chg' is not present, the regime will be the sign difference between the fractal value and the current 'Close' price.
np.sign() to convert differences to regime signals: +1 (bullish), -1 (bearish), 0 (neutral).Next, let's illustrate this with a chart. We recycle the same block of code to:
We recycle the same block of code to plot floor and ceiling regimes across fractal levels on a candlestick chart with volume and score panels.

Figure 4.8: Nissan floor and ceiling
This regime definition is more nervous than the higher highs. The presence of a natural stop loss, e.g., the 'Close' price at the time the fractal was discovered, induces a lot of stops. In production, this can be circumvented by setting the default to the fractal price. This will also mean smaller position sizes and reduced risk appetite. There are no solutions, only trade-offs.
We have empirically calculated a composite score. It is time to formalize it into a proper function.
"Paradoxically, the best way for a group to be smart is for each person in it to think and act as independently as possible."
– James Surowiecki, The Wisdom of Crowds
This is a substantial departure from the first version of this book. We really wanted to show the primacy of the Floor and Ceiling methodology. This signal universally works across a) asset classes and b) timeframes without c) additional need to reset the settings, and d) is more robust than classic methods. Furthermore, anyone from absolute beginner to market expert can intuitively understand this signal. At that time, it seemed like a good idea to proclaim the Floor and Ceiling as the winner of the regime definition contest.
A few years later, the thinking process has matured. The markets might be an epic adventure, but this is not a quest like The Lord of the Rings. There is NO "one signal to rule them all".
In reality, trading signals are more like French wines. French wines are famous for blending varietals to increase complexity and flavor. Chateauneuf-du-Pape blends as many as 13 varietals to come up with a unique, delicious flavor. Well, the more signals and methodologies we include in the mix, the more nuanced the regime definition might turn out. There is unquestionably value in blending signals. Perhaps we should go as far as to include countertrend or mean-reverting signals to account for euphoria or depression. Bottom line, a simple average of all methods is better than any single one in particular.
Our objective is to blend regime definition methods into one continuous score. The strength of the signal increases as more methods turn either bullish or bearish. Conversely, it weakens as the methods disagree.
We will start with the fractals. Regime is calculated at every fractal level. Each one carries some information. For example, one may choose to interpret each level on the daily chart as:
We will calculate a weighted average of all the methods. This is a 2-step process. First, we calculate the composite fractals regimes. Secondly, we calculate a composite across all regimes.
def dict_fractal(col_name, rg_method, fractal_dict): ''' col_name: 'abs ' or 'rel ' rg_method: 'HiLo_FC' or 'HiLo_HH' fractal_dict: for example {1: 0.1,2:0.4, 3:0.3, 4:0.2} ''' return {f'{col_name}{rg_method}{v}':fractal_dict[v] for v in fractal_dict} This function is a dictionary comprehension. We blend the column name, the regime method, and the fractal level with their corresponding weights.
This produces a flexible weight mapping that enables weighted averaging of regime signals across multiple fractal levels and timeframes.
Next, let's build a function that calculates a weighted average of blended regime. This function can be used across multiple regime definition methodologies.
def rg_dict_blend(df, rg_dict): blend = np.zeros(len(df)) n = 0 for rg in rg_dict: if rg in df.columns: if rg_dict[rg] > 0: blend += df[rg] * rg_dict[rg] n += rg_dict[rg] else: blend += df[rg] n += 1 return round(blend / max(n,1),2) This function calculates the weighted average of a regime dictionary. We iterate through the dictionary. If values are not null, then we append the weighted column to the composite weighted average.
This produces a single blended regime score combining multiple signals with a flexible weighting scheme.
Next, let's calculate a dictionary score for all the regime definition methods:
for rel in rel_list: print(rel, end = ', ') _o,_h,_l,_c = rohlc(df,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') rg_cols_dict = {f'{col_name}BO{st}':0.1, f'{col_name}BO{lt}':0.2, f'{col_name}BO{xlt}':0.1, f'{col_name}TT{st}/{lt}':0.2, f'{col_name}SMA{st}/{lt}':0.2, f'{col_name}EMA{st}/{lt}': 0.2, f'{col_name}HiLo_HH':0.25,f'{col_name}HiLo_FC':0.25} if rel == False: score_abs_dict = rg_cols_dict elif rel == True: score_rel_dict = rg_cols_dictscore_dict = {**score_abs_dict, **score_rel_dict}df['score_abs'] = rg_dict_blend(df, score_abs_dict)df['score_rel'] = rg_dict_blend(df, score_rel_dict)df['score'] = rg_dict_blend(df, score_dict)df[['Close', 'score_abs', 'score_rel', 'score']].plot(figsize=(15,5), style = ['k', 'c-.', 'y:', 'm'], secondary_y = 'Close',grid=True, title = f'{ticker} score abs, rel and blended') We take both functions for a spin. We will create a dictionary of regime methods and their corresponding weights. We then create a score for the absolute series and for the relative series. We then concatenate the two dictionaries, absolute and relative, into a composite score. We plot the absolute, relative, and overall scores along with the 'Close' price for the entire duration of the data frame.
We combine multiple technical indicators (breakouts, trend transitions, moving average crosses, higher-highs, floor, and ceiling) with predefined weights to generate absolute, relative, and composite regime scores.
rg_dict_blend() to compute three scores: absolute only, relative only, and combined.This produces three blended scores enabling multi-perspective regime confirmation: absolute for intrinsic moves, relative for benchmark-adjusted signals, and composite for a holistic view.
Let's visualize this with the following chart.

Figure 4.9: Nissan scores absolute, relative, and composite
This is "wisdom of the crowds" applied to the stock market. The blended score really captures market gyration. We assigned random weights and semi-random durations to the moving averages and breakout ranges. Still, the weighted average comes back with something half decent.
This composite score is an avenue worth exploring. Multiply this score by a position sizing algorithm and you will have a strength-adjusted position sizing in real time. Let's not get ahead of ourselves for now. We will revisit the idea further down the book. For now, let's apply what we have built so far to the entire population of stocks and store it in an arctic database.
Before we process the data for the entire tickers list, let's take a few minutes to set up a database that we will be reusing throughout the book.
Arctic database has been developed by the Man Group since 2012. It was released to the public in 2023. It is designed by quants for quants. arcticdb is open source (terms and conditions apply), fast, and easy to use. For more information on arcticdb, visit their GitHub page: . One major drawback is that arcticdb does not work with the latest Mac M series processors.
Without further ado, let's initialize an arcticdb database.
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,)# library_options=LibraryOptions(dynamic_schema=True)) return library Defining arcticdb is simple and effortless, as we will see below.
We will create helper functions to initialize Arctic database connections and retrieve single or multiple columns across multiple symbols efficiently.
initialise_adb_library_local() connects to the Arctic database using local file system storage (LMDB) and creates the library if it is missing.adb_concat_single_column() filters symbols that exist in the library, reads a specific column for each symbol, renames columns by symbol name, and concatenates into a wide DataFrame.We have now created reusable database connection and data extraction functions for working with Arctic's columnar storage across multiple assets.
{ticker:df}. If we want to retrieve one field, such as 'Close' or 'score', across the DB, we need to query ticker by ticker. So, let's build two simple functions that allow us to build dataframes. Let's start with a single field dataframe. 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) This is predictably a list comprehension:
arcticdb.column_name in a library for every symbol in a symbols list.Now, let's set up or initialize our arcticdb. Brace for impact:
library = initialise_adb_library_local('data', 'autos') That's it. The database is stored in a folder called data. It sits on top of the current working directory. For more information on how to append, update, write, or slice arcticdb, please go to their GitHub page.
Now that we have built regime definition functions and set up a database, it is time to calculate regime across our investment universe.
All the building blocks are in place. It is now time to run the regime definition functions across the entire investment universe.
We will use two ways to store information for the entire universe. One is a multi-index dataframe {ticker:df}. The other is arcticdb. We will also create a snapshot of every ticker's last row.
fractal_dict = {1: 0.1,2:0.4, 3:0.3, 4:0.2}df_dict = {} ; last_row_list= []for ticker in tickers_list: raw_df = yf_droplevel(multiIndex_raw_data,ticker) _o,_h,_l,_c = rohlc(raw_df,relative = False) df = rel_fx(raw_df,_o,_h,_l,_c, bm_df, bm, ccy_df, tickers_fx_dict[ticker], start, end,rebase=True, mult=1) print(ticker, df.shape, end =',') for rel in rel_list: _o,_h,_l,_c = rohlc(df,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') for slt in sorted(bo_list): df[f'{col_name}BO{slt}'] = regime_breakout(df,_h,_l,slt) df[f'{col_name}TT{st}/{lt}'] = turtle_trader(df, _h, _l, st, lt) df[f'{col_name}SMA{st}/{lt}'] = regime_sma(df, _c, st, lt) df[f'{col_name}EMA{st}/{lt}'] = regime_ema(df, _c, st, lt) col = f'avg_{col_name}' df[col] = avg_px(df, _h, _l, _c) df = fractals_df(df, col, col_name) df = fractals_dates(df,col,col_name) df = floorceiling_df(df,_c,col_name) df[f'{col_name}HiLo_FC'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_FC', fractal_dict)) df = higherhighs_df(df,_c,col_name,shft= 2) df[f'{col_name}HiLo_HH'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_HH', fractal_dict)) df['score_abs'] = rg_dict_blend(df, score_abs_dict) df['score_rel'] = rg_dict_blend(df, score_rel_dict) df['score'] = rg_dict_blend(df, score_dict) df = round(df,2) library.write(ticker, df) last_row_list.append(last_row_cols_dict(df,ticker,df.columns)) df_dict.update({ticker: df.copy()}) multiIndex_df = multiindex_from_dict(df_dict, yf_format=False). round(2) We are now processing the autos universe through a comprehensive regime analysis pipeline, generating breakout, trend, moving average, and fractal-based signals across absolute and relative returns, then storing results in the Arctic database.
Let's review the code step by step:
Fractal_dict is the weighted average dictionary for the fractals. We define fractal weights (0.1, 0.4, 0.3, 0.2 for periods 1–4) and benchmark/currency settings.df_dict is the multiIndex dictionary. Last_row_list is the list of last_row dictionaries.rel_list. We process the regime definition functions. For absolute and relative returns, we calculate 8 regime indicators (breakouts, turtle trader, SMA/EMA crosses, fractals, floor and ceiling, higher highs).score_abs_dict and score_rel_dict we saw earlier. We write the df for each ticker in the arcticdb. For more information on how to append, update, or write arcticdb, please refer to the documentation.last_row_list with a dictionary ticker, df.columns.df_dict with a new dictionary {ticker,df}.multiindex_df from the df_dict. Note that the levels are inverse to the raw_df {ticker:df} generated from yfinance. To generate a multiindex df with {df:ticker}, switch the Boolean to True.We have built two databases: one arcticdb and one multiindex dataframe. This investment universe is relatively small, so the information is still manageable. It will, however, rapidly become overwhelming.
Our objective as market participants is to triage information and concentrate our limited resources on the most promising candidates. So, let's print a quick snapshot of everyone's score:
last_row_df = pd.DataFrame(last_row_list).set_index('ticker')display_cols = ['date','score', 'score_rel', 'score_abs'] + list(score_dict.keys())score_sorted = last_row_df[display_cols].sort_values(by=['score'], ascending= True) Extract the most recent regime scores across all tickers in the universe and rank them by composite signal strength to identify the strongest bullish and bearish opportunities. Let's break down this snippet of code:
This gives a table of the detailed score at a specific date. At the time of writing, Renault and Volkswagen have scores of -0.9 and -0.82, respectively. They deserve a lot of attention.
Now, let's imagine the investment universe grows to 50 stocks. How could we find out the worst and top 3 scores?
pd.concat([score_sorted.head(3), score_sorted.tail(3)], axis=0) This simple line of code would concatenate the top 3 and worst 3 into a single dataframe.
We would have the following order at the time of writing:
| ticker | Score | score_rel | score_abs |
|---|---|---|---|
| RNO.PA | -0.9 | -0.92 | -0.88 |
| VOW3.DE | -0.82 | -0.88 | -0.75 |
| 7201.T | -0.53 | -0.58 | -0.48 |
| 005380.KS | -0.07 | 0.41 | -0.55 |
| 7203.T | -0.07 | 0.55 | -0.68 |
| F | 0.72 | 0.62 | 0.82 |
Table 4.1: Score table
Voila! We have our work cut out for us. Our job is to understand why Mr. Market fails to appreciate European auto manufacturers Renault or Volkswagen for now.
Fundamental short selling starts where algorithmic short selling ends. The quantitative triage is done, and the detective work starts. Similarly, Ford seems to be outperforming. Our job is to understand why the market rewards Ford and decide whether it is worth going long.
Next, we will see how to work with fractals in production. Now that we have had a gentle practice run over a small population of stocks, let's expand the universe to the S&P 500.
In Chapter 3, we calculated a rudimentary bullish/bearish demarcation for the entire S&P 500 using a one-year breakout. Let's do it again with all the regime methodologies.
First, let's download the S&P 500 constituents from Wikipedia. If you want reliable data adjusted for survivorship bias, norgatedata.com is a reasonably priced and highly accurate data vendor.
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(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 ') Extract a list of S&P 500 component stocks and prepare them for regime analysis alongside the benchmark. This is how we do it:
We have happily built a dataframe containing 500+ S&P 500 stocks organized by sector, ready for a multi-stock regime analysis pipeline.
Next, let's download historical OHLCV prices for the entire S&P 500:
tickers_list = list(df_SP500.index)[:]multiIndex_raw_data_SP500 = batch_df(tickers_list,batch_size, start, end,show_batch = False).round(2)multiIndex_raw_data_SP500.shape Let's retrieve historical OHLCV data for all S&P 500 constituents plus the benchmark index for subsequent regime analysis.
^GSPC) with all 500+ S&P constituents.batch_df() function to efficiently download price data across all tickers within the specified date range.We have now created a multi-indexed DataFrame containing OHLCV data for the S&P 500 universe, ready for regime indicator computation.
Let's initialize a new library called library_SP500:
library_SP500 = initialise_adb_library_local('data', 'SP500') Let's run the regime loop across the entire population:
fractal_dict = {1: 0.1,2:0.4, 3:0.3, 4:0.2}bm_ticker = '^GSPC' ; bm = 'SP500' ; ccy = 'local' ; dgt = 2df_dict_SP500 = {} ; last_row_list_SP500 = []for ticker in tickers_list: raw_df = yf_droplevel(multiIndex_raw_data_SP500,ticker) _o,_h,_l,_c = rohlc(raw_df,relative = False) df = rel_fx(raw_df,_o,_h,_l,_c, bm_df, bm, ccy_df, ccy, start, end,rebase=True, mult=1) for rel in rel_list: _o,_h,_l,_c = rohlc(df,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') for slt in sorted(bo_list): df[f'{col_name}BO{slt}'] = regime_breakout(df,_h,_l,slt) df[f'{col_name}TT{st}/{lt}'] = turtle_trader(df, _h, _l, st, lt) df[f'{col_name}SMA{st}/{lt}'] = regime_sma(df, _c, st, lt) df[f'{col_name}EMA{st}/{lt}'] = regime_ema(df, _c, st, lt) col = f'avg_{col_name}' df[col] = avg_px(df, _h, _l, _c) df = fractals_df(df, col, col_name) df = fractals_dates(df,col,col_name) df = floorceiling_df(df,_c,col_name) df[f'{col_name}HiLo_FC'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_FC', fractal_dict)) df = higherhighs_df(df,_c,col_name,shft= 2) df[f'{col_name}HiLo_HH'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_HH', fractal_dict)) df['score_abs'] = rg_dict_blend(df, score_abs_dict) df['score_rel'] = rg_dict_blend(df, score_rel_dict) df['score'] = rg_dict_blend(df, score_dict) df = round(df,2) library_SP500.write(ticker, df) last_row_list_SP500.append(last_row_cols_dict(df,ticker,df.columns)) df_dict_SP500.update({ticker: df.copy()}) multiIndex_df_SP500 = multiindex_from_dict(df_dict_SP500, yf_format=False).round(2) The loop takes a little less than 3 minutes on this time-honored, museum-worthy machine to calculate regime methods across 500+ securities over more than 10 years of daily data. It is not bad considering not only did we calculate fractals, but we also spent time looking for the time when they were discovered.
Let's take a look at the code.
Initialize fractal weight dictionary and storage containers for processed dataframes and metadata
score_abs (absolute returns), score_rel (relative to benchmark), and score (combined).We now have a multiindex DataFrame and an Arctic database complete with all signals for all constituents.
For the grand finale, let's calculate the sector rotation:
score_SP500_rel = adb_concat_single_column(library_SP500, tickers_list, 'score_rel')sector_avg_score_rel = bm_df[[bm]].join(sector_avg_df(df_SP500, 'sector', score_SP500_rel),how ='inner')sector_avg_score_rel.plot(figsize=(15,8),secondary_y=[bm], style = ['k']) last_row_list_SP500.append(last_row_cols_dict(df,ticker,df.columns)) df_dict_SP500.update({ticker: df.copy()}) In , we touched on sector rotation. Now, we will use the mighty arcticdb to calculate sector rotation from all the scores in the index.
This produces a sector rotation chart showing which sectors are in regime phases (positive scores) or anti-regime phases (negative scores) relative to the S&P 500 benchmark.

Figure 4.10: S&P 500 & Sector rotation
That neurasthenic Rorschach chart is a clear upgrade from . We will revisit this arcticdb further along in the book. One thorny question remains. How do fractals fare in production? Fractals_df is a snapshot for the fractals at the last bar.
Next, let's explore how we can make use of fractals in production as fresh data keeps coming in, in real time.
So far, we have been calculating fractals on historical datasets. fractals_df calculates fractals at a bar t. It has neither history nor memory. Fractals_dates is an attempt at finding when the fractals took place.
Production is a different story altogether. Data is refreshed continuously. We do not need fractals_dates when we can keep score from the previous bar to the current. All we need to do is compare the score before and after the data is refreshed. If it changes, then a new fractal was discovered.
Let's simulate a real-time price update and see how we can work with fractals in that environment.
We will query the Rolls Royce of short sellers from arcticdb. We will only extract OHLCV and average price in both absolute and relative terms. We will then set the clock back by slicing the dataframe to 550 or more periods ago.
ticker = '7201.T'_columns = list(df.columns[:11]) + ['avg_abs ', 'avg_rel ']data = library.read(ticker, columns=_columns, as_of=0).datafractal_dict = {1: 0.1,2:0.2, 3:0.3, 4:0.2}last_row_list = []n = len(data) + start_mpfwhile n <= len(data): df = data.iloc[:n,].copy() for rel in rel_list: _o,_h,_l,_c = rohlc(df2,rel) col_name = col_name_abs_rel(rel, name_abs = 'abs ', name_rel = 'rel ') col = f'avg_{col_name}' df = fractals_df(df, col, col_name) df = floorceiling_df(df,_c,col_name) df[f'{col_name}HiLo_FC'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_FC', fractal_dict)) df = higherhighs_df(df,_c,col_name,shft= 2) df[f'{col_name}HiLo_HH'] = rg_dict_blend(df,dict_fractal(col_name, 'HiLo_HH', fractal_dict)) last_row_list.append(last_row_cols_dict(df,ticker,df_cols = list(df.columns))) n += 1continuous_df = pd.DataFrame.from_dict(last_row_list).set_index('date').round(2) Demonstrating real-time regime signal computation by incrementally processing historical data bar by bar, capturing how regime indicators evolve as new price data arrives.
last_row_list. We define fractal weights across timeframes (1-4 periods with specified weights).+start_mpf.n and process the fractals. For each window, compute all regime indicators (fractals, floor and ceiling, higher-highs) for both absolute and relative series.last_row_list.continuous_df from the list of dictionaries.This produces a complete time-indexed DataFrame showing how all regime indicators evolve continuously as new price data is processed, enabling analysis of signal timing and transitions.
When we collate all the individual snapshots into one consolidated DataFrame, fractals turn into lines. Individual dots are repeated snapshot after snapshot and become a continuous line. We can then plot fractals and score as seen in the following block of code:
continuous_df[['avg_abs ', 'abs Lo3','abs Hi3', 'abs HiLo_HH', 'abs HiLo_FC',]].plot(figsize=(15,4), style = ['k', 'g-', 'r-','c:','y:', 'g','r',], secondary_y = ['abs HiLo_HH', 'abs HiLo_FC'], grid=True, title = f'{ticker} continuous last row absolute level 3') Displaying how absolute and relative regime indicators evolve over time, showing price levels, support/resistance bands, and higher-highs patterns with multi-panel visualization. Let's go through the code before visualizing the chart:
We plot the absolute return series with the price average (black), floor and ceiling bands (green/red), higher-highs pattern (cyan dotted), and floor/ceiling composite score (yellow dotted, secondary y-axis). Here is the corresponding chart:

Figure 4.11: Nissan continuous last row absolute level 3
We plot average price, floor and ceiling, higher highs, and fractals highs/lows level 3.
Next, let's plot fractals highs and lows for the relative series level 2:
continuous_df[['avg_rel ', 'rel Lo2','rel Hi2', 'rel HiLo_HH', 'rel HiLo_FC',]].plot(figsize=(15,4), style = ['k', 'g-', 'r-','c:','y:', 'g','r',], secondary_y = ['rel HiLo_HH', 'rel HiLo_FC'], grid=True, title = f'{ticker} continuous last row relative level 2') We plot the relative return series at level 2 with similar indicators to compare relative performance against the benchmark.

Figure 4.12: Nissan continuous last row relative, level 2
Fractals are stable considering the level of abstraction. We have generated three time-indexed charts showing continuous evolution of regime signals, enabling visual identification of regime transitions, support/resistance holds, and higher-highs patterns in real time. The objective was to demonstrate how to work with fractals with real-time prices.
fractals_df function.If there is a change, then one fractal was discovered. This method is particularly useful for finding higher levels of abstraction (3, 4, and beyond). Those higher fractals are discovered long after they occur. As such, they are quite hard to discover. The comparison using last_row_cols_dict(df,ticker,df_cols = list(df.columns)) simplifies the process and will recapture all fractals, regime changes, etc. In production, this is the fastest and most accurate method.
Next and final topic, let's look at fractals on intraday data.
"A fractal is a way of seeing infinity."
– Benoit Mandelbrot
Every avalanche starts with a snowball. The search for the 5-minute bar that triggered the bear avalanche is the consecration of a twenty-year search. Indicators generally require parametrization. For example, the Relative Strength Index (RSI) default is 14 periods, MACD is (12,26), etc. Those parameters generally do not translate well from one timeframe to the next.
On the other hand, the fractals methodology does not require a default value. It preserves integrity across timeframes. It can be universally applied across timeframes and asset classes.
Fractals are like Matryoshka, Russian dolls. Our basic conjecture is that fractals at a fast timeframe can fold into a slower duration. One lower or higher price can trigger a fractal level 1, which in turn sets up a level 2, 3, 4, etc. So, could a fractal 1 on a 5-minute bar leading to a level 5, 6, 7 be the equivalent of a level 3 daily bar? If that is the case, we could potentially have found the bar that triggered the bear avalanche. Let's put that theory to the test. We will need as much historical low-resolution data as possible. After all, we want to know how many levels of abstraction a 1-minute bar can pack into a 20-year-long span. Then, we want to resample the data to higher resolution, like 5-minute, all the way up to daily and weekly bars. If the low resolution on high levels looks like the high resolution on lower to middle levels, then we are clearly on to something. This means there is integrity along the time continuum. Enough with the theory, let's get down to the code.
We need some long-term data to calculate fractals. The ultimate weapon of choice for long-term accurate data is this website .
Eodhd has 30 years of history with 1-minute bars for the entire S&P500. Data is clean and generally accurate. Eodhd is to retail traders what Bloomberg is to institutions. Besides, if you want to test strategies where you will put your hard-earned savings, you need accurate data. is well worth the investment both for testing and production for anyone who is serious about trading.
For the purpose of this book, we will use freely available data so that anyone can replicate the code without incurring undesired costs. Fortunately, there is a website that provides 1-minute data for free for limited dataset. Shout out to the eponymous website: .
Below is the resampling function we will be using:
def resample_ohlcv(df, rel, period ): _o,_h,_l,_c = rohlc(df,relative = rel) ohlcv_dict = {_o: 'first',_h: 'max',_l:'min',_c: 'last','volume': 'sum'} intraday_dict = {dct : 'mean' for dct in df.columns[5:]} ohlcv_dict.update(intraday_dict) df_resampled = df.resample(period, closed='left', label='right').apply(ohlcv_dict) df_resampled = df_resampled.dropna(subset = [_o,_h,_l,_c]) return df_resampled Creating a reusable function to resample one-minute OHLCV data to higher timeframes.
ohlcv_dict.dropna along the ohlc column to ensure clean, upsampled data.We have created a flexible resampling function that enables analysis across multiple timeframes (1-min to weekly) with consistent OHLCV aggregation and indicator preservation.
Next, we build a gigantic dataframe of 1-minute data directly from the internet.
one_minute_weblinks = [ # Website source data: 'https://oneminutedata.com/spy-intraday-data''https://indigo-gazelle447398.hostingersite.com/data/spy/spy_2024_6e0851922b721890a49c6a240c230ba39670d1a7.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2023_703d78ef9b61798e6de7dd6bf8d8a668d51e5aaa.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2022_b0cb458763c66c311a46aa1f8b8549ded4e9da6f.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2021_b65d518b0047dd8eaa2c2c3a98c6f61ea8637569.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2020_cdb851e720986036f80db3aeeb5943e11490f10f.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2019_82b12186b367ab8006e0bf6c51c61f61a471e7ff.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2018_8347579bc522cc3617ace45ff3f81dc0df5a04d7.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2017_28c365f5e53b09b163ca8f36d62d82ae434ca538.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2016_c39e8ddcd9bced3a283b987d1ef91048ccfbe6bd.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2015_736c7bd2f88a7bdcd476fbdf0dedfd66b8c217a2.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2014_a8e5ee2292b7ebd074baf6720412476ecc19c441.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2013_e8bb4ae219ec14fcbe73ff011b6c537ba0b25fd7.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2012_a89a832898914f255e370b16908f536ff851538f.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2011_e63fe48d566afde3820631656e6cf2b313a71e00.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2010_16cd469f7011689f465dc50e13a6076853745de6.csv.gz', 'https://indigo-gazelle-447398.hostingersite.com/data/spy/spy_2009_3aa7d5c5434c69cb4ad89073c9cfe5a22db2713f.csv.gz',] historical_df_1min = pd.DataFrame()for weblink in one_minute_weblinks: df = pd.read_csv(weblink, index_col=0, parse_dates= True,compression = 'gzip') historical_df_1min = pd.concat([historical_df_1min, df], axis =0) print(df['close'][-1:])historical_df_1min.shape This looks impressive, but the code is quite rudimentary:
read_csv with a compression format gzip, parse dates, and parse the first column as DatetimeIndex.We concatenate each year's DataFrame vertically (axis=0) into a single time-indexed dfNext. Now, let's create a dictionary of resampled dataframes and process fractals for each period:
rel = False ; _o,_h,_l,_c = rohlc(df,rel) ; col_name= '' ; col = f'avg'rg_dict = {t : round(t*0.1,1) for t in range(1,16)}df_dict = {}interval_list= ['1min','2min','3min','4min','5min','10min','15min','20min','30min','1h','4h','1d','1W']df_1min = historical_df_1min.copy()for interval in interval_list: if interval != '1min': df_dict[interval]= resample_ohlcv(df_1min, rel= False, period = interval) else : df_dict[interval]= df_1min.copy() print(interval, df_dict[interval].shape, end = " ") df_dict[interval][col] = avg_px(df_dict[interval], _h, _l, _c) df_dict[interval] = fractals_df(df_dict[interval], col, col_name) df_dict[interval] = fractals_dates(df_dict[interval],col,col_name) df_dict[interval] = floorceiling_df(df_dict[interval],_c,col_name) df_dict[interval][f'{col_name}HiLo_FC'] = rg_dict_blend(df_dict[interval],dict_fractal(col_name, 'HiLo_FC', fractal_dict)) df_dict[interval] = higherhighs_df(df_dict[interval],_c,col_name,shft= 2) df_dict[interval][f'{col_name}HiLo_HH'] = rg_dict_blend(df_dict[interval],dict_fractal(col_name, 'HiLo_HH', fractal_dict)) print(df_dict[interval].shape) Building a multi-timeframe regime analysis by resampling one-minute SPY data across 13 intervals (1-min to weekly) and computing fractals, floor and ceiling, and higher-highs patterns for each interval.
_o, _h, _l, _c and , rel.df_dict that will contain all the dataframes.'1min', '2min', '3min'... '1W'.interval_list. For each resampled timeframe, we compute average price, fractals at multiple levels, floor and ceiling bands, and higher-highs patterns.df_dict. We blend fractals across levels using a predefined weight dictionary for composite scores at each timeframe.We have built a dictionary containing 13 resampled DataFrames with complete regime indicators across all timeframes, enabling multi-scale analysis from intraday to weekly.
Next, let's plot the 5-minute along with the daily charts:
interval = '5min'df_dict[interval][['HiLo3', 'HiLo4','HiLo5','HiLo6', 'HiLo7',]].plot( figsize=(20,3), secondary_y = ['close'], grid=True,style = ['y','c','g','m','b'], title = interval)interval = '1d'df_dict[interval][['HiLo1','HiLo2','HiLo3','HiLo4']].plot( figsize=(20,3), secondary_y = ['close'], grid=True,style = ['c','g','m','b'], title = interval) Visualizing fractal-based regime indicators across different lookback periods for both intraday (5-minute) and daily timeframes to identify signal behavior.
HiLo3 through HiLo7) to visualize multi-period regime signals.HiLo1 through HiLo4) showing longer-term regime patterns.We generated a side-by-side visualization of multi-level fractal regime signals for intraday and daily data, revealing how different lookback periods capture regime transitions at various timescales.
This produces two charts:

Figure 4.13: 5-minute chart with fractals levels 3 to 7
Now, let's compare the above graph with the daily chart.

Figure 4.14: 1-day chart with fractal levels 1 to 3
Those charts look eerily similar. The higher the level of abstraction at the 5-minute bar, the more similar it looks to the daily chart. It seems this 20-year mission may finally come to a happy ending. Next, let's zoom!
chart_start = -35000chart_end = -5000interval = '5min't = 4df_dict[interval][chart_start:chart_end][['close','Hi2', 'Lo2','Hi3', 'Lo3','Hi4', 'Lo4','Hi5', 'Lo5', f'HiLo{t}',f'HiLo{t}_fc',f'HiLo{t}_chg', f'HiLo{t+1}',f'HiLo{t+1}_fc',f'HiLo{t+1}_chg', f'HiLo{t+2}',f'HiLo{t+2}_fc',f'HiLo{t+2}_chg',]].plot(figsize=(18,6), secondary_y = [f'HiLo_FC{t}'], grid=True, style = ['grey', 'r.','g.','ro','go','gv','r^','bv','m^', 'y:','y','y-.',:','c','c-.','b:','b','b-.',], title = f'{interval}: HiLo{t} HiLo{t}_fc HiLo{t}_chg, HiLo{t+1} HiLo{t+1}_fc HiLo{t+1}_chg, HiLo{t+2} HiLo{t+2}_fc HiLo{t+2}_chg') Creating a zoom view that shows how consecutive fractal levels capture regime changes at 5-minute resolution over a specific chart window.
HiLo composite (dotted), floor and ceiling band (solid), and change indicator (dash-dot).This produces a comprehensive 5-minute chart revealing fractal-based support and resistance zones and their transitions, showing multi-level regime signal coordination.
We cropped the duration to make it more visible. We will publish the 1d code and then print the charts back-to-back.
chart_start = -600chart_end = -60interval = '1d' ;t = 2df_dict[interval][chart_start:chart_end][['close','Hi2', 'Lo2','Hi3', 'Lo3','Hi4', 'Lo4', f'HiLo{t}',f'HiLo{t}_fc',f'HiLo{t}_chg', f'HiLo{t+1}',f'HiLo{t+1}_fc',f'HiLo{t+1}_chg', f'HiLo{t+2}',f'HiLo{t+2}_fc',f'HiLo{t+2}_chg',]].plot(figsize=(18,6), secondary_y = [f'HiLo_FC{t}'], grid=True,style = ['grey', 'r.','g.','ro','go','gv','r^', 'y:','y','y-.', 'c:','c','c-.', 'b:','b','b-.',], title = f'{interval}: HiLo{t} HiLo{t}_fc HiLo{t}_chg, HiLo{t+1} HiLo{t+1}_fc HiLo{t+1}_chg, HiLo{t+2} HiLo{t+2}_fc HiLo{t+2}_chg') Let's start with the 5-minute bar:

Figure 4.15: 5-minute chart with fractals levels 4 to 6
Next, let's compare with the daily chart.

Figure 4.16: 1-day chart with fractals levels 2 to 4
The fractals on the 5-minute chart look pretty much like the ones on the daily bar two levels lower. For example, the 5-minute level 5 and 6 look like the level 3 and 4 on the daily chart. They were discovered on the same day at the same price.
At this juncture, we could put this quest to rest. We could find the exact 5-minute bar at level 1 that triggered the level 6 change at the daily level 3. And we did. Mission accomplished.
This sends multi-timeframe trading into a whole new multiverse of possibilities. 5-minute charts fold neatly into 15, 30, 1 hour, 4 hours, and daily charts. The peace of finding the 5-minute bar that triggered the bear market lasted less time than an inefficiency on the S&P 500 futures. It promptly prompted the next question. If we trade the exact same strategy with the exact same parameters on the same instrument, then what asset class are we exactly trading? Drum roll: TIME. We could take the red pill and see how deep the rabbit hole goes, but it would lead us far beyond the scope of this book.
We have covered a lot of ground in this chapter. We have examined a few regime methodologies that will help you pick up on signals that a market is going up or down. Regime breakouts and moving average crossovers are staples in the arsenal of trend-following traders. Duration is as much a function of style as of what the market happens to reward. Then, we introduced the higher highs and floor and ceiling methodologies. Those regime definitions work on absolute and relative series. They are symmetrical and tend to be more stable than other methodologies.
Regime definition methodologies are not mutually exclusive. For example, the floor and ceiling method could be used to determine the direction of trades, long or short. Then, regime breakout could be used to enter after consolidation or sideways markets. Finally, the moving average crossover could be used to exit positions. All these signals could be blended into a continuous weighted average score. The average of all opinions is statistically more robust than any one method in particular.
In this chapter, we defined a methodology that remains congruent from short time intervals to daily bars and beyond without the need for additional settings. We invite you to explore the possibilities generated from the fractals methodology. There is so much work to be done, so much potential for heartbreaks, disappointments, frustration, and ultimately breakthroughs. Do not hesitate to reach out to me.
Having a signal is one thing. Turning it into a profitable strategy with a robust statistical edge is another. There is no profitable equivalent to the "buy-and-hold" mentality on the short side. Short selling is like mixed martial arts. That belt will claim its pound of flesh. For this reason, the next chapter will focus on developing a robust trading edge.
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.