The Quantile Channel Strategy is a trend-following
system that identifies and trades breakouts from dynamically calculated
price channels. Unlike traditional channels that rely on fixed standard
deviations (like Bollinger Bands) or fixed price ranges, quantile
channels adapt to the underlying price distribution, defining boundaries
based on percentiles of historical price data. This article details the
implementation of this strategy in backtrader
, along with a
robust framework for parameter optimization, a comprehensive rolling
backtest, and detailed statistical reporting with visualizations.
This section presents the core QuantileChannelStrategy
class, which defines the trading logic, including the custom channel
calculation and breakout detection.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import dateutil.relativedelta as rd
import warnings
# %matplotlib inline # This line is for Jupyter/IPython and should not be in the article code
"ignore")
warnings.filterwarnings(
# ------------------------------------------------------------------
# 1. Simplified Quantile Channel Strategy
# ------------------------------------------------------------------
class QuantileChannelStrategy(bt.Strategy):
= (
params 'lookback_period', 60), # Lookback for channel estimation
('upper_quantile', 0.8), # Upper channel quantile (80th percentile)
('lower_quantile', 0.2), # Lower channel quantile (20th percentile)
('breakout_threshold', 1.02), # Breakout confirmation (2% above/below)
('stop_loss_pct', 0.08), # 8% stop loss
('rebalance_period', 5), # Rebalance every 5 days (less frequent)
('min_channel_width', 0.02), # Minimum 2% channel width
('order_percentage', 0.95), # Position sizing
('printlog', False),
(
)
def __init__(self):
# Use rolling windows for efficiency
self.price_window = []
# Pre-calculate quantile indices for faster lookup
self.upper_idx = int(self.params.lookback_period * self.params.upper_quantile)
self.lower_idx = int(self.params.lookback_period * self.params.lower_quantile)
self.median_idx = int(self.params.lookback_period * 0.5)
# Channel levels
self.upper_channel = 0
self.lower_channel = 0
self.trend_line = 0
# Trading variables
self.rebalance_counter = 0
self.stop_price = 0
self.last_calculation_bar = -999
self.order = None
# Track strategy statistics
self.trade_count = 0
self.breakout_entries = 0
self.stop_loss_exits = 0
self.profitable_trades = 0
self.upper_breakouts = 0
self.lower_breakouts = 0
self.mean_reversion_exits = 0
def log(self, txt, dt=None):
if self.params.printlog:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()}: {txt}')
def calculate_channels_fast(self):
"""Fast channel calculation using numpy quantiles only"""
if len(self.price_window) < self.params.lookback_period:
return False
# Simple quantile calculation - much faster than regression
= np.array(self.price_window)
prices
# Calculate basic quantiles
self.upper_channel = np.quantile(prices, self.params.upper_quantile)
self.lower_channel = np.quantile(prices, self.params.lower_quantile)
self.trend_line = np.quantile(prices, 0.5) # Median
# Ensure minimum channel width
= (self.upper_channel - self.lower_channel) / self.trend_line
channel_width if channel_width < self.params.min_channel_width:
= self.trend_line * self.params.min_channel_width / 2
half_width self.upper_channel = self.trend_line + half_width
self.lower_channel = self.trend_line - half_width
return True
def detect_breakout_simple(self, current_price):
"""Simplified breakout detection"""
if current_price > self.upper_channel * self.params.breakout_threshold:
return 1 # Upper breakout
elif current_price < self.lower_channel / self.params.breakout_threshold:
return -1 # Lower breakout
else:
return 0 # No breakout
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED: Price {order.executed.price:.2f}')
self.stop_price = order.executed.price * (1.0 - self.params.stop_loss_pct)
self.trade_count += 1
self.breakout_entries += 1
elif order.issell():
self.log(f'SELL EXECUTED: Price {order.executed.price:.2f}')
self.stop_price = order.executed.price * (1.0 + self.params.stop_loss_pct)
self.trade_count += 1
self.breakout_entries += 1
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Order Canceled/Margin/Rejected')
self.order = None
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}')
self.stop_price = 0
def next(self):
= self.data.close[0]
current_price = len(self.data)
current_bar
# Maintain rolling window
self.price_window.append(current_price)
if len(self.price_window) > self.params.lookback_period:
self.price_window.pop(0) # Remove oldest price
# Check if we have an open order
if self.order:
return
# Only recalculate channels periodically to save computation
self.rebalance_counter += 1
if (self.rebalance_counter >= self.params.rebalance_period or
- self.last_calculation_bar > self.params.rebalance_period):
current_bar
if not self.calculate_channels_fast():
return # Not enough data
self.last_calculation_bar = current_bar
self.rebalance_counter = 0
# Check stop loss first (always active)
if self.position.size > 0 and current_price <= self.stop_price:
self.log(f'STOP LOSS LONG: Price {current_price:.2f} <= Stop {self.stop_price:.2f}')
self.order = self.close()
self.stop_loss_exits += 1
return
elif self.position.size < 0 and current_price >= self.stop_price:
self.log(f'STOP LOSS SHORT: Price {current_price:.2f} >= Stop {self.stop_price:.2f}')
self.order = self.close()
self.stop_loss_exits += 1
return
# Only trade on rebalance periods
if self.rebalance_counter != 0:
return
# Detect breakout
= self.detect_breakout_simple(current_price)
breakout
# Current position
= 0
current_pos if self.position.size > 0:
= 1
current_pos elif self.position.size < 0:
= -1
current_pos
# Trading logic
if breakout != 0:
# Close opposing position
if current_pos != 0 and current_pos != breakout:
self.order = self.close()
return
# Open new position
if current_pos == 0:
= self.broker.get_cash()
cash = (cash * self.params.order_percentage) / current_price
size
if breakout == 1: # Long breakout
self.log(f'UPPER BREAKOUT: Price {current_price:.2f} > Channel {self.upper_channel:.2f}')
self.order = self.buy(size=size)
self.upper_breakouts += 1
elif breakout == -1: # Short breakout
self.log(f'LOWER BREAKOUT: Price {current_price:.2f} < Channel {self.lower_channel:.2f}')
self.order = self.sell(size=size)
self.lower_breakouts += 1
# Mean reversion exit
elif self.position.size != 0:
# Close if price returns to middle of channel
if abs(current_price - self.trend_line) / self.trend_line < 0.01: # Within 1% of median
self.log(f'MEAN REVERSION EXIT: Price {current_price:.2f} near trend {self.trend_line:.2f}')
self.order = self.close()
self.mean_reversion_exits += 1
params
):lookback_period
: The number of past bars used to
calculate the quantile channels.upper_quantile
, lower_quantile
: The
percentile levels for the upper and lower channel boundaries (e.g., 0.8
for 80th percentile).breakout_threshold
: A multiplier to confirm a breakout,
requiring price to move a certain percentage beyond the channel (e.g.,
1.02 for 2% above upper, or 1/1.02 for 2% below lower).stop_loss_pct
: A fixed percentage below (for long) or
above (for short) the entry price to trigger a stop loss.rebalance_period
: How often (in bars) the quantile
channels are recalculated to save computation.min_channel_width
: A minimum percentage width for the
channel, preventing very narrow channels that might lead to excessive
trades.order_percentage
: The percentage of available cash to
allocate to a trade.printlog
: A boolean flag to enable or disable
logging.__init__
):self.price_window
: A list used as a rolling window to
store historical prices for quantile calculation.self.upper_idx
, self.lower_idx
,
self.median_idx
: Pre-calculated indices for faster quantile
lookup (though np.quantile
handles this internally, these
could be for custom sorting approaches).self.upper_channel
, self.lower_channel
,
self.trend_line
: Variables to store the calculated channel
boundaries and median line.self.rebalance_counter
,
self.last_calculation_bar
: Used to control the frequency of
channel recalculations.self.stop_price
: Stores the active stop loss
price.self.order
: Tracks any pending orders.trade_count
,
breakout_entries
, stop_loss_exits
,
profitable_trades
, upper_breakouts
,
lower_breakouts
, mean_reversion_exits
are
initialized to collect detailed strategy statistics.log
, notify_order
,
notify_trade
):log(self, txt, dt=None)
: A utility method for printing
time-stamped messages if printlog
is
True
.notify_order(self, order)
: Called by
backtrader
when an order’s status changes.
buy
or sell
order, it
logs the execution, sets the stop_price
, and increments
trade_count
and breakout_entries
.self.order
is reset after an order is completed or
fails.notify_trade(self, trade)
: Called when a trade is fully
closed. It logs the profit/loss and increments
self.profitable_trades
if the trade was profitable. It also
resets self.stop_price
.calculate_channels_fast
):numpy.quantile
.price_window
has enough data
(lookback_period
).min_channel_width
constraint to prevent
the channel from becoming too narrow, which could lead to false signals
or excessive trading.detect_breakout_simple
):current_price
has broken out of the channel.1
for an upper breakout (price above
upper_channel
multiplied by
breakout_threshold
), -1
for a lower breakout
(price below lower_channel
divided by
breakout_threshold
), and 0
if no breakout is
detected.next
):The next
method is the core of the strategy, executed on
each new bar of data:
current_price
is added to self.price_window
,
and the oldest price is removed if the window exceeds
lookback_period
.if self.order: return
prevents multiple orders if one is
already in progress.self.rebalance_counter
and
self.last_calculation_bar
ensure that
calculate_channels_fast()
is called only every
rebalance_period
bars, improving performance.current_price
falls below or
equals self.stop_price
, the position is closed,
self.stop_loss_exits
is incremented, and the method
returns.current_price
rises above or
equals self.stop_price
, the position is closed,
self.stop_loss_exits
is incremented, and the method
returns.if self.rebalance_counter != 0: return
ensures that
entry/exit logic (other than stop loss) only executes on bars where
channels have just been recalculated.breakout = self.detect_breakout_simple(current_price)
:
Determines if a breakout has occurred.upper breakout
(breakout == 1
), a
buy
order is placed. self.upper_breakouts
is
incremented.lower breakout
(breakout == -1
), a
sell
order is placed. self.lower_breakouts
is
incremented.trend_line
(median of the channel). If
abs(current_price - self.trend_line) / self.trend_line < 0.01
(within 1% of the median), the position is closed, and
self.mean_reversion_exits
is incremented. This acts as a
profit-taking or risk-reduction mechanism when the breakout momentum
fades and price reverts to the mean.This function automates the process of finding the most effective combination of strategy parameters by running multiple backtests and evaluating their performance using metrics like Sharpe Ratio and detailed trade statistics.
# ------------------------------------------------------------------
# 2. Optimization Function
# ------------------------------------------------------------------
def optimize_quantile_channel_parameters():
"""Run optimization to find best parameters with diagnostics"""
print("="*60)
print("QUANTILE CHANNEL STRATEGY OPTIMIZATION")
print("="*60)
# Fetch data for optimization
print("Fetching data for optimization...")
= yf.download('BTC-USD', start='2020-01-01', end='2025-01-01', auto_adjust=False, progress=False)
df if isinstance(df.columns, pd.MultiIndex):
= df.droplevel(1, axis=1)
df
print(f"Data shape: {df.shape}")
print(f"Date range: {df.index[0]} to {df.index[-1]}")
# Set up optimization
= bt.Cerebro()
cerebro = bt.feeds.PandasData(dataname=df)
data
cerebro.adddata(data)
= 10000.0
start_cash
cerebro.broker.setcash(start_cash)=0.001)
cerebro.broker.setcommission(commission
# Add analyzers
='sharpe',
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name=bt.TimeFrame.Days, annualize=True, riskfreerate=0.0)
timeframe='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
print("Testing parameter combinations...")
# Parameter ranges for quantile channel strategy
cerebro.optstrategy(
SimplifiedQuantileChannelStrategy,=[30, 60, 90], # 3 values - Lookback period
lookback_period=[0.75, 0.8, 0.85, 0.9], # 4 values - Upper quantile
upper_quantile=[0.1, 0.15, 0.2, 0.25], # 4 values - Lower quantile
lower_quantile=[1.015, 1.02, 1.03], # 3 values - Breakout threshold
breakout_threshold=[0.05, 0.08, 0.10], # 3 values - Stop loss %
stop_loss_pct=[3, 5, 7] # 3 values - Rebalance frequency
rebalance_period
)# Total: 3 × 4 × 4 × 3 × 3 × 3 = 1,296 combinations
= cerebro.run()
stratruns print(f"Optimization complete! Tested {len(stratruns)} combinations.")
# Collect and analyze results with detailed diagnostics
= []
results = 0
valid_count = 0
no_trades_count = 0
invalid_sharpe_count
for i, run in enumerate(stratruns):
= run[0]
strategy = strategy.analyzers.sharpe.get_analysis()
sharpe_analysis = strategy.analyzers.returns.get_analysis()
returns_analysis = strategy.analyzers.trades.get_analysis()
trades_analysis
= returns_analysis.get('rtot', 0.0)
rtot = start_cash * (1 + rtot)
final_value = sharpe_analysis.get('sharperatio', None)
sharpe_ratio = trades_analysis.get('total', {}).get('total', 0)
total_trades
# Diagnostic information
= total_trades > 0
has_trades = sharpe_ratio is not None and not np.isnan(sharpe_ratio)
has_valid_sharpe
if not has_trades:
+= 1
no_trades_count if not has_valid_sharpe:
+= 1
invalid_sharpe_count = -999.0
sharpe_ratio
# Safe attribute access
= getattr(strategy, 'trade_count', 0)
trade_count = getattr(strategy, 'breakout_entries', 0)
breakout_entries = getattr(strategy, 'stop_loss_exits', 0)
stop_loss_exits = getattr(strategy, 'profitable_trades', 0)
profitable_trades = getattr(strategy, 'upper_breakouts', 0)
upper_breakouts = getattr(strategy, 'lower_breakouts', 0)
lower_breakouts = getattr(strategy, 'mean_reversion_exits', 0)
mean_reversion_exits
= {
result 'combination_id': i + 1,
'sharpe_ratio': sharpe_ratio,
'final_value': final_value,
'return_pct': rtot * 100,
'total_analyzer_trades': total_trades,
'lookback_period': strategy.p.lookback_period,
'upper_quantile': strategy.p.upper_quantile,
'lower_quantile': strategy.p.lower_quantile,
'breakout_threshold': strategy.p.breakout_threshold,
'stop_loss_pct': strategy.p.stop_loss_pct,
'rebalance_period': strategy.p.rebalance_period,
'trade_count': trade_count,
'breakout_entries': breakout_entries,
'stop_loss_exits': stop_loss_exits,
'profitable_trades': profitable_trades,
'upper_breakouts': upper_breakouts,
'lower_breakouts': lower_breakouts,
'mean_reversion_exits': mean_reversion_exits,
'has_trades': has_trades,
'has_valid_sharpe': has_valid_sharpe,
}
results.append(result)
if has_trades and has_valid_sharpe:
+= 1
valid_count
# Print diagnostics
print(f"\n{'='*60}")
print("OPTIMIZATION DIAGNOSTICS")
print(f"{'='*60}")
print(f"Total combinations tested: {len(results)}")
print(f"Combinations with trades: {len(results) - no_trades_count}")
print(f"Combinations with no trades: {no_trades_count}")
print(f"Combinations with invalid Sharpe: {invalid_sharpe_count}")
print(f"Valid combinations: {valid_count}")
# Show some examples of each category
print(f"\n--- SAMPLE RESULTS ---")
for result in results[:5]: # Show first 5 combinations
= f"{result['sharpe_ratio']:.3f}" if result['sharpe_ratio'] != -999.0 else "Invalid"
sharpe_display print(f"Combination {result['combination_id']}: "
f"Lookback({result['lookback_period']}) Upper({result['upper_quantile']:.2f}) "
f"Lower({result['lower_quantile']:.2f}) Breakout({result['breakout_threshold']:.3f}) "
f"Stop({result['stop_loss_pct']:.1%}) Rebal({result['rebalance_period']}) -> "
f"Trades: {result['total_analyzer_trades']}, "
f"Return: {result['return_pct']:.1f}%, "
f"Sharpe: {sharpe_display}")
# Try to find any valid results
= [r for r in results if r['has_trades'] and r['has_valid_sharpe']]
valid_results
if not valid_results:
print(f"\n{'='*60}")
print("NO VALID RESULTS FOUND - RUNNING SINGLE STRATEGY TEST")
print(f"{'='*60}")
# Test a single strategy with logging enabled to see what's happening
= {
test_params 'lookback_period': 60,
'upper_quantile': 0.8,
'lower_quantile': 0.2,
'breakout_threshold': 1.02,
'stop_loss_pct': 0.08,
'rebalance_period': 5,
'printlog': True # Enable logging
}
print(f"Testing single strategy with parameters: {test_params}")
= bt.Cerebro()
cerebro_test =df))
cerebro_test.adddata(bt.feeds.PandasData(dataname**test_params)
cerebro_test.addstrategy(SimplifiedQuantileChannelStrategy, 10000)
cerebro_test.broker.setcash(=0.001)
cerebro_test.broker.setcommission(commission='trades')
cerebro_test.addanalyzer(bt.analyzers.TradeAnalyzer, _name
print("Running test strategy...")
= cerebro_test.run()
test_result = test_result[0]
test_strategy
= test_strategy.analyzers.trades.get_analysis()
trades_test = trades_test.get('total', {}).get('total', 0)
total_trades_test
print(f"Test strategy results:")
print(f" Total trades: {total_trades_test}")
print(f" Strategy trade count: {getattr(test_strategy, 'trade_count', 0)}")
print(f" Breakout entries: {getattr(test_strategy, 'breakout_entries', 0)}")
print(f" Upper breakouts: {getattr(test_strategy, 'upper_breakouts', 0)}")
print(f" Lower breakouts: {getattr(test_strategy, 'lower_breakouts', 0)}")
print(f" Final value: ${cerebro_test.broker.getvalue():.2f}")
if total_trades_test == 0:
print(f"\n*** STRATEGY IS NOT GENERATING ANY TRADES ***")
print(f"Possible issues:")
print(f"1. Lookback period too long - not enough data for setup")
print(f"2. Quantile levels too extreme")
print(f"3. Breakout threshold too high")
print(f"4. Data period doesn't contain suitable breakout conditions")
# Let's check the data characteristics for channel strategy
print(f"\nDATA ANALYSIS:")
print(f"Price range: ${df['Close'].min():.2f} - ${df['Close'].max():.2f}")
print(f"Price change: {((df['Close'].iloc[-1] / df['Close'].iloc[0]) - 1) * 100:.1f}%")
# Calculate sample quantiles to see channel behavior
= df['Close'].tail(60).values
recent_prices = np.quantile(recent_prices, 0.8)
upper_80 = np.quantile(recent_prices, 0.2)
lower_20 = np.quantile(recent_prices, 0.5)
median = df['Close'].iloc[-1]
current_price
print(f"Recent 60-day quantiles:")
print(f" Upper 80%: ${upper_80:.2f}")
print(f" Median: ${median:.2f}")
print(f" Lower 20%: ${lower_20:.2f}")
print(f" Current: ${current_price:.2f}")
print(f" Channel width: {((upper_80 - lower_20) / median * 100):.1f}%")
# Check how often breakouts occur
= (df['Close'] > upper_80 * 1.02).sum()
upper_breakouts = (df['Close'] < lower_20 / 1.02).sum()
lower_breakouts
print(f"Potential breakouts in data:")
print(f" Upper breakouts: {upper_breakouts}")
print(f" Lower breakouts: {lower_breakouts}")
if upper_breakouts + lower_breakouts < 10:
print("*** Very few potential breakouts - this may explain lack of trades ***")
return None
# If we have valid results, continue with normal processing
= sorted(valid_results, key=lambda x: x['sharpe_ratio'], reverse=True)
results_sorted
print(f"\n{'='*140}")
print("TOP 10 PARAMETER COMBINATIONS BY SHARPE RATIO")
print(f"{'='*140}")
print("Rank | Sharpe | Return% | Value | Lookback | Upper | Lower | Breakout | Stop% | Rebal | Trades | Upper BO | Lower BO | Win%")
print("-" * 140)
for i, result in enumerate(results_sorted[:10]):
= (result['profitable_trades'] / max(1, result['trade_count'])) * 100
win_rate print(f"{i+1:4d} | {result['sharpe_ratio']:5.2f} | {result['return_pct']:6.1f}% | "
f"${result['final_value']:8,.0f} | {result['lookback_period']:8d} | {result['upper_quantile']:5.2f} | "
f"{result['lower_quantile']:5.2f} | {result['breakout_threshold']:8.3f} | {result['stop_loss_pct']:4.1%} | "
f"{result['rebalance_period']:5d} | {result['total_analyzer_trades']:6d} | {result['upper_breakouts']:8d} | "
f"{result['lower_breakouts']:8d} | {win_rate:4.1f}%")
= results_sorted[0]
best_params print(f"\n{'='*60}")
print("BEST PARAMETERS FOUND:")
print(f"{'='*60}")
print(f"Lookback Period: {best_params['lookback_period']}")
print(f"Upper Quantile: {best_params['upper_quantile']:.2f}")
print(f"Lower Quantile: {best_params['lower_quantile']:.2f}")
print(f"Breakout Threshold: {best_params['breakout_threshold']:.3f}")
print(f"Stop Loss: {best_params['stop_loss_pct']:.1%}")
print(f"Rebalance Period: {best_params['rebalance_period']}")
print(f"Sharpe Ratio: {best_params['sharpe_ratio']:.3f}")
print(f"Total Return: {best_params['return_pct']:.1f}%")
print(f"Total Trades: {best_params['total_analyzer_trades']}")
print(f"Upper Breakouts: {best_params['upper_breakouts']}")
print(f"Lower Breakouts: {best_params['lower_breakouts']}")
print(f"Mean Reversion Exits: {best_params['mean_reversion_exits']}")
= (best_params['profitable_trades'] / max(1, best_params['trade_count'])) * 100
win_rate print(f"Win Rate: {win_rate:.1f}%")
return best_params
The optimize_quantile_channel_parameters
function
performs a parameter optimization for the
SimplifiedQuantileChannelStrategy
.
bt.Cerebro
instance, adds the data,
initial cash, and commission. It also adds SharpeRatio
,
Returns
, and TradeAnalyzer
to collect detailed
performance metrics.cerebro.optstrategy
is used to run the strategy with
various combinations of lookback_period
,
upper_quantile
, lower_quantile
,
breakout_threshold
, stop_loss_pct
, and
rebalance_period
.A rolling backtest evaluates a strategy’s performance over sequential, overlapping or non-overlapping time windows. This provides a more robust assessment of its consistency across different market conditions than a single historical backtest.
# ------------------------------------------------------------------
# 3. Rolling Backtest Function
# ------------------------------------------------------------------
def run_rolling_backtest(ticker, start, end, window_months, strategy_params=None):
"""Rolling backtest function for Quantile Channel strategy"""
= 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()}")
# Fetch data using yfinance
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
if data.empty or len(data) < 90:
print("Not enough data for this period.")
+= rd.relativedelta(months=window_months)
current_start continue
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, 1)
data
# Calculate Buy & Hold return for the period
= data['Close'].iloc[0]
start_price = data['Close'].iloc[-1]
end_price = (end_price - start_price) / start_price * 100
benchmark_ret
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro
**strategy_params)
cerebro.addstrategy(SimplifiedQuantileChannelStrategy,
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()
result = cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
strategy_ret
# Get strategy statistics
= result[0]
strategy_instance = getattr(strategy_instance, 'trade_count', 0)
trades = getattr(strategy_instance, 'breakout_entries', 0)
breakouts = getattr(strategy_instance, 'stop_loss_exits', 0)
stops = getattr(strategy_instance, 'profitable_trades', 0)
profitable = getattr(strategy_instance, 'upper_breakouts', 0)
upper_breakouts = getattr(strategy_instance, 'lower_breakouts', 0)
lower_breakouts = getattr(strategy_instance, 'mean_reversion_exits', 0)
mean_reversions
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': strategy_ret,
'benchmark_pct': benchmark_ret,
'final_value': final_val,
'trades': trades,
'breakout_entries': breakouts,
'stop_exits': stops,
'profitable_trades': profitable,
'upper_breakouts': upper_breakouts,
'lower_breakouts': lower_breakouts,
'mean_reversion_exits': mean_reversions,
})
print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}% | Trades: {trades}")
+= rd.relativedelta(months=window_months)
current_start
return pd.DataFrame(all_results)
The run_rolling_backtest
function takes a
ticker
, an overall start
and end
date, and a window_months
parameter to define the size of
each rolling window.
yfinance
(with auto_adjust=False
and droplevel
for
column handling), sets up a bt.Cerebro
instance, adds the
SimplifiedQuantileChannelStrategy
with the specified
strategy_params
, and runs the backtest.The main execution block performs the following steps:
optimize_quantile_channel_parameters()
to find the best
performing parameters for the strategy over a specific historical period
(2020-2025 for BTC-USD). This step includes detailed diagnostics for the
optimization process.run_rolling_backtest()
function to evaluate the strategy
with the optimized parameters over a longer, comprehensive
period (2018-2025 for BTC-USD), using 12-month rolling windows.report_rolling_stats_with_plots()
to display key
performance metrics and generate the four specified plots (cumulative
returns, period-by-period comparison, breakout distribution, and exit
method analysis).This structured approach allows for a thorough analysis of the Simplified Quantile Channel Strategy, from identifying optimal parameters to understanding its performance consistency and behavioral characteristics across different market conditions.