Implement BBRS strategy with multi-timeframe support and enhance strategy manager

- Added BBRS strategy implementation, incorporating Bollinger Bands and RSI for trading signals.
- Introduced multi-timeframe analysis support, allowing strategies to handle internal resampling.
- Enhanced StrategyManager to log strategy initialization and unique timeframes in use.
- Updated DefaultStrategy to support flexible timeframe configurations and improved stop-loss execution.
- Improved plotting logic in BacktestCharts for better visualization of strategy outputs and trades.
- Refactored strategy base class to facilitate resampling and data handling across different timeframes.
This commit is contained in:
Vasily.onl 2025-05-23 16:56:53 +08:00
parent 235098c045
commit 5d0b707bc6
6 changed files with 948 additions and 142 deletions

View File

@ -68,4 +68,386 @@ class BacktestCharts:
plt.tight_layout(h_pad=0.1)
plt.show()
@staticmethod
def format_strategy_data_with_trades(strategy_data, backtest_results):
"""
Format strategy data for universal plotting with actual executed trades.
Converts strategy output into the expected column format: "x_type_name"
Args:
strategy_data (DataFrame): Output from strategy with columns like 'close', 'UpperBand', 'LowerBand', 'RSI'
backtest_results (dict): Results from backtest.run() containing actual executed trades
Returns:
DataFrame: Formatted data ready for plot_data function
"""
formatted_df = pd.DataFrame(index=strategy_data.index)
# Plot 1: Price data with Bollinger Bands and actual trade signals
if 'close' in strategy_data.columns:
formatted_df['1_line_close'] = strategy_data['close']
# Bollinger Bands area (prefer standard names, fallback to timeframe-specific)
upper_band_col = None
lower_band_col = None
sma_col = None
# Check for standard BB columns first
if 'UpperBand' in strategy_data.columns and 'LowerBand' in strategy_data.columns:
upper_band_col = 'UpperBand'
lower_band_col = 'LowerBand'
# Check for 15m BB columns
elif 'UpperBand_15m' in strategy_data.columns and 'LowerBand_15m' in strategy_data.columns:
upper_band_col = 'UpperBand_15m'
lower_band_col = 'LowerBand_15m'
if upper_band_col and lower_band_col:
formatted_df['1_area_bb_upper'] = strategy_data[upper_band_col]
formatted_df['1_area_bb_lower'] = strategy_data[lower_band_col]
# SMA/Moving Average line
if 'SMA' in strategy_data.columns:
sma_col = 'SMA'
elif 'SMA_15m' in strategy_data.columns:
sma_col = 'SMA_15m'
if sma_col:
formatted_df['1_line_sma'] = strategy_data[sma_col]
# Strategy buy/sell signals (all signals from strategy) as smaller scatter points
if 'BuySignal' in strategy_data.columns and 'close' in strategy_data.columns:
strategy_buy_points = strategy_data['close'].where(strategy_data['BuySignal'], np.nan)
formatted_df['1_scatter_strategy_buy'] = strategy_buy_points
if 'SellSignal' in strategy_data.columns and 'close' in strategy_data.columns:
strategy_sell_points = strategy_data['close'].where(strategy_data['SellSignal'], np.nan)
formatted_df['1_scatter_strategy_sell'] = strategy_sell_points
# Actual executed trades from backtest results (larger, more prominent)
if 'trades' in backtest_results and backtest_results['trades']:
# Create series for buy and sell points
buy_points = pd.Series(np.nan, index=strategy_data.index)
sell_points = pd.Series(np.nan, index=strategy_data.index)
for trade in backtest_results['trades']:
entry_time = trade.get('entry_time')
exit_time = trade.get('exit_time')
entry_price = trade.get('entry')
exit_price = trade.get('exit')
# Find closest index for entry time
if entry_time is not None and entry_price is not None:
try:
if isinstance(entry_time, str):
entry_time = pd.to_datetime(entry_time)
# Find the closest index to entry_time
closest_entry_idx = strategy_data.index.get_indexer([entry_time], method='nearest')[0]
if closest_entry_idx >= 0:
buy_points.iloc[closest_entry_idx] = entry_price
except (ValueError, IndexError, TypeError):
pass # Skip if can't find matching time
# Find closest index for exit time
if exit_time is not None and exit_price is not None:
try:
if isinstance(exit_time, str):
exit_time = pd.to_datetime(exit_time)
# Find the closest index to exit_time
closest_exit_idx = strategy_data.index.get_indexer([exit_time], method='nearest')[0]
if closest_exit_idx >= 0:
sell_points.iloc[closest_exit_idx] = exit_price
except (ValueError, IndexError, TypeError):
pass # Skip if can't find matching time
formatted_df['1_scatter_actual_buy'] = buy_points
formatted_df['1_scatter_actual_sell'] = sell_points
# Stop Loss and Take Profit levels
if 'StopLoss' in strategy_data.columns:
formatted_df['1_line_stop_loss'] = strategy_data['StopLoss']
if 'TakeProfit' in strategy_data.columns:
formatted_df['1_line_take_profit'] = strategy_data['TakeProfit']
# Plot 2: RSI
rsi_col = None
if 'RSI' in strategy_data.columns:
rsi_col = 'RSI'
elif 'RSI_15m' in strategy_data.columns:
rsi_col = 'RSI_15m'
if rsi_col:
formatted_df['2_line_rsi'] = strategy_data[rsi_col]
# Add RSI overbought/oversold levels
formatted_df['2_line_rsi_overbought'] = 70
formatted_df['2_line_rsi_oversold'] = 30
# Plot 3: Volume (if available)
if 'volume' in strategy_data.columns:
formatted_df['3_bar_volume'] = strategy_data['volume']
# Add volume moving average if available
if 'VolumeMA_15m' in strategy_data.columns:
formatted_df['3_line_volume_ma'] = strategy_data['VolumeMA_15m']
return formatted_df
@staticmethod
def format_strategy_data(strategy_data):
"""
Format strategy data for universal plotting (without trade signals).
Converts strategy output into the expected column format: "x_type_name"
Args:
strategy_data (DataFrame): Output from strategy with columns like 'close', 'UpperBand', 'LowerBand', 'RSI'
Returns:
DataFrame: Formatted data ready for plot_data function
"""
formatted_df = pd.DataFrame(index=strategy_data.index)
# Plot 1: Price data with Bollinger Bands
if 'close' in strategy_data.columns:
formatted_df['1_line_close'] = strategy_data['close']
# Bollinger Bands area (prefer standard names, fallback to timeframe-specific)
upper_band_col = None
lower_band_col = None
sma_col = None
# Check for standard BB columns first
if 'UpperBand' in strategy_data.columns and 'LowerBand' in strategy_data.columns:
upper_band_col = 'UpperBand'
lower_band_col = 'LowerBand'
# Check for 15m BB columns
elif 'UpperBand_15m' in strategy_data.columns and 'LowerBand_15m' in strategy_data.columns:
upper_band_col = 'UpperBand_15m'
lower_band_col = 'LowerBand_15m'
if upper_band_col and lower_band_col:
formatted_df['1_area_bb_upper'] = strategy_data[upper_band_col]
formatted_df['1_area_bb_lower'] = strategy_data[lower_band_col]
# SMA/Moving Average line
if 'SMA' in strategy_data.columns:
sma_col = 'SMA'
elif 'SMA_15m' in strategy_data.columns:
sma_col = 'SMA_15m'
if sma_col:
formatted_df['1_line_sma'] = strategy_data[sma_col]
# Stop Loss and Take Profit levels
if 'StopLoss' in strategy_data.columns:
formatted_df['1_line_stop_loss'] = strategy_data['StopLoss']
if 'TakeProfit' in strategy_data.columns:
formatted_df['1_line_take_profit'] = strategy_data['TakeProfit']
# Plot 2: RSI
rsi_col = None
if 'RSI' in strategy_data.columns:
rsi_col = 'RSI'
elif 'RSI_15m' in strategy_data.columns:
rsi_col = 'RSI_15m'
if rsi_col:
formatted_df['2_line_rsi'] = strategy_data[rsi_col]
# Add RSI overbought/oversold levels
formatted_df['2_line_rsi_overbought'] = 70
formatted_df['2_line_rsi_oversold'] = 30
# Plot 3: Volume (if available)
if 'volume' in strategy_data.columns:
formatted_df['3_bar_volume'] = strategy_data['volume']
# Add volume moving average if available
if 'VolumeMA_15m' in strategy_data.columns:
formatted_df['3_line_volume_ma'] = strategy_data['VolumeMA_15m']
return formatted_df
@staticmethod
def plot_data(df):
"""
Universal plot function for any formatted data.
- df: DataFrame with column names in format "x_type_name" where:
x = plot number (subplot)
type = plot type (line, area, scatter, bar, etc.)
name = descriptive name for the data series
"""
if df.empty:
print("No data to plot")
return
# Parse all columns
plot_info = []
for column in df.columns:
parts = column.split('_', 2) # Split into max 3 parts
if len(parts) < 3:
print(f"Warning: Skipping column '{column}' - invalid format. Expected 'x_type_name'")
continue
try:
plot_number = int(parts[0])
plot_type = parts[1].lower()
plot_name = parts[2]
plot_info.append((plot_number, plot_type, plot_name, column))
except ValueError:
print(f"Warning: Skipping column '{column}' - invalid plot number")
continue
if not plot_info:
print("No valid columns found for plotting")
return
# Group by plot number
plots = {}
for plot_num, plot_type, plot_name, column in plot_info:
if plot_num not in plots:
plots[plot_num] = []
plots[plot_num].append((plot_type, plot_name, column))
# Sort plot numbers
plot_numbers = sorted(plots.keys())
n_plots = len(plot_numbers)
# Create subplots
fig, axs = plt.subplots(n_plots, 1, figsize=(16, 6 * n_plots), sharex=True)
if n_plots == 1:
axs = [axs] # Ensure axs is always a list
# Plot each subplot
for i, plot_num in enumerate(plot_numbers):
ax = axs[i]
plot_items = plots[plot_num]
# Handle Bollinger Bands area first (needs special handling)
bb_upper = None
bb_lower = None
for plot_type, plot_name, column in plot_items:
if plot_type == 'area' and 'bb_upper' in plot_name:
bb_upper = df[column]
elif plot_type == 'area' and 'bb_lower' in plot_name:
bb_lower = df[column]
# Plot Bollinger Bands area if both bounds exist
if bb_upper is not None and bb_lower is not None:
ax.fill_between(df.index, bb_upper, bb_lower, alpha=0.2, color='gray', label='Bollinger Bands')
# Plot other items
for plot_type, plot_name, column in plot_items:
if plot_type == 'area' and ('bb_upper' in plot_name or 'bb_lower' in plot_name):
continue # Already handled above
data = df[column].dropna() # Remove NaN values for cleaner plots
if plot_type == 'line':
color = None
linestyle = '-'
alpha = 1.0
# Special styling for different line types
if 'overbought' in plot_name:
color = 'red'
linestyle = '--'
alpha = 0.7
elif 'oversold' in plot_name:
color = 'green'
linestyle = '--'
alpha = 0.7
elif 'stop_loss' in plot_name:
color = 'red'
linestyle = ':'
alpha = 0.8
elif 'take_profit' in plot_name:
color = 'green'
linestyle = ':'
alpha = 0.8
elif 'sma' in plot_name:
color = 'orange'
alpha = 0.8
elif 'volume_ma' in plot_name:
color = 'purple'
alpha = 0.7
ax.plot(data.index, data, label=plot_name.replace('_', ' ').title(),
color=color, linestyle=linestyle, alpha=alpha)
elif plot_type == 'scatter':
color = 'green' if 'buy' in plot_name else 'red' if 'sell' in plot_name else 'blue'
marker = '^' if 'buy' in plot_name else 'v' if 'sell' in plot_name else 'o'
size = 100 if 'buy' in plot_name or 'sell' in plot_name else 50
alpha = 0.8
zorder = 5
label_name = plot_name.replace('_', ' ').title()
# Special styling for different signal types
if 'actual_buy' in plot_name:
color = 'darkgreen'
marker = '^'
size = 120
alpha = 1.0
zorder = 10 # Higher z-order to appear on top
label_name = 'Actual Buy Trades'
elif 'actual_sell' in plot_name:
color = 'darkred'
marker = 'v'
size = 120
alpha = 1.0
zorder = 10 # Higher z-order to appear on top
label_name = 'Actual Sell Trades'
elif 'strategy_buy' in plot_name:
color = 'lightgreen'
marker = '^'
size = 60
alpha = 0.6
zorder = 3 # Lower z-order to appear behind actual trades
label_name = 'Strategy Buy Signals'
elif 'strategy_sell' in plot_name:
color = 'lightcoral'
marker = 'v'
size = 60
alpha = 0.6
zorder = 3 # Lower z-order to appear behind actual trades
label_name = 'Strategy Sell Signals'
ax.scatter(data.index, data, label=label_name,
color=color, marker=marker, s=size, alpha=alpha, zorder=zorder)
elif plot_type == 'area':
ax.fill_between(data.index, data, alpha=0.5, label=plot_name.replace('_', ' ').title())
elif plot_type == 'bar':
ax.bar(data.index, data, alpha=0.7, label=plot_name.replace('_', ' ').title())
else:
print(f"Warning: Plot type '{plot_type}' not supported for column '{column}'")
# Customize subplot
ax.grid(True, alpha=0.3)
ax.legend()
# Set titles and labels
if plot_num == 1:
ax.set_title('Price Chart with Bollinger Bands and Signals')
ax.set_ylabel('Price')
elif plot_num == 2:
ax.set_title('RSI Indicator')
ax.set_ylabel('RSI')
ax.set_ylim(0, 100)
elif plot_num == 3:
ax.set_title('Volume')
ax.set_ylabel('Volume')
else:
ax.set_title(f'Plot {plot_num}')
# Set x-axis label only on the bottom subplot
axs[-1].set_xlabel('Time')
plt.tight_layout()
plt.show()

View File

@ -6,8 +6,9 @@ This module contains the fundamental building blocks for all trading strategies:
- StrategyBase: Abstract base class that all strategies must inherit from
"""
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Optional
from typing import Dict, Optional, List, Union
class StrategySignal:
@ -60,6 +61,7 @@ class StrategyBase(ABC):
Abstract base class for all trading strategies.
This class defines the interface that all strategies must implement:
- get_timeframes(): Specify required timeframes for the strategy
- initialize(): Setup strategy with backtester data
- get_entry_signal(): Generate entry signals
- get_exit_signal(): Generate exit signals
@ -70,11 +72,15 @@ class StrategyBase(ABC):
weight (float): Strategy weight for combination
params (Dict): Strategy parameters
initialized (bool): Whether strategy has been initialized
timeframes_data (Dict): Resampled data for different timeframes
Example:
class MyStrategy(StrategyBase):
def get_timeframes(self):
return ["15min"] # This strategy works on 15-minute data
def initialize(self, backtester):
# Setup strategy indicators
# Setup strategy indicators using self.timeframes_data["15min"]
self.initialized = True
def get_entry_signal(self, backtester, df_index):
@ -97,6 +103,77 @@ class StrategyBase(ABC):
self.weight = weight
self.params = params or {}
self.initialized = False
self.timeframes_data = {} # Will store resampled data for each timeframe
def get_timeframes(self) -> List[str]:
"""
Get the list of timeframes required by this strategy.
Override this method to specify which timeframes your strategy needs.
The base class will automatically resample the 1-minute data to these timeframes
and make them available in self.timeframes_data.
Returns:
List[str]: List of timeframe strings (e.g., ["1min", "15min", "1h"])
Example:
def get_timeframes(self):
return ["15min"] # Strategy needs 15-minute data
def get_timeframes(self):
return ["5min", "15min", "1h"] # Multi-timeframe strategy
"""
return ["1min"] # Default to 1-minute data
def _resample_data(self, original_data: pd.DataFrame) -> None:
"""
Resample the original 1-minute data to all required timeframes.
This method is called automatically during initialization to create
resampled versions of the data for each timeframe the strategy needs.
Args:
original_data: Original 1-minute OHLCV data with DatetimeIndex
"""
self.timeframes_data = {}
for timeframe in self.get_timeframes():
if timeframe == "1min":
# For 1-minute data, just use the original
self.timeframes_data[timeframe] = original_data.copy()
else:
# Resample to the specified timeframe
resampled = original_data.resample(timeframe).agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
'volume': 'sum'
}).dropna()
self.timeframes_data[timeframe] = resampled
def get_data_for_timeframe(self, timeframe: str) -> Optional[pd.DataFrame]:
"""
Get resampled data for a specific timeframe.
Args:
timeframe: Timeframe string (e.g., "15min", "1h")
Returns:
pd.DataFrame: Resampled OHLCV data or None if timeframe not available
"""
return self.timeframes_data.get(timeframe)
def get_primary_timeframe_data(self) -> pd.DataFrame:
"""
Get data for the primary (first) timeframe.
Returns:
pd.DataFrame: Data for the first timeframe in get_timeframes() list
"""
primary_timeframe = self.get_timeframes()[0]
return self.timeframes_data[primary_timeframe]
@abstractmethod
def initialize(self, backtester) -> None:
@ -104,6 +181,9 @@ class StrategyBase(ABC):
Initialize strategy with backtester data.
This method is called once before backtesting begins.
The original 1-minute data will already be resampled to all required timeframes
and available in self.timeframes_data.
Strategies should setup indicators, validate data, and
set self.initialized = True when complete.
@ -117,9 +197,12 @@ class StrategyBase(ABC):
"""
Generate entry signal for the given data index.
The df_index refers to the index in the backtester's working dataframe,
which corresponds to the primary timeframe data.
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Entry signal with confidence level
@ -131,9 +214,12 @@ class StrategyBase(ABC):
"""
Generate exit signal for the given data index.
The df_index refers to the index in the backtester's working dataframe,
which corresponds to the primary timeframe data.
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Exit signal with confidence level
@ -149,7 +235,7 @@ class StrategyBase(ABC):
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
float: Confidence level (0.0 to 1.0)
@ -158,5 +244,7 @@ class StrategyBase(ABC):
def __repr__(self) -> str:
"""String representation of the strategy."""
timeframes = self.get_timeframes()
return (f"{self.__class__.__name__}(name={self.name}, "
f"weight={self.weight}, initialized={self.initialized})")
f"weight={self.weight}, timeframes={timeframes}, "
f"initialized={self.initialized})")

View File

@ -10,12 +10,13 @@ Key Features:
- Bollinger Band squeeze detection
- RSI overbought/oversold conditions
- Market regime-specific thresholds
- Multi-timeframe analysis support
"""
import pandas as pd
import numpy as np
import logging
from typing import Tuple, Optional
from typing import Tuple, Optional, List
from .base import StrategyBase, StrategySignal
@ -28,6 +29,10 @@ class BBRSStrategy(StrategyBase):
to generate trading signals. It adapts its parameters based on whether the market
is in a trending or sideways regime.
The strategy works with 1-minute data as input and lets the underlying Strategy class
handle internal resampling to the timeframes it needs (typically 15min and 1h).
Stop-loss execution uses 1-minute precision.
Parameters:
bb_width (float): Bollinger Band width threshold (default: 0.05)
bb_period (int): Bollinger Band period (default: 20)
@ -36,7 +41,7 @@ class BBRSStrategy(StrategyBase):
trending_bb_multiplier (float): BB multiplier for trending market
sideways_rsi_threshold (list): RSI thresholds for sideways market [low, high]
sideways_bb_multiplier (float): BB multiplier for sideways market
strategy_name (str): Strategy implementation name
strategy_name (str): Strategy implementation name ("MarketRegimeStrategy" or "CryptoTradingStrategy")
SqueezeStrategy (bool): Enable squeeze strategy
stop_loss_pct (float): Stop loss percentage (default: 0.05)
@ -45,7 +50,8 @@ class BBRSStrategy(StrategyBase):
"bb_width": 0.05,
"bb_period": 20,
"rsi_period": 14,
"strategy_name": "MarketRegimeStrategy"
"strategy_name": "MarketRegimeStrategy",
"SqueezeStrategy": true
}
strategy = BBRSStrategy(weight=1.0, params=params)
"""
@ -60,26 +66,47 @@ class BBRSStrategy(StrategyBase):
"""
super().__init__("bbrs", weight, params)
def get_timeframes(self) -> List[str]:
"""
Get the timeframes required by the BBRS strategy.
BBRS strategy uses 1-minute data as input and lets the Strategy class
handle internal resampling to the timeframes it needs (15min, 1h, etc.).
We still include 1min for stop-loss precision.
Returns:
List[str]: List of timeframes needed for the strategy
"""
# BBRS strategy works with 1-minute data and lets Strategy class handle resampling
return ["1min"]
def initialize(self, backtester) -> None:
"""
Initialize BBRS strategy with signal processing.
Sets up the strategy by:
1. Initializing empty signal series
2. Running the BBRS strategy processing if original data is available
3. Resampling signals from 15-minute to 1-minute resolution
1. Using 1-minute data directly (Strategy class handles internal resampling)
2. Running the BBRS strategy processing on 1-minute data
3. Creating signals aligned with backtester expectations
Args:
backtester: Backtest instance with OHLCV data
"""
# Initialize empty signal series
backtester.strategies["buy_signals"] = pd.Series(False, index=range(len(backtester.df)))
backtester.strategies["sell_signals"] = pd.Series(False, index=range(len(backtester.df)))
backtester.strategies["stop_loss_pct"] = self.params.get("stop_loss_pct", 0.05)
# Resample to get 1-minute data (which should be the original data)
self._resample_data(backtester.original_df)
# Run strategy processing if original data is available
if hasattr(backtester, 'original_df'):
self._run_strategy_processing(backtester)
# Get 1-minute data for strategy processing - Strategy class will handle internal resampling
min1_data = self.get_data_for_timeframe("1min")
# Initialize empty signal series for backtester compatibility
# Note: These will be populated after strategy processing
backtester.strategies["buy_signals"] = pd.Series(False, index=range(len(min1_data)))
backtester.strategies["sell_signals"] = pd.Series(False, index=range(len(min1_data)))
backtester.strategies["stop_loss_pct"] = self.params.get("stop_loss_pct", 0.05)
backtester.strategies["primary_timeframe"] = "1min"
# Run strategy processing on 1-minute data
self._run_strategy_processing(backtester)
self.initialized = True
@ -88,14 +115,17 @@ class BBRSStrategy(StrategyBase):
Run the actual BBRS strategy processing.
Uses the Strategy class from cycles.Analysis.strategies to process
the original dataframe and generate buy/sell signals based on
Bollinger Bands, RSI, and market regime detection.
the 1-minute data. The Strategy class will handle internal resampling
to the timeframes it needs (15min, 1h, etc.) and generate buy/sell signals.
Args:
backtester: Backtest instance with original_df attribute
backtester: Backtest instance with timeframes_data available
"""
from cycles.Analysis.strategies import Strategy
# Get 1-minute data for strategy processing - let Strategy class handle resampling
strategy_data = self.get_data_for_timeframe("1min")
# Configure strategy parameters with defaults
config_strategy = {
"bb_width": self.params.get("bb_width", 0.05),
@ -113,9 +143,9 @@ class BBRSStrategy(StrategyBase):
"SqueezeStrategy": self.params.get("SqueezeStrategy", True)
}
# Run strategy processing
# Run strategy processing on 1-minute data - Strategy class handles internal resampling
strategy = Strategy(config=config_strategy, logging=logging)
processed_data = strategy.run(backtester.original_df, config_strategy["strategy_name"])
processed_data = strategy.run(strategy_data, config_strategy["strategy_name"])
# Store processed data for plotting and analysis
backtester.processed_data = processed_data
@ -128,62 +158,29 @@ class BBRSStrategy(StrategyBase):
buy_signals_raw = processed_data.get('BuySignal', pd.Series(False, index=processed_data.index)).astype(bool)
sell_signals_raw = processed_data.get('SellSignal', pd.Series(False, index=processed_data.index)).astype(bool)
# Resample signals from 15-minute to 1-minute resolution
self._resample_signals_to_1min(backtester, buy_signals_raw, sell_signals_raw)
def _resample_signals_to_1min(self, backtester, buy_signals_raw, sell_signals_raw) -> None:
"""
Resample signals from 15-minute to 1-minute resolution.
# The processed_data will be on whatever timeframe the Strategy class outputs
# We need to map these signals back to 1-minute resolution for backtesting
original_1min_data = self.get_data_for_timeframe("1min")
Takes the 15-minute signals and maps them to 1-minute timestamps
using forward-fill to maintain signal consistency.
# Reindex signals to 1-minute resolution using forward-fill
buy_signals_1min = buy_signals_raw.reindex(original_1min_data.index, method='ffill').fillna(False)
sell_signals_1min = sell_signals_raw.reindex(original_1min_data.index, method='ffill').fillna(False)
Args:
backtester: Backtest instance
buy_signals_raw: Raw buy signals from strategy processing
sell_signals_raw: Raw sell signals from strategy processing
"""
# Get the DatetimeIndex from the original 1-minute data
original_datetime_index = backtester.original_df.index
# Reindex signals from 15-minute to 1-minute resolution using forward-fill
buy_signals_1min = buy_signals_raw.reindex(original_datetime_index, method='ffill').fillna(False)
sell_signals_1min = sell_signals_raw.reindex(original_datetime_index, method='ffill').fillna(False)
# Convert to integer index to match backtest DataFrame
buy_condition = pd.Series(buy_signals_1min.values, index=range(len(buy_signals_1min)))
sell_condition = pd.Series(sell_signals_1min.values, index=range(len(sell_signals_1min)))
# Ensure same length as backtest DataFrame
if len(buy_condition) != len(backtester.df):
target_length = len(backtester.df)
if len(buy_condition) > target_length:
# Truncate if longer
buy_condition = buy_condition[:target_length]
sell_condition = sell_condition[:target_length]
else:
# Pad with False if shorter
buy_values = buy_condition.values
sell_values = sell_condition.values
buy_values = np.pad(buy_values, (0, target_length - len(buy_values)), constant_values=False)
sell_values = np.pad(sell_values, (0, target_length - len(sell_values)), constant_values=False)
buy_condition = pd.Series(buy_values, index=range(target_length))
sell_condition = pd.Series(sell_values, index=range(target_length))
# Store the resampled signals
backtester.strategies["buy_signals"] = buy_condition
backtester.strategies["sell_signals"] = sell_condition
# Convert to integer index to match backtester expectations
backtester.strategies["buy_signals"] = pd.Series(buy_signals_1min.values, index=range(len(buy_signals_1min)))
backtester.strategies["sell_signals"] = pd.Series(sell_signals_1min.values, index=range(len(sell_signals_1min)))
def get_entry_signal(self, backtester, df_index: int) -> StrategySignal:
"""
Generate entry signal based on BBRS buy signals.
Entry occurs when the BBRS strategy processing has generated
a buy signal based on Bollinger Bands and RSI conditions.
a buy signal based on Bollinger Bands and RSI conditions on
the primary timeframe.
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Entry signal if buy condition met, hold otherwise
@ -196,7 +193,8 @@ class BBRSStrategy(StrategyBase):
if backtester.strategies["buy_signals"].iloc[df_index]:
# High confidence for BBRS buy signals
return StrategySignal("ENTRY", confidence=1.0)
confidence = self._calculate_signal_confidence(backtester, df_index, "entry")
return StrategySignal("ENTRY", confidence=confidence)
return StrategySignal("HOLD", confidence=0.0)
@ -210,7 +208,7 @@ class BBRSStrategy(StrategyBase):
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Exit signal with type and price, or hold signal
@ -223,10 +221,11 @@ class BBRSStrategy(StrategyBase):
# Check for sell signal
if backtester.strategies["sell_signals"].iloc[df_index]:
return StrategySignal("EXIT", confidence=1.0,
confidence = self._calculate_signal_confidence(backtester, df_index, "exit")
return StrategySignal("EXIT", confidence=confidence,
metadata={"type": "SELL_SIGNAL"})
# Check for stop loss
# Check for stop loss using 1-minute data for precision
stop_loss_result, sell_price = self._check_stop_loss(backtester)
if stop_loss_result:
return StrategySignal("EXIT", confidence=1.0, price=sell_price,
@ -238,12 +237,12 @@ class BBRSStrategy(StrategyBase):
"""
Get strategy confidence based on signal strength and market conditions.
Confidence is higher when signals are present and market conditions
are favorable for the BBRS strategy.
Confidence can be enhanced by analyzing multiple timeframes and
market regime consistency.
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
float: Confidence level (0.0 to 1.0)
@ -251,26 +250,51 @@ class BBRSStrategy(StrategyBase):
if not self.initialized:
return 0.0
# Check if we have processed data for confidence calculation
if hasattr(backtester, 'processed_data') and not backtester.processed_data.empty:
# Could analyze RSI levels, BB position, etc. for dynamic confidence
# For now, return high confidence when signals are present
if (df_index < len(backtester.strategies["buy_signals"]) and
backtester.strategies["buy_signals"].iloc[df_index]):
return 1.0
elif (df_index < len(backtester.strategies["sell_signals"]) and
backtester.strategies["sell_signals"].iloc[df_index]):
return 1.0
# Check for active signals
has_buy_signal = (df_index < len(backtester.strategies["buy_signals"]) and
backtester.strategies["buy_signals"].iloc[df_index])
has_sell_signal = (df_index < len(backtester.strategies["sell_signals"]) and
backtester.strategies["sell_signals"].iloc[df_index])
if has_buy_signal or has_sell_signal:
signal_type = "entry" if has_buy_signal else "exit"
return self._calculate_signal_confidence(backtester, df_index, signal_type)
# Moderate confidence during neutral periods
return 0.5
def _calculate_signal_confidence(self, backtester, df_index: int, signal_type: str) -> float:
"""
Calculate confidence level for a signal based on multiple factors.
Can consider multiple timeframes, market regime, volatility, etc.
Args:
backtester: Backtest instance
df_index: Current index
signal_type: "entry" or "exit"
Returns:
float: Confidence level (0.0 to 1.0)
"""
base_confidence = 1.0
# TODO: Implement multi-timeframe confirmation
# For now, return high confidence for primary signals
# Future enhancements could include:
# - Checking confirmation from additional timeframes
# - Analyzing market regime consistency
# - Considering volatility levels
# - RSI and BB position analysis
return base_confidence
def _check_stop_loss(self, backtester) -> Tuple[bool, Optional[float]]:
"""
Check if stop loss is triggered using BBRS-specific logic.
Check if stop loss is triggered using 1-minute data for precision.
Similar to default strategy but uses BBRS-specific stop loss percentage
and can be enhanced with additional BBRS-specific exit conditions.
Uses 1-minute data regardless of primary timeframe to ensure
accurate stop loss execution.
Args:
backtester: Backtest instance with current trade state
@ -281,9 +305,13 @@ class BBRSStrategy(StrategyBase):
# Calculate stop loss price
stop_price = backtester.entry_price * (1 - backtester.strategies["stop_loss_pct"])
# Get minute-level data for precise stop loss checking
min1_df = backtester.original_df if hasattr(backtester, 'original_df') else backtester.min1_df
min1_index = min1_df.index
# Use 1-minute data for precise stop loss checking
min1_data = self.get_data_for_timeframe("1min")
if min1_data is None:
# Fallback to original_df if 1min timeframe not available
min1_data = backtester.original_df if hasattr(backtester, 'original_df') else backtester.min1_df
min1_index = min1_data.index
# Find data range from entry to current time
start_candidates = min1_index[min1_index >= backtester.entry_time]
@ -299,7 +327,7 @@ class BBRSStrategy(StrategyBase):
backtester.current_min1_end_idx = end_candidates[-1]
# Check if any candle in the range triggered stop loss
min1_slice = min1_df.loc[backtester.current_trade_min1_start_idx:backtester.current_min1_end_idx]
min1_slice = min1_data.loc[backtester.current_trade_min1_start_idx:backtester.current_min1_end_idx]
if (min1_slice['low'] <= stop_price).any():
# Find the first candle that triggered stop loss

View File

@ -11,7 +11,7 @@ The meta-trend is calculated by comparing three Supertrend indicators:
"""
import numpy as np
from typing import Tuple, Optional
from typing import Tuple, Optional, List
from .base import StrategyBase, StrategySignal
@ -24,11 +24,14 @@ class DefaultStrategy(StrategyBase):
It generates entry signals when all three Supertrend indicators align in an
upward direction, and exit signals when they reverse or stop loss is triggered.
The strategy works best on 15-minute timeframes but can be configured for other timeframes.
Parameters:
stop_loss_pct (float): Stop loss percentage (default: 0.03)
timeframe (str): Preferred timeframe for analysis (default: "15min")
Example:
strategy = DefaultStrategy(weight=1.0, params={"stop_loss_pct": 0.05})
strategy = DefaultStrategy(weight=1.0, params={"stop_loss_pct": 0.05, "timeframe": "15min"})
"""
def __init__(self, weight: float = 1.0, params: Optional[dict] = None):
@ -37,10 +40,29 @@ class DefaultStrategy(StrategyBase):
Args:
weight: Strategy weight for combination (default: 1.0)
params: Strategy parameters including stop_loss_pct
params: Strategy parameters including stop_loss_pct and timeframe
"""
super().__init__("default", weight, params)
def get_timeframes(self) -> List[str]:
"""
Get the timeframes required by the default strategy.
The default strategy works on a single timeframe (typically 15min)
but also needs 1min data for precise stop-loss execution.
Returns:
List[str]: List containing primary timeframe and 1min for stop-loss
"""
primary_timeframe = self.params.get("timeframe", "15min")
# Always include 1min for stop-loss precision, avoid duplicates
timeframes = [primary_timeframe]
if primary_timeframe != "1min":
timeframes.append("1min")
return timeframes
def initialize(self, backtester) -> None:
"""
Initialize meta trend calculation using Supertrend indicators.
@ -54,8 +76,15 @@ class DefaultStrategy(StrategyBase):
"""
from cycles.Analysis.supertrend import Supertrends
# Calculate Supertrend indicators
supertrends = Supertrends(backtester.df, verbose=False)
# First, resample the original 1-minute data to required timeframes
self._resample_data(backtester.original_df)
# Get the primary timeframe data for strategy calculations
primary_timeframe = self.get_timeframes()[0]
strategy_data = self.get_data_for_timeframe(primary_timeframe)
# Calculate Supertrend indicators on the primary timeframe
supertrends = Supertrends(strategy_data, verbose=False)
supertrend_results_list = supertrends.calculate_supertrend_indicators()
# Extract trend arrays from each Supertrend
@ -70,8 +99,10 @@ class DefaultStrategy(StrategyBase):
)
# Store in backtester for access during trading
# Note: backtester.df should now be using our primary timeframe
backtester.strategies["meta_trend"] = meta_trend
backtester.strategies["stop_loss_pct"] = self.params.get("stop_loss_pct", 0.03)
backtester.strategies["primary_timeframe"] = primary_timeframe
self.initialized = True
@ -84,7 +115,7 @@ class DefaultStrategy(StrategyBase):
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Entry signal if trend aligns, hold signal otherwise
@ -115,7 +146,7 @@ class DefaultStrategy(StrategyBase):
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
StrategySignal: Exit signal with type and price, or hold signal
@ -134,7 +165,7 @@ class DefaultStrategy(StrategyBase):
return StrategySignal("EXIT", confidence=1.0,
metadata={"type": "META_TREND_EXIT_SIGNAL"})
# Check for stop loss
# Check for stop loss using 1-minute data for precision
stop_loss_result, sell_price = self._check_stop_loss(backtester)
if stop_loss_result:
return StrategySignal("EXIT", confidence=1.0, price=sell_price,
@ -151,7 +182,7 @@ class DefaultStrategy(StrategyBase):
Args:
backtester: Backtest instance with current state
df_index: Current index in the dataframe
df_index: Current index in the primary timeframe dataframe
Returns:
float: Confidence level (0.0 to 1.0)
@ -172,8 +203,8 @@ class DefaultStrategy(StrategyBase):
"""
Check if stop loss is triggered based on price movement.
Calculates stop loss price and checks if any candle since entry
has triggered the stop loss condition.
Uses 1-minute data for precise stop loss checking regardless of
the primary timeframe used for strategy signals.
Args:
backtester: Backtest instance with current trade state
@ -184,9 +215,13 @@ class DefaultStrategy(StrategyBase):
# Calculate stop loss price
stop_price = backtester.entry_price * (1 - backtester.strategies["stop_loss_pct"])
# Get minute-level data for precise stop loss checking
min1_df = backtester.original_df if hasattr(backtester, 'original_df') else backtester.min1_df
min1_index = min1_df.index
# Use 1-minute data for precise stop loss checking
min1_data = self.get_data_for_timeframe("1min")
if min1_data is None:
# Fallback to original_df if 1min timeframe not available
min1_data = backtester.original_df if hasattr(backtester, 'original_df') else backtester.min1_df
min1_index = min1_data.index
# Find data range from entry to current time
start_candidates = min1_index[min1_index >= backtester.entry_time]
@ -202,7 +237,7 @@ class DefaultStrategy(StrategyBase):
backtester.current_min1_end_idx = end_candidates[-1]
# Check if any candle in the range triggered stop loss
min1_slice = min1_df.loc[backtester.current_trade_min1_start_idx:backtester.current_min1_end_idx]
min1_slice = min1_data.loc[backtester.current_trade_min1_start_idx:backtester.current_min1_end_idx]
if (min1_slice['low'] <= stop_price).any():
# Find the first candle that triggered stop loss

View File

@ -100,6 +100,7 @@ class StrategyManager:
Calls the initialize method on each strategy, allowing them
to set up indicators, validate data, and prepare for trading.
Each strategy will handle its own timeframe resampling.
Args:
backtester: Backtest instance with OHLCV data
@ -107,13 +108,23 @@ class StrategyManager:
for strategy in self.strategies:
try:
strategy.initialize(backtester)
logging.info(f"Initialized strategy: {strategy.name}")
# Log strategy timeframe information
timeframes = strategy.get_timeframes()
logging.info(f"Initialized strategy: {strategy.name} with timeframes: {timeframes}")
except Exception as e:
logging.error(f"Failed to initialize strategy {strategy.name}: {e}")
raise
self.initialized = True
logging.info(f"Strategy manager initialized with {len(self.strategies)} strategies")
# Log summary of all timeframes being used
all_timeframes = set()
for strategy in self.strategies:
all_timeframes.update(strategy.get_timeframes())
logging.info(f"Total unique timeframes in use: {sorted(all_timeframes)}")
def get_entry_signal(self, backtester, df_index: int) -> bool:
"""
@ -323,7 +334,7 @@ class StrategyManager:
Get summary of loaded strategies and their configuration.
Returns:
Dict: Summary of strategies, weights, and combination rules
Dict: Summary of strategies, weights, combination rules, and timeframes
"""
return {
"strategies": [
@ -331,13 +342,15 @@ class StrategyManager:
"name": strategy.name,
"weight": strategy.weight,
"params": strategy.params,
"timeframes": strategy.get_timeframes(),
"initialized": strategy.initialized
}
for strategy in self.strategies
],
"combination_rules": self.combination_rules,
"total_strategies": len(self.strategies),
"initialized": self.initialized
"initialized": self.initialized,
"all_timeframes": list(set().union(*[strategy.get_timeframes() for strategy in self.strategies]))
}
def __repr__(self) -> str:

320
main.py
View File

@ -12,6 +12,8 @@ from cycles.utils.system import SystemUtils
from cycles.backtest import Backtest
from cycles.Analysis.supertrend import Supertrends
from cycles.charts import BacktestCharts
from cycles.Analysis.strategies import Strategy
from cycles.strategies import StrategyManager, create_strategy_manager
logging.basicConfig(
level=logging.INFO,
@ -23,6 +25,8 @@ logging.basicConfig(
)
def default_init_strategy(backtester: Backtest):
"""Calculate meta trend
"""
supertrends = Supertrends(backtester.df, verbose=False)
supertrend_results_list = supertrends.calculate_supertrend_indicators()
@ -33,10 +37,158 @@ def default_init_strategy(backtester: Backtest):
backtester.strategies["meta_trend"] = meta_trend
def bbrs_init_strategy(backtester: Backtest):
"""BBRs entry strategy initialization - just setup basic structure"""
# Initialize empty strategies
backtester.strategies["buy_signals"] = pd.Series(False, index=range(len(backtester.df)))
backtester.strategies["sell_signals"] = pd.Series(False, index=range(len(backtester.df)))
return backtester
def run_bbrs_strategy_processing(backtester: Backtest, original_df):
"""Run the actual strategy processing after backtest is initialized"""
config_strategy = {
"bb_width": 0.05,
"bb_period": 20,
"rsi_period": 14,
"trending": {
"rsi_threshold": [30, 70],
"bb_std_dev_multiplier": 2.5,
},
"sideways": {
"rsi_threshold": [40, 60],
"bb_std_dev_multiplier": 1.8,
},
"strategy_name": "MarketRegimeStrategy", # "MarketRegimeStrategy", # CryptoTradingStrategy
"SqueezeStrategy": True
}
strategy = Strategy(config=config_strategy, logging=logging)
processed_data = strategy.run(original_df, config_strategy["strategy_name"])
print(f"processed_data: {processed_data.head()}")
# Store processed data for plotting
backtester.processed_data = processed_data
if processed_data.empty:
# If strategy processing failed, create empty signals aligned with backtest DataFrame
buy_condition = pd.Series(False, index=range(len(backtester.df)))
sell_condition = pd.Series(False, index=range(len(backtester.df)))
else:
# Get original signals from processed data
buy_signals_raw = processed_data.get('BuySignal', pd.Series(False, index=processed_data.index)).astype(bool)
sell_signals_raw = processed_data.get('SellSignal', pd.Series(False, index=processed_data.index)).astype(bool)
# Get the DatetimeIndex from the original 1-minute data
original_datetime_index = original_df.index
# Reindex signals from 15-minute to 1-minute resolution using forward-fill
# This maps each 15-minute signal to the corresponding 1-minute timestamps
buy_signals_1min = buy_signals_raw.reindex(original_datetime_index, method='ffill').fillna(False)
sell_signals_1min = sell_signals_raw.reindex(original_datetime_index, method='ffill').fillna(False)
# Convert to integer index to match backtest DataFrame
buy_condition = pd.Series(buy_signals_1min.values, index=range(len(buy_signals_1min)))
sell_condition = pd.Series(sell_signals_1min.values, index=range(len(sell_signals_1min)))
# Ensure same length as backtest DataFrame (should be same now, but safety check)
if len(buy_condition) != len(backtester.df):
target_length = len(backtester.df)
if len(buy_condition) > target_length:
buy_condition = buy_condition[:target_length]
sell_condition = sell_condition[:target_length]
else:
# Pad with False if shorter
buy_values = buy_condition.values
sell_values = sell_condition.values
buy_values = np.pad(buy_values, (0, target_length - len(buy_values)), constant_values=False)
sell_values = np.pad(sell_values, (0, target_length - len(sell_values)), constant_values=False)
buy_condition = pd.Series(buy_values, index=range(target_length))
sell_condition = pd.Series(sell_values, index=range(target_length))
backtester.strategies["buy_signals"] = buy_condition
backtester.strategies["sell_signals"] = sell_condition
# backtester.strategies["buy_signals"] = sell_condition
# backtester.strategies["sell_signals"] = buy_condition
print(f"buy_signals length: {len(backtester.strategies['buy_signals'])}, backtest df length: {len(backtester.df)}")
def bbrs_entry_strategy(backtester: Backtest, df_index):
"""BBRs entry strategy
Entry when buy signal is true
"""
return backtester.strategies["buy_signals"].iloc[df_index]
def bbrs_exit_strategy(backtester: Backtest, df_index):
"""BBRs exit strategy
Exit when sell signal is true or stop loss is triggered
"""
if backtester.strategies["sell_signals"].iloc[df_index]:
return "SELL_SIGNAL", backtester.df.iloc[df_index]['close']
# Check for stop loss using BBRs-specific stop loss strategy
stop_loss_result, sell_price = bbrs_stop_loss_strategy(backtester)
if stop_loss_result:
backtester.strategies["current_trade_min1_start_idx"] = \
backtester.current_trade_min1_start_idx
return "STOP_LOSS", sell_price
return None, None
def bbrs_stop_loss_strategy(backtester: Backtest):
"""BBRs stop loss strategy
Calculate stop loss price based on 5% loss
Find the first min1 candle that is below the stop loss price
If the stop loss price is below the open price, use the open price as the stop loss price
"""
# Use 5% stop loss as requested
stop_loss_pct = 0.05
stop_price = backtester.entry_price * (1 - stop_loss_pct)
# Use the original min1 dataframe that has datetime index
min1_df = backtester.original_df if hasattr(backtester, 'original_df') else backtester.min1_df
min1_index = min1_df.index
# Find candles from entry time to current time
start_candidates = min1_index[min1_index >= backtester.entry_time]
if len(start_candidates) == 0:
return False, None
backtester.current_trade_min1_start_idx = start_candidates[0]
end_candidates = min1_index[min1_index <= backtester.current_date]
if len(end_candidates) == 0:
print("Warning: no end candidate here. Need to be checked")
return False, None
backtester.current_min1_end_idx = end_candidates[-1]
# Get the slice of data between entry and current time
min1_slice = min1_df.loc[backtester.current_trade_min1_start_idx:backtester.current_min1_end_idx]
# Check if any candle's low price hits the stop loss
if (min1_slice['low'] <= stop_price).any():
stop_candle = min1_slice[min1_slice['low'] <= stop_price].iloc[0]
# If the candle opened below stop price, use open price; otherwise use stop price
if stop_candle['open'] < stop_price:
sell_price = stop_candle['open']
else:
sell_price = stop_price
return True, sell_price
return False, None
def default_entry_strategy(backtester: Backtest, df_index):
"""Entry strategy
Entry when meta trend is 1
"""
return backtester.strategies["meta_trend"][df_index - 1] != 1 and backtester.strategies["meta_trend"][df_index] == 1
def stop_loss_strategy(backtester: Backtest):
"""Stop loss strategy
Calculate stop loss price
Find the first min1 candle that is below the stop loss price
If the stop loss price is below the open price, use the open price as the stop loss price
"""
stop_price = backtester.entry_price * (1 - backtester.strategies["stop_loss_pct"])
min1_index = backtester.min1_df.index
@ -78,24 +230,96 @@ def default_exit_strategy(backtester: Backtest, df_index):
return None, None
def process_timeframe_data(min1_df, df, stop_loss_pcts, rule_name, initial_usd, debug=False):
"""Process the entire timeframe with all stop loss values (no monthly split)"""
df = df.copy().reset_index(drop=True)
def strategy_manager_init(backtester: Backtest):
"""Strategy Manager initialization function"""
# This will be called by Backtest.__init__, but actual initialization
# happens in strategy_manager.initialize()
pass
def strategy_manager_entry(backtester: Backtest, df_index: int):
"""Strategy Manager entry function"""
return backtester.strategy_manager.get_entry_signal(backtester, df_index)
def strategy_manager_exit(backtester: Backtest, df_index: int):
"""Strategy Manager exit function"""
return backtester.strategy_manager.get_exit_signal(backtester, df_index)
def process_timeframe_data(min1_df, df, stop_loss_pcts, rule_name, initial_usd, strategy_config=None, debug=False):
"""Process the entire timeframe with all stop loss values using Strategy Manager"""
results_rows = []
trade_rows = []
min1_df['timestamp'] = pd.to_datetime(min1_df.index) # need ?
for stop_loss_pct in stop_loss_pcts:
backtester = Backtest(initial_usd, df, min1_df, default_init_strategy)
backtester.strategies["stop_loss_pct"] = stop_loss_pct
# Create and initialize strategy manager
if strategy_config:
# Use provided strategy configuration
strategy_manager = create_strategy_manager(strategy_config)
else:
# Default to single default strategy for backward compatibility
default_strategy_config = {
"strategies": [
{
"name": "default",
"weight": 1.0,
"params": {"stop_loss_pct": stop_loss_pct}
}
],
"combination_rules": {
"entry": "any",
"exit": "any",
"min_confidence": 0.5
}
}
strategy_manager = create_strategy_manager(default_strategy_config)
# Inject stop_loss_pct into all strategy params if not present
for strategy in strategy_manager.strategies:
if "stop_loss_pct" not in strategy.params:
strategy.params["stop_loss_pct"] = stop_loss_pct
# Get the primary timeframe from the first strategy for backtester setup
primary_strategy = strategy_manager.strategies[0]
primary_timeframe = primary_strategy.get_timeframes()[0]
# For BBRS strategy, it works with 1-minute data directly and handles internal resampling
# For other strategies, use their preferred timeframe
if primary_strategy.name == "bbrs":
# BBRS strategy processes 1-minute data and outputs signals on its internal timeframes
# Use 1-minute data for backtester working dataframe
working_df = min1_df.copy()
else:
# Other strategies specify their preferred timeframe
# Create backtester working data from the primary strategy's primary timeframe
temp_backtester = type('temp', (), {})()
temp_backtester.original_df = min1_df
# Let the primary strategy resample the data to get the working dataframe
primary_strategy._resample_data(min1_df)
working_df = primary_strategy.get_primary_timeframe_data()
# Prepare working dataframe for backtester (ensure timestamp column)
working_df_for_backtest = working_df.copy().reset_index()
if 'index' in working_df_for_backtest.columns:
working_df_for_backtest = working_df_for_backtest.rename(columns={'index': 'timestamp'})
# Initialize backtest with strategy manager initialization
backtester = Backtest(initial_usd, working_df_for_backtest, working_df_for_backtest, strategy_manager_init)
# Store original min1_df for strategy processing
backtester.original_df = min1_df
# Attach strategy manager to backtester and initialize
backtester.strategy_manager = strategy_manager
strategy_manager.initialize(backtester)
# Run backtest with strategy manager functions
results = backtester.run(
default_entry_strategy,
default_exit_strategy,
strategy_manager_entry,
strategy_manager_exit,
debug
)
n_trades = results["n_trades"]
trades = results.get('trades', [])
wins = [1 for t in trades if t['exit'] is not None and t['exit'] > t['entry']]
@ -126,8 +350,9 @@ def process_timeframe_data(min1_df, df, stop_loss_pcts, rule_name, initial_usd,
total_fees_usd = sum(trade.get('fee_usd', 0.0) for trade in trades)
# Update row to include timeframe information
row = {
"timeframe": rule_name,
"timeframe": f"{rule_name}({primary_timeframe})", # Show actual timeframe used
"stop_loss_pct": stop_loss_pct,
"n_trades": n_trades,
"n_stop_loss": sum(1 for trade in trades if 'type' in trade and trade['type'] == 'STOP_LOSS'),
@ -145,7 +370,7 @@ def process_timeframe_data(min1_df, df, stop_loss_pcts, rule_name, initial_usd,
for trade in trades:
trade_rows.append({
"timeframe": rule_name,
"timeframe": f"{rule_name}({primary_timeframe})",
"stop_loss_pct": stop_loss_pct,
"entry_time": trade.get("entry_time"),
"exit_time": trade.get("exit_time"),
@ -155,34 +380,48 @@ def process_timeframe_data(min1_df, df, stop_loss_pcts, rule_name, initial_usd,
"type": trade.get("type"),
"fee_usd": trade.get("fee_usd"),
})
logging.info(f"Timeframe: {rule_name}, Stop Loss: {stop_loss_pct}, Trades: {n_trades}")
# Log strategy summary
strategy_summary = strategy_manager.get_strategy_summary()
logging.info(f"Timeframe: {rule_name}({primary_timeframe}), Stop Loss: {stop_loss_pct}, "
f"Trades: {n_trades}, Strategies: {[s['name'] for s in strategy_summary['strategies']]}")
if debug:
# Plot after each backtest run
try:
meta_trend = backtester.strategies["meta_trend"]
BacktestCharts.plot(df, meta_trend)
# Check if any strategy has processed_data for universal plotting
processed_data = None
for strategy in strategy_manager.strategies:
if hasattr(backtester, 'processed_data') and backtester.processed_data is not None:
processed_data = backtester.processed_data
break
if processed_data is not None and not processed_data.empty:
# Format strategy data with actual executed trades for universal plotting
formatted_data = BacktestCharts.format_strategy_data_with_trades(processed_data, results)
# Plot using universal function
BacktestCharts.plot_data(formatted_data)
else:
# Fallback to meta_trend plot if available
if "meta_trend" in backtester.strategies:
meta_trend = backtester.strategies["meta_trend"]
# Use the working dataframe for plotting
BacktestCharts.plot(working_df, meta_trend)
else:
print("No plotting data available")
except Exception as e:
print(f"Plotting failed: {e}")
return results_rows, trade_rows
def process(timeframe_info, debug=False):
"""Process a single (timeframe, stop_loss_pct) combination (no monthly split)"""
rule, data_1min, stop_loss_pct, initial_usd = timeframe_info
"""Process a single (timeframe, stop_loss_pct) combination with strategy config"""
rule, data_1min, stop_loss_pct, initial_usd, strategy_config = timeframe_info
if rule == "1min":
df = data_1min.copy()
else:
df = data_1min.resample(rule).agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
'volume': 'sum'
}).dropna()
df = df.reset_index()
results_rows, all_trade_rows = process_timeframe_data(data_1min, df, [stop_loss_pct], rule, initial_usd, debug=debug)
# Pass the original 1-minute data - strategies will handle their own timeframe resampling
results_rows, all_trade_rows = process_timeframe_data(
data_1min, data_1min, [stop_loss_pct], rule, initial_usd, strategy_config, debug=debug
)
return results_rows, all_trade_rows
def aggregate_results(all_rows):
@ -241,11 +480,23 @@ if __name__ == "__main__":
# Default values (from config.json)
default_config = {
"start_date": "2025-05-01",
"start_date": "2025-03-01",
"stop_date": datetime.datetime.today().strftime('%Y-%m-%d'),
"initial_usd": 10000,
"timeframes": ["15min"],
"stop_loss_pcts": [0.03],
"strategies": [
{
"name": "default",
"weight": 1.0,
"params": {}
}
],
"combination_rules": {
"entry": "any",
"exit": "any",
"min_confidence": 0.5
}
}
if args.config:
@ -272,6 +523,8 @@ if __name__ == "__main__":
'initial_usd': initial_usd,
'timeframes': timeframes,
'stop_loss_pcts': stop_loss_pcts,
'strategies': default_config['strategies'],
'combination_rules': default_config['combination_rules']
}
else:
config = default_config
@ -281,6 +534,12 @@ if __name__ == "__main__":
initial_usd = config['initial_usd']
timeframes = config['timeframes']
stop_loss_pcts = config['stop_loss_pcts']
# Extract strategy configuration
strategy_config = {
"strategies": config.get('strategies', default_config['strategies']),
"combination_rules": config.get('combination_rules', default_config['combination_rules'])
}
timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M")
@ -298,8 +557,9 @@ if __name__ == "__main__":
f"Initial USD\t{initial_usd}"
]
# Create tasks for each (timeframe, stop_loss_pct) combination
tasks = [
(name, data_1min, stop_loss_pct, initial_usd)
(name, data_1min, stop_loss_pct, initial_usd, strategy_config)
for name in timeframes
for stop_loss_pct in stop_loss_pcts
]