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
uptrend = self.data.close[0] > self.trend_ema[0]
downtrend = self.data.close[0] < self.trend_ema[0]
# 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])
new_stop = self.highest_price_since_entry - self.atr[0] * self.p.atr_stop_multiplier
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])
new_stop = self.lowest_price_since_entry + self.atr[0] * self.p.atr_stop_multiplier
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:
strategy = load_strategy("WilliamsPullbackStrategy")
def run_rolling_backtest(
ticker,
start,
end,
window_months,
strategy_params=None
):
strategy_params = strategy_params or {}
all_results = []
start_dt = pd.to_datetime(start)
end_dt = pd.to_datetime(end)
current_start = start_dt
while True:
current_end = current_start + rd.relativedelta(months=window_months)
if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
data = yf.download(ticker, start=current_start, end=current_end, progress=False)
if data.empty or len(data) < 90:
print("Not enough data.")
current_start += rd.relativedelta(months=window_months)
continue
data = data.droplevel(1, 1) if isinstance(data.columns, pd.MultiIndex) else data
feed = bt.feeds.PandasData(dataname=data)
cerebro = bt.Cerebro()
cerebro.addstrategy(strategy, **strategy_params)
cerebro.adddata(feed)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
start_val = cerebro.broker.getvalue()
cerebro.run()
final_val = cerebro.broker.getvalue()
ret = (final_val - start_val) / start_val * 100
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}")
current_start += rd.relativedelta(months=window_months)
return pd.DataFrame(all_results)
ticker = "SOL-USD"
start = "2018-01-01"
end = "2025-01-01"
window_months = 12
df = run_rolling_backtest(ticker=ticker, start=start, end=end, window_months=window_months)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.