The Time Decay Adaptive EMA Strategy is an advanced
trend-following system that dynamically adjusts its sensitivity to
market price changes based on prevailing volatility. Unlike traditional
Exponential Moving Averages (EMAs) with fixed periods, this strategy’s
EMA adapts its smoothing factor (alpha) to market
conditions. This allows the EMA to be more responsive during low
volatility and smoother during high volatility, aiming to provide more
robust signals. The strategy also integrates robust risk management
through an ATR-based trailing stop.
The TimeDecayAdaptiveEMA strategy adapts its core
indicator and manages trades through the following mechanisms:
Adaptive EMA Calculation: The core of this strategy
is a custom adaptive EMA. Its smoothing factor, alpha, is
not constant but varies inversely with market volatility.
vol_calc_window. This raw volatility is then smoothed using
an Exponentially Weighted Moving Average (EWMA) over a
vol_ema_period.alpha
is derived from this smoothed EWMA volatility using a time-decay
function (an exponential decay controlled by
lambda_decay_param). This means higher volatility leads to
a smaller alpha (slower EMA), and lower volatility leads to
a larger alpha (faster EMA). The alpha is also
clipped between a alpha_0 (derived from
period_ema_min_for_alpha0) and alpha_min
(derived from period_ema_max_cap) to ensure it stays within
reasonable bounds.Entry Logic: The strategy uses a simple crossover mechanism for entries:
adaptive_ema.adaptive_ema.Exit Logic (ATR Trailing Stop): All open positions are managed with a dynamic ATR-based trailing stop:
atr_multiplier_sl) of the Average True Range (ATR) away
from the entry price.The strategy also tracks internal statistics such as trade counts, entry types (long/short), profitable trades, ATR stop exits, adaptive signal generation, and volatility regime changes. It also categorizes trades by volatility regime at entry for further analysis.
Here’s the TimeDecayAdaptiveEMA class, including its
helper methods:
import backtrader as bt
import numpy as np
import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import dateutil.relativedelta as rd
import warnings
from scipy import stats # This import might not be directly used in the strategy class itself, but required for the main script
import seaborn as sns # This import might not be directly used in the strategy class itself, but required for the main script
import multiprocessing
warnings.filterwarnings("ignore")
class TimeDecayAdaptiveEMA(bt.Strategy):
params = (
('vol_calc_window', 20),
('vol_ema_period', 10),
('period_ema_min_for_alpha0', 10),
('lambda_decay_param', 50.0),
('period_ema_max_cap', 150),
('atr_window_sl', 14),
('atr_multiplier_sl', 2.0),
('order_percentage', 0.95),
('printlog', False),
)
def __init__(self):
# Calculate returns and historical volatility
self.returns = bt.indicators.PctChange(self.data.close, period=1)
self.hist_vol = bt.indicators.StandardDeviation(self.returns, period=self.params.vol_calc_window)
# ATR for stop loss
self.atr = bt.indicators.AverageTrueRange(self.data, period=self.params.atr_window_sl)
# Initialize state variables
self.ewma_vol = None # Exponentially Weighted Moving Average of volatility
self.adaptive_ema = None
self.trailing_stop = None
self.entry_price = None # Stores entry price for initial stop calculation
# Constants for alpha calculation
self.alpha_0 = 2 / (self.params.period_ema_min_for_alpha0 + 1) # Max alpha (min smoothing)
self.alpha_min = 2 / (self.params.period_ema_max_cap + 1) # Min alpha (max smoothing)
# Strategy statistics
self.trade_count = 0
self.long_entries = 0
self.short_entries = 0
self.atr_stop_exits = 0
self.profitable_trades = 0
self.adaptive_signals = 0 # Count of signals generated by adaptive EMA
self.vol_regime_changes = 0 # Count of significant volatility shifts
self.high_vol_trades = 0 # Trades entered during high volatility
self.low_vol_trades = 0 # Trades entered during low volatility
def log(self, txt, dt=None):
if self.params.printlog:
dt = dt or self.datas[0].datetime.date(0)
print(f'{dt.isoformat()}: {txt}')
def _update_ewma_volatility(self):
"""Update EWMA of historical volatility"""
current_hist_vol = self.hist_vol[0]
if np.isnan(current_hist_vol):
return
prev_ewma_vol = self.ewma_vol
if self.ewma_vol is None:
self.ewma_vol = current_hist_vol
else:
# EWMA calculation: alpha_vol = 2 / (period + 1)
alpha_vol = 2 / (self.params.vol_ema_period + 1)
self.ewma_vol = alpha_vol * current_hist_vol + (1 - alpha_vol) * self.ewma_vol
# Detect volatility regime changes (e.g., 10% change in EWMA vol)
if prev_ewma_vol is not None and self.ewma_vol is not None:
if prev_ewma_vol != 0: # Avoid division by zero
vol_change = abs(self.ewma_vol - prev_ewma_vol) / prev_ewma_vol
if vol_change > 0.1: # Threshold for significant change
self.vol_regime_changes += 1
def _calculate_adaptive_alpha(self):
"""Calculate adaptive alpha based on volatility"""
if self.ewma_vol is None:
return self.alpha_0 # Use max alpha if EWMA vol not yet available
# Time-decay function: alpha = alpha_0 * exp(-lambda * volatility)
alpha = self.alpha_0 * np.exp(-self.params.lambda_decay_param * self.ewma_vol)
# Clip alpha to be within defined bounds [alpha_min, alpha_0]
return np.clip(alpha, self.alpha_min, self.alpha_0)
def _update_adaptive_ema(self):
"""Update the time-decay adaptive EMA"""
current_price = self.data.close[0]
alpha = self._calculate_adaptive_alpha()
if self.adaptive_ema is None:
self.adaptive_ema = current_price # Initialize with current price if first time
else:
self.adaptive_ema = alpha * current_price + (1 - alpha) * self.adaptive_ema
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
self.trade_count += 1
self.long_entries += 1
self.log(f'LONG ENTRY: Price {order.executed.price:.2f}')
# Track volatility regime at entry
if self.ewma_vol is not None and self.ewma_vol > 0.02: # Example threshold for 'high' vol (can be parametrized)
self.high_vol_trades += 1
else:
self.low_vol_trades += 1
elif order.issell():
# Check if it's a new short entry or closing a long position (which triggers notify_trade PnL)
if self.position.size <= 0: # Indicates a new short position (size is 0 or negative after sell)
self.trade_count += 1
self.short_entries += 1
self.log(f'SHORT ENTRY: Price {order.executed.price:.2f}')
# Track volatility regime at entry
if self.ewma_vol is not None and self.ewma_vol > 0.02:
self.high_vol_trades += 1
else:
self.low_vol_trades += 1
else: # This is a closing sell order for a long position (e.g. stop loss)
self.atr_stop_exits += 1
self.log(f'ATR STOP EXIT: Price {order.executed.price:.2f}')
# Reset trailing stop and entry price data when a position is closed (size is 0)
if not self.position:
self.trailing_stop = None
self.entry_price = None
self.order = None # Clear pending order
def notify_trade(self, trade):
if not trade.isclosed:
return
if trade.pnl > 0:
self.profitable_trades += 1
self.log(f'TRADE CLOSED: PnL {trade.pnl:.2f}')
def next(self):
# Skip if insufficient data for indicators to warm up
if len(self.data) < max(self.params.vol_calc_window, self.params.atr_window_sl,
self.params.period_ema_min_for_alpha0, self.params.vol_ema_period):
return
# Update custom adaptive indicators before core logic
self._update_ewma_volatility()
self._update_adaptive_ema()
# Ensure adaptive EMA has warmed up
if self.adaptive_ema is None:
return
position = self.position.size
current_close = self.data.close[0]
current_atr = self.atr[0]
prev_close = self.data.close[-1]
# Handle existing positions - check trailing stops first
# For long positions
if position > 0:
if self.trailing_stop is None: # Initialize stop on the first bar after entry
self.trailing_stop = current_close - self.params.atr_multiplier_sl * current_atr
elif self.data.low[0] <= self.trailing_stop: # Check if price hit stop
self.close() # Close position
# notify_order will increment atr_stop_exits if this is a sell to close
self.trailing_stop = None # Reset stop
self.entry_price = None # Reset entry price
return # Exit next() after closing
else: # Update trailing stop if price moved favorably
new_stop = current_close - self.params.atr_multiplier_sl * current_atr
if new_stop > self.trailing_stop: # Stop only moves up
self.trailing_stop = new_stop
# For short positions
elif position < 0:
if self.trailing_stop is None: # Initialize stop on the first bar after entry
self.trailing_stop = current_close + self.params.atr_multiplier_sl * current_atr
elif self.data.high[0] >= self.trailing_stop: # Check if price hit stop
self.close() # Close position
# notify_order will increment atr_stop_exits if this is a buy to close
self.trailing_stop = None # Reset stop
self.entry_price = None # Reset entry price
return # Exit next() after closing
else: # Update trailing stop if price moved favorably
new_stop = current_close + self.params.atr_multiplier_sl * current_atr
if new_stop < self.trailing_stop: # Stop only moves down
self.trailing_stop = new_stop
# Entry signals based on EMA crossover - only if no position open
if position == 0:
# Long signal: previous close above adaptive EMA
if prev_close > self.adaptive_ema:
self.adaptive_signals += 1
cash = self.broker.get_cash()
size = (cash * self.params.order_percentage) / self.data.close[0]
self.buy(size=size) # Place buy order
self.entry_price = self.data.open[0] # Record entry price for initial stop
# Initial trailing stop set in notify_order for long, based on entry price and current ATR
# Short signal: previous close below adaptive EMA
elif prev_close < self.adaptive_ema:
self.adaptive_signals += 1
cash = self.broker.get_cash()
size = (cash * self.params.order_percentage) / self.data.close[0]
self.sell(size=size) # Place sell order
self.entry_price = self.data.open[0] # Record entry price for initial stop
# Initial trailing stop set in notify_order for short, based on entry price and current ATRparams):vol_calc_window: Window size for calculating historical
volatility (Standard Deviation of returns).vol_ema_period: Period for the EWMA of historical
volatility.period_ema_min_for_alpha0: Defines alpha_0
(the maximum smoothing factor, representing the fastest EMA
period).lambda_decay_param: Controls the exponential decay of
alpha based on volatility. Higher values make
alpha decay faster with increasing volatility.period_ema_max_cap: Defines the minimum
alpha (representing the slowest EMA period) by capping the
maximum possible EMA period.atr_window_sl: Window size for the Average True Range
(ATR) used in stop loss calculation.atr_multiplier_sl: Multiplier for ATR to set the
distance of the trailing stop.order_percentage: Percentage of available cash to use
for each trade.printlog: Boolean flag to enable/disable logging.__init__):self.returns, self.hist_vol: Indicators to
calculate daily returns and their historical standard deviation
(volatility).self.atr: Average True Range indicator for stop
loss.self.ewma_vol, self.adaptive_ema,
self.trailing_stop, self.entry_price: State
variables to store the EWMA of volatility, the adaptive EMA value, the
current trailing stop price, and the trade entry price.self.alpha_0, self.alpha_min:
Pre-calculated constants for the adaptive alpha
bounds.trade_count, long_entries,
short_entries, atr_stop_exits,
profitable_trades, adaptive_signals,
vol_regime_changes, high_vol_trades,
low_vol_trades) to track various aspects of strategy
behavior._update_ewma_volatility():
hist_vol. This smoothed volatility is key for adapting the
EMA’s alpha.vol_regime_changes if the EWMA
volatility changes by more than 10% from the previous bar, indicating a
significant shift in market volatility._calculate_adaptive_alpha():
alpha for the EMA.alpha = self.alpha_0 * np.exp(-self.params.lambda_decay_param * self.ewma_vol).
This means higher volatility (ewma_vol) results in a
smaller alpha (slower EMA), and lower volatility results in
a larger alpha (faster EMA).alpha is then clipped between
self.alpha_min and self.alpha_0 to keep it
within sensible bounds, preventing the EMA from becoming too fast or too
slow._update_adaptive_ema():
self.adaptive_ema using the
standard EMA formula with the dynamically calculated
alpha.notify_order,
notify_trade):notify_order(): This method is called
by backtrader whenever an order’s status changes (e.g.,
submitted, accepted, completed).
trade_count, long_entries, logs the
entry, and records if the trade occurred in a high or low volatility
regime.trade_count, short_entries, logs the entry,
and tracks the volatility regime.atr_stop_exits and logs the
exit.trailing_stop and entry_price state variables
are reset.notify_trade(): This method is called
when a trade (a complete buy and sell cycle) is closed. It increments
self.profitable_trades if the trade generated a profit and
logs the trade’s PnL.next):The next method contains the core strategy logic,
executed on each new bar of data:
_update_ewma_volatility() and
_update_adaptive_ema() to ensure the adaptive EMA and
volatility measures are up-to-date for the current bar.position > 0):
trailing_stop is None (meaning this is
the first bar after entry), it initializes the
trailing_stop.data.low[0] has hit or fallen
below the trailing_stop. If so, the position is closed, and
the method returns.new_stop and updates
self.trailing_stop only if new_stop is higher
(trailing the price upwards).position < 0):
trailing_stop is None, it initializes
the trailing_stop.data.high[0] has hit or risen
above the trailing_stop. If so, the position is closed, and
the method returns.new_stop and updates
self.trailing_stop only if new_stop is lower
(trailing the price downwards).position == 0 (no open position), it checks for
crossover signals from the adaptive_ema:
prev_close is above
self.adaptive_ema, a buy order is placed.
self.adaptive_signals is incremented, and
self.entry_price is recorded for initial stop placement
(which occurs in notify_order).prev_close is below
self.adaptive_ema, a sell order is placed.
self.adaptive_signals is incremented, and
self.entry_price is recorded.This section presents the full code for running optimization, a
rolling backtest, and generating detailed results with plots for the
TimeDecayAdaptiveEMA strategy.
def run_momentum_analysis():
# OPTIMIZATION
print("="*60)
print("MOMENTUM IGNITION STRATEGY OPTIMIZATION")
print("="*60)
df = yf.download('BTC-USD', start='2020-01-01', end='2025-01-01', auto_adjust=False, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df = df.droplevel(1, axis=1)
print(f"Fetched data: {df.shape}")
cerebro = bt.Cerebro()
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
cerebro.broker.setcash(10000.0)
cerebro.broker.setcommission(commission=0.001)
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', timeframe=bt.TimeFrame.Days, annualize=True, riskfreerate=0.0)
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
print("Testing Adaptive EMA parameter combinations...")
cerebro.optstrategy(
TimeDecayAdaptiveEMA,
vol_calc_window=[15, 20, 25], # Volatility calculation window
vol_ema_period=[8, 10, 12], # Volatility EWMA period
period_ema_min_for_alpha0=[8, 10, 12], # Min EMA period for alpha0
lambda_decay_param=[30.0, 50.0, 70.0], # Decay parameter
period_ema_max_cap=[100, 150, 200], # Max EMA period cap
atr_multiplier_sl=[1.5, 2.0, 2.5] # ATR stop multiplier
)
stratruns = cerebro.run(maxcpus=1) # Force single-threaded to avoid multiprocessing issues with Jupyter/IDE
print(f"Optimization complete! Tested {len(stratruns)} combinations.")
# Find best parameters
best_result = None
best_sharpe = -999
for run in stratruns:
strategy = run[0]
sharpe_analysis = strategy.analyzers.sharpe.get_analysis()
returns_analysis = strategy.analyzers.returns.get_analysis()
sharpe_ratio = sharpe_analysis.get('sharperatio', None)
if sharpe_ratio is not None and not np.isnan(sharpe_ratio) and sharpe_ratio > best_sharpe:
best_sharpe = sharpe_ratio
best_result = {
'vol_calc_window': strategy.p.vol_calc_window,
'vol_ema_period': strategy.p.vol_ema_period,
'period_ema_min_for_alpha0': strategy.p.period_ema_min_for_alpha0,
'lambda_decay_param': strategy.p.lambda_decay_param,
'period_ema_max_cap': strategy.p.period_ema_max_cap,
'atr_multiplier_sl': strategy.p.atr_multiplier_sl,
'sharpe': sharpe_ratio,
'return': returns_analysis.get('rtot', 0) * 100,
'trades': getattr(strategy, 'trade_count', 0),
'adaptive_signals': getattr(strategy, 'adaptive_signals', 0),
'vol_regime_changes': getattr(strategy, 'vol_regime_changes', 0)
}
if best_result:
print(f"\nBEST ADAPTIVE EMA PARAMETERS:")
print(f"Vol Calc Window: {best_result['vol_calc_window']}")
print(f"Vol EMA Period: {best_result['vol_ema_period']}")
print(f"Min EMA Period: {best_result['period_ema_min_for_alpha0']}")
print(f"Lambda Decay: {best_result['lambda_decay_param']:.1f}")
print(f"Max EMA Cap: {best_result['period_ema_max_cap']}")
print(f"ATR Multiplier: {best_result['atr_multiplier_sl']:.1f}")
print(f"Sharpe: {best_result['sharpe']:.3f}")
print(f"Return: {best_result['return']:.1f}%")
print(f"Trades: {best_result['trades']}")
print(f"Adaptive Signals: {best_result['adaptive_signals']}")
else:
print("No valid results found, using defaults")
best_result = {
'vol_calc_window': 20,
'vol_ema_period': 10,
'period_ema_min_for_alpha0': 10,
'lambda_decay_param': 50.0,
'period_ema_max_cap': 150,
'atr_multiplier_sl': 2.0
}
# ROLLING BACKTEST
print(f"\n{'='*60}")
print("RUNNING YEARLY ROLLING BACKTESTS")
print(f"{'='*60}")
strategy_params = {
'vol_calc_window': best_result['vol_calc_window'],
'vol_ema_period': best_result['vol_ema_period'],
'period_ema_min_for_alpha0': best_result['period_ema_min_for_alpha0'],
'lambda_decay_param': best_result['lambda_decay_param'],
'period_ema_max_cap': best_result['period_ema_max_cap'],
'atr_multiplier_sl': best_result['atr_multiplier_sl'],
'order_percentage': 0.95,
'printlog': False
}
results = []
start_dt = pd.to_datetime("2018-01-01")
end_dt = pd.to_datetime("2025-01-01")
current_start = start_dt
while True:
current_end = current_start + rd.relativedelta(months=12)
if current_end > end_dt:
break
print(f"Period: {current_start.date()} to {current_end.date()}")
data = yf.download("BTC-USD", start=current_start, end=current_end, auto_adjust=False, progress=False)
if data.empty or len(data) < 90:
current_start += rd.relativedelta(months=12)
continue
if isinstance(data.columns, pd.MultiIndex):
data = data.droplevel(1, 1)
start_price = data['Close'].iloc[0]
end_price = data['Close'].iloc[-1]
benchmark_return = (end_price - start_price) / start_price
feed = bt.feeds.PandasData(dataname=data)
cerebro = bt.Cerebro()
cerebro.addstrategy(TimeDecayAdaptiveEMA, **strategy_params)
cerebro.adddata(feed)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)
start_val = cerebro.broker.getvalue()
result = cerebro.run()
final_val = cerebro.broker.getvalue()
strategy_return = (final_val - start_val) / start_val
strategy_instance = result[0]
trades = getattr(strategy_instance, 'trade_count', 0)
long_entries = getattr(strategy_instance, 'long_entries', 0)
short_entries = getattr(strategy_instance, 'short_entries', 0)
atr_stops = getattr(strategy_instance, 'atr_stop_exits', 0)
profitable = getattr(strategy_instance, 'profitable_trades', 0)
adaptive_signals = getattr(strategy_instance, 'adaptive_signals', 0)
vol_regime_changes = getattr(strategy_instance, 'vol_regime_changes', 0)
high_vol_trades = getattr(strategy_instance, 'high_vol_trades', 0)
low_vol_trades = getattr(strategy_instance, 'low_vol_trades', 0)
results.append({
'year': current_start.year,
'strategy_return': strategy_return,
'benchmark_return': benchmark_return,
'trades': trades,
'long_entries': long_entries,
'short_entries': short_entries,
'atr_stop_exits': atr_stops,
'profitable_trades': profitable,
'adaptive_signals': adaptive_signals,
'vol_regime_changes': vol_regime_changes,
'high_vol_trades': high_vol_trades,
'low_vol_trades': low_vol_trades
})
print(f"Strategy: {strategy_return:.1%} | Buy&Hold: {benchmark_return:.1%} | Trades: {trades} | VolRegimes: {vol_regime_changes}")
current_start += rd.relativedelta(months=12)
# RESULTS & PLOTTING
results_df = pd.DataFrame(results)
if not results_df.empty:
print(f"\n{'='*80}")
print("YEARLY RESULTS")
print(f"{'='*80}")
print("Year | Strategy | Buy&Hold | Outperf | Trades | Long | Short | WinRate")
print("-" * 80)
for _, row in results_df.iterrows():
strat_ret = row['strategy_return'] * 100
bench_ret = row['benchmark_return'] * 100
outperf = strat_ret - bench_ret
win_rate = (row['profitable_trades'] / max(1, row['trades'])) * 100
print(f"{int(row['year'])} | {strat_ret:7.1f}% | {bench_ret:7.1f}% | {outperf:+6.1f}% | "
f"{int(row['trades']):6d} | {int(row['long_entries']):4d} | {int(row['short_entries']):5d} | {win_rate:6.1f}%")
# PLOTS
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.suptitle('Time Decay Adaptive EMA Strategy Analysis', fontsize=16, fontweight='bold')
# Cumulative performance
strategy_cumulative = (1 + results_df['strategy_return']).cumprod()
benchmark_cumulative = (1 + results_df['benchmark_return']).cumprod()
ax1 = axes[0, 0]
ax1.plot(results_df['year'], strategy_cumulative, 'o-', linewidth=3, color='purple', label='Strategy')
ax1.plot(results_df['year'], benchmark_cumulative, 's-', linewidth=3, color='orange', label='Buy & Hold')
ax1.set_title('Cumulative Performance')
ax1.set_ylabel('Cumulative Return')
ax1.legend()
ax1.grid(True, alpha=0.3)
# Annual returns
ax2 = axes[0, 1]
x_pos = range(len(results_df))
width = 0.35
ax2.bar([x - width/2 for x in x_pos], results_df['strategy_return'] * 100, width,
label='Strategy', color='purple', alpha=0.8)
ax2.bar([x + width/2 for x in x_pos], results_df['benchmark_return'] * 100, width,
label='Buy & Hold', color='orange', alpha=0.8)
ax2.set_title('Annual Returns')
ax2.set_ylabel('Return (%)')
ax2.set_xticks(x_pos)
ax2.set_xticklabels([int(year) for year in results_df['year']])
ax2.legend()
ax2.grid(True, alpha=0.3)
# Long vs Short
ax3 = axes[1, 0]
ax3.bar(x_pos, results_df['long_entries'], label='Long', color='green', alpha=0.8)
ax3.bar(x_pos, results_df['short_entries'], bottom=results_df['long_entries'],
label='Short', color='red', alpha=0.8)
ax3.set_title('Long vs Short Entries')
ax3.set_ylabel('Number of Entries')
ax3.set_xticks(x_pos)
ax3.set_xticklabels([int(year) for year in results_df['year']])
ax3.legend()
ax3.grid(True, alpha=0.3)
# Summary stats
ax4 = axes[1, 1]
ax4.axis('off')
total_strategy_return = strategy_cumulative.iloc[-1] - 1
total_benchmark_return = benchmark_cumulative.iloc[-1] - 1
total_trades = results_df['trades'].sum()
total_profitable = results_df['profitable_trades'].sum()
summary_text = f"""
SUMMARY STATISTICS
Total Strategy Return: {total_strategy_return:.1%}
Total Buy & Hold Return: {total_benchmark_return:.1%}
Outperformance: {total_strategy_return - total_benchmark_return:+.1%}
Total Trades: {int(total_trades)}
Overall Win Rate: {total_profitable/max(1,total_trades):.1%}
Long Entries: {int(results_df['long_entries'].sum())}
Short Entries: {int(results_df['short_entries'].sum())}
Strategy wins in {(results_df['strategy_return'] > results_df['benchmark_return']).sum()}/{len(results_df)} years
"""
ax4.text(0.1, 0.9, summary_text, transform=ax4.transAxes, va='top', ha='left',
fontsize=12, fontfamily='monospace',
bbox=dict(boxstyle="round,pad=0.5", facecolor="lightgray", alpha=0.8))
plt.tight_layout()
plt.show()
# Final verdict
print(f"\n{'='*60}")
if total_strategy_return > total_benchmark_return:
print(f"STRATEGY WINS by {total_strategy_return - total_benchmark_return:+.1%}!")
else:
print(f"Strategy underperformed by {total_strategy_return - total_benchmark_return:.1%}")
print(f"{'='*60}")
else:
print("No results generated!")
print("ANALYSIS COMPLETE!")run_momentum_analysis - Optimization Section):This section finds the best parameter combination for the
TimeDecayAdaptiveEMA strategy:
yfinance.backtrader.Cerebro with the data, starting cash, and
commission. Adds SharpeRatio and Returns
analyzers.cerebro.optstrategy to run multiple backtests across
defined ranges for vol_calc_window,
vol_ema_period, period_ema_min_for_alpha0,
lambda_decay_param, period_ema_max_cap, and
atr_multiplier_sl.stratruns) to identify the parameter set that
yielded the highest Sharpe Ratio.run_momentum_analysis - Rolling Backtest
Section):Following optimization, this section performs a rolling backtest with the identified best parameters to evaluate the strategy’s consistency over a broader period (2018-2025).
backtrader.Cerebro instance is created for each period,
ensuring independent backtests. The TimeDecayAdaptiveEMA
strategy is added with the optimized parameters.==================================================
SIMPLE SUMMARY
==================================================
Adaptive EMA Total Return: 1096.5%
Buy & Hold Total Return: 507.8%
Outperformance: +588.8%
Total Trades: 297
Win Rate: 19.5%
Strategy wins in 3/7 years
============================================================
🎉 ADAPTIVE EMA STRATEGY WINS by +588.8%!
============================================================
📊 ADAPTIVE EMA INSIGHTS:
- Total adaptive signals: 152
- Volatility regime changes: 50
- High volatility trades: 240 (80.8%)
- Low volatility trades: 57 (19.2%)
- Signal conversion rate: 195.4%
- EMA adapts faster in high volatility (α = α₀ × e^(-λ×σ))
- ATR trailing stops provide dynamic risk management
This comprehensive analysis framework allows for a thorough
evaluation of the TimeDecayAdaptiveEMA strategy, from
finding optimal parameters to understanding its performance consistency
and behavioral characteristics across various market conditions.