4.0 - 1.0 Implement strategy engine foundation with modular components

- Introduced a new `strategies` package containing the core structure for trading strategies, including `BaseStrategy`, `StrategyFactory`, and various strategy implementations (EMA, RSI, MACD).
- Added utility functions for signal detection and validation in `strategies/utils.py`, enhancing modularity and maintainability.
- Updated `pyproject.toml` to include the new `strategies` package in the build configuration.
- Implemented comprehensive unit tests for the strategy foundation components, ensuring reliability and adherence to project standards.

These changes establish a solid foundation for the strategy engine, aligning with project goals for modularity, performance, and maintainability.
This commit is contained in:
Vasily.onl
2025-06-12 14:41:16 +08:00
parent 571d583a5b
commit fd5a59fc39
14 changed files with 1624 additions and 13 deletions

View File

@@ -0,0 +1,18 @@
"""
Strategy implementations package.
This package contains individual implementations of trading strategies,
each in its own module for better maintainability and separation of concerns.
"""
from .ema_crossover import EMAStrategy
from .rsi import RSIStrategy
from .macd import MACDStrategy
# from .macd import MACDIndicator
__all__ = [
'EMAStrategy',
'RSIStrategy',
'MACDStrategy',
# 'MACDIndicator'
]

View File

@@ -0,0 +1,185 @@
"""
EMA Crossover Strategy Implementation
This module implements an Exponential Moving Average (EMA) Crossover trading strategy.
It extends the BaseStrategy and generates buy/sell signals based on the crossover
of a fast EMA and a slow EMA.
"""
import pandas as pd
from typing import List, Dict, Any
from ..base import BaseStrategy
from ..data_types import StrategyResult, StrategySignal, SignalType
from ..utils import create_indicator_key, detect_crossover_signals_vectorized
class EMAStrategy(BaseStrategy):
"""
EMA Crossover Strategy.
Generates buy/sell signals when a fast EMA crosses above or below a slow EMA.
"""
def __init__(self, logger=None):
super().__init__(logger)
self.strategy_name = "ema_crossover"
def get_required_indicators(self) -> List[Dict[str, Any]]:
"""
Defines the indicators required by the EMA Crossover strategy.
It needs two EMA indicators: a fast one and a slow one.
"""
# Default periods for EMA crossover, can be overridden by strategy config
return [
{'type': 'ema', 'period': 12, 'price_column': 'close'},
{'type': 'ema', 'period': 26, 'price_column': 'close'}
]
def calculate(self, df: pd.DataFrame, indicators_data: Dict[str, pd.DataFrame], **kwargs) -> List[StrategyResult]:
"""
Calculate EMA Crossover strategy signals.
Args:
df: DataFrame with OHLCV data.
indicators_data: Dictionary of pre-calculated indicator DataFrames.
Expected keys: 'ema_period_12', 'ema_period_26'.
**kwargs: Additional strategy parameters (e.g., fast_period, slow_period, price_column).
Returns:
List of StrategyResult objects, each containing generated signals.
"""
# Extract EMA periods from kwargs or use defaults
fast_period = kwargs.get('fast_period', 12)
slow_period = kwargs.get('slow_period', 26)
price_column = kwargs.get('price_column', 'close')
# Generate indicator keys using shared utility function
fast_ema_key = create_indicator_key({'type': 'ema', 'period': fast_period})
slow_ema_key = create_indicator_key({'type': 'ema', 'period': slow_period})
# Validate that the main DataFrame has enough data for strategy calculation (not just indicators)
if not self.validate_dataframe(df, max(fast_period, slow_period)):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Insufficient main DataFrame for calculation.")
return []
# Validate that the required indicators are present and have sufficient data
required_indicators = [
{'type': 'ema', 'period': fast_period},
{'type': 'ema', 'period': slow_period}
]
if not self.validate_indicators_data(indicators_data, required_indicators):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Missing or insufficient indicator data.")
return []
fast_ema_df = indicators_data.get(fast_ema_key)
slow_ema_df = indicators_data.get(slow_ema_key)
if fast_ema_df is None or slow_ema_df is None or fast_ema_df.empty or slow_ema_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: EMA indicator DataFrames are not found or empty.")
return []
# Merge all necessary data into a single DataFrame for easier processing
# Ensure alignment by index (timestamp)
merged_df = pd.merge(df[[price_column, 'symbol', 'timeframe']],
fast_ema_df[['ema']],
left_index=True, right_index=True, how='inner',
suffixes= ('', '_fast'))
merged_df = pd.merge(merged_df,
slow_ema_df[['ema']],
left_index=True, right_index=True, how='inner',
suffixes= ('', '_slow'))
# Rename columns to their logical names after merge
merged_df.rename(columns={'ema': 'ema_fast', 'ema_slow': 'ema_slow'}, inplace=True)
if merged_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: Merged DataFrame is empty after indicator alignment. Check data ranges.")
return []
# Use vectorized signal detection for better performance
bullish_crossover, bearish_crossover = detect_crossover_signals_vectorized(
merged_df, 'ema_fast', 'ema_slow'
)
results: List[StrategyResult] = []
strategy_metadata = {
'fast_period': fast_period,
'slow_period': slow_period
}
# Process bullish crossover signals
bullish_indices = merged_df[bullish_crossover].index
for timestamp in bullish_indices:
row = merged_df.loc[timestamp]
# Skip if any EMA values are NaN
if pd.isna(row['ema_fast']) or pd.isna(row['ema_slow']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.BUY,
price=float(row[price_column]),
confidence=0.8,
metadata={'crossover_type': 'bullish', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={
'ema_fast': float(row['ema_fast']),
'ema_slow': float(row['ema_slow'])
},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: BUY signal at {timestamp} for {row['symbol']}")
# Process bearish crossover signals
bearish_indices = merged_df[bearish_crossover].index
for timestamp in bearish_indices:
row = merged_df.loc[timestamp]
# Skip if any EMA values are NaN
if pd.isna(row['ema_fast']) or pd.isna(row['ema_slow']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.SELL,
price=float(row[price_column]),
confidence=0.8,
metadata={'crossover_type': 'bearish', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={
'ema_fast': float(row['ema_fast']),
'ema_slow': float(row['ema_slow'])
},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: SELL signal at {timestamp} for {row['symbol']}")
return results

View File

@@ -0,0 +1,180 @@
"""
MACD Strategy Implementation
This module implements a Moving Average Convergence Divergence (MACD) trading strategy.
It extends the BaseStrategy and generates buy/sell signals based on the crossover
of the MACD line and its signal line.
"""
import pandas as pd
from typing import List, Dict, Any
from ..base import BaseStrategy
from ..data_types import StrategyResult, StrategySignal, SignalType
from ..utils import create_indicator_key, detect_crossover_signals_vectorized
class MACDStrategy(BaseStrategy):
"""
MACD Strategy.
Generates buy/sell signals when the MACD line crosses above or below its signal line.
"""
def __init__(self, logger=None):
super().__init__(logger)
self.strategy_name = "macd"
def get_required_indicators(self) -> List[Dict[str, Any]]:
"""
Defines the indicators required by the MACD strategy.
It needs one MACD indicator.
"""
# Default periods for MACD, can be overridden by strategy config
return [
{'type': 'macd', 'fast_period': 12, 'slow_period': 26, 'signal_period': 9, 'price_column': 'close'}
]
def calculate(self, df: pd.DataFrame, indicators_data: Dict[str, pd.DataFrame], **kwargs) -> List[StrategyResult]:
"""
Calculate MACD strategy signals.
Args:
df: DataFrame with OHLCV data.
indicators_data: Dictionary of pre-calculated indicator DataFrames.
Expected key: 'macd_fast_period_12_slow_period_26_signal_period_9'.
**kwargs: Additional strategy parameters (e.g., fast_period, slow_period, signal_period, price_column).
Returns:
List of StrategyResult objects, each containing generated signals.
"""
# Extract parameters from kwargs or use defaults
fast_period = kwargs.get('fast_period', 12)
slow_period = kwargs.get('slow_period', 26)
signal_period = kwargs.get('signal_period', 9)
price_column = kwargs.get('price_column', 'close')
# Generate indicator key using shared utility function
macd_key = create_indicator_key({
'type': 'macd',
'fast_period': fast_period,
'slow_period': slow_period,
'signal_period': signal_period
})
# Validate that the main DataFrame has enough data for strategy calculation
min_periods = max(slow_period, signal_period)
if not self.validate_dataframe(df, min_periods):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Insufficient main DataFrame for calculation.")
return []
# Validate that the required MACD indicator data is present and sufficient
required_indicators = [
{'type': 'macd', 'fast_period': fast_period, 'slow_period': slow_period, 'signal_period': signal_period}
]
if not self.validate_indicators_data(indicators_data, required_indicators):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Missing or insufficient MACD indicator data.")
return []
macd_df = indicators_data.get(macd_key)
if macd_df is None or macd_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: MACD indicator DataFrame is not found or empty.")
return []
# Merge all necessary data into a single DataFrame for easier processing
merged_df = pd.merge(df[[price_column, 'symbol', 'timeframe']],
macd_df[['macd', 'signal', 'histogram']],
left_index=True, right_index=True, how='inner')
if merged_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: Merged DataFrame is empty after indicator alignment. Check data ranges.")
return []
# Use vectorized signal detection for better performance
bullish_crossover, bearish_crossover = detect_crossover_signals_vectorized(
merged_df, 'macd', 'signal'
)
results: List[StrategyResult] = []
strategy_metadata = {
'fast_period': fast_period,
'slow_period': slow_period,
'signal_period': signal_period
}
# Process bullish crossover signals (MACD crosses above Signal)
bullish_indices = merged_df[bullish_crossover].index
for timestamp in bullish_indices:
row = merged_df.loc[timestamp]
# Skip if any MACD values are NaN
if pd.isna(row['macd']) or pd.isna(row['signal']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.BUY,
price=float(row[price_column]),
confidence=0.9,
metadata={'macd_cross': 'bullish', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={
'macd': float(row['macd']),
'signal': float(row['signal'])
},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: BUY signal at {timestamp} for {row['symbol']} (MACD: {row['macd']:.2f}, Signal: {row['signal']:.2f})")
# Process bearish crossover signals (MACD crosses below Signal)
bearish_indices = merged_df[bearish_crossover].index
for timestamp in bearish_indices:
row = merged_df.loc[timestamp]
# Skip if any MACD values are NaN
if pd.isna(row['macd']) or pd.isna(row['signal']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.SELL,
price=float(row[price_column]),
confidence=0.9,
metadata={'macd_cross': 'bearish', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={
'macd': float(row['macd']),
'signal': float(row['signal'])
},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: SELL signal at {timestamp} for {row['symbol']} (MACD: {row['macd']:.2f}, Signal: {row['signal']:.2f})")
return results

View File

@@ -0,0 +1,168 @@
"""
Relative Strength Index (RSI) Strategy Implementation
This module implements an RSI-based momentum trading strategy.
It extends the BaseStrategy and generates buy/sell signals based on
RSI crossing overbought/oversold thresholds.
"""
import pandas as pd
from typing import List, Dict, Any
from ..base import BaseStrategy
from ..data_types import StrategyResult, StrategySignal, SignalType
from ..utils import create_indicator_key, detect_threshold_signals_vectorized
class RSIStrategy(BaseStrategy):
"""
RSI Strategy.
Generates buy/sell signals when RSI crosses overbought/oversold thresholds.
"""
def __init__(self, logger=None):
super().__init__(logger)
self.strategy_name = "rsi"
def get_required_indicators(self) -> List[Dict[str, Any]]:
"""
Defines the indicators required by the RSI strategy.
It needs one RSI indicator.
"""
# Default period for RSI, can be overridden by strategy config
return [
{'type': 'rsi', 'period': 14, 'price_column': 'close'}
]
def calculate(self, df: pd.DataFrame, indicators_data: Dict[str, pd.DataFrame], **kwargs) -> List[StrategyResult]:
"""
Calculate RSI strategy signals.
Args:
df: DataFrame with OHLCV data.
indicators_data: Dictionary of pre-calculated indicator DataFrames.
Expected key: 'rsi_period_14'.
**kwargs: Additional strategy parameters (e.g., period, overbought, oversold, price_column).
Returns:
List of StrategyResult objects, each containing generated signals.
"""
# Extract parameters from kwargs or use defaults
period = kwargs.get('period', 14)
overbought = kwargs.get('overbought', 70)
oversold = kwargs.get('oversold', 30)
price_column = kwargs.get('price_column', 'close')
# Generate indicator key using shared utility function
rsi_key = create_indicator_key({'type': 'rsi', 'period': period})
# Validate that the main DataFrame has enough data for strategy calculation
if not self.validate_dataframe(df, period):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Insufficient main DataFrame for calculation.")
return []
# Validate that the required RSI indicator data is present and sufficient
required_indicators = [
{'type': 'rsi', 'period': period}
]
if not self.validate_indicators_data(indicators_data, required_indicators):
if self.logger:
self.logger.warning(f"{self.strategy_name}: Missing or insufficient RSI indicator data.")
return []
rsi_df = indicators_data.get(rsi_key)
if rsi_df is None or rsi_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: RSI indicator DataFrame is not found or empty.")
return []
# Merge all necessary data into a single DataFrame for easier processing
merged_df = pd.merge(df[[price_column, 'symbol', 'timeframe']],
rsi_df[['rsi']],
left_index=True, right_index=True, how='inner')
if merged_df.empty:
if self.logger:
self.logger.warning(f"{self.strategy_name}: Merged DataFrame is empty after indicator alignment. Check data ranges.")
return []
# Use vectorized signal detection for better performance
buy_signals, sell_signals = detect_threshold_signals_vectorized(
merged_df, 'rsi', overbought, oversold
)
results: List[StrategyResult] = []
strategy_metadata = {
'period': period,
'overbought': overbought,
'oversold': oversold
}
# Process buy signals (RSI crosses above oversold threshold)
buy_indices = merged_df[buy_signals].index
for timestamp in buy_indices:
row = merged_df.loc[timestamp]
# Skip if RSI value is NaN
if pd.isna(row['rsi']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.BUY,
price=float(row[price_column]),
confidence=0.7,
metadata={'rsi_cross': 'oversold_to_buy', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={'rsi': float(row['rsi'])},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: BUY signal at {timestamp} for {row['symbol']} (RSI: {row['rsi']:.2f})")
# Process sell signals (RSI crosses below overbought threshold)
sell_indices = merged_df[sell_signals].index
for timestamp in sell_indices:
row = merged_df.loc[timestamp]
# Skip if RSI value is NaN
if pd.isna(row['rsi']):
continue
signal = StrategySignal(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
signal_type=SignalType.SELL,
price=float(row[price_column]),
confidence=0.7,
metadata={'rsi_cross': 'overbought_to_sell', **strategy_metadata}
)
results.append(StrategyResult(
timestamp=timestamp,
symbol=row['symbol'],
timeframe=row['timeframe'],
strategy_name=self.strategy_name,
signals=[signal],
indicators_used={'rsi': float(row['rsi'])},
metadata=strategy_metadata
))
if self.logger:
self.logger.info(f"{self.strategy_name}: SELL signal at {timestamp} for {row['symbol']} (RSI: {row['rsi']:.2f})")
return results