A directional pullback framework that:
The approach targets mean-reverting pauses within trends rather than countertrend fades.
For close \(C_t\) and period \(n\), smoothing factor \(\alpha=\frac{2}{n+1}\):
\[ \text{EMA}_t=\alpha C_t+(1-\alpha)\text{EMA}_{t-1} \]
Regime proxy: uptrend if \(C_t>\text{EMA}_t\); downtrend if \(C_t<\text{EMA}_t\).
For lookback \(L\) with highest high \(H_t^{(L)}\) and lowest low \(L_t^{(L)}\):
\[ \%R_t=-100\cdot \frac{H_t^{(L)}-C_t}{H_t^{(L)}-L_t^{(L)}} \]
Scale \([-100,0]\). Oversold below threshold (e.g., \(-70\)), overbought above threshold (e.g., \(-30\)).
Crossing logic:
With fast EMA \(\text{EMA}^{(f)}\), slow EMA \(\text{EMA}^{(s)}\), and signal EMA of MACD:
\[ \text{MACD}_t=\text{EMA}^{(f)}_t(C)-\text{EMA}^{(s)}_t(C),\quad \text{Signal}_t=\text{EMA}^{(\ell)}_t(\text{MACD}),\quad \text{Hist}_t=\text{MACD}_t-\text{Signal}_t \]
Momentum confirmation: long only if \(\text{Hist}_t>0\); short only if \(\text{Hist}_t<0\).
True range:
\[ \text{TR}_t=\max\{H_t-L_t, |H_t-C_{t-1}|, |L_t-C_{t-1}|\} \]
ATR is an EMA of TR over \(n\) bars. Volatility stop:
Long setup
Short setup
Exit
next
loop only)def next(self):
if self.order:
return
if not self.position:
# Regime via EMA trend
= self.data.close[0] > self.trend_ema[0]
uptrend = self.data.close[0] < self.trend_ema[0]
downtrend
# Pullback-release with MACD histogram confirmation
if uptrend and self.buy_signal[0]: # %R crosses up from oversold
if self.macd_histo[0] > 0: # momentum aligned
self.order = self.buy()
elif downtrend and self.sell_signal[0]: # %R crosses down from overbought
if self.macd_histo[0] < 0: # momentum aligned
self.order = self.sell()
else:
# ATR-multiple trailing stop that only tightens
if self.position.size > 0: # long
self.highest_price_since_entry = max(self.highest_price_since_entry, self.data.high[0])
= self.highest_price_since_entry - self.atr[0] * self.p.atr_stop_multiplier
new_stop self.stop_price = max(self.stop_price, new_stop)
if self.data.close[0] < self.stop_price:
self.order = self.close()
elif self.position.size < 0: # short
self.lowest_price_since_entry = min(self.lowest_price_since_entry, self.data.low[0])
= self.lowest_price_since_entry + self.atr[0] * self.p.atr_stop_multiplier
new_stop self.stop_price = min(self.stop_price, new_stop)
if self.data.close[0] > self.stop_price:
self.order = self.close()
This construction formalizes a classic pullback–resume motif: a structural trend filter, an oscillator that detects release from extremes, a momentum check to avoid early entries, and an exit that is fully delegated to volatility geometry.
Here’s a detailed interpretation of your rolling backtest results:
Let’s try a rolling backtest to see the performance over time:
= load_strategy("WilliamsPullbackStrategy")
strategy
def run_rolling_backtest(
ticker,
start,
end,
window_months,=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, progress=False)
data if data.empty or len(data) < 90:
print("Not enough data.")
+= rd.relativedelta(months=window_months)
current_start continue
= data.droplevel(1, 1) if isinstance(data.columns, pd.MultiIndex) else data
data
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params)
cerebro.addstrategy(strategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': ret,
'final_value': final_val,
})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
+= rd.relativedelta(months=window_months)
current_start
return pd.DataFrame(all_results)
= "SOL-USD"
ticker = "2018-01-01"
start = "2025-01-01"
end = 12
window_months
= run_rolling_backtest(ticker=ticker, start=start, end=end, window_months=window_months) df
1. Return distribution
Mean return per window: 36.88% → Over the 7 yearly windows, the strategy averaged a strong positive return, though the dispersion is large.
Median return: 28.64% → The median being lower than the mean suggests that a few very strong years (e.g., 2020–2021 with +164.25%) pulled the average up.
High variance (Std Dev ≈ 65.33%) → Returns fluctuate significantly between windows, indicating sensitivity to market regimes.
Best year: 2020–2021 at +164.25% — likely a period of strong trending after volatility expansion.
Worst year: 2018–2019 at −41.27% — probably a choppy or mean-reverting market where pullback entries repeatedly failed.
2. Risk-adjusted metrics
Per-window Sharpe Ratio: 0.56 → Moderate performance consistency per year. Not outstanding, but acceptable for a trend–pullback strategy.
Total-period Sharpe Ratio: 0.81 → For the entire multi-year period, risk-adjusted returns improve due to compounding and fewer reversals in aggregate.
3. Risk control
Max drawdown: −15.39% (overall) → Very good downside control, considering some years had large losses. The trailing ATR stop likely capped adverse moves.
Win rate: 57.14% → Above coin-flip probability, showing that the strategy wins more often than it loses, but relies on a few outsized wins to drive performance.
4. Regime sensitivity
Strongest performance came during clear trend resumption phases (2020–2021, +164.25%; 2019–2020, +87.12%).
Weak performance (loss years) occurred when:
Market trended weakly with deep whipsaws (2018–2019, 2022–2023, 2024–2025).
Pullback releases triggered but failed to sustain momentum after entry.
The Williams %R pullback + MACD confirmation filter works well in persistent trends but suffers in volatile range-bound markets.
ATR trailing stops effectively limit max drawdown but do not prevent multi-month underperformance in low-momentum environments.
Performance distribution is positively skewed, relying on big years to outweigh small losses.
Potential improvements:
Add a trend strength filter (e.g., ADX > threshold) to avoid trading in sideways markets.
Include a volatility regime check to skip trades in low-ATR environments.
Experiment with dynamic %R thresholds adjusted for volatility.