- 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.
344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""
|
|
Bollinger Bands + RSI Strategy (BBRS)
|
|
|
|
This module implements a sophisticated trading strategy that combines Bollinger Bands
|
|
and RSI indicators with market regime detection. The strategy adapts its parameters
|
|
based on whether the market is trending or moving sideways.
|
|
|
|
Key Features:
|
|
- Dynamic parameter adjustment based on market regime
|
|
- 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, List
|
|
|
|
from .base import StrategyBase, StrategySignal
|
|
|
|
|
|
class BBRSStrategy(StrategyBase):
|
|
"""
|
|
Bollinger Bands + RSI Strategy implementation.
|
|
|
|
This strategy uses Bollinger Bands and RSI indicators with market regime detection
|
|
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)
|
|
rsi_period (int): RSI calculation period (default: 14)
|
|
trending_rsi_threshold (list): RSI thresholds for trending market [low, high]
|
|
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 ("MarketRegimeStrategy" or "CryptoTradingStrategy")
|
|
SqueezeStrategy (bool): Enable squeeze strategy
|
|
stop_loss_pct (float): Stop loss percentage (default: 0.05)
|
|
|
|
Example:
|
|
params = {
|
|
"bb_width": 0.05,
|
|
"bb_period": 20,
|
|
"rsi_period": 14,
|
|
"strategy_name": "MarketRegimeStrategy",
|
|
"SqueezeStrategy": true
|
|
}
|
|
strategy = BBRSStrategy(weight=1.0, params=params)
|
|
"""
|
|
|
|
def __init__(self, weight: float = 1.0, params: Optional[dict] = None):
|
|
"""
|
|
Initialize the BBRS strategy.
|
|
|
|
Args:
|
|
weight: Strategy weight for combination (default: 1.0)
|
|
params: Strategy parameters for Bollinger Bands and RSI
|
|
"""
|
|
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. 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
|
|
"""
|
|
# Resample to get 1-minute data (which should be the original data)
|
|
self._resample_data(backtester.original_df)
|
|
|
|
# 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
|
|
|
|
def _run_strategy_processing(self, backtester) -> None:
|
|
"""
|
|
Run the actual BBRS strategy processing.
|
|
|
|
Uses the Strategy class from cycles.Analysis.strategies to process
|
|
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 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),
|
|
"bb_period": self.params.get("bb_period", 20),
|
|
"rsi_period": self.params.get("rsi_period", 14),
|
|
"trending": {
|
|
"rsi_threshold": self.params.get("trending_rsi_threshold", [30, 70]),
|
|
"bb_std_dev_multiplier": self.params.get("trending_bb_multiplier", 2.5),
|
|
},
|
|
"sideways": {
|
|
"rsi_threshold": self.params.get("sideways_rsi_threshold", [40, 60]),
|
|
"bb_std_dev_multiplier": self.params.get("sideways_bb_multiplier", 1.8),
|
|
},
|
|
"strategy_name": self.params.get("strategy_name", "MarketRegimeStrategy"),
|
|
"SqueezeStrategy": self.params.get("SqueezeStrategy", True)
|
|
}
|
|
|
|
# Run strategy processing on 1-minute data - Strategy class handles internal resampling
|
|
strategy = Strategy(config=config_strategy, logging=logging)
|
|
processed_data = strategy.run(strategy_data, config_strategy["strategy_name"])
|
|
|
|
# Store processed data for plotting and analysis
|
|
backtester.processed_data = processed_data
|
|
|
|
if processed_data.empty:
|
|
# If strategy processing failed, keep empty signals
|
|
return
|
|
|
|
# Extract 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)
|
|
|
|
# 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")
|
|
|
|
# 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)
|
|
|
|
# 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 on
|
|
the primary timeframe.
|
|
|
|
Args:
|
|
backtester: Backtest instance with current state
|
|
df_index: Current index in the primary timeframe dataframe
|
|
|
|
Returns:
|
|
StrategySignal: Entry signal if buy condition met, hold otherwise
|
|
"""
|
|
if not self.initialized:
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
if df_index >= len(backtester.strategies["buy_signals"]):
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
if backtester.strategies["buy_signals"].iloc[df_index]:
|
|
# High confidence for BBRS buy signals
|
|
confidence = self._calculate_signal_confidence(backtester, df_index, "entry")
|
|
return StrategySignal("ENTRY", confidence=confidence)
|
|
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
def get_exit_signal(self, backtester, df_index: int) -> StrategySignal:
|
|
"""
|
|
Generate exit signal based on BBRS sell signals or stop loss.
|
|
|
|
Exit occurs when:
|
|
1. BBRS strategy generates a sell signal
|
|
2. Stop loss is triggered based on price movement
|
|
|
|
Args:
|
|
backtester: Backtest instance with current state
|
|
df_index: Current index in the primary timeframe dataframe
|
|
|
|
Returns:
|
|
StrategySignal: Exit signal with type and price, or hold signal
|
|
"""
|
|
if not self.initialized:
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
if df_index >= len(backtester.strategies["sell_signals"]):
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
# Check for sell signal
|
|
if backtester.strategies["sell_signals"].iloc[df_index]:
|
|
confidence = self._calculate_signal_confidence(backtester, df_index, "exit")
|
|
return StrategySignal("EXIT", confidence=confidence,
|
|
metadata={"type": "SELL_SIGNAL"})
|
|
|
|
# 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,
|
|
metadata={"type": "STOP_LOSS"})
|
|
|
|
return StrategySignal("HOLD", confidence=0.0)
|
|
|
|
def get_confidence(self, backtester, df_index: int) -> float:
|
|
"""
|
|
Get strategy confidence based on signal strength and market conditions.
|
|
|
|
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 primary timeframe dataframe
|
|
|
|
Returns:
|
|
float: Confidence level (0.0 to 1.0)
|
|
"""
|
|
if not self.initialized:
|
|
return 0.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 1-minute data for precision.
|
|
|
|
Uses 1-minute data regardless of primary timeframe to ensure
|
|
accurate stop loss execution.
|
|
|
|
Args:
|
|
backtester: Backtest instance with current trade state
|
|
|
|
Returns:
|
|
Tuple[bool, Optional[float]]: (stop_loss_triggered, sell_price)
|
|
"""
|
|
# Calculate stop loss price
|
|
stop_price = backtester.entry_price * (1 - backtester.strategies["stop_loss_pct"])
|
|
|
|
# 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]
|
|
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:
|
|
return False, None
|
|
|
|
backtester.current_min1_end_idx = end_candidates[-1]
|
|
|
|
# Check if any candle in the range triggered stop loss
|
|
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
|
|
stop_candle = min1_slice[min1_slice['low'] <= stop_price].iloc[0]
|
|
|
|
# Use open price if it gapped below stop, 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 |