- Introduced `BBRSIncrementalState` for real-time processing of the Bollinger Bands + RSI strategy, allowing minute-level data input and internal timeframe aggregation. - Added `TimeframeAggregator` class to handle real-time data aggregation to higher timeframes (15min, 1h, etc.). - Updated `README_BBRS.md` to document the new incremental strategy, including key features and usage examples. - Created comprehensive tests to validate the incremental strategy against the original implementation, ensuring signal accuracy and performance consistency. - Enhanced error handling and logging for better monitoring during real-time processing. - Updated `TODO.md` to reflect the completion of the incremental BBRS strategy implementation.
532 lines
20 KiB
Python
532 lines
20 KiB
Python
"""
|
||
Incremental BBRS Strategy
|
||
|
||
This module implements an incremental version of the Bollinger Bands + RSI Strategy (BBRS)
|
||
for real-time data processing. It maintains constant memory usage and provides
|
||
identical results to the batch implementation after the warm-up period.
|
||
|
||
Key Features:
|
||
- Accepts minute-level data input for real-time compatibility
|
||
- Internal timeframe aggregation (1min, 5min, 15min, 1h, etc.)
|
||
- Incremental Bollinger Bands calculation
|
||
- Incremental RSI calculation with Wilder's smoothing
|
||
- Market regime detection (trending vs sideways)
|
||
- Real-time signal generation
|
||
- Constant memory usage
|
||
"""
|
||
|
||
from typing import Dict, Optional, Union, Tuple
|
||
import numpy as np
|
||
import pandas as pd
|
||
from datetime import datetime, timedelta
|
||
from .indicators.bollinger_bands import BollingerBandsState
|
||
from .indicators.rsi import RSIState
|
||
|
||
|
||
class TimeframeAggregator:
|
||
"""
|
||
Handles real-time aggregation of minute data to higher timeframes.
|
||
|
||
This class accumulates minute-level OHLCV data and produces complete
|
||
bars when a timeframe period is completed.
|
||
"""
|
||
|
||
def __init__(self, timeframe_minutes: int = 15):
|
||
"""
|
||
Initialize timeframe aggregator.
|
||
|
||
Args:
|
||
timeframe_minutes: Target timeframe in minutes (e.g., 60 for 1h, 15 for 15min)
|
||
"""
|
||
self.timeframe_minutes = timeframe_minutes
|
||
self.current_bar = None
|
||
self.current_bar_start = None
|
||
self.last_completed_bar = None
|
||
|
||
def update(self, timestamp: pd.Timestamp, ohlcv_data: Dict[str, float]) -> Optional[Dict[str, float]]:
|
||
"""
|
||
Update with new minute data and return completed bar if timeframe is complete.
|
||
|
||
Args:
|
||
timestamp: Timestamp of the data
|
||
ohlcv_data: OHLCV data dictionary
|
||
|
||
Returns:
|
||
Completed OHLCV bar if timeframe period ended, None otherwise
|
||
"""
|
||
# Calculate which timeframe bar this timestamp belongs to
|
||
bar_start = self._get_bar_start_time(timestamp)
|
||
|
||
# Check if we're starting a new bar
|
||
if self.current_bar_start != bar_start:
|
||
# Save the completed bar (if any)
|
||
completed_bar = self.current_bar.copy() if self.current_bar is not None else None
|
||
|
||
# Start new bar
|
||
self.current_bar_start = bar_start
|
||
self.current_bar = {
|
||
'timestamp': bar_start,
|
||
'open': ohlcv_data['close'], # Use current close as open for new bar
|
||
'high': ohlcv_data['close'],
|
||
'low': ohlcv_data['close'],
|
||
'close': ohlcv_data['close'],
|
||
'volume': ohlcv_data['volume']
|
||
}
|
||
|
||
# Return the completed bar (if any)
|
||
if completed_bar is not None:
|
||
self.last_completed_bar = completed_bar
|
||
return completed_bar
|
||
else:
|
||
# Update current bar with new data
|
||
if self.current_bar is not None:
|
||
self.current_bar['high'] = max(self.current_bar['high'], ohlcv_data['high'])
|
||
self.current_bar['low'] = min(self.current_bar['low'], ohlcv_data['low'])
|
||
self.current_bar['close'] = ohlcv_data['close']
|
||
self.current_bar['volume'] += ohlcv_data['volume']
|
||
|
||
return None # No completed bar yet
|
||
|
||
def _get_bar_start_time(self, timestamp: pd.Timestamp) -> pd.Timestamp:
|
||
"""Calculate the start time of the timeframe bar for given timestamp."""
|
||
# Round down to the nearest timeframe boundary
|
||
minutes_since_midnight = timestamp.hour * 60 + timestamp.minute
|
||
bar_minutes = (minutes_since_midnight // self.timeframe_minutes) * self.timeframe_minutes
|
||
|
||
return timestamp.replace(
|
||
hour=bar_minutes // 60,
|
||
minute=bar_minutes % 60,
|
||
second=0,
|
||
microsecond=0
|
||
)
|
||
|
||
def get_current_bar(self) -> Optional[Dict[str, float]]:
|
||
"""Get the current incomplete bar (for debugging)."""
|
||
return self.current_bar.copy() if self.current_bar is not None else None
|
||
|
||
def reset(self):
|
||
"""Reset aggregator state."""
|
||
self.current_bar = None
|
||
self.current_bar_start = None
|
||
self.last_completed_bar = None
|
||
|
||
|
||
class BBRSIncrementalState:
|
||
"""
|
||
Incremental BBRS strategy state for real-time processing.
|
||
|
||
This class maintains all the state needed for the BBRS strategy and can
|
||
process new minute-level price data incrementally, internally aggregating
|
||
to the configured timeframe before running indicators.
|
||
|
||
Attributes:
|
||
timeframe_minutes (int): Strategy timeframe in minutes (default: 60 for 1h)
|
||
bb_period (int): Bollinger Bands period
|
||
rsi_period (int): RSI period
|
||
bb_width_threshold (float): BB width threshold for market regime detection
|
||
trending_bb_multiplier (float): BB multiplier for trending markets
|
||
sideways_bb_multiplier (float): BB multiplier for sideways markets
|
||
trending_rsi_thresholds (tuple): RSI thresholds for trending markets (low, high)
|
||
sideways_rsi_thresholds (tuple): RSI thresholds for sideways markets (low, high)
|
||
squeeze_strategy (bool): Enable squeeze strategy
|
||
|
||
Example:
|
||
# Initialize strategy for 1-hour timeframe
|
||
config = {
|
||
"timeframe_minutes": 60, # 1 hour bars
|
||
"bb_period": 20,
|
||
"rsi_period": 14,
|
||
"bb_width": 0.05,
|
||
"trending": {
|
||
"bb_std_dev_multiplier": 2.5,
|
||
"rsi_threshold": [30, 70]
|
||
},
|
||
"sideways": {
|
||
"bb_std_dev_multiplier": 1.8,
|
||
"rsi_threshold": [40, 60]
|
||
},
|
||
"SqueezeStrategy": True
|
||
}
|
||
|
||
strategy = BBRSIncrementalState(config)
|
||
|
||
# Process minute-level data in real-time
|
||
for minute_data in live_data_stream:
|
||
result = strategy.update_minute_data(minute_data['timestamp'], minute_data)
|
||
if result is not None: # New timeframe bar completed
|
||
if result['buy_signal']:
|
||
print("Buy signal generated!")
|
||
"""
|
||
|
||
def __init__(self, config: Dict):
|
||
"""
|
||
Initialize incremental BBRS strategy.
|
||
|
||
Args:
|
||
config: Strategy configuration dictionary
|
||
"""
|
||
# Store configuration
|
||
self.timeframe_minutes = config.get("timeframe_minutes", 60) # Default to 1 hour
|
||
self.bb_period = config.get("bb_period", 20)
|
||
self.rsi_period = config.get("rsi_period", 14)
|
||
self.bb_width_threshold = config.get("bb_width", 0.05)
|
||
|
||
# Market regime specific parameters
|
||
trending_config = config.get("trending", {})
|
||
sideways_config = config.get("sideways", {})
|
||
|
||
self.trending_bb_multiplier = trending_config.get("bb_std_dev_multiplier", 2.5)
|
||
self.sideways_bb_multiplier = sideways_config.get("bb_std_dev_multiplier", 1.8)
|
||
self.trending_rsi_thresholds = tuple(trending_config.get("rsi_threshold", [30, 70]))
|
||
self.sideways_rsi_thresholds = tuple(sideways_config.get("rsi_threshold", [40, 60]))
|
||
|
||
self.squeeze_strategy = config.get("SqueezeStrategy", True)
|
||
|
||
# Initialize timeframe aggregator
|
||
self.aggregator = TimeframeAggregator(self.timeframe_minutes)
|
||
|
||
# Initialize indicators with different multipliers for regime detection
|
||
self.bb_trending = BollingerBandsState(self.bb_period, self.trending_bb_multiplier)
|
||
self.bb_sideways = BollingerBandsState(self.bb_period, self.sideways_bb_multiplier)
|
||
self.bb_reference = BollingerBandsState(self.bb_period, 2.0) # For regime detection
|
||
self.rsi = RSIState(self.rsi_period)
|
||
|
||
# State tracking
|
||
self.bars_processed = 0
|
||
self.current_price = None
|
||
self.current_volume = None
|
||
self.volume_ma = None
|
||
self.volume_sum = 0.0
|
||
self.volume_history = [] # For volume MA calculation
|
||
|
||
# Signal state
|
||
self.last_buy_signal = False
|
||
self.last_sell_signal = False
|
||
self.last_result = None
|
||
|
||
def update_minute_data(self, timestamp: pd.Timestamp, ohlcv_data: Dict[str, float]) -> Optional[Dict[str, Union[float, bool]]]:
|
||
"""
|
||
Update strategy with new minute-level OHLCV data.
|
||
|
||
This method accepts minute-level data and internally aggregates to the
|
||
configured timeframe. It only processes indicators and generates signals
|
||
when a complete timeframe bar is formed.
|
||
|
||
Args:
|
||
timestamp: Timestamp of the minute data
|
||
ohlcv_data: Dictionary with 'open', 'high', 'low', 'close', 'volume'
|
||
|
||
Returns:
|
||
Strategy result dictionary if a timeframe bar completed, None otherwise
|
||
"""
|
||
# Validate input
|
||
required_keys = ['open', 'high', 'low', 'close', 'volume']
|
||
for key in required_keys:
|
||
if key not in ohlcv_data:
|
||
raise ValueError(f"Missing required key: {key}")
|
||
|
||
# Update timeframe aggregator
|
||
completed_bar = self.aggregator.update(timestamp, ohlcv_data)
|
||
|
||
if completed_bar is not None:
|
||
# Process the completed timeframe bar
|
||
return self._process_timeframe_bar(completed_bar)
|
||
|
||
return None # No completed bar yet
|
||
|
||
def update(self, ohlcv_data: Dict[str, float]) -> Dict[str, Union[float, bool]]:
|
||
"""
|
||
Update strategy with pre-aggregated timeframe data (for testing/compatibility).
|
||
|
||
This method is for backward compatibility and testing with pre-aggregated data.
|
||
For real-time use, prefer update_minute_data().
|
||
|
||
Args:
|
||
ohlcv_data: Dictionary with 'open', 'high', 'low', 'close', 'volume'
|
||
|
||
Returns:
|
||
Strategy result dictionary
|
||
"""
|
||
# Create a fake timestamp for compatibility
|
||
fake_timestamp = pd.Timestamp.now()
|
||
|
||
# Process directly as a completed bar
|
||
completed_bar = {
|
||
'timestamp': fake_timestamp,
|
||
'open': ohlcv_data['open'],
|
||
'high': ohlcv_data['high'],
|
||
'low': ohlcv_data['low'],
|
||
'close': ohlcv_data['close'],
|
||
'volume': ohlcv_data['volume']
|
||
}
|
||
|
||
return self._process_timeframe_bar(completed_bar)
|
||
|
||
def _process_timeframe_bar(self, bar_data: Dict[str, float]) -> Dict[str, Union[float, bool]]:
|
||
"""
|
||
Process a completed timeframe bar and generate signals.
|
||
|
||
Args:
|
||
bar_data: Completed timeframe bar data
|
||
|
||
Returns:
|
||
Strategy result dictionary
|
||
"""
|
||
close_price = float(bar_data['close'])
|
||
volume = float(bar_data['volume'])
|
||
|
||
# Update indicators
|
||
bb_trending_result = self.bb_trending.update(close_price)
|
||
bb_sideways_result = self.bb_sideways.update(close_price)
|
||
bb_reference_result = self.bb_reference.update(close_price)
|
||
rsi_value = self.rsi.update(close_price)
|
||
|
||
# Update volume tracking
|
||
self._update_volume_tracking(volume)
|
||
|
||
# Determine market regime
|
||
market_regime = self._determine_market_regime(bb_reference_result)
|
||
|
||
# Select appropriate BB values based on regime
|
||
if market_regime == "sideways":
|
||
bb_result = bb_sideways_result
|
||
rsi_thresholds = self.sideways_rsi_thresholds
|
||
else: # trending
|
||
bb_result = bb_trending_result
|
||
rsi_thresholds = self.trending_rsi_thresholds
|
||
|
||
# Generate signals
|
||
buy_signal, sell_signal = self._generate_signals(
|
||
close_price, volume, bb_result, rsi_value,
|
||
market_regime, rsi_thresholds
|
||
)
|
||
|
||
# Update state
|
||
self.current_price = close_price
|
||
self.current_volume = volume
|
||
self.bars_processed += 1
|
||
self.last_buy_signal = buy_signal
|
||
self.last_sell_signal = sell_signal
|
||
|
||
# Create comprehensive result
|
||
result = {
|
||
# Timeframe info
|
||
'timestamp': bar_data['timestamp'],
|
||
'timeframe_minutes': self.timeframe_minutes,
|
||
|
||
# Price data
|
||
'open': bar_data['open'],
|
||
'high': bar_data['high'],
|
||
'low': bar_data['low'],
|
||
'close': close_price,
|
||
'volume': volume,
|
||
|
||
# Bollinger Bands (regime-specific)
|
||
'upper_band': bb_result['upper_band'],
|
||
'middle_band': bb_result['middle_band'],
|
||
'lower_band': bb_result['lower_band'],
|
||
'bb_width': bb_result['bandwidth'],
|
||
|
||
# RSI
|
||
'rsi': rsi_value,
|
||
|
||
# Market regime
|
||
'market_regime': market_regime,
|
||
'bb_width_reference': bb_reference_result['bandwidth'],
|
||
|
||
# Volume analysis
|
||
'volume_ma': self.volume_ma,
|
||
'volume_spike': self._check_volume_spike(volume),
|
||
|
||
# Signals
|
||
'buy_signal': buy_signal,
|
||
'sell_signal': sell_signal,
|
||
|
||
# Strategy metadata
|
||
'is_warmed_up': self.is_warmed_up(),
|
||
'bars_processed': self.bars_processed,
|
||
'rsi_thresholds': rsi_thresholds,
|
||
'bb_multiplier': bb_result.get('std_dev', self.trending_bb_multiplier)
|
||
}
|
||
|
||
self.last_result = result
|
||
return result
|
||
|
||
def _update_volume_tracking(self, volume: float) -> None:
|
||
"""Update volume moving average tracking."""
|
||
# Simple moving average for volume (20 periods)
|
||
volume_period = 20
|
||
|
||
if len(self.volume_history) >= volume_period:
|
||
# Remove oldest volume
|
||
self.volume_sum -= self.volume_history[0]
|
||
self.volume_history.pop(0)
|
||
|
||
# Add new volume
|
||
self.volume_history.append(volume)
|
||
self.volume_sum += volume
|
||
|
||
# Calculate moving average
|
||
if len(self.volume_history) > 0:
|
||
self.volume_ma = self.volume_sum / len(self.volume_history)
|
||
else:
|
||
self.volume_ma = volume
|
||
|
||
def _determine_market_regime(self, bb_reference: Dict[str, float]) -> str:
|
||
"""
|
||
Determine market regime based on Bollinger Band width.
|
||
|
||
Args:
|
||
bb_reference: Reference BB result for regime detection
|
||
|
||
Returns:
|
||
"sideways" or "trending"
|
||
"""
|
||
if not self.bb_reference.is_warmed_up():
|
||
return "trending" # Default to trending during warm-up
|
||
|
||
bb_width = bb_reference['bandwidth']
|
||
|
||
if bb_width < self.bb_width_threshold:
|
||
return "sideways"
|
||
else:
|
||
return "trending"
|
||
|
||
def _check_volume_spike(self, current_volume: float) -> bool:
|
||
"""Check if current volume represents a spike (≥1.5× average)."""
|
||
if self.volume_ma is None or self.volume_ma == 0:
|
||
return False
|
||
|
||
return current_volume >= 1.5 * self.volume_ma
|
||
|
||
def _generate_signals(self, price: float, volume: float, bb_result: Dict[str, float],
|
||
rsi_value: float, market_regime: str,
|
||
rsi_thresholds: Tuple[float, float]) -> Tuple[bool, bool]:
|
||
"""
|
||
Generate buy/sell signals based on strategy logic.
|
||
|
||
Args:
|
||
price: Current close price
|
||
volume: Current volume
|
||
bb_result: Bollinger Bands result
|
||
rsi_value: Current RSI value
|
||
market_regime: "sideways" or "trending"
|
||
rsi_thresholds: (low_threshold, high_threshold)
|
||
|
||
Returns:
|
||
(buy_signal, sell_signal)
|
||
"""
|
||
# Don't generate signals during warm-up
|
||
if not self.is_warmed_up():
|
||
return False, False
|
||
|
||
# Don't generate signals if RSI is NaN
|
||
if np.isnan(rsi_value):
|
||
return False, False
|
||
|
||
upper_band = bb_result['upper_band']
|
||
lower_band = bb_result['lower_band']
|
||
rsi_low, rsi_high = rsi_thresholds
|
||
|
||
volume_spike = self._check_volume_spike(volume)
|
||
|
||
buy_signal = False
|
||
sell_signal = False
|
||
|
||
if market_regime == "sideways":
|
||
# Sideways market (Mean Reversion)
|
||
buy_condition = (price <= lower_band) and (rsi_value <= rsi_low)
|
||
sell_condition = (price >= upper_band) and (rsi_value >= rsi_high)
|
||
|
||
if self.squeeze_strategy:
|
||
# Add volume contraction filter for sideways markets
|
||
volume_contraction = volume < 0.7 * (self.volume_ma or volume)
|
||
buy_condition = buy_condition and volume_contraction
|
||
sell_condition = sell_condition and volume_contraction
|
||
|
||
buy_signal = buy_condition
|
||
sell_signal = sell_condition
|
||
|
||
else: # trending
|
||
# Trending market (Breakout Mode)
|
||
buy_condition = (price < lower_band) and (rsi_value < 50) and volume_spike
|
||
sell_condition = (price > upper_band) and (rsi_value > 50) and volume_spike
|
||
|
||
buy_signal = buy_condition
|
||
sell_signal = sell_condition
|
||
|
||
return buy_signal, sell_signal
|
||
|
||
def is_warmed_up(self) -> bool:
|
||
"""
|
||
Check if strategy is warmed up and ready for reliable signals.
|
||
|
||
Returns:
|
||
True if all indicators are warmed up
|
||
"""
|
||
return (self.bb_trending.is_warmed_up() and
|
||
self.bb_sideways.is_warmed_up() and
|
||
self.bb_reference.is_warmed_up() and
|
||
self.rsi.is_warmed_up() and
|
||
len(self.volume_history) >= 20)
|
||
|
||
def get_current_incomplete_bar(self) -> Optional[Dict[str, float]]:
|
||
"""
|
||
Get the current incomplete timeframe bar (for monitoring).
|
||
|
||
Returns:
|
||
Current incomplete bar data or None
|
||
"""
|
||
return self.aggregator.get_current_bar()
|
||
|
||
def reset(self) -> None:
|
||
"""Reset strategy state to initial conditions."""
|
||
self.aggregator.reset()
|
||
self.bb_trending.reset()
|
||
self.bb_sideways.reset()
|
||
self.bb_reference.reset()
|
||
self.rsi.reset()
|
||
|
||
self.bars_processed = 0
|
||
self.current_price = None
|
||
self.current_volume = None
|
||
self.volume_ma = None
|
||
self.volume_sum = 0.0
|
||
self.volume_history.clear()
|
||
|
||
self.last_buy_signal = False
|
||
self.last_sell_signal = False
|
||
self.last_result = None
|
||
|
||
def get_state_summary(self) -> Dict:
|
||
"""Get comprehensive state summary for debugging."""
|
||
return {
|
||
'strategy_type': 'BBRS_Incremental',
|
||
'timeframe_minutes': self.timeframe_minutes,
|
||
'bars_processed': self.bars_processed,
|
||
'is_warmed_up': self.is_warmed_up(),
|
||
'current_price': self.current_price,
|
||
'current_volume': self.current_volume,
|
||
'volume_ma': self.volume_ma,
|
||
'current_incomplete_bar': self.get_current_incomplete_bar(),
|
||
'last_signals': {
|
||
'buy': self.last_buy_signal,
|
||
'sell': self.last_sell_signal
|
||
},
|
||
'indicators': {
|
||
'bb_trending': self.bb_trending.get_state_summary(),
|
||
'bb_sideways': self.bb_sideways.get_state_summary(),
|
||
'bb_reference': self.bb_reference.get_state_summary(),
|
||
'rsi': self.rsi.get_state_summary()
|
||
},
|
||
'config': {
|
||
'bb_period': self.bb_period,
|
||
'rsi_period': self.rsi_period,
|
||
'bb_width_threshold': self.bb_width_threshold,
|
||
'trending_bb_multiplier': self.trending_bb_multiplier,
|
||
'sideways_bb_multiplier': self.sideways_bb_multiplier,
|
||
'trending_rsi_thresholds': self.trending_rsi_thresholds,
|
||
'sideways_rsi_thresholds': self.sideways_rsi_thresholds,
|
||
'squeeze_strategy': self.squeeze_strategy
|
||
}
|
||
} |