I'm creating a function that gives me the Williams Fractal technical indicator, which by nature is a lagging indicator to apply at the currently analysed row of the dataframe, rather than where it actually happen, but without look-ahead bias to not have unrealistic backtesting results (but also avoiding the need to go back and always looking for the signal 2 rows before when it actually occurs).
The indicator is defined with these constraints:
\begin{aligned} \text{Bearish Fractal} =\ &\text{High} ( N ) > \text{High} ( N - 2 ) \text{ and} \\ &\text{High} ( N ) > \text{High} ( N - 1 ) \text{ and} \\ &\text{High} ( N ) > \text{High} ( N + 1 ) \text{ and} \\ &\text{High} ( N ) > \text{High} ( N + 2 ) \\ \end{aligned}
\begin{aligned} \text{Bullish Fractal} =\ &\text{Low} ( N ) < \text{Low} ( N - 2 ) \text{ and} \\ &\text{Low} ( N ) < \text{Low} ( N - 1 ) \text{ and} \\ &\text{Low} ( N ) < \text{Low} ( N + 1 ) \text{ and} \\ &\text{Low} ( N ) < \text{Low} ( N + 2 ) \\ \end{aligned}
which I implemented with the following code:
def wilFractal(dataframe):
df = dataframe.copy()
df['bear_fractal'] = (
dataframe['high'].shift(4).lt(dataframe['high'].shift(2)) &
dataframe['high'].shift(3).lt(dataframe['high'].shift(2)) &
dataframe['high'].shift(1).lt(dataframe['high'].shift(2)) &
dataframe['high'].lt(dataframe['high'].shift(2))
)
df['bull_fractal'] = (
dataframe['low'].shift(4).gt(dataframe['low'].shift(2)) &
dataframe['low'].shift(3).gt(dataframe['low'].shift(2)) &
dataframe['low'].shift(1).gt(dataframe['low'].shift(2)) &
dataframe['low'].gt(dataframe['high'].shift(2))
)
return df['bear_fractal'], df['bull_fractal']
Any suggestions?
The code in this case is used as a buy signal for a crypto trading bot in this manner:
def populate_buy_trend(self, dataframe, metadata)
dataframe.loc[
dataframe['bull_fractal'],
'buy'] = 1
return dataframe
1 Answer 1
Instead of making a
copy()
solely to assign result columns, save some overhead by just using standalone Series.Instead of testing
shift(2)
vs4,3,1,0
, it's more natural to test unshifted vs-2,-1,1,2
.Instead of chaining
& & &
, it's faster to use a comprehension withnp.logical_and.reduce()
.Instead of hardcoding the number of shifts, parameterize it. Although Investopedia defines this indicator with a period of 2, we can add a
period
param (with a default of 2) and generate allperiods
based on that value.
def will_frac(df: pd.DataFrame, period: int = 2) -> tuple[pd.Series, pd.Series]:
"""Indicate bearish and bullish fractal patterns using shifted Series.
:param df: OHLC data
:param period: number of lower (or higher) points on each side of a high (or low)
:return: tuple of boolean Series (bearish, bullish) where True marks a fractal pattern
"""
periods = [p for p in range(-period, period + 1) if p != 0] # default [-2, -1, 1, 2]
highs = [df['high'] > df['high'].shift(p) for p in periods]
bears = pd.Series(np.logical_and.reduce(highs), index=df.index)
lows = [df['low'] < df['low'].shift(p) for p in periods]
bulls = pd.Series(np.logical_and.reduce(lows), index=df.index)
return bears, bulls
Alternatively, use rolling()
to check if the center value in a sliding window is the max (bear) or min (bull):
def will_frac_roll(df: pd.DataFrame, period: int = 2) -> tuple[pd.Series, pd.Series]:
"""Indicate bearish and bullish fractal patterns using rolling windows.
:param df: OHLC data
:param period: number of lower (or higher) points on each side of a high (or low)
:return: tuple of boolean Series (bearish, bullish) where True marks a fractal pattern
"""
window = 2 * period + 1 # default 5
bears = df['high'].rolling(window, center=True).apply(lambda x: x[period] == max(x), raw=True)
bulls = df['low'].rolling(window, center=True).apply(lambda x: x[period] == min(x), raw=True)
return bears, bulls
The rolling()
version is more concise and understandable, but the shift()
versions scale much better (memory permitting):