From 5d0b707bc6b03a44bbef4dfb473191df21780a83 Mon Sep 17 00:00:00 2001 From: "Vasily.onl" Date: Fri, 23 May 2025 16:56:53 +0800 Subject: [PATCH] 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. --- cycles/charts.py | 384 +++++++++++++++++++++++++- cycles/strategies/base.py | 100 ++++++- cycles/strategies/bbrs_strategy.py | 202 ++++++++------ cycles/strategies/default_strategy.py | 65 ++++- cycles/strategies/manager.py | 19 +- main.py | 320 +++++++++++++++++++-- 6 files changed, 948 insertions(+), 142 deletions(-) diff --git a/cycles/charts.py b/cycles/charts.py index a10a366..d6b33ec 100644 --- a/cycles/charts.py +++ b/cycles/charts.py @@ -68,4 +68,386 @@ class BacktestCharts: plt.tight_layout(h_pad=0.1) plt.show() - \ No newline at end of file + + @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() + + + + \ No newline at end of file diff --git a/cycles/strategies/base.py b/cycles/strategies/base.py index 3aca909..68c0225 100644 --- a/cycles/strategies/base.py +++ b/cycles/strategies/base.py @@ -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})") \ No newline at end of file + f"weight={self.weight}, timeframes={timeframes}, " + f"initialized={self.initialized})") \ No newline at end of file diff --git a/cycles/strategies/bbrs_strategy.py b/cycles/strategies/bbrs_strategy.py index 309f427..330e63a 100644 --- a/cycles/strategies/bbrs_strategy.py +++ b/cycles/strategies/bbrs_strategy.py @@ -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 diff --git a/cycles/strategies/default_strategy.py b/cycles/strategies/default_strategy.py index 65fae0b..78b1c35 100644 --- a/cycles/strategies/default_strategy.py +++ b/cycles/strategies/default_strategy.py @@ -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 diff --git a/cycles/strategies/manager.py b/cycles/strategies/manager.py index 8cc3ed7..6684541 100644 --- a/cycles/strategies/manager.py +++ b/cycles/strategies/manager.py @@ -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: diff --git a/main.py b/main.py index af28a10..6aa47c7 100644 --- a/main.py +++ b/main.py @@ -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 ]