Implement Incremental Trading Framework
- Introduced a comprehensive framework for incremental trading strategies, including modules for strategy execution, backtesting, and data processing. - Added key components such as `IncTrader`, `IncBacktester`, and various trading strategies (e.g., `MetaTrendStrategy`, `BBRSStrategy`, `RandomStrategy`) to facilitate real-time trading and backtesting. - Implemented a robust backtesting framework with configuration management, parallel execution, and result analysis capabilities. - Developed an incremental indicators framework to support real-time data processing with constant memory usage. - Enhanced documentation to provide clear usage examples and architecture overview, ensuring maintainability and ease of understanding for future development. - Ensured compatibility with existing strategies and maintained a focus on performance and scalability throughout the implementation.
This commit is contained in:
parent
8055f46328
commit
c9ae507bb7
86
IncrementalTrader/__init__.py
Normal file
86
IncrementalTrader/__init__.py
Normal file
@ -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__",
|
||||
]
|
||||
48
IncrementalTrader/backtester/__init__.py
Normal file
48
IncrementalTrader/backtester/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
524
IncrementalTrader/backtester/backtester.py
Normal file
524
IncrementalTrader/backtester/backtester.py
Normal file
@ -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})")
|
||||
207
IncrementalTrader/backtester/config.py
Normal file
207
IncrementalTrader/backtester/config.py
Normal file
@ -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})")
|
||||
480
IncrementalTrader/backtester/utils.py
Normal file
480
IncrementalTrader/backtester/utils.py
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
255
IncrementalTrader/docs/architecture.md
Normal file
255
IncrementalTrader/docs/architecture.md
Normal file
@ -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.
|
||||
59
IncrementalTrader/strategies/__init__.py
Normal file
59
IncrementalTrader/strategies/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
637
IncrementalTrader/strategies/base.py
Normal file
637
IncrementalTrader/strategies/base.py
Normal file
@ -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})")
|
||||
510
IncrementalTrader/strategies/bbrs.py
Normal file
510
IncrementalTrader/strategies/bbrs.py
Normal file
@ -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
|
||||
91
IncrementalTrader/strategies/indicators/__init__.py
Normal file
91
IncrementalTrader/strategies/indicators/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
254
IncrementalTrader/strategies/indicators/atr.py
Normal file
254
IncrementalTrader/strategies/indicators/atr.py
Normal file
@ -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
|
||||
197
IncrementalTrader/strategies/indicators/base.py
Normal file
197
IncrementalTrader/strategies/indicators/base.py
Normal file
@ -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
|
||||
325
IncrementalTrader/strategies/indicators/bollinger_bands.py
Normal file
325
IncrementalTrader/strategies/indicators/bollinger_bands.py
Normal file
@ -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
|
||||
228
IncrementalTrader/strategies/indicators/moving_average.py
Normal file
228
IncrementalTrader/strategies/indicators/moving_average.py
Normal file
@ -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
|
||||
289
IncrementalTrader/strategies/indicators/rsi.py
Normal file
289
IncrementalTrader/strategies/indicators/rsi.py
Normal file
@ -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
|
||||
316
IncrementalTrader/strategies/indicators/supertrend.py
Normal file
316
IncrementalTrader/strategies/indicators/supertrend.py
Normal file
@ -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]
|
||||
}
|
||||
423
IncrementalTrader/strategies/metatrend.py
Normal file
423
IncrementalTrader/strategies/metatrend.py
Normal file
@ -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
|
||||
332
IncrementalTrader/strategies/random.py
Normal file
332
IncrementalTrader/strategies/random.py
Normal file
@ -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
|
||||
35
IncrementalTrader/trader/__init__.py
Normal file
35
IncrementalTrader/trader/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
301
IncrementalTrader/trader/position.py
Normal file
301
IncrementalTrader/trader/position.py
Normal file
@ -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)})")
|
||||
301
IncrementalTrader/trader/trader.py
Normal file
301
IncrementalTrader/trader/trader.py
Normal file
@ -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)})")
|
||||
Loading…
x
Reference in New Issue
Block a user