diff --git a/IncrementalTrader/__init__.py b/IncrementalTrader/__init__.py new file mode 100644 index 0000000..1889222 --- /dev/null +++ b/IncrementalTrader/__init__.py @@ -0,0 +1,86 @@ +""" +IncrementalTrader - A modular incremental trading system + +This module provides a complete framework for incremental trading strategies, +including real-time data processing, backtesting, and strategy development tools. + +Key Components: +- strategies: Incremental trading strategies and indicators +- trader: Trading execution and position management +- backtester: Backtesting framework and configuration + +Example: + from IncrementalTrader import IncTrader, IncBacktester + from IncrementalTrader.strategies import MetaTrendStrategy + + # Create strategy + strategy = MetaTrendStrategy("metatrend", params={"timeframe": "15min"}) + + # Create trader + trader = IncTrader(strategy, initial_usd=10000) + + # Run backtest + backtester = IncBacktester() + results = backtester.run_single_strategy(strategy) +""" + +__version__ = "1.0.0" +__author__ = "Cycles Trading Team" + +# Import main components for easy access +# Note: These are now available after migration +try: + from .trader import IncTrader, TradeRecord, PositionManager, MarketFees +except ImportError: + IncTrader = None + TradeRecord = None + PositionManager = None + MarketFees = None + +try: + from .backtester import IncBacktester, BacktestConfig, OptimizationConfig +except ImportError: + IncBacktester = None + BacktestConfig = None + OptimizationConfig = None + +# Import strategy framework (now available) +from .strategies import IncStrategyBase, IncStrategySignal, TimeframeAggregator + +# Import available strategies +from .strategies import ( + MetaTrendStrategy, + IncMetaTrendStrategy, # Compatibility alias + RandomStrategy, + IncRandomStrategy, # Compatibility alias + BBRSStrategy, + IncBBRSStrategy, # Compatibility alias +) + +# Public API +__all__ = [ + # Core components (now available after migration) + "IncTrader", + "IncBacktester", + "BacktestConfig", + "OptimizationConfig", + "TradeRecord", + "PositionManager", + "MarketFees", + + # Strategy framework (available now) + "IncStrategyBase", + "IncStrategySignal", + "TimeframeAggregator", + + # Available strategies + "MetaTrendStrategy", + "IncMetaTrendStrategy", # Compatibility alias + "RandomStrategy", + "IncRandomStrategy", # Compatibility alias + "BBRSStrategy", + "IncBBRSStrategy", # Compatibility alias + + # Version info + "__version__", +] \ No newline at end of file diff --git a/IncrementalTrader/backtester/__init__.py b/IncrementalTrader/backtester/__init__.py new file mode 100644 index 0000000..ac3ad86 --- /dev/null +++ b/IncrementalTrader/backtester/__init__.py @@ -0,0 +1,48 @@ +""" +Incremental Backtesting Framework + +This module provides comprehensive backtesting capabilities for incremental trading strategies. +It includes configuration management, data loading, parallel execution, and result analysis. + +Components: +- IncBacktester: Main backtesting engine +- BacktestConfig: Configuration management for backtests +- OptimizationConfig: Configuration for parameter optimization +- DataLoader: Data loading and validation utilities +- SystemUtils: System resource management +- ResultsSaver: Result saving and reporting utilities + +Example: + from IncrementalTrader.backtester import IncBacktester, BacktestConfig + from IncrementalTrader.strategies import MetaTrendStrategy + + # Configure backtest + config = BacktestConfig( + data_file="btc_1min_2023.csv", + start_date="2023-01-01", + end_date="2023-12-31", + initial_usd=10000 + ) + + # Run single strategy + strategy = MetaTrendStrategy("metatrend") + backtester = IncBacktester(config) + results = backtester.run_single_strategy(strategy) + + # Parameter optimization + param_grid = {"timeframe": ["5min", "15min", "30min"]} + results = backtester.optimize_parameters(MetaTrendStrategy, param_grid) +""" + +from .backtester import IncBacktester +from .config import BacktestConfig, OptimizationConfig +from .utils import DataLoader, SystemUtils, ResultsSaver + +__all__ = [ + "IncBacktester", + "BacktestConfig", + "OptimizationConfig", + "DataLoader", + "SystemUtils", + "ResultsSaver", +] \ No newline at end of file diff --git a/IncrementalTrader/backtester/backtester.py b/IncrementalTrader/backtester/backtester.py new file mode 100644 index 0000000..b9d6a06 --- /dev/null +++ b/IncrementalTrader/backtester/backtester.py @@ -0,0 +1,524 @@ +""" +Incremental Backtester for testing incremental strategies. + +This module provides the IncBacktester class that orchestrates multiple IncTraders +for parallel testing, handles data loading and feeding, and supports multiprocessing +for parameter optimization. +""" + +import pandas as pd +import numpy as np +from typing import Dict, List, Optional, Any, Callable, Union, Tuple +import logging +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from itertools import product +import multiprocessing as mp +from datetime import datetime + +# Use try/except for imports to handle both relative and absolute import scenarios +try: + from ..trader.trader import IncTrader + from ..strategies.base import IncStrategyBase + from .config import BacktestConfig, OptimizationConfig + from .utils import DataLoader, SystemUtils, ResultsSaver +except ImportError: + # Fallback for direct execution + import sys + import os + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from trader.trader import IncTrader + from strategies.base import IncStrategyBase + from config import BacktestConfig, OptimizationConfig + from utils import DataLoader, SystemUtils, ResultsSaver + +logger = logging.getLogger(__name__) + + +def _worker_function(args: Tuple[type, Dict, Dict, BacktestConfig]) -> Dict[str, Any]: + """ + Worker function for multiprocessing parameter optimization. + + This function must be at module level to be picklable for multiprocessing. + + Args: + args: Tuple containing (strategy_class, strategy_params, trader_params, config) + + Returns: + Dict containing backtest results + """ + try: + strategy_class, strategy_params, trader_params, config = args + + # Create new backtester instance for this worker + worker_backtester = IncBacktester(config) + + # Create strategy instance + strategy = strategy_class(params=strategy_params) + + # Run backtest + result = worker_backtester.run_single_strategy(strategy, trader_params) + result["success"] = True + + return result + + except Exception as e: + logger.error(f"Worker error for {strategy_params}, {trader_params}: {e}") + return { + "strategy_params": strategy_params, + "trader_params": trader_params, + "error": str(e), + "success": False + } + + +class IncBacktester: + """ + Incremental backtester for testing incremental strategies. + + This class orchestrates multiple IncTraders for parallel testing: + - Loads data using the integrated DataLoader + - Creates multiple IncTrader instances with different parameters + - Feeds data sequentially to all traders + - Collects and aggregates results + - Supports multiprocessing for parallel execution + - Uses SystemUtils for optimal worker count determination + + The backtester can run multiple strategies simultaneously or test + parameter combinations across multiple CPU cores. + + Example: + # Single strategy backtest + config = BacktestConfig( + data_file="btc_1min_2023.csv", + start_date="2023-01-01", + end_date="2023-12-31", + initial_usd=10000 + ) + + strategy = RandomStrategy("random", params={"timeframe": "15min"}) + backtester = IncBacktester(config) + results = backtester.run_single_strategy(strategy) + + # Multiple strategies + strategies = [strategy1, strategy2, strategy3] + results = backtester.run_multiple_strategies(strategies) + + # Parameter optimization + param_grid = { + "timeframe": ["5min", "15min", "30min"], + "stop_loss_pct": [0.01, 0.02, 0.03] + } + results = backtester.optimize_parameters(strategy_class, param_grid) + """ + + def __init__(self, config: BacktestConfig): + """ + Initialize the incremental backtester. + + Args: + config: Backtesting configuration + """ + self.config = config + + # Initialize utilities + self.data_loader = DataLoader(config.data_dir) + self.system_utils = SystemUtils() + self.results_saver = ResultsSaver(config.results_dir) + + # State management + self.data = None + self.results_cache = {} + + # Track all actions performed during backtesting + self.action_log = [] + self.session_start_time = datetime.now() + + logger.info(f"IncBacktester initialized: {config.data_file}, " + f"{config.start_date} to {config.end_date}") + + self._log_action("backtester_initialized", { + "config": config.to_dict(), + "session_start": self.session_start_time.isoformat(), + "system_info": self.system_utils.get_system_info() + }) + + def _log_action(self, action_type: str, details: Dict[str, Any]) -> None: + """Log an action performed during backtesting.""" + self.action_log.append({ + "timestamp": datetime.now().isoformat(), + "action_type": action_type, + "details": details + }) + + def load_data(self) -> pd.DataFrame: + """ + Load and prepare data for backtesting. + + Returns: + pd.DataFrame: Loaded OHLCV data with DatetimeIndex + """ + if self.data is None: + logger.info(f"Loading data from {self.config.data_file}...") + start_time = time.time() + + self.data = self.data_loader.load_data( + self.config.data_file, + self.config.start_date, + self.config.end_date + ) + + load_time = time.time() - start_time + logger.info(f"Data loaded: {len(self.data)} rows in {load_time:.2f}s") + + # Validate data + if self.data.empty: + raise ValueError(f"No data loaded for the specified date range") + + if not self.data_loader.validate_data(self.data): + raise ValueError("Data validation failed") + + self._log_action("data_loaded", { + "file": self.config.data_file, + "rows": len(self.data), + "load_time_seconds": load_time, + "date_range": f"{self.config.start_date} to {self.config.end_date}", + "columns": list(self.data.columns) + }) + + return self.data + + def run_single_strategy(self, strategy: IncStrategyBase, + trader_params: Optional[Dict] = None) -> Dict[str, Any]: + """ + Run backtest for a single strategy. + + Args: + strategy: Incremental strategy instance + trader_params: Additional trader parameters + + Returns: + Dict containing backtest results + """ + data = self.load_data() + + # Merge trader parameters + final_trader_params = { + "stop_loss_pct": self.config.stop_loss_pct, + "take_profit_pct": self.config.take_profit_pct + } + if trader_params: + final_trader_params.update(trader_params) + + # Create trader + trader = IncTrader( + strategy=strategy, + initial_usd=self.config.initial_usd, + params=final_trader_params + ) + + # Run backtest + logger.info(f"Starting backtest for {strategy.name}...") + start_time = time.time() + + self._log_action("single_strategy_backtest_started", { + "strategy_name": strategy.name, + "strategy_params": strategy.params, + "trader_params": final_trader_params, + "data_points": len(data) + }) + + for timestamp, row in data.iterrows(): + ohlcv_data = { + 'open': row['open'], + 'high': row['high'], + 'low': row['low'], + 'close': row['close'], + 'volume': row['volume'] + } + trader.process_data_point(timestamp, ohlcv_data) + + # Finalize and get results + trader.finalize() + results = trader.get_results() + + backtest_time = time.time() - start_time + results["backtest_duration_seconds"] = backtest_time + results["data_points"] = len(data) + results["config"] = self.config.to_dict() + + logger.info(f"Backtest completed for {strategy.name} in {backtest_time:.2f}s: " + f"${results['final_usd']:.2f} ({results['profit_ratio']*100:.2f}%), " + f"{results['n_trades']} trades") + + self._log_action("single_strategy_backtest_completed", { + "strategy_name": strategy.name, + "backtest_duration_seconds": backtest_time, + "final_usd": results['final_usd'], + "profit_ratio": results['profit_ratio'], + "n_trades": results['n_trades'], + "win_rate": results['win_rate'] + }) + + return results + + def run_multiple_strategies(self, strategies: List[IncStrategyBase], + trader_params: Optional[Dict] = None) -> List[Dict[str, Any]]: + """ + Run backtest for multiple strategies simultaneously. + + Args: + strategies: List of incremental strategy instances + trader_params: Additional trader parameters + + Returns: + List of backtest results for each strategy + """ + self._log_action("multiple_strategies_backtest_started", { + "strategy_count": len(strategies), + "strategy_names": [s.name for s in strategies] + }) + + results = [] + + for strategy in strategies: + try: + result = self.run_single_strategy(strategy, trader_params) + results.append(result) + except Exception as e: + logger.error(f"Error running strategy {strategy.name}: {e}") + # Add error result + error_result = { + "strategy_name": strategy.name, + "error": str(e), + "success": False + } + results.append(error_result) + + self._log_action("strategy_error", { + "strategy_name": strategy.name, + "error": str(e) + }) + + self._log_action("multiple_strategies_backtest_completed", { + "total_strategies": len(strategies), + "successful_strategies": len([r for r in results if r.get("success", True)]), + "failed_strategies": len([r for r in results if not r.get("success", True)]) + }) + + return results + + def optimize_parameters(self, strategy_class: type, param_grid: Dict[str, List], + trader_param_grid: Optional[Dict[str, List]] = None, + max_workers: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Optimize strategy parameters using grid search with multiprocessing. + + Args: + strategy_class: Strategy class to instantiate + param_grid: Grid of strategy parameters to test + trader_param_grid: Grid of trader parameters to test + max_workers: Maximum number of worker processes (uses SystemUtils if None) + + Returns: + List of results for each parameter combination + """ + # Generate parameter combinations + strategy_combinations = list(self._generate_param_combinations(param_grid)) + trader_combinations = list(self._generate_param_combinations(trader_param_grid or {})) + + # If no trader param grid, use default + if not trader_combinations: + trader_combinations = [{}] + + # Create all combinations + all_combinations = [] + for strategy_params in strategy_combinations: + for trader_params in trader_combinations: + all_combinations.append((strategy_params, trader_params)) + + logger.info(f"Starting parameter optimization: {len(all_combinations)} combinations") + + # Determine number of workers using SystemUtils + if max_workers is None: + max_workers = self.system_utils.get_optimal_workers() + else: + max_workers = min(max_workers, len(all_combinations)) + + self._log_action("parameter_optimization_started", { + "strategy_class": strategy_class.__name__, + "total_combinations": len(all_combinations), + "max_workers": max_workers, + "strategy_param_grid": param_grid, + "trader_param_grid": trader_param_grid or {} + }) + + # Run optimization + if max_workers == 1 or len(all_combinations) == 1: + # Single-threaded execution + results = [] + for strategy_params, trader_params in all_combinations: + result = self._run_single_combination(strategy_class, strategy_params, trader_params) + results.append(result) + else: + # Multi-threaded execution + results = self._run_parallel_optimization( + strategy_class, all_combinations, max_workers + ) + + # Sort results by profit ratio + valid_results = [r for r in results if r.get("success", True)] + valid_results.sort(key=lambda x: x.get("profit_ratio", -float('inf')), reverse=True) + + logger.info(f"Parameter optimization completed: {len(valid_results)} successful runs") + + self._log_action("parameter_optimization_completed", { + "total_runs": len(results), + "successful_runs": len(valid_results), + "failed_runs": len(results) - len(valid_results), + "best_profit_ratio": valid_results[0]["profit_ratio"] if valid_results else None, + "worst_profit_ratio": valid_results[-1]["profit_ratio"] if valid_results else None + }) + + return results + + def _generate_param_combinations(self, param_grid: Dict[str, List]) -> List[Dict]: + """Generate all parameter combinations from grid.""" + if not param_grid: + return [{}] + + keys = list(param_grid.keys()) + values = list(param_grid.values()) + + combinations = [] + for combination in product(*values): + param_dict = dict(zip(keys, combination)) + combinations.append(param_dict) + + return combinations + + def _run_single_combination(self, strategy_class: type, strategy_params: Dict, + trader_params: Dict) -> Dict[str, Any]: + """Run backtest for a single parameter combination.""" + try: + # Create strategy instance + strategy = strategy_class(params=strategy_params) + + # Run backtest + result = self.run_single_strategy(strategy, trader_params) + result["success"] = True + + return result + + except Exception as e: + logger.error(f"Error in parameter combination {strategy_params}, {trader_params}: {e}") + return { + "strategy_params": strategy_params, + "trader_params": trader_params, + "error": str(e), + "success": False + } + + def _run_parallel_optimization(self, strategy_class: type, combinations: List, + max_workers: int) -> List[Dict[str, Any]]: + """Run parameter optimization in parallel.""" + results = [] + + # Prepare arguments for worker function + worker_args = [] + for strategy_params, trader_params in combinations: + args = (strategy_class, strategy_params, trader_params, self.config) + worker_args.append(args) + + # Execute in parallel + with ProcessPoolExecutor(max_workers=max_workers) as executor: + # Submit all jobs + future_to_params = { + executor.submit(_worker_function, args): args[1:3] # strategy_params, trader_params + for args in worker_args + } + + # Collect results as they complete + for future in as_completed(future_to_params): + combo = future_to_params[future] + try: + result = future.result() + results.append(result) + + if result.get("success", True): + logger.info(f"Completed: {combo[0]} -> " + f"${result.get('final_usd', 0):.2f} " + f"({result.get('profit_ratio', 0)*100:.2f}%)") + except Exception as e: + logger.error(f"Worker error for {combo}: {e}") + results.append({ + "strategy_params": combo[0], + "trader_params": combo[1], + "error": str(e), + "success": False + }) + + return results + + def get_summary_statistics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Calculate summary statistics across multiple backtest results. + + Args: + results: List of backtest results + + Returns: + Dict containing summary statistics + """ + return self.results_saver._calculate_summary_statistics(results) + + def save_results(self, results: List[Dict[str, Any]], filename: str) -> None: + """ + Save backtest results to CSV file. + + Args: + results: List of backtest results + filename: Output filename + """ + self.results_saver.save_results_csv(results, filename) + + def save_comprehensive_results(self, results: List[Dict[str, Any]], + base_filename: str, + summary: Optional[Dict[str, Any]] = None) -> None: + """ + Save comprehensive backtest results including summary, individual results, and action log. + + Args: + results: List of backtest results + base_filename: Base filename (without extension) + summary: Optional summary statistics + """ + self.results_saver.save_comprehensive_results( + results=results, + base_filename=base_filename, + summary=summary, + action_log=self.action_log, + session_start_time=self.session_start_time + ) + + def get_action_log(self) -> List[Dict[str, Any]]: + """Get the complete action log for this session.""" + return self.action_log.copy() + + def reset_session(self) -> None: + """Reset the backtester session (clear cache and logs).""" + self.data = None + self.results_cache.clear() + self.action_log.clear() + self.session_start_time = datetime.now() + + logger.info("Backtester session reset") + self._log_action("session_reset", { + "reset_time": self.session_start_time.isoformat() + }) + + def __repr__(self) -> str: + """String representation of the backtester.""" + return (f"IncBacktester(data_file={self.config.data_file}, " + f"date_range={self.config.start_date} to {self.config.end_date}, " + f"initial_usd=${self.config.initial_usd})") \ No newline at end of file diff --git a/IncrementalTrader/backtester/config.py b/IncrementalTrader/backtester/config.py new file mode 100644 index 0000000..3acf0eb --- /dev/null +++ b/IncrementalTrader/backtester/config.py @@ -0,0 +1,207 @@ +""" +Backtester Configuration + +This module provides configuration classes and utilities for backtesting +incremental trading strategies. +""" + +import os +import pandas as pd +from dataclasses import dataclass +from typing import Optional, Dict, Any, List +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class BacktestConfig: + """ + Configuration for backtesting runs. + + This class encapsulates all configuration parameters needed for running + backtests, including data settings, trading parameters, and performance options. + + Attributes: + data_file: Path to the data file (relative to data directory) + start_date: Start date for backtesting (YYYY-MM-DD format) + end_date: End date for backtesting (YYYY-MM-DD format) + initial_usd: Initial USD balance for trading + timeframe: Data timeframe (e.g., "1min", "5min", "15min") + stop_loss_pct: Default stop loss percentage (0.0 to disable) + take_profit_pct: Default take profit percentage (0.0 to disable) + max_workers: Maximum number of worker processes for parallel execution + chunk_size: Chunk size for data processing + data_dir: Directory containing data files + results_dir: Directory for saving results + + Example: + config = BacktestConfig( + data_file="btc_1min_2023.csv", + start_date="2023-01-01", + end_date="2023-12-31", + initial_usd=10000, + stop_loss_pct=0.02 + ) + """ + data_file: str + start_date: str + end_date: str + initial_usd: float = 10000 + timeframe: str = "1min" + + # Risk management parameters + stop_loss_pct: float = 0.0 + take_profit_pct: float = 0.0 + + # Performance settings + max_workers: Optional[int] = None + chunk_size: int = 1000 + + # Directory settings + data_dir: str = "data" + results_dir: str = "results" + + def __post_init__(self): + """Validate configuration after initialization.""" + self._validate_config() + self._ensure_directories() + + def _validate_config(self): + """Validate configuration parameters.""" + # Validate dates + try: + start_dt = pd.to_datetime(self.start_date) + end_dt = pd.to_datetime(self.end_date) + if start_dt >= end_dt: + raise ValueError("start_date must be before end_date") + except Exception as e: + raise ValueError(f"Invalid date format: {e}") + + # Validate financial parameters + if self.initial_usd <= 0: + raise ValueError("initial_usd must be positive") + + if not (0 <= self.stop_loss_pct <= 1): + raise ValueError("stop_loss_pct must be between 0 and 1") + + if not (0 <= self.take_profit_pct <= 1): + raise ValueError("take_profit_pct must be between 0 and 1") + + # Validate performance parameters + if self.max_workers is not None and self.max_workers <= 0: + raise ValueError("max_workers must be positive") + + if self.chunk_size <= 0: + raise ValueError("chunk_size must be positive") + + def _ensure_directories(self): + """Ensure required directories exist.""" + os.makedirs(self.data_dir, exist_ok=True) + os.makedirs(self.results_dir, exist_ok=True) + + def get_data_path(self) -> str: + """Get full path to data file.""" + return os.path.join(self.data_dir, self.data_file) + + def get_results_path(self, filename: str) -> str: + """Get full path for results file.""" + return os.path.join(self.results_dir, filename) + + def to_dict(self) -> Dict[str, Any]: + """Convert configuration to dictionary.""" + return { + "data_file": self.data_file, + "start_date": self.start_date, + "end_date": self.end_date, + "initial_usd": self.initial_usd, + "timeframe": self.timeframe, + "stop_loss_pct": self.stop_loss_pct, + "take_profit_pct": self.take_profit_pct, + "max_workers": self.max_workers, + "chunk_size": self.chunk_size, + "data_dir": self.data_dir, + "results_dir": self.results_dir + } + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> 'BacktestConfig': + """Create configuration from dictionary.""" + return cls(**config_dict) + + def copy(self, **kwargs) -> 'BacktestConfig': + """Create a copy of the configuration with optional parameter overrides.""" + config_dict = self.to_dict() + config_dict.update(kwargs) + return self.from_dict(config_dict) + + def __repr__(self) -> str: + """String representation of the configuration.""" + return (f"BacktestConfig(data_file={self.data_file}, " + f"date_range={self.start_date} to {self.end_date}, " + f"initial_usd=${self.initial_usd})") + + +class OptimizationConfig: + """ + Configuration for parameter optimization runs. + + This class provides additional configuration options specifically for + parameter optimization and grid search operations. + """ + + def __init__(self, + base_config: BacktestConfig, + strategy_param_grid: Dict[str, List], + trader_param_grid: Optional[Dict[str, List]] = None, + max_workers: Optional[int] = None, + save_individual_results: bool = True, + save_detailed_logs: bool = False): + """ + Initialize optimization configuration. + + Args: + base_config: Base backtesting configuration + strategy_param_grid: Grid of strategy parameters to test + trader_param_grid: Grid of trader parameters to test + max_workers: Maximum number of worker processes + save_individual_results: Whether to save individual strategy results + save_detailed_logs: Whether to save detailed action logs + """ + self.base_config = base_config + self.strategy_param_grid = strategy_param_grid + self.trader_param_grid = trader_param_grid or {} + self.max_workers = max_workers + self.save_individual_results = save_individual_results + self.save_detailed_logs = save_detailed_logs + + def get_total_combinations(self) -> int: + """Calculate total number of parameter combinations.""" + from itertools import product + + # Calculate strategy combinations + strategy_values = list(self.strategy_param_grid.values()) + strategy_combinations = len(list(product(*strategy_values))) if strategy_values else 1 + + # Calculate trader combinations + trader_values = list(self.trader_param_grid.values()) + trader_combinations = len(list(product(*trader_values))) if trader_values else 1 + + return strategy_combinations * trader_combinations + + def to_dict(self) -> Dict[str, Any]: + """Convert optimization configuration to dictionary.""" + return { + "base_config": self.base_config.to_dict(), + "strategy_param_grid": self.strategy_param_grid, + "trader_param_grid": self.trader_param_grid, + "max_workers": self.max_workers, + "save_individual_results": self.save_individual_results, + "save_detailed_logs": self.save_detailed_logs, + "total_combinations": self.get_total_combinations() + } + + def __repr__(self) -> str: + """String representation of the optimization configuration.""" + return (f"OptimizationConfig(combinations={self.get_total_combinations()}, " + f"max_workers={self.max_workers})") \ No newline at end of file diff --git a/IncrementalTrader/backtester/utils.py b/IncrementalTrader/backtester/utils.py new file mode 100644 index 0000000..a7cd59a --- /dev/null +++ b/IncrementalTrader/backtester/utils.py @@ -0,0 +1,480 @@ +""" +Backtester Utilities + +This module provides utility functions for data loading, system resource management, +and result saving for the incremental backtesting framework. +""" + +import os +import json +import pandas as pd +import numpy as np +import psutil +from typing import Dict, List, Any, Optional +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class DataLoader: + """ + Data loading utilities for backtesting. + + This class handles loading and preprocessing of market data from various formats + including CSV and JSON files. + """ + + def __init__(self, data_dir: str = "data"): + """ + Initialize data loader. + + Args: + data_dir: Directory containing data files + """ + self.data_dir = data_dir + os.makedirs(self.data_dir, exist_ok=True) + + def load_data(self, file_path: str, start_date: str, end_date: str) -> pd.DataFrame: + """ + Load data with optimized dtypes and filtering, supporting CSV and JSON input. + + Args: + file_path: Path to the data file (relative to data_dir) + start_date: Start date for filtering (YYYY-MM-DD format) + end_date: End date for filtering (YYYY-MM-DD format) + + Returns: + pd.DataFrame: Loaded OHLCV data with DatetimeIndex + """ + full_path = os.path.join(self.data_dir, file_path) + + if not os.path.exists(full_path): + raise FileNotFoundError(f"Data file not found: {full_path}") + + # Determine file type + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + try: + if ext == ".json": + return self._load_json_data(full_path, start_date, end_date) + else: + return self._load_csv_data(full_path, start_date, end_date) + + except Exception as e: + logger.error(f"Error loading data from {file_path}: {e}") + # Return an empty DataFrame with a DatetimeIndex + return pd.DataFrame(index=pd.to_datetime([])) + + def _load_json_data(self, file_path: str, start_date: str, end_date: str) -> pd.DataFrame: + """Load data from JSON file.""" + with open(file_path, 'r') as f: + raw = json.load(f) + + data = pd.DataFrame(raw["Data"]) + + # Convert columns to lowercase + data.columns = data.columns.str.lower() + + # Convert timestamp to datetime + data["timestamp"] = pd.to_datetime(data["timestamp"], unit="s") + + # Filter by date range + data = data[(data["timestamp"] >= start_date) & (data["timestamp"] <= end_date)] + + logger.info(f"JSON data loaded: {len(data)} rows for {start_date} to {end_date}") + return data.set_index("timestamp") + + def _load_csv_data(self, file_path: str, start_date: str, end_date: str) -> pd.DataFrame: + """Load data from CSV file.""" + # Define optimized dtypes + dtypes = { + 'Open': 'float32', + 'High': 'float32', + 'Low': 'float32', + 'Close': 'float32', + 'Volume': 'float32' + } + + # Read data with original capitalized column names + data = pd.read_csv(file_path, dtype=dtypes) + + # Handle timestamp column + if 'Timestamp' in data.columns: + data['Timestamp'] = pd.to_datetime(data['Timestamp'], unit='s') + # Filter by date range + data = data[(data['Timestamp'] >= start_date) & (data['Timestamp'] <= end_date)] + # Convert column names to lowercase + data.columns = data.columns.str.lower() + logger.info(f"CSV data loaded: {len(data)} rows for {start_date} to {end_date}") + return data.set_index('timestamp') + else: + # Attempt to use the first column if 'Timestamp' is not present + data.rename(columns={data.columns[0]: 'timestamp'}, inplace=True) + data['timestamp'] = pd.to_datetime(data['timestamp'], unit='s') + data = data[(data['timestamp'] >= start_date) & (data['timestamp'] <= end_date)] + data.columns = data.columns.str.lower() + logger.info(f"CSV data loaded (first column as timestamp): {len(data)} rows for {start_date} to {end_date}") + return data.set_index('timestamp') + + def validate_data(self, data: pd.DataFrame) -> bool: + """ + Validate loaded data for required columns and basic integrity. + + Args: + data: DataFrame to validate + + Returns: + bool: True if data is valid + """ + if data.empty: + logger.error("Data is empty") + return False + + required_columns = ['open', 'high', 'low', 'close', 'volume'] + missing_columns = [col for col in required_columns if col not in data.columns] + + if missing_columns: + logger.error(f"Missing required columns: {missing_columns}") + return False + + # Check for NaN values + if data[required_columns].isnull().any().any(): + logger.warning("Data contains NaN values") + + # Check for negative prices + price_columns = ['open', 'high', 'low', 'close'] + if (data[price_columns] <= 0).any().any(): + logger.warning("Data contains non-positive prices") + + # Check OHLC consistency + if not ((data['low'] <= data['open']) & + (data['low'] <= data['close']) & + (data['high'] >= data['open']) & + (data['high'] >= data['close'])).all(): + logger.warning("Data contains OHLC inconsistencies") + + return True + + +class SystemUtils: + """ + System resource management utilities. + + This class provides methods for determining optimal system resource usage + for parallel processing and performance optimization. + """ + + def __init__(self): + """Initialize system utilities.""" + pass + + def get_optimal_workers(self) -> int: + """ + Determine optimal number of worker processes based on system resources. + + Returns: + int: Optimal number of worker processes + """ + cpu_count = os.cpu_count() or 4 + memory_gb = psutil.virtual_memory().total / (1024**3) + + # Heuristic: Use 75% of cores, but cap based on available memory + # Assume each worker needs ~2GB for large datasets + workers_by_memory = max(1, int(memory_gb / 2)) + workers_by_cpu = max(1, int(cpu_count * 0.75)) + + optimal_workers = min(workers_by_cpu, workers_by_memory) + + logger.info(f"System resources: {cpu_count} CPUs, {memory_gb:.1f}GB RAM") + logger.info(f"Using {optimal_workers} workers for processing") + + return optimal_workers + + def get_system_info(self) -> Dict[str, Any]: + """ + Get comprehensive system information. + + Returns: + Dict containing system information + """ + memory = psutil.virtual_memory() + + return { + "cpu_count": os.cpu_count(), + "memory_total_gb": memory.total / (1024**3), + "memory_available_gb": memory.available / (1024**3), + "memory_percent": memory.percent, + "optimal_workers": self.get_optimal_workers() + } + + +class ResultsSaver: + """ + Results saving utilities for backtesting. + + This class handles saving backtest results in various formats including + CSV, JSON, and comprehensive reports. + """ + + def __init__(self, results_dir: str = "results"): + """ + Initialize results saver. + + Args: + results_dir: Directory for saving results + """ + self.results_dir = results_dir + os.makedirs(self.results_dir, exist_ok=True) + + def save_results_csv(self, results: List[Dict[str, Any]], filename: str) -> None: + """ + Save backtest results to CSV file. + + Args: + results: List of backtest results + filename: Output filename + """ + try: + # Convert results to DataFrame for easy saving + df_data = [] + for result in results: + if result.get("success", True): + row = { + "strategy_name": result.get("strategy_name", ""), + "profit_ratio": result.get("profit_ratio", 0), + "final_usd": result.get("final_usd", 0), + "n_trades": result.get("n_trades", 0), + "win_rate": result.get("win_rate", 0), + "max_drawdown": result.get("max_drawdown", 0), + "avg_trade": result.get("avg_trade", 0), + "total_fees_usd": result.get("total_fees_usd", 0), + "backtest_duration_seconds": result.get("backtest_duration_seconds", 0), + "data_points_processed": result.get("data_points_processed", 0) + } + + # Add strategy parameters + strategy_params = result.get("strategy_params", {}) + for key, value in strategy_params.items(): + row[f"strategy_{key}"] = value + + # Add trader parameters + trader_params = result.get("trader_params", {}) + for key, value in trader_params.items(): + row[f"trader_{key}"] = value + + df_data.append(row) + + # Save to CSV + df = pd.DataFrame(df_data) + full_path = os.path.join(self.results_dir, filename) + df.to_csv(full_path, index=False) + + logger.info(f"Results saved to {full_path}: {len(df_data)} rows") + + except Exception as e: + logger.error(f"Error saving results to {filename}: {e}") + raise + + def save_comprehensive_results(self, results: List[Dict[str, Any]], + base_filename: str, + summary: Optional[Dict[str, Any]] = None, + action_log: Optional[List[Dict[str, Any]]] = None, + session_start_time: Optional[datetime] = None) -> None: + """ + Save comprehensive backtest results including summary, individual results, and logs. + + Args: + results: List of backtest results + base_filename: Base filename (without extension) + summary: Optional summary statistics + action_log: Optional action log + session_start_time: Optional session start time + """ + try: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + session_start = session_start_time or datetime.now() + + # 1. Save summary report + if summary is None: + summary = self._calculate_summary_statistics(results) + + summary_data = { + "session_info": { + "timestamp": timestamp, + "session_start": session_start.isoformat(), + "session_duration_seconds": (datetime.now() - session_start).total_seconds() + }, + "summary_statistics": summary, + "action_log_summary": { + "total_actions": len(action_log) if action_log else 0, + "action_types": list(set(action["action_type"] for action in action_log)) if action_log else [] + } + } + + summary_filename = f"{base_filename}_summary_{timestamp}.json" + self._save_json(summary_data, summary_filename) + + # 2. Save detailed results CSV + self.save_results_csv(results, f"{base_filename}_detailed_{timestamp}.csv") + + # 3. Save individual strategy results + valid_results = [r for r in results if r.get("success", True)] + for i, result in enumerate(valid_results): + strategy_filename = f"{base_filename}_strategy_{i+1}_{result['strategy_name']}_{timestamp}.json" + strategy_data = self._format_strategy_result(result) + self._save_json(strategy_data, strategy_filename) + + # 4. Save action log if provided + if action_log: + action_log_filename = f"{base_filename}_actions_{timestamp}.json" + action_log_data = { + "session_info": { + "timestamp": timestamp, + "session_start": session_start.isoformat(), + "total_actions": len(action_log) + }, + "actions": action_log + } + self._save_json(action_log_data, action_log_filename) + + # 5. Create master index file + index_filename = f"{base_filename}_index_{timestamp}.json" + index_data = self._create_index_file(base_filename, timestamp, valid_results, summary) + self._save_json(index_data, index_filename) + + # Print summary + print(f"\nπŸ“Š Comprehensive results saved:") + print(f" πŸ“‹ Summary: {self.results_dir}/{summary_filename}") + print(f" πŸ“ˆ Detailed CSV: {self.results_dir}/{base_filename}_detailed_{timestamp}.csv") + if action_log: + print(f" πŸ“ Action Log: {self.results_dir}/{action_log_filename}") + print(f" πŸ“ Individual Strategies: {len(valid_results)} files") + print(f" πŸ—‚οΈ Master Index: {self.results_dir}/{index_filename}") + + except Exception as e: + logger.error(f"Error saving comprehensive results: {e}") + raise + + def _save_json(self, data: Dict[str, Any], filename: str) -> None: + """Save data to JSON file.""" + full_path = os.path.join(self.results_dir, filename) + with open(full_path, 'w') as f: + json.dump(data, f, indent=2, default=str) + logger.info(f"JSON saved: {full_path}") + + def _calculate_summary_statistics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]: + """Calculate summary statistics from results.""" + valid_results = [r for r in results if r.get("success", True)] + + if not valid_results: + return { + "total_runs": len(results), + "successful_runs": 0, + "failed_runs": len(results), + "error": "No valid results to summarize" + } + + # Extract metrics + profit_ratios = [r["profit_ratio"] for r in valid_results] + final_balances = [r["final_usd"] for r in valid_results] + n_trades_list = [r["n_trades"] for r in valid_results] + win_rates = [r["win_rate"] for r in valid_results] + max_drawdowns = [r["max_drawdown"] for r in valid_results] + + return { + "total_runs": len(results), + "successful_runs": len(valid_results), + "failed_runs": len(results) - len(valid_results), + "profit_ratio": { + "mean": np.mean(profit_ratios), + "std": np.std(profit_ratios), + "min": np.min(profit_ratios), + "max": np.max(profit_ratios), + "median": np.median(profit_ratios) + }, + "final_usd": { + "mean": np.mean(final_balances), + "std": np.std(final_balances), + "min": np.min(final_balances), + "max": np.max(final_balances), + "median": np.median(final_balances) + }, + "n_trades": { + "mean": np.mean(n_trades_list), + "std": np.std(n_trades_list), + "min": np.min(n_trades_list), + "max": np.max(n_trades_list), + "median": np.median(n_trades_list) + }, + "win_rate": { + "mean": np.mean(win_rates), + "std": np.std(win_rates), + "min": np.min(win_rates), + "max": np.max(win_rates), + "median": np.median(win_rates) + }, + "max_drawdown": { + "mean": np.mean(max_drawdowns), + "std": np.std(max_drawdowns), + "min": np.min(max_drawdowns), + "max": np.max(max_drawdowns), + "median": np.median(max_drawdowns) + }, + "best_run": max(valid_results, key=lambda x: x["profit_ratio"]), + "worst_run": min(valid_results, key=lambda x: x["profit_ratio"]) + } + + def _format_strategy_result(self, result: Dict[str, Any]) -> Dict[str, Any]: + """Format individual strategy result for saving.""" + return { + "strategy_info": { + "name": result['strategy_name'], + "params": result.get('strategy_params', {}), + "trader_params": result.get('trader_params', {}) + }, + "performance": { + "initial_usd": result['initial_usd'], + "final_usd": result['final_usd'], + "profit_ratio": result['profit_ratio'], + "n_trades": result['n_trades'], + "win_rate": result['win_rate'], + "max_drawdown": result['max_drawdown'], + "avg_trade": result['avg_trade'], + "total_fees_usd": result['total_fees_usd'] + }, + "execution": { + "backtest_duration_seconds": result.get('backtest_duration_seconds', 0), + "data_points_processed": result.get('data_points_processed', 0), + "warmup_complete": result.get('warmup_complete', False) + }, + "trades": result.get('trades', []) + } + + def _create_index_file(self, base_filename: str, timestamp: str, + valid_results: List[Dict[str, Any]], + summary: Dict[str, Any]) -> Dict[str, Any]: + """Create master index file.""" + return { + "session_info": { + "timestamp": timestamp, + "base_filename": base_filename, + "total_strategies": len(valid_results) + }, + "files": { + "summary": f"{base_filename}_summary_{timestamp}.json", + "detailed_csv": f"{base_filename}_detailed_{timestamp}.csv", + "individual_strategies": [ + f"{base_filename}_strategy_{i+1}_{result['strategy_name']}_{timestamp}.json" + for i, result in enumerate(valid_results) + ] + }, + "quick_stats": { + "best_profit": summary.get("profit_ratio", {}).get("max", 0) if summary.get("profit_ratio") else 0, + "worst_profit": summary.get("profit_ratio", {}).get("min", 0) if summary.get("profit_ratio") else 0, + "avg_profit": summary.get("profit_ratio", {}).get("mean", 0) if summary.get("profit_ratio") else 0, + "total_successful_runs": summary.get("successful_runs", 0), + "total_failed_runs": summary.get("failed_runs", 0) + } + } \ No newline at end of file diff --git a/IncrementalTrader/docs/architecture.md b/IncrementalTrader/docs/architecture.md new file mode 100644 index 0000000..63c2f86 --- /dev/null +++ b/IncrementalTrader/docs/architecture.md @@ -0,0 +1,255 @@ +# Architecture Overview + +## Design Philosophy + +IncrementalTrader is built around the principle of **incremental computation** - processing new data points efficiently without recalculating the entire history. This approach provides significant performance benefits for real-time trading applications. + +### Core Principles + +1. **Modularity**: Clear separation of concerns between strategies, execution, and testing +2. **Efficiency**: Constant memory usage and minimal computational overhead +3. **Extensibility**: Easy to add new strategies, indicators, and features +4. **Reliability**: Robust error handling and comprehensive testing +5. **Simplicity**: Clean APIs that are easy to understand and use + +## System Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ IncrementalTrader β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Strategies β”‚ β”‚ Trader β”‚ β”‚ Backtester β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Base β”‚ β”‚ β€’ Execution β”‚ β”‚ β€’ Configuration β”‚ β”‚ +β”‚ β”‚ β€’ MetaTrend β”‚ β”‚ β€’ Position β”‚ β”‚ β€’ Results β”‚ β”‚ +β”‚ β”‚ β€’ Random β”‚ β”‚ β€’ Tracking β”‚ β”‚ β€’ Optimization β”‚ β”‚ +β”‚ β”‚ β€’ BBRS β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Indicators β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Supertrendβ”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ Bollinger β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β€’ RSI β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Component Details + +### Strategies Module + +The strategies module contains all trading logic and signal generation: + +- **Base Classes**: `IncStrategyBase` provides the foundation for all strategies +- **Timeframe Aggregation**: Built-in support for multiple timeframes +- **Signal Generation**: Standardized signal types (BUY, SELL, HOLD) +- **Incremental Indicators**: Memory-efficient technical indicators + +#### Strategy Lifecycle + +```python +# 1. Initialize strategy with parameters +strategy = MetaTrendStrategy("metatrend", params={"timeframe": "15min"}) + +# 2. Process data points sequentially +for timestamp, ohlcv in data_stream: + signal = strategy.process_data_point(timestamp, ohlcv) + +# 3. Get current state and signals +current_signal = strategy.get_current_signal() +``` + +### Trader Module + +The trader module handles trade execution and position management: + +- **Trade Execution**: Converts strategy signals into trades +- **Position Management**: Tracks USD/coin balances and position state +- **Risk Management**: Stop-loss and take-profit handling +- **Performance Tracking**: Real-time performance metrics + +#### Trading Workflow + +```python +# 1. Create trader with strategy +trader = IncTrader(strategy, initial_usd=10000) + +# 2. Process data and execute trades +for timestamp, ohlcv in data_stream: + trader.process_data_point(timestamp, ohlcv) + +# 3. Get final results +results = trader.get_results() +``` + +### Backtester Module + +The backtester module provides comprehensive testing capabilities: + +- **Single Strategy Testing**: Test individual strategies +- **Parameter Optimization**: Systematic parameter sweeps +- **Multiprocessing**: Parallel execution for faster testing +- **Results Analysis**: Comprehensive performance metrics + +#### Backtesting Process + +```python +# 1. Configure backtest +config = BacktestConfig( + initial_usd=10000, + stop_loss_pct=0.03, + start_date="2024-01-01", + end_date="2024-12-31" +) + +# 2. Run backtest +backtester = IncBacktester() +results = backtester.run_single_strategy(strategy, config) + +# 3. Analyze results +performance = results['performance_metrics'] +``` + +## Data Flow + +### Real-time Processing + +``` +Market Data β†’ Strategy β†’ Signal β†’ Trader β†’ Trade Execution + ↓ ↓ ↓ ↓ ↓ + OHLCV Indicators BUY/SELL Position Portfolio + Data Updates Signals Updates Updates +``` + +### Backtesting Flow + +``` +Historical Data β†’ Backtester β†’ Multiple Traders β†’ Results Aggregation + ↓ ↓ ↓ ↓ + Time Series Strategy Trade Records Performance + OHLCV Instances Collections Metrics +``` + +## Memory Management + +### Incremental Computation + +Traditional batch processing recalculates everything for each new data point: + +```python +# Batch approach - O(n) memory, O(n) computation +def calculate_sma(prices, period): + return [sum(prices[i:i+period])/period for i in range(len(prices)-period+1)] +``` + +Incremental approach maintains only necessary state: + +```python +# Incremental approach - O(1) memory, O(1) computation +class IncrementalSMA: + def __init__(self, period): + self.period = period + self.values = deque(maxlen=period) + self.sum = 0 + + def update(self, value): + if len(self.values) == self.period: + self.sum -= self.values[0] + self.values.append(value) + self.sum += value + + def get_value(self): + return self.sum / len(self.values) if self.values else 0 +``` + +### Benefits + +- **Constant Memory**: Memory usage doesn't grow with data history +- **Fast Updates**: New data points processed in constant time +- **Real-time Capable**: Suitable for live trading applications +- **Scalable**: Performance independent of history length + +## Error Handling + +### Strategy Level + +- Input validation for all parameters +- Graceful handling of missing or invalid data +- Fallback mechanisms for indicator failures + +### Trader Level + +- Position state validation +- Trade execution error handling +- Balance consistency checks + +### System Level + +- Comprehensive logging at all levels +- Exception propagation with context +- Recovery mechanisms for transient failures + +## Performance Characteristics + +### Computational Complexity + +| Operation | Batch Approach | Incremental Approach | +|-----------|----------------|---------------------| +| Memory Usage | O(n) | O(1) | +| Update Time | O(n) | O(1) | +| Initialization | O(1) | O(k) where k = warmup period | + +### Benchmarks + +- **Processing Speed**: ~10x faster than batch recalculation +- **Memory Usage**: ~100x less memory for long histories +- **Latency**: Sub-millisecond processing for new data points + +## Extensibility + +### Adding New Strategies + +1. Inherit from `IncStrategyBase` +2. Implement `process_data_point()` method +3. Return appropriate `IncStrategySignal` objects +4. Register in strategy module + +### Adding New Indicators + +1. Implement incremental update logic +2. Maintain minimal state for calculations +3. Provide consistent API (update/get_value) +4. Add comprehensive tests + +### Integration Points + +- **Data Sources**: Easy to connect different data feeds +- **Execution Engines**: Pluggable trade execution backends +- **Risk Management**: Configurable risk management rules +- **Reporting**: Extensible results and analytics framework + +## Testing Strategy + +### Unit Tests + +- Individual component testing +- Mock data for isolated testing +- Edge case validation + +### Integration Tests + +- End-to-end workflow testing +- Real data validation +- Performance benchmarking + +### Accuracy Validation + +- Comparison with batch implementations +- Historical data validation +- Signal timing verification + +--- + +This architecture provides a solid foundation for building efficient, scalable, and maintainable trading systems while keeping the complexity manageable and the interfaces clean. \ No newline at end of file diff --git a/IncrementalTrader/strategies/__init__.py b/IncrementalTrader/strategies/__init__.py new file mode 100644 index 0000000..29f284a --- /dev/null +++ b/IncrementalTrader/strategies/__init__.py @@ -0,0 +1,59 @@ +""" +Incremental Trading Strategies Framework + +This module provides the strategy framework and implementations for incremental trading. +All strategies inherit from IncStrategyBase and support real-time data processing +with constant memory usage. + +Available Components: +- Base Framework: IncStrategyBase, IncStrategySignal, TimeframeAggregator +- Strategies: MetaTrendStrategy, RandomStrategy, BBRSStrategy +- Indicators: Complete indicator framework in .indicators submodule + +Example: + from IncrementalTrader.strategies import MetaTrendStrategy, IncStrategySignal + + # Create strategy + strategy = MetaTrendStrategy("metatrend", params={"timeframe": "15min"}) + + # Process data + strategy.process_data_point(timestamp, ohlcv_data) + + # Get signals + entry_signal = strategy.get_entry_signal() + if entry_signal.action == "BUY": + print(f"Entry signal with confidence: {entry_signal.confidence}") +""" + +# Base strategy framework (already migrated) +from .base import ( + IncStrategyBase, + IncStrategySignal, + TimeframeAggregator, +) + +# Migrated strategies +from .metatrend import MetaTrendStrategy, IncMetaTrendStrategy +from .random import RandomStrategy, IncRandomStrategy +from .bbrs import BBRSStrategy, IncBBRSStrategy + +# Indicators submodule +from . import indicators + +__all__ = [ + # Base framework + "IncStrategyBase", + "IncStrategySignal", + "TimeframeAggregator", + + # Available strategies + "MetaTrendStrategy", + "IncMetaTrendStrategy", # Compatibility alias + "RandomStrategy", + "IncRandomStrategy", # Compatibility alias + "BBRSStrategy", + "IncBBRSStrategy", # Compatibility alias + + # Indicators submodule + "indicators", +] \ No newline at end of file diff --git a/IncrementalTrader/strategies/base.py b/IncrementalTrader/strategies/base.py new file mode 100644 index 0000000..97a9575 --- /dev/null +++ b/IncrementalTrader/strategies/base.py @@ -0,0 +1,637 @@ +""" +Base classes for the incremental strategy system. + +This module contains the fundamental building blocks for all incremental trading strategies: +- IncStrategySignal: Represents trading signals with confidence and metadata +- IncStrategyBase: Abstract base class that all incremental strategies must inherit from +- TimeframeAggregator: Built-in timeframe aggregation for minute-level data processing + +The incremental approach allows strategies to: +- Process new data points without full recalculation +- Maintain bounded memory usage regardless of data history length +- Provide real-time performance with minimal latency +- Support both initialization and incremental modes +- Accept minute-level data and internally aggregate to any timeframe +""" + +import pandas as pd +from abc import ABC, abstractmethod +from typing import Dict, Optional, List, Union, Any +from collections import deque +import logging +import time + +logger = logging.getLogger(__name__) + + +class IncStrategySignal: + """ + Represents a trading signal from an incremental strategy. + + A signal encapsulates the strategy's recommendation along with confidence + level, optional price target, and additional metadata. + + Attributes: + signal_type (str): Type of signal - "ENTRY", "EXIT", or "HOLD" + confidence (float): Confidence level from 0.0 to 1.0 + price (Optional[float]): Optional specific price for the signal + metadata (Dict): Additional signal data and context + + Example: + # Entry signal with high confidence + signal = IncStrategySignal("ENTRY", confidence=0.8) + + # Exit signal with stop loss price + signal = IncStrategySignal("EXIT", confidence=1.0, price=50000, + metadata={"type": "STOP_LOSS"}) + """ + + def __init__(self, signal_type: str, confidence: float = 1.0, + price: Optional[float] = None, metadata: Optional[Dict] = None): + """ + Initialize a strategy signal. + + Args: + signal_type: Type of signal ("ENTRY", "EXIT", "HOLD") + confidence: Confidence level (0.0 to 1.0) + price: Optional specific price for the signal + metadata: Additional signal data and context + """ + self.signal_type = signal_type + self.confidence = max(0.0, min(1.0, confidence)) # Clamp to [0,1] + self.price = price + self.metadata = metadata or {} + + @classmethod + def BUY(cls, confidence: float = 1.0, price: Optional[float] = None, **metadata): + """Create a BUY signal.""" + return cls("ENTRY", confidence, price, metadata) + + @classmethod + def SELL(cls, confidence: float = 1.0, price: Optional[float] = None, **metadata): + """Create a SELL signal.""" + return cls("EXIT", confidence, price, metadata) + + @classmethod + def HOLD(cls, confidence: float = 0.0, **metadata): + """Create a HOLD signal.""" + return cls("HOLD", confidence, None, metadata) + + def __repr__(self) -> str: + """String representation of the signal.""" + return (f"IncStrategySignal(type={self.signal_type}, " + f"confidence={self.confidence:.2f}, " + f"price={self.price}, metadata={self.metadata})") + + +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. Integrated into IncStrategyBase + to provide consistent minute-level data processing across all strategies. + """ + + 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. + + This method aligns with pandas resampling to ensure consistency + with the original strategy's bar boundaries. + """ + # Use pandas-style resampling alignment + # This ensures bars align to standard boundaries (e.g., 00:00, 00:15, 00:30, 00:45) + freq_str = f'{self.timeframe_minutes}min' + + try: + # Create a temporary series with the timestamp and resample to get the bar start + temp_series = pd.Series([1], index=[timestamp]) + resampled = temp_series.resample(freq_str) + + # Get the first group's name (which is the bar start time) + for bar_start, _ in resampled: + return bar_start + except Exception: + # Fallback to original method if resampling fails + pass + + # Fallback method + 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 IncStrategyBase(ABC): + """ + Abstract base class for all incremental trading strategies. + + This class defines the interface that all incremental strategies must implement: + - get_minimum_buffer_size(): Specify minimum data requirements + - process_data_point(): Process new data points incrementally + - supports_incremental_calculation(): Whether strategy supports incremental mode + - get_entry_signal(): Generate entry signals + - get_exit_signal(): Generate exit signals + + The incremental approach allows strategies to: + - Process new data points without full recalculation + - Maintain bounded memory usage regardless of data history length + - Provide real-time performance with minimal latency + - Support both initialization and incremental modes + - Accept minute-level data and internally aggregate to any timeframe + + New Features: + - Built-in TimeframeAggregator for minute-level data processing + - update_minute_data() method for real-time trading systems + - Automatic timeframe detection and aggregation + - Backward compatibility with existing update() methods + + Attributes: + name (str): Strategy name + weight (float): Strategy weight for combination + params (Dict): Strategy parameters + calculation_mode (str): Current mode ('initialization' or 'incremental') + is_warmed_up (bool): Whether strategy has sufficient data for reliable signals + timeframe_buffers (Dict): Rolling buffers for different timeframes + indicator_states (Dict): Internal indicator calculation states + timeframe_aggregator (TimeframeAggregator): Built-in aggregator for minute data + + Example: + class MyIncStrategy(IncStrategyBase): + def get_minimum_buffer_size(self): + return {"15min": 50} # Strategy works on 15min timeframe + + def process_data_point(self, timestamp, ohlcv_data): + # Process new data incrementally + self._update_indicators(ohlcv_data) + return self.get_current_signal() + + def get_entry_signal(self): + # Generate signal based on current state + if self._should_enter(): + return IncStrategySignal.BUY(confidence=0.8) + return IncStrategySignal.HOLD() + + # Usage with minute-level data: + strategy = MyIncStrategy(params={"timeframe_minutes": 15}) + for minute_data in live_stream: + signal = strategy.process_data_point(minute_data['timestamp'], minute_data) + """ + + def __init__(self, name: str, weight: float = 1.0, params: Optional[Dict] = None): + """ + Initialize the incremental strategy base. + + Args: + name: Strategy name/identifier + weight: Strategy weight for combination (default: 1.0) + params: Strategy-specific parameters + """ + self.name = name + self.weight = weight + self.params = params or {} + + # Calculation state + self._calculation_mode = "initialization" + self._is_warmed_up = False + self._data_points_received = 0 + + # Data management + self._timeframe_buffers = {} + self._timeframe_last_update = {} + self._indicator_states = {} + self._last_signals = {} + self._signal_history = deque(maxlen=100) # Keep last 100 signals + + # Performance tracking + self._performance_metrics = { + 'update_times': deque(maxlen=1000), + 'signal_generation_times': deque(maxlen=1000), + 'state_validation_failures': 0, + 'data_gaps_handled': 0, + 'minute_data_points_processed': 0, + 'timeframe_bars_completed': 0 + } + + # Configuration + self._buffer_size_multiplier = 1.5 # Extra buffer for safety + self._state_validation_enabled = True + self._max_acceptable_gap = pd.Timedelta(minutes=5) + + # Timeframe aggregation + self._primary_timeframe_minutes = self._extract_timeframe_minutes() + self._timeframe_aggregator = None + if self._primary_timeframe_minutes > 1: + self._timeframe_aggregator = TimeframeAggregator(self._primary_timeframe_minutes) + + logger.info(f"Initialized incremental strategy: {self.name}") + + def _extract_timeframe_minutes(self) -> int: + """Extract timeframe in minutes from strategy parameters.""" + timeframe = self.params.get("timeframe", "1min") + + if isinstance(timeframe, str): + if timeframe.endswith("min"): + return int(timeframe[:-3]) + elif timeframe.endswith("h"): + return int(timeframe[:-1]) * 60 + elif timeframe.endswith("d"): + return int(timeframe[:-1]) * 24 * 60 + elif isinstance(timeframe, int): + return timeframe + + # Default to 1 minute + return 1 + + def process_data_point(self, timestamp: pd.Timestamp, ohlcv_data: Dict[str, float]) -> Optional[IncStrategySignal]: + """ + Process a new data point and return signal if generated. + + This is the main entry point for incremental processing. It handles + timeframe aggregation, buffer updates, and signal generation. + + Args: + timestamp: Timestamp of the data point + ohlcv_data: OHLCV data dictionary + + Returns: + IncStrategySignal if a signal is generated, None otherwise + """ + start_time = time.time() + + try: + # Update performance metrics + self._performance_metrics['minute_data_points_processed'] += 1 + self._data_points_received += 1 + + # Handle timeframe aggregation if needed + if self._timeframe_aggregator is not None: + completed_bar = self._timeframe_aggregator.update(timestamp, ohlcv_data) + if completed_bar is not None: + # Process the completed timeframe bar + self._performance_metrics['timeframe_bars_completed'] += 1 + return self._process_timeframe_bar(completed_bar['timestamp'], completed_bar) + else: + # No complete bar yet, return None + return None + else: + # Process minute data directly + return self._process_timeframe_bar(timestamp, ohlcv_data) + + except Exception as e: + logger.error(f"Error processing data point in {self.name}: {e}") + return None + finally: + # Track processing time + processing_time = time.time() - start_time + self._performance_metrics['update_times'].append(processing_time) + + def _process_timeframe_bar(self, timestamp: pd.Timestamp, ohlcv_data: Dict[str, float]) -> Optional[IncStrategySignal]: + """Process a complete timeframe bar and generate signals.""" + # Update timeframe buffers + self._update_timeframe_buffers(ohlcv_data, timestamp) + + # Call strategy-specific calculation + self.calculate_on_data(ohlcv_data, timestamp) + + # Check if strategy is warmed up + if not self._is_warmed_up: + self._check_warmup_status() + + # Generate signal if warmed up + if self._is_warmed_up: + signal_start = time.time() + signal = self.get_current_signal() + signal_time = time.time() - signal_start + self._performance_metrics['signal_generation_times'].append(signal_time) + + # Store signal in history + if signal and signal.signal_type != "HOLD": + self._signal_history.append({ + 'timestamp': timestamp, + 'signal': signal, + 'strategy_state': self.get_current_state_summary() + }) + + return signal + + return None + + def _check_warmup_status(self): + """Check if strategy has enough data to be considered warmed up.""" + min_buffer_sizes = self.get_minimum_buffer_size() + + for timeframe, min_size in min_buffer_sizes.items(): + buffer = self._timeframe_buffers.get(timeframe, deque()) + if len(buffer) < min_size: + return # Not enough data yet + + # All buffers have sufficient data + self._is_warmed_up = True + self._calculation_mode = "incremental" + logger.info(f"Strategy {self.name} is now warmed up after {self._data_points_received} data points") + + def get_current_signal(self) -> IncStrategySignal: + """Get the current signal based on strategy state.""" + # Try entry signal first + entry_signal = self.get_entry_signal() + if entry_signal and entry_signal.signal_type != "HOLD": + return entry_signal + + # Check exit signal + exit_signal = self.get_exit_signal() + if exit_signal and exit_signal.signal_type != "HOLD": + return exit_signal + + # Default to hold + return IncStrategySignal.HOLD() + + def get_current_incomplete_bar(self) -> Optional[Dict[str, float]]: + """Get current incomplete timeframe bar (for debugging).""" + if self._timeframe_aggregator is not None: + return self._timeframe_aggregator.get_current_bar() + return None + + # Properties + @property + def calculation_mode(self) -> str: + """Get current calculation mode.""" + return self._calculation_mode + + @property + def is_warmed_up(self) -> bool: + """Check if strategy is warmed up.""" + return self._is_warmed_up + + # Abstract methods that must be implemented by strategies + @abstractmethod + def get_minimum_buffer_size(self) -> Dict[str, int]: + """ + Get minimum buffer sizes for each timeframe. + + This method specifies how much historical data the strategy needs + for each timeframe to generate reliable signals. + + Returns: + Dict[str, int]: Mapping of timeframe to minimum buffer size + + Example: + return {"15min": 50, "1h": 24} # 50 15min bars, 24 1h bars + """ + pass + + @abstractmethod + def calculate_on_data(self, new_data_point: Dict[str, float], timestamp: pd.Timestamp) -> None: + """ + Process new data point and update internal indicators. + + This method is called for each new timeframe bar and should update + all internal indicators and strategy state incrementally. + + Args: + new_data_point: New OHLCV data point + timestamp: Timestamp of the data point + """ + pass + + @abstractmethod + def supports_incremental_calculation(self) -> bool: + """ + Check if strategy supports incremental calculation. + + Returns: + bool: True if strategy can process data incrementally + """ + pass + + @abstractmethod + def get_entry_signal(self) -> IncStrategySignal: + """ + Generate entry signal based on current strategy state. + + This method should use the current internal state to determine + whether an entry signal should be generated. + + Returns: + IncStrategySignal: Entry signal with confidence level + """ + pass + + @abstractmethod + def get_exit_signal(self) -> IncStrategySignal: + """ + Generate exit signal based on current strategy state. + + This method should use the current internal state to determine + whether an exit signal should be generated. + + Returns: + IncStrategySignal: Exit signal with confidence level + """ + pass + + # Utility methods + def get_confidence(self) -> float: + """ + Get strategy confidence for the current market state. + + Default implementation returns 1.0. Strategies can override + this to provide dynamic confidence based on market conditions. + + Returns: + float: Confidence level (0.0 to 1.0) + """ + return 1.0 + + def reset_calculation_state(self) -> None: + """Reset internal calculation state for reinitialization.""" + self._calculation_mode = "initialization" + self._is_warmed_up = False + self._data_points_received = 0 + self._timeframe_buffers.clear() + self._timeframe_last_update.clear() + self._indicator_states.clear() + self._last_signals.clear() + self._signal_history.clear() + + # Reset timeframe aggregator + if self._timeframe_aggregator is not None: + self._timeframe_aggregator.reset() + + # Reset performance metrics + for key in self._performance_metrics: + if isinstance(self._performance_metrics[key], deque): + self._performance_metrics[key].clear() + else: + self._performance_metrics[key] = 0 + + def get_current_state_summary(self) -> Dict[str, Any]: + """Get summary of current calculation state for debugging.""" + return { + 'strategy_name': self.name, + 'calculation_mode': self._calculation_mode, + 'is_warmed_up': self._is_warmed_up, + 'data_points_received': self._data_points_received, + 'timeframes': list(self._timeframe_buffers.keys()), + 'buffer_sizes': {tf: len(buf) for tf, buf in self._timeframe_buffers.items()}, + 'indicator_states': {name: state.get_state_summary() if hasattr(state, 'get_state_summary') else str(state) + for name, state in self._indicator_states.items()}, + 'last_signals': self._last_signals, + 'timeframe_aggregator': { + 'enabled': self._timeframe_aggregator is not None, + 'primary_timeframe_minutes': self._primary_timeframe_minutes, + 'current_incomplete_bar': self.get_current_incomplete_bar() + }, + 'performance_metrics': { + 'avg_update_time': sum(self._performance_metrics['update_times']) / len(self._performance_metrics['update_times']) + if self._performance_metrics['update_times'] else 0, + 'avg_signal_time': sum(self._performance_metrics['signal_generation_times']) / len(self._performance_metrics['signal_generation_times']) + if self._performance_metrics['signal_generation_times'] else 0, + 'validation_failures': self._performance_metrics['state_validation_failures'], + 'data_gaps_handled': self._performance_metrics['data_gaps_handled'], + 'minute_data_points_processed': self._performance_metrics['minute_data_points_processed'], + 'timeframe_bars_completed': self._performance_metrics['timeframe_bars_completed'] + } + } + + def _update_timeframe_buffers(self, new_data_point: Dict[str, float], timestamp: pd.Timestamp) -> None: + """Update all timeframe buffers with new data point.""" + # Get minimum buffer sizes + min_buffer_sizes = self.get_minimum_buffer_size() + + for timeframe in min_buffer_sizes.keys(): + # Calculate actual buffer size with multiplier + min_size = min_buffer_sizes[timeframe] + actual_buffer_size = int(min_size * self._buffer_size_multiplier) + + # Initialize buffer if needed + if timeframe not in self._timeframe_buffers: + self._timeframe_buffers[timeframe] = deque(maxlen=actual_buffer_size) + self._timeframe_last_update[timeframe] = None + + # Add data point to buffer + data_point = new_data_point.copy() + data_point['timestamp'] = timestamp + self._timeframe_buffers[timeframe].append(data_point) + self._timeframe_last_update[timeframe] = timestamp + + def _get_timeframe_buffer(self, timeframe: str) -> pd.DataFrame: + """Get current buffer for specific timeframe as DataFrame.""" + if timeframe not in self._timeframe_buffers: + return pd.DataFrame() + + buffer_data = list(self._timeframe_buffers[timeframe]) + if not buffer_data: + return pd.DataFrame() + + df = pd.DataFrame(buffer_data) + if 'timestamp' in df.columns: + df = df.set_index('timestamp') + + return df + + def handle_data_gap(self, gap_duration: pd.Timedelta) -> None: + """Handle gaps in data stream.""" + self._performance_metrics['data_gaps_handled'] += 1 + + if gap_duration > self._max_acceptable_gap: + logger.warning(f"Data gap {gap_duration} exceeds maximum acceptable gap {self._max_acceptable_gap}") + self._trigger_reinitialization() + else: + logger.info(f"Handling acceptable data gap: {gap_duration}") + # For small gaps, continue with current state + + def _trigger_reinitialization(self) -> None: + """Trigger strategy reinitialization due to data gap or corruption.""" + logger.info(f"Triggering reinitialization for strategy {self.name}") + self.reset_calculation_state() + + # Compatibility methods for original strategy interface + def get_timeframes(self) -> List[str]: + """Get required timeframes (compatibility method).""" + return list(self.get_minimum_buffer_size().keys()) + + def initialize(self, backtester) -> None: + """Initialize strategy (compatibility method).""" + # This method provides compatibility with the original strategy interface + # The actual initialization happens through the incremental interface + self.initialized = True + logger.info(f"Incremental strategy {self.name} initialized in compatibility mode") + + def __repr__(self) -> str: + """String representation of the strategy.""" + return (f"{self.__class__.__name__}(name={self.name}, " + f"weight={self.weight}, mode={self._calculation_mode}, " + f"warmed_up={self._is_warmed_up}, " + f"data_points={self._data_points_received})") \ No newline at end of file diff --git a/IncrementalTrader/strategies/bbrs.py b/IncrementalTrader/strategies/bbrs.py new file mode 100644 index 0000000..e049cc1 --- /dev/null +++ b/IncrementalTrader/strategies/bbrs.py @@ -0,0 +1,510 @@ +""" +Incremental BBRS Strategy (Bollinger Bands + RSI 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 +""" + +import pandas as pd +import numpy as np +from typing import Dict, Optional, List, Any, Tuple, Union +import logging +from collections import deque + +from .base import IncStrategyBase, IncStrategySignal +from .indicators.bollinger_bands import BollingerBandsState +from .indicators.rsi import RSIState + +logger = logging.getLogger(__name__) + + +class BBRSStrategy(IncStrategyBase): + """ + Incremental BBRS (Bollinger Bands + RSI) strategy implementation. + + This strategy combines Bollinger Bands and RSI indicators to detect market + conditions and generate trading signals. It adapts its behavior based on + market regime detection (trending vs sideways markets). + + The strategy uses different Bollinger Band multipliers and RSI thresholds + for different market regimes: + - Trending markets: Breakout strategy with higher BB multiplier + - Sideways markets: Mean reversion strategy with lower BB multiplier + + Parameters: + timeframe (str): Primary timeframe for analysis (default: "1h") + bb_period (int): Bollinger Bands period (default: 20) + rsi_period (int): RSI period (default: 14) + bb_width_threshold (float): BB width threshold for regime detection (default: 0.05) + trending_bb_multiplier (float): BB multiplier for trending markets (default: 2.5) + sideways_bb_multiplier (float): BB multiplier for sideways markets (default: 1.8) + trending_rsi_thresholds (list): RSI thresholds for trending markets (default: [30, 70]) + sideways_rsi_thresholds (list): RSI thresholds for sideways markets (default: [40, 60]) + squeeze_strategy (bool): Enable squeeze strategy (default: True) + enable_logging (bool): Enable detailed logging (default: False) + + Example: + strategy = BBRSStrategy("bbrs", weight=1.0, params={ + "timeframe": "1h", + "bb_period": 20, + "rsi_period": 14, + "bb_width_threshold": 0.05, + "trending_bb_multiplier": 2.5, + "sideways_bb_multiplier": 1.8, + "trending_rsi_thresholds": [30, 70], + "sideways_rsi_thresholds": [40, 60], + "squeeze_strategy": True + }) + """ + + def __init__(self, name: str = "bbrs", weight: float = 1.0, params: Optional[Dict] = None): + """Initialize the incremental BBRS strategy.""" + super().__init__(name, weight, params) + + # Strategy configuration + self.primary_timeframe = self.params.get("timeframe", "1h") + self.bb_period = self.params.get("bb_period", 20) + self.rsi_period = self.params.get("rsi_period", 14) + self.bb_width_threshold = self.params.get("bb_width_threshold", 0.05) + + # Market regime specific parameters + self.trending_bb_multiplier = self.params.get("trending_bb_multiplier", 2.5) + self.sideways_bb_multiplier = self.params.get("sideways_bb_multiplier", 1.8) + self.trending_rsi_thresholds = tuple(self.params.get("trending_rsi_thresholds", [30, 70])) + self.sideways_rsi_thresholds = tuple(self.params.get("sideways_rsi_thresholds", [40, 60])) + + self.squeeze_strategy = self.params.get("squeeze_strategy", True) + self.enable_logging = self.params.get("enable_logging", False) + + # Configure logging level + if self.enable_logging: + logger.setLevel(logging.DEBUG) + + # 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) + + # Volume tracking for volume analysis + self.volume_history = deque(maxlen=20) # 20-period volume MA + self.volume_sum = 0.0 + self.volume_ma = None + + # Strategy state + self.current_price = None + self.current_volume = None + self.current_market_regime = "trending" # Default to trending + self.last_bb_result = None + self.last_rsi_value = None + + # Signal generation state + self._last_entry_signal = None + self._last_exit_signal = None + self._signal_count = {"entry": 0, "exit": 0} + + # Performance tracking + self._update_count = 0 + self._last_update_time = None + + logger.info(f"BBRSStrategy initialized: timeframe={self.primary_timeframe}, " + f"bb_period={self.bb_period}, rsi_period={self.rsi_period}, " + f"aggregation_enabled={self._timeframe_aggregator is not None}") + + def get_minimum_buffer_size(self) -> Dict[str, int]: + """ + Return minimum data points needed for reliable BBRS calculations. + + Returns: + Dict[str, int]: {timeframe: min_points} mapping + """ + # Need enough data for BB, RSI, and volume MA + min_buffer_size = max(self.bb_period, self.rsi_period, 20) * 2 + 10 + + return {self.primary_timeframe: min_buffer_size} + + def calculate_on_data(self, new_data_point: Dict[str, float], timestamp: pd.Timestamp) -> None: + """ + Process a single new data point incrementally. + + Args: + new_data_point: OHLCV data point {open, high, low, close, volume} + timestamp: Timestamp of the data point + """ + try: + self._update_count += 1 + self._last_update_time = timestamp + + if self.enable_logging: + logger.debug(f"Processing data point {self._update_count} at {timestamp}") + + close_price = float(new_data_point['close']) + volume = float(new_data_point['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 + self.current_market_regime = self._determine_market_regime(bb_reference_result) + + # Select appropriate BB values based on regime + if self.current_market_regime == "sideways": + self.last_bb_result = bb_sideways_result + else: # trending + self.last_bb_result = bb_trending_result + + # Store current state + self.current_price = close_price + self.current_volume = volume + self.last_rsi_value = rsi_value + self._data_points_received += 1 + + # Update warm-up status + if not self._is_warmed_up and self.is_warmed_up(): + self._is_warmed_up = True + logger.info(f"BBRSStrategy warmed up after {self._update_count} data points") + + if self.enable_logging and self._update_count % 10 == 0: + logger.debug(f"BBRS state: price=${close_price:.2f}, " + f"regime={self.current_market_regime}, " + f"rsi={rsi_value:.1f}, " + f"bb_width={bb_reference_result.get('bandwidth', 0):.4f}") + + except Exception as e: + logger.error(f"Error in calculate_on_data: {e}") + raise + + def supports_incremental_calculation(self) -> bool: + """ + Whether strategy supports incremental calculation. + + Returns: + bool: True (this strategy is fully incremental) + """ + return True + + def get_entry_signal(self) -> IncStrategySignal: + """ + Generate entry signal based on BBRS strategy logic. + + Returns: + IncStrategySignal: Entry signal if conditions are met, hold signal otherwise + """ + if not self.is_warmed_up(): + return IncStrategySignal.HOLD() + + # Check for entry condition + if self._check_entry_condition(): + self._signal_count["entry"] += 1 + self._last_entry_signal = { + 'timestamp': self._last_update_time, + 'price': self.current_price, + 'market_regime': self.current_market_regime, + 'rsi': self.last_rsi_value, + 'update_count': self._update_count + } + + if self.enable_logging: + logger.info(f"ENTRY SIGNAL generated at {self._last_update_time} " + f"(signal #{self._signal_count['entry']})") + + return IncStrategySignal.BUY(confidence=1.0, metadata={ + "market_regime": self.current_market_regime, + "rsi": self.last_rsi_value, + "bb_position": self._get_bb_position(), + "signal_count": self._signal_count["entry"] + }) + + return IncStrategySignal.HOLD() + + def get_exit_signal(self) -> IncStrategySignal: + """ + Generate exit signal based on BBRS strategy logic. + + Returns: + IncStrategySignal: Exit signal if conditions are met, hold signal otherwise + """ + if not self.is_warmed_up(): + return IncStrategySignal.HOLD() + + # Check for exit condition + if self._check_exit_condition(): + self._signal_count["exit"] += 1 + self._last_exit_signal = { + 'timestamp': self._last_update_time, + 'price': self.current_price, + 'market_regime': self.current_market_regime, + 'rsi': self.last_rsi_value, + 'update_count': self._update_count + } + + if self.enable_logging: + logger.info(f"EXIT SIGNAL generated at {self._last_update_time} " + f"(signal #{self._signal_count['exit']})") + + return IncStrategySignal.SELL(confidence=1.0, metadata={ + "market_regime": self.current_market_regime, + "rsi": self.last_rsi_value, + "bb_position": self._get_bb_position(), + "signal_count": self._signal_count["exit"] + }) + + return IncStrategySignal.HOLD() + + def get_confidence(self) -> float: + """ + Get strategy confidence based on signal strength. + + Returns: + float: Confidence level (0.0 to 1.0) + """ + if not self.is_warmed_up(): + return 0.0 + + # Higher confidence when signals are clear + if self._check_entry_condition() or self._check_exit_condition(): + return 1.0 + + # Medium confidence during normal operation + return 0.5 + + def _update_volume_tracking(self, volume: float) -> None: + """Update volume moving average tracking.""" + # Update rolling sum + if len(self.volume_history) == 20: # maxlen reached + self.volume_sum -= self.volume_history[0] + + 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) -> bool: + """Check if current volume represents a spike (β‰₯1.5Γ— average).""" + if self.volume_ma is None or self.volume_ma == 0 or self.current_volume is None: + return False + + return self.current_volume >= 1.5 * self.volume_ma + + def _get_bb_position(self) -> str: + """Get current price position relative to Bollinger Bands.""" + if not self.last_bb_result or self.current_price is None: + return 'unknown' + + upper_band = self.last_bb_result['upper_band'] + lower_band = self.last_bb_result['lower_band'] + + if self.current_price > upper_band: + return 'above_upper' + elif self.current_price < lower_band: + return 'below_lower' + else: + return 'between_bands' + + def _check_entry_condition(self) -> bool: + """ + Check if entry condition is met based on market regime. + + Returns: + bool: True if entry condition is met + """ + if not self.is_warmed_up() or self.last_bb_result is None: + return False + + if np.isnan(self.last_rsi_value): + return False + + upper_band = self.last_bb_result['upper_band'] + lower_band = self.last_bb_result['lower_band'] + + if self.current_market_regime == "sideways": + # Sideways market (Mean Reversion) + rsi_low, rsi_high = self.sideways_rsi_thresholds + buy_condition = (self.current_price <= lower_band) and (self.last_rsi_value <= rsi_low) + + if self.squeeze_strategy: + # Add volume contraction filter for sideways markets + volume_contraction = self.current_volume < 0.7 * (self.volume_ma or self.current_volume) + buy_condition = buy_condition and volume_contraction + + return buy_condition + + else: # trending + # Trending market (Breakout Mode) + volume_spike = self._check_volume_spike() + buy_condition = (self.current_price < lower_band) and (self.last_rsi_value < 50) and volume_spike + + return buy_condition + + def _check_exit_condition(self) -> bool: + """ + Check if exit condition is met based on market regime. + + Returns: + bool: True if exit condition is met + """ + if not self.is_warmed_up() or self.last_bb_result is None: + return False + + if np.isnan(self.last_rsi_value): + return False + + upper_band = self.last_bb_result['upper_band'] + lower_band = self.last_bb_result['lower_band'] + + if self.current_market_regime == "sideways": + # Sideways market (Mean Reversion) + rsi_low, rsi_high = self.sideways_rsi_thresholds + sell_condition = (self.current_price >= upper_band) and (self.last_rsi_value >= rsi_high) + + if self.squeeze_strategy: + # Add volume contraction filter for sideways markets + volume_contraction = self.current_volume < 0.7 * (self.volume_ma or self.current_volume) + sell_condition = sell_condition and volume_contraction + + return sell_condition + + else: # trending + # Trending market (Breakout Mode) + volume_spike = self._check_volume_spike() + sell_condition = (self.current_price > upper_band) and (self.last_rsi_value > 50) and volume_spike + + return sell_condition + + 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 reset_calculation_state(self) -> None: + """Reset internal calculation state for reinitialization.""" + super().reset_calculation_state() + + # Reset indicators + self.bb_trending.reset() + self.bb_sideways.reset() + self.bb_reference.reset() + self.rsi.reset() + + # Reset volume tracking + self.volume_history.clear() + self.volume_sum = 0.0 + self.volume_ma = None + + # Reset strategy state + self.current_price = None + self.current_volume = None + self.current_market_regime = "trending" + self.last_bb_result = None + self.last_rsi_value = None + + # Reset signal state + self._last_entry_signal = None + self._last_exit_signal = None + self._signal_count = {"entry": 0, "exit": 0} + + # Reset performance tracking + self._update_count = 0 + self._last_update_time = None + + logger.info("BBRSStrategy state reset") + + def get_current_state_summary(self) -> Dict[str, Any]: + """Get detailed state summary for debugging and monitoring.""" + base_summary = super().get_current_state_summary() + + # Add BBRS-specific state + base_summary.update({ + 'primary_timeframe': self.primary_timeframe, + 'current_price': self.current_price, + 'current_volume': self.current_volume, + 'volume_ma': self.volume_ma, + 'current_market_regime': self.current_market_regime, + 'last_rsi_value': self.last_rsi_value, + 'bb_position': self._get_bb_position(), + 'volume_spike': self._check_volume_spike(), + 'signal_counts': self._signal_count.copy(), + 'update_count': self._update_count, + 'last_update_time': str(self._last_update_time) if self._last_update_time else None, + 'last_entry_signal': self._last_entry_signal, + 'last_exit_signal': self._last_exit_signal, + 'indicators_warmed_up': { + 'bb_trending': self.bb_trending.is_warmed_up(), + 'bb_sideways': self.bb_sideways.is_warmed_up(), + 'bb_reference': self.bb_reference.is_warmed_up(), + 'rsi': self.rsi.is_warmed_up(), + 'volume_tracking': len(self.volume_history) >= 20 + }, + '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 + } + }) + + return base_summary + + def __repr__(self) -> str: + """String representation of the strategy.""" + return (f"BBRSStrategy(timeframe={self.primary_timeframe}, " + f"bb_period={self.bb_period}, rsi_period={self.rsi_period}, " + f"regime={self.current_market_regime}, " + f"warmed_up={self.is_warmed_up()}, " + f"updates={self._update_count})") + + +# Compatibility alias for easier imports +IncBBRSStrategy = BBRSStrategy \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/__init__.py b/IncrementalTrader/strategies/indicators/__init__.py new file mode 100644 index 0000000..553b2b9 --- /dev/null +++ b/IncrementalTrader/strategies/indicators/__init__.py @@ -0,0 +1,91 @@ +""" +Incremental Indicators Framework + +This module provides incremental indicator implementations for real-time trading strategies. +All indicators maintain constant memory usage and provide identical results to traditional +batch calculations. + +Available Indicators: +- Base classes: IndicatorState, SimpleIndicatorState, OHLCIndicatorState +- Moving Averages: MovingAverageState, ExponentialMovingAverageState +- Volatility: ATRState, SimpleATRState +- Trend: SupertrendState, SupertrendCollection +- Bollinger Bands: BollingerBandsState, BollingerBandsOHLCState +- RSI: RSIState, SimpleRSIState + +Example: + from IncrementalTrader.strategies.indicators import SupertrendState, ATRState + + # Create indicators + atr = ATRState(period=14) + supertrend = SupertrendState(period=10, multiplier=3.0) + + # Update with OHLC data + ohlc = {'open': 100, 'high': 105, 'low': 98, 'close': 103} + atr_value = atr.update(ohlc) + st_result = supertrend.update(ohlc) +""" + +# Base indicator classes +from .base import ( + IndicatorState, + SimpleIndicatorState, + OHLCIndicatorState, +) + +# Moving average indicators +from .moving_average import ( + MovingAverageState, + ExponentialMovingAverageState, +) + +# Volatility indicators +from .atr import ( + ATRState, + SimpleATRState, +) + +# Trend indicators +from .supertrend import ( + SupertrendState, + SupertrendCollection, +) + +# Bollinger Bands indicators +from .bollinger_bands import ( + BollingerBandsState, + BollingerBandsOHLCState, +) + +# RSI indicators +from .rsi import ( + RSIState, + SimpleRSIState, +) + +__all__ = [ + # Base classes + "IndicatorState", + "SimpleIndicatorState", + "OHLCIndicatorState", + + # Moving averages + "MovingAverageState", + "ExponentialMovingAverageState", + + # Volatility indicators + "ATRState", + "SimpleATRState", + + # Trend indicators + "SupertrendState", + "SupertrendCollection", + + # Bollinger Bands + "BollingerBandsState", + "BollingerBandsOHLCState", + + # RSI indicators + "RSIState", + "SimpleRSIState", +] \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/atr.py b/IncrementalTrader/strategies/indicators/atr.py new file mode 100644 index 0000000..7bd583f --- /dev/null +++ b/IncrementalTrader/strategies/indicators/atr.py @@ -0,0 +1,254 @@ +""" +Average True Range (ATR) Indicator State + +This module implements incremental ATR calculation that maintains constant memory usage +and provides identical results to traditional batch calculations. ATR is used by +Supertrend and other volatility-based indicators. +""" + +from typing import Dict, Union, Optional +from .base import OHLCIndicatorState +from .moving_average import ExponentialMovingAverageState + + +class ATRState(OHLCIndicatorState): + """ + Incremental Average True Range calculation state. + + ATR measures market volatility by calculating the average of true ranges over + a specified period. True Range is the maximum of: + 1. Current High - Current Low + 2. |Current High - Previous Close| + 3. |Current Low - Previous Close| + + This implementation uses exponential moving average for smoothing, which is + more responsive than simple moving average and requires less memory. + + Attributes: + period (int): The ATR period + ema_state (ExponentialMovingAverageState): EMA state for smoothing true ranges + previous_close (float): Previous period's close price + + Example: + atr = ATRState(period=14) + + # Add OHLC data incrementally + ohlc = {'open': 100, 'high': 105, 'low': 98, 'close': 103} + atr_value = atr.update(ohlc) # Returns current ATR value + + # Check if warmed up + if atr.is_warmed_up(): + current_atr = atr.get_current_value() + """ + + def __init__(self, period: int = 14): + """ + Initialize ATR state. + + Args: + period: Number of periods for ATR calculation (default: 14) + + Raises: + ValueError: If period is not a positive integer + """ + super().__init__(period) + self.ema_state = ExponentialMovingAverageState(period) + self.previous_close = None + self.is_initialized = True + + def update(self, ohlc_data: Dict[str, float]) -> float: + """ + Update ATR with new OHLC data. + + Args: + ohlc_data: Dictionary with 'open', 'high', 'low', 'close' keys + + Returns: + Current ATR value + + Raises: + ValueError: If OHLC data is invalid + TypeError: If ohlc_data is not a dictionary + """ + # Validate input + if not isinstance(ohlc_data, dict): + raise TypeError(f"ohlc_data must be a dictionary, got {type(ohlc_data)}") + + self.validate_input(ohlc_data) + + high = float(ohlc_data['high']) + low = float(ohlc_data['low']) + close = float(ohlc_data['close']) + + # Calculate True Range + if self.previous_close is None: + # First period - True Range is just High - Low + true_range = high - low + else: + # True Range is the maximum of: + # 1. Current High - Current Low + # 2. |Current High - Previous Close| + # 3. |Current Low - Previous Close| + tr1 = high - low + tr2 = abs(high - self.previous_close) + tr3 = abs(low - self.previous_close) + true_range = max(tr1, tr2, tr3) + + # Update EMA with the true range + atr_value = self.ema_state.update(true_range) + + # Store current close as previous close for next calculation + self.previous_close = close + self.values_received += 1 + + # Store current ATR value + self._current_values = {'atr': atr_value} + + return atr_value + + def is_warmed_up(self) -> bool: + """ + Check if ATR has enough data for reliable values. + + Returns: + True if EMA state is warmed up (has enough true range values) + """ + return self.ema_state.is_warmed_up() + + def reset(self) -> None: + """Reset ATR state to initial conditions.""" + self.ema_state.reset() + self.previous_close = None + self.values_received = 0 + self._current_values = {} + + def get_current_value(self) -> Optional[float]: + """ + Get current ATR value without updating. + + Returns: + Current ATR value, or None if not warmed up + """ + if not self.is_warmed_up(): + return None + return self.ema_state.get_current_value() + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'previous_close': self.previous_close, + 'ema_state': self.ema_state.get_state_summary(), + 'current_atr': self.get_current_value() + }) + return base_summary + + +class SimpleATRState(OHLCIndicatorState): + """ + Simple ATR implementation using simple moving average instead of EMA. + + This version uses a simple moving average for smoothing true ranges, + which matches some traditional ATR implementations but requires more memory. + """ + + def __init__(self, period: int = 14): + """ + Initialize simple ATR state. + + Args: + period: Number of periods for ATR calculation (default: 14) + """ + super().__init__(period) + from collections import deque + self.true_ranges = deque(maxlen=period) + self.tr_sum = 0.0 + self.previous_close = None + self.is_initialized = True + + def update(self, ohlc_data: Dict[str, float]) -> float: + """ + Update simple ATR with new OHLC data. + + Args: + ohlc_data: Dictionary with 'open', 'high', 'low', 'close' keys + + Returns: + Current ATR value + """ + # Validate input + if not isinstance(ohlc_data, dict): + raise TypeError(f"ohlc_data must be a dictionary, got {type(ohlc_data)}") + + self.validate_input(ohlc_data) + + high = float(ohlc_data['high']) + low = float(ohlc_data['low']) + close = float(ohlc_data['close']) + + # Calculate True Range + if self.previous_close is None: + true_range = high - low + else: + tr1 = high - low + tr2 = abs(high - self.previous_close) + tr3 = abs(low - self.previous_close) + true_range = max(tr1, tr2, tr3) + + # Update rolling sum + if len(self.true_ranges) == self.period: + self.tr_sum -= self.true_ranges[0] # Remove oldest value + + self.true_ranges.append(true_range) + self.tr_sum += true_range + + # Calculate ATR + atr_value = self.tr_sum / len(self.true_ranges) + + # Store current close as previous close for next calculation + self.previous_close = close + self.values_received += 1 + + # Store current ATR value + self._current_values = {'atr': atr_value} + + return atr_value + + def is_warmed_up(self) -> bool: + """ + Check if simple ATR has enough data for reliable values. + + Returns: + True if we have at least 'period' number of true range values + """ + return len(self.true_ranges) >= self.period + + def reset(self) -> None: + """Reset simple ATR state to initial conditions.""" + self.true_ranges.clear() + self.tr_sum = 0.0 + self.previous_close = None + self.values_received = 0 + self._current_values = {} + + def get_current_value(self) -> Optional[float]: + """ + Get current simple ATR value without updating. + + Returns: + Current ATR value, or None if not warmed up + """ + if not self.is_warmed_up(): + return None + return self.tr_sum / len(self.true_ranges) + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'previous_close': self.previous_close, + 'tr_sum': self.tr_sum, + 'true_ranges_count': len(self.true_ranges), + 'current_atr': self.get_current_value() + }) + return base_summary \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/base.py b/IncrementalTrader/strategies/indicators/base.py new file mode 100644 index 0000000..e3cfb50 --- /dev/null +++ b/IncrementalTrader/strategies/indicators/base.py @@ -0,0 +1,197 @@ +""" +Base Indicator State Class + +This module contains the abstract base class for all incremental indicator states. +All indicator implementations must inherit from IndicatorState and implement +the required methods for incremental calculation. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Union +import numpy as np + + +class IndicatorState(ABC): + """ + Abstract base class for maintaining indicator calculation state. + + This class defines the interface that all incremental indicators must implement. + Indicators maintain their internal state and can be updated incrementally with + new data points, providing constant memory usage and high performance. + + Attributes: + period (int): The period/window size for the indicator + values_received (int): Number of values processed so far + is_initialized (bool): Whether the indicator has been initialized + + Example: + class MyIndicator(IndicatorState): + def __init__(self, period: int): + super().__init__(period) + self._sum = 0.0 + + def update(self, new_value: float) -> float: + self._sum += new_value + self.values_received += 1 + return self._sum / min(self.values_received, self.period) + """ + + def __init__(self, period: int): + """ + Initialize the indicator state. + + Args: + period: The period/window size for the indicator calculation + + Raises: + ValueError: If period is not a positive integer + """ + if not isinstance(period, int) or period <= 0: + raise ValueError(f"Period must be a positive integer, got {period}") + + self.period = period + self.values_received = 0 + self.is_initialized = False + + @abstractmethod + def update(self, new_value: Union[float, Dict[str, float]]) -> Union[float, Dict[str, float]]: + """ + Update indicator with new value and return current indicator value. + + This method processes a new data point and updates the internal state + of the indicator. It returns the current indicator value after the update. + + Args: + new_value: New data point (can be single value or OHLCV dict) + + Returns: + Current indicator value after update (single value or dict) + + Raises: + ValueError: If new_value is invalid or incompatible + """ + pass + + @abstractmethod + def is_warmed_up(self) -> bool: + """ + Check whether indicator has enough data for reliable values. + + Returns: + True if indicator has received enough data points for reliable calculation + """ + pass + + @abstractmethod + def reset(self) -> None: + """ + Reset indicator state to initial conditions. + + This method clears all internal state and resets the indicator + as if it was just initialized. + """ + pass + + @abstractmethod + def get_current_value(self) -> Union[float, Dict[str, float], None]: + """ + Get the current indicator value without updating. + + Returns: + Current indicator value, or None if not warmed up + """ + pass + + def get_state_summary(self) -> Dict[str, Any]: + """ + Get summary of current indicator state for debugging. + + Returns: + Dictionary containing indicator state information + """ + return { + 'indicator_type': self.__class__.__name__, + 'period': self.period, + 'values_received': self.values_received, + 'is_warmed_up': self.is_warmed_up(), + 'is_initialized': self.is_initialized, + 'current_value': self.get_current_value() + } + + def validate_input(self, value: Union[float, Dict[str, float]]) -> None: + """ + Validate input value for the indicator. + + Args: + value: Input value to validate + + Raises: + ValueError: If value is invalid + TypeError: If value type is incorrect + """ + if isinstance(value, (int, float)): + if not np.isfinite(value): + raise ValueError(f"Input value must be finite, got {value}") + elif isinstance(value, dict): + required_keys = ['open', 'high', 'low', 'close'] + for key in required_keys: + if key not in value: + raise ValueError(f"OHLCV dict missing required key: {key}") + if not np.isfinite(value[key]): + raise ValueError(f"OHLCV value for {key} must be finite, got {value[key]}") + # Validate OHLC relationships + if not (value['low'] <= value['open'] <= value['high'] and + value['low'] <= value['close'] <= value['high']): + raise ValueError(f"Invalid OHLC relationships: {value}") + else: + raise TypeError(f"Input value must be float or OHLCV dict, got {type(value)}") + + def __repr__(self) -> str: + """String representation of the indicator state.""" + return (f"{self.__class__.__name__}(period={self.period}, " + f"values_received={self.values_received}, " + f"warmed_up={self.is_warmed_up()})") + + +class SimpleIndicatorState(IndicatorState): + """ + Base class for simple single-value indicators. + + This class provides common functionality for indicators that work with + single float values and maintain a simple rolling calculation. + """ + + def __init__(self, period: int): + """Initialize simple indicator state.""" + super().__init__(period) + self._current_value = None + + def get_current_value(self) -> Optional[float]: + """Get current indicator value.""" + return self._current_value if self.is_warmed_up() else None + + def is_warmed_up(self) -> bool: + """Check if indicator is warmed up.""" + return self.values_received >= self.period + + +class OHLCIndicatorState(IndicatorState): + """ + Base class for OHLC-based indicators. + + This class provides common functionality for indicators that work with + OHLC data (Open, High, Low, Close) and may return multiple values. + """ + + def __init__(self, period: int): + """Initialize OHLC indicator state.""" + super().__init__(period) + self._current_values = {} + + def get_current_value(self) -> Optional[Dict[str, float]]: + """Get current indicator values.""" + return self._current_values.copy() if self.is_warmed_up() else None + + def is_warmed_up(self) -> bool: + """Check if indicator is warmed up.""" + return self.values_received >= self.period \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/bollinger_bands.py b/IncrementalTrader/strategies/indicators/bollinger_bands.py new file mode 100644 index 0000000..4cb08bf --- /dev/null +++ b/IncrementalTrader/strategies/indicators/bollinger_bands.py @@ -0,0 +1,325 @@ +""" +Bollinger Bands Indicator State + +This module implements incremental Bollinger Bands calculation that maintains constant memory usage +and provides identical results to traditional batch calculations. Used by the BBRSStrategy. +""" + +from typing import Dict, Union, Optional +from collections import deque +import math +from .base import OHLCIndicatorState +from .moving_average import MovingAverageState + + +class BollingerBandsState(OHLCIndicatorState): + """ + Incremental Bollinger Bands calculation state. + + Bollinger Bands consist of: + - Middle Band: Simple Moving Average of close prices + - Upper Band: Middle Band + (Standard Deviation * multiplier) + - Lower Band: Middle Band - (Standard Deviation * multiplier) + + This implementation maintains a rolling window for standard deviation calculation + while using the MovingAverageState for the middle band. + + Attributes: + period (int): Period for moving average and standard deviation + std_dev_multiplier (float): Multiplier for standard deviation + ma_state (MovingAverageState): Moving average state for middle band + close_values (deque): Rolling window of close prices for std dev calculation + close_sum_sq (float): Sum of squared close values for variance calculation + + Example: + bb = BollingerBandsState(period=20, std_dev_multiplier=2.0) + + # Add price data incrementally + result = bb.update(103.5) # Close price + upper_band = result['upper_band'] + middle_band = result['middle_band'] + lower_band = result['lower_band'] + bandwidth = result['bandwidth'] + """ + + def __init__(self, period: int = 20, std_dev_multiplier: float = 2.0): + """ + Initialize Bollinger Bands state. + + Args: + period: Period for moving average and standard deviation (default: 20) + std_dev_multiplier: Multiplier for standard deviation (default: 2.0) + + Raises: + ValueError: If period is not positive or multiplier is not positive + """ + super().__init__(period) + + if std_dev_multiplier <= 0: + raise ValueError(f"Standard deviation multiplier must be positive, got {std_dev_multiplier}") + + self.std_dev_multiplier = std_dev_multiplier + self.ma_state = MovingAverageState(period) + + # For incremental standard deviation calculation + self.close_values = deque(maxlen=period) + self.close_sum_sq = 0.0 # Sum of squared values + + self.is_initialized = True + + def update(self, close_price: Union[float, int]) -> Dict[str, float]: + """ + Update Bollinger Bands with new close price. + + Args: + close_price: New closing price + + Returns: + Dictionary with 'upper_band', 'middle_band', 'lower_band', 'bandwidth', 'std_dev' + + Raises: + ValueError: If close_price is not finite + TypeError: If close_price is not numeric + """ + # Validate input + if not isinstance(close_price, (int, float)): + raise TypeError(f"close_price must be numeric, got {type(close_price)}") + + self.validate_input(close_price) + + close_price = float(close_price) + + # Update moving average (middle band) + middle_band = self.ma_state.update(close_price) + + # Update rolling window for standard deviation + if len(self.close_values) == self.period: + # Remove oldest value from sum of squares + old_value = self.close_values[0] + self.close_sum_sq -= old_value * old_value + + # Add new value + self.close_values.append(close_price) + self.close_sum_sq += close_price * close_price + + # Calculate standard deviation + n = len(self.close_values) + if n < 2: + # Not enough data for standard deviation + std_dev = 0.0 + else: + # Incremental variance calculation: Var = (sum_sq - n*mean^2) / (n-1) + mean = middle_band + variance = (self.close_sum_sq - n * mean * mean) / (n - 1) + std_dev = math.sqrt(max(variance, 0.0)) # Ensure non-negative + + # Calculate bands + upper_band = middle_band + (self.std_dev_multiplier * std_dev) + lower_band = middle_band - (self.std_dev_multiplier * std_dev) + + # Calculate bandwidth (normalized band width) + if middle_band != 0: + bandwidth = (upper_band - lower_band) / middle_band + else: + bandwidth = 0.0 + + self.values_received += 1 + + # Store current values + result = { + 'upper_band': upper_band, + 'middle_band': middle_band, + 'lower_band': lower_band, + 'bandwidth': bandwidth, + 'std_dev': std_dev + } + + self._current_values = result + return result + + def is_warmed_up(self) -> bool: + """ + Check if Bollinger Bands has enough data for reliable values. + + Returns: + True if we have at least 'period' number of values + """ + return self.ma_state.is_warmed_up() + + def reset(self) -> None: + """Reset Bollinger Bands state to initial conditions.""" + self.ma_state.reset() + self.close_values.clear() + self.close_sum_sq = 0.0 + self.values_received = 0 + self._current_values = {} + + def get_current_value(self) -> Optional[Dict[str, float]]: + """ + Get current Bollinger Bands values without updating. + + Returns: + Dictionary with current BB values, or None if not warmed up + """ + if not self.is_warmed_up(): + return None + return self._current_values.copy() if self._current_values else None + + def get_squeeze_status(self, squeeze_threshold: float = 0.05) -> bool: + """ + Check if Bollinger Bands are in a squeeze condition. + + Args: + squeeze_threshold: Bandwidth threshold for squeeze detection + + Returns: + True if bandwidth is below threshold (squeeze condition) + """ + if not self.is_warmed_up() or not self._current_values: + return False + + bandwidth = self._current_values.get('bandwidth', float('inf')) + return bandwidth < squeeze_threshold + + def get_position_relative_to_bands(self, current_price: float) -> str: + """ + Get current price position relative to Bollinger Bands. + + Args: + current_price: Current price to evaluate + + Returns: + 'above_upper', 'between_bands', 'below_lower', or 'unknown' + """ + if not self.is_warmed_up() or not self._current_values: + return 'unknown' + + upper_band = self._current_values['upper_band'] + lower_band = self._current_values['lower_band'] + + if current_price > upper_band: + return 'above_upper' + elif current_price < lower_band: + return 'below_lower' + else: + return 'between_bands' + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'std_dev_multiplier': self.std_dev_multiplier, + 'close_values_count': len(self.close_values), + 'close_sum_sq': self.close_sum_sq, + 'ma_state': self.ma_state.get_state_summary(), + 'current_squeeze': self.get_squeeze_status() if self.is_warmed_up() else None + }) + return base_summary + + +class BollingerBandsOHLCState(OHLCIndicatorState): + """ + Bollinger Bands implementation that works with OHLC data. + + This version can calculate Bollinger Bands based on different price types + (close, typical price, etc.) and provides additional OHLC-based analysis. + """ + + def __init__(self, period: int = 20, std_dev_multiplier: float = 2.0, price_type: str = 'close'): + """ + Initialize OHLC Bollinger Bands state. + + Args: + period: Period for calculation + std_dev_multiplier: Standard deviation multiplier + price_type: Price type to use ('close', 'typical', 'median', 'weighted') + """ + super().__init__(period) + + if price_type not in ['close', 'typical', 'median', 'weighted']: + raise ValueError(f"Invalid price_type: {price_type}") + + self.std_dev_multiplier = std_dev_multiplier + self.price_type = price_type + self.bb_state = BollingerBandsState(period, std_dev_multiplier) + self.is_initialized = True + + def _extract_price(self, ohlc_data: Dict[str, float]) -> float: + """Extract price based on price_type setting.""" + if self.price_type == 'close': + return ohlc_data['close'] + elif self.price_type == 'typical': + return (ohlc_data['high'] + ohlc_data['low'] + ohlc_data['close']) / 3.0 + elif self.price_type == 'median': + return (ohlc_data['high'] + ohlc_data['low']) / 2.0 + elif self.price_type == 'weighted': + return (ohlc_data['high'] + ohlc_data['low'] + 2 * ohlc_data['close']) / 4.0 + else: + return ohlc_data['close'] + + def update(self, ohlc_data: Dict[str, float]) -> Dict[str, float]: + """ + Update Bollinger Bands with OHLC data. + + Args: + ohlc_data: Dictionary with OHLC data + + Returns: + Dictionary with Bollinger Bands values plus OHLC analysis + """ + # Validate input + if not isinstance(ohlc_data, dict): + raise TypeError(f"ohlc_data must be a dictionary, got {type(ohlc_data)}") + + self.validate_input(ohlc_data) + + # Extract price based on type + price = self._extract_price(ohlc_data) + + # Update underlying BB state + bb_result = self.bb_state.update(price) + + # Add OHLC-specific analysis + high = ohlc_data['high'] + low = ohlc_data['low'] + close = ohlc_data['close'] + + # Check if high/low touched bands + upper_band = bb_result['upper_band'] + lower_band = bb_result['lower_band'] + + bb_result.update({ + 'high_above_upper': high > upper_band, + 'low_below_lower': low < lower_band, + 'close_position': self.bb_state.get_position_relative_to_bands(close), + 'price_type': self.price_type, + 'extracted_price': price + }) + + self.values_received += 1 + self._current_values = bb_result + + return bb_result + + def is_warmed_up(self) -> bool: + """Check if OHLC Bollinger Bands is warmed up.""" + return self.bb_state.is_warmed_up() + + def reset(self) -> None: + """Reset OHLC Bollinger Bands state.""" + self.bb_state.reset() + self.values_received = 0 + self._current_values = {} + + def get_current_value(self) -> Optional[Dict[str, float]]: + """Get current OHLC Bollinger Bands values.""" + return self.bb_state.get_current_value() + + def get_state_summary(self) -> dict: + """Get detailed state summary.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'price_type': self.price_type, + 'bb_state': self.bb_state.get_state_summary() + }) + return base_summary \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/moving_average.py b/IncrementalTrader/strategies/indicators/moving_average.py new file mode 100644 index 0000000..768e0a4 --- /dev/null +++ b/IncrementalTrader/strategies/indicators/moving_average.py @@ -0,0 +1,228 @@ +""" +Moving Average Indicator State + +This module implements incremental moving average calculation that maintains +constant memory usage and provides identical results to traditional batch calculations. +""" + +from collections import deque +from typing import Union +from .base import SimpleIndicatorState + + +class MovingAverageState(SimpleIndicatorState): + """ + Incremental moving average calculation state. + + This class maintains the state for calculating a simple moving average + incrementally. It uses a rolling window approach with constant memory usage. + + Attributes: + period (int): The moving average period + values (deque): Rolling window of values (max length = period) + sum (float): Current sum of values in the window + + Example: + ma = MovingAverageState(period=20) + + # Add values incrementally + ma_value = ma.update(100.0) # Returns current MA value + ma_value = ma.update(105.0) # Updates and returns new MA value + + # Check if warmed up (has enough values) + if ma.is_warmed_up(): + current_ma = ma.get_current_value() + """ + + def __init__(self, period: int): + """ + Initialize moving average state. + + Args: + period: Number of periods for the moving average + + Raises: + ValueError: If period is not a positive integer + """ + super().__init__(period) + self.values = deque(maxlen=period) + self.sum = 0.0 + self.is_initialized = True + + def update(self, new_value: Union[float, int]) -> float: + """ + Update moving average with new value. + + Args: + new_value: New price/value to add to the moving average + + Returns: + Current moving average value + + Raises: + ValueError: If new_value is not finite + TypeError: If new_value is not numeric + """ + # Validate input + if not isinstance(new_value, (int, float)): + raise TypeError(f"new_value must be numeric, got {type(new_value)}") + + self.validate_input(new_value) + + # If deque is at max capacity, subtract the value being removed + if len(self.values) == self.period: + self.sum -= self.values[0] # Will be automatically removed by deque + + # Add new value + self.values.append(float(new_value)) + self.sum += float(new_value) + self.values_received += 1 + + # Calculate current moving average + current_count = len(self.values) + self._current_value = self.sum / current_count + + return self._current_value + + def is_warmed_up(self) -> bool: + """ + Check if moving average has enough data for reliable values. + + Returns: + True if we have at least 'period' number of values + """ + return len(self.values) >= self.period + + def reset(self) -> None: + """Reset moving average state to initial conditions.""" + self.values.clear() + self.sum = 0.0 + self.values_received = 0 + self._current_value = None + + def get_current_value(self) -> Union[float, None]: + """ + Get current moving average value without updating. + + Returns: + Current moving average value, or None if not enough data + """ + if len(self.values) == 0: + return None + return self.sum / len(self.values) + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'window_size': len(self.values), + 'sum': self.sum, + 'values_in_window': list(self.values) if len(self.values) <= 10 else f"[{len(self.values)} values]" + }) + return base_summary + + +class ExponentialMovingAverageState(SimpleIndicatorState): + """ + Incremental exponential moving average calculation state. + + This class maintains the state for calculating an exponential moving average (EMA) + incrementally. EMA gives more weight to recent values and requires minimal memory. + + Attributes: + period (int): The EMA period (used to calculate smoothing factor) + alpha (float): Smoothing factor (2 / (period + 1)) + ema_value (float): Current EMA value + + Example: + ema = ExponentialMovingAverageState(period=20) + + # Add values incrementally + ema_value = ema.update(100.0) # Returns current EMA value + ema_value = ema.update(105.0) # Updates and returns new EMA value + """ + + def __init__(self, period: int): + """ + Initialize exponential moving average state. + + Args: + period: Number of periods for the EMA (used to calculate alpha) + + Raises: + ValueError: If period is not a positive integer + """ + super().__init__(period) + self.alpha = 2.0 / (period + 1) # Smoothing factor + self.ema_value = None + self.is_initialized = True + + def update(self, new_value: Union[float, int]) -> float: + """ + Update exponential moving average with new value. + + Args: + new_value: New price/value to add to the EMA + + Returns: + Current EMA value + + Raises: + ValueError: If new_value is not finite + TypeError: If new_value is not numeric + """ + # Validate input + if not isinstance(new_value, (int, float)): + raise TypeError(f"new_value must be numeric, got {type(new_value)}") + + self.validate_input(new_value) + + new_value = float(new_value) + + if self.ema_value is None: + # First value - initialize EMA + self.ema_value = new_value + else: + # EMA formula: EMA = alpha * new_value + (1 - alpha) * previous_EMA + self.ema_value = self.alpha * new_value + (1 - self.alpha) * self.ema_value + + self.values_received += 1 + self._current_value = self.ema_value + + return self.ema_value + + def is_warmed_up(self) -> bool: + """ + Check if EMA has enough data for reliable values. + + For EMA, we consider it warmed up after receiving 'period' number of values, + though it starts producing values immediately. + + Returns: + True if we have received at least 'period' number of values + """ + return self.values_received >= self.period + + def reset(self) -> None: + """Reset EMA state to initial conditions.""" + self.ema_value = None + self.values_received = 0 + self._current_value = None + + def get_current_value(self) -> Union[float, None]: + """ + Get current EMA value without updating. + + Returns: + Current EMA value, or None if no values received yet + """ + return self.ema_value + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'alpha': self.alpha, + 'ema_value': self.ema_value + }) + return base_summary \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/rsi.py b/IncrementalTrader/strategies/indicators/rsi.py new file mode 100644 index 0000000..490b865 --- /dev/null +++ b/IncrementalTrader/strategies/indicators/rsi.py @@ -0,0 +1,289 @@ +""" +RSI (Relative Strength Index) Indicator State + +This module implements incremental RSI calculation that maintains constant memory usage +and provides identical results to traditional batch calculations. +""" + +from typing import Union, Optional +from .base import SimpleIndicatorState +from .moving_average import ExponentialMovingAverageState + + +class RSIState(SimpleIndicatorState): + """ + Incremental RSI calculation state using Wilder's smoothing. + + RSI measures the speed and magnitude of price changes to evaluate overbought + or oversold conditions. It oscillates between 0 and 100. + + RSI = 100 - (100 / (1 + RS)) + where RS = Average Gain / Average Loss over the specified period + + This implementation uses Wilder's smoothing (alpha = 1/period) to match + the original pandas implementation exactly. + + Attributes: + period (int): The RSI period (typically 14) + alpha (float): Wilder's smoothing factor (1/period) + avg_gain (float): Current average gain + avg_loss (float): Current average loss + previous_close (float): Previous period's close price + + Example: + rsi = RSIState(period=14) + + # Add price data incrementally + rsi_value = rsi.update(100.0) # Returns current RSI value + rsi_value = rsi.update(105.0) # Updates and returns new RSI value + + # Check if warmed up + if rsi.is_warmed_up(): + current_rsi = rsi.get_current_value() + """ + + def __init__(self, period: int = 14): + """ + Initialize RSI state. + + Args: + period: Number of periods for RSI calculation (default: 14) + + Raises: + ValueError: If period is not a positive integer + """ + super().__init__(period) + self.alpha = 1.0 / period # Wilder's smoothing factor + self.avg_gain = None + self.avg_loss = None + self.previous_close = None + self.is_initialized = True + + def update(self, new_close: Union[float, int]) -> float: + """ + Update RSI with new close price using Wilder's smoothing. + + Args: + new_close: New closing price + + Returns: + Current RSI value (0-100), or NaN if not warmed up + + Raises: + ValueError: If new_close is not finite + TypeError: If new_close is not numeric + """ + # Validate input - accept numpy types as well + import numpy as np + if not isinstance(new_close, (int, float, np.integer, np.floating)): + raise TypeError(f"new_close must be numeric, got {type(new_close)}") + + self.validate_input(float(new_close)) + + new_close = float(new_close) + + if self.previous_close is None: + # First value - no gain/loss to calculate + self.previous_close = new_close + self.values_received += 1 + # Return NaN until warmed up (matches original behavior) + self._current_value = float('nan') + return self._current_value + + # Calculate price change + price_change = new_close - self.previous_close + + # Separate gains and losses + gain = max(price_change, 0.0) + loss = max(-price_change, 0.0) + + if self.avg_gain is None: + # Initialize with first gain/loss + self.avg_gain = gain + self.avg_loss = loss + else: + # Wilder's smoothing: avg = alpha * new_value + (1 - alpha) * previous_avg + self.avg_gain = self.alpha * gain + (1 - self.alpha) * self.avg_gain + self.avg_loss = self.alpha * loss + (1 - self.alpha) * self.avg_loss + + # Calculate RSI only if warmed up + # RSI should start when we have 'period' price changes (not including the first value) + if self.values_received > self.period: + if self.avg_loss == 0.0: + # Avoid division by zero - all gains, no losses + if self.avg_gain > 0: + rsi_value = 100.0 + else: + rsi_value = 50.0 # Neutral when both are zero + else: + rs = self.avg_gain / self.avg_loss + rsi_value = 100.0 - (100.0 / (1.0 + rs)) + else: + # Not warmed up yet - return NaN + rsi_value = float('nan') + + # Store state + self.previous_close = new_close + self.values_received += 1 + self._current_value = rsi_value + + return rsi_value + + def is_warmed_up(self) -> bool: + """ + Check if RSI has enough data for reliable values. + + Returns: + True if we have enough price changes for RSI calculation + """ + return self.values_received > self.period + + def reset(self) -> None: + """Reset RSI state to initial conditions.""" + self.alpha = 1.0 / self.period + self.avg_gain = None + self.avg_loss = None + self.previous_close = None + self.values_received = 0 + self._current_value = None + + def get_current_value(self) -> Optional[float]: + """ + Get current RSI value without updating. + + Returns: + Current RSI value (0-100), or None if not enough data + """ + if not self.is_warmed_up(): + return None + return self._current_value + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'alpha': self.alpha, + 'previous_close': self.previous_close, + 'avg_gain': self.avg_gain, + 'avg_loss': self.avg_loss, + 'current_rsi': self.get_current_value() + }) + return base_summary + + +class SimpleRSIState(SimpleIndicatorState): + """ + Simple RSI implementation using simple moving averages instead of EMAs. + + This version uses simple moving averages for gain and loss smoothing, + which matches traditional RSI implementations but requires more memory. + """ + + def __init__(self, period: int = 14): + """ + Initialize simple RSI state. + + Args: + period: Number of periods for RSI calculation (default: 14) + """ + super().__init__(period) + from collections import deque + self.gains = deque(maxlen=period) + self.losses = deque(maxlen=period) + self.gain_sum = 0.0 + self.loss_sum = 0.0 + self.previous_close = None + self.is_initialized = True + + def update(self, new_close: Union[float, int]) -> float: + """ + Update simple RSI with new close price. + + Args: + new_close: New closing price + + Returns: + Current RSI value (0-100) + """ + # Validate input + if not isinstance(new_close, (int, float)): + raise TypeError(f"new_close must be numeric, got {type(new_close)}") + + self.validate_input(new_close) + + new_close = float(new_close) + + if self.previous_close is None: + # First value + self.previous_close = new_close + self.values_received += 1 + self._current_value = 50.0 + return self._current_value + + # Calculate price change + price_change = new_close - self.previous_close + gain = max(price_change, 0.0) + loss = max(-price_change, 0.0) + + # Update rolling sums + if len(self.gains) == self.period: + self.gain_sum -= self.gains[0] + self.loss_sum -= self.losses[0] + + self.gains.append(gain) + self.losses.append(loss) + self.gain_sum += gain + self.loss_sum += loss + + # Calculate RSI + if len(self.gains) == 0: + rsi_value = 50.0 + else: + avg_gain = self.gain_sum / len(self.gains) + avg_loss = self.loss_sum / len(self.losses) + + if avg_loss == 0.0: + rsi_value = 100.0 + else: + rs = avg_gain / avg_loss + rsi_value = 100.0 - (100.0 / (1.0 + rs)) + + # Store state + self.previous_close = new_close + self.values_received += 1 + self._current_value = rsi_value + + return rsi_value + + def is_warmed_up(self) -> bool: + """Check if simple RSI is warmed up.""" + return len(self.gains) >= self.period + + def reset(self) -> None: + """Reset simple RSI state.""" + self.gains.clear() + self.losses.clear() + self.gain_sum = 0.0 + self.loss_sum = 0.0 + self.previous_close = None + self.values_received = 0 + self._current_value = None + + def get_current_value(self) -> Optional[float]: + """Get current simple RSI value.""" + if self.values_received == 0: + return None + return self._current_value + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'previous_close': self.previous_close, + 'gains_window_size': len(self.gains), + 'losses_window_size': len(self.losses), + 'gain_sum': self.gain_sum, + 'loss_sum': self.loss_sum, + 'current_rsi': self.get_current_value() + }) + return base_summary \ No newline at end of file diff --git a/IncrementalTrader/strategies/indicators/supertrend.py b/IncrementalTrader/strategies/indicators/supertrend.py new file mode 100644 index 0000000..cad1e4b --- /dev/null +++ b/IncrementalTrader/strategies/indicators/supertrend.py @@ -0,0 +1,316 @@ +""" +Supertrend Indicator State + +This module implements incremental Supertrend calculation that maintains constant memory usage +and provides identical results to traditional batch calculations. Supertrend is used by +the DefaultStrategy for trend detection. +""" + +from typing import Dict, Union, Optional +from .base import OHLCIndicatorState +from .atr import ATRState + + +class SupertrendState(OHLCIndicatorState): + """ + Incremental Supertrend calculation state. + + Supertrend is a trend-following indicator that uses Average True Range (ATR) + to calculate dynamic support and resistance levels. It provides clear trend + direction signals: +1 for uptrend, -1 for downtrend. + + The calculation involves: + 1. Calculate ATR for the given period + 2. Calculate basic upper and lower bands using ATR and multiplier + 3. Calculate final upper and lower bands with trend logic + 4. Determine trend direction based on price vs bands + + Attributes: + period (int): ATR period for Supertrend calculation + multiplier (float): Multiplier for ATR in band calculation + atr_state (ATRState): ATR calculation state + previous_close (float): Previous period's close price + previous_trend (int): Previous trend direction (+1 or -1) + final_upper_band (float): Current final upper band + final_lower_band (float): Current final lower band + + Example: + supertrend = SupertrendState(period=10, multiplier=3.0) + + # Add OHLC data incrementally + ohlc = {'open': 100, 'high': 105, 'low': 98, 'close': 103} + result = supertrend.update(ohlc) + trend = result['trend'] # +1 or -1 + supertrend_value = result['supertrend'] # Supertrend line value + """ + + def __init__(self, period: int = 10, multiplier: float = 3.0): + """ + Initialize Supertrend state. + + Args: + period: ATR period for Supertrend calculation (default: 10) + multiplier: Multiplier for ATR in band calculation (default: 3.0) + + Raises: + ValueError: If period is not positive or multiplier is not positive + """ + super().__init__(period) + + if multiplier <= 0: + raise ValueError(f"Multiplier must be positive, got {multiplier}") + + self.multiplier = multiplier + self.atr_state = ATRState(period) + + # State variables + self.previous_close = None + self.previous_trend = None # Don't assume initial trend, let first calculation determine it + self.final_upper_band = None + self.final_lower_band = None + + # Current values + self.current_trend = None + self.current_supertrend = None + + self.is_initialized = True + + def update(self, ohlc_data: Dict[str, float]) -> Dict[str, float]: + """ + Update Supertrend with new OHLC data. + + Args: + ohlc_data: Dictionary with 'open', 'high', 'low', 'close' keys + + Returns: + Dictionary with 'trend', 'supertrend', 'upper_band', 'lower_band' keys + + Raises: + ValueError: If OHLC data is invalid + TypeError: If ohlc_data is not a dictionary + """ + # Validate input + if not isinstance(ohlc_data, dict): + raise TypeError(f"ohlc_data must be a dictionary, got {type(ohlc_data)}") + + self.validate_input(ohlc_data) + + high = float(ohlc_data['high']) + low = float(ohlc_data['low']) + close = float(ohlc_data['close']) + + # Update ATR + atr_value = self.atr_state.update(ohlc_data) + + # Calculate HL2 (typical price) + hl2 = (high + low) / 2.0 + + # Calculate basic upper and lower bands + basic_upper_band = hl2 + (self.multiplier * atr_value) + basic_lower_band = hl2 - (self.multiplier * atr_value) + + # Calculate final upper band + if self.final_upper_band is None or basic_upper_band < self.final_upper_band or self.previous_close > self.final_upper_band: + final_upper_band = basic_upper_band + else: + final_upper_band = self.final_upper_band + + # Calculate final lower band + if self.final_lower_band is None or basic_lower_band > self.final_lower_band or self.previous_close < self.final_lower_band: + final_lower_band = basic_lower_band + else: + final_lower_band = self.final_lower_band + + # Determine trend + if self.previous_close is None: + # First calculation - match original logic + # If close <= upper_band, trend is -1 (downtrend), else trend is 1 (uptrend) + trend = -1 if close <= basic_upper_band else 1 + else: + # Trend logic for subsequent calculations + if self.previous_trend == 1 and close <= final_lower_band: + trend = -1 + elif self.previous_trend == -1 and close >= final_upper_band: + trend = 1 + else: + trend = self.previous_trend + + # Calculate Supertrend value + if trend == 1: + supertrend_value = final_lower_band + else: + supertrend_value = final_upper_band + + # Store current state + self.previous_close = close + self.previous_trend = trend + self.final_upper_band = final_upper_band + self.final_lower_band = final_lower_band + self.current_trend = trend + self.current_supertrend = supertrend_value + self.values_received += 1 + + # Prepare result + result = { + 'trend': trend, + 'supertrend': supertrend_value, + 'upper_band': final_upper_band, + 'lower_band': final_lower_band, + 'atr': atr_value + } + + self._current_values = result + return result + + def is_warmed_up(self) -> bool: + """ + Check if Supertrend has enough data for reliable values. + + Returns: + True if ATR state is warmed up + """ + return self.atr_state.is_warmed_up() + + def reset(self) -> None: + """Reset Supertrend state to initial conditions.""" + self.atr_state.reset() + self.previous_close = None + self.previous_trend = None + self.final_upper_band = None + self.final_lower_band = None + self.current_trend = None + self.current_supertrend = None + self.values_received = 0 + self._current_values = {} + + def get_current_value(self) -> Optional[Dict[str, float]]: + """ + Get current Supertrend values without updating. + + Returns: + Dictionary with current Supertrend values, or None if not warmed up + """ + if not self.is_warmed_up(): + return None + return self._current_values.copy() if self._current_values else None + + def get_current_trend(self) -> int: + """ + Get current trend direction. + + Returns: + Current trend (+1 for uptrend, -1 for downtrend, 0 if not warmed up) + """ + return self.current_trend if self.current_trend is not None else 0 + + def get_current_supertrend_value(self) -> Optional[float]: + """ + Get current Supertrend line value. + + Returns: + Current Supertrend value, or None if not warmed up + """ + return self.current_supertrend + + def get_state_summary(self) -> dict: + """Get detailed state summary for debugging.""" + base_summary = super().get_state_summary() + base_summary.update({ + 'multiplier': self.multiplier, + 'previous_close': self.previous_close, + 'previous_trend': self.previous_trend, + 'current_trend': self.current_trend, + 'current_supertrend': self.current_supertrend, + 'final_upper_band': self.final_upper_band, + 'final_lower_band': self.final_lower_band, + 'atr_state': self.atr_state.get_state_summary() + }) + return base_summary + + +class SupertrendCollection: + """ + Collection of multiple Supertrend indicators for meta-trend calculation. + + This class manages multiple Supertrend indicators with different parameters + and provides meta-trend calculation based on their agreement. + """ + + def __init__(self, supertrend_configs: list): + """ + Initialize collection of Supertrend indicators. + + Args: + supertrend_configs: List of (period, multiplier) tuples + """ + self.supertrends = [] + self.configs = supertrend_configs + + for period, multiplier in supertrend_configs: + supertrend = SupertrendState(period=period, multiplier=multiplier) + self.supertrends.append(supertrend) + + def update(self, ohlc_data: Dict[str, float]) -> Dict[str, Union[int, list]]: + """ + Update all Supertrend indicators and calculate meta-trend. + + Args: + ohlc_data: OHLC data dictionary + + Returns: + Dictionary with 'meta_trend' and 'trends' keys + """ + trends = [] + + # Update each Supertrend and collect trends + for supertrend in self.supertrends: + result = supertrend.update(ohlc_data) + trends.append(result['trend']) + + # Calculate meta-trend + meta_trend = self.get_current_meta_trend() + + return { + 'meta_trend': meta_trend, + 'trends': trends + } + + def is_warmed_up(self) -> bool: + """Check if all Supertrend indicators are warmed up.""" + return all(st.is_warmed_up() for st in self.supertrends) + + def reset(self) -> None: + """Reset all Supertrend indicators.""" + for supertrend in self.supertrends: + supertrend.reset() + + def get_current_meta_trend(self) -> int: + """ + Calculate current meta-trend from all Supertrend indicators. + + Meta-trend logic: + - If all trends agree, return that trend + - If trends disagree, return 0 (neutral) + + Returns: + Meta-trend value (1, -1, or 0) + """ + if not self.is_warmed_up(): + return 0 + + trends = [st.get_current_trend() for st in self.supertrends] + + # Check if all trends agree + if all(trend == trends[0] for trend in trends): + return trends[0] # All agree: return the common trend + else: + return 0 # Neutral when trends disagree + + def get_state_summary(self) -> dict: + """Get detailed state summary for all Supertrend indicators.""" + return { + 'configs': self.configs, + 'meta_trend': self.get_current_meta_trend(), + 'is_warmed_up': self.is_warmed_up(), + 'supertrends': [st.get_state_summary() for st in self.supertrends] + } \ No newline at end of file diff --git a/IncrementalTrader/strategies/metatrend.py b/IncrementalTrader/strategies/metatrend.py new file mode 100644 index 0000000..128385a --- /dev/null +++ b/IncrementalTrader/strategies/metatrend.py @@ -0,0 +1,423 @@ +""" +Incremental MetaTrend Strategy + +This module implements an incremental version of the DefaultStrategy that processes +real-time data efficiently while producing identical meta-trend signals to the +original batch-processing implementation. + +The strategy uses 3 Supertrend indicators with parameters: +- Supertrend 1: period=12, multiplier=3.0 +- Supertrend 2: period=10, multiplier=1.0 +- Supertrend 3: period=11, multiplier=2.0 + +Meta-trend calculation: +- Meta-trend = 1 when all 3 Supertrends agree on uptrend +- Meta-trend = -1 when all 3 Supertrends agree on downtrend +- Meta-trend = 0 when Supertrends disagree (neutral) + +Signal generation: +- Entry: meta-trend changes from != 1 to == 1 +- Exit: meta-trend changes from != -1 to == -1 + +Stop-loss handling is delegated to the trader layer. +""" + +import pandas as pd +import numpy as np +from typing import Dict, Optional, List, Any +import logging + +from .base import IncStrategyBase, IncStrategySignal +from .indicators.supertrend import SupertrendCollection + +logger = logging.getLogger(__name__) + + +class MetaTrendStrategy(IncStrategyBase): + """ + Incremental MetaTrend strategy implementation. + + This strategy uses multiple Supertrend indicators to determine market direction + and generates entry/exit signals based on meta-trend changes. It processes + data incrementally for real-time performance while maintaining mathematical + equivalence to the original DefaultStrategy. + + The strategy is designed to work with any timeframe but defaults to the + timeframe specified in parameters (or 15min if not specified). + + Parameters: + timeframe (str): Primary timeframe for analysis (default: "15min") + buffer_size_multiplier (float): Buffer size multiplier for memory management (default: 2.0) + enable_logging (bool): Enable detailed logging (default: False) + + Example: + strategy = MetaTrendStrategy("metatrend", weight=1.0, params={ + "timeframe": "15min", + "enable_logging": True + }) + """ + + def __init__(self, name: str = "metatrend", weight: float = 1.0, params: Optional[Dict] = None): + """ + Initialize the incremental MetaTrend strategy. + + Args: + name: Strategy name/identifier + weight: Strategy weight for combination (default: 1.0) + params: Strategy parameters + """ + super().__init__(name, weight, params) + + # Strategy configuration - now handled by base class timeframe aggregation + self.primary_timeframe = self.params.get("timeframe", "15min") + self.enable_logging = self.params.get("enable_logging", False) + + # Configure logging level + if self.enable_logging: + logger.setLevel(logging.DEBUG) + + # Initialize Supertrend collection with exact parameters from original strategy + self.supertrend_configs = [ + (12, 3.0), # period=12, multiplier=3.0 + (10, 1.0), # period=10, multiplier=1.0 + (11, 2.0) # period=11, multiplier=2.0 + ] + + self.supertrend_collection = SupertrendCollection(self.supertrend_configs) + + # Meta-trend state + self.current_meta_trend = 0 + self.previous_meta_trend = 0 + self._meta_trend_history = [] # For debugging/analysis + + # Signal generation state + self._last_entry_signal = None + self._last_exit_signal = None + self._signal_count = {"entry": 0, "exit": 0} + + # Performance tracking + self._update_count = 0 + self._last_update_time = None + + logger.info(f"MetaTrendStrategy initialized: timeframe={self.primary_timeframe}, " + f"aggregation_enabled={self._timeframe_aggregator is not None}") + + def get_minimum_buffer_size(self) -> Dict[str, int]: + """ + Return minimum data points needed for reliable Supertrend calculations. + + With the new base class timeframe aggregation, we only need to specify + the minimum buffer size for our primary timeframe. The base class + handles minute-level data aggregation automatically. + + Returns: + Dict[str, int]: {timeframe: min_points} mapping + """ + # Find the largest period among all Supertrend configurations + max_period = max(config[0] for config in self.supertrend_configs) + + # Add buffer for ATR warmup (ATR typically needs ~2x period for stability) + min_buffer_size = max_period * 2 + 10 # Extra 10 points for safety + + # With new base class, we only specify our primary timeframe + # The base class handles minute-level aggregation automatically + return {self.primary_timeframe: min_buffer_size} + + def calculate_on_data(self, new_data_point: Dict[str, float], timestamp: pd.Timestamp) -> None: + """ + Process a single new data point incrementally. + + This method updates the Supertrend indicators and recalculates the meta-trend + based on the new data point. + + Args: + new_data_point: OHLCV data point {open, high, low, close, volume} + timestamp: Timestamp of the data point + """ + try: + self._update_count += 1 + self._last_update_time = timestamp + + if self.enable_logging: + logger.debug(f"Processing data point {self._update_count} at {timestamp}") + logger.debug(f"OHLC: O={new_data_point.get('open', 0):.2f}, " + f"H={new_data_point.get('high', 0):.2f}, " + f"L={new_data_point.get('low', 0):.2f}, " + f"C={new_data_point.get('close', 0):.2f}") + + # Store previous meta-trend for change detection + self.previous_meta_trend = self.current_meta_trend + + # Update Supertrend collection with new data + supertrend_results = self.supertrend_collection.update(new_data_point) + + # Calculate new meta-trend + self.current_meta_trend = self._calculate_meta_trend(supertrend_results) + + # Store meta-trend history for analysis + self._meta_trend_history.append({ + 'timestamp': timestamp, + 'meta_trend': self.current_meta_trend, + 'individual_trends': supertrend_results['trends'].copy(), + 'update_count': self._update_count + }) + + # Limit history size to prevent memory growth + if len(self._meta_trend_history) > 1000: + self._meta_trend_history = self._meta_trend_history[-500:] # Keep last 500 + + # Log meta-trend changes + if self.enable_logging and self.current_meta_trend != self.previous_meta_trend: + logger.info(f"Meta-trend changed: {self.previous_meta_trend} -> {self.current_meta_trend} " + f"at {timestamp} (update #{self._update_count})") + logger.debug(f"Individual trends: {supertrend_results['trends']}") + + # Update warmup status + if not self._is_warmed_up and self.supertrend_collection.is_warmed_up(): + self._is_warmed_up = True + logger.info(f"Strategy warmed up after {self._update_count} data points") + + except Exception as e: + logger.error(f"Error in calculate_on_data: {e}") + raise + + def supports_incremental_calculation(self) -> bool: + """ + Whether strategy supports incremental calculation. + + Returns: + bool: True (this strategy is fully incremental) + """ + return True + + def get_entry_signal(self) -> IncStrategySignal: + """ + Generate entry signal based on meta-trend direction change. + + Entry occurs when meta-trend changes from != 1 to == 1, indicating + all Supertrend indicators now agree on upward direction. + + Returns: + IncStrategySignal: Entry signal if trend aligns, hold signal otherwise + """ + if not self.is_warmed_up: + return IncStrategySignal.HOLD() + + # Check for meta-trend entry condition + if self._check_entry_condition(): + self._signal_count["entry"] += 1 + self._last_entry_signal = { + 'timestamp': self._last_update_time, + 'meta_trend': self.current_meta_trend, + 'previous_meta_trend': self.previous_meta_trend, + 'update_count': self._update_count + } + + if self.enable_logging: + logger.info(f"ENTRY SIGNAL generated at {self._last_update_time} " + f"(signal #{self._signal_count['entry']})") + + return IncStrategySignal.BUY(confidence=1.0, metadata={ + "meta_trend": self.current_meta_trend, + "previous_meta_trend": self.previous_meta_trend, + "signal_count": self._signal_count["entry"] + }) + + return IncStrategySignal.HOLD() + + def get_exit_signal(self) -> IncStrategySignal: + """ + Generate exit signal based on meta-trend reversal. + + Exit occurs when meta-trend changes from != -1 to == -1, indicating + trend reversal to downward direction. + + Returns: + IncStrategySignal: Exit signal if trend reverses, hold signal otherwise + """ + if not self.is_warmed_up: + return IncStrategySignal.HOLD() + + # Check for meta-trend exit condition + if self._check_exit_condition(): + self._signal_count["exit"] += 1 + self._last_exit_signal = { + 'timestamp': self._last_update_time, + 'meta_trend': self.current_meta_trend, + 'previous_meta_trend': self.previous_meta_trend, + 'update_count': self._update_count + } + + if self.enable_logging: + logger.info(f"EXIT SIGNAL generated at {self._last_update_time} " + f"(signal #{self._signal_count['exit']})") + + return IncStrategySignal.SELL(confidence=1.0, metadata={ + "type": "META_TREND_EXIT", + "meta_trend": self.current_meta_trend, + "previous_meta_trend": self.previous_meta_trend, + "signal_count": self._signal_count["exit"] + }) + + return IncStrategySignal.HOLD() + + def get_confidence(self) -> float: + """ + Get strategy confidence based on meta-trend strength. + + Higher confidence when meta-trend is strongly directional, + lower confidence during neutral periods. + + Returns: + float: Confidence level (0.0 to 1.0) + """ + if not self.is_warmed_up: + return 0.0 + + # High confidence for strong directional signals + if self.current_meta_trend == 1 or self.current_meta_trend == -1: + return 1.0 + + # Lower confidence for neutral trend + return 0.3 + + def _calculate_meta_trend(self, supertrend_results: Dict) -> int: + """ + Calculate meta-trend from SupertrendCollection results. + + Meta-trend logic (matching original DefaultStrategy): + - All 3 Supertrends must agree for directional signal + - If all trends are the same, meta-trend = that trend + - If trends disagree, meta-trend = 0 (neutral) + + Args: + supertrend_results: Results from SupertrendCollection.update() + + Returns: + int: Meta-trend value (1, -1, or 0) + """ + trends = supertrend_results['trends'] + + # Check if all trends agree + if all(trend == trends[0] for trend in trends): + return trends[0] # All agree: return the common trend + else: + return 0 # Neutral when trends disagree + + def _check_entry_condition(self) -> bool: + """ + Check if meta-trend entry condition is met. + + Entry condition: meta-trend changes from != 1 to == 1 + + Returns: + bool: True if entry condition is met + """ + return (self.previous_meta_trend != 1 and + self.current_meta_trend == 1) + + def _check_exit_condition(self) -> bool: + """ + Check if meta-trend exit condition is met. + + Exit condition: meta-trend changes from != 1 to == -1 + (Modified to match original strategy behavior) + + Returns: + bool: True if exit condition is met + """ + return (self.previous_meta_trend != 1 and + self.current_meta_trend == -1) + + def get_current_state_summary(self) -> Dict[str, Any]: + """ + Get detailed state summary for debugging and monitoring. + + Returns: + Dict with current strategy state information + """ + base_summary = super().get_current_state_summary() + + # Add MetaTrend-specific state + base_summary.update({ + 'primary_timeframe': self.primary_timeframe, + 'current_meta_trend': self.current_meta_trend, + 'previous_meta_trend': self.previous_meta_trend, + 'supertrend_collection_warmed_up': self.supertrend_collection.is_warmed_up(), + 'supertrend_configs': self.supertrend_configs, + 'signal_counts': self._signal_count.copy(), + 'update_count': self._update_count, + 'last_update_time': str(self._last_update_time) if self._last_update_time else None, + 'meta_trend_history_length': len(self._meta_trend_history), + 'last_entry_signal': self._last_entry_signal, + 'last_exit_signal': self._last_exit_signal + }) + + # Add Supertrend collection state + if hasattr(self.supertrend_collection, 'get_state_summary'): + base_summary['supertrend_collection_state'] = self.supertrend_collection.get_state_summary() + + return base_summary + + def reset_calculation_state(self) -> None: + """Reset internal calculation state for reinitialization.""" + super().reset_calculation_state() + + # Reset Supertrend collection + self.supertrend_collection.reset() + + # Reset meta-trend state + self.current_meta_trend = 0 + self.previous_meta_trend = 0 + self._meta_trend_history.clear() + + # Reset signal state + self._last_entry_signal = None + self._last_exit_signal = None + self._signal_count = {"entry": 0, "exit": 0} + + # Reset performance tracking + self._update_count = 0 + self._last_update_time = None + + logger.info("MetaTrendStrategy state reset") + + def get_meta_trend_history(self, limit: Optional[int] = None) -> List[Dict]: + """ + Get meta-trend history for analysis. + + Args: + limit: Maximum number of recent entries to return + + Returns: + List of meta-trend history entries + """ + if limit is None: + return self._meta_trend_history.copy() + else: + return self._meta_trend_history[-limit:] if limit > 0 else [] + + def get_current_meta_trend(self) -> int: + """ + Get current meta-trend value. + + Returns: + int: Current meta-trend (1, -1, or 0) + """ + return self.current_meta_trend + + def get_individual_supertrend_states(self) -> List[Dict]: + """ + Get current state of individual Supertrend indicators. + + Returns: + List of Supertrend state summaries + """ + if hasattr(self.supertrend_collection, 'get_state_summary'): + collection_state = self.supertrend_collection.get_state_summary() + return collection_state.get('supertrends', []) + return [] + + +# Compatibility alias for easier imports +IncMetaTrendStrategy = MetaTrendStrategy \ No newline at end of file diff --git a/IncrementalTrader/strategies/random.py b/IncrementalTrader/strategies/random.py new file mode 100644 index 0000000..10f974a --- /dev/null +++ b/IncrementalTrader/strategies/random.py @@ -0,0 +1,332 @@ +""" +Incremental Random Strategy for Testing + +This strategy generates random entry and exit signals for testing the incremental strategy system. +It's useful for verifying that the incremental strategy framework is working correctly. +""" + +import random +import logging +import time +from typing import Dict, Optional, Any +import pandas as pd + +from .base import IncStrategyBase, IncStrategySignal + +logger = logging.getLogger(__name__) + + +class RandomStrategy(IncStrategyBase): + """ + Incremental random signal generator strategy for testing. + + This strategy generates random entry and exit signals with configurable + probability and confidence levels. It's designed to test the incremental + strategy framework and signal processing system. + + The incremental version maintains minimal state and processes each new + data point independently, making it ideal for testing real-time performance. + + Parameters: + entry_probability: Probability of generating an entry signal (0.0-1.0) + exit_probability: Probability of generating an exit signal (0.0-1.0) + min_confidence: Minimum confidence level for signals + max_confidence: Maximum confidence level for signals + timeframe: Timeframe to operate on (default: "1min") + signal_frequency: How often to generate signals (every N bars) + random_seed: Optional seed for reproducible random signals + + Example: + strategy = RandomStrategy( + name="random_test", + weight=1.0, + params={ + "entry_probability": 0.1, + "exit_probability": 0.15, + "min_confidence": 0.7, + "max_confidence": 0.9, + "signal_frequency": 5, + "random_seed": 42 # For reproducible testing + } + ) + """ + + def __init__(self, name: str = "random", weight: float = 1.0, params: Optional[Dict] = None): + """Initialize the incremental random strategy.""" + super().__init__(name, weight, params) + + # Strategy parameters with defaults + self.entry_probability = self.params.get("entry_probability", 0.05) # 5% chance per bar + self.exit_probability = self.params.get("exit_probability", 0.1) # 10% chance per bar + self.min_confidence = self.params.get("min_confidence", 0.6) + self.max_confidence = self.params.get("max_confidence", 0.9) + self.timeframe = self.params.get("timeframe", "1min") + self.signal_frequency = self.params.get("signal_frequency", 1) # Every bar + + # Create separate random instance for this strategy + self._random = random.Random() + random_seed = self.params.get("random_seed") + if random_seed is not None: + self._random.seed(random_seed) + logger.info(f"RandomStrategy: Set random seed to {random_seed}") + + # Internal state (minimal for random strategy) + self._bar_count = 0 + self._last_signal_bar = -1 + self._current_price = None + self._last_timestamp = None + + logger.info(f"RandomStrategy initialized with entry_prob={self.entry_probability}, " + f"exit_prob={self.exit_probability}, timeframe={self.timeframe}, " + f"aggregation_enabled={self._timeframe_aggregator is not None}") + + def get_minimum_buffer_size(self) -> Dict[str, int]: + """ + Return minimum data points needed for each timeframe. + + Random strategy doesn't need any historical data for calculations, + so we only need 1 data point to start generating signals. + With the new base class timeframe aggregation, we only specify + our primary timeframe. + + Returns: + Dict[str, int]: Minimal buffer requirements + """ + return {self.timeframe: 1} # Only need current data point + + def supports_incremental_calculation(self) -> bool: + """ + Whether strategy supports incremental calculation. + + Random strategy is ideal for incremental mode since it doesn't + depend on historical calculations. + + Returns: + bool: Always True for random strategy + """ + return True + + def calculate_on_data(self, new_data_point: Dict[str, float], timestamp: pd.Timestamp) -> None: + """ + Process a single new data point incrementally. + + For random strategy, we just update our internal state with the + current price. The base class now handles timeframe aggregation + automatically, so we only receive data when a complete timeframe + bar is formed. + + Args: + new_data_point: OHLCV data point {open, high, low, close, volume} + timestamp: Timestamp of the data point + """ + start_time = time.perf_counter() + + try: + # Update internal state - base class handles timeframe aggregation + self._current_price = new_data_point['close'] + self._last_timestamp = timestamp + self._data_points_received += 1 + + # Increment bar count for each processed timeframe bar + self._bar_count += 1 + + # Debug logging every 10 bars + if self._bar_count % 10 == 0: + logger.debug(f"RandomStrategy: Processing bar {self._bar_count}, " + f"price=${self._current_price:.2f}, timestamp={timestamp}") + + # Update warm-up status + if not self._is_warmed_up and self._data_points_received >= 1: + self._is_warmed_up = True + self._calculation_mode = "incremental" + logger.info(f"RandomStrategy: Warmed up after {self._data_points_received} data points") + + # Record performance metrics + update_time = time.perf_counter() - start_time + self._performance_metrics['update_times'].append(update_time) + + except Exception as e: + logger.error(f"RandomStrategy: Error in calculate_on_data: {e}") + self._performance_metrics['state_validation_failures'] += 1 + raise + + def get_entry_signal(self) -> IncStrategySignal: + """ + Generate random entry signals based on current state. + + Returns: + IncStrategySignal: Entry signal with confidence level + """ + if not self._is_warmed_up: + return IncStrategySignal.HOLD() + + start_time = time.perf_counter() + + try: + # Check if we should generate a signal based on frequency + if (self._bar_count - self._last_signal_bar) < self.signal_frequency: + return IncStrategySignal.HOLD() + + # Generate random entry signal using strategy's random instance + random_value = self._random.random() + if random_value < self.entry_probability: + confidence = self._random.uniform(self.min_confidence, self.max_confidence) + self._last_signal_bar = self._bar_count + + logger.info(f"RandomStrategy: Generated ENTRY signal at bar {self._bar_count}, " + f"price=${self._current_price:.2f}, confidence={confidence:.2f}, " + f"random_value={random_value:.3f}") + + signal = IncStrategySignal.BUY( + confidence=confidence, + price=self._current_price, + metadata={ + "strategy": "random", + "bar_count": self._bar_count, + "timeframe": self.timeframe, + "random_value": random_value, + "timestamp": self._last_timestamp + } + ) + + # Record performance metrics + signal_time = time.perf_counter() - start_time + self._performance_metrics['signal_generation_times'].append(signal_time) + + return signal + + return IncStrategySignal.HOLD() + + except Exception as e: + logger.error(f"RandomStrategy: Error in get_entry_signal: {e}") + return IncStrategySignal.HOLD() + + def get_exit_signal(self) -> IncStrategySignal: + """ + Generate random exit signals based on current state. + + Returns: + IncStrategySignal: Exit signal with confidence level + """ + if not self._is_warmed_up: + return IncStrategySignal.HOLD() + + start_time = time.perf_counter() + + try: + # Generate random exit signal using strategy's random instance + random_value = self._random.random() + if random_value < self.exit_probability: + confidence = self._random.uniform(self.min_confidence, self.max_confidence) + + # Randomly choose exit type + exit_types = ["SELL_SIGNAL", "TAKE_PROFIT", "STOP_LOSS"] + exit_type = self._random.choice(exit_types) + + logger.info(f"RandomStrategy: Generated EXIT signal at bar {self._bar_count}, " + f"price=${self._current_price:.2f}, confidence={confidence:.2f}, " + f"type={exit_type}, random_value={random_value:.3f}") + + signal = IncStrategySignal.SELL( + confidence=confidence, + price=self._current_price, + metadata={ + "type": exit_type, + "strategy": "random", + "bar_count": self._bar_count, + "timeframe": self.timeframe, + "random_value": random_value, + "timestamp": self._last_timestamp + } + ) + + # Record performance metrics + signal_time = time.perf_counter() - start_time + self._performance_metrics['signal_generation_times'].append(signal_time) + + return signal + + return IncStrategySignal.HOLD() + + except Exception as e: + logger.error(f"RandomStrategy: Error in get_exit_signal: {e}") + return IncStrategySignal.HOLD() + + def get_confidence(self) -> float: + """ + Return random confidence level for current market state. + + Returns: + float: Random confidence level between min and max confidence + """ + if not self._is_warmed_up: + return 0.0 + + return self._random.uniform(self.min_confidence, self.max_confidence) + + def reset_calculation_state(self) -> None: + """Reset internal calculation state for reinitialization.""" + super().reset_calculation_state() + + # Reset random strategy specific state + self._bar_count = 0 + self._last_signal_bar = -1 + self._current_price = None + self._last_timestamp = None + + # Reset random state if seed was provided + random_seed = self.params.get("random_seed") + if random_seed is not None: + self._random.seed(random_seed) + + logger.info("RandomStrategy: Calculation state reset") + + def _reinitialize_from_buffers(self) -> None: + """ + Reinitialize indicators from available buffer data. + + For random strategy, we just need to restore the current price + from the latest data point in the buffer. + """ + try: + # Get the latest data point from 1min buffer + buffer_1min = self._timeframe_buffers.get("1min") + if buffer_1min and len(buffer_1min) > 0: + latest_data = buffer_1min[-1] + self._current_price = latest_data['close'] + self._last_timestamp = latest_data.get('timestamp') + self._bar_count = len(buffer_1min) + + logger.info(f"RandomStrategy: Reinitialized from buffer with {self._bar_count} bars") + else: + logger.warning("RandomStrategy: No buffer data available for reinitialization") + + except Exception as e: + logger.error(f"RandomStrategy: Error reinitializing from buffers: {e}") + raise + + def get_current_state_summary(self) -> Dict[str, Any]: + """Get summary of current calculation state for debugging.""" + base_summary = super().get_current_state_summary() + base_summary.update({ + 'entry_probability': self.entry_probability, + 'exit_probability': self.exit_probability, + 'bar_count': self._bar_count, + 'last_signal_bar': self._last_signal_bar, + 'current_price': self._current_price, + 'last_timestamp': self._last_timestamp, + 'signal_frequency': self.signal_frequency, + 'timeframe': self.timeframe + }) + return base_summary + + def __repr__(self) -> str: + """String representation of the strategy.""" + return (f"RandomStrategy(entry_prob={self.entry_probability}, " + f"exit_prob={self.exit_probability}, timeframe={self.timeframe}, " + f"mode={self._calculation_mode}, warmed_up={self._is_warmed_up}, " + f"bars={self._bar_count})") + + +# Compatibility alias for easier imports +IncRandomStrategy = RandomStrategy \ No newline at end of file diff --git a/IncrementalTrader/trader/__init__.py b/IncrementalTrader/trader/__init__.py new file mode 100644 index 0000000..13959c9 --- /dev/null +++ b/IncrementalTrader/trader/__init__.py @@ -0,0 +1,35 @@ +""" +Incremental Trading Execution + +This module provides trading execution and position management for incremental strategies. +It handles real-time trade execution, risk management, and performance tracking. + +Components: +- IncTrader: Main trader class for strategy execution +- PositionManager: Position state and trade execution management +- TradeRecord: Data structure for completed trades +- MarketFees: Fee calculation utilities + +Example: + from IncrementalTrader.trader import IncTrader, PositionManager + from IncrementalTrader.strategies import MetaTrendStrategy + + strategy = MetaTrendStrategy("metatrend") + trader = IncTrader(strategy, initial_usd=10000) + + # Process data stream + for timestamp, ohlcv in data_stream: + trader.process_data_point(timestamp, ohlcv) + + results = trader.get_results() +""" + +from .trader import IncTrader +from .position import PositionManager, TradeRecord, MarketFees + +__all__ = [ + "IncTrader", + "PositionManager", + "TradeRecord", + "MarketFees", +] \ No newline at end of file diff --git a/IncrementalTrader/trader/position.py b/IncrementalTrader/trader/position.py new file mode 100644 index 0000000..4d3d2ad --- /dev/null +++ b/IncrementalTrader/trader/position.py @@ -0,0 +1,301 @@ +""" +Position Management for Incremental Trading + +This module handles position state, balance tracking, and trade calculations +for the incremental trading system. +""" + +import pandas as pd +import numpy as np +from typing import Dict, Optional, List, Any +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class TradeRecord: + """Record of a completed trade.""" + entry_time: pd.Timestamp + exit_time: pd.Timestamp + entry_price: float + exit_price: float + entry_fee: float + exit_fee: float + profit_pct: float + exit_reason: str + strategy_name: str + + +class MarketFees: + """Market fee calculations for different exchanges.""" + + @staticmethod + def calculate_okx_taker_maker_fee(amount: float, is_maker: bool = True) -> float: + """Calculate OKX trading fees.""" + fee_rate = 0.0008 if is_maker else 0.0010 + return amount * fee_rate + + @staticmethod + def calculate_binance_fee(amount: float, is_maker: bool = True) -> float: + """Calculate Binance trading fees.""" + fee_rate = 0.001 if is_maker else 0.001 + return amount * fee_rate + + +class PositionManager: + """ + Manages trading position state and calculations. + + This class handles: + - USD/coin balance tracking + - Position state management + - Trade execution calculations + - Fee calculations + - Performance metrics + """ + + def __init__(self, initial_usd: float = 10000, fee_calculator=None): + """ + Initialize position manager. + + Args: + initial_usd: Initial USD balance + fee_calculator: Fee calculation function (defaults to OKX) + """ + self.initial_usd = initial_usd + self.fee_calculator = fee_calculator or MarketFees.calculate_okx_taker_maker_fee + + # Position state + self.usd = initial_usd + self.coin = 0.0 + self.position = 0 # 0 = no position, 1 = long position + self.entry_price = 0.0 + self.entry_time = None + + # Performance tracking + self.max_balance = initial_usd + self.drawdowns = [] + self.trade_records = [] + + logger.debug(f"PositionManager initialized with ${initial_usd}") + + def is_in_position(self) -> bool: + """Check if currently in a position.""" + return self.position == 1 + + def get_current_balance(self, current_price: float) -> float: + """Get current total balance value.""" + if self.position == 0: + return self.usd + else: + return self.coin * current_price + + def execute_entry(self, entry_price: float, timestamp: pd.Timestamp, + strategy_name: str) -> Dict[str, Any]: + """ + Execute entry trade. + + Args: + entry_price: Entry price + timestamp: Entry timestamp + strategy_name: Name of the strategy + + Returns: + Dict with entry details + """ + if self.position == 1: + raise ValueError("Cannot enter position: already in position") + + # Calculate fees + entry_fee = self.fee_calculator(self.usd, is_maker=False) + usd_after_fee = self.usd - entry_fee + + # Execute entry + self.coin = usd_after_fee / entry_price + self.entry_price = entry_price + self.entry_time = timestamp + self.usd = 0.0 + self.position = 1 + + entry_details = { + 'entry_price': entry_price, + 'entry_time': timestamp, + 'entry_fee': entry_fee, + 'coin_amount': self.coin, + 'strategy_name': strategy_name + } + + logger.debug(f"ENTRY executed: ${entry_price:.2f}, fee=${entry_fee:.2f}") + return entry_details + + def execute_exit(self, exit_price: float, timestamp: pd.Timestamp, + exit_reason: str, strategy_name: str) -> Dict[str, Any]: + """ + Execute exit trade. + + Args: + exit_price: Exit price + timestamp: Exit timestamp + exit_reason: Reason for exit + strategy_name: Name of the strategy + + Returns: + Dict with exit details and trade record + """ + if self.position == 0: + raise ValueError("Cannot exit position: not in position") + + # Calculate exit + usd_gross = self.coin * exit_price + exit_fee = self.fee_calculator(usd_gross, is_maker=False) + self.usd = usd_gross - exit_fee + + # Calculate profit + profit_pct = (exit_price - self.entry_price) / self.entry_price + + # Calculate entry fee (for record keeping) + entry_fee = self.fee_calculator(self.coin * self.entry_price, is_maker=False) + + # Create trade record + trade_record = TradeRecord( + entry_time=self.entry_time, + exit_time=timestamp, + entry_price=self.entry_price, + exit_price=exit_price, + entry_fee=entry_fee, + exit_fee=exit_fee, + profit_pct=profit_pct, + exit_reason=exit_reason, + strategy_name=strategy_name + ) + self.trade_records.append(trade_record) + + # Reset position + coin_amount = self.coin + self.coin = 0.0 + self.position = 0 + entry_price = self.entry_price + entry_time = self.entry_time + self.entry_price = 0.0 + self.entry_time = None + + exit_details = { + 'exit_price': exit_price, + 'exit_time': timestamp, + 'exit_fee': exit_fee, + 'profit_pct': profit_pct, + 'exit_reason': exit_reason, + 'trade_record': trade_record, + 'final_usd': self.usd + } + + logger.debug(f"EXIT executed: ${exit_price:.2f}, reason={exit_reason}, " + f"profit={profit_pct*100:.2f}%, fee=${exit_fee:.2f}") + return exit_details + + def update_performance_metrics(self, current_price: float) -> None: + """Update performance tracking metrics.""" + current_balance = self.get_current_balance(current_price) + + # Update max balance and drawdown + if current_balance > self.max_balance: + self.max_balance = current_balance + + drawdown = (self.max_balance - current_balance) / self.max_balance + self.drawdowns.append(drawdown) + + def check_stop_loss(self, current_price: float, stop_loss_pct: float) -> bool: + """Check if stop loss should be triggered.""" + if self.position == 0 or stop_loss_pct <= 0: + return False + + stop_loss_price = self.entry_price * (1 - stop_loss_pct) + return current_price <= stop_loss_price + + def check_take_profit(self, current_price: float, take_profit_pct: float) -> bool: + """Check if take profit should be triggered.""" + if self.position == 0 or take_profit_pct <= 0: + return False + + take_profit_price = self.entry_price * (1 + take_profit_pct) + return current_price >= take_profit_price + + def get_performance_summary(self) -> Dict[str, Any]: + """Get performance summary statistics.""" + final_balance = self.usd + n_trades = len(self.trade_records) + + # Calculate statistics + if n_trades > 0: + profits = [trade.profit_pct for trade in self.trade_records] + wins = [p for p in profits if p > 0] + win_rate = len(wins) / n_trades + avg_trade = np.mean(profits) + total_fees = sum(trade.entry_fee + trade.exit_fee for trade in self.trade_records) + else: + win_rate = 0.0 + avg_trade = 0.0 + total_fees = 0.0 + + max_drawdown = max(self.drawdowns) if self.drawdowns else 0.0 + profit_ratio = (final_balance - self.initial_usd) / self.initial_usd + + return { + "initial_usd": self.initial_usd, + "final_usd": final_balance, + "profit_ratio": profit_ratio, + "n_trades": n_trades, + "win_rate": win_rate, + "max_drawdown": max_drawdown, + "avg_trade": avg_trade, + "total_fees_usd": total_fees + } + + def get_trades_as_dicts(self) -> List[Dict[str, Any]]: + """Convert trade records to dictionaries.""" + trades = [] + for trade in self.trade_records: + trades.append({ + 'entry_time': trade.entry_time, + 'exit_time': trade.exit_time, + 'entry': trade.entry_price, + 'exit': trade.exit_price, + 'profit_pct': trade.profit_pct, + 'type': trade.exit_reason, + 'fee_usd': trade.entry_fee + trade.exit_fee, + 'strategy': trade.strategy_name + }) + return trades + + def get_current_state(self) -> Dict[str, Any]: + """Get current position state.""" + return { + "position": self.position, + "usd": self.usd, + "coin": self.coin, + "entry_price": self.entry_price, + "entry_time": self.entry_time, + "n_trades": len(self.trade_records), + "max_balance": self.max_balance + } + + def reset(self) -> None: + """Reset position manager to initial state.""" + self.usd = self.initial_usd + self.coin = 0.0 + self.position = 0 + self.entry_price = 0.0 + self.entry_time = None + self.max_balance = self.initial_usd + self.drawdowns.clear() + self.trade_records.clear() + + logger.debug("PositionManager reset to initial state") + + def __repr__(self) -> str: + """String representation of position manager.""" + return (f"PositionManager(position={self.position}, " + f"usd=${self.usd:.2f}, coin={self.coin:.6f}, " + f"trades={len(self.trade_records)})") \ No newline at end of file diff --git a/IncrementalTrader/trader/trader.py b/IncrementalTrader/trader/trader.py new file mode 100644 index 0000000..989c93f --- /dev/null +++ b/IncrementalTrader/trader/trader.py @@ -0,0 +1,301 @@ +""" +Incremental Trader for backtesting incremental strategies. + +This module provides the IncTrader class that manages a single incremental strategy +during backtesting, handling strategy execution, trade decisions, and performance tracking. +""" + +import pandas as pd +import numpy as np +from typing import Dict, Optional, List, Any +import logging + +# Use try/except for imports to handle both relative and absolute import scenarios +try: + from ..strategies.base import IncStrategyBase, IncStrategySignal + from .position import PositionManager, TradeRecord +except ImportError: + # Fallback for direct execution + import sys + import os + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from strategies.base import IncStrategyBase, IncStrategySignal + from position import PositionManager, TradeRecord + +logger = logging.getLogger(__name__) + + +class IncTrader: + """ + Incremental trader that manages a single strategy during backtesting. + + This class handles: + - Strategy initialization and data feeding + - Trade decision logic based on strategy signals + - Risk management (stop loss, take profit) + - Performance tracking and metrics collection + + The trader processes data points sequentially, feeding them to the strategy + and executing trades based on the generated signals. + + Example: + from IncrementalTrader.strategies import MetaTrendStrategy + from IncrementalTrader.trader import IncTrader + + strategy = MetaTrendStrategy("metatrend", params={"timeframe": "15min"}) + trader = IncTrader( + strategy=strategy, + initial_usd=10000, + params={"stop_loss_pct": 0.02} + ) + + # Process data sequentially + for timestamp, ohlcv_data in data_stream: + trader.process_data_point(timestamp, ohlcv_data) + + # Get results + results = trader.get_results() + """ + + def __init__(self, strategy: IncStrategyBase, initial_usd: float = 10000, + params: Optional[Dict] = None): + """ + Initialize the incremental trader. + + Args: + strategy: Incremental strategy instance + initial_usd: Initial USD balance + params: Trader parameters (stop_loss_pct, take_profit_pct, etc.) + """ + self.strategy = strategy + self.initial_usd = initial_usd + self.params = params or {} + + # Initialize position manager + self.position_manager = PositionManager(initial_usd) + + # Current state + self.current_timestamp = None + self.current_price = None + + # Strategy state tracking + self.data_points_processed = 0 + self.warmup_complete = False + + # Risk management parameters + self.stop_loss_pct = self.params.get("stop_loss_pct", 0.0) + self.take_profit_pct = self.params.get("take_profit_pct", 0.0) + + # Performance tracking + self.portfolio_history = [] + + logger.info(f"IncTrader initialized: strategy={strategy.name}, " + f"initial_usd=${initial_usd}, stop_loss={self.stop_loss_pct*100:.1f}%") + + def process_data_point(self, timestamp: pd.Timestamp, ohlcv_data: Dict[str, float]) -> None: + """ + Process a single data point through the strategy and handle trading logic. + + Args: + timestamp: Data point timestamp + ohlcv_data: OHLCV data dictionary with keys: open, high, low, close, volume + """ + self.current_timestamp = timestamp + self.current_price = ohlcv_data['close'] + self.data_points_processed += 1 + + try: + # Feed data to strategy and get signal + signal = self.strategy.process_data_point(timestamp, ohlcv_data) + + # Check if strategy is warmed up + if not self.warmup_complete and self.strategy.is_warmed_up: + self.warmup_complete = True + logger.info(f"Strategy {self.strategy.name} warmed up after " + f"{self.data_points_processed} data points") + + # Only process signals if strategy is warmed up + if self.warmup_complete: + self._process_trading_logic(signal) + + # Update performance tracking + self._update_performance_tracking() + + except Exception as e: + logger.error(f"Error processing data point at {timestamp}: {e}") + raise + + def _process_trading_logic(self, signal: Optional[IncStrategySignal]) -> None: + """Process trading logic based on current position and strategy signals.""" + if not self.position_manager.is_in_position(): + # No position - check for entry signals + self._check_entry_signals(signal) + else: + # In position - check for exit signals + self._check_exit_signals(signal) + + def _check_entry_signals(self, signal: Optional[IncStrategySignal]) -> None: + """Check for entry signals when not in position.""" + try: + # Check if we have a valid entry signal + if signal and signal.signal_type == "ENTRY" and signal.confidence > 0: + self._execute_entry(signal) + + except Exception as e: + logger.error(f"Error checking entry signals: {e}") + + def _check_exit_signals(self, signal: Optional[IncStrategySignal]) -> None: + """Check for exit signals when in position.""" + try: + # Check strategy exit signals first + if signal and signal.signal_type == "EXIT" and signal.confidence > 0: + exit_reason = signal.metadata.get("type", "STRATEGY_EXIT") + exit_price = signal.price if signal.price else self.current_price + self._execute_exit(exit_reason, exit_price) + return + + # Check stop loss + if self.position_manager.check_stop_loss(self.current_price, self.stop_loss_pct): + self._execute_exit("STOP_LOSS", self.current_price) + return + + # Check take profit + if self.position_manager.check_take_profit(self.current_price, self.take_profit_pct): + self._execute_exit("TAKE_PROFIT", self.current_price) + return + + except Exception as e: + logger.error(f"Error checking exit signals: {e}") + + def _execute_entry(self, signal: IncStrategySignal) -> None: + """Execute entry trade.""" + entry_price = signal.price if signal.price else self.current_price + + try: + entry_details = self.position_manager.execute_entry( + entry_price, self.current_timestamp, self.strategy.name + ) + + logger.info(f"ENTRY: {self.strategy.name} at ${entry_price:.2f}, " + f"confidence={signal.confidence:.2f}, " + f"fee=${entry_details['entry_fee']:.2f}") + + except Exception as e: + logger.error(f"Error executing entry: {e}") + raise + + def _execute_exit(self, exit_reason: str, exit_price: Optional[float] = None) -> None: + """Execute exit trade.""" + exit_price = exit_price if exit_price else self.current_price + + try: + exit_details = self.position_manager.execute_exit( + exit_price, self.current_timestamp, exit_reason, self.strategy.name + ) + + logger.info(f"EXIT: {self.strategy.name} at ${exit_price:.2f}, " + f"reason={exit_reason}, " + f"profit={exit_details['profit_pct']*100:.2f}%, " + f"fee=${exit_details['exit_fee']:.2f}") + + except Exception as e: + logger.error(f"Error executing exit: {e}") + raise + + def _update_performance_tracking(self) -> None: + """Update performance tracking metrics.""" + # Update position manager metrics + self.position_manager.update_performance_metrics(self.current_price) + + # Track portfolio value over time + current_balance = self.position_manager.get_current_balance(self.current_price) + self.portfolio_history.append({ + 'timestamp': self.current_timestamp, + 'balance': current_balance, + 'price': self.current_price, + 'position': self.position_manager.position + }) + + def finalize(self) -> None: + """Finalize trading session (close any open positions).""" + if self.position_manager.is_in_position(): + self._execute_exit("EOD", self.current_price) + logger.info(f"Closed final position for {self.strategy.name} at EOD") + + def get_results(self) -> Dict[str, Any]: + """ + Get comprehensive trading results. + + Returns: + Dict containing performance metrics, trade records, and statistics + """ + # Get performance summary from position manager + performance = self.position_manager.get_performance_summary() + + # Get trades as dictionaries + trades = self.position_manager.get_trades_as_dicts() + + # Build comprehensive results + results = { + "strategy_name": self.strategy.name, + "strategy_params": self.strategy.params, + "trader_params": self.params, + "data_points_processed": self.data_points_processed, + "warmup_complete": self.warmup_complete, + "trades": trades, + "portfolio_history": self.portfolio_history, + **performance # Include all performance metrics + } + + # Add first and last trade info if available + if len(trades) > 0: + results["first_trade"] = { + "entry_time": trades[0]["entry_time"], + "entry": trades[0]["entry"] + } + results["last_trade"] = { + "exit_time": trades[-1]["exit_time"], + "exit": trades[-1]["exit"] + } + + # Add final balance for compatibility + results["final_balance"] = performance["final_usd"] + + return results + + def get_current_state(self) -> Dict[str, Any]: + """Get current trader state for debugging.""" + position_state = self.position_manager.get_current_state() + + return { + "strategy": self.strategy.name, + "current_price": self.current_price, + "current_timestamp": self.current_timestamp, + "data_points_processed": self.data_points_processed, + "warmup_complete": self.warmup_complete, + "strategy_state": self.strategy.get_current_state_summary(), + **position_state # Include all position state + } + + def get_portfolio_value(self) -> float: + """Get current portfolio value.""" + return self.position_manager.get_current_balance(self.current_price) + + def reset(self) -> None: + """Reset trader to initial state.""" + self.position_manager.reset() + self.strategy.reset_calculation_state() + self.current_timestamp = None + self.current_price = None + self.data_points_processed = 0 + self.warmup_complete = False + self.portfolio_history.clear() + + logger.info(f"IncTrader reset for strategy {self.strategy.name}") + + def __repr__(self) -> str: + """String representation of the trader.""" + return (f"IncTrader(strategy={self.strategy.name}, " + f"position={self.position_manager.position}, " + f"balance=${self.position_manager.get_current_balance(self.current_price or 0):.2f}, " + f"trades={len(self.position_manager.trade_records)})") \ No newline at end of file