diff --git a/.gitignore b/.gitignore index 2cf5136..0327dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -70,8 +70,8 @@ models/**/*.bin models/backtest_results*/ models/**/backtest_results*/ -# Datos de entrenamiento -data/ +# Datos de entrenamiento (root-level data/ only, not src/data/) +/data/ *.csv *.parquet *.feather diff --git a/config/validation_oos.yaml b/config/validation_oos.yaml index 7436204..c665b5e 100644 --- a/config/validation_oos.yaml +++ b/config/validation_oos.yaml @@ -2,27 +2,33 @@ # VALIDATION OUT-OF-SAMPLE CONFIGURATION # ============================================================================ # Archivo: config/validation_oos.yaml -# Proposito: Configurar validacion out-of-sample excluyendo 2025 del training -# Fecha: 2026-01-04 +# Proposito: Configurar validacion out-of-sample con exclusion dinamica +# Fecha: 2026-01-27 (actualizado) # Creado por: ML-Specialist (NEXUS v4.0) # ============================================================================ validation: # ------------------------------------------------------------------------- - # PERIODO DE TRAINING + # MODO DINAMICO (RECOMENDADO) + # ------------------------------------------------------------------------- + # Calcula automaticamente OOS = ultimos N meses desde max fecha en BD + # Si dynamic.enabled = true, ignora train/test_oos fijos + dynamic: + enabled: true + oos_months: 12 # Ultimos 12 meses = OOS + description: "OOS dinamico: excluye ultimos 12 meses desde max fecha en BD" + + # ------------------------------------------------------------------------- + # PERIODO DE TRAINING (fallback si dynamic.enabled = false) # ------------------------------------------------------------------------- - # Datos usados para entrenar los modelos - # IMPORTANTE: 2025 esta EXCLUIDO para validacion out-of-sample train: start_date: "2023-01-01T00:00:00" end_date: "2024-12-31T23:59:59" description: "Datos historicos para entrenamiento de modelos" # ------------------------------------------------------------------------- - # PERIODO DE VALIDACION OUT-OF-SAMPLE + # PERIODO DE VALIDACION OUT-OF-SAMPLE (fallback si dynamic.enabled = false) # ------------------------------------------------------------------------- - # Datos NUNCA vistos durante el entrenamiento - # Usados para evaluar performance real del modelo test_oos: start_date: "2025-01-01T00:00:00" end_date: "2025-12-31T23:59:59" @@ -145,11 +151,23 @@ symbols: - symbol: "EURUSD" description: "Euro vs USD" - enabled: false + enabled: true - symbol: "GBPUSD" description: "GBP vs USD" - enabled: false + enabled: true + + - symbol: "BTCUSD" + description: "Bitcoin vs USD" + enabled: true + + - symbol: "USDJPY" + description: "USD vs JPY" + enabled: true + + - symbol: "GBPJPY" + description: "GBP vs JPY" + enabled: true # ============================================================================ # REPORTES diff --git a/requirements.txt b/requirements.txt index a0c1f2b..d7ee314 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ scipy>=1.11.0 # Deep Learning torch>=2.0.0 torchvision>=0.15.0 +einops>=0.7.0 # Tensor operations for attention models # XGBoost with CUDA support xgboost>=2.0.0 @@ -17,6 +18,7 @@ uvicorn>=0.24.0 websockets>=12.0 pydantic>=2.0.0 python-multipart>=0.0.6 +aiohttp>=3.9.0 # Data processing pyarrow>=14.0.0 @@ -33,6 +35,8 @@ python-dotenv>=1.0.0 # Database pymongo>=4.6.0 motor>=3.3.0 +sqlalchemy>=2.0.0 +psycopg2-binary>=2.9.0 # Utilities python-dateutil>=2.8.2 diff --git a/scripts/ingest_ohlcv_polygon.py b/scripts/ingest_ohlcv_polygon.py new file mode 100644 index 0000000..a818751 --- /dev/null +++ b/scripts/ingest_ohlcv_polygon.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +OHLCV Data Ingestion from Polygon API → PostgreSQL +==================================================== +Downloads historical 5-minute OHLCV data for all ML training symbols +and inserts into PostgreSQL market_data.ohlcv_5m table. + +Symbols: XAUUSD, EURUSD, GBPUSD, BTCUSD, USDJPY, GBPJPY, AUDUSD + +Usage: + # Set API key + export POLYGON_API_KEY="your_key_here" + + # Download all symbols (default: 2020-01-01 to today) + python scripts/ingest_ohlcv_polygon.py + + # Download specific symbols + python scripts/ingest_ohlcv_polygon.py --symbols XAUUSD EURUSD + + # Custom date range + python scripts/ingest_ohlcv_polygon.py --start 2023-01-01 --end 2025-12-31 + + # Incremental mode (only fetch from last timestamp in DB) + python scripts/ingest_ohlcv_polygon.py --incremental + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-27 +""" + +import argparse +import asyncio +import os +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import aiohttp +import psycopg2 +import psycopg2.extras +from loguru import logger + +# Configure logging +logger.remove() +logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +POLYGON_BASE_URL = "https://api.polygon.io" + +# Symbol → Polygon ticker prefix mapping +SYMBOL_CONFIG = { + "XAUUSD": {"polygon": "C:XAUUSD", "type": "forex"}, + "EURUSD": {"polygon": "C:EURUSD", "type": "forex"}, + "GBPUSD": {"polygon": "C:GBPUSD", "type": "forex"}, + "BTCUSD": {"polygon": "X:BTCUSD", "type": "crypto"}, + "USDJPY": {"polygon": "C:USDJPY", "type": "forex"}, + "GBPJPY": {"polygon": "C:GBPJPY", "type": "forex"}, + "AUDUSD": {"polygon": "C:AUDUSD", "type": "forex"}, +} + +# PostgreSQL connection (matches trading_platform config) +PG_CONFIG = { + "host": os.getenv("DB_HOST", "localhost"), + "port": int(os.getenv("DB_PORT", "5432")), + "dbname": os.getenv("DB_NAME", "trading_platform"), + "user": os.getenv("DB_USER", "trading_user"), + "password": os.getenv("DB_PASSWORD", "trading_dev_2026"), +} + + +# ============================================================================ +# POLYGON API FETCHER +# ============================================================================ + +async def fetch_polygon_bars( + api_key: str, + polygon_symbol: str, + start_date: datetime, + end_date: datetime, + multiplier: int = 5, + timespan: str = "minute", +) -> list: + """ + Fetch OHLCV bars from Polygon API with pagination and rate limiting. + + Args: + api_key: Polygon API key + polygon_symbol: Full polygon symbol (e.g., 'C:XAUUSD') + start_date: Start date + end_date: End date + multiplier: Timeframe multiplier (5 for 5-min) + timespan: Timeframe span ('minute', 'hour', 'day') + + Returns: + List of OHLCV bar dicts + """ + all_bars = [] + current_start = start_date + + # Chunk by month to respect Polygon's 50k result limit + async with aiohttp.ClientSession() as session: + while current_start < end_date: + chunk_end = min(current_start + timedelta(days=30), end_date) + + start_str = current_start.strftime("%Y-%m-%d") + end_str = chunk_end.strftime("%Y-%m-%d") + + endpoint = ( + f"{POLYGON_BASE_URL}/v2/aggs/ticker/{polygon_symbol}" + f"/range/{multiplier}/{timespan}/{start_str}/{end_str}" + ) + + params = { + "apiKey": api_key, + "adjusted": "true", + "sort": "asc", + "limit": 50000, + } + + try: + async with session.get(endpoint, params=params) as response: + if response.status == 429: + retry_after = int(response.headers.get("Retry-After", 60)) + logger.warning(f"Rate limited, waiting {retry_after}s...") + await asyncio.sleep(retry_after) + continue # Retry same chunk + + if response.status == 403: + logger.error(f"API key unauthorized (403). Check POLYGON_API_KEY.") + break + + if response.status != 200: + text = await response.text() + logger.error(f"API error {response.status}: {text[:200]}") + current_start = chunk_end + continue + + data = await response.json() + results = data.get("results", []) + + if results: + all_bars.extend(results) + logger.info( + f" {polygon_symbol} {start_str}→{end_str}: " + f"{len(results)} bars (total: {len(all_bars)})" + ) + else: + logger.debug(f" {polygon_symbol} {start_str}→{end_str}: no data") + + except aiohttp.ClientError as e: + logger.error(f"Request failed for {polygon_symbol}: {e}") + + current_start = chunk_end + await asyncio.sleep(0.5) # Respect rate limits (~2 req/s) + + return all_bars + + +# ============================================================================ +# POSTGRESQL INSERTER +# ============================================================================ + +def get_ticker_id(conn, symbol: str) -> int: + """ + Get ticker_id from market_data.tickers table. + + Args: + conn: psycopg2 connection + symbol: Symbol name (e.g., 'XAUUSD') + + Returns: + Ticker ID + + Raises: + ValueError if ticker not found + """ + with conn.cursor() as cur: + cur.execute( + "SELECT id FROM market_data.tickers WHERE UPPER(symbol) = UPPER(%s)", + (symbol,), + ) + row = cur.fetchone() + if not row: + raise ValueError(f"Ticker '{symbol}' not found in market_data.tickers") + return row[0] + + +def get_last_timestamp(conn, ticker_id: int) -> datetime: + """Get the last ingested timestamp for a ticker (for incremental sync).""" + with conn.cursor() as cur: + cur.execute( + "SELECT MAX(timestamp) FROM market_data.ohlcv_5m WHERE ticker_id = %s", + (ticker_id,), + ) + row = cur.fetchone() + return row[0] if row and row[0] else None + + +def insert_bars_to_postgres(conn, ticker_id: int, bars: list) -> int: + """ + Insert OHLCV bars into PostgreSQL market_data.ohlcv_5m. + Uses ON CONFLICT for upsert behavior. + + Args: + conn: psycopg2 connection + ticker_id: Ticker ID from market_data.tickers + bars: List of Polygon API bar dicts + + Returns: + Number of rows inserted/updated + """ + if not bars: + return 0 + + insert_sql = """ + INSERT INTO market_data.ohlcv_5m + (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (ticker_id, timestamp) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume, + vwap = EXCLUDED.vwap + """ + + # Convert bars to tuples + rows = [] + for bar in bars: + ts_ms = bar["t"] # milliseconds epoch + timestamp = datetime.fromtimestamp(ts_ms / 1000) + rows.append(( + ticker_id, + timestamp, + bar["o"], # open + bar["h"], # high + bar["l"], # low + bar["c"], # close + bar.get("v", 0), # volume + bar.get("vw") or None, # vwap (nullable) + ts_ms, # ts_epoch in ms + )) + + # Insert in batches + batch_size = 5000 + total_inserted = 0 + + with conn.cursor() as cur: + for i in range(0, len(rows), batch_size): + batch = rows[i : i + batch_size] + psycopg2.extras.execute_batch(cur, insert_sql, batch, page_size=1000) + conn.commit() + total_inserted += len(batch) + + return total_inserted + + +# ============================================================================ +# MAIN +# ============================================================================ + +async def ingest_symbol( + api_key: str, + conn, + symbol: str, + start_date: datetime, + end_date: datetime, + incremental: bool = False, +) -> dict: + """ + Full ingestion pipeline for a single symbol. + + Returns: + Dict with results summary + """ + config = SYMBOL_CONFIG.get(symbol) + if not config: + logger.error(f"Unknown symbol: {symbol}") + return {"symbol": symbol, "status": "error", "error": "Unknown symbol"} + + polygon_symbol = config["polygon"] + + # Get ticker ID from DB + try: + ticker_id = get_ticker_id(conn, symbol) + except ValueError as e: + logger.error(str(e)) + return {"symbol": symbol, "status": "error", "error": str(e)} + + # In incremental mode, start from last timestamp + 5 min + actual_start = start_date + if incremental: + last_ts = get_last_timestamp(conn, ticker_id) + if last_ts: + actual_start = last_ts + timedelta(minutes=5) + logger.info(f" Incremental: continuing from {actual_start}") + + if actual_start >= end_date: + logger.info(f" {symbol}: already up to date") + return {"symbol": symbol, "status": "up_to_date", "rows": 0} + + # Fetch from Polygon + logger.info(f"Fetching {symbol} ({polygon_symbol}) from {actual_start.date()} to {end_date.date()}...") + bars = await fetch_polygon_bars( + api_key=api_key, + polygon_symbol=polygon_symbol, + start_date=actual_start, + end_date=end_date, + ) + + if not bars: + logger.warning(f" {symbol}: no data received from API") + return {"symbol": symbol, "status": "no_data", "rows": 0} + + # Show range + first_ts = datetime.fromtimestamp(bars[0]["t"] / 1000) + last_ts = datetime.fromtimestamp(bars[-1]["t"] / 1000) + logger.info(f" {symbol}: {len(bars)} bars from {first_ts} to {last_ts}") + + # Insert into PostgreSQL + logger.info(f" Inserting {len(bars)} bars into market_data.ohlcv_5m...") + inserted = insert_bars_to_postgres(conn, ticker_id, bars) + logger.info(f" {symbol}: {inserted} rows inserted/updated") + + return { + "symbol": symbol, + "status": "success", + "rows": inserted, + "first_bar": str(first_ts), + "last_bar": str(last_ts), + } + + +async def main(): + parser = argparse.ArgumentParser(description="Ingest OHLCV data from Polygon → PostgreSQL") + parser.add_argument( + "--symbols", + nargs="+", + default=list(SYMBOL_CONFIG.keys()), + help=f"Symbols to ingest (default: all {len(SYMBOL_CONFIG)})", + ) + parser.add_argument( + "--start", + type=str, + default="2020-01-01", + help="Start date YYYY-MM-DD (default: 2020-01-01)", + ) + parser.add_argument( + "--end", + type=str, + default=datetime.now().strftime("%Y-%m-%d"), + help="End date YYYY-MM-DD (default: today)", + ) + parser.add_argument( + "--incremental", + action="store_true", + help="Only fetch data after last timestamp in DB", + ) + parser.add_argument( + "--api-key", + type=str, + default=os.getenv("POLYGON_API_KEY", ""), + help="Polygon API key (or set POLYGON_API_KEY env var)", + ) + + args = parser.parse_args() + + # Validate API key + api_key = args.api_key + if not api_key: + logger.error("POLYGON_API_KEY is required. Set env var or use --api-key") + sys.exit(1) + + start_date = datetime.strptime(args.start, "%Y-%m-%d") + end_date = datetime.strptime(args.end, "%Y-%m-%d") + + logger.info("=" * 60) + logger.info("OHLCV Data Ingestion: Polygon → PostgreSQL") + logger.info("=" * 60) + logger.info(f"Symbols: {args.symbols}") + logger.info(f"Date range: {start_date.date()} → {end_date.date()}") + logger.info(f"Incremental: {args.incremental}") + logger.info(f"Database: {PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['dbname']}") + + # Connect to PostgreSQL + try: + conn = psycopg2.connect(**PG_CONFIG) + logger.info("PostgreSQL connected") + except Exception as e: + logger.error(f"PostgreSQL connection failed: {e}") + sys.exit(1) + + # Process each symbol + results = [] + for symbol in args.symbols: + logger.info(f"\n{'='*40}") + logger.info(f"Processing {symbol}") + logger.info(f"{'='*40}") + + result = await ingest_symbol( + api_key=api_key, + conn=conn, + symbol=symbol, + start_date=start_date, + end_date=end_date, + incremental=args.incremental, + ) + results.append(result) + + conn.close() + + # Print summary + logger.info("\n" + "=" * 60) + logger.info("INGESTION SUMMARY") + logger.info("=" * 60) + + total_rows = 0 + for r in results: + status_icon = { + "success": "[OK]", + "no_data": "[EMPTY]", + "up_to_date": "[SKIP]", + "error": "[FAIL]", + }.get(r["status"], "[?]") + + rows = r.get("rows", 0) + total_rows += rows + logger.info(f" {status_icon} {r['symbol']}: {rows:,} rows") + + logger.info(f"\nTotal rows inserted: {total_rows:,}") + logger.info("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/run_oos_backtest.py b/scripts/run_oos_backtest.py index 2de0203..25f4300 100644 --- a/scripts/run_oos_backtest.py +++ b/scripts/run_oos_backtest.py @@ -25,7 +25,7 @@ import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from src.backtesting import RRBacktester, BacktestConfig, MetricsCalculator, TradingMetrics -from src.data.database import DatabaseConnection +from src.data.database import PostgreSQLConnection from loguru import logger @@ -57,6 +57,7 @@ class OOSBacktestRunner: def load_data(self, symbol: str) -> tuple[pd.DataFrame, pd.DataFrame]: """ Carga datos separados para training y OOS testing. + Soporta modo dinamico (ultimos N meses = OOS) o fechas fijas. Args: symbol: Simbolo a cargar (ej: XAUUSD) @@ -64,29 +65,55 @@ class OOSBacktestRunner: Returns: Tuple de (df_train, df_oos) """ - train_config = self.config['validation']['train'] - oos_config = self.config['validation']['test_oos'] - # Conectar a base de datos - db = DatabaseConnection() + db = PostgreSQLConnection() - # Cargar datos de training (pre-2025) - logger.info(f"Loading training data for {symbol}...") - df_train = db.get_ticker_data( - symbol=symbol, - start_date=train_config['start_date'], - end_date=train_config['end_date'] - ) - logger.info(f"Training data: {len(df_train)} bars from {df_train.index.min()} to {df_train.index.max()}") + # Check if dynamic OOS is enabled + dynamic_config = self.config['validation'].get('dynamic', {}) + if dynamic_config.get('enabled', False): + oos_months = dynamic_config.get('oos_months', 12) + logger.info(f"Dynamic OOS mode: excluding last {oos_months} months") - # Cargar datos OOS (2025) - logger.info(f"Loading OOS data for {symbol}...") - df_oos = db.get_ticker_data( - symbol=symbol, - start_date=oos_config['start_date'], - end_date=oos_config['end_date'] - ) - logger.info(f"OOS data: {len(df_oos)} bars from {df_oos.index.min()} to {df_oos.index.max()}") + # Load all data first to find max date + logger.info(f"Loading all data for {symbol} to determine OOS boundary...") + df_all = db.get_ticker_data(symbol=symbol) + + if df_all.empty: + logger.error(f"No data found for {symbol}") + return pd.DataFrame(), pd.DataFrame() + + max_date = df_all.index.max() + from dateutil.relativedelta import relativedelta + oos_start = (max_date - relativedelta(months=oos_months)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + df_train = df_all[df_all.index < oos_start] + df_oos = df_all[df_all.index >= oos_start] + + logger.info(f"OOS boundary: {oos_start}") + logger.info(f"Training data: {len(df_train)} bars ({df_train.index.min()} to {df_train.index.max()})") + logger.info(f"OOS data: {len(df_oos)} bars ({df_oos.index.min()} to {df_oos.index.max()})") + else: + # Static date ranges from config + train_config = self.config['validation']['train'] + oos_config = self.config['validation']['test_oos'] + + logger.info(f"Loading training data for {symbol}...") + df_train = db.get_ticker_data( + symbol=symbol, + start_date=train_config['start_date'], + end_date=train_config['end_date'] + ) + logger.info(f"Training data: {len(df_train)} bars from {df_train.index.min()} to {df_train.index.max()}") + + logger.info(f"Loading OOS data for {symbol}...") + df_oos = db.get_ticker_data( + symbol=symbol, + start_date=oos_config['start_date'], + end_date=oos_config['end_date'] + ) + logger.info(f"OOS data: {len(df_oos)} bars from {df_oos.index.min()} to {df_oos.index.max()}") return df_train, df_oos diff --git a/scripts/train_symbol_timeframe_models.py b/scripts/train_symbol_timeframe_models.py index 466fc43..ff348c3 100644 --- a/scripts/train_symbol_timeframe_models.py +++ b/scripts/train_symbol_timeframe_models.py @@ -12,7 +12,7 @@ This script uses the SymbolTimeframeTrainer to train models for: Each symbol is trained for both 5m and 15m timeframes. Features: -- Loads data from MySQL database +- Loads data from PostgreSQL database - Excludes last year (2025) for backtesting - Uses dynamic factor-based sample weighting - Generates comprehensive feature set @@ -45,7 +45,7 @@ from training.symbol_timeframe_trainer import ( TrainerConfig, SYMBOL_CONFIGS ) -from data.database import MySQLConnection +from data.database import PostgreSQLConnection def setup_logging(log_dir: Path, experiment_name: str): @@ -62,17 +62,17 @@ def setup_logging(log_dir: Path, experiment_name: str): def load_data_from_db( - db: MySQLConnection, + db: PostgreSQLConnection, symbol: str, start_date: str = None, end_date: str = None, limit: int = None ) -> pd.DataFrame: """ - Load OHLCV data from MySQL database. + Load OHLCV data from PostgreSQL database. Args: - db: MySQL connection + db: PostgreSQL connection symbol: Trading symbol (e.g., 'XAUUSD') start_date: Start date filter (YYYY-MM-DD) end_date: End date filter (YYYY-MM-DD) @@ -81,57 +81,22 @@ def load_data_from_db( Returns: DataFrame with OHLCV data """ - # Normalize symbol name - db_symbol = symbol - if not symbol.startswith('C:') and not symbol.startswith('X:'): - if symbol == 'BTCUSD': - db_symbol = f'X:{symbol}' - else: - db_symbol = f'C:{symbol}' + logger.info(f"Loading data for {symbol}...") - logger.info(f"Loading data for {db_symbol}...") - - query = """ - SELECT - date_agg as time, - open, - high, - low, - close, - volume, - vwap - FROM tickers_agg_data - WHERE ticker = :symbol - """ - - params = {'symbol': db_symbol} - - if start_date: - query += " AND date_agg >= :start_date" - params['start_date'] = start_date - if end_date: - query += " AND date_agg <= :end_date" - params['end_date'] = end_date - - query += " ORDER BY date_agg ASC" - - if limit: - query += f" LIMIT {limit}" - - df = db.execute_query(query, params) + # Use PostgreSQLConnection.get_ticker_data which handles the + # market_data.ohlcv_5m JOIN with tickers table + df = db.get_ticker_data( + symbol=symbol, + timeframe='5m', + limit=limit or 500000, + start_date=start_date, + end_date=end_date + ) if df.empty: logger.warning(f"No data found for {symbol}") return df - # Set datetime index - df['time'] = pd.to_datetime(df['time']) - df.set_index('time', inplace=True) - df = df.sort_index() - - # Rename columns to match expected format - df.columns = ['open', 'high', 'low', 'close', 'volume', 'vwap'] - logger.info(f"Loaded {len(df)} records for {symbol}") logger.info(f" Date range: {df.index.min()} to {df.index.max()}") @@ -369,7 +334,7 @@ def train_models( logger.info(f"Use attention features: {use_attention}") # Connect to database - db = MySQLConnection(db_config_path) + db = PostgreSQLConnection(db_config_path) # Configure trainer with improved parameters for better R^2 # Key improvements: diff --git a/src/backtesting/__init__.py b/src/backtesting/__init__.py index f94b707..6b0a659 100644 --- a/src/backtesting/__init__.py +++ b/src/backtesting/__init__.py @@ -1,19 +1,255 @@ """ -Backtesting module for TradingAgent +Backtesting module for TradingAgent and ML model validation. + +This module provides comprehensive backtesting capabilities for: +- Individual strategy testing +- Ensemble model testing +- Walk-forward validation +- ML model validation + +Core Components: +- BacktestEngine: Main engine for running backtests +- PositionManager: Manages positions with Kelly criterion sizing +- Trade: Trade data structure with full lifecycle tracking + +Transaction Cost Models: +- Forex: 0.5 pips commission default +- Crypto: 0.1% commission default +- Configurable slippage + +Position Sizing: +- Kelly criterion (default, max 2%) +- Fixed percentage +- Fixed risk per trade +- Volatility adjusted + +Example: + ```python + from backtesting import BacktestEngine, BacktestConfig + + config = BacktestConfig.for_forex( + initial_capital=10000, + position_sizing='kelly', + max_position_pct=0.02 + ) + + engine = BacktestEngine(config) + result = engine.run_backtest(strategy, data) + result.print_summary() + ``` """ -from .engine import MaxMinBacktester, BacktestResult, Trade -from .metrics import TradingMetrics, TradeRecord, MetricsCalculator -from .rr_backtester import RRBacktester, BacktestConfig, BacktestResult as RRBacktestResult +from .trade import ( + Trade, + TradeDirection, + TradeStatus +) + +from .position_manager import ( + PositionManager, + PositionSizingConfig, + MarginRequirements, + PositionSummary +) + +from .ml_backtest_engine import ( + BacktestEngine, + BacktestConfig, + BacktestResult, + BacktestMetrics, + WalkForwardPeriod, + Strategy, + create_simple_strategy +) + +from .engine import ( + MaxMinBacktester, + BacktestResult as MaxMinBacktestResult, + Trade as MaxMinTrade +) + +from .metrics import ( + TradingMetrics, + TradeRecord, + MetricsCalculator, + PerformanceMetrics +) + +from .rr_backtester import ( + RRBacktester, + BacktestConfig as RRBacktestConfig, + BacktestResult as RRBacktestResult +) + +from .effectiveness_validator import ( + EffectivenessValidator, + ValidationResult, + validate_backtest_effectiveness +) + +from .confidence_analysis import ( + ConfidenceAnalyzer, + CalibrationData, + ReliabilityDiagramData, + ConfidenceBandAnalysis, + analyze_trade_confidence +) + +from .report_generator import ( + ReportGenerator, + StrategyReport, + EnsembleReport, + ComparisonReport, + PerformanceThresholds, + ReportSection +) + +from .visualization import ( + VisualizationData, + ChartSeries, + ChartDataPoint, + prepare_equity_curve_data, + prepare_drawdown_chart_data, + prepare_monthly_returns_heatmap_data, + prepare_trade_distribution_data, + prepare_strategy_weights_data, + prepare_calibration_plot_data, + prepare_rolling_metrics_data +) + +from .comparison import ( + StrategyComparison, + ComparisonResult, + StrategyMetrics, + RankingEntry +) + +from .strategy_adapter import ( + StrategyAdapter, + BaseStrategy, + Signal, + SignalDirection, + Prediction, + PVAAdapter, + MRDAdapter, + VBPAdapter, + MSAAdapter, + MTSAdapter +) + +from .walk_forward import ( + WalkForwardValidator, + WalkForwardConfig, + WalkForwardSplit, + AggregatedResult +) + +from .runner import ( + BacktestRunner, + ValidationReport, + PerformanceMetrics as RunnerPerformanceMetrics, + ValidationResult as RunnerValidationResult +) __all__ = [ - 'MaxMinBacktester', - 'BacktestResult', + # Core trade structures 'Trade', + 'TradeDirection', + 'TradeStatus', + + # Position management + 'PositionManager', + 'PositionSizingConfig', + 'MarginRequirements', + 'PositionSummary', + + # ML Backtest Engine (primary) + 'BacktestEngine', + 'BacktestConfig', + 'BacktestResult', + 'BacktestMetrics', + 'WalkForwardPeriod', + 'Strategy', + 'create_simple_strategy', + + # Legacy MaxMin backtester + 'MaxMinBacktester', + 'MaxMinBacktestResult', + 'MaxMinTrade', + + # Metrics 'TradingMetrics', 'TradeRecord', 'MetricsCalculator', + 'PerformanceMetrics', + + # R:R Backtester 'RRBacktester', - 'BacktestConfig', - 'RRBacktestResult' -] \ No newline at end of file + 'RRBacktestConfig', + 'RRBacktestResult', + + # Effectiveness Validation + 'EffectivenessValidator', + 'ValidationResult', + 'validate_backtest_effectiveness', + + # Confidence Analysis + 'ConfidenceAnalyzer', + 'CalibrationData', + 'ReliabilityDiagramData', + 'ConfidenceBandAnalysis', + 'analyze_trade_confidence', + + # Report Generation + 'ReportGenerator', + 'StrategyReport', + 'EnsembleReport', + 'ComparisonReport', + 'PerformanceThresholds', + 'ReportSection', + + # Visualization + 'VisualizationData', + 'ChartSeries', + 'ChartDataPoint', + 'prepare_equity_curve_data', + 'prepare_drawdown_chart_data', + 'prepare_monthly_returns_heatmap_data', + 'prepare_trade_distribution_data', + 'prepare_strategy_weights_data', + 'prepare_calibration_plot_data', + 'prepare_rolling_metrics_data', + + # Strategy Comparison + 'StrategyComparison', + 'ComparisonResult', + 'StrategyMetrics', + 'RankingEntry', + + # Strategy Adapters + 'StrategyAdapter', + 'BaseStrategy', + 'Signal', + 'SignalDirection', + 'Prediction', + 'PVAAdapter', + 'MRDAdapter', + 'VBPAdapter', + 'MSAAdapter', + 'MTSAdapter', + + # Walk-Forward Validation + 'WalkForwardValidator', + 'WalkForwardConfig', + 'WalkForwardSplit', + 'AggregatedResult', + + # Backtest Runner + 'BacktestRunner', + 'ValidationReport', + 'RunnerPerformanceMetrics', + 'RunnerValidationResult' +] + + +__version__ = '2.2.0' diff --git a/src/backtesting/comparison.py b/src/backtesting/comparison.py new file mode 100644 index 0000000..01546cb --- /dev/null +++ b/src/backtesting/comparison.py @@ -0,0 +1,797 @@ +""" +Strategy Comparison Utilities +============================= +Utilities for comparing multiple trading strategies. + +Provides: +- Multi-strategy comparison +- Ranking by various metrics +- Correlation analysis between strategies +- Regime-specific strategy selection +- Diversification benefit calculation + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from datetime import datetime +from typing import Dict, List, Optional, Any, Tuple, Union +from dataclasses import dataclass, field +import numpy as np +import pandas as pd +from loguru import logger + + +@dataclass +class StrategyMetrics: + """Metrics for a single strategy.""" + + name: str + sharpe_ratio: float + sortino_ratio: float + calmar_ratio: float + profit_factor: float + win_rate: float + max_drawdown_pct: float + total_trades: int + net_profit: float + volatility: float + returns: Optional[np.ndarray] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'name': self.name, + 'sharpe_ratio': self.sharpe_ratio, + 'sortino_ratio': self.sortino_ratio, + 'calmar_ratio': self.calmar_ratio, + 'profit_factor': self.profit_factor, + 'win_rate': self.win_rate, + 'max_drawdown_pct': self.max_drawdown_pct, + 'total_trades': self.total_trades, + 'net_profit': self.net_profit, + 'volatility': self.volatility + } + + +@dataclass +class RankingEntry: + """Entry in a strategy ranking.""" + + rank: int + strategy: str + value: float + metric: str + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'rank': self.rank, + 'strategy': self.strategy, + 'value': self.value, + 'metric': self.metric + } + + +@dataclass +class ComparisonResult: + """Result of strategy comparison.""" + + strategies: List[str] + metrics: Dict[str, StrategyMetrics] + rankings: Dict[str, List[RankingEntry]] + correlation_matrix: np.ndarray + best_overall: str + diversification_scores: Dict[Tuple[str, str], float] + combined_metrics: Dict[str, float] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'strategies': self.strategies, + 'metrics': {k: v.to_dict() for k, v in self.metrics.items()}, + 'rankings': { + k: [entry.to_dict() for entry in v] + for k, v in self.rankings.items() + }, + 'correlation_matrix': self.correlation_matrix.tolist(), + 'best_overall': self.best_overall, + 'diversification_scores': { + f"{k[0]}_{k[1]}": v for k, v in self.diversification_scores.items() + }, + 'combined_metrics': self.combined_metrics + } + + +class StrategyComparison: + """ + Compare and analyze multiple trading strategies. + + Provides comprehensive comparison including: + - Ranking by various performance metrics + - Correlation analysis + - Regime-specific performance + - Diversification benefit calculation + - Optimal combination recommendations + + Usage: + comparison = StrategyComparison() + result = comparison.compare_strategies(results_dict) + rankings = comparison.rank_by_metric(results_dict, 'sharpe_ratio') + """ + + def __init__( + self, + risk_free_rate: float = 0.02, + annualization_factor: int = 252, + min_trades_for_significance: int = 30 + ): + """ + Initialize strategy comparison utility. + + Args: + risk_free_rate: Annual risk-free rate for ratio calculations + annualization_factor: Factor for annualizing returns (252 for daily) + min_trades_for_significance: Minimum trades for statistical significance + """ + self.risk_free_rate = risk_free_rate + self.annualization_factor = annualization_factor + self.min_trades_for_significance = min_trades_for_significance + + logger.info("StrategyComparison initialized") + + def compare_strategies( + self, + results: Dict[str, Any], + weights: Optional[Dict[str, float]] = None + ) -> ComparisonResult: + """ + Perform comprehensive comparison of multiple strategies. + + Args: + results: Dictionary mapping strategy names to backtest results + weights: Optional weights for combined metrics calculation + + Returns: + ComparisonResult with all comparison data + """ + logger.info(f"Comparing {len(results)} strategies") + + strategy_metrics = {} + strategy_returns = {} + + for name, result in results.items(): + metrics, returns = self._extract_strategy_data(name, result) + strategy_metrics[name] = metrics + if returns is not None: + strategy_returns[name] = returns + + strategies = list(strategy_metrics.keys()) + + rankings = self._compute_all_rankings(strategy_metrics) + + correlation_matrix = self.calculate_correlation_matrix(strategy_returns) + + diversification_scores = self._compute_pairwise_diversification( + strategy_metrics, correlation_matrix, strategies + ) + + best_overall = self._determine_best_overall(rankings) + + if weights is None: + weights = {s: 1.0 / len(strategies) for s in strategies} + combined_metrics = self._compute_combined_metrics(strategy_metrics, weights) + + return ComparisonResult( + strategies=strategies, + metrics=strategy_metrics, + rankings=rankings, + correlation_matrix=correlation_matrix, + best_overall=best_overall, + diversification_scores=diversification_scores, + combined_metrics=combined_metrics + ) + + def rank_by_metric( + self, + results: Dict[str, Any], + metric: str = 'sharpe_ratio', + descending: bool = True + ) -> List[RankingEntry]: + """ + Rank strategies by a specific metric. + + Args: + results: Dictionary mapping strategy names to backtest results + metric: Metric to rank by + descending: If True, higher values rank first + + Returns: + List of RankingEntry sorted by metric + """ + metric_values = [] + + for name, result in results.items(): + metrics, _ = self._extract_strategy_data(name, result) + value = getattr(metrics, metric, 0) + metric_values.append((name, value)) + + metric_values.sort(key=lambda x: x[1], reverse=descending) + + rankings = [ + RankingEntry( + rank=i + 1, + strategy=name, + value=value, + metric=metric + ) + for i, (name, value) in enumerate(metric_values) + ] + + return rankings + + def identify_best_regime_strategy( + self, + results: Dict[str, Any], + regime_data: Dict[str, Any] + ) -> Dict[str, Dict[str, Any]]: + """ + Identify the best strategy for each market regime. + + Args: + results: Dictionary mapping strategy names to backtest results + regime_data: Dictionary with regime information including: + - regimes: List of regime names + - regime_labels: Array of regime labels for each period + - regime_periods: Dict mapping regime to period indices + + Returns: + Dictionary mapping each regime to best strategy and metrics + """ + regimes = regime_data.get('regimes', ['low_volatility', 'high_volatility', 'trending', 'ranging']) + regime_labels = regime_data.get('regime_labels') + regime_periods = regime_data.get('regime_periods', {}) + + best_by_regime = {} + + for regime in regimes: + regime_metrics = {} + + for name, result in results.items(): + result_dict = self._normalize_result(result) + + if regime_labels is not None: + regime_perf = self._compute_regime_performance( + result_dict, regime_labels, regime + ) + elif regime in regime_periods: + regime_perf = self._compute_performance_for_periods( + result_dict, regime_periods[regime] + ) + else: + metrics_by_regime = result_dict.get('metrics_by_volatility', {}) + regime_perf = metrics_by_regime.get(regime, {}) + + regime_metrics[name] = regime_perf + + best_strategy = None + best_sharpe = float('-inf') + + for name, perf in regime_metrics.items(): + sharpe = perf.get('sharpe_ratio', 0) + if sharpe > best_sharpe: + best_sharpe = sharpe + best_strategy = name + + best_by_regime[regime] = { + 'best_strategy': best_strategy, + 'sharpe_ratio': best_sharpe, + 'all_strategies': { + name: { + 'sharpe_ratio': perf.get('sharpe_ratio', 0), + 'win_rate': perf.get('win_rate', perf.get('winrate', 0)), + 'profit_factor': perf.get('profit_factor', 0) + } + for name, perf in regime_metrics.items() + } + } + + return best_by_regime + + def calculate_correlation_matrix( + self, + strategy_returns: Dict[str, np.ndarray] + ) -> np.ndarray: + """ + Calculate correlation matrix between strategy returns. + + Args: + strategy_returns: Dictionary mapping strategy names to return arrays + + Returns: + Correlation matrix as numpy array + """ + if len(strategy_returns) < 2: + n = len(strategy_returns) if strategy_returns else 1 + return np.eye(n) + + strategies = list(strategy_returns.keys()) + n = len(strategies) + + min_length = min(len(r) for r in strategy_returns.values()) + aligned_returns = { + name: returns[:min_length] + for name, returns in strategy_returns.items() + } + + correlation_matrix = np.eye(n) + + for i, name_i in enumerate(strategies): + for j, name_j in enumerate(strategies): + if i < j: + returns_i = aligned_returns[name_i] + returns_j = aligned_returns[name_j] + + if len(returns_i) > 1: + corr = np.corrcoef(returns_i, returns_j)[0, 1] + if np.isnan(corr): + corr = 0.0 + else: + corr = 0.0 + + correlation_matrix[i, j] = corr + correlation_matrix[j, i] = corr + + return correlation_matrix + + def diversification_benefit( + self, + individual_sharpes: List[float], + ensemble_sharpe: float + ) -> float: + """ + Calculate the diversification benefit of combining strategies. + + Diversification benefit measures how much the ensemble Sharpe ratio + exceeds the average of individual Sharpe ratios, expressed as a percentage. + + Args: + individual_sharpes: List of individual strategy Sharpe ratios + ensemble_sharpe: Sharpe ratio of the combined ensemble + + Returns: + Diversification benefit as percentage + """ + if not individual_sharpes: + return 0.0 + + avg_individual = np.mean(individual_sharpes) + + if avg_individual <= 0: + if ensemble_sharpe > 0: + return 100.0 + return 0.0 + + benefit = (ensemble_sharpe - avg_individual) / avg_individual * 100 + + return float(benefit) + + def compute_optimal_weights( + self, + results: Dict[str, Any], + method: str = 'sharpe_weighted', + constraints: Optional[Dict[str, Any]] = None + ) -> Dict[str, float]: + """ + Compute optimal strategy weights for an ensemble. + + Args: + results: Dictionary mapping strategy names to backtest results + method: Weight computation method: + - 'equal': Equal weights + - 'sharpe_weighted': Weight by Sharpe ratio + - 'inverse_volatility': Weight by inverse volatility + - 'risk_parity': Equal risk contribution + constraints: Optional constraints (min_weight, max_weight) + + Returns: + Dictionary of optimal weights + """ + strategies = list(results.keys()) + n = len(strategies) + + if n == 0: + return {} + + strategy_metrics = {} + for name, result in results.items(): + metrics, _ = self._extract_strategy_data(name, result) + strategy_metrics[name] = metrics + + if method == 'equal': + weights = {s: 1.0 / n for s in strategies} + + elif method == 'sharpe_weighted': + sharpes = {s: max(0, m.sharpe_ratio) for s, m in strategy_metrics.items()} + total_sharpe = sum(sharpes.values()) + if total_sharpe > 0: + weights = {s: sharpe / total_sharpe for s, sharpe in sharpes.items()} + else: + weights = {s: 1.0 / n for s in strategies} + + elif method == 'inverse_volatility': + volatilities = {s: max(0.0001, m.volatility) for s, m in strategy_metrics.items()} + inv_vols = {s: 1.0 / vol for s, vol in volatilities.items()} + total_inv_vol = sum(inv_vols.values()) + weights = {s: iv / total_inv_vol for s, iv in inv_vols.items()} + + elif method == 'risk_parity': + volatilities = {s: max(0.0001, m.volatility) for s, m in strategy_metrics.items()} + total_vol = sum(volatilities.values()) + target_risk = total_vol / n + weights = {} + for s, vol in volatilities.items(): + raw_weight = target_risk / vol + weights[s] = raw_weight + total_weight = sum(weights.values()) + weights = {s: w / total_weight for s, w in weights.items()} + + else: + weights = {s: 1.0 / n for s in strategies} + + if constraints: + min_weight = constraints.get('min_weight', 0.0) + max_weight = constraints.get('max_weight', 1.0) + + for s in weights: + weights[s] = max(min_weight, min(max_weight, weights[s])) + + total = sum(weights.values()) + if total > 0: + weights = {s: w / total for s, w in weights.items()} + + return weights + + def analyze_strategy_combination( + self, + results: Dict[str, Any], + weights: Dict[str, float] + ) -> Dict[str, Any]: + """ + Analyze the performance of a specific strategy combination. + + Args: + results: Dictionary mapping strategy names to backtest results + weights: Dictionary of strategy weights + + Returns: + Dictionary with combined analysis + """ + strategy_returns = {} + strategy_metrics = {} + + for name, result in results.items(): + metrics, returns = self._extract_strategy_data(name, result) + strategy_metrics[name] = metrics + if returns is not None: + strategy_returns[name] = returns + + if not strategy_returns: + return { + 'error': 'No return data available', + 'combined_metrics': {} + } + + min_length = min(len(r) for r in strategy_returns.values()) + + combined_returns = np.zeros(min_length) + for name, returns in strategy_returns.items(): + weight = weights.get(name, 0) + combined_returns += weight * returns[:min_length] + + combined_sharpe = self._compute_sharpe(combined_returns) + combined_sortino = self._compute_sortino(combined_returns) + combined_volatility = np.std(combined_returns) * np.sqrt(self.annualization_factor) + + individual_sharpes = [m.sharpe_ratio for m in strategy_metrics.values()] + div_benefit = self.diversification_benefit(individual_sharpes, combined_sharpe) + + correlation_matrix = self.calculate_correlation_matrix(strategy_returns) + strategies = list(strategy_returns.keys()) + avg_correlation = 0 + count = 0 + for i in range(len(strategies)): + for j in range(i + 1, len(strategies)): + avg_correlation += correlation_matrix[i, j] + count += 1 + avg_correlation = avg_correlation / count if count > 0 else 0 + + return { + 'combined_metrics': { + 'sharpe_ratio': float(combined_sharpe), + 'sortino_ratio': float(combined_sortino), + 'volatility': float(combined_volatility), + 'annual_return': float(np.mean(combined_returns) * self.annualization_factor * 100) + }, + 'diversification_benefit': float(div_benefit), + 'average_correlation': float(avg_correlation), + 'weights_used': weights, + 'individual_contributions': { + name: { + 'weight': weights.get(name, 0), + 'sharpe': strategy_metrics[name].sharpe_ratio, + 'contribution_to_return': float( + weights.get(name, 0) * np.mean(strategy_returns.get(name, [0])[:min_length]) * self.annualization_factor * 100 + ) + } + for name in strategies + } + } + + def _extract_strategy_data( + self, + name: str, + result: Any + ) -> Tuple[StrategyMetrics, Optional[np.ndarray]]: + """Extract metrics and returns from a strategy result.""" + result_dict = self._normalize_result(result) + + if 'metrics' in result_dict and isinstance(result_dict['metrics'], dict): + metrics_dict = result_dict['metrics'] + else: + metrics_dict = result_dict + + sharpe = metrics_dict.get('sharpe_ratio', 0) + sortino = metrics_dict.get('sortino_ratio', 0) + calmar = metrics_dict.get('calmar_ratio', 0) + profit_factor = metrics_dict.get('profit_factor', 0) + win_rate = metrics_dict.get('winrate', metrics_dict.get('win_rate', 0)) + max_dd = metrics_dict.get('max_drawdown_pct', 0) + total_trades = metrics_dict.get('total_trades', 0) + net_profit = metrics_dict.get('net_profit', 0) + + returns = None + equity_curve = result_dict.get('equity_curve') + if equity_curve is not None: + if isinstance(equity_curve, pd.Series): + equity_curve = equity_curve.values + elif isinstance(equity_curve, list): + equity_curve = np.array(equity_curve) + + if isinstance(equity_curve, np.ndarray) and len(equity_curve) > 1: + returns = np.diff(equity_curve) / equity_curve[:-1] + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + + volatility = np.std(returns) * np.sqrt(self.annualization_factor) if returns is not None else 0 + + strategy_metrics = StrategyMetrics( + name=name, + sharpe_ratio=float(sharpe), + sortino_ratio=float(sortino), + calmar_ratio=float(calmar), + profit_factor=float(profit_factor), + win_rate=float(win_rate), + max_drawdown_pct=float(max_dd), + total_trades=int(total_trades), + net_profit=float(net_profit), + volatility=float(volatility), + returns=returns + ) + + return strategy_metrics, returns + + def _normalize_result(self, result: Any) -> Dict[str, Any]: + """Normalize result to dictionary.""" + if isinstance(result, dict): + return result + if hasattr(result, 'to_dict'): + return result.to_dict() + if hasattr(result, '__dict__'): + return vars(result) + return {'result': result} + + def _compute_all_rankings( + self, + strategy_metrics: Dict[str, StrategyMetrics] + ) -> Dict[str, List[RankingEntry]]: + """Compute rankings for all standard metrics.""" + metrics_to_rank = [ + ('sharpe_ratio', True), + ('sortino_ratio', True), + ('calmar_ratio', True), + ('profit_factor', True), + ('win_rate', True), + ('max_drawdown_pct', False), + ('total_trades', True), + ('net_profit', True) + ] + + rankings = {} + + for metric, descending in metrics_to_rank: + values = [ + (name, getattr(metrics, metric, 0)) + for name, metrics in strategy_metrics.items() + ] + values.sort(key=lambda x: x[1], reverse=descending) + + rankings[metric] = [ + RankingEntry( + rank=i + 1, + strategy=name, + value=value, + metric=metric + ) + for i, (name, value) in enumerate(values) + ] + + return rankings + + def _compute_pairwise_diversification( + self, + strategy_metrics: Dict[str, StrategyMetrics], + correlation_matrix: np.ndarray, + strategies: List[str] + ) -> Dict[Tuple[str, str], float]: + """Compute pairwise diversification scores.""" + diversification_scores = {} + + for i, name_i in enumerate(strategies): + for j, name_j in enumerate(strategies): + if i < j: + corr = correlation_matrix[i, j] + sharpe_i = strategy_metrics[name_i].sharpe_ratio + sharpe_j = strategy_metrics[name_j].sharpe_ratio + + combined_sharpe = (sharpe_i + sharpe_j) / 2 * np.sqrt(2 / (1 + corr)) + avg_sharpe = (sharpe_i + sharpe_j) / 2 + + if avg_sharpe > 0: + div_score = (combined_sharpe - avg_sharpe) / avg_sharpe * 100 + else: + div_score = 0 + + diversification_scores[(name_i, name_j)] = float(div_score) + + return diversification_scores + + def _determine_best_overall( + self, + rankings: Dict[str, List[RankingEntry]] + ) -> str: + """Determine best overall strategy based on rankings.""" + points = {} + + for metric, ranking in rankings.items(): + n = len(ranking) + for entry in ranking: + if entry.strategy not in points: + points[entry.strategy] = 0 + points[entry.strategy] += (n - entry.rank + 1) + + if not points: + return 'N/A' + + best = max(points.items(), key=lambda x: x[1]) + return best[0] + + def _compute_combined_metrics( + self, + strategy_metrics: Dict[str, StrategyMetrics], + weights: Dict[str, float] + ) -> Dict[str, float]: + """Compute weighted combined metrics.""" + combined = { + 'weighted_sharpe': 0, + 'weighted_sortino': 0, + 'weighted_win_rate': 0, + 'weighted_profit_factor': 0, + 'avg_max_drawdown': 0 + } + + total_weight = sum(weights.values()) + if total_weight == 0: + return combined + + for name, metrics in strategy_metrics.items(): + weight = weights.get(name, 0) / total_weight + + combined['weighted_sharpe'] += weight * metrics.sharpe_ratio + combined['weighted_sortino'] += weight * metrics.sortino_ratio + combined['weighted_win_rate'] += weight * metrics.win_rate + combined['weighted_profit_factor'] += weight * metrics.profit_factor + combined['avg_max_drawdown'] += weight * metrics.max_drawdown_pct + + return {k: float(v) for k, v in combined.items()} + + def _compute_regime_performance( + self, + result_dict: Dict[str, Any], + regime_labels: np.ndarray, + target_regime: str + ) -> Dict[str, float]: + """Compute performance metrics for a specific regime.""" + return { + 'sharpe_ratio': 0, + 'win_rate': 0, + 'profit_factor': 0 + } + + def _compute_performance_for_periods( + self, + result_dict: Dict[str, Any], + periods: List[Tuple[int, int]] + ) -> Dict[str, float]: + """Compute performance for specific time periods.""" + equity_curve = result_dict.get('equity_curve') + if equity_curve is None: + return {'sharpe_ratio': 0, 'win_rate': 0, 'profit_factor': 0} + + if isinstance(equity_curve, pd.Series): + equity_curve = equity_curve.values + elif isinstance(equity_curve, list): + equity_curve = np.array(equity_curve) + + period_returns = [] + for start, end in periods: + if end <= len(equity_curve) and start < end: + period_equity = equity_curve[start:end] + if len(period_equity) > 1: + returns = np.diff(period_equity) / period_equity[:-1] + period_returns.extend(returns) + + if not period_returns: + return {'sharpe_ratio': 0, 'win_rate': 0, 'profit_factor': 0} + + returns = np.array(period_returns) + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + + sharpe = self._compute_sharpe(returns) + win_rate = np.mean(returns > 0) + + gross_profit = np.sum(returns[returns > 0]) + gross_loss = np.abs(np.sum(returns[returns < 0])) + profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0 + + return { + 'sharpe_ratio': float(sharpe), + 'win_rate': float(win_rate), + 'profit_factor': float(profit_factor) + } + + def _compute_sharpe(self, returns: np.ndarray) -> float: + """Compute Sharpe ratio from returns.""" + if len(returns) < 2: + return 0.0 + + mean_return = np.mean(returns) + std_return = np.std(returns) + + if std_return == 0: + return 0.0 + + daily_rf = self.risk_free_rate / self.annualization_factor + sharpe = (mean_return - daily_rf) / std_return * np.sqrt(self.annualization_factor) + + return float(sharpe) + + def _compute_sortino(self, returns: np.ndarray) -> float: + """Compute Sortino ratio from returns.""" + if len(returns) < 2: + return 0.0 + + mean_return = np.mean(returns) + negative_returns = returns[returns < 0] + + if len(negative_returns) == 0: + return float('inf') if mean_return > 0 else 0.0 + + downside_std = np.std(negative_returns) + if downside_std == 0: + return 0.0 + + daily_rf = self.risk_free_rate / self.annualization_factor + sortino = (mean_return - daily_rf) / downside_std * np.sqrt(self.annualization_factor) + + return float(sortino) diff --git a/src/backtesting/confidence_analysis.py b/src/backtesting/confidence_analysis.py new file mode 100644 index 0000000..ee7d6fb --- /dev/null +++ b/src/backtesting/confidence_analysis.py @@ -0,0 +1,872 @@ +""" +Confidence Analysis Module - Backtesting Validation +==================================================== +Analyzes prediction confidence vs actual performance. + +This module provides tools to: +- Generate calibration curves +- Create reliability diagrams +- Calculate Expected Calibration Error (ECE) +- Analyze performance by confidence bands + +Ensures that model confidence scores are well-calibrated +with actual trading outcomes. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Any, Union +from datetime import datetime +from loguru import logger + + +@dataclass +class CalibrationData: + """ + Data for calibration curve and reliability diagram. + + Contains binned confidence and accuracy values + for visualization and analysis. + """ + + # Bin information + bin_centers: np.ndarray = field(default_factory=lambda: np.array([])) + bin_edges: np.ndarray = field(default_factory=lambda: np.array([])) + + # Calibration values + bin_confidences: np.ndarray = field(default_factory=lambda: np.array([])) + bin_accuracies: np.ndarray = field(default_factory=lambda: np.array([])) + bin_counts: np.ndarray = field(default_factory=lambda: np.array([])) + + # Error metrics + bin_errors: np.ndarray = field(default_factory=lambda: np.array([])) + + # Overall metrics + mean_confidence: float = 0.0 + mean_accuracy: float = 0.0 + correlation: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'bin_centers': self.bin_centers.tolist(), + 'bin_edges': self.bin_edges.tolist(), + 'bin_confidences': self.bin_confidences.tolist(), + 'bin_accuracies': self.bin_accuracies.tolist(), + 'bin_counts': self.bin_counts.tolist(), + 'bin_errors': self.bin_errors.tolist(), + 'mean_confidence': float(self.mean_confidence), + 'mean_accuracy': float(self.mean_accuracy), + 'correlation': float(self.correlation) + } + + +@dataclass +class ReliabilityDiagramData: + """ + Data structure for reliability diagram visualization. + + Contains all data needed to plot a reliability diagram + showing calibration quality. + """ + + # Main diagram data + bin_centers: np.ndarray = field(default_factory=lambda: np.array([])) + bin_accuracies: np.ndarray = field(default_factory=lambda: np.array([])) + bin_confidences: np.ndarray = field(default_factory=lambda: np.array([])) + bin_counts: np.ndarray = field(default_factory=lambda: np.array([])) + + # Reference lines + perfect_calibration: np.ndarray = field(default_factory=lambda: np.array([])) + + # Gap bars (for gap visualization) + gap_positive: np.ndarray = field(default_factory=lambda: np.array([])) + gap_negative: np.ndarray = field(default_factory=lambda: np.array([])) + + # Histogram data + histogram_bins: np.ndarray = field(default_factory=lambda: np.array([])) + histogram_counts: np.ndarray = field(default_factory=lambda: np.array([])) + + # Summary statistics + ece: float = 0.0 + mce: float = 0.0 + overconfidence_ratio: float = 0.0 + underconfidence_ratio: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'bin_centers': self.bin_centers.tolist(), + 'bin_accuracies': self.bin_accuracies.tolist(), + 'bin_confidences': self.bin_confidences.tolist(), + 'bin_counts': self.bin_counts.tolist(), + 'perfect_calibration': self.perfect_calibration.tolist(), + 'gap_positive': self.gap_positive.tolist(), + 'gap_negative': self.gap_negative.tolist(), + 'ece': float(self.ece), + 'mce': float(self.mce), + 'overconfidence_ratio': float(self.overconfidence_ratio), + 'underconfidence_ratio': float(self.underconfidence_ratio) + } + + +@dataclass +class ConfidenceBandAnalysis: + """ + Analysis results for confidence bands. + + Breaks down performance metrics by confidence level. + """ + + bands: Dict[str, Dict[str, Any]] = field(default_factory=dict) + overall_metrics: Dict[str, float] = field(default_factory=dict) + recommendations: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'bands': self.bands, + 'overall_metrics': self.overall_metrics, + 'recommendations': self.recommendations + } + + def print_summary(self): + """Print formatted band analysis summary.""" + print("\n" + "=" * 70) + print("CONFIDENCE BAND ANALYSIS") + print("=" * 70) + + print("\n--- Overall Metrics ---") + for key, value in self.overall_metrics.items(): + if isinstance(value, float): + print(f" {key}: {value:.4f}") + else: + print(f" {key}: {value}") + + print("\n--- Per Band Breakdown ---") + for band_name, stats in sorted(self.bands.items()): + print(f"\n {band_name}:") + print(f" Trades: {stats.get('count', 0)}") + print(f" Win Rate: {stats.get('win_rate', 0):.2%}") + print(f" Avg Confidence: {stats.get('avg_confidence', 0):.2%}") + print(f" Calibration Error: {stats.get('calibration_error', 0):.4f}") + print(f" Profit Factor: {stats.get('profit_factor', 0):.2f}") + + if self.recommendations: + print("\n--- Recommendations ---") + for i, rec in enumerate(self.recommendations, 1): + print(f" {i}. {rec}") + + print("=" * 70 + "\n") + + +class ConfidenceAnalyzer: + """ + Analyzes prediction confidence vs actual performance. + + Provides comprehensive analysis of how well confidence + scores predict actual trading outcomes, including: + - Calibration curves + - Reliability diagrams + - Expected Calibration Error (ECE) + - Per-band performance analysis + + Usage: + analyzer = ConfidenceAnalyzer(n_bins=15) + + # From predictions and actuals + calibration = analyzer.calibration_curve(predictions, actuals) + diagram = analyzer.reliability_diagram(predictions, actuals) + ece = analyzer.expected_calibration_error(predictions, actuals) + + # From trades + band_analysis = analyzer.analyze_confidence_bands(trades) + band_analysis.print_summary() + """ + + DEFAULT_BINS = 15 + DEFAULT_BANDS = [0.5, 0.6, 0.7, 0.8, 0.9] + + def __init__( + self, + n_bins: int = 15, + confidence_bands: Optional[List[float]] = None + ): + """ + Initialize the confidence analyzer. + + Args: + n_bins: Number of bins for calibration analysis + confidence_bands: Confidence thresholds for band analysis + """ + self.n_bins = n_bins + self.confidence_bands = confidence_bands or self.DEFAULT_BANDS + + logger.info(f"ConfidenceAnalyzer initialized with n_bins={n_bins}") + + def calibration_curve( + self, + predictions: Union[np.ndarray, List[float], pd.Series], + actuals: Union[np.ndarray, List[int], pd.Series], + n_bins: Optional[int] = None + ) -> CalibrationData: + """ + Generate calibration curve data. + + The calibration curve shows the relationship between + predicted confidence and actual accuracy. A perfectly + calibrated model has confidence = accuracy for all bins. + + Args: + predictions: Predicted probabilities (0 to 1) + actuals: Actual binary outcomes (0 or 1) + n_bins: Number of bins (uses instance default if None) + + Returns: + CalibrationData with binned confidence and accuracy + """ + predictions = np.asarray(predictions).ravel() + actuals = np.asarray(actuals).ravel() + + if len(predictions) != len(actuals): + raise ValueError("predictions and actuals must have same length") + + n_bins = n_bins or self.n_bins + + # Clip predictions to valid range + predictions = np.clip(predictions, 0.0, 1.0) + + # Create bins + bin_edges = np.linspace(0, 1, n_bins + 1) + bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 + + # Calculate statistics per bin + bin_confidences = [] + bin_accuracies = [] + bin_counts = [] + bin_errors = [] + + for i in range(n_bins): + lower = bin_edges[i] + upper = bin_edges[i + 1] + + # Include upper bound in last bin + if i == n_bins - 1: + in_bin = (predictions >= lower) & (predictions <= upper) + else: + in_bin = (predictions >= lower) & (predictions < upper) + + count = np.sum(in_bin) + bin_counts.append(count) + + if count > 0: + avg_conf = np.mean(predictions[in_bin]) + avg_acc = np.mean(actuals[in_bin]) + error = abs(avg_conf - avg_acc) + else: + avg_conf = bin_centers[i] + avg_acc = np.nan + error = np.nan + + bin_confidences.append(avg_conf) + bin_accuracies.append(avg_acc) + bin_errors.append(error) + + # Calculate correlation + valid_mask = ~np.isnan(bin_accuracies) + if np.sum(valid_mask) > 1: + correlation = np.corrcoef( + np.array(bin_confidences)[valid_mask], + np.array(bin_accuracies)[valid_mask] + )[0, 1] + else: + correlation = 0.0 + + return CalibrationData( + bin_centers=np.array(bin_centers), + bin_edges=bin_edges, + bin_confidences=np.array(bin_confidences), + bin_accuracies=np.array(bin_accuracies), + bin_counts=np.array(bin_counts), + bin_errors=np.array(bin_errors), + mean_confidence=float(np.mean(predictions)), + mean_accuracy=float(np.mean(actuals)), + correlation=float(correlation) if not np.isnan(correlation) else 0.0 + ) + + def reliability_diagram( + self, + predictions: Union[np.ndarray, List[float], pd.Series], + actuals: Union[np.ndarray, List[int], pd.Series], + n_bins: Optional[int] = None + ) -> ReliabilityDiagramData: + """ + Generate data for reliability diagram visualization. + + The reliability diagram shows: + - Bar chart of accuracy per confidence bin + - Reference diagonal (perfect calibration) + - Gap visualization (over/under confidence) + - Histogram of prediction distribution + + Args: + predictions: Predicted probabilities (0 to 1) + actuals: Actual binary outcomes (0 or 1) + n_bins: Number of bins (uses instance default if None) + + Returns: + ReliabilityDiagramData for visualization + """ + predictions = np.asarray(predictions).ravel() + actuals = np.asarray(actuals).ravel() + n_bins = n_bins or self.n_bins + + # Get calibration data + calibration = self.calibration_curve(predictions, actuals, n_bins) + + # Perfect calibration line + perfect_calibration = np.linspace(0, 1, 100) + + # Calculate gaps + gap_positive = np.zeros(n_bins) + gap_negative = np.zeros(n_bins) + + for i in range(n_bins): + if not np.isnan(calibration.bin_accuracies[i]): + gap = calibration.bin_accuracies[i] - calibration.bin_confidences[i] + if gap > 0: + gap_positive[i] = gap + else: + gap_negative[i] = abs(gap) + + # Calculate ECE and MCE + ece = self.expected_calibration_error(predictions, actuals, n_bins) + mce = self._maximum_calibration_error(predictions, actuals, n_bins) + + # Calculate over/under confidence ratios + overconfident_bins = np.sum( + (calibration.bin_confidences > calibration.bin_accuracies) & + (calibration.bin_counts > 0) & + ~np.isnan(calibration.bin_accuracies) + ) + underconfident_bins = np.sum( + (calibration.bin_confidences < calibration.bin_accuracies) & + (calibration.bin_counts > 0) & + ~np.isnan(calibration.bin_accuracies) + ) + total_valid = np.sum( + (calibration.bin_counts > 0) & + ~np.isnan(calibration.bin_accuracies) + ) + + overconfidence_ratio = overconfident_bins / total_valid if total_valid > 0 else 0 + underconfidence_ratio = underconfident_bins / total_valid if total_valid > 0 else 0 + + return ReliabilityDiagramData( + bin_centers=calibration.bin_centers, + bin_accuracies=calibration.bin_accuracies, + bin_confidences=calibration.bin_confidences, + bin_counts=calibration.bin_counts, + perfect_calibration=perfect_calibration, + gap_positive=gap_positive, + gap_negative=gap_negative, + histogram_bins=calibration.bin_edges, + histogram_counts=calibration.bin_counts, + ece=ece, + mce=mce, + overconfidence_ratio=overconfidence_ratio, + underconfidence_ratio=underconfidence_ratio + ) + + def expected_calibration_error( + self, + predictions: Union[np.ndarray, List[float], pd.Series], + actuals: Union[np.ndarray, List[int], pd.Series], + n_bins: Optional[int] = None + ) -> float: + """ + Calculate Expected Calibration Error (ECE). + + ECE = sum_b (|B_b|/n) * |acc(B_b) - conf(B_b)| + + Lower ECE indicates better calibration. + + Args: + predictions: Predicted probabilities (0 to 1) + actuals: Actual binary outcomes (0 or 1) + n_bins: Number of bins (uses instance default if None) + + Returns: + ECE value (0 is perfect calibration) + """ + predictions = np.asarray(predictions).ravel() + actuals = np.asarray(actuals).ravel() + n_bins = n_bins or self.n_bins + + if len(predictions) == 0: + return 0.0 + + # Clip predictions + predictions = np.clip(predictions, 0.0, 1.0) + + # Create bins + bin_edges = np.linspace(0, 1, n_bins + 1) + + ece = 0.0 + total_samples = len(predictions) + + for i in range(n_bins): + lower = bin_edges[i] + upper = bin_edges[i + 1] + + # Include upper bound in last bin + if i == n_bins - 1: + in_bin = (predictions >= lower) & (predictions <= upper) + else: + in_bin = (predictions >= lower) & (predictions < upper) + + count = np.sum(in_bin) + + if count > 0: + avg_confidence = np.mean(predictions[in_bin]) + avg_accuracy = np.mean(actuals[in_bin]) + bin_weight = count / total_samples + ece += bin_weight * abs(avg_confidence - avg_accuracy) + + return float(ece) + + def _maximum_calibration_error( + self, + predictions: Union[np.ndarray, List[float], pd.Series], + actuals: Union[np.ndarray, List[int], pd.Series], + n_bins: Optional[int] = None + ) -> float: + """ + Calculate Maximum Calibration Error (MCE). + + MCE = max_b |acc(B_b) - conf(B_b)| + + Args: + predictions: Predicted probabilities + actuals: Actual outcomes + n_bins: Number of bins + + Returns: + MCE value + """ + predictions = np.asarray(predictions).ravel() + actuals = np.asarray(actuals).ravel() + n_bins = n_bins or self.n_bins + + if len(predictions) == 0: + return 0.0 + + predictions = np.clip(predictions, 0.0, 1.0) + bin_edges = np.linspace(0, 1, n_bins + 1) + + max_error = 0.0 + + for i in range(n_bins): + lower = bin_edges[i] + upper = bin_edges[i + 1] + + if i == n_bins - 1: + in_bin = (predictions >= lower) & (predictions <= upper) + else: + in_bin = (predictions >= lower) & (predictions < upper) + + count = np.sum(in_bin) + + if count > 0: + avg_confidence = np.mean(predictions[in_bin]) + avg_accuracy = np.mean(actuals[in_bin]) + error = abs(avg_confidence - avg_accuracy) + max_error = max(max_error, error) + + return float(max_error) + + def analyze_confidence_bands( + self, + trades: List[Any], + bands: Optional[List[float]] = None, + confidence_field: str = 'confidence' + ) -> ConfidenceBandAnalysis: + """ + Analyze performance by confidence bands. + + Breaks down trading performance by confidence level + to understand how prediction confidence relates to + actual trading success. + + Args: + trades: List of trade records + bands: Confidence band thresholds + confidence_field: Field name for confidence + + Returns: + ConfidenceBandAnalysis with per-band metrics + """ + bands = bands or self.confidence_bands + result = ConfidenceBandAnalysis() + + if not trades: + return result + + # Get closed trades only + closed_trades = [ + t for t in trades + if getattr(t, 'result', 'open') != 'open' + ] + + if not closed_trades: + return result + + # Extract confidence values + def get_confidence(trade): + conf = getattr(trade, confidence_field, None) + if conf is None: + conf = getattr(trade, 'prob_tp_first', 0.5) + return conf if conf is not None else 0.5 + + # Analyze each band + all_bands = bands + [1.0] # Add upper bound + + all_confidences = [] + all_actuals = [] + + for i in range(len(all_bands) - 1): + lower = all_bands[i] + upper = all_bands[i + 1] + band_name = f"{lower:.0%}-{upper:.0%}" + + # Filter trades in this band + band_trades = [ + t for t in closed_trades + if lower <= get_confidence(t) < upper + ] + + if band_trades: + # Extract metrics + confidences = [get_confidence(t) for t in band_trades] + pnls = [t.pnl for t in band_trades] + outcomes = [1 if p > 0 else 0 for p in pnls] + + all_confidences.extend(confidences) + all_actuals.extend(outcomes) + + # Calculate band statistics + win_rate = np.mean(outcomes) + avg_confidence = np.mean(confidences) + calibration_error = abs(avg_confidence - win_rate) + + wins = [p for p in pnls if p > 0] + losses = [abs(p) for p in pnls if p < 0] + profit_factor = sum(wins) / sum(losses) if losses else float('inf') + + avg_pnl = np.mean(pnls) + total_pnl = sum(pnls) + + result.bands[band_name] = { + 'count': len(band_trades), + 'win_rate': win_rate, + 'avg_confidence': avg_confidence, + 'calibration_error': calibration_error, + 'profit_factor': profit_factor, + 'avg_pnl': avg_pnl, + 'total_pnl': total_pnl, + 'confidence_range': (lower, upper), + 'is_calibrated': calibration_error < 0.10 + } + + # Calculate overall metrics + if all_confidences: + result.overall_metrics = { + 'total_trades': len(closed_trades), + 'ece': self.expected_calibration_error( + np.array(all_confidences), + np.array(all_actuals) + ), + 'mean_confidence': np.mean(all_confidences), + 'mean_accuracy': np.mean(all_actuals), + 'calibration_correlation': float( + np.corrcoef(all_confidences, all_actuals)[0, 1] + ) if len(all_confidences) > 1 else 0.0 + } + + # Generate recommendations + result.recommendations = self._generate_band_recommendations(result) + + return result + + def _generate_band_recommendations( + self, + analysis: ConfidenceBandAnalysis + ) -> List[str]: + """ + Generate recommendations based on band analysis. + + Args: + analysis: ConfidenceBandAnalysis result + + Returns: + List of recommendation strings + """ + recommendations = [] + + if not analysis.bands: + recommendations.append("Insufficient data for band analysis") + return recommendations + + # Check for overconfidence in high bands + high_bands = { + k: v for k, v in analysis.bands.items() + if v.get('confidence_range', (0, 0))[0] >= 0.7 + } + + for band_name, stats in high_bands.items(): + if stats['avg_confidence'] > stats['win_rate'] + 0.15: + recommendations.append( + f"High confidence band {band_name} is overconfident: " + f"confidence={stats['avg_confidence']:.0%} but win_rate={stats['win_rate']:.0%}" + ) + + # Check for underconfidence in low bands + low_bands = { + k: v for k, v in analysis.bands.items() + if v.get('confidence_range', (1, 1))[1] <= 0.6 + } + + for band_name, stats in low_bands.items(): + if stats['win_rate'] > stats['avg_confidence'] + 0.15: + recommendations.append( + f"Low confidence band {band_name} is underconfident: " + f"confidence={stats['avg_confidence']:.0%} but win_rate={stats['win_rate']:.0%}" + ) + + # Check overall ECE + ece = analysis.overall_metrics.get('ece', 0) + if ece > 0.15: + recommendations.append( + f"High calibration error (ECE={ece:.3f}): " + "Consider applying calibration techniques" + ) + elif ece > 0.10: + recommendations.append( + f"Moderate calibration error (ECE={ece:.3f}): " + "Model could benefit from calibration tuning" + ) + + # Check for bands with poor performance + poor_bands = [ + band_name for band_name, stats in analysis.bands.items() + if stats['win_rate'] < 0.45 and stats['count'] >= 10 + ] + + if poor_bands: + recommendations.append( + f"Consider filtering trades in bands: {', '.join(poor_bands)} " + "(win rate below 45%)" + ) + + # Check correlation + correlation = analysis.overall_metrics.get('calibration_correlation', 0) + if correlation < 0.3: + recommendations.append( + f"Low confidence-accuracy correlation ({correlation:.2f}): " + "Confidence scores may not be predictive" + ) + + return recommendations + + def compare_calibration( + self, + before_predictions: np.ndarray, + before_actuals: np.ndarray, + after_predictions: np.ndarray, + after_actuals: Optional[np.ndarray] = None + ) -> Dict[str, Any]: + """ + Compare calibration before and after calibration adjustment. + + Args: + before_predictions: Original predictions + before_actuals: Original actuals + after_predictions: Calibrated predictions + after_actuals: Actuals for calibrated (defaults to before_actuals) + + Returns: + Comparison dictionary with improvement metrics + """ + if after_actuals is None: + after_actuals = before_actuals + + before_ece = self.expected_calibration_error(before_predictions, before_actuals) + after_ece = self.expected_calibration_error(after_predictions, after_actuals) + + before_mce = self._maximum_calibration_error(before_predictions, before_actuals) + after_mce = self._maximum_calibration_error(after_predictions, after_actuals) + + before_diagram = self.reliability_diagram(before_predictions, before_actuals) + after_diagram = self.reliability_diagram(after_predictions, after_actuals) + + ece_improvement = (before_ece - after_ece) / before_ece if before_ece > 0 else 0 + mce_improvement = (before_mce - after_mce) / before_mce if before_mce > 0 else 0 + + return { + 'before': { + 'ece': before_ece, + 'mce': before_mce, + 'overconfidence_ratio': before_diagram.overconfidence_ratio, + 'underconfidence_ratio': before_diagram.underconfidence_ratio + }, + 'after': { + 'ece': after_ece, + 'mce': after_mce, + 'overconfidence_ratio': after_diagram.overconfidence_ratio, + 'underconfidence_ratio': after_diagram.underconfidence_ratio + }, + 'improvement': { + 'ece_improvement_pct': ece_improvement * 100, + 'mce_improvement_pct': mce_improvement * 100, + 'ece_reduced': before_ece - after_ece, + 'mce_reduced': before_mce - after_mce + }, + 'is_improved': after_ece < before_ece + } + + +def analyze_trade_confidence( + trades: List[Any], + n_bins: int = 15, + confidence_bands: Optional[List[float]] = None +) -> Tuple[CalibrationData, ConfidenceBandAnalysis]: + """ + Convenience function to analyze trade confidence. + + Args: + trades: List of trade records + n_bins: Number of bins for calibration + confidence_bands: Band thresholds + + Returns: + Tuple of (CalibrationData, ConfidenceBandAnalysis) + """ + analyzer = ConfidenceAnalyzer(n_bins=n_bins, confidence_bands=confidence_bands) + + # Extract predictions and actuals + closed_trades = [t for t in trades if getattr(t, 'result', 'open') != 'open'] + + predictions = [] + actuals = [] + + for t in closed_trades: + conf = getattr(t, 'confidence', None) or getattr(t, 'prob_tp_first', 0.5) + actual = 1 if t.pnl > 0 else 0 + predictions.append(conf) + actuals.append(actual) + + calibration = analyzer.calibration_curve( + np.array(predictions), + np.array(actuals) + ) + + band_analysis = analyzer.analyze_confidence_bands(trades) + + return calibration, band_analysis + + +if __name__ == "__main__": + # Test confidence analyzer + from dataclasses import dataclass as dc + import random + + print("Testing ConfidenceAnalyzer") + print("=" * 60) + + # Create mock trade class + @dc + class MockTrade: + id: int + pnl: float + confidence: float + prob_tp_first: float + result: str + + # Generate sample trades with realistic confidence-accuracy relationship + random.seed(42) + trades = [] + + for i in range(200): + # Confidence level + confidence = random.uniform(0.4, 0.95) + + # Win probability increases with confidence (but not perfectly calibrated) + # Add some overconfidence bias + true_win_prob = 0.35 + confidence * 0.5 # Maps [0.4, 0.95] to [0.55, 0.825] + true_win_prob = min(true_win_prob, 0.90) + + is_win = random.random() < true_win_prob + + trade = MockTrade( + id=i, + pnl=10.0 if is_win else -5.0, + confidence=confidence, + prob_tp_first=confidence, + result='tp' if is_win else 'sl' + ) + trades.append(trade) + + # Initialize analyzer + analyzer = ConfidenceAnalyzer(n_bins=10) + + # Extract predictions and actuals + predictions = np.array([t.confidence for t in trades]) + actuals = np.array([1 if t.pnl > 0 else 0 for t in trades]) + + # Test calibration curve + print("\n--- Calibration Curve ---") + calibration = analyzer.calibration_curve(predictions, actuals) + print(f"Mean Confidence: {calibration.mean_confidence:.3f}") + print(f"Mean Accuracy: {calibration.mean_accuracy:.3f}") + print(f"Correlation: {calibration.correlation:.3f}") + + # Test reliability diagram + print("\n--- Reliability Diagram Data ---") + diagram = analyzer.reliability_diagram(predictions, actuals) + print(f"ECE: {diagram.ece:.4f}") + print(f"MCE: {diagram.mce:.4f}") + print(f"Overconfidence Ratio: {diagram.overconfidence_ratio:.2%}") + print(f"Underconfidence Ratio: {diagram.underconfidence_ratio:.2%}") + + # Test ECE directly + print("\n--- Expected Calibration Error ---") + ece = analyzer.expected_calibration_error(predictions, actuals) + print(f"ECE: {ece:.4f}") + + # Test band analysis + print("\n--- Confidence Band Analysis ---") + band_analysis = analyzer.analyze_confidence_bands(trades) + band_analysis.print_summary() + + # Test comparison + print("\n--- Calibration Comparison ---") + # Simulate calibrated predictions (adjusted to be better calibrated) + calibrated_predictions = predictions * 0.85 + 0.1 # Reduce overconfidence + calibrated_predictions = np.clip(calibrated_predictions, 0.01, 0.99) + + comparison = analyzer.compare_calibration( + predictions, actuals, + calibrated_predictions, actuals + ) + + print(f"Before ECE: {comparison['before']['ece']:.4f}") + print(f"After ECE: {comparison['after']['ece']:.4f}") + print(f"ECE Improvement: {comparison['improvement']['ece_improvement_pct']:.1f}%") + print(f"Is Improved: {comparison['is_improved']}") + + print("\nConfidenceAnalyzer test complete!") diff --git a/src/backtesting/effectiveness_validator.py b/src/backtesting/effectiveness_validator.py new file mode 100644 index 0000000..ed07b92 --- /dev/null +++ b/src/backtesting/effectiveness_validator.py @@ -0,0 +1,732 @@ +""" +Effectiveness Validator - Backtesting Validation Module +======================================================== +Validates the 80% effectiveness target for trading strategies. + +This module provides comprehensive validation of trading strategy +effectiveness including: +- Overall effectiveness validation +- Per-symbol effectiveness breakdown +- Per-regime effectiveness analysis +- Confidence-filtered effectiveness + +Target: 80% effectiveness rate for high-confidence trades + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Any, Union +from datetime import datetime +from loguru import logger + +from .metrics import TradeRecord, MetricsCalculator, PerformanceMetrics + + +@dataclass +class ValidationResult: + """ + Result of strategy effectiveness validation. + + Contains pass/fail status, actual metrics, gaps to targets, + and detailed breakdown by various dimensions. + """ + + # Overall validation + passed: bool = False + effectiveness: float = 0.0 + target: float = 0.80 + + # Gap analysis + gap: float = 0.0 + gap_pct: float = 0.0 + + # Confidence levels + confidence_level: str = 'low' # 'low', 'medium', 'high', 'very_high' + statistical_significance: bool = False + p_value: Optional[float] = None + + # Sample info + total_trades: int = 0 + valid_trades: int = 0 + filtered_trades: int = 0 + + # Detailed metrics + actual_metrics: Dict[str, float] = field(default_factory=dict) + target_metrics: Dict[str, float] = field(default_factory=dict) + gaps: Dict[str, float] = field(default_factory=dict) + + # Breakdowns + per_symbol: Dict[str, Dict[str, Any]] = field(default_factory=dict) + per_regime: Dict[str, Dict[str, Any]] = field(default_factory=dict) + per_confidence_band: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # Recommendations + recommendations: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'passed': self.passed, + 'effectiveness': float(self.effectiveness), + 'target': float(self.target), + 'gap': float(self.gap), + 'gap_pct': float(self.gap_pct), + 'confidence_level': self.confidence_level, + 'statistical_significance': self.statistical_significance, + 'p_value': self.p_value, + 'total_trades': self.total_trades, + 'valid_trades': self.valid_trades, + 'actual_metrics': self.actual_metrics, + 'target_metrics': self.target_metrics, + 'gaps': self.gaps, + 'per_symbol': self.per_symbol, + 'per_regime': self.per_regime, + 'per_confidence_band': self.per_confidence_band, + 'recommendations': self.recommendations + } + + def print_summary(self): + """Print formatted validation summary.""" + status = "PASSED" if self.passed else "FAILED" + status_color = "green" if self.passed else "red" + + print("\n" + "=" * 70) + print(f"EFFECTIVENESS VALIDATION: {status}") + print("=" * 70) + + print(f"\nOverall Effectiveness: {self.effectiveness:.2%}") + print(f"Target: {self.target:.2%}") + print(f"Gap: {self.gap:.2%} ({self.gap_pct:+.1f}%)") + print(f"Confidence Level: {self.confidence_level.upper()}") + + print(f"\nSample Size:") + print(f" Total Trades: {self.total_trades}") + print(f" Valid Trades: {self.valid_trades}") + print(f" Filtered: {self.filtered_trades}") + + if self.statistical_significance: + print(f"\nStatistically Significant (p={self.p_value:.4f})") + else: + print(f"\nNOT Statistically Significant (p={self.p_value:.4f})") + + if self.per_symbol: + print("\n--- Per Symbol ---") + for symbol, stats in self.per_symbol.items(): + eff = stats.get('effectiveness', 0) + n = stats.get('trades', 0) + passed = stats.get('passed', False) + status = "PASS" if passed else "FAIL" + print(f" {symbol}: {eff:.2%} (n={n}) [{status}]") + + if self.per_regime: + print("\n--- Per Regime ---") + for regime, stats in self.per_regime.items(): + eff = stats.get('effectiveness', 0) + n = stats.get('trades', 0) + passed = stats.get('passed', False) + status = "PASS" if passed else "FAIL" + print(f" {regime}: {eff:.2%} (n={n}) [{status}]") + + if self.per_confidence_band: + print("\n--- Per Confidence Band ---") + for band, stats in sorted(self.per_confidence_band.items()): + eff = stats.get('effectiveness', 0) + n = stats.get('trades', 0) + passed = stats.get('passed', False) + status = "PASS" if passed else "FAIL" + print(f" {band}: {eff:.2%} (n={n}) [{status}]") + + if self.recommendations: + print("\n--- Recommendations ---") + for i, rec in enumerate(self.recommendations, 1): + print(f" {i}. {rec}") + + print("=" * 70 + "\n") + + +class EffectivenessValidator: + """ + Validates trading strategy effectiveness against targets. + + The validator checks if a trading strategy meets the 80% + effectiveness target across different dimensions: + - Overall effectiveness + - Per-symbol effectiveness + - Per-regime effectiveness (bull/bear/sideways) + - Confidence-filtered effectiveness + + Usage: + validator = EffectivenessValidator(target=0.80) + + # Validate from backtest result + result = validator.validate_strategy(backtest_result) + + # Validate from trades list + result = validator.validate_trades(trades) + + # Print summary + result.print_summary() + """ + + DEFAULT_TARGET = 0.80 + MIN_TRADES_FOR_SIGNIFICANCE = 30 + CONFIDENCE_BANDS = [0.5, 0.6, 0.7, 0.8, 0.9] + + def __init__( + self, + target: float = 0.80, + min_trades: int = 30, + confidence_bands: Optional[List[float]] = None, + regime_field: str = 'volatility_regime', + symbol_field: str = 'symbol' + ): + """ + Initialize the effectiveness validator. + + Args: + target: Target effectiveness rate (default 80%) + min_trades: Minimum trades for statistical significance + confidence_bands: Confidence thresholds for filtering + regime_field: Field name for regime in trades + symbol_field: Field name for symbol in trades + """ + self.target = target + self.min_trades = min_trades + self.confidence_bands = confidence_bands or self.CONFIDENCE_BANDS + self.regime_field = regime_field + self.symbol_field = symbol_field + self.metrics_calculator = MetricsCalculator() + + logger.info(f"EffectivenessValidator initialized with target={target:.0%}") + + def validate_strategy( + self, + backtest_result: Any, + include_breakdowns: bool = True + ) -> ValidationResult: + """ + Validate strategy effectiveness from backtest result. + + Args: + backtest_result: BacktestResult object with trades + include_breakdowns: Whether to include per-symbol/regime breakdowns + + Returns: + ValidationResult with pass/fail and detailed metrics + """ + trades = getattr(backtest_result, 'trades', []) + return self.validate_trades(trades, include_breakdowns) + + def validate_trades( + self, + trades: List[TradeRecord], + include_breakdowns: bool = True + ) -> ValidationResult: + """ + Validate effectiveness from list of trades. + + Args: + trades: List of TradeRecord objects + include_breakdowns: Whether to include per-symbol/regime breakdowns + + Returns: + ValidationResult with pass/fail and detailed metrics + """ + result = ValidationResult(target=self.target) + + # Filter closed trades + closed_trades = [t for t in trades if t.result != 'open'] + result.total_trades = len(trades) + result.valid_trades = len(closed_trades) + result.filtered_trades = len(trades) - len(closed_trades) + + if not closed_trades: + result.recommendations.append("No closed trades available for validation") + return result + + # Calculate overall effectiveness (win rate) + winning_trades = sum(1 for t in closed_trades if t.pnl > 0) + result.effectiveness = winning_trades / len(closed_trades) + + # Calculate gap + result.gap = result.effectiveness - self.target + result.gap_pct = (result.gap / self.target) * 100 if self.target > 0 else 0 + + # Determine if passed + result.passed = result.effectiveness >= self.target + + # Statistical significance test + result.statistical_significance, result.p_value = self._test_significance( + winning_trades, len(closed_trades), self.target + ) + + # Confidence level + result.confidence_level = self._determine_confidence_level( + result.effectiveness, len(closed_trades), result.p_value + ) + + # Actual vs target metrics + result.actual_metrics = { + 'effectiveness': result.effectiveness, + 'win_rate': result.effectiveness, + 'total_trades': len(closed_trades), + 'winning_trades': winning_trades, + 'losing_trades': len(closed_trades) - winning_trades + } + + result.target_metrics = { + 'effectiveness': self.target, + 'min_trades': self.min_trades + } + + result.gaps = { + 'effectiveness': result.gap, + 'trades_gap': max(0, self.min_trades - len(closed_trades)) + } + + # Breakdowns + if include_breakdowns: + result.per_symbol = self._validate_per_symbol(closed_trades) + result.per_regime = self._validate_per_regime(closed_trades) + result.per_confidence_band = self._validate_per_confidence_band(closed_trades) + + # Generate recommendations + result.recommendations = self._generate_recommendations(result, closed_trades) + + return result + + def validate_high_confidence_trades( + self, + trades: List[TradeRecord], + confidence_threshold: float = 0.70 + ) -> ValidationResult: + """ + Validate effectiveness for high-confidence trades only. + + High-confidence trades should have a higher effectiveness rate + as the model is more certain about these predictions. + + Args: + trades: List of TradeRecord objects + confidence_threshold: Minimum confidence for inclusion + + Returns: + ValidationResult for high-confidence trades + """ + # Filter by confidence + high_conf_trades = [ + t for t in trades + if getattr(t, 'confidence', 0) >= confidence_threshold + or getattr(t, 'prob_tp_first', 0) >= confidence_threshold + ] + + result = self.validate_trades(high_conf_trades, include_breakdowns=False) + result.actual_metrics['confidence_threshold'] = confidence_threshold + result.actual_metrics['original_trades'] = len(trades) + result.actual_metrics['filtered_trades'] = len(high_conf_trades) + result.actual_metrics['filter_ratio'] = len(high_conf_trades) / len(trades) if trades else 0 + + return result + + def _validate_per_symbol( + self, + trades: List[TradeRecord] + ) -> Dict[str, Dict[str, Any]]: + """Validate effectiveness per trading symbol.""" + per_symbol = {} + + # Group by symbol (try multiple possible fields) + symbols = {} + for t in trades: + symbol = getattr(t, 'symbol', None) or getattr(t, 'horizon', 'unknown') + if symbol not in symbols: + symbols[symbol] = [] + symbols[symbol].append(t) + + for symbol, symbol_trades in symbols.items(): + winning = sum(1 for t in symbol_trades if t.pnl > 0) + effectiveness = winning / len(symbol_trades) if symbol_trades else 0 + + per_symbol[symbol] = { + 'effectiveness': effectiveness, + 'trades': len(symbol_trades), + 'winning': winning, + 'losing': len(symbol_trades) - winning, + 'passed': effectiveness >= self.target, + 'gap': effectiveness - self.target + } + + return per_symbol + + def _validate_per_regime( + self, + trades: List[TradeRecord] + ) -> Dict[str, Dict[str, Any]]: + """Validate effectiveness per market regime.""" + per_regime = {} + + # Group by regime + regimes = {} + for t in trades: + regime = getattr(t, self.regime_field, None) or getattr(t, 'amd_phase', 'unknown') + if regime is None: + regime = 'unknown' + if regime not in regimes: + regimes[regime] = [] + regimes[regime].append(t) + + for regime, regime_trades in regimes.items(): + winning = sum(1 for t in regime_trades if t.pnl > 0) + effectiveness = winning / len(regime_trades) if regime_trades else 0 + + per_regime[regime] = { + 'effectiveness': effectiveness, + 'trades': len(regime_trades), + 'winning': winning, + 'losing': len(regime_trades) - winning, + 'passed': effectiveness >= self.target, + 'gap': effectiveness - self.target + } + + return per_regime + + def _validate_per_confidence_band( + self, + trades: List[TradeRecord] + ) -> Dict[str, Dict[str, Any]]: + """Validate effectiveness per confidence band.""" + per_band = {} + + # Get confidence values + confidences = [] + for t in trades: + conf = getattr(t, 'confidence', 0) or getattr(t, 'prob_tp_first', 0) or 0 + confidences.append(conf) + + # Group by confidence bands + bands = self.confidence_bands + [1.0] # Add upper bound + + for i in range(len(bands) - 1): + lower = bands[i] + upper = bands[i + 1] + band_name = f"{lower:.0%}-{upper:.0%}" + + band_trades = [ + t for t, c in zip(trades, confidences) + if lower <= c < upper + ] + + if band_trades: + winning = sum(1 for t in band_trades if t.pnl > 0) + effectiveness = winning / len(band_trades) + + per_band[band_name] = { + 'effectiveness': effectiveness, + 'trades': len(band_trades), + 'winning': winning, + 'losing': len(band_trades) - winning, + 'passed': effectiveness >= self.target, + 'gap': effectiveness - self.target, + 'confidence_range': (lower, upper) + } + + return per_band + + def _test_significance( + self, + successes: int, + total: int, + target: float + ) -> Tuple[bool, float]: + """ + Test if effectiveness is statistically significant. + + Uses binomial test to check if observed win rate + is significantly different from target. + + Args: + successes: Number of winning trades + total: Total number of trades + target: Target effectiveness rate + + Returns: + Tuple of (is_significant, p_value) + """ + if total < self.min_trades: + return False, 1.0 + + try: + from scipy import stats + # One-sided test: is effectiveness >= target? + result = stats.binom_test(successes, total, target, alternative='greater') + return result < 0.05, result + except ImportError: + # Fallback: simple approximation using normal distribution + p_hat = successes / total + se = np.sqrt(target * (1 - target) / total) + z = (p_hat - target) / se if se > 0 else 0 + p_value = 1 - 0.5 * (1 + np.math.erf(z / np.sqrt(2))) + return p_value < 0.05, p_value + except Exception: + return False, 1.0 + + def _determine_confidence_level( + self, + effectiveness: float, + n_trades: int, + p_value: Optional[float] + ) -> str: + """ + Determine confidence level of the validation result. + + Args: + effectiveness: Observed effectiveness + n_trades: Number of trades + p_value: Statistical p-value + + Returns: + Confidence level string + """ + if n_trades < 10: + return 'very_low' + elif n_trades < self.min_trades: + return 'low' + elif p_value is not None and p_value > 0.10: + return 'medium' + elif p_value is not None and p_value <= 0.01: + return 'very_high' + elif p_value is not None and p_value <= 0.05: + return 'high' + else: + return 'medium' + + def _generate_recommendations( + self, + result: ValidationResult, + trades: List[TradeRecord] + ) -> List[str]: + """ + Generate actionable recommendations based on validation results. + + Args: + result: Current validation result + trades: List of trades analyzed + + Returns: + List of recommendation strings + """ + recommendations = [] + + # Sample size recommendations + if result.valid_trades < self.min_trades: + recommendations.append( + f"Increase sample size: {result.valid_trades} trades is below " + f"minimum {self.min_trades} for statistical significance" + ) + + # Overall effectiveness recommendations + if not result.passed: + gap_pct = abs(result.gap_pct) + if gap_pct > 20: + recommendations.append( + f"Major improvement needed: effectiveness is {gap_pct:.0f}% below target" + ) + elif gap_pct > 10: + recommendations.append( + f"Moderate improvement needed: effectiveness is {gap_pct:.0f}% below target" + ) + else: + recommendations.append( + f"Minor tuning needed: effectiveness is only {gap_pct:.0f}% below target" + ) + + # Per-regime recommendations + if result.per_regime: + failing_regimes = [ + regime for regime, stats in result.per_regime.items() + if not stats.get('passed', False) and stats.get('trades', 0) >= 5 + ] + if failing_regimes: + recommendations.append( + f"Focus on improving performance in regimes: {', '.join(failing_regimes)}" + ) + + # Confidence band recommendations + if result.per_confidence_band: + # Check if high confidence trades perform better + high_conf_bands = { + k: v for k, v in result.per_confidence_band.items() + if v.get('confidence_range', (0, 0))[0] >= 0.7 + } + low_conf_bands = { + k: v for k, v in result.per_confidence_band.items() + if v.get('confidence_range', (1, 1))[1] <= 0.6 + } + + if high_conf_bands and low_conf_bands: + high_eff = np.mean([v['effectiveness'] for v in high_conf_bands.values()]) + low_eff = np.mean([v['effectiveness'] for v in low_conf_bands.values()]) + + if high_eff > low_eff + 0.10: + recommendations.append( + f"Consider filtering low-confidence trades: " + f"high-conf={high_eff:.0%} vs low-conf={low_eff:.0%}" + ) + elif high_eff <= low_eff: + recommendations.append( + "Confidence scores may need recalibration: " + "high-confidence trades not outperforming low-confidence" + ) + + # Statistical significance recommendations + if not result.statistical_significance: + recommendations.append( + "Results are not statistically significant - " + "consider gathering more data before making decisions" + ) + + return recommendations + + def compare_strategies( + self, + results: Dict[str, ValidationResult] + ) -> Dict[str, Any]: + """ + Compare effectiveness across multiple strategies. + + Args: + results: Dictionary mapping strategy names to ValidationResults + + Returns: + Comparison summary + """ + comparison = { + 'strategies': {}, + 'best_overall': None, + 'best_effectiveness': 0, + 'all_passing': True + } + + for name, result in results.items(): + comparison['strategies'][name] = { + 'effectiveness': result.effectiveness, + 'passed': result.passed, + 'trades': result.valid_trades, + 'confidence': result.confidence_level + } + + if result.effectiveness > comparison['best_effectiveness']: + comparison['best_effectiveness'] = result.effectiveness + comparison['best_overall'] = name + + if not result.passed: + comparison['all_passing'] = False + + return comparison + + +def validate_backtest_effectiveness( + backtest_result: Any, + target: float = 0.80, + confidence_threshold: float = 0.70 +) -> Tuple[ValidationResult, ValidationResult]: + """ + Convenience function to validate backtest effectiveness. + + Validates both overall effectiveness and high-confidence + trades effectiveness. + + Args: + backtest_result: BacktestResult object + target: Target effectiveness rate + confidence_threshold: Threshold for high-confidence filtering + + Returns: + Tuple of (overall_result, high_confidence_result) + """ + validator = EffectivenessValidator(target=target) + + overall_result = validator.validate_strategy(backtest_result) + high_conf_result = validator.validate_high_confidence_trades( + backtest_result.trades, + confidence_threshold=confidence_threshold + ) + + return overall_result, high_conf_result + + +if __name__ == "__main__": + # Test effectiveness validator + from datetime import datetime, timedelta + import random + + print("Testing EffectivenessValidator") + print("=" * 60) + + # Generate sample trades + trades = [] + base_time = datetime(2024, 1, 1, 9, 0) + + random.seed(42) + for i in range(150): + # Higher confidence trades have higher win rate + confidence = random.uniform(0.5, 0.9) + base_win_prob = 0.65 + (confidence - 0.5) * 0.3 # 65-80% based on conf + + is_win = random.random() < base_win_prob + + result = 'tp' if is_win else 'sl' + pnl = 10.0 if is_win else -5.0 + pnl_r = 2.0 if is_win else -1.0 + + entry_time = base_time + timedelta(hours=i * 2) + + trade = TradeRecord( + id=i, + entry_time=entry_time, + exit_time=entry_time + timedelta(minutes=random.randint(5, 60)), + direction=random.choice(['long', 'short']), + entry_price=2000.0, + exit_price=2000.0 + pnl, + sl_price=1995.0, + tp_price=2010.0, + sl_distance=5.0, + tp_distance=10.0, + rr_config='rr_2_1', + result=result, + pnl=pnl, + pnl_r=pnl_r, + duration_minutes=random.randint(5, 60), + horizon=random.choice(['15m', '1h']), + amd_phase=random.choice(['accumulation', 'manipulation', 'distribution']), + volatility_regime=random.choice(['low', 'medium', 'high']), + confidence=confidence, + prob_tp_first=confidence + ) + trades.append(trade) + + # Validate + validator = EffectivenessValidator(target=0.80) + + print("\n--- Overall Validation ---") + result = validator.validate_trades(trades) + result.print_summary() + + print("\n--- High Confidence Validation (>70%) ---") + high_conf_result = validator.validate_high_confidence_trades(trades, 0.70) + print(f"Effectiveness: {high_conf_result.effectiveness:.2%}") + print(f"Trades: {high_conf_result.valid_trades}") + print(f"Passed: {high_conf_result.passed}") + + print("\n--- High Confidence Validation (>80%) ---") + very_high_conf_result = validator.validate_high_confidence_trades(trades, 0.80) + print(f"Effectiveness: {very_high_conf_result.effectiveness:.2%}") + print(f"Trades: {very_high_conf_result.valid_trades}") + print(f"Passed: {very_high_conf_result.passed}") + + print("\nEffectivenessValidator test complete!") diff --git a/src/backtesting/metrics.py b/src/backtesting/metrics.py index ac5765f..c288799 100644 --- a/src/backtesting/metrics.py +++ b/src/backtesting/metrics.py @@ -1,19 +1,292 @@ """ Trading Metrics - Phase 2 Comprehensive metrics for trading performance evaluation + +This module provides: +1. PerformanceMetrics - Complete performance metrics dataclass +2. TradingMetrics - Trading-specific metrics (legacy compatibility) +3. MetricsCalculator - Calculator class with all metric methods + +Target metrics from spec: +- Direction Accuracy >= 60% +- Sharpe Ratio >= 1.5 (ensemble) +- Max Drawdown <= 15% +- Win Rate for 80% effectiveness target + +Author: ML-Specialist (NEXUS v4.0) +Version: 2.0.0 +Created: 2026-01-25 """ import numpy as np import pandas as pd from dataclasses import dataclass, field -from typing import Dict, List, Optional, Tuple, Any +from typing import Dict, List, Optional, Tuple, Any, Union from datetime import datetime, timedelta from loguru import logger +@dataclass +class PerformanceMetrics: + """ + Complete performance metrics for backtesting validation. + + Contains all metrics needed for strategy evaluation including: + - Returns metrics (total, annualized, daily) + - Risk-adjusted returns (Sharpe, Sortino, Calmar) + - Drawdown metrics + - Trade statistics + - Direction accuracy + - Consistency metrics + + Target thresholds: + - Direction Accuracy: >= 60% + - Sharpe Ratio: >= 1.5 + - Max Drawdown: <= 15% + - Win Rate: target 80% effectiveness + """ + + # Returns metrics + total_return: float = 0.0 + total_return_pct: float = 0.0 + annualized_return: float = 0.0 + daily_return_mean: float = 0.0 + daily_return_std: float = 0.0 + + # Risk-adjusted returns + sharpe_ratio: float = 0.0 + sortino_ratio: float = 0.0 + calmar_ratio: float = 0.0 + information_ratio: float = 0.0 + omega_ratio: float = 0.0 + + # Drawdown metrics + max_drawdown: float = 0.0 + max_drawdown_pct: float = 0.0 + max_drawdown_duration: int = 0 + avg_drawdown: float = 0.0 + avg_drawdown_duration: float = 0.0 + recovery_factor: float = 0.0 + + # Trade statistics + total_trades: int = 0 + winning_trades: int = 0 + losing_trades: int = 0 + breakeven_trades: int = 0 + win_rate: float = 0.0 + loss_rate: float = 0.0 + + # Profit metrics + gross_profit: float = 0.0 + gross_loss: float = 0.0 + net_profit: float = 0.0 + profit_factor: float = 0.0 + avg_win: float = 0.0 + avg_loss: float = 0.0 + avg_trade: float = 0.0 + avg_win_loss_ratio: float = 0.0 + expectancy: float = 0.0 + expectancy_ratio: float = 0.0 + + # Direction accuracy + direction_accuracy: float = 0.0 + long_accuracy: float = 0.0 + short_accuracy: float = 0.0 + long_trades: int = 0 + short_trades: int = 0 + + # Extremes + largest_win: float = 0.0 + largest_loss: float = 0.0 + largest_win_streak: int = 0 + largest_loss_streak: int = 0 + + # Consistency metrics + positive_periods_ratio: float = 0.0 + gain_to_pain_ratio: float = 0.0 + tail_ratio: float = 0.0 + common_sense_ratio: float = 0.0 + + # Time metrics + avg_trade_duration: float = 0.0 + avg_win_duration: float = 0.0 + avg_loss_duration: float = 0.0 + total_time_in_market: float = 0.0 + + # Period info + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + trading_days: int = 0 + total_periods: int = 0 + + # Segmented metrics + metrics_by_symbol: Dict[str, Dict[str, float]] = field(default_factory=dict) + metrics_by_regime: Dict[str, Dict[str, float]] = field(default_factory=dict) + metrics_by_confidence: Dict[str, Dict[str, float]] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + # Returns + 'total_return': float(self.total_return), + 'total_return_pct': float(self.total_return_pct), + 'annualized_return': float(self.annualized_return), + 'daily_return_mean': float(self.daily_return_mean), + 'daily_return_std': float(self.daily_return_std), + + # Risk-adjusted + 'sharpe_ratio': float(self.sharpe_ratio), + 'sortino_ratio': float(self.sortino_ratio), + 'calmar_ratio': float(self.calmar_ratio), + 'information_ratio': float(self.information_ratio), + 'omega_ratio': float(self.omega_ratio), + + # Drawdown + 'max_drawdown': float(self.max_drawdown), + 'max_drawdown_pct': float(self.max_drawdown_pct), + 'max_drawdown_duration': int(self.max_drawdown_duration), + 'avg_drawdown': float(self.avg_drawdown), + 'recovery_factor': float(self.recovery_factor), + + # Trade stats + 'total_trades': int(self.total_trades), + 'winning_trades': int(self.winning_trades), + 'losing_trades': int(self.losing_trades), + 'win_rate': float(self.win_rate), + + # Profit + 'gross_profit': float(self.gross_profit), + 'gross_loss': float(self.gross_loss), + 'net_profit': float(self.net_profit), + 'profit_factor': float(self.profit_factor), + 'avg_win': float(self.avg_win), + 'avg_loss': float(self.avg_loss), + 'avg_win_loss_ratio': float(self.avg_win_loss_ratio), + 'expectancy': float(self.expectancy), + + # Direction + 'direction_accuracy': float(self.direction_accuracy), + 'long_accuracy': float(self.long_accuracy), + 'short_accuracy': float(self.short_accuracy), + + # Extremes + 'largest_win': float(self.largest_win), + 'largest_loss': float(self.largest_loss), + 'largest_win_streak': int(self.largest_win_streak), + 'largest_loss_streak': int(self.largest_loss_streak), + + # Consistency + 'positive_periods_ratio': float(self.positive_periods_ratio), + 'gain_to_pain_ratio': float(self.gain_to_pain_ratio), + 'tail_ratio': float(self.tail_ratio), + + # Time + 'avg_trade_duration': float(self.avg_trade_duration), + 'trading_days': int(self.trading_days), + + # Segmented + 'metrics_by_symbol': self.metrics_by_symbol, + 'metrics_by_regime': self.metrics_by_regime, + } + + def meets_targets( + self, + direction_accuracy_target: float = 0.60, + sharpe_target: float = 1.5, + max_drawdown_target: float = 0.15, + win_rate_target: float = 0.80 + ) -> Dict[str, bool]: + """ + Check if metrics meet target thresholds. + + Args: + direction_accuracy_target: Target for direction accuracy (default 60%) + sharpe_target: Target for Sharpe ratio (default 1.5) + max_drawdown_target: Maximum allowed drawdown (default 15%) + win_rate_target: Target win rate (default 80%) + + Returns: + Dictionary with target compliance for each metric + """ + return { + 'direction_accuracy': self.direction_accuracy >= direction_accuracy_target, + 'sharpe_ratio': self.sharpe_ratio >= sharpe_target, + 'max_drawdown': self.max_drawdown_pct <= max_drawdown_target, + 'win_rate': self.win_rate >= win_rate_target, + 'all_targets_met': ( + self.direction_accuracy >= direction_accuracy_target and + self.sharpe_ratio >= sharpe_target and + self.max_drawdown_pct <= max_drawdown_target + ) + } + + def print_summary(self, include_targets: bool = True): + """Print formatted summary of metrics.""" + print("\n" + "=" * 70) + print("PERFORMANCE METRICS SUMMARY") + print("=" * 70) + + print("\n--- Returns ---") + print(f"Total Return: ${self.total_return:,.2f} ({self.total_return_pct:.2%})") + print(f"Annualized Return: {self.annualized_return:.2%}") + print(f"Daily Return: {self.daily_return_mean:.4%} +/- {self.daily_return_std:.4%}") + + print("\n--- Risk-Adjusted Returns ---") + print(f"Sharpe Ratio: {self.sharpe_ratio:.3f}") + print(f"Sortino Ratio: {self.sortino_ratio:.3f}") + print(f"Calmar Ratio: {self.calmar_ratio:.3f}") + print(f"Omega Ratio: {self.omega_ratio:.3f}") + + print("\n--- Drawdown ---") + print(f"Max Drawdown: ${self.max_drawdown:,.2f} ({self.max_drawdown_pct:.2%})") + print(f"Max DD Duration: {self.max_drawdown_duration} periods") + print(f"Avg Drawdown: ${self.avg_drawdown:,.2f}") + print(f"Recovery Factor: {self.recovery_factor:.2f}") + + print("\n--- Trade Statistics ---") + print(f"Total Trades: {self.total_trades}") + print(f"Win Rate: {self.win_rate:.2%} ({self.winning_trades}W / {self.losing_trades}L)") + print(f"Profit Factor: {self.profit_factor:.2f}") + print(f"Expectancy: ${self.expectancy:.2f} per trade") + + print("\n--- Direction Accuracy ---") + print(f"Overall: {self.direction_accuracy:.2%}") + print(f"Long: {self.long_accuracy:.2%} ({self.long_trades} trades)") + print(f"Short: {self.short_accuracy:.2%} ({self.short_trades} trades)") + + print("\n--- Profit/Loss ---") + print(f"Net Profit: ${self.net_profit:,.2f}") + print(f"Gross Profit: ${self.gross_profit:,.2f}") + print(f"Gross Loss: ${self.gross_loss:,.2f}") + print(f"Avg Win: ${self.avg_win:,.2f}") + print(f"Avg Loss: ${self.avg_loss:,.2f}") + print(f"Avg Win/Loss Ratio: {self.avg_win_loss_ratio:.2f}") + print(f"Largest Win: ${self.largest_win:,.2f}") + print(f"Largest Loss: ${self.largest_loss:,.2f}") + + print("\n--- Streaks ---") + print(f"Largest Win Streak: {self.largest_win_streak}") + print(f"Largest Loss Streak: {self.largest_loss_streak}") + + if include_targets: + targets = self.meets_targets() + print("\n--- Target Compliance ---") + print(f"Direction Accuracy >= 60%: {'PASS' if targets['direction_accuracy'] else 'FAIL'}") + print(f"Sharpe Ratio >= 1.5: {'PASS' if targets['sharpe_ratio'] else 'FAIL'}") + print(f"Max Drawdown <= 15%: {'PASS' if targets['max_drawdown'] else 'FAIL'}") + print(f"All Targets Met: {'YES' if targets['all_targets_met'] else 'NO'}") + + print("=" * 70 + "\n") + + @dataclass class TradingMetrics: - """Complete trading metrics for Phase 2""" + """ + Complete trading metrics for Phase 2. + + Legacy class maintained for backward compatibility. + For new code, prefer using PerformanceMetrics. + """ # Basic counts total_trades: int = 0 @@ -69,7 +342,7 @@ class TradingMetrics: trading_days: int = 0 def to_dict(self) -> Dict: - """Convert to dictionary""" + """Convert to dictionary.""" return { 'total_trades': self.total_trades, 'winning_trades': self.winning_trades, @@ -97,10 +370,10 @@ class TradingMetrics: } def print_summary(self): - """Print formatted summary""" - print("\n" + "="*50) + """Print formatted summary.""" + print("\n" + "=" * 50) print("TRADING METRICS SUMMARY") - print("="*50) + print("=" * 50) print(f"Total Trades: {self.total_trades}") print(f"Win Rate: {self.winrate:.2%}") print(f"Profit Factor: {self.profit_factor:.2f}") @@ -120,7 +393,7 @@ class TradingMetrics: for rr, rate in self.winrate_by_rr.items(): print(f" {rr}: {rate:.2%}") - print("="*50 + "\n") + print("=" * 50 + "\n") @dataclass @@ -172,20 +445,637 @@ class TradeRecord: class MetricsCalculator: - """Calculator for trading metrics""" + """ + Calculator for trading performance metrics. - def __init__(self, risk_free_rate: float = 0.02): + Provides comprehensive methods for calculating: + - Returns (total, annualized, daily) + - Risk-adjusted returns (Sharpe, Sortino, Calmar) + - Drawdown analysis + - Trade statistics + - Direction accuracy + - Win/loss analysis + + Target metrics from spec: + - Direction Accuracy >= 60% + - Sharpe Ratio >= 1.5 (ensemble) + - Max Drawdown <= 15% + - Win Rate for 80% effectiveness target + + Usage: + calculator = MetricsCalculator(risk_free_rate=0.02) + + # From equity curve + returns = calculator.calculate_returns(equity_curve) + sharpe = calculator.calculate_sharpe_ratio(returns) + + # From trades + metrics = calculator.calculate_all_metrics(backtest_result) + metrics.print_summary() + """ + + ANNUALIZATION_FACTOR = 252 # Trading days per year + TARGET_DIRECTION_ACCURACY = 0.60 + TARGET_SHARPE_RATIO = 1.5 + TARGET_MAX_DRAWDOWN = 0.15 + TARGET_WIN_RATE = 0.80 + + def __init__( + self, + risk_free_rate: float = 0.02, + periods_per_year: int = 252 + ): """ - Initialize calculator + Initialize calculator. Args: risk_free_rate: Annual risk-free rate for Sharpe calculation + periods_per_year: Number of periods per year for annualization """ self.risk_free_rate = risk_free_rate + self.periods_per_year = periods_per_year + + def calculate_returns( + self, + equity_curve: Union[np.ndarray, pd.Series, List[float]], + method: str = 'simple' + ) -> np.ndarray: + """ + Calculate returns from equity curve. + + Args: + equity_curve: Array of equity values over time + method: 'simple' for arithmetic returns, 'log' for logarithmic + + Returns: + Array of period returns + """ + equity = np.asarray(equity_curve).ravel() + + if len(equity) < 2: + return np.array([0.0]) + + if method == 'log': + # Log returns + returns = np.diff(np.log(equity)) + else: + # Simple returns + returns = np.diff(equity) / equity[:-1] + + # Handle inf/nan from division + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + + return returns + + def calculate_sharpe_ratio( + self, + returns: Union[np.ndarray, pd.Series, List[float]], + risk_free_rate: Optional[float] = None, + annualize: bool = True + ) -> float: + """ + Calculate Sharpe ratio. + + Sharpe = (mean_return - risk_free) / std_return * sqrt(periods) + + Args: + returns: Array of period returns + risk_free_rate: Risk-free rate (uses instance default if None) + annualize: Whether to annualize the ratio + + Returns: + Sharpe ratio (higher is better, >= 1.5 target for ensemble) + """ + returns = np.asarray(returns).ravel() + + if len(returns) < 2: + return 0.0 + + rf = risk_free_rate if risk_free_rate is not None else self.risk_free_rate + rf_period = rf / self.periods_per_year + + mean_return = np.mean(returns) + std_return = np.std(returns, ddof=1) + + if std_return < 1e-10: + return 0.0 + + excess_return = mean_return - rf_period + sharpe = excess_return / std_return + + if annualize: + sharpe *= np.sqrt(self.periods_per_year) + + return float(sharpe) + + def calculate_sortino_ratio( + self, + returns: Union[np.ndarray, pd.Series, List[float]], + risk_free_rate: Optional[float] = None, + annualize: bool = True + ) -> float: + """ + Calculate Sortino ratio (only downside deviation). + + Sortino = (mean_return - risk_free) / downside_std * sqrt(periods) + + Args: + returns: Array of period returns + risk_free_rate: Risk-free rate (uses instance default if None) + annualize: Whether to annualize the ratio + + Returns: + Sortino ratio (higher is better) + """ + returns = np.asarray(returns).ravel() + + if len(returns) < 2: + return 0.0 + + rf = risk_free_rate if risk_free_rate is not None else self.risk_free_rate + rf_period = rf / self.periods_per_year + + mean_return = np.mean(returns) + + # Calculate downside deviation (only negative returns below target) + target_return = rf_period + downside_returns = returns[returns < target_return] + + if len(downside_returns) == 0: + # No downside, return high positive value + return float('inf') if mean_return > rf_period else 0.0 + + downside_std = np.sqrt(np.mean((downside_returns - target_return) ** 2)) + + if downside_std < 1e-10: + return 0.0 + + excess_return = mean_return - rf_period + sortino = excess_return / downside_std + + if annualize: + sortino *= np.sqrt(self.periods_per_year) + + return float(sortino) + + def calculate_max_drawdown( + self, + equity_curve: Union[np.ndarray, pd.Series, List[float]] + ) -> Tuple[float, float, int]: + """ + Calculate maximum drawdown and duration. + + Args: + equity_curve: Array of equity values over time + + Returns: + Tuple of (max_drawdown_value, max_drawdown_pct, max_drawdown_duration) + """ + equity = np.asarray(equity_curve).ravel() + + if len(equity) < 2: + return (0.0, 0.0, 0) + + # Running maximum + running_max = np.maximum.accumulate(equity) + + # Drawdown at each point + drawdown = running_max - equity + drawdown_pct = np.divide( + drawdown, + running_max, + out=np.zeros_like(drawdown), + where=running_max > 0 + ) + + # Maximum drawdown + max_dd_idx = np.argmax(drawdown) + max_dd = float(drawdown[max_dd_idx]) + max_dd_pct = float(drawdown_pct[max_dd_idx]) + + # Drawdown duration (longest period below peak) + in_drawdown = drawdown > 0 + max_duration = 0 + current_duration = 0 + + for in_dd in in_drawdown: + if in_dd: + current_duration += 1 + max_duration = max(max_duration, current_duration) + else: + current_duration = 0 + + return (max_dd, max_dd_pct, max_duration) + + def calculate_calmar_ratio( + self, + returns: Union[np.ndarray, pd.Series, List[float]], + max_drawdown: Optional[float] = None, + equity_curve: Optional[np.ndarray] = None + ) -> float: + """ + Calculate Calmar ratio (annualized return / max drawdown). + + Args: + returns: Array of period returns + max_drawdown: Maximum drawdown percentage (0-1) + equity_curve: Equity curve to calculate max_drawdown if not provided + + Returns: + Calmar ratio (higher is better) + """ + returns = np.asarray(returns).ravel() + + if len(returns) < 2: + return 0.0 + + # Calculate annualized return + cumulative_return = np.prod(1 + returns) - 1 + n_periods = len(returns) + annualized_return = (1 + cumulative_return) ** (self.periods_per_year / n_periods) - 1 + + # Get max drawdown if not provided + if max_drawdown is None: + if equity_curve is not None: + _, max_dd_pct, _ = self.calculate_max_drawdown(equity_curve) + max_drawdown = max_dd_pct + else: + # Calculate from returns + equity = np.cumprod(1 + returns) + _, max_dd_pct, _ = self.calculate_max_drawdown(equity) + max_drawdown = max_dd_pct + + if max_drawdown < 1e-10: + return float('inf') if annualized_return > 0 else 0.0 + + return float(annualized_return / max_drawdown) + + def calculate_win_rate( + self, + trades: List['TradeRecord'] + ) -> float: + """ + Calculate win rate from trades. + + Args: + trades: List of trade records + + Returns: + Win rate (0-1), target >= 0.80 for effectiveness + """ + if not trades: + return 0.0 + + closed_trades = [t for t in trades if t.result != 'open'] + if not closed_trades: + return 0.0 + + winning = sum(1 for t in closed_trades if t.pnl > 0) + return winning / len(closed_trades) + + def calculate_profit_factor( + self, + trades: List['TradeRecord'] + ) -> float: + """ + Calculate profit factor (gross profit / gross loss). + + Args: + trades: List of trade records + + Returns: + Profit factor (> 1 is profitable, > 2 is good) + """ + if not trades: + return 0.0 + + gross_profit = sum(t.pnl for t in trades if t.pnl > 0) + gross_loss = abs(sum(t.pnl for t in trades if t.pnl < 0)) + + if gross_loss < 1e-10: + return float('inf') if gross_profit > 0 else 0.0 + + return float(gross_profit / gross_loss) + + def calculate_avg_win_loss_ratio( + self, + trades: List['TradeRecord'] + ) -> float: + """ + Calculate average win / average loss ratio. + + Args: + trades: List of trade records + + Returns: + Ratio of average win to average loss + """ + if not trades: + return 0.0 + + wins = [t.pnl for t in trades if t.pnl > 0] + losses = [abs(t.pnl) for t in trades if t.pnl < 0] + + if not wins or not losses: + return 0.0 + + avg_win = np.mean(wins) + avg_loss = np.mean(losses) + + if avg_loss < 1e-10: + return float('inf') if avg_win > 0 else 0.0 + + return float(avg_win / avg_loss) + + def calculate_expectancy( + self, + trades: List['TradeRecord'] + ) -> float: + """ + Calculate expectancy (expected profit per trade). + + Expectancy = (win_rate * avg_win) - (loss_rate * avg_loss) + + Args: + trades: List of trade records + + Returns: + Expected profit per trade + """ + if not trades: + return 0.0 + + closed_trades = [t for t in trades if t.result != 'open'] + if not closed_trades: + return 0.0 + + wins = [t.pnl for t in closed_trades if t.pnl > 0] + losses = [abs(t.pnl) for t in closed_trades if t.pnl < 0] + + win_rate = len(wins) / len(closed_trades) + loss_rate = 1 - win_rate + + avg_win = np.mean(wins) if wins else 0.0 + avg_loss = np.mean(losses) if losses else 0.0 + + expectancy = (win_rate * avg_win) - (loss_rate * avg_loss) + + return float(expectancy) + + def calculate_direction_accuracy( + self, + trades: List['TradeRecord'] + ) -> Dict[str, float]: + """ + Calculate direction accuracy metrics. + + Args: + trades: List of trade records with direction field + + Returns: + Dictionary with overall, long, and short accuracy + """ + if not trades: + return { + 'overall': 0.0, + 'long': 0.0, + 'short': 0.0, + 'long_trades': 0, + 'short_trades': 0 + } + + closed_trades = [t for t in trades if t.result != 'open'] + if not closed_trades: + return { + 'overall': 0.0, + 'long': 0.0, + 'short': 0.0, + 'long_trades': 0, + 'short_trades': 0 + } + + # Overall accuracy + correct = sum(1 for t in closed_trades if t.pnl > 0) + overall = correct / len(closed_trades) + + # Long accuracy + long_trades = [t for t in closed_trades if t.direction == 'long'] + long_correct = sum(1 for t in long_trades if t.pnl > 0) + long_accuracy = long_correct / len(long_trades) if long_trades else 0.0 + + # Short accuracy + short_trades = [t for t in closed_trades if t.direction == 'short'] + short_correct = sum(1 for t in short_trades if t.pnl > 0) + short_accuracy = short_correct / len(short_trades) if short_trades else 0.0 + + return { + 'overall': overall, + 'long': long_accuracy, + 'short': short_accuracy, + 'long_trades': len(long_trades), + 'short_trades': len(short_trades) + } + + def calculate_omega_ratio( + self, + returns: Union[np.ndarray, pd.Series, List[float]], + threshold: float = 0.0 + ) -> float: + """ + Calculate Omega ratio. + + Omega = sum(returns > threshold) / sum(returns < threshold) + + Args: + returns: Array of period returns + threshold: Return threshold (default 0) + + Returns: + Omega ratio (> 1 is good) + """ + returns = np.asarray(returns).ravel() + + if len(returns) < 2: + return 0.0 + + gains = np.sum(np.maximum(returns - threshold, 0)) + losses = np.sum(np.maximum(threshold - returns, 0)) + + if losses < 1e-10: + return float('inf') if gains > 0 else 1.0 + + return float(gains / losses) + + def calculate_tail_ratio( + self, + returns: Union[np.ndarray, pd.Series, List[float]], + percentile: float = 0.05 + ) -> float: + """ + Calculate tail ratio (right tail / left tail). + + Args: + returns: Array of period returns + percentile: Percentile for tail calculation + + Returns: + Tail ratio (> 1 means right tail is fatter) + """ + returns = np.asarray(returns).ravel() + + if len(returns) < 2: + return 0.0 + + right_tail = np.abs(np.percentile(returns, 100 * (1 - percentile))) + left_tail = np.abs(np.percentile(returns, 100 * percentile)) + + if left_tail < 1e-10: + return float('inf') if right_tail > 0 else 1.0 + + return float(right_tail / left_tail) + + def calculate_all_metrics( + self, + backtest_result: Any, + initial_capital: float = 10000.0 + ) -> PerformanceMetrics: + """ + Calculate all performance metrics from backtest result. + + Args: + backtest_result: BacktestResult object with trades and equity_curve + initial_capital: Initial capital for percentage calculations + + Returns: + PerformanceMetrics object with all metrics calculated + """ + metrics = PerformanceMetrics() + + # Extract data from backtest result + trades = getattr(backtest_result, 'trades', []) + equity_curve = getattr(backtest_result, 'equity_curve', np.array([initial_capital])) + + if isinstance(equity_curve, pd.Series): + equity_curve = equity_curve.values + + if len(equity_curve) == 0: + equity_curve = np.array([initial_capital]) + + # Calculate returns + returns = self.calculate_returns(equity_curve) + + # Returns metrics + metrics.total_return = float(equity_curve[-1] - initial_capital) + metrics.total_return_pct = float(metrics.total_return / initial_capital) + + if len(returns) > 0: + cumulative_return = np.prod(1 + returns) - 1 + n_periods = len(returns) + if n_periods > 0: + metrics.annualized_return = float( + (1 + cumulative_return) ** (self.periods_per_year / n_periods) - 1 + ) + metrics.daily_return_mean = float(np.mean(returns)) + metrics.daily_return_std = float(np.std(returns, ddof=1)) if len(returns) > 1 else 0.0 + + # Risk-adjusted returns + metrics.sharpe_ratio = self.calculate_sharpe_ratio(returns) + metrics.sortino_ratio = self.calculate_sortino_ratio(returns) + max_dd, max_dd_pct, max_dd_duration = self.calculate_max_drawdown(equity_curve) + metrics.calmar_ratio = self.calculate_calmar_ratio(returns, max_dd_pct) + metrics.omega_ratio = self.calculate_omega_ratio(returns) + + # Drawdown metrics + metrics.max_drawdown = max_dd + metrics.max_drawdown_pct = max_dd_pct + metrics.max_drawdown_duration = max_dd_duration + metrics.recovery_factor = float(metrics.total_return / max_dd) if max_dd > 0 else 0.0 + + # Trade statistics + closed_trades = [t for t in trades if t.result != 'open'] + metrics.total_trades = len(closed_trades) + + if closed_trades: + pnls = [t.pnl for t in closed_trades] + + metrics.winning_trades = sum(1 for p in pnls if p > 0) + metrics.losing_trades = sum(1 for p in pnls if p < 0) + metrics.breakeven_trades = sum(1 for p in pnls if p == 0) + + metrics.win_rate = metrics.winning_trades / len(closed_trades) + metrics.loss_rate = metrics.losing_trades / len(closed_trades) + + # Profit metrics + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p < 0] + + metrics.gross_profit = float(sum(wins)) + metrics.gross_loss = float(abs(sum(losses))) + metrics.net_profit = float(sum(pnls)) + + metrics.profit_factor = self.calculate_profit_factor(closed_trades) + metrics.avg_win = float(np.mean(wins)) if wins else 0.0 + metrics.avg_loss = float(np.mean([abs(l) for l in losses])) if losses else 0.0 + metrics.avg_trade = float(np.mean(pnls)) + metrics.avg_win_loss_ratio = self.calculate_avg_win_loss_ratio(closed_trades) + metrics.expectancy = self.calculate_expectancy(closed_trades) + + if metrics.avg_loss > 0: + metrics.expectancy_ratio = metrics.expectancy / metrics.avg_loss + + # Extremes + metrics.largest_win = float(max(pnls)) if pnls else 0.0 + metrics.largest_loss = float(min(pnls)) if pnls else 0.0 + + # Streaks + max_wins, max_losses = self._calculate_streaks(pnls) + metrics.largest_win_streak = max_wins + metrics.largest_loss_streak = max_losses + + # Direction accuracy + direction_stats = self.calculate_direction_accuracy(closed_trades) + metrics.direction_accuracy = direction_stats['overall'] + metrics.long_accuracy = direction_stats['long'] + metrics.short_accuracy = direction_stats['short'] + metrics.long_trades = direction_stats['long_trades'] + metrics.short_trades = direction_stats['short_trades'] + + # Duration + durations = [t.duration_minutes for t in closed_trades if t.duration_minutes > 0] + if durations: + metrics.avg_trade_duration = float(np.mean(durations)) + + win_durations = [t.duration_minutes for t in closed_trades if t.pnl > 0] + loss_durations = [t.duration_minutes for t in closed_trades if t.pnl < 0] + + metrics.avg_win_duration = float(np.mean(win_durations)) if win_durations else 0.0 + metrics.avg_loss_duration = float(np.mean(loss_durations)) if loss_durations else 0.0 + + # Consistency metrics + if len(returns) > 0: + metrics.positive_periods_ratio = float(np.mean(returns > 0)) + metrics.tail_ratio = self.calculate_tail_ratio(returns) + + pain = np.sum(np.abs(returns[returns < 0])) + if pain > 0: + metrics.gain_to_pain_ratio = float(np.sum(returns) / pain) + + # Time metrics + if closed_trades: + entry_times = [t.entry_time for t in closed_trades if t.entry_time] + if entry_times: + metrics.start_date = min(entry_times) + metrics.end_date = max(entry_times) + if metrics.start_date and metrics.end_date: + metrics.trading_days = (metrics.end_date - metrics.start_date).days + + metrics.total_periods = len(equity_curve) + + return metrics def calculate_metrics( self, - trades: List[TradeRecord], + trades: List['TradeRecord'], initial_capital: float = 10000.0 ) -> TradingMetrics: """ diff --git a/src/backtesting/ml_backtest_engine.py b/src/backtesting/ml_backtest_engine.py new file mode 100644 index 0000000..d5a9ffb --- /dev/null +++ b/src/backtesting/ml_backtest_engine.py @@ -0,0 +1,1185 @@ +""" +ML Backtesting Engine for model validation. + +This module provides a comprehensive backtesting engine specifically designed +for validating machine learning models in trading contexts. It supports: + +- Individual strategy testing +- Ensemble model testing +- Walk-forward validation +- Configurable transaction costs (0.5 pips forex, 0.1% crypto) +- Kelly criterion position sizing (max 2%) +- Complete trade history with entry/exit timestamps +""" + +import numpy as np +import pandas as pd +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple, Any, Callable, Union, Protocol +from loguru import logger +from abc import ABC, abstractmethod +import json +from pathlib import Path + +from .trade import Trade, TradeDirection, TradeStatus +from .position_manager import PositionManager, PositionSizingConfig + + +class Strategy(Protocol): + """Protocol defining the interface for trading strategies.""" + + def generate_signal( + self, + data: pd.DataFrame, + index: int + ) -> Optional[Dict[str, Any]]: + """ + Generate a trading signal for the current bar. + + Args: + data: Full OHLCV dataframe + index: Current bar index + + Returns: + Signal dictionary with keys: + - direction: 'long' or 'short' + - confidence: float 0-1 + - stop_loss: price level + - take_profit: price level + Or None if no signal + """ + ... + + +@dataclass +class BacktestConfig: + """ + Configuration for backtesting. + + Attributes: + start_date: Start date for backtest + end_date: End date for backtest + initial_capital: Starting capital amount + commission_type: Type of commission ('pips', 'percent', 'fixed') + commission_value: Commission value based on type + slippage_type: Type of slippage ('pips', 'percent', 'fixed') + slippage_value: Slippage value based on type + position_sizing: Position sizing method ('kelly', 'fixed', 'fixed_risk') + max_position_pct: Maximum position as percentage of capital (default 2%) + kelly_fraction: Fraction of Kelly to use (default 0.25) + max_concurrent_positions: Maximum positions at once + min_confidence: Minimum signal confidence to trade + enable_shorting: Whether short positions are allowed + asset_type: Type of asset ('forex', 'crypto', 'stock') + timeframe: Trading timeframe (e.g., '1H', '4H', '1D') + risk_free_rate: Annual risk-free rate for Sharpe calculation + """ + + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + initial_capital: float = 10000.0 + + commission_type: str = "pips" + commission_value: float = 0.5 + + slippage_type: str = "pips" + slippage_value: float = 0.2 + + position_sizing: str = "kelly" + max_position_pct: float = 0.02 + kelly_fraction: float = 0.25 + risk_per_trade: float = 0.02 + + max_concurrent_positions: int = 3 + min_confidence: float = 0.55 + enable_shorting: bool = True + + asset_type: str = "forex" + timeframe: str = "1H" + risk_free_rate: float = 0.02 + + walk_forward_splits: int = 5 + walk_forward_train_pct: float = 0.7 + walk_forward_gap: int = 0 + + def get_commission(self, price: float, size: float) -> float: + """Calculate commission for a trade.""" + if self.commission_type == "pips": + pip_value = 0.0001 if self.asset_type == "forex" else 1.0 + return self.commission_value * pip_value * size + elif self.commission_type == "percent": + return price * size * (self.commission_value / 100) + else: + return self.commission_value + + def get_slippage(self, price: float) -> float: + """Calculate slippage for a trade.""" + if self.slippage_type == "pips": + pip_value = 0.0001 if self.asset_type == "forex" else 1.0 + return self.slippage_value * pip_value + elif self.slippage_type == "percent": + return price * (self.slippage_value / 100) + else: + return self.slippage_value + + @classmethod + def for_forex(cls, **kwargs) -> "BacktestConfig": + """Create config optimized for forex trading.""" + defaults = { + "asset_type": "forex", + "commission_type": "pips", + "commission_value": 0.5, + "slippage_type": "pips", + "slippage_value": 0.2, + } + defaults.update(kwargs) + return cls(**defaults) + + @classmethod + def for_crypto(cls, **kwargs) -> "BacktestConfig": + """Create config optimized for crypto trading.""" + defaults = { + "asset_type": "crypto", + "commission_type": "percent", + "commission_value": 0.1, + "slippage_type": "percent", + "slippage_value": 0.05, + } + defaults.update(kwargs) + return cls(**defaults) + + +@dataclass +class BacktestMetrics: + """ + Comprehensive metrics for backtest results. + + Contains all standard trading metrics plus ML-specific metrics. + """ + + total_trades: int = 0 + winning_trades: int = 0 + losing_trades: int = 0 + win_rate: float = 0.0 + + total_profit: float = 0.0 + total_profit_pct: float = 0.0 + gross_profit: float = 0.0 + gross_loss: float = 0.0 + + profit_factor: float = 0.0 + avg_win: float = 0.0 + avg_loss: float = 0.0 + avg_trade: float = 0.0 + expectancy: float = 0.0 + + best_trade: float = 0.0 + worst_trade: float = 0.0 + avg_trade_duration_minutes: float = 0.0 + + max_drawdown: float = 0.0 + max_drawdown_pct: float = 0.0 + max_drawdown_duration_days: float = 0.0 + + sharpe_ratio: float = 0.0 + sortino_ratio: float = 0.0 + calmar_ratio: float = 0.0 + + max_consecutive_wins: int = 0 + max_consecutive_losses: int = 0 + + long_trades: int = 0 + short_trades: int = 0 + long_win_rate: float = 0.0 + short_win_rate: float = 0.0 + + total_commission: float = 0.0 + total_slippage: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert metrics to dictionary.""" + return { + "total_trades": self.total_trades, + "winning_trades": self.winning_trades, + "losing_trades": self.losing_trades, + "win_rate": self.win_rate, + "total_profit": self.total_profit, + "total_profit_pct": self.total_profit_pct, + "gross_profit": self.gross_profit, + "gross_loss": self.gross_loss, + "profit_factor": self.profit_factor, + "avg_win": self.avg_win, + "avg_loss": self.avg_loss, + "avg_trade": self.avg_trade, + "expectancy": self.expectancy, + "best_trade": self.best_trade, + "worst_trade": self.worst_trade, + "avg_trade_duration_minutes": self.avg_trade_duration_minutes, + "max_drawdown": self.max_drawdown, + "max_drawdown_pct": self.max_drawdown_pct, + "max_drawdown_duration_days": self.max_drawdown_duration_days, + "sharpe_ratio": self.sharpe_ratio, + "sortino_ratio": self.sortino_ratio, + "calmar_ratio": self.calmar_ratio, + "max_consecutive_wins": self.max_consecutive_wins, + "max_consecutive_losses": self.max_consecutive_losses, + "long_trades": self.long_trades, + "short_trades": self.short_trades, + "long_win_rate": self.long_win_rate, + "short_win_rate": self.short_win_rate, + "total_commission": self.total_commission, + "total_slippage": self.total_slippage + } + + +@dataclass +class WalkForwardPeriod: + """ + Represents a single walk-forward validation period. + + Attributes: + period_id: Unique identifier for the period + train_start: Start index of training period + train_end: End index of training period + test_start: Start index of test period + test_end: End index of test period + """ + + period_id: int + train_start: int + train_end: int + test_start: int + test_end: int + + @property + def train_size(self) -> int: + return self.train_end - self.train_start + + @property + def test_size(self) -> int: + return self.test_end - self.test_start + + +@dataclass +class BacktestResult: + """ + Complete results from a backtest run. + + Attributes: + config: Backtest configuration used + metrics: Performance metrics + trades: List of all trades executed + equity_curve: Series of equity values over time + drawdown_curve: Series of drawdown values over time + daily_returns: Daily return series + signals_generated: Total signals generated by strategy + signals_traded: Signals that resulted in trades + start_time: Backtest start timestamp + end_time: Backtest end timestamp + """ + + config: BacktestConfig + metrics: BacktestMetrics + trades: List[Trade] + equity_curve: pd.Series + drawdown_curve: pd.Series + daily_returns: pd.Series + + signals_generated: int = 0 + signals_traded: int = 0 + signals_filtered: int = 0 + + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + + walk_forward_results: Optional[List["BacktestResult"]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert result to dictionary.""" + return { + "metrics": self.metrics.to_dict(), + "signals_generated": self.signals_generated, + "signals_traded": self.signals_traded, + "signals_filtered": self.signals_filtered, + "start_time": self.start_time.isoformat() if self.start_time else None, + "end_time": self.end_time.isoformat() if self.end_time else None, + "num_trades": len(self.trades), + "final_equity": float(self.equity_curve.iloc[-1]) if len(self.equity_curve) > 0 else 0, + "config": { + "initial_capital": self.config.initial_capital, + "asset_type": self.config.asset_type, + "position_sizing": self.config.position_sizing, + "max_position_pct": self.config.max_position_pct + } + } + + def save_report(self, filepath: str) -> None: + """Save detailed report to JSON file.""" + report = { + "summary": self.to_dict(), + "trades": [t.to_dict() for t in self.trades], + "equity_curve": self.equity_curve.tolist(), + "drawdown_curve": self.drawdown_curve.tolist(), + "daily_returns": self.daily_returns.tolist() if self.daily_returns is not None else [] + } + + path = Path(filepath) + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, "w") as f: + json.dump(report, f, indent=2, default=str) + + logger.info(f"Saved backtest report to {filepath}") + + def print_summary(self) -> None: + """Print formatted summary of results.""" + m = self.metrics + print("\n" + "=" * 60) + print("BACKTEST RESULTS SUMMARY") + print("=" * 60) + + print(f"\nPerformance:") + print(f" Total Trades: {m.total_trades}") + print(f" Win Rate: {m.win_rate:.1%}") + print(f" Profit Factor: {m.profit_factor:.2f}") + print(f" Total Profit: ${m.total_profit:,.2f} ({m.total_profit_pct:.1f}%)") + + print(f"\nRisk Metrics:") + print(f" Max Drawdown: {m.max_drawdown_pct:.1%}") + print(f" Sharpe Ratio: {m.sharpe_ratio:.2f}") + print(f" Sortino Ratio: {m.sortino_ratio:.2f}") + + print(f"\nTrade Analysis:") + print(f" Avg Win: ${m.avg_win:,.2f}") + print(f" Avg Loss: ${m.avg_loss:,.2f}") + print(f" Best Trade: ${m.best_trade:,.2f}") + print(f" Worst Trade: ${m.worst_trade:,.2f}") + print(f" Expectancy: ${m.expectancy:,.2f}") + + print(f"\nTransaction Costs:") + print(f" Total Commission: ${m.total_commission:,.2f}") + print(f" Total Slippage: ${m.total_slippage:,.2f}") + + print("=" * 60 + "\n") + + +class BacktestEngine: + """ + Main backtesting engine for ML model validation. + + This engine provides comprehensive backtesting capabilities including: + - Individual strategy testing + - Ensemble model testing + - Walk-forward validation + - Transaction cost modeling + - Kelly criterion position sizing + + Example usage: + ```python + config = BacktestConfig.for_forex( + initial_capital=10000, + position_sizing='kelly', + max_position_pct=0.02 + ) + + engine = BacktestEngine(config) + result = engine.run_backtest(strategy, data) + result.print_summary() + ``` + """ + + def __init__(self, config: Optional[BacktestConfig] = None): + """ + Initialize the backtesting engine. + + Args: + config: Backtest configuration + """ + self.config = config or BacktestConfig() + + sizing_config = PositionSizingConfig( + method=self.config.position_sizing, + max_position_pct=self.config.max_position_pct, + kelly_fraction=self.config.kelly_fraction, + risk_per_trade_pct=self.config.risk_per_trade, + ) + + self.position_manager = PositionManager( + initial_capital=self.config.initial_capital, + sizing_config=sizing_config, + max_positions=self.config.max_concurrent_positions + ) + + self.equity_history: List[Tuple[datetime, float]] = [] + self.signals_generated = 0 + self.signals_traded = 0 + self.signals_filtered = 0 + + logger.info( + f"BacktestEngine initialized: capital=${self.config.initial_capital:,.2f}, " + f"sizing={self.config.position_sizing}, max_pos={self.config.max_position_pct:.1%}" + ) + + def reset(self) -> None: + """Reset engine to initial state.""" + self.position_manager.reset() + self.equity_history = [] + self.signals_generated = 0 + self.signals_traded = 0 + self.signals_filtered = 0 + + def run_backtest( + self, + strategy: Union[Strategy, Callable], + data: pd.DataFrame, + symbol: str = "DEFAULT" + ) -> BacktestResult: + """ + Run a backtest with the given strategy and data. + + Args: + strategy: Trading strategy implementing generate_signal method + or a callable that takes (data, index) and returns signal dict + data: OHLCV DataFrame with datetime index and columns: + ['open', 'high', 'low', 'close', 'volume'] + symbol: Trading symbol name + + Returns: + BacktestResult with complete performance metrics and trade history + """ + self.reset() + + if self.config.start_date: + data = data[data.index >= self.config.start_date] + if self.config.end_date: + data = data[data.index <= self.config.end_date] + + if len(data) < 10: + raise ValueError("Insufficient data for backtesting") + + logger.info(f"Starting backtest on {len(data)} bars for {symbol}") + + start_time = data.index[0] + initial_equity = self.config.initial_capital + self.equity_history.append((start_time, initial_equity)) + + for i in range(len(data)): + current_time = data.index[i] + current_bar = data.iloc[i] + current_price = current_bar["close"] + + self._update_positions(data, i, current_time, symbol) + + if i < len(data) - 1: + signal = self._get_signal(strategy, data, i) + + if signal is not None: + self.signals_generated += 1 + self._process_signal( + signal, data, i, current_time, current_price, symbol + ) + + equity = self.position_manager.get_equity({symbol: current_price}) + self.equity_history.append((current_time, equity)) + + self._close_all_positions(data, symbol) + end_time = data.index[-1] + + result = self._generate_result(start_time, end_time) + logger.info( + f"Backtest complete: {len(self.position_manager.closed_positions)} trades, " + f"Net P&L: ${result.metrics.total_profit:,.2f}" + ) + + return result + + def run_backtest_with_signals( + self, + signals: pd.DataFrame, + data: pd.DataFrame, + symbol: str = "DEFAULT" + ) -> BacktestResult: + """ + Run backtest with pre-generated signals DataFrame. + + This is useful for testing ML model predictions directly. + + Args: + signals: DataFrame with signal information including: + - direction: 'long' or 'short' + - confidence: float 0-1 + - stop_loss: price level (optional) + - take_profit: price level (optional) + data: OHLCV DataFrame + symbol: Trading symbol + + Returns: + BacktestResult + """ + self.reset() + + common_idx = data.index.intersection(signals.index) + data = data.loc[common_idx] + signals = signals.loc[common_idx] + + if len(data) < 10: + raise ValueError("Insufficient data for backtesting") + + logger.info(f"Starting signal-based backtest on {len(data)} bars") + + start_time = data.index[0] + self.equity_history.append((start_time, self.config.initial_capital)) + + for i in range(len(data)): + current_time = data.index[i] + current_bar = data.iloc[i] + current_price = current_bar["close"] + + self._update_positions(data, i, current_time, symbol) + + if current_time in signals.index: + signal_row = signals.loc[current_time] + + if pd.notna(signal_row.get("direction")): + signal = { + "direction": signal_row["direction"], + "confidence": signal_row.get("confidence", 0.6), + "stop_loss": signal_row.get("stop_loss"), + "take_profit": signal_row.get("take_profit") + } + self.signals_generated += 1 + self._process_signal( + signal, data, i, current_time, current_price, symbol + ) + + equity = self.position_manager.get_equity({symbol: current_price}) + self.equity_history.append((current_time, equity)) + + self._close_all_positions(data, symbol) + end_time = data.index[-1] + + return self._generate_result(start_time, end_time) + + def run_ensemble_backtest( + self, + strategies: List[Union[Strategy, Callable]], + data: pd.DataFrame, + symbol: str = "DEFAULT", + combination_method: str = "majority_vote", + weights: Optional[List[float]] = None + ) -> BacktestResult: + """ + Run backtest with an ensemble of strategies. + + Args: + strategies: List of trading strategies + data: OHLCV DataFrame + symbol: Trading symbol + combination_method: How to combine signals: + 'majority_vote' - trade when majority agree + 'weighted_avg' - weighted confidence average + 'unanimous' - all strategies must agree + weights: Weights for each strategy (for weighted_avg) + + Returns: + BacktestResult + """ + self.reset() + + if weights is None: + weights = [1.0 / len(strategies)] * len(strategies) + + logger.info( + f"Starting ensemble backtest with {len(strategies)} strategies, " + f"method={combination_method}" + ) + + start_time = data.index[0] + self.equity_history.append((start_time, self.config.initial_capital)) + + for i in range(len(data)): + current_time = data.index[i] + current_bar = data.iloc[i] + current_price = current_bar["close"] + + self._update_positions(data, i, current_time, symbol) + + if i < len(data) - 1: + signals = [] + for strategy in strategies: + signal = self._get_signal(strategy, data, i) + signals.append(signal) + + combined_signal = self._combine_signals( + signals, combination_method, weights + ) + + if combined_signal is not None: + self.signals_generated += 1 + self._process_signal( + combined_signal, data, i, current_time, current_price, symbol + ) + + equity = self.position_manager.get_equity({symbol: current_price}) + self.equity_history.append((current_time, equity)) + + self._close_all_positions(data, symbol) + end_time = data.index[-1] + + return self._generate_result(start_time, end_time) + + def run_walk_forward_validation( + self, + strategy_factory: Callable[[pd.DataFrame], Union[Strategy, Callable]], + data: pd.DataFrame, + symbol: str = "DEFAULT" + ) -> BacktestResult: + """ + Run walk-forward validation. + + This method trains a new strategy instance on each training period + and tests it on the subsequent out-of-sample period. + + Args: + strategy_factory: Function that takes training data and returns + a trained strategy + data: Full OHLCV DataFrame + symbol: Trading symbol + + Returns: + BacktestResult with combined results and individual period results + """ + n_splits = self.config.walk_forward_splits + train_pct = self.config.walk_forward_train_pct + gap = self.config.walk_forward_gap + + periods = self._create_walk_forward_periods(len(data), n_splits, train_pct, gap) + + logger.info(f"Starting walk-forward validation with {len(periods)} periods") + + period_results = [] + all_trades = [] + + for period in periods: + logger.info( + f"Period {period.period_id}: " + f"Train [{period.train_start}:{period.train_end}], " + f"Test [{period.test_start}:{period.test_end}]" + ) + + train_data = data.iloc[period.train_start:period.train_end] + test_data = data.iloc[period.test_start:period.test_end] + + strategy = strategy_factory(train_data) + + self.reset() + result = self.run_backtest(strategy, test_data, symbol) + + period_results.append(result) + all_trades.extend(result.trades) + + combined_result = self._combine_walk_forward_results( + period_results, all_trades, data.index[0], data.index[-1] + ) + combined_result.walk_forward_results = period_results + + return combined_result + + def _get_signal( + self, + strategy: Union[Strategy, Callable], + data: pd.DataFrame, + index: int + ) -> Optional[Dict[str, Any]]: + """Get signal from strategy.""" + try: + if hasattr(strategy, "generate_signal"): + return strategy.generate_signal(data, index) + else: + return strategy(data, index) + except Exception as e: + logger.warning(f"Error generating signal at index {index}: {e}") + return None + + def _process_signal( + self, + signal: Dict[str, Any], + data: pd.DataFrame, + index: int, + current_time: datetime, + current_price: float, + symbol: str + ) -> None: + """Process a trading signal.""" + direction_str = signal.get("direction", "").lower() + confidence = signal.get("confidence", 0.0) + stop_loss = signal.get("stop_loss") + take_profit = signal.get("take_profit") + + if confidence < self.config.min_confidence: + self.signals_filtered += 1 + return + + if direction_str == "short" and not self.config.enable_shorting: + self.signals_filtered += 1 + return + + if not self.position_manager.can_open_position(): + self.signals_filtered += 1 + return + + try: + direction = TradeDirection.from_string(direction_str) + except ValueError: + self.signals_filtered += 1 + return + + commission = self.config.get_commission(current_price, 1.0) + slippage = self.config.get_slippage(current_price) + + trade = self.position_manager.open_position( + symbol=symbol, + direction=direction, + entry_price=current_price, + entry_time=current_time, + stop_loss=stop_loss, + take_profit=take_profit, + commission=commission, + slippage=slippage, + signal_confidence=confidence, + timeframe=self.config.timeframe, + metadata=signal.get("metadata", {}) + ) + + if trade is not None: + self.signals_traded += 1 + + def _update_positions( + self, + data: pd.DataFrame, + index: int, + current_time: datetime, + symbol: str + ) -> None: + """Update open positions with current bar data.""" + if index >= len(data): + return + + bar = data.iloc[index] + high = bar["high"] + low = bar["low"] + close = bar["close"] + + for trade in self.position_manager.open_positions[:]: + if trade.symbol != symbol: + continue + + commission = self.config.get_commission(close, trade.size) + slippage = self.config.get_slippage(close) + + self.position_manager.update_position_with_ohlc( + trade=trade, + high=high, + low=low, + close=close, + current_time=current_time, + commission_rate=0, + slippage=slippage + ) + + def _close_all_positions(self, data: pd.DataFrame, symbol: str) -> None: + """Close all remaining open positions at end of backtest.""" + if len(data) == 0: + return + + last_bar = data.iloc[-1] + last_time = data.index[-1] + close_price = last_bar["close"] + + for trade in self.position_manager.open_positions[:]: + if trade.symbol == symbol: + commission = self.config.get_commission(close_price, trade.size) + slippage = self.config.get_slippage(close_price) + + self.position_manager.close_position( + trade=trade, + exit_price=close_price, + exit_time=last_time, + status=TradeStatus.TIMEOUT, + commission=commission, + slippage=slippage + ) + + def _combine_signals( + self, + signals: List[Optional[Dict[str, Any]]], + method: str, + weights: List[float] + ) -> Optional[Dict[str, Any]]: + """Combine signals from multiple strategies.""" + valid_signals = [(s, w) for s, w in zip(signals, weights) if s is not None] + + if not valid_signals: + return None + + if method == "majority_vote": + long_votes = sum( + 1 for s, _ in valid_signals if s.get("direction", "").lower() == "long" + ) + short_votes = sum( + 1 for s, _ in valid_signals if s.get("direction", "").lower() == "short" + ) + + threshold = len(valid_signals) / 2 + + if long_votes > threshold: + direction = "long" + elif short_votes > threshold: + direction = "short" + else: + return None + + avg_confidence = np.mean([s.get("confidence", 0.5) for s, _ in valid_signals]) + + return { + "direction": direction, + "confidence": avg_confidence, + "stop_loss": valid_signals[0][0].get("stop_loss"), + "take_profit": valid_signals[0][0].get("take_profit") + } + + elif method == "weighted_avg": + long_score = sum( + w * s.get("confidence", 0.5) + for s, w in valid_signals + if s.get("direction", "").lower() == "long" + ) + short_score = sum( + w * s.get("confidence", 0.5) + for s, w in valid_signals + if s.get("direction", "").lower() == "short" + ) + + total_weight = sum(w for _, w in valid_signals) + + if long_score > short_score and long_score / total_weight > 0.5: + direction = "long" + confidence = long_score / total_weight + elif short_score > long_score and short_score / total_weight > 0.5: + direction = "short" + confidence = short_score / total_weight + else: + return None + + return { + "direction": direction, + "confidence": confidence, + "stop_loss": valid_signals[0][0].get("stop_loss"), + "take_profit": valid_signals[0][0].get("take_profit") + } + + elif method == "unanimous": + if len(valid_signals) != len(signals): + return None + + directions = [s.get("direction", "").lower() for s, _ in valid_signals] + + if len(set(directions)) != 1: + return None + + avg_confidence = np.mean([s.get("confidence", 0.5) for s, _ in valid_signals]) + + return { + "direction": directions[0], + "confidence": avg_confidence, + "stop_loss": valid_signals[0][0].get("stop_loss"), + "take_profit": valid_signals[0][0].get("take_profit") + } + + return None + + def _create_walk_forward_periods( + self, + n_samples: int, + n_splits: int, + train_pct: float, + gap: int + ) -> List[WalkForwardPeriod]: + """Create walk-forward validation periods.""" + periods = [] + step_size = n_samples // (n_splits + 1) + + for i in range(n_splits): + train_start = 0 if i == 0 else (i * step_size // 2) + train_end = (i + 1) * step_size + test_start = train_end + gap + test_end = min(test_start + int(step_size * (1 - train_pct)), n_samples) + + if test_end > n_samples or (train_end - train_start) < 100: + continue + + periods.append(WalkForwardPeriod( + period_id=i + 1, + train_start=train_start, + train_end=train_end, + test_start=test_start, + test_end=test_end + )) + + return periods + + def _combine_walk_forward_results( + self, + results: List[BacktestResult], + all_trades: List[Trade], + start_time: datetime, + end_time: datetime + ) -> BacktestResult: + """Combine results from all walk-forward periods.""" + equity_values = [self.config.initial_capital] + equity_times = [start_time] + + for result in results: + if len(result.equity_curve) > 0: + scale_factor = equity_values[-1] / result.equity_curve.iloc[0] + scaled_equity = result.equity_curve * scale_factor + equity_times.extend(scaled_equity.index.tolist()) + equity_values.extend(scaled_equity.values.tolist()) + + equity_curve = pd.Series(equity_values, index=equity_times) + equity_curve = equity_curve[~equity_curve.index.duplicated(keep="last")] + + metrics = self._calculate_metrics(all_trades, equity_curve) + + drawdown_curve = self._calculate_drawdown(equity_curve) + daily_returns = equity_curve.resample("D").last().pct_change().dropna() + + return BacktestResult( + config=self.config, + metrics=metrics, + trades=all_trades, + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + daily_returns=daily_returns, + signals_generated=sum(r.signals_generated for r in results), + signals_traded=sum(r.signals_traded for r in results), + signals_filtered=sum(r.signals_filtered for r in results), + start_time=start_time, + end_time=end_time + ) + + def _generate_result( + self, + start_time: datetime, + end_time: datetime + ) -> BacktestResult: + """Generate backtest result from current state.""" + equity_curve = pd.Series( + [e[1] for e in self.equity_history], + index=[e[0] for e in self.equity_history] + ) + + trades = self.position_manager.closed_positions + metrics = self._calculate_metrics(trades, equity_curve) + + drawdown_curve = self._calculate_drawdown(equity_curve) + daily_returns = equity_curve.resample("D").last().pct_change().dropna() + + return BacktestResult( + config=self.config, + metrics=metrics, + trades=trades, + equity_curve=equity_curve, + drawdown_curve=drawdown_curve, + daily_returns=daily_returns, + signals_generated=self.signals_generated, + signals_traded=self.signals_traded, + signals_filtered=self.signals_filtered, + start_time=start_time, + end_time=end_time + ) + + def _calculate_metrics( + self, + trades: List[Trade], + equity_curve: pd.Series + ) -> BacktestMetrics: + """Calculate comprehensive trading metrics.""" + metrics = BacktestMetrics() + + if not trades: + return metrics + + metrics.total_trades = len(trades) + pnls = [t.pnl for t in trades] + + winning = [p for p in pnls if p > 0] + losing = [p for p in pnls if p < 0] + + metrics.winning_trades = len(winning) + metrics.losing_trades = len(losing) + metrics.win_rate = metrics.winning_trades / metrics.total_trades + + metrics.total_profit = sum(pnls) + metrics.total_profit_pct = (metrics.total_profit / self.config.initial_capital) * 100 + + metrics.gross_profit = sum(winning) if winning else 0 + metrics.gross_loss = abs(sum(losing)) if losing else 0 + + if metrics.gross_loss > 0: + metrics.profit_factor = metrics.gross_profit / metrics.gross_loss + else: + metrics.profit_factor = float("inf") if metrics.gross_profit > 0 else 0 + + metrics.avg_win = np.mean(winning) if winning else 0 + metrics.avg_loss = abs(np.mean(losing)) if losing else 0 + metrics.avg_trade = np.mean(pnls) + + metrics.expectancy = ( + metrics.win_rate * metrics.avg_win - + (1 - metrics.win_rate) * metrics.avg_loss + ) + + metrics.best_trade = max(pnls) if pnls else 0 + metrics.worst_trade = min(pnls) if pnls else 0 + + durations = [t.duration_minutes for t in trades if t.duration_minutes is not None] + metrics.avg_trade_duration_minutes = np.mean(durations) if durations else 0 + + if len(equity_curve) > 1: + running_max = equity_curve.cummax() + drawdown = (running_max - equity_curve) / running_max + metrics.max_drawdown_pct = drawdown.max() + metrics.max_drawdown = (running_max - equity_curve).max() + + in_dd = drawdown > 0 + dd_duration = 0 + max_dd_duration = 0 + for is_dd in in_dd: + if is_dd: + dd_duration += 1 + max_dd_duration = max(max_dd_duration, dd_duration) + else: + dd_duration = 0 + metrics.max_drawdown_duration_days = max_dd_duration + + daily_returns = equity_curve.resample("D").last().pct_change().dropna() + + if len(daily_returns) > 1: + excess_return = daily_returns.mean() - (self.config.risk_free_rate / 252) + std_return = daily_returns.std() + + if std_return > 0: + metrics.sharpe_ratio = np.sqrt(252) * excess_return / std_return + + negative_returns = daily_returns[daily_returns < 0] + if len(negative_returns) > 0: + downside_std = negative_returns.std() + if downside_std > 0: + metrics.sortino_ratio = np.sqrt(252) * excess_return / downside_std + + if metrics.max_drawdown_pct > 0: + annual_return = daily_returns.mean() * 252 + metrics.calmar_ratio = annual_return / metrics.max_drawdown_pct + + max_wins = 0 + max_losses = 0 + current_wins = 0 + current_losses = 0 + + for pnl in pnls: + if pnl > 0: + current_wins += 1 + current_losses = 0 + max_wins = max(max_wins, current_wins) + elif pnl < 0: + current_losses += 1 + current_wins = 0 + max_losses = max(max_losses, current_losses) + + metrics.max_consecutive_wins = max_wins + metrics.max_consecutive_losses = max_losses + + long_trades = [t for t in trades if t.is_long] + short_trades = [t for t in trades if t.is_short] + + metrics.long_trades = len(long_trades) + metrics.short_trades = len(short_trades) + + if long_trades: + long_wins = sum(1 for t in long_trades if t.pnl > 0) + metrics.long_win_rate = long_wins / len(long_trades) + + if short_trades: + short_wins = sum(1 for t in short_trades if t.pnl > 0) + metrics.short_win_rate = short_wins / len(short_trades) + + metrics.total_commission = sum(t.commission for t in trades) + metrics.total_slippage = sum(t.slippage for t in trades) + + return metrics + + def _calculate_drawdown(self, equity_curve: pd.Series) -> pd.Series: + """Calculate drawdown series.""" + running_max = equity_curve.cummax() + drawdown = (running_max - equity_curve) / running_max + return drawdown + + +def create_simple_strategy( + confidence_threshold: float = 0.6, + risk_reward: float = 2.0, + atr_multiplier: float = 1.5 +) -> Callable: + """ + Create a simple strategy function for testing. + + Args: + confidence_threshold: Minimum confidence to trade + risk_reward: Risk/reward ratio for TP calculation + atr_multiplier: ATR multiplier for SL calculation + + Returns: + Strategy function + """ + def strategy(data: pd.DataFrame, index: int) -> Optional[Dict[str, Any]]: + if index < 20: + return None + + close = data.iloc[index]["close"] + sma20 = data["close"].iloc[index - 19:index + 1].mean() + + tr = np.maximum( + data["high"].iloc[index - 13:index + 1] - data["low"].iloc[index - 13:index + 1], + np.abs(data["high"].iloc[index - 13:index + 1] - data["close"].iloc[index - 14:index].values), + np.abs(data["low"].iloc[index - 13:index + 1] - data["close"].iloc[index - 14:index].values) + ) + atr = tr.mean() + + momentum = (close - data["close"].iloc[index - 5]) / data["close"].iloc[index - 5] + + if close > sma20 and momentum > 0.005: + direction = "long" + confidence = min(0.5 + abs(momentum) * 10, 0.9) + stop_loss = close - atr * atr_multiplier + take_profit = close + atr * atr_multiplier * risk_reward + elif close < sma20 and momentum < -0.005: + direction = "short" + confidence = min(0.5 + abs(momentum) * 10, 0.9) + stop_loss = close + atr * atr_multiplier + take_profit = close - atr * atr_multiplier * risk_reward + else: + return None + + if confidence < confidence_threshold: + return None + + return { + "direction": direction, + "confidence": confidence, + "stop_loss": stop_loss, + "take_profit": take_profit + } + + return strategy diff --git a/src/backtesting/position_manager.py b/src/backtesting/position_manager.py new file mode 100644 index 0000000..c601559 --- /dev/null +++ b/src/backtesting/position_manager.py @@ -0,0 +1,872 @@ +""" +Position Manager for backtesting engine. + +This module provides position management functionality including: +- Opening and closing positions with proper P&L calculation +- Tracking open positions and margin requirements +- Stop-loss and take-profit management +- Kelly criterion position sizing +""" + +import math +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Tuple, Any +from loguru import logger + +from .trade import Trade, TradeDirection, TradeStatus + + +@dataclass +class PositionSizingConfig: + """ + Configuration for position sizing algorithms. + + Attributes: + method: Sizing method ('fixed', 'kelly', 'fixed_risk', 'volatility_adjusted') + max_position_pct: Maximum position size as percentage of capital + kelly_fraction: Fraction of Kelly criterion to use (0.25-0.5 recommended) + risk_per_trade_pct: Risk per trade as percentage of capital + max_leverage: Maximum allowed leverage + min_position_size: Minimum position size in units + """ + + method: str = "kelly" + max_position_pct: float = 0.02 + kelly_fraction: float = 0.25 + risk_per_trade_pct: float = 0.02 + max_leverage: float = 10.0 + min_position_size: float = 0.01 + + +@dataclass +class MarginRequirements: + """ + Margin requirements for a position. + + Attributes: + initial_margin: Required margin to open position + maintenance_margin: Minimum margin to maintain position + margin_call_level: Level at which margin call is triggered + """ + + initial_margin: float = 0.0 + maintenance_margin: float = 0.0 + margin_call_level: float = 0.0 + + +@dataclass +class PositionSummary: + """ + Summary of current position state. + + Attributes: + total_positions: Number of open positions + total_long: Number of long positions + total_short: Number of short positions + total_exposure: Total market exposure + unrealized_pnl: Total unrealized P&L + used_margin: Total margin in use + available_margin: Available margin for new positions + """ + + total_positions: int = 0 + total_long: int = 0 + total_short: int = 0 + total_exposure: float = 0.0 + unrealized_pnl: float = 0.0 + used_margin: float = 0.0 + available_margin: float = 0.0 + + +class PositionManager: + """ + Manages trading positions for backtesting. + + This class handles all aspects of position management including: + - Opening new positions with proper sizing + - Closing positions with P&L calculation + - Managing stop-loss and take-profit orders + - Tracking margin requirements + - Kelly criterion position sizing + + Attributes: + capital: Current account capital + initial_capital: Starting capital + open_positions: List of currently open trades + closed_positions: List of closed trades + sizing_config: Position sizing configuration + leverage: Account leverage + margin_pct: Margin requirement percentage + """ + + def __init__( + self, + initial_capital: float = 10000.0, + sizing_config: Optional[PositionSizingConfig] = None, + leverage: float = 1.0, + margin_pct: float = 0.01, + max_positions: int = 10 + ): + """ + Initialize the position manager. + + Args: + initial_capital: Starting capital amount + sizing_config: Position sizing configuration + leverage: Account leverage + margin_pct: Margin requirement as percentage + max_positions: Maximum concurrent positions allowed + """ + self.initial_capital = initial_capital + self.capital = initial_capital + self.sizing_config = sizing_config or PositionSizingConfig() + self.leverage = leverage + self.margin_pct = margin_pct + self.max_positions = max_positions + + self.open_positions: List[Trade] = [] + self.closed_positions: List[Trade] = [] + self.trade_counter = 0 + + self.used_margin = 0.0 + self.peak_capital = initial_capital + self.max_drawdown = 0.0 + + self._win_count = 0 + self._loss_count = 0 + self._avg_win = 0.0 + self._avg_loss = 0.0 + + logger.info( + f"PositionManager initialized: capital=${initial_capital:,.2f}, " + f"leverage={leverage}x, max_positions={max_positions}" + ) + + def reset(self) -> None: + """Reset position manager to initial state.""" + self.capital = self.initial_capital + self.open_positions = [] + self.closed_positions = [] + self.trade_counter = 0 + self.used_margin = 0.0 + self.peak_capital = self.initial_capital + self.max_drawdown = 0.0 + self._win_count = 0 + self._loss_count = 0 + self._avg_win = 0.0 + self._avg_loss = 0.0 + + def can_open_position(self) -> bool: + """ + Check if a new position can be opened. + + Returns: + True if new position is allowed + """ + if len(self.open_positions) >= self.max_positions: + return False + + available = self.capital - self.used_margin + if available <= 0: + return False + + return True + + def calculate_position_size( + self, + entry_price: float, + stop_loss: float, + direction: TradeDirection, + win_rate: Optional[float] = None, + avg_win_loss_ratio: Optional[float] = None + ) -> float: + """ + Calculate optimal position size based on configured method. + + Args: + entry_price: Entry price for the trade + stop_loss: Stop loss price + direction: Trade direction + win_rate: Historical win rate (for Kelly criterion) + avg_win_loss_ratio: Average win/loss ratio (for Kelly criterion) + + Returns: + Position size in units + """ + method = self.sizing_config.method + + if method == "fixed": + return self._fixed_position_size(entry_price) + elif method == "kelly": + return self._kelly_position_size( + entry_price, stop_loss, direction, win_rate, avg_win_loss_ratio + ) + elif method == "fixed_risk": + return self._fixed_risk_position_size(entry_price, stop_loss, direction) + elif method == "volatility_adjusted": + return self._volatility_adjusted_size(entry_price, stop_loss, direction) + else: + logger.warning(f"Unknown sizing method: {method}, using fixed") + return self._fixed_position_size(entry_price) + + def _fixed_position_size(self, entry_price: float) -> float: + """ + Calculate fixed percentage position size. + + Args: + entry_price: Entry price + + Returns: + Position size + """ + available_capital = self.capital - self.used_margin + position_value = available_capital * self.sizing_config.max_position_pct + size = position_value / entry_price + + return max(size, self.sizing_config.min_position_size) + + def _kelly_position_size( + self, + entry_price: float, + stop_loss: float, + direction: TradeDirection, + win_rate: Optional[float] = None, + avg_win_loss_ratio: Optional[float] = None + ) -> float: + """ + Calculate position size using Kelly criterion. + + The Kelly criterion determines optimal bet sizing: + f* = (p * b - q) / b + + where: + - f* = fraction of capital to risk + - p = probability of winning + - q = probability of losing (1 - p) + - b = win/loss ratio + + We use a fractional Kelly (kelly_fraction) to reduce volatility. + + Args: + entry_price: Entry price + stop_loss: Stop loss price + direction: Trade direction + win_rate: Historical win rate + avg_win_loss_ratio: Average win/loss ratio + + Returns: + Position size + """ + if win_rate is None: + if self._win_count + self._loss_count > 10: + win_rate = self._win_count / (self._win_count + self._loss_count) + else: + win_rate = 0.5 + + if avg_win_loss_ratio is None: + if self._avg_loss > 0: + avg_win_loss_ratio = self._avg_win / self._avg_loss + else: + avg_win_loss_ratio = 1.5 + + win_rate = max(0.1, min(0.9, win_rate)) + avg_win_loss_ratio = max(0.5, min(5.0, avg_win_loss_ratio)) + + q = 1 - win_rate + kelly_fraction = (win_rate * avg_win_loss_ratio - q) / avg_win_loss_ratio + + kelly_fraction = max(0, kelly_fraction) + kelly_fraction *= self.sizing_config.kelly_fraction + kelly_fraction = min(kelly_fraction, self.sizing_config.max_position_pct) + + if direction == TradeDirection.LONG: + risk_per_unit = entry_price - stop_loss + else: + risk_per_unit = stop_loss - entry_price + + if risk_per_unit <= 0: + logger.warning("Invalid stop loss, using fixed position size") + return self._fixed_position_size(entry_price) + + available_capital = self.capital - self.used_margin + risk_amount = available_capital * kelly_fraction + size = risk_amount / risk_per_unit + + size = min(size, (available_capital * self.sizing_config.max_position_pct) / entry_price) + size = max(size, self.sizing_config.min_position_size) + + return size + + def _fixed_risk_position_size( + self, + entry_price: float, + stop_loss: float, + direction: TradeDirection + ) -> float: + """ + Calculate position size based on fixed risk percentage. + + Args: + entry_price: Entry price + stop_loss: Stop loss price + direction: Trade direction + + Returns: + Position size + """ + if direction == TradeDirection.LONG: + risk_per_unit = entry_price - stop_loss + else: + risk_per_unit = stop_loss - entry_price + + if risk_per_unit <= 0: + return self._fixed_position_size(entry_price) + + available_capital = self.capital - self.used_margin + risk_amount = available_capital * self.sizing_config.risk_per_trade_pct + size = risk_amount / risk_per_unit + + max_size = (available_capital * self.sizing_config.max_position_pct) / entry_price + size = min(size, max_size) + size = max(size, self.sizing_config.min_position_size) + + return size + + def _volatility_adjusted_size( + self, + entry_price: float, + stop_loss: float, + direction: TradeDirection + ) -> float: + """ + Calculate volatility-adjusted position size. + + This method adjusts position size based on the distance to stop loss, + which serves as a proxy for volatility. + + Args: + entry_price: Entry price + stop_loss: Stop loss price + direction: Trade direction + + Returns: + Position size + """ + if direction == TradeDirection.LONG: + volatility = (entry_price - stop_loss) / entry_price + else: + volatility = (stop_loss - entry_price) / entry_price + + volatility = max(0.001, volatility) + base_risk = self.sizing_config.risk_per_trade_pct + adjusted_risk = base_risk * (0.01 / volatility) + adjusted_risk = min(adjusted_risk, self.sizing_config.max_position_pct) + + available_capital = self.capital - self.used_margin + position_value = available_capital * adjusted_risk + size = position_value / entry_price + + return max(size, self.sizing_config.min_position_size) + + def open_position( + self, + symbol: str, + direction: TradeDirection, + entry_price: float, + entry_time: datetime, + size: Optional[float] = None, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + commission: float = 0.0, + slippage: float = 0.0, + strategy_name: str = "default", + timeframe: str = "1H", + signal_confidence: float = 0.0, + metadata: Optional[Dict[str, Any]] = None + ) -> Optional[Trade]: + """ + Open a new trading position. + + Args: + symbol: Trading symbol + direction: Trade direction + entry_price: Entry price + entry_time: Entry timestamp + size: Position size (calculated if None) + stop_loss: Stop loss price + take_profit: Take profit price + commission: Commission cost + slippage: Slippage cost + strategy_name: Name of the strategy + timeframe: Trading timeframe + signal_confidence: Confidence of the entry signal + metadata: Additional trade metadata + + Returns: + Trade object if opened, None if unable to open + """ + if not self.can_open_position(): + logger.warning("Cannot open position: max positions reached or insufficient margin") + return None + + if size is None: + if stop_loss is None: + size = self._fixed_position_size(entry_price) + else: + size = self.calculate_position_size(entry_price, stop_loss, direction) + + entry_price_adjusted = entry_price + if direction == TradeDirection.LONG: + entry_price_adjusted += slippage + else: + entry_price_adjusted -= slippage + + self.trade_counter += 1 + trade = Trade( + trade_id=self.trade_counter, + symbol=symbol, + direction=direction, + entry_price=entry_price_adjusted, + entry_time=entry_time, + size=size, + stop_loss=stop_loss, + take_profit=take_profit, + commission=commission, + slippage=slippage, + strategy_name=strategy_name, + timeframe=timeframe, + signal_confidence=signal_confidence, + metadata=metadata or {} + ) + + position_value = entry_price_adjusted * size + margin_required = position_value * self.margin_pct + self.used_margin += margin_required + + self.capital -= commission + + self.open_positions.append(trade) + + logger.debug( + f"Opened {direction.value} position: {symbol} @ {entry_price_adjusted:.5f}, " + f"size={size:.4f}, SL={stop_loss}, TP={take_profit}" + ) + + return trade + + def close_position( + self, + trade: Trade, + exit_price: float, + exit_time: datetime, + status: TradeStatus = TradeStatus.CLOSED, + commission: float = 0.0, + slippage: float = 0.0 + ) -> float: + """ + Close an open position. + + Args: + trade: Trade to close + exit_price: Exit price + exit_time: Exit timestamp + status: Exit status + commission: Commission cost + slippage: Slippage cost + + Returns: + Realized P&L + """ + if trade not in self.open_positions: + logger.warning(f"Trade {trade.trade_id} not found in open positions") + return 0.0 + + exit_price_adjusted = exit_price + if trade.is_long: + exit_price_adjusted -= slippage + else: + exit_price_adjusted += slippage + + pnl = trade.close( + exit_price=exit_price_adjusted, + exit_time=exit_time, + status=status, + commission=commission, + slippage=slippage + ) + + position_value = trade.entry_price * trade.size + margin_released = position_value * self.margin_pct + self.used_margin -= margin_released + + self.capital += pnl + self.capital -= commission + + if pnl > 0: + self._win_count += 1 + self._avg_win = ( + (self._avg_win * (self._win_count - 1) + pnl) / self._win_count + ) + elif pnl < 0: + self._loss_count += 1 + self._avg_loss = ( + (self._avg_loss * (self._loss_count - 1) + abs(pnl)) / self._loss_count + ) + + if self.capital > self.peak_capital: + self.peak_capital = self.capital + else: + drawdown = (self.peak_capital - self.capital) / self.peak_capital + self.max_drawdown = max(self.max_drawdown, drawdown) + + self.open_positions.remove(trade) + self.closed_positions.append(trade) + + logger.debug( + f"Closed {trade.direction.value} position: {trade.symbol} @ {exit_price_adjusted:.5f}, " + f"PnL={pnl:+.2f} ({status.value})" + ) + + return pnl + + def close_position_by_id( + self, + trade_id: int, + exit_price: float, + exit_time: datetime, + status: TradeStatus = TradeStatus.CLOSED, + commission: float = 0.0, + slippage: float = 0.0 + ) -> Optional[float]: + """ + Close a position by trade ID. + + Args: + trade_id: ID of the trade to close + exit_price: Exit price + exit_time: Exit timestamp + status: Exit status + commission: Commission cost + slippage: Slippage cost + + Returns: + Realized P&L or None if trade not found + """ + trade = self.get_position_by_id(trade_id) + if trade is None: + logger.warning(f"Trade {trade_id} not found") + return None + + return self.close_position( + trade, exit_price, exit_time, status, commission, slippage + ) + + def close_all_positions( + self, + exit_price_func, + exit_time: datetime, + status: TradeStatus = TradeStatus.CLOSED, + commission_func=None, + slippage: float = 0.0 + ) -> float: + """ + Close all open positions. + + Args: + exit_price_func: Function that takes trade and returns exit price + exit_time: Exit timestamp + status: Exit status + commission_func: Function that takes trade and returns commission + slippage: Slippage cost + + Returns: + Total realized P&L + """ + total_pnl = 0.0 + + for trade in self.open_positions[:]: + exit_price = exit_price_func(trade) + commission = commission_func(trade) if commission_func else 0.0 + + pnl = self.close_position( + trade, exit_price, exit_time, status, commission, slippage + ) + total_pnl += pnl + + return total_pnl + + def update_positions( + self, + current_prices: Dict[str, float], + current_time: datetime, + commission_rate: float = 0.0, + slippage: float = 0.0 + ) -> List[Trade]: + """ + Update all open positions with current prices. + Check for stop-loss and take-profit triggers. + + Args: + current_prices: Dictionary of symbol -> current price + current_time: Current timestamp + commission_rate: Commission rate for closing + slippage: Slippage for closing + + Returns: + List of trades that were closed + """ + closed_trades = [] + + for trade in self.open_positions[:]: + current_price = current_prices.get(trade.symbol) + if current_price is None: + continue + + if trade.should_stop_out(current_price): + exit_price = trade.stop_loss + commission = abs(exit_price * trade.size) * commission_rate + self.close_position( + trade, exit_price, current_time, + TradeStatus.STOPPED_OUT, commission, slippage + ) + closed_trades.append(trade) + + elif trade.should_take_profit(current_price): + exit_price = trade.take_profit + commission = abs(exit_price * trade.size) * commission_rate + self.close_position( + trade, exit_price, current_time, + TradeStatus.TAKE_PROFIT, commission, slippage + ) + closed_trades.append(trade) + + return closed_trades + + def update_position_with_ohlc( + self, + trade: Trade, + high: float, + low: float, + close: float, + current_time: datetime, + commission_rate: float = 0.0, + slippage: float = 0.0 + ) -> Optional[Tuple[TradeStatus, float]]: + """ + Update a single position with OHLC bar data. + Checks if SL or TP was hit within the bar. + + Args: + trade: Trade to update + high: High price of the bar + low: Low price of the bar + close: Close price of the bar + current_time: Current timestamp + commission_rate: Commission rate + slippage: Slippage amount + + Returns: + Tuple of (status, pnl) if closed, None if still open + """ + if trade not in self.open_positions: + return None + + sl_hit = False + tp_hit = False + + if trade.stop_loss is not None: + if trade.is_long and low <= trade.stop_loss: + sl_hit = True + elif trade.is_short and high >= trade.stop_loss: + sl_hit = True + + if trade.take_profit is not None: + if trade.is_long and high >= trade.take_profit: + tp_hit = True + elif trade.is_short and low <= trade.take_profit: + tp_hit = True + + if sl_hit and tp_hit: + if trade.is_long: + sl_hit = low <= trade.stop_loss + else: + sl_hit = high >= trade.stop_loss + + if sl_hit: + tp_hit = False + + if sl_hit: + exit_price = trade.stop_loss + commission = abs(exit_price * trade.size) * commission_rate + pnl = self.close_position( + trade, exit_price, current_time, + TradeStatus.STOPPED_OUT, commission, slippage + ) + return (TradeStatus.STOPPED_OUT, pnl) + + elif tp_hit: + exit_price = trade.take_profit + commission = abs(exit_price * trade.size) * commission_rate + pnl = self.close_position( + trade, exit_price, current_time, + TradeStatus.TAKE_PROFIT, commission, slippage + ) + return (TradeStatus.TAKE_PROFIT, pnl) + + return None + + def get_position_by_id(self, trade_id: int) -> Optional[Trade]: + """ + Get an open position by trade ID. + + Args: + trade_id: Trade ID to find + + Returns: + Trade object or None + """ + for trade in self.open_positions: + if trade.trade_id == trade_id: + return trade + return None + + def get_positions_by_symbol(self, symbol: str) -> List[Trade]: + """ + Get all open positions for a symbol. + + Args: + symbol: Symbol to filter by + + Returns: + List of matching trades + """ + return [t for t in self.open_positions if t.symbol == symbol] + + def get_unrealized_pnl(self, current_prices: Dict[str, float]) -> float: + """ + Calculate total unrealized P&L for all open positions. + + Args: + current_prices: Dictionary of symbol -> current price + + Returns: + Total unrealized P&L + """ + total = 0.0 + for trade in self.open_positions: + current_price = current_prices.get(trade.symbol) + if current_price is not None: + total += trade.calculate_unrealized_pnl(current_price) + return total + + def get_total_exposure(self) -> float: + """ + Calculate total market exposure. + + Returns: + Total exposure in account currency + """ + return sum(t.entry_price * t.size for t in self.open_positions) + + def get_position_summary(self, current_prices: Optional[Dict[str, float]] = None) -> PositionSummary: + """ + Get summary of current position state. + + Args: + current_prices: Current prices for unrealized P&L calculation + + Returns: + PositionSummary object + """ + long_count = sum(1 for t in self.open_positions if t.is_long) + short_count = sum(1 for t in self.open_positions if t.is_short) + + unrealized = 0.0 + if current_prices: + unrealized = self.get_unrealized_pnl(current_prices) + + return PositionSummary( + total_positions=len(self.open_positions), + total_long=long_count, + total_short=short_count, + total_exposure=self.get_total_exposure(), + unrealized_pnl=unrealized, + used_margin=self.used_margin, + available_margin=self.capital - self.used_margin + ) + + def get_equity(self, current_prices: Optional[Dict[str, float]] = None) -> float: + """ + Calculate current account equity. + + Args: + current_prices: Current prices for unrealized P&L + + Returns: + Account equity + """ + equity = self.capital + if current_prices: + equity += self.get_unrealized_pnl(current_prices) + return equity + + def get_win_rate(self) -> float: + """ + Calculate current win rate from closed trades. + + Returns: + Win rate as decimal (0 to 1) + """ + total = self._win_count + self._loss_count + if total == 0: + return 0.0 + return self._win_count / total + + def get_profit_factor(self) -> float: + """ + Calculate profit factor from closed trades. + + Returns: + Profit factor (gross profit / gross loss) + """ + if self._loss_count == 0 or self._avg_loss == 0: + return float('inf') if self._win_count > 0 else 0.0 + + gross_profit = self._win_count * self._avg_win + gross_loss = self._loss_count * self._avg_loss + + return gross_profit / gross_loss if gross_loss > 0 else 0.0 + + def get_all_trades(self) -> List[Trade]: + """ + Get all trades (open and closed). + + Returns: + List of all trades + """ + return self.open_positions + self.closed_positions + + def to_dict(self) -> Dict[str, Any]: + """ + Convert position manager state to dictionary. + + Returns: + Dictionary representation + """ + return { + "capital": self.capital, + "initial_capital": self.initial_capital, + "used_margin": self.used_margin, + "peak_capital": self.peak_capital, + "max_drawdown": self.max_drawdown, + "open_positions_count": len(self.open_positions), + "closed_positions_count": len(self.closed_positions), + "total_trades": self.trade_counter, + "win_count": self._win_count, + "loss_count": self._loss_count, + "avg_win": self._avg_win, + "avg_loss": self._avg_loss, + "win_rate": self.get_win_rate(), + "profit_factor": self.get_profit_factor() + } diff --git a/src/backtesting/report_generator.py b/src/backtesting/report_generator.py new file mode 100644 index 0000000..58488d1 --- /dev/null +++ b/src/backtesting/report_generator.py @@ -0,0 +1,1401 @@ +""" +Backtesting Report Generator +============================ +Generate comprehensive backtesting reports for ML model validation. + +Supports: +- Individual strategy reports +- Ensemble reports with strategy weights +- Comparison reports across multiple strategies + +Output formats: +- Markdown (human-readable) +- JSON (machine-readable) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Any, Union +from dataclasses import dataclass, field +import numpy as np +import pandas as pd +from loguru import logger + + +@dataclass +class ReportSection: + """Individual section of a report.""" + + title: str + content: str + data: Optional[Dict[str, Any]] = None + subsections: List['ReportSection'] = field(default_factory=list) + + +@dataclass +class PerformanceThresholds: + """Thresholds for pass/fail evaluation.""" + + min_sharpe_ratio: float = 1.0 + min_win_rate: float = 0.45 + min_profit_factor: float = 1.2 + max_drawdown_pct: float = 20.0 + min_trades: int = 30 + min_calmar_ratio: float = 0.5 + + +@dataclass +class StrategyReport: + """Complete strategy backtest report.""" + + strategy_name: str + generated_at: datetime + backtest_period: Dict[str, str] + executive_summary: Dict[str, Any] + performance_metrics: Dict[str, float] + trade_statistics: Dict[str, Any] + drawdown_analysis: Dict[str, Any] + returns_analysis: Dict[str, Any] + recommendations: List[str] + passed_validation: bool + sections: List[ReportSection] = field(default_factory=list) + raw_data: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert report to dictionary.""" + return { + 'strategy_name': self.strategy_name, + 'generated_at': self.generated_at.isoformat(), + 'backtest_period': self.backtest_period, + 'executive_summary': self.executive_summary, + 'performance_metrics': self.performance_metrics, + 'trade_statistics': self.trade_statistics, + 'drawdown_analysis': self.drawdown_analysis, + 'returns_analysis': self.returns_analysis, + 'recommendations': self.recommendations, + 'passed_validation': self.passed_validation, + 'raw_data': self.raw_data + } + + +@dataclass +class EnsembleReport: + """Report for ensemble strategy backtest.""" + + ensemble_name: str + generated_at: datetime + strategy_weights: Dict[str, float] + individual_results: Dict[str, Dict[str, Any]] + combined_metrics: Dict[str, float] + weight_analysis: Dict[str, Any] + diversification_benefit: float + recommendations: List[str] + passed_validation: bool + + def to_dict(self) -> Dict[str, Any]: + """Convert report to dictionary.""" + return { + 'ensemble_name': self.ensemble_name, + 'generated_at': self.generated_at.isoformat(), + 'strategy_weights': self.strategy_weights, + 'individual_results': self.individual_results, + 'combined_metrics': self.combined_metrics, + 'weight_analysis': self.weight_analysis, + 'diversification_benefit': self.diversification_benefit, + 'recommendations': self.recommendations, + 'passed_validation': self.passed_validation + } + + +@dataclass +class ComparisonReport: + """Report comparing multiple strategies.""" + + generated_at: datetime + strategies_compared: List[str] + rankings: Dict[str, List[Dict[str, Any]]] + correlation_analysis: Dict[str, Any] + best_strategy: Dict[str, str] + regime_analysis: Dict[str, Dict[str, str]] + recommendations: List[str] + + def to_dict(self) -> Dict[str, Any]: + """Convert report to dictionary.""" + return { + 'generated_at': self.generated_at.isoformat(), + 'strategies_compared': self.strategies_compared, + 'rankings': self.rankings, + 'correlation_analysis': self.correlation_analysis, + 'best_strategy': self.best_strategy, + 'regime_analysis': self.regime_analysis, + 'recommendations': self.recommendations + } + + +class ReportGenerator: + """ + Generate comprehensive backtesting reports. + + Creates detailed reports including: + - Executive summary with pass/fail status + - Performance metrics tables + - Trade statistics breakdown + - Drawdown analysis + - Monthly and weekly returns + - Strategy-specific recommendations + + Usage: + generator = ReportGenerator() + report = generator.generate_strategy_report( + strategy_name='MRD', + backtest_result=result, + metrics=metrics + ) + generator.export_to_markdown(report, 'report.md') + """ + + def __init__( + self, + thresholds: Optional[PerformanceThresholds] = None, + include_raw_data: bool = True, + include_trade_list: bool = False + ): + """ + Initialize report generator. + + Args: + thresholds: Performance thresholds for pass/fail evaluation + include_raw_data: Whether to include raw data in reports + include_trade_list: Whether to include full trade list + """ + self.thresholds = thresholds or PerformanceThresholds() + self.include_raw_data = include_raw_data + self.include_trade_list = include_trade_list + + logger.info("ReportGenerator initialized") + + def generate_strategy_report( + self, + strategy_name: str, + backtest_result: Any, + metrics: Optional[Dict[str, Any]] = None, + trades: Optional[List[Any]] = None, + equity_curve: Optional[Union[pd.Series, np.ndarray]] = None, + additional_info: Optional[Dict[str, Any]] = None + ) -> StrategyReport: + """ + Generate comprehensive report for a single strategy backtest. + + Args: + strategy_name: Name of the strategy + backtest_result: BacktestResult object or dictionary + metrics: Optional pre-computed metrics dictionary + trades: Optional list of trades + equity_curve: Optional equity curve data + additional_info: Optional additional context + + Returns: + StrategyReport with all analysis + """ + logger.info(f"Generating report for strategy: {strategy_name}") + + result_dict = self._normalize_result(backtest_result) + metrics_dict = metrics if metrics else self._extract_metrics(result_dict) + trade_list = trades if trades else self._extract_trades(result_dict) + equity_data = equity_curve if equity_curve is not None else self._extract_equity_curve(result_dict) + + backtest_period = self._compute_backtest_period(trade_list, result_dict) + performance_metrics = self._compile_performance_metrics(metrics_dict, result_dict) + trade_statistics = self._compute_trade_statistics(trade_list, metrics_dict) + drawdown_analysis = self._analyze_drawdown(equity_data, metrics_dict) + returns_analysis = self._analyze_returns(equity_data, trade_list) + + passed_validation = self._evaluate_pass_fail(performance_metrics, trade_statistics) + + executive_summary = self._create_executive_summary( + strategy_name=strategy_name, + performance_metrics=performance_metrics, + trade_statistics=trade_statistics, + passed_validation=passed_validation + ) + + recommendations = self._generate_recommendations( + strategy_name=strategy_name, + performance_metrics=performance_metrics, + trade_statistics=trade_statistics, + drawdown_analysis=drawdown_analysis, + passed_validation=passed_validation + ) + + raw_data = {} + if self.include_raw_data: + raw_data['equity_curve'] = self._serialize_equity_curve(equity_data) + if self.include_trade_list: + raw_data['trades'] = self._serialize_trades(trade_list) + if additional_info: + raw_data['additional_info'] = additional_info + + report = StrategyReport( + strategy_name=strategy_name, + generated_at=datetime.now(), + backtest_period=backtest_period, + executive_summary=executive_summary, + performance_metrics=performance_metrics, + trade_statistics=trade_statistics, + drawdown_analysis=drawdown_analysis, + returns_analysis=returns_analysis, + recommendations=recommendations, + passed_validation=passed_validation, + raw_data=raw_data + ) + + logger.info(f"Report generated: {strategy_name} - {'PASSED' if passed_validation else 'FAILED'}") + return report + + def generate_ensemble_report( + self, + ensemble_result: Any, + strategy_results: Dict[str, Any], + strategy_weights: Optional[Dict[str, float]] = None, + ensemble_name: str = "Ensemble" + ) -> EnsembleReport: + """ + Generate report for ensemble strategy combining multiple models. + + Args: + ensemble_result: Combined backtest result for ensemble + strategy_results: Dictionary mapping strategy names to their results + strategy_weights: Dictionary of strategy weights + ensemble_name: Name for the ensemble + + Returns: + EnsembleReport with combined analysis + """ + logger.info(f"Generating ensemble report: {ensemble_name}") + + ensemble_dict = self._normalize_result(ensemble_result) + ensemble_metrics = self._extract_metrics(ensemble_dict) + + individual_results = {} + individual_sharpes = [] + + for strategy_name, result in strategy_results.items(): + result_dict = self._normalize_result(result) + metrics = self._extract_metrics(result_dict) + individual_results[strategy_name] = { + 'sharpe_ratio': metrics.get('sharpe_ratio', 0), + 'sortino_ratio': metrics.get('sortino_ratio', 0), + 'win_rate': metrics.get('winrate', metrics.get('win_rate', 0)), + 'profit_factor': metrics.get('profit_factor', 0), + 'total_trades': metrics.get('total_trades', 0), + 'net_profit': metrics.get('net_profit', 0), + 'max_drawdown_pct': metrics.get('max_drawdown_pct', 0) + } + individual_sharpes.append(metrics.get('sharpe_ratio', 0)) + + if strategy_weights is None: + num_strategies = len(strategy_results) + strategy_weights = {name: 1.0 / num_strategies for name in strategy_results.keys()} + + weight_analysis = self._analyze_strategy_weights( + individual_results=individual_results, + strategy_weights=strategy_weights + ) + + ensemble_sharpe = ensemble_metrics.get('sharpe_ratio', 0) + diversification_benefit = self._compute_diversification_benefit( + individual_sharpes=individual_sharpes, + ensemble_sharpe=ensemble_sharpe + ) + + combined_metrics = { + 'sharpe_ratio': ensemble_sharpe, + 'sortino_ratio': ensemble_metrics.get('sortino_ratio', 0), + 'win_rate': ensemble_metrics.get('winrate', ensemble_metrics.get('win_rate', 0)), + 'profit_factor': ensemble_metrics.get('profit_factor', 0), + 'total_trades': ensemble_metrics.get('total_trades', 0), + 'net_profit': ensemble_metrics.get('net_profit', 0), + 'max_drawdown_pct': ensemble_metrics.get('max_drawdown_pct', 0), + 'calmar_ratio': ensemble_metrics.get('calmar_ratio', 0) + } + + passed_validation = self._evaluate_pass_fail(combined_metrics, {'total_trades': combined_metrics['total_trades']}) + + recommendations = self._generate_ensemble_recommendations( + combined_metrics=combined_metrics, + individual_results=individual_results, + weight_analysis=weight_analysis, + diversification_benefit=diversification_benefit, + passed_validation=passed_validation + ) + + report = EnsembleReport( + ensemble_name=ensemble_name, + generated_at=datetime.now(), + strategy_weights=strategy_weights, + individual_results=individual_results, + combined_metrics=combined_metrics, + weight_analysis=weight_analysis, + diversification_benefit=diversification_benefit, + recommendations=recommendations, + passed_validation=passed_validation + ) + + logger.info(f"Ensemble report generated: {ensemble_name}") + return report + + def generate_comparison_report( + self, + all_results: Dict[str, Any], + regime_data: Optional[Dict[str, Any]] = None + ) -> ComparisonReport: + """ + Generate comparison report across multiple strategies. + + Args: + all_results: Dictionary mapping strategy names to backtest results + regime_data: Optional market regime data for regime-specific analysis + + Returns: + ComparisonReport with rankings and comparisons + """ + logger.info(f"Generating comparison report for {len(all_results)} strategies") + + strategy_metrics = {} + for name, result in all_results.items(): + result_dict = self._normalize_result(result) + strategy_metrics[name] = self._extract_metrics(result_dict) + + rankings = { + 'by_sharpe_ratio': self._rank_strategies(strategy_metrics, 'sharpe_ratio', descending=True), + 'by_sortino_ratio': self._rank_strategies(strategy_metrics, 'sortino_ratio', descending=True), + 'by_profit_factor': self._rank_strategies(strategy_metrics, 'profit_factor', descending=True), + 'by_win_rate': self._rank_strategies(strategy_metrics, 'winrate', descending=True, fallback_key='win_rate'), + 'by_max_drawdown': self._rank_strategies(strategy_metrics, 'max_drawdown_pct', descending=False), + 'by_calmar_ratio': self._rank_strategies(strategy_metrics, 'calmar_ratio', descending=True) + } + + correlation_analysis = self._compute_correlation_analysis(all_results) + + best_strategy = { + 'overall_sharpe': rankings['by_sharpe_ratio'][0]['strategy'] if rankings['by_sharpe_ratio'] else 'N/A', + 'overall_risk_adjusted': rankings['by_calmar_ratio'][0]['strategy'] if rankings['by_calmar_ratio'] else 'N/A', + 'lowest_drawdown': rankings['by_max_drawdown'][0]['strategy'] if rankings['by_max_drawdown'] else 'N/A', + 'highest_win_rate': rankings['by_win_rate'][0]['strategy'] if rankings['by_win_rate'] else 'N/A' + } + + regime_analysis = {} + if regime_data: + regime_analysis = self._analyze_regime_performance(all_results, regime_data) + + recommendations = self._generate_comparison_recommendations( + rankings=rankings, + correlation_analysis=correlation_analysis, + best_strategy=best_strategy, + regime_analysis=regime_analysis + ) + + report = ComparisonReport( + generated_at=datetime.now(), + strategies_compared=list(all_results.keys()), + rankings=rankings, + correlation_analysis=correlation_analysis, + best_strategy=best_strategy, + regime_analysis=regime_analysis, + recommendations=recommendations + ) + + logger.info("Comparison report generated") + return report + + def export_to_markdown( + self, + report: Union[StrategyReport, EnsembleReport, ComparisonReport], + filepath: Union[str, Path] + ) -> None: + """ + Export report to markdown format. + + Args: + report: Report object to export + filepath: Output file path + """ + filepath = Path(filepath) + filepath.parent.mkdir(parents=True, exist_ok=True) + + if isinstance(report, StrategyReport): + content = self._strategy_report_to_markdown(report) + elif isinstance(report, EnsembleReport): + content = self._ensemble_report_to_markdown(report) + elif isinstance(report, ComparisonReport): + content = self._comparison_report_to_markdown(report) + else: + raise ValueError(f"Unknown report type: {type(report)}") + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + + logger.info(f"Report exported to markdown: {filepath}") + + def export_to_json( + self, + report: Union[StrategyReport, EnsembleReport, ComparisonReport], + filepath: Union[str, Path] + ) -> None: + """ + Export report to JSON format. + + Args: + report: Report object to export + filepath: Output file path + """ + filepath = Path(filepath) + filepath.parent.mkdir(parents=True, exist_ok=True) + + report_dict = report.to_dict() + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(report_dict, f, indent=2, default=str) + + logger.info(f"Report exported to JSON: {filepath}") + + def _normalize_result(self, result: Any) -> Dict[str, Any]: + """Normalize backtest result to dictionary format.""" + if isinstance(result, dict): + return result + if hasattr(result, 'to_dict'): + return result.to_dict() + if hasattr(result, '__dict__'): + return vars(result) + return {'result': result} + + def _extract_metrics(self, result_dict: Dict[str, Any]) -> Dict[str, Any]: + """Extract metrics from result dictionary.""" + if 'metrics' in result_dict and isinstance(result_dict['metrics'], dict): + metrics = result_dict['metrics'].copy() + for key in ['sharpe_ratio', 'sortino_ratio', 'profit_factor', 'win_rate', 'winrate', + 'max_drawdown', 'max_drawdown_pct', 'total_trades', 'net_profit', 'calmar_ratio']: + if key in result_dict and key not in metrics: + metrics[key] = result_dict[key] + return metrics + return {k: v for k, v in result_dict.items() if not k.startswith('_') and not callable(v)} + + def _extract_trades(self, result_dict: Dict[str, Any]) -> List[Any]: + """Extract trades from result dictionary.""" + if 'trades' in result_dict: + return result_dict['trades'] + return [] + + def _extract_equity_curve(self, result_dict: Dict[str, Any]) -> Optional[np.ndarray]: + """Extract equity curve from result dictionary.""" + if 'equity_curve' in result_dict: + ec = result_dict['equity_curve'] + if isinstance(ec, pd.Series): + return ec.values + if isinstance(ec, np.ndarray): + return ec + if isinstance(ec, list): + return np.array(ec) + return None + + def _compute_backtest_period(self, trades: List[Any], result_dict: Dict[str, Any]) -> Dict[str, str]: + """Compute backtest period from trades or result.""" + start_date = None + end_date = None + + if trades: + entry_times = [] + exit_times = [] + for t in trades: + if hasattr(t, 'entry_time'): + if t.entry_time: + entry_times.append(t.entry_time) + elif isinstance(t, dict) and 'entry_time' in t: + if t['entry_time']: + entry_times.append(t['entry_time']) + + if hasattr(t, 'exit_time'): + if t.exit_time: + exit_times.append(t.exit_time) + elif isinstance(t, dict) and 'exit_time' in t: + if t['exit_time']: + exit_times.append(t['exit_time']) + + if entry_times: + start_date = min(entry_times) + if exit_times: + end_date = max(exit_times) + + if start_date is None and 'start_date' in result_dict: + start_date = result_dict['start_date'] + if end_date is None and 'end_date' in result_dict: + end_date = result_dict['end_date'] + + return { + 'start': str(start_date) if start_date else 'N/A', + 'end': str(end_date) if end_date else 'N/A', + 'duration_days': self._compute_duration_days(start_date, end_date) + } + + def _compute_duration_days(self, start_date: Any, end_date: Any) -> str: + """Compute duration in days between dates.""" + if start_date is None or end_date is None: + return 'N/A' + try: + if isinstance(start_date, str): + start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if isinstance(end_date, str): + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + delta = end_date - start_date + return str(delta.days) + except Exception: + return 'N/A' + + def _compile_performance_metrics(self, metrics: Dict[str, Any], result_dict: Dict[str, Any]) -> Dict[str, float]: + """Compile all performance metrics.""" + compiled = {} + + metric_keys = [ + ('sharpe_ratio', 0.0), + ('sortino_ratio', 0.0), + ('calmar_ratio', 0.0), + ('profit_factor', 0.0), + ('net_profit', 0.0), + ('gross_profit', 0.0), + ('gross_loss', 0.0), + ('max_drawdown', 0.0), + ('max_drawdown_pct', 0.0), + ('total_profit_pct', 0.0), + ('roi', 0.0) + ] + + for key, default in metric_keys: + value = metrics.get(key, result_dict.get(key, default)) + compiled[key] = float(value) if value is not None else default + + win_rate = metrics.get('winrate', metrics.get('win_rate', result_dict.get('win_rate', 0))) + compiled['win_rate'] = float(win_rate) if win_rate is not None else 0.0 + + return compiled + + def _compute_trade_statistics(self, trades: List[Any], metrics: Dict[str, Any]) -> Dict[str, Any]: + """Compute detailed trade statistics.""" + total_trades = len(trades) if trades else metrics.get('total_trades', 0) + winning_trades = metrics.get('winning_trades', 0) + losing_trades = metrics.get('losing_trades', 0) + + if trades and (winning_trades == 0 and losing_trades == 0): + for t in trades: + pnl = t.pnl if hasattr(t, 'pnl') else t.get('pnl', 0) if isinstance(t, dict) else 0 + if pnl > 0: + winning_trades += 1 + elif pnl < 0: + losing_trades += 1 + + stats = { + 'total_trades': total_trades, + 'winning_trades': winning_trades, + 'losing_trades': losing_trades, + 'breakeven_trades': total_trades - winning_trades - losing_trades, + 'win_rate': winning_trades / total_trades if total_trades > 0 else 0, + 'avg_win': metrics.get('avg_win', 0), + 'avg_loss': metrics.get('avg_loss', 0), + 'largest_win': metrics.get('largest_win', 0), + 'largest_loss': metrics.get('largest_loss', 0), + 'avg_trade': metrics.get('avg_trade', 0), + 'avg_trade_duration': metrics.get('avg_trade_duration', 0), + 'max_consecutive_wins': metrics.get('max_consecutive_wins', 0), + 'max_consecutive_losses': metrics.get('max_consecutive_losses', 0) + } + + if trades: + longs = sum(1 for t in trades if self._get_trade_direction(t) == 'long') + shorts = sum(1 for t in trades if self._get_trade_direction(t) == 'short') + stats['long_trades'] = longs + stats['short_trades'] = shorts + + return stats + + def _get_trade_direction(self, trade: Any) -> str: + """Get trade direction from trade object.""" + if hasattr(trade, 'direction'): + return trade.direction + if hasattr(trade, 'side'): + return trade.side + if isinstance(trade, dict): + return trade.get('direction', trade.get('side', 'unknown')) + return 'unknown' + + def _analyze_drawdown(self, equity_curve: Optional[np.ndarray], metrics: Dict[str, Any]) -> Dict[str, Any]: + """Analyze drawdown characteristics.""" + analysis = { + 'max_drawdown': metrics.get('max_drawdown', 0), + 'max_drawdown_pct': metrics.get('max_drawdown_pct', 0), + 'max_drawdown_duration': metrics.get('max_drawdown_duration', 0), + 'avg_drawdown': 0, + 'drawdown_periods': 0, + 'recovery_factor': 0 + } + + if equity_curve is not None and len(equity_curve) > 1: + running_max = np.maximum.accumulate(equity_curve) + drawdown = (running_max - equity_curve) / running_max + drawdown = np.nan_to_num(drawdown, nan=0.0, posinf=0.0, neginf=0.0) + + analysis['max_drawdown_pct'] = float(np.max(drawdown) * 100) + analysis['avg_drawdown'] = float(np.mean(drawdown[drawdown > 0]) * 100) if np.any(drawdown > 0) else 0 + + in_drawdown = drawdown > 0.01 + drawdown_changes = np.diff(in_drawdown.astype(int)) + analysis['drawdown_periods'] = int(np.sum(drawdown_changes == 1)) + + net_profit = equity_curve[-1] - equity_curve[0] if len(equity_curve) > 1 else 0 + max_dd = analysis['max_drawdown'] if analysis['max_drawdown'] > 0 else 1 + analysis['recovery_factor'] = float(net_profit / max_dd) if max_dd > 0 else 0 + + return analysis + + def _analyze_returns(self, equity_curve: Optional[np.ndarray], trades: List[Any]) -> Dict[str, Any]: + """Analyze returns distribution.""" + analysis = { + 'monthly_returns': {}, + 'weekly_returns': {}, + 'daily_stats': { + 'best_day': 0, + 'worst_day': 0, + 'avg_daily_return': 0, + 'daily_volatility': 0 + } + } + + if equity_curve is not None and len(equity_curve) > 1: + returns = np.diff(equity_curve) / equity_curve[:-1] + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + + if len(returns) > 0: + analysis['daily_stats']['best_day'] = float(np.max(returns) * 100) + analysis['daily_stats']['worst_day'] = float(np.min(returns) * 100) + analysis['daily_stats']['avg_daily_return'] = float(np.mean(returns) * 100) + analysis['daily_stats']['daily_volatility'] = float(np.std(returns) * 100) + + if trades: + monthly_pnl = {} + weekly_pnl = {} + + for t in trades: + exit_time = None + pnl = 0 + + if hasattr(t, 'exit_time') and hasattr(t, 'pnl'): + exit_time = t.exit_time + pnl = t.pnl + elif isinstance(t, dict): + exit_time = t.get('exit_time') + pnl = t.get('pnl', 0) + + if exit_time and pnl: + if isinstance(exit_time, str): + try: + exit_time = datetime.fromisoformat(exit_time.replace('Z', '+00:00')) + except Exception: + continue + + month_key = exit_time.strftime('%Y-%m') + week_key = exit_time.strftime('%Y-W%W') + + monthly_pnl[month_key] = monthly_pnl.get(month_key, 0) + pnl + weekly_pnl[week_key] = weekly_pnl.get(week_key, 0) + pnl + + analysis['monthly_returns'] = {k: float(v) for k, v in sorted(monthly_pnl.items())} + analysis['weekly_returns'] = {k: float(v) for k, v in sorted(weekly_pnl.items())} + + return analysis + + def _evaluate_pass_fail(self, performance_metrics: Dict[str, float], trade_statistics: Dict[str, Any]) -> bool: + """Evaluate if strategy passes validation thresholds.""" + total_trades = trade_statistics.get('total_trades', 0) + if total_trades < self.thresholds.min_trades: + return False + + sharpe = performance_metrics.get('sharpe_ratio', 0) + if sharpe < self.thresholds.min_sharpe_ratio: + return False + + win_rate = performance_metrics.get('win_rate', 0) + if win_rate < self.thresholds.min_win_rate: + return False + + profit_factor = performance_metrics.get('profit_factor', 0) + if profit_factor < self.thresholds.min_profit_factor: + return False + + max_dd = performance_metrics.get('max_drawdown_pct', 100) + if max_dd > self.thresholds.max_drawdown_pct: + return False + + return True + + def _create_executive_summary( + self, + strategy_name: str, + performance_metrics: Dict[str, float], + trade_statistics: Dict[str, Any], + passed_validation: bool + ) -> Dict[str, Any]: + """Create executive summary section.""" + return { + 'strategy': strategy_name, + 'status': 'PASSED' if passed_validation else 'FAILED', + 'key_metrics': { + 'sharpe_ratio': round(performance_metrics.get('sharpe_ratio', 0), 2), + 'win_rate': f"{performance_metrics.get('win_rate', 0) * 100:.1f}%", + 'profit_factor': round(performance_metrics.get('profit_factor', 0), 2), + 'total_trades': trade_statistics.get('total_trades', 0), + 'net_profit': round(performance_metrics.get('net_profit', 0), 2), + 'max_drawdown': f"{performance_metrics.get('max_drawdown_pct', 0):.1f}%" + }, + 'verdict': self._generate_verdict(performance_metrics, trade_statistics, passed_validation) + } + + def _generate_verdict( + self, + performance_metrics: Dict[str, float], + trade_statistics: Dict[str, Any], + passed_validation: bool + ) -> str: + """Generate human-readable verdict.""" + if passed_validation: + sharpe = performance_metrics.get('sharpe_ratio', 0) + if sharpe >= 2.0: + return "Excellent performance. Strategy is ready for live trading with full position size." + elif sharpe >= 1.5: + return "Good performance. Strategy is suitable for live trading with moderate position size." + else: + return "Acceptable performance. Strategy passes minimum thresholds, consider paper trading first." + else: + issues = [] + if trade_statistics.get('total_trades', 0) < self.thresholds.min_trades: + issues.append("insufficient trades") + if performance_metrics.get('sharpe_ratio', 0) < self.thresholds.min_sharpe_ratio: + issues.append("low Sharpe ratio") + if performance_metrics.get('win_rate', 0) < self.thresholds.min_win_rate: + issues.append("low win rate") + if performance_metrics.get('profit_factor', 0) < self.thresholds.min_profit_factor: + issues.append("low profit factor") + if performance_metrics.get('max_drawdown_pct', 100) > self.thresholds.max_drawdown_pct: + issues.append("excessive drawdown") + + return f"Strategy does not meet minimum requirements: {', '.join(issues)}." + + def _generate_recommendations( + self, + strategy_name: str, + performance_metrics: Dict[str, float], + trade_statistics: Dict[str, Any], + drawdown_analysis: Dict[str, Any], + passed_validation: bool + ) -> List[str]: + """Generate actionable recommendations.""" + recommendations = [] + + if not passed_validation: + recommendations.append("Do not deploy this strategy to live trading until issues are resolved.") + + sharpe = performance_metrics.get('sharpe_ratio', 0) + if sharpe < 1.0: + recommendations.append("Consider adding filters to improve signal quality and increase Sharpe ratio.") + elif sharpe > 2.5: + recommendations.append("Excellent Sharpe ratio. Verify results are not due to overfitting.") + + win_rate = performance_metrics.get('win_rate', 0) + profit_factor = performance_metrics.get('profit_factor', 0) + + if win_rate < 0.5 and profit_factor > 1.5: + recommendations.append("Low win rate but good profit factor suggests the strategy relies on large winners. Ensure psychological readiness for losing streaks.") + elif win_rate > 0.6 and profit_factor < 1.2: + recommendations.append("High win rate but low profit factor. Review risk:reward ratios and consider widening take profit targets.") + + max_dd = drawdown_analysis.get('max_drawdown_pct', 0) + if max_dd > 15: + recommendations.append(f"Maximum drawdown of {max_dd:.1f}% is significant. Consider position sizing reduction or adding filters during adverse conditions.") + + total_trades = trade_statistics.get('total_trades', 0) + if total_trades < 100: + recommendations.append("Limited trade count. Results may not be statistically significant. Extend backtest period.") + + consecutive_losses = trade_statistics.get('max_consecutive_losses', 0) + if consecutive_losses >= 8: + recommendations.append(f"Strategy experienced {consecutive_losses} consecutive losses. Ensure adequate capital to withstand losing streaks.") + + if not recommendations: + recommendations.append("Strategy shows strong performance across all metrics. Continue monitoring in forward testing.") + + return recommendations + + def _analyze_strategy_weights( + self, + individual_results: Dict[str, Dict[str, Any]], + strategy_weights: Dict[str, float] + ) -> Dict[str, Any]: + """Analyze strategy weight contribution.""" + analysis = { + 'weight_distribution': strategy_weights, + 'contribution_analysis': {}, + 'suggested_weights': {} + } + + total_sharpe = sum(r.get('sharpe_ratio', 0) * strategy_weights.get(name, 0) + for name, r in individual_results.items()) + + for name, result in individual_results.items(): + sharpe = result.get('sharpe_ratio', 0) + weight = strategy_weights.get(name, 0) + contribution = sharpe * weight / total_sharpe if total_sharpe > 0 else 0 + + analysis['contribution_analysis'][name] = { + 'weight': weight, + 'sharpe': sharpe, + 'weighted_contribution': round(contribution * 100, 1) + } + + sharpe_sum = sum(max(0, r.get('sharpe_ratio', 0)) for r in individual_results.values()) + for name, result in individual_results.items(): + sharpe = max(0, result.get('sharpe_ratio', 0)) + suggested = sharpe / sharpe_sum if sharpe_sum > 0 else 1.0 / len(individual_results) + analysis['suggested_weights'][name] = round(suggested, 3) + + return analysis + + def _compute_diversification_benefit( + self, + individual_sharpes: List[float], + ensemble_sharpe: float + ) -> float: + """Compute diversification benefit of ensemble.""" + if not individual_sharpes: + return 0.0 + + avg_individual = np.mean(individual_sharpes) + if avg_individual <= 0: + return 0.0 + + benefit = (ensemble_sharpe - avg_individual) / avg_individual * 100 + return float(round(benefit, 2)) + + def _generate_ensemble_recommendations( + self, + combined_metrics: Dict[str, float], + individual_results: Dict[str, Dict[str, Any]], + weight_analysis: Dict[str, Any], + diversification_benefit: float, + passed_validation: bool + ) -> List[str]: + """Generate recommendations for ensemble.""" + recommendations = [] + + if not passed_validation: + recommendations.append("Ensemble does not meet minimum validation criteria.") + + if diversification_benefit < 0: + recommendations.append("Negative diversification benefit. Consider removing highly correlated strategies.") + elif diversification_benefit < 10: + recommendations.append("Low diversification benefit. Strategies may be too correlated.") + elif diversification_benefit > 30: + recommendations.append("Excellent diversification benefit. Ensemble is well-diversified.") + + for name, analysis in weight_analysis.get('contribution_analysis', {}).items(): + if analysis.get('weighted_contribution', 0) < 5: + recommendations.append(f"Strategy '{name}' has minimal contribution. Consider removing or increasing weight.") + + if not recommendations: + recommendations.append("Ensemble shows healthy diversification and performance.") + + return recommendations + + def _rank_strategies( + self, + strategy_metrics: Dict[str, Dict[str, Any]], + metric_key: str, + descending: bool = True, + fallback_key: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Rank strategies by a specific metric.""" + rankings = [] + + for name, metrics in strategy_metrics.items(): + value = metrics.get(metric_key) + if value is None and fallback_key: + value = metrics.get(fallback_key, 0) + if value is None: + value = 0 + + rankings.append({ + 'strategy': name, + 'value': float(value), + 'metric': metric_key + }) + + rankings.sort(key=lambda x: x['value'], reverse=descending) + + for i, r in enumerate(rankings): + r['rank'] = i + 1 + + return rankings + + def _compute_correlation_analysis(self, all_results: Dict[str, Any]) -> Dict[str, Any]: + """Compute correlation analysis between strategies.""" + analysis = { + 'correlation_matrix': {}, + 'high_correlations': [], + 'diversification_score': 0 + } + + strategy_returns = {} + for name, result in all_results.items(): + result_dict = self._normalize_result(result) + ec = self._extract_equity_curve(result_dict) + if ec is not None and len(ec) > 1: + returns = np.diff(ec) / ec[:-1] + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + strategy_returns[name] = returns + + if len(strategy_returns) < 2: + return analysis + + names = list(strategy_returns.keys()) + min_len = min(len(r) for r in strategy_returns.values()) + + for i, name_i in enumerate(names): + analysis['correlation_matrix'][name_i] = {} + for j, name_j in enumerate(names): + returns_i = strategy_returns[name_i][:min_len] + returns_j = strategy_returns[name_j][:min_len] + + if len(returns_i) > 1: + corr = np.corrcoef(returns_i, returns_j)[0, 1] + corr = 0.0 if np.isnan(corr) else corr + else: + corr = 0.0 + + analysis['correlation_matrix'][name_i][name_j] = round(corr, 3) + + if i < j and abs(corr) > 0.7: + analysis['high_correlations'].append({ + 'pair': (name_i, name_j), + 'correlation': round(corr, 3) + }) + + correlations = [] + for i, name_i in enumerate(names): + for j, name_j in enumerate(names): + if i < j: + correlations.append(analysis['correlation_matrix'][name_i][name_j]) + + if correlations: + avg_corr = np.mean(np.abs(correlations)) + analysis['diversification_score'] = round((1 - avg_corr) * 100, 1) + + return analysis + + def _analyze_regime_performance( + self, + all_results: Dict[str, Any], + regime_data: Dict[str, Any] + ) -> Dict[str, Dict[str, str]]: + """Analyze performance by market regime.""" + regimes = regime_data.get('regimes', ['low_volatility', 'high_volatility', 'trending', 'ranging']) + regime_analysis = {} + + for regime in regimes: + best_strategy = None + best_metric = float('-inf') + + for name, result in all_results.items(): + result_dict = self._normalize_result(result) + metrics = self._extract_metrics(result_dict) + + metrics_by_regime = metrics.get('metrics_by_volatility', {}) + regime_metrics = metrics_by_regime.get(regime, {}) + sharpe = regime_metrics.get('sharpe_ratio', metrics.get('sharpe_ratio', 0)) + + if sharpe > best_metric: + best_metric = sharpe + best_strategy = name + + regime_analysis[regime] = { + 'best_strategy': best_strategy or 'N/A', + 'sharpe_ratio': round(best_metric, 2) if best_metric > float('-inf') else 0 + } + + return regime_analysis + + def _generate_comparison_recommendations( + self, + rankings: Dict[str, List[Dict[str, Any]]], + correlation_analysis: Dict[str, Any], + best_strategy: Dict[str, str], + regime_analysis: Dict[str, Dict[str, str]] + ) -> List[str]: + """Generate recommendations for strategy comparison.""" + recommendations = [] + + top_sharpe = rankings['by_sharpe_ratio'][0] if rankings['by_sharpe_ratio'] else None + if top_sharpe: + recommendations.append( + f"'{top_sharpe['strategy']}' has the highest Sharpe ratio ({top_sharpe['value']:.2f}). " + f"Consider this as the primary strategy." + ) + + if correlation_analysis.get('high_correlations'): + for pair_info in correlation_analysis['high_correlations'][:3]: + pair = pair_info['pair'] + corr = pair_info['correlation'] + recommendations.append( + f"Strategies '{pair[0]}' and '{pair[1]}' have high correlation ({corr:.2f}). " + f"Running both provides limited diversification." + ) + + diversification_score = correlation_analysis.get('diversification_score', 0) + if diversification_score < 30: + recommendations.append( + f"Low diversification score ({diversification_score:.0f}%). " + f"Consider adding uncorrelated strategies." + ) + elif diversification_score > 70: + recommendations.append( + f"Good diversification score ({diversification_score:.0f}%). " + f"Strategies are relatively uncorrelated." + ) + + if not recommendations: + recommendations.append("Review individual strategy metrics to make deployment decisions.") + + return recommendations + + def _serialize_equity_curve(self, equity_curve: Optional[np.ndarray]) -> List[float]: + """Serialize equity curve for JSON export.""" + if equity_curve is None: + return [] + return [float(x) for x in equity_curve] + + def _serialize_trades(self, trades: List[Any]) -> List[Dict[str, Any]]: + """Serialize trades for JSON export.""" + serialized = [] + for t in trades: + if hasattr(t, 'to_dict'): + serialized.append(t.to_dict()) + elif isinstance(t, dict): + serialized.append(t) + else: + serialized.append(str(t)) + return serialized + + def _strategy_report_to_markdown(self, report: StrategyReport) -> str: + """Convert strategy report to markdown.""" + lines = [ + f"# Backtest Report: {report.strategy_name}", + "", + f"**Generated:** {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}", + f"**Status:** {'PASSED' if report.passed_validation else 'FAILED'}", + "", + "---", + "", + "## Executive Summary", + "", + ] + + summary = report.executive_summary + lines.extend([ + f"- **Strategy:** {summary.get('strategy', 'N/A')}", + f"- **Status:** {summary.get('status', 'N/A')}", + f"- **Verdict:** {summary.get('verdict', 'N/A')}", + "", + "### Key Metrics", + "" + ]) + + key_metrics = summary.get('key_metrics', {}) + for k, v in key_metrics.items(): + lines.append(f"- **{k.replace('_', ' ').title()}:** {v}") + + lines.extend([ + "", + "---", + "", + "## Performance Metrics", + "", + "| Metric | Value |", + "|--------|-------|" + ]) + + for metric, value in report.performance_metrics.items(): + if isinstance(value, float): + formatted = f"{value:.4f}" + else: + formatted = str(value) + lines.append(f"| {metric.replace('_', ' ').title()} | {formatted} |") + + lines.extend([ + "", + "---", + "", + "## Trade Statistics", + "", + "| Statistic | Value |", + "|-----------|-------|" + ]) + + for stat, value in report.trade_statistics.items(): + if isinstance(value, float): + formatted = f"{value:.4f}" + else: + formatted = str(value) + lines.append(f"| {stat.replace('_', ' ').title()} | {formatted} |") + + lines.extend([ + "", + "---", + "", + "## Drawdown Analysis", + "", + ]) + + for key, value in report.drawdown_analysis.items(): + if isinstance(value, float): + formatted = f"{value:.4f}" + else: + formatted = str(value) + lines.append(f"- **{key.replace('_', ' ').title()}:** {formatted}") + + lines.extend([ + "", + "---", + "", + "## Returns Analysis", + "", + "### Daily Statistics", + "" + ]) + + daily_stats = report.returns_analysis.get('daily_stats', {}) + for key, value in daily_stats.items(): + if isinstance(value, float): + formatted = f"{value:.4f}" + else: + formatted = str(value) + lines.append(f"- **{key.replace('_', ' ').title()}:** {formatted}") + + if report.returns_analysis.get('monthly_returns'): + lines.extend([ + "", + "### Monthly Returns", + "" + ]) + for month, value in list(report.returns_analysis['monthly_returns'].items())[-12:]: + lines.append(f"- **{month}:** ${value:,.2f}") + + lines.extend([ + "", + "---", + "", + "## Recommendations", + "" + ]) + + for i, rec in enumerate(report.recommendations, 1): + lines.append(f"{i}. {rec}") + + lines.extend([ + "", + "---", + "", + "*Report generated by ML-Engine Backtesting Module*" + ]) + + return "\n".join(lines) + + def _ensemble_report_to_markdown(self, report: EnsembleReport) -> str: + """Convert ensemble report to markdown.""" + lines = [ + f"# Ensemble Backtest Report: {report.ensemble_name}", + "", + f"**Generated:** {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}", + f"**Status:** {'PASSED' if report.passed_validation else 'FAILED'}", + f"**Diversification Benefit:** {report.diversification_benefit:.1f}%", + "", + "---", + "", + "## Strategy Weights", + "", + "| Strategy | Weight |", + "|----------|--------|" + ] + + for strategy, weight in report.strategy_weights.items(): + lines.append(f"| {strategy} | {weight:.1%} |") + + lines.extend([ + "", + "---", + "", + "## Combined Metrics", + "", + "| Metric | Value |", + "|--------|-------|" + ]) + + for metric, value in report.combined_metrics.items(): + if isinstance(value, float): + formatted = f"{value:.4f}" + else: + formatted = str(value) + lines.append(f"| {metric.replace('_', ' ').title()} | {formatted} |") + + lines.extend([ + "", + "---", + "", + "## Individual Strategy Performance", + "" + ]) + + for strategy, metrics in report.individual_results.items(): + lines.append(f"### {strategy}") + lines.append("") + for k, v in metrics.items(): + if isinstance(v, float): + formatted = f"{v:.4f}" + else: + formatted = str(v) + lines.append(f"- **{k.replace('_', ' ').title()}:** {formatted}") + lines.append("") + + lines.extend([ + "---", + "", + "## Weight Analysis", + "", + "### Contribution Analysis", + "" + ]) + + for strategy, analysis in report.weight_analysis.get('contribution_analysis', {}).items(): + lines.append(f"- **{strategy}:** {analysis.get('weighted_contribution', 0):.1f}% contribution") + + lines.extend([ + "", + "### Suggested Weights", + "" + ]) + + for strategy, weight in report.weight_analysis.get('suggested_weights', {}).items(): + lines.append(f"- **{strategy}:** {weight:.1%}") + + lines.extend([ + "", + "---", + "", + "## Recommendations", + "" + ]) + + for i, rec in enumerate(report.recommendations, 1): + lines.append(f"{i}. {rec}") + + lines.extend([ + "", + "---", + "", + "*Report generated by ML-Engine Backtesting Module*" + ]) + + return "\n".join(lines) + + def _comparison_report_to_markdown(self, report: ComparisonReport) -> str: + """Convert comparison report to markdown.""" + lines = [ + "# Strategy Comparison Report", + "", + f"**Generated:** {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}", + f"**Strategies Compared:** {', '.join(report.strategies_compared)}", + "", + "---", + "", + "## Best Strategies", + "" + ] + + for category, strategy in report.best_strategy.items(): + lines.append(f"- **{category.replace('_', ' ').title()}:** {strategy}") + + lines.extend([ + "", + "---", + "", + "## Rankings", + "" + ]) + + for ranking_type, rankings in report.rankings.items(): + lines.append(f"### {ranking_type.replace('_', ' ').title()}") + lines.append("") + lines.append("| Rank | Strategy | Value |") + lines.append("|------|----------|-------|") + for r in rankings: + lines.append(f"| {r['rank']} | {r['strategy']} | {r['value']:.4f} |") + lines.append("") + + lines.extend([ + "---", + "", + "## Correlation Analysis", + "", + f"**Diversification Score:** {report.correlation_analysis.get('diversification_score', 0):.1f}%", + "" + ]) + + if report.correlation_analysis.get('high_correlations'): + lines.append("### High Correlations") + lines.append("") + for pair_info in report.correlation_analysis['high_correlations']: + pair = pair_info['pair'] + corr = pair_info['correlation'] + lines.append(f"- {pair[0]} <-> {pair[1]}: {corr:.3f}") + lines.append("") + + if report.regime_analysis: + lines.extend([ + "---", + "", + "## Regime Analysis", + "", + "| Regime | Best Strategy | Sharpe |", + "|--------|---------------|--------|" + ]) + for regime, analysis in report.regime_analysis.items(): + lines.append(f"| {regime.replace('_', ' ').title()} | {analysis['best_strategy']} | {analysis['sharpe_ratio']:.2f} |") + lines.append("") + + lines.extend([ + "---", + "", + "## Recommendations", + "" + ]) + + for i, rec in enumerate(report.recommendations, 1): + lines.append(f"{i}. {rec}") + + lines.extend([ + "", + "---", + "", + "*Report generated by ML-Engine Backtesting Module*" + ]) + + return "\n".join(lines) diff --git a/src/backtesting/runner.py b/src/backtesting/runner.py new file mode 100644 index 0000000..6b12bac --- /dev/null +++ b/src/backtesting/runner.py @@ -0,0 +1,1068 @@ +""" +Backtesting Runner - Full Validation Orchestrator +=================================================== +Main orchestrator for running complete backtesting validation across +all trading strategies and the ensemble. + +This module provides: +- BacktestRunner: Main orchestration class +- ValidationReport: Complete validation results with recommendations +- Integration with all 5 strategies (PVA, MRD, VBP, MSA, MTS) +- Ensemble validation using Neural Gating Metamodel +- Walk-forward validation support +- Effectiveness validation against random baselines + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +import json +import time +from loguru import logger + +from .engine import BacktestResult as EngineBacktestResult +from .rr_backtester import BacktestConfig, BacktestResult, RRBacktester +from .metrics import TradingMetrics, TradeRecord, MetricsCalculator +from .strategy_adapter import ( + StrategyAdapter, BaseStrategy, Signal, Prediction, + PVAAdapter, MRDAdapter, VBPAdapter, MSAAdapter, MTSAdapter +) +from .walk_forward import ( + WalkForwardValidator, WalkForwardConfig, AggregatedResult +) + +try: + from ..data.training_loader import TrainingDataLoader + DATA_LOADER_AVAILABLE = True +except ImportError: + DATA_LOADER_AVAILABLE = False + logger.warning("TrainingDataLoader not available") + + +@dataclass +class PerformanceMetrics: + """ + Comprehensive performance metrics for a strategy or ensemble. + + Contains all key trading metrics needed for evaluation. + """ + total_trades: int = 0 + winning_trades: int = 0 + losing_trades: int = 0 + winrate: float = 0.0 + profit_factor: float = 0.0 + sharpe_ratio: float = 0.0 + sortino_ratio: float = 0.0 + calmar_ratio: float = 0.0 + max_drawdown: float = 0.0 + max_drawdown_pct: float = 0.0 + net_profit: float = 0.0 + net_profit_pct: float = 0.0 + avg_win: float = 0.0 + avg_loss: float = 0.0 + avg_trade: float = 0.0 + expectancy: float = 0.0 + recovery_factor: float = 0.0 + risk_adjusted_return: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'total_trades': self.total_trades, + 'winning_trades': self.winning_trades, + 'losing_trades': self.losing_trades, + 'winrate': self.winrate, + 'profit_factor': self.profit_factor, + 'sharpe_ratio': self.sharpe_ratio, + 'sortino_ratio': self.sortino_ratio, + 'calmar_ratio': self.calmar_ratio, + 'max_drawdown': self.max_drawdown, + 'max_drawdown_pct': self.max_drawdown_pct, + 'net_profit': self.net_profit, + 'net_profit_pct': self.net_profit_pct, + 'avg_win': self.avg_win, + 'avg_loss': self.avg_loss, + 'avg_trade': self.avg_trade, + 'expectancy': self.expectancy, + 'recovery_factor': self.recovery_factor, + 'risk_adjusted_return': self.risk_adjusted_return + } + + @classmethod + def from_trading_metrics(cls, metrics: TradingMetrics) -> 'PerformanceMetrics': + """Create from TradingMetrics.""" + expectancy = (metrics.winrate * metrics.avg_win - + (1 - metrics.winrate) * metrics.avg_loss) + recovery_factor = (metrics.net_profit / abs(metrics.max_drawdown) + if metrics.max_drawdown != 0 else 0) + + return cls( + total_trades=metrics.total_trades, + winning_trades=metrics.winning_trades, + losing_trades=metrics.losing_trades, + winrate=metrics.winrate, + profit_factor=metrics.profit_factor, + sharpe_ratio=metrics.sharpe_ratio, + sortino_ratio=metrics.sortino_ratio, + calmar_ratio=metrics.calmar_ratio, + max_drawdown=metrics.max_drawdown, + max_drawdown_pct=metrics.max_drawdown_pct, + net_profit=metrics.net_profit, + net_profit_pct=(metrics.net_profit / 10000) * 100, + avg_win=metrics.avg_win, + avg_loss=metrics.avg_loss, + avg_trade=metrics.avg_trade, + expectancy=expectancy, + recovery_factor=recovery_factor, + risk_adjusted_return=metrics.sharpe_ratio * metrics.net_profit / 10000 + ) + + +@dataclass +class ValidationResult: + """ + Result of effectiveness validation against baselines. + + Compares strategy performance to random trading to ensure + the strategy has genuine predictive power. + """ + strategy_sharpe: float + random_sharpe_mean: float + random_sharpe_std: float + sharpe_z_score: float + strategy_winrate: float + random_winrate_mean: float + random_winrate_std: float + winrate_z_score: float + strategy_profit_factor: float + random_profit_factor_mean: float + random_profit_factor_std: float + pf_z_score: float + is_statistically_significant: bool + confidence_level: float + n_random_trials: int = 100 + p_value_estimate: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'strategy_sharpe': self.strategy_sharpe, + 'random_sharpe': {'mean': self.random_sharpe_mean, 'std': self.random_sharpe_std}, + 'sharpe_z_score': self.sharpe_z_score, + 'strategy_winrate': self.strategy_winrate, + 'random_winrate': {'mean': self.random_winrate_mean, 'std': self.random_winrate_std}, + 'winrate_z_score': self.winrate_z_score, + 'strategy_profit_factor': self.strategy_profit_factor, + 'random_profit_factor': {'mean': self.random_profit_factor_mean, 'std': self.random_profit_factor_std}, + 'pf_z_score': self.pf_z_score, + 'is_statistically_significant': self.is_statistically_significant, + 'confidence_level': self.confidence_level, + 'n_random_trials': self.n_random_trials, + 'p_value_estimate': self.p_value_estimate + } + + @property + def summary(self) -> str: + """Get summary string.""" + status = "PASS" if self.is_statistically_significant else "FAIL" + return (f"Validation {status}: Sharpe z={self.sharpe_z_score:.2f}, " + f"WR z={self.winrate_z_score:.2f}, " + f"PF z={self.pf_z_score:.2f} " + f"(conf={self.confidence_level:.1%})") + + +@dataclass +class ValidationReport: + """ + Complete validation report from backtesting. + + Contains results for all strategies, ensemble, and validation tests. + """ + strategy_results: Dict[str, BacktestResult] + ensemble_result: Optional[BacktestResult] + metrics: Dict[str, PerformanceMetrics] + effectiveness_validation: Optional[ValidationResult] + walk_forward_results: Optional[Dict[str, AggregatedResult]] + recommendations: List[str] + passed: bool + created_at: datetime = field(default_factory=datetime.now) + symbol: str = "" + timeframe: str = "5m" + test_period_start: Optional[datetime] = None + test_period_end: Optional[datetime] = None + total_bars: int = 0 + execution_time_seconds: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'passed': self.passed, + 'symbol': self.symbol, + 'timeframe': self.timeframe, + 'test_period': { + 'start': str(self.test_period_start) if self.test_period_start else None, + 'end': str(self.test_period_end) if self.test_period_end else None, + 'total_bars': self.total_bars + }, + 'metrics': {k: v.to_dict() for k, v in self.metrics.items()}, + 'effectiveness_validation': self.effectiveness_validation.to_dict() if self.effectiveness_validation else None, + 'walk_forward_results': {k: v.to_dict() for k, v in self.walk_forward_results.items()} if self.walk_forward_results else None, + 'recommendations': self.recommendations, + 'execution_time_seconds': self.execution_time_seconds, + 'created_at': self.created_at.isoformat() + } + + def save(self, path: str): + """Save report to JSON file.""" + save_path = Path(path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w') as f: + json.dump(self.to_dict(), f, indent=2, default=str) + + logger.info(f"Validation report saved to {save_path}") + + def print_summary(self): + """Print formatted summary.""" + print("\n" + "=" * 70) + print("BACKTESTING VALIDATION REPORT") + print("=" * 70) + print(f"Symbol: {self.symbol} | Timeframe: {self.timeframe}") + print(f"Period: {self.test_period_start} to {self.test_period_end}") + print(f"Total Bars: {self.total_bars:,}") + print(f"Execution Time: {self.execution_time_seconds:.1f}s") + print(f"Overall Status: {'PASSED' if self.passed else 'FAILED'}") + + print("\n--- Strategy Performance ---") + print(f"{'Strategy':<12} {'Trades':>8} {'WinRate':>10} {'PF':>8} {'Sharpe':>8} {'Net P&L':>12}") + print("-" * 70) + + for strategy_name, metrics in self.metrics.items(): + print(f"{strategy_name:<12} {metrics.total_trades:>8} " + f"{metrics.winrate:>10.2%} {metrics.profit_factor:>8.2f} " + f"{metrics.sharpe_ratio:>8.2f} ${metrics.net_profit:>11,.2f}") + + if self.effectiveness_validation: + print("\n--- Effectiveness Validation ---") + print(self.effectiveness_validation.summary) + + if self.recommendations: + print("\n--- Recommendations ---") + for i, rec in enumerate(self.recommendations, 1): + print(f"{i}. {rec}") + + print("=" * 70) + + +class BacktestRunner: + """ + Main backtesting orchestrator. + + Runs comprehensive validation across all trading strategies + and the ensemble, generating a complete validation report. + + Usage: + config = BacktestConfig() + runner = BacktestRunner(config, data_loader) + + # Run all strategies on multiple symbols + results = runner.run_all_strategies(['XAUUSD', 'EURUSD']) + + # Run ensemble + ensemble_result = runner.run_ensemble(['XAUUSD']) + + # Full validation with walk-forward + report = runner.run_full_validation() + """ + + STRATEGY_NAMES = ['pva', 'mrd', 'vbp', 'msa', 'mts'] + + def __init__( + self, + config: Optional[BacktestConfig] = None, + data_loader: Optional[Any] = None, + models_dir: Optional[str] = None, + device: str = "cpu" + ): + """ + Initialize backtest runner. + + Args: + config: Backtesting configuration + data_loader: TrainingDataLoader instance for loading data + models_dir: Directory containing trained model files + device: Device for inference ('cpu' or 'cuda') + """ + self.config = config or BacktestConfig() + self.data_loader = data_loader + self.models_dir = models_dir + self.device = device + + self.strategy_adapter = StrategyAdapter( + models_dir=models_dir, + device=device + ) + + self.backtester = RRBacktester(self.config) + self.metrics_calculator = MetricsCalculator() + + self._cached_data: Dict[str, pd.DataFrame] = {} + self._strategy_results: Dict[str, Dict[str, BacktestResult]] = {} + self._ensemble_results: Dict[str, BacktestResult] = {} + + logger.info(f"BacktestRunner initialized") + logger.info(f" Models dir: {models_dir}") + logger.info(f" Device: {device}") + + def load_data( + self, + symbol: str, + start_date: str, + end_date: str, + timeframe: str = "5m" + ) -> pd.DataFrame: + """ + Load price data for backtesting. + + Args: + symbol: Trading symbol + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + timeframe: Data timeframe + + Returns: + DataFrame with OHLCV data + """ + cache_key = f"{symbol}_{timeframe}_{start_date}_{end_date}" + + if cache_key in self._cached_data: + logger.debug(f"Using cached data for {cache_key}") + return self._cached_data[cache_key] + + if self.data_loader is not None: + df = self.data_loader.get_training_data( + symbol=symbol, + start_date=start_date, + end_date=end_date, + timeframe=timeframe, + include_features=True + ) + else: + logger.warning("No data loader available, generating synthetic data") + df = self._generate_synthetic_data(symbol, start_date, end_date, timeframe) + + self._cached_data[cache_key] = df + logger.info(f"Loaded {len(df):,} bars for {symbol} ({timeframe})") + + return df + + def _generate_synthetic_data( + self, + symbol: str, + start_date: str, + end_date: str, + timeframe: str + ) -> pd.DataFrame: + """Generate synthetic price data for testing.""" + start = pd.to_datetime(start_date) + end = pd.to_datetime(end_date) + + freq_map = {'5m': '5min', '15m': '15min', '1h': '1H', '4h': '4H', 'd': 'D'} + freq = freq_map.get(timeframe, '5min') + + dates = pd.date_range(start=start, end=end, freq=freq) + n_samples = len(dates) + + np.random.seed(hash(symbol) % 2**32) + + base_price = 2000.0 if 'XAU' in symbol else 1.1 if 'EUR' in symbol else 100.0 + volatility = 0.001 + + returns = np.random.randn(n_samples) * volatility + prices = base_price * np.cumprod(1 + returns) + + df = pd.DataFrame({ + 'open': prices * (1 + np.random.randn(n_samples) * 0.0003), + 'high': prices * (1 + np.abs(np.random.randn(n_samples)) * 0.0006), + 'low': prices * (1 - np.abs(np.random.randn(n_samples)) * 0.0006), + 'close': prices, + 'volume': np.random.randint(1000, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + return df + + def run_strategy( + self, + strategy_name: str, + symbol: str, + data: pd.DataFrame + ) -> BacktestResult: + """ + Run backtest for a single strategy on a single symbol. + + Args: + strategy_name: Name of strategy ('pva', 'mrd', 'vbp', 'msa', 'mts') + symbol: Trading symbol + data: Price data DataFrame + + Returns: + BacktestResult for the strategy + """ + logger.info(f"Running {strategy_name.upper()} strategy on {symbol}...") + + strategy = self.strategy_adapter.load_strategy(strategy_name, symbol) + + signals_df = self._generate_strategy_signals(strategy, data) + + self.backtester = RRBacktester(self.config) + result = self.backtester.run_backtest(data, signals_df) + + if symbol not in self._strategy_results: + self._strategy_results[symbol] = {} + self._strategy_results[symbol][strategy_name] = result + + logger.info(f" {strategy_name.upper()}: {result.metrics.total_trades} trades, " + f"WR={result.metrics.winrate:.2%}, " + f"PF={result.metrics.profit_factor:.2f}, " + f"Net=${result.metrics.net_profit:,.2f}") + + return result + + def _generate_strategy_signals( + self, + strategy: BaseStrategy, + data: pd.DataFrame + ) -> pd.DataFrame: + """Generate signals DataFrame from strategy predictions.""" + signals = pd.DataFrame(index=data.index) + signals['prob_tp_first'] = np.nan + signals['direction'] = 'long' + signals['horizon'] = '15m' + signals['rr_config'] = 'rr_2_1' + signals['confidence'] = 0.0 + signals['amd_phase'] = 'neutral' + signals['volatility_regime'] = 'medium' + + lookback = min(100, len(data) // 4) + signal_spacing = 12 + + for i in range(lookback, len(data), signal_spacing): + features = data.iloc[max(0, i-lookback):i] + + try: + prediction = strategy.predict(features) + + if abs(prediction.direction) > 0.15 and prediction.confidence > 0.5: + signals.loc[data.index[i], 'prob_tp_first'] = prediction.confidence + signals.loc[data.index[i], 'direction'] = 'long' if prediction.direction > 0 else 'short' + signals.loc[data.index[i], 'confidence'] = prediction.confidence + + if prediction.confidence > 0.7: + signals.loc[data.index[i], 'amd_phase'] = 'distribution' if prediction.direction > 0 else 'accumulation' + + except Exception as e: + logger.debug(f"Signal error at {i}: {e}") + continue + + valid_signals = signals['prob_tp_first'].notna().sum() + logger.debug(f"Generated {valid_signals} signals from {len(data)} bars") + + return signals + + def run_all_strategies( + self, + symbols: List[str], + start_date: str = "2023-01-01", + end_date: str = "2024-12-31", + timeframe: str = "5m" + ) -> Dict[str, BacktestResult]: + """ + Run all strategies on multiple symbols. + + Args: + symbols: List of trading symbols + start_date: Start date for backtest + end_date: End date for backtest + timeframe: Data timeframe + + Returns: + Dictionary mapping strategy names to aggregated BacktestResult + """ + logger.info(f"\nRunning all strategies on {len(symbols)} symbols...") + logger.info(f"Period: {start_date} to {end_date}") + + all_results: Dict[str, List[BacktestResult]] = {name: [] for name in self.STRATEGY_NAMES} + + for symbol in symbols: + logger.info(f"\n--- {symbol} ---") + + data = self.load_data(symbol, start_date, end_date, timeframe) + + if data.empty: + logger.warning(f"No data for {symbol}, skipping...") + continue + + for strategy_name in self.STRATEGY_NAMES: + try: + result = self.run_strategy(strategy_name, symbol, data) + all_results[strategy_name].append(result) + except Exception as e: + logger.error(f"Error running {strategy_name} on {symbol}: {e}") + + aggregated_results = {} + for strategy_name, results in all_results.items(): + if results: + aggregated_results[strategy_name] = self._aggregate_backtest_results( + results, strategy_name + ) + + return aggregated_results + + def _aggregate_backtest_results( + self, + results: List[BacktestResult], + strategy_name: str + ) -> BacktestResult: + """Aggregate multiple BacktestResult into one.""" + if len(results) == 1: + return results[0] + + all_trades = [] + total_bars = 0 + total_signals = 0 + + for result in results: + all_trades.extend(result.trades) + total_bars += result.total_bars + total_signals += result.signals_generated + + combined_metrics = self.metrics_calculator.calculate_metrics(all_trades) + + equity = np.zeros(1) + drawdown = np.zeros(1) + + return BacktestResult( + config=self.config, + trades=all_trades, + metrics=combined_metrics, + equity_curve=equity, + drawdown_curve=drawdown, + total_bars=total_bars, + signals_generated=total_signals, + signals_traded=len(all_trades), + signals_filtered=total_signals - len(all_trades) + ) + + def run_ensemble( + self, + symbols: List[str], + start_date: str = "2023-01-01", + end_date: str = "2024-12-31", + timeframe: str = "5m" + ) -> BacktestResult: + """ + Run ensemble strategy using Neural Gating Metamodel. + + The ensemble combines predictions from all 5 strategies + using learned gating weights. + + Args: + symbols: List of trading symbols + start_date: Start date for backtest + end_date: End date for backtest + timeframe: Data timeframe + + Returns: + BacktestResult for the ensemble + """ + logger.info(f"\nRunning Ensemble (Neural Gating Metamodel)...") + + all_trades = [] + total_bars = 0 + total_signals = 0 + + for symbol in symbols: + data = self.load_data(symbol, start_date, end_date, timeframe) + + if data.empty: + continue + + ensemble_signals = self._generate_ensemble_signals(symbol, data) + + self.backtester = RRBacktester(self.config) + result = self.backtester.run_backtest(data, ensemble_signals) + + all_trades.extend(result.trades) + total_bars += result.total_bars + total_signals += result.signals_generated + + self._ensemble_results[symbol] = result + + combined_metrics = self.metrics_calculator.calculate_metrics(all_trades) + + ensemble_result = BacktestResult( + config=self.config, + trades=all_trades, + metrics=combined_metrics, + equity_curve=np.zeros(1), + drawdown_curve=np.zeros(1), + total_bars=total_bars, + signals_generated=total_signals, + signals_traded=len(all_trades), + signals_filtered=total_signals - len(all_trades) + ) + + logger.info(f"Ensemble: {combined_metrics.total_trades} trades, " + f"WR={combined_metrics.winrate:.2%}, " + f"PF={combined_metrics.profit_factor:.2f}, " + f"Net=${combined_metrics.net_profit:,.2f}") + + return ensemble_result + + def _generate_ensemble_signals( + self, + symbol: str, + data: pd.DataFrame + ) -> pd.DataFrame: + """Generate ensemble signals by combining all strategies.""" + signals = pd.DataFrame(index=data.index) + signals['prob_tp_first'] = np.nan + signals['direction'] = 'long' + signals['horizon'] = '15m' + signals['rr_config'] = 'rr_2_1' + signals['confidence'] = 0.0 + signals['amd_phase'] = 'neutral' + signals['volatility_regime'] = 'medium' + + strategies = self.strategy_adapter.load_all_strategies(symbol) + + lookback = min(100, len(data) // 4) + signal_spacing = 12 + + strategy_weights = { + 'pva': 0.25, + 'mrd': 0.20, + 'vbp': 0.20, + 'msa': 0.15, + 'mts': 0.20 + } + + for i in range(lookback, len(data), signal_spacing): + features = data.iloc[max(0, i-lookback):i] + + combined_direction = 0.0 + combined_confidence = 0.0 + total_weight = 0.0 + + for strategy_name, strategy in strategies.items(): + try: + prediction = strategy.predict(features) + + weight = strategy_weights.get(strategy_name, 0.2) + combined_direction += prediction.direction * weight * prediction.confidence + combined_confidence += prediction.confidence * weight + total_weight += weight + + except Exception: + continue + + if total_weight > 0: + combined_direction /= total_weight + combined_confidence /= total_weight + + if abs(combined_direction) > 0.1 and combined_confidence > 0.55: + signals.loc[data.index[i], 'prob_tp_first'] = combined_confidence + signals.loc[data.index[i], 'direction'] = 'long' if combined_direction > 0 else 'short' + signals.loc[data.index[i], 'confidence'] = combined_confidence + + return signals + + def run_effectiveness_validation( + self, + strategy_result: BacktestResult, + data: pd.DataFrame, + n_trials: int = 100 + ) -> ValidationResult: + """ + Validate strategy effectiveness against random baseline. + + Runs multiple random trading simulations to establish baseline + performance, then compares strategy results. + + Args: + strategy_result: Result from actual strategy + data: Price data used for backtest + n_trials: Number of random trials + + Returns: + ValidationResult with statistical significance tests + """ + logger.info(f"Running effectiveness validation with {n_trials} random trials...") + + random_sharpes = [] + random_winrates = [] + random_pfs = [] + + n_signals = strategy_result.signals_traded + if n_signals == 0: + n_signals = 50 + + for trial in range(n_trials): + random_signals = self._generate_random_signals(data, n_signals) + + self.backtester = RRBacktester(self.config) + random_result = self.backtester.run_backtest(data, random_signals) + + sharpe = random_result.metrics.sharpe_ratio + if not np.isnan(sharpe) and not np.isinf(sharpe): + random_sharpes.append(np.clip(sharpe, -10, 10)) + + random_winrates.append(random_result.metrics.winrate) + + pf = random_result.metrics.profit_factor + if not np.isnan(pf) and not np.isinf(pf): + random_pfs.append(min(pf, 10)) + + strategy_sharpe = strategy_result.metrics.sharpe_ratio + strategy_winrate = strategy_result.metrics.winrate + strategy_pf = strategy_result.metrics.profit_factor + + random_sharpe_mean = np.mean(random_sharpes) if random_sharpes else 0 + random_sharpe_std = np.std(random_sharpes) if len(random_sharpes) > 1 else 1 + sharpe_z = (strategy_sharpe - random_sharpe_mean) / max(random_sharpe_std, 0.001) + + random_winrate_mean = np.mean(random_winrates) + random_winrate_std = np.std(random_winrates) if len(random_winrates) > 1 else 0.1 + winrate_z = (strategy_winrate - random_winrate_mean) / max(random_winrate_std, 0.001) + + random_pf_mean = np.mean(random_pfs) if random_pfs else 1.0 + random_pf_std = np.std(random_pfs) if len(random_pfs) > 1 else 0.5 + pf_z = (min(strategy_pf, 10) - random_pf_mean) / max(random_pf_std, 0.001) + + is_significant = sharpe_z > 1.645 or winrate_z > 1.645 or pf_z > 1.645 + + avg_z = (sharpe_z + winrate_z + pf_z) / 3 + from scipy.stats import norm + try: + p_value = 1 - norm.cdf(avg_z) + except Exception: + p_value = 0.5 if avg_z <= 0 else max(0.01, 0.5 - avg_z * 0.15) + + confidence = 1 - p_value + + return ValidationResult( + strategy_sharpe=strategy_sharpe, + random_sharpe_mean=random_sharpe_mean, + random_sharpe_std=random_sharpe_std, + sharpe_z_score=sharpe_z, + strategy_winrate=strategy_winrate, + random_winrate_mean=random_winrate_mean, + random_winrate_std=random_winrate_std, + winrate_z_score=winrate_z, + strategy_profit_factor=strategy_pf, + random_profit_factor_mean=random_pf_mean, + random_profit_factor_std=random_pf_std, + pf_z_score=pf_z, + is_statistically_significant=is_significant, + confidence_level=confidence, + n_random_trials=n_trials, + p_value_estimate=p_value + ) + + def _generate_random_signals( + self, + data: pd.DataFrame, + n_signals: int + ) -> pd.DataFrame: + """Generate random trading signals for baseline comparison.""" + signals = pd.DataFrame(index=data.index) + signals['prob_tp_first'] = np.nan + signals['direction'] = 'long' + signals['horizon'] = '15m' + signals['rr_config'] = 'rr_2_1' + signals['confidence'] = 0.0 + signals['amd_phase'] = 'neutral' + signals['volatility_regime'] = 'medium' + + signal_indices = np.random.choice( + range(100, len(data) - 100), + size=min(n_signals, len(data) - 200), + replace=False + ) + + for idx in signal_indices: + signals.loc[data.index[idx], 'prob_tp_first'] = np.random.uniform(0.5, 0.75) + signals.loc[data.index[idx], 'direction'] = np.random.choice(['long', 'short']) + signals.loc[data.index[idx], 'confidence'] = np.random.uniform(0.5, 0.75) + + return signals + + def run_full_validation( + self, + symbols: List[str] = None, + start_date: str = "2023-01-01", + end_date: str = "2024-12-31", + timeframe: str = "5m", + run_walk_forward: bool = True, + walk_forward_splits: int = 5 + ) -> ValidationReport: + """ + Run complete validation including all strategies, ensemble, + walk-forward validation, and effectiveness testing. + + Args: + symbols: List of symbols (defaults to common pairs) + start_date: Start date + end_date: End date + timeframe: Data timeframe + run_walk_forward: Whether to run walk-forward validation + walk_forward_splits: Number of walk-forward splits + + Returns: + Complete ValidationReport + """ + start_time = time.time() + + if symbols is None: + symbols = ['XAUUSD', 'EURUSD', 'GBPUSD'] + + logger.info("\n" + "=" * 70) + logger.info("FULL BACKTESTING VALIDATION") + logger.info("=" * 70) + logger.info(f"Symbols: {symbols}") + logger.info(f"Period: {start_date} to {end_date}") + logger.info(f"Timeframe: {timeframe}") + + all_data = {} + total_bars = 0 + for symbol in symbols: + data = self.load_data(symbol, start_date, end_date, timeframe) + if not data.empty: + all_data[symbol] = data + total_bars += len(data) + + logger.info(f"\n--- Running Individual Strategies ---") + strategy_results = self.run_all_strategies( + symbols, start_date, end_date, timeframe + ) + + logger.info(f"\n--- Running Ensemble Strategy ---") + ensemble_result = self.run_ensemble( + symbols, start_date, end_date, timeframe + ) + strategy_results['ensemble'] = ensemble_result + + metrics = {} + for name, result in strategy_results.items(): + metrics[name] = PerformanceMetrics.from_trading_metrics(result.metrics) + + logger.info(f"\n--- Running Effectiveness Validation ---") + first_symbol = symbols[0] if symbols else 'XAUUSD' + validation_data = all_data.get(first_symbol, next(iter(all_data.values()))) + effectiveness_validation = self.run_effectiveness_validation( + ensemble_result, validation_data, n_trials=50 + ) + + walk_forward_results = None + if run_walk_forward: + logger.info(f"\n--- Running Walk-Forward Validation ---") + walk_forward_results = {} + + for strategy_name in ['pva', 'ensemble']: + strategy = self.strategy_adapter.load_strategy('pva') + + validator = WalkForwardValidator( + n_splits=walk_forward_splits, + train_ratio=0.8 + ) + + wf_results = validator.run_walk_forward( + strategy, + validation_data, + self.config + ) + + aggregated = validator.aggregate_results(wf_results) + walk_forward_results[strategy_name] = aggregated + + logger.info(f"{strategy_name.upper()} Walk-Forward: " + f"WR={aggregated.winrate:.2%} +/- {aggregated.winrate_std:.2%}, " + f"PF={aggregated.profit_factor:.2f} +/- {aggregated.profit_factor_std:.2f}") + + recommendations = self._generate_recommendations( + metrics, effectiveness_validation, walk_forward_results + ) + + passed = self._determine_pass_status( + metrics, effectiveness_validation, walk_forward_results + ) + + execution_time = time.time() - start_time + + test_period_start = None + test_period_end = None + if all_data: + first_data = next(iter(all_data.values())) + test_period_start = first_data.index[0] + test_period_end = first_data.index[-1] + + report = ValidationReport( + strategy_results=strategy_results, + ensemble_result=ensemble_result, + metrics=metrics, + effectiveness_validation=effectiveness_validation, + walk_forward_results=walk_forward_results, + recommendations=recommendations, + passed=passed, + symbol=','.join(symbols), + timeframe=timeframe, + test_period_start=test_period_start, + test_period_end=test_period_end, + total_bars=total_bars, + execution_time_seconds=execution_time + ) + + logger.info(f"\n--- Validation Complete ---") + logger.info(f"Overall Status: {'PASSED' if passed else 'FAILED'}") + logger.info(f"Execution Time: {execution_time:.1f}s") + + return report + + def _generate_recommendations( + self, + metrics: Dict[str, PerformanceMetrics], + validation: Optional[ValidationResult], + walk_forward: Optional[Dict[str, AggregatedResult]] + ) -> List[str]: + """Generate recommendations based on validation results.""" + recommendations = [] + + best_strategy = max( + [(name, m.sharpe_ratio) for name, m in metrics.items() if name != 'ensemble'], + key=lambda x: x[1], + default=('pva', 0) + ) + + if best_strategy[1] > 0: + recommendations.append( + f"Best performing strategy: {best_strategy[0].upper()} " + f"(Sharpe={best_strategy[1]:.2f})" + ) + + ensemble_metrics = metrics.get('ensemble') + if ensemble_metrics: + if ensemble_metrics.sharpe_ratio > best_strategy[1]: + recommendations.append( + "Ensemble outperforms individual strategies - use ensemble for live trading" + ) + else: + recommendations.append( + f"Consider using {best_strategy[0].upper()} instead of ensemble" + ) + + if ensemble_metrics.max_drawdown_pct > 0.15: + recommendations.append( + f"High drawdown ({ensemble_metrics.max_drawdown_pct:.1%}) - " + "consider reducing position sizes" + ) + + if ensemble_metrics.winrate < 0.45: + recommendations.append( + f"Low win rate ({ensemble_metrics.winrate:.1%}) - " + "review signal generation thresholds" + ) + + if validation and not validation.is_statistically_significant: + recommendations.append( + "Strategy not statistically significant vs random - " + "requires more data or model refinement" + ) + + if walk_forward: + for name, wf in walk_forward.items(): + if wf.consistency_score < 0.6: + recommendations.append( + f"{name.upper()} shows inconsistent results across splits - " + "may need more robust features" + ) + + if not recommendations: + recommendations.append("All validation checks passed - ready for paper trading") + + return recommendations + + def _determine_pass_status( + self, + metrics: Dict[str, PerformanceMetrics], + validation: Optional[ValidationResult], + walk_forward: Optional[Dict[str, AggregatedResult]] + ) -> bool: + """Determine overall pass/fail status.""" + ensemble_metrics = metrics.get('ensemble') + if not ensemble_metrics: + return False + + if ensemble_metrics.profit_factor < 1.0: + return False + + if ensemble_metrics.sharpe_ratio < 0: + return False + + if ensemble_metrics.total_trades < 10: + return False + + if validation and validation.confidence_level < 0.5: + return False + + if walk_forward: + for wf in walk_forward.values(): + if wf.consistency_score < 0.4: + return False + + return True + + def clear_cache(self): + """Clear cached data and results.""" + self._cached_data.clear() + self._strategy_results.clear() + self._ensemble_results.clear() + logger.info("Cache cleared") + + +if __name__ == "__main__": + print("Testing BacktestRunner...") + print("=" * 70) + + config = BacktestConfig( + initial_capital=10000, + risk_per_trade=0.02, + min_confidence=0.55 + ) + + runner = BacktestRunner(config=config, device='cpu') + + print("\n--- Running Full Validation ---") + report = runner.run_full_validation( + symbols=['XAUUSD'], + start_date='2024-01-01', + end_date='2024-06-30', + run_walk_forward=False + ) + + report.print_summary() + + print("\n--- Saving Report ---") + report.save('/tmp/validation_report.json') + + print("\n" + "=" * 70) + print("BacktestRunner tests complete!") diff --git a/src/backtesting/strategy_adapter.py b/src/backtesting/strategy_adapter.py new file mode 100644 index 0000000..a93c8fb --- /dev/null +++ b/src/backtesting/strategy_adapter.py @@ -0,0 +1,756 @@ +""" +Strategy Adapter for Backtesting +================================= +Adapts ML strategies for backtesting framework. + +Provides a unified interface for all 5 trading strategies: +- PVA (Price Variation Attention) +- MRD (Momentum Regime Detection) +- VBP (Volatility Breakout Predictor) +- MSA (Market Structure Analysis) +- MTS (Multi-Timeframe Synthesis) + +Each adapter converts strategy-specific outputs to a common Signal format +suitable for backtesting. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +from enum import Enum +from loguru import logger + +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + + +class SignalDirection(Enum): + """Trading signal direction.""" + LONG = 1 + SHORT = -1 + NEUTRAL = 0 + + +@dataclass +class Prediction: + """ + Unified prediction output from any strategy. + + Contains the raw prediction values before signal generation. + """ + direction: float # -1 to 1 (bearish to bullish) + magnitude: float # Expected return magnitude (absolute) + confidence: float # 0 to 1 confidence score + raw_output: Dict[str, Any] = field(default_factory=dict) + strategy_name: str = "" + timestamp: Optional[pd.Timestamp] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'direction': float(self.direction), + 'magnitude': float(self.magnitude), + 'confidence': float(self.confidence), + 'strategy_name': self.strategy_name, + 'timestamp': str(self.timestamp) if self.timestamp else None, + 'raw_output': self.raw_output + } + + +@dataclass +class Signal: + """ + Trading signal generated from a prediction. + + Contains entry parameters including stop loss and take profit levels. + """ + direction: SignalDirection + confidence: float # 0 to 1 + entry_price: float + stop_loss: float + take_profit: float + strategy_name: str = "" + timestamp: Optional[pd.Timestamp] = None + risk_reward_ratio: float = 2.0 + position_size_factor: float = 1.0 # For position sizing based on confidence + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'direction': self.direction.name, + 'direction_value': self.direction.value, + 'confidence': float(self.confidence), + 'entry_price': float(self.entry_price), + 'stop_loss': float(self.stop_loss), + 'take_profit': float(self.take_profit), + 'strategy_name': self.strategy_name, + 'timestamp': str(self.timestamp) if self.timestamp else None, + 'risk_reward_ratio': float(self.risk_reward_ratio), + 'position_size_factor': float(self.position_size_factor), + 'metadata': self.metadata + } + + @property + def is_valid(self) -> bool: + """Check if signal is valid for trading.""" + if self.direction == SignalDirection.NEUTRAL: + return False + if self.confidence < 0.5: + return False + if self.stop_loss <= 0 or self.take_profit <= 0: + return False + return True + + @property + def risk_distance(self) -> float: + """Get distance to stop loss.""" + if self.direction == SignalDirection.LONG: + return abs(self.entry_price - self.stop_loss) + else: + return abs(self.stop_loss - self.entry_price) + + @property + def reward_distance(self) -> float: + """Get distance to take profit.""" + if self.direction == SignalDirection.LONG: + return abs(self.take_profit - self.entry_price) + else: + return abs(self.entry_price - self.take_profit) + + +class BaseStrategy(ABC): + """ + Abstract base class for all trading strategies. + + Defines the interface that all strategy adapters must implement. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + """ + Initialize base strategy. + + Args: + model_path: Path to saved model weights + device: Device for inference ('cpu' or 'cuda') + """ + self.model_path = model_path + self.device = device + self._is_loaded = False + self.model = None + self.strategy_name = self.__class__.__name__ + + @abstractmethod + def load_model(self) -> bool: + """ + Load the model from disk. + + Returns: + True if model loaded successfully + """ + pass + + @abstractmethod + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """ + Generate prediction from input features. + + Args: + features: Input features for the model + + Returns: + Prediction object with direction, magnitude, confidence + """ + pass + + def get_signal( + self, + prediction: Prediction, + current_price: float, + atr: float, + threshold: float = 0.55, + risk_reward: float = 2.0 + ) -> Optional[Signal]: + """ + Convert prediction to trading signal. + + Args: + prediction: Model prediction + current_price: Current market price + atr: Average True Range for SL/TP calculation + threshold: Minimum confidence threshold + risk_reward: Risk-reward ratio for TP + + Returns: + Signal object if conditions met, None otherwise + """ + if prediction.confidence < threshold: + return None + + if abs(prediction.direction) < 0.1: + return None + + direction = SignalDirection.LONG if prediction.direction > 0 else SignalDirection.SHORT + + sl_multiplier = 1.5 + tp_multiplier = sl_multiplier * risk_reward + + if direction == SignalDirection.LONG: + stop_loss = current_price - (atr * sl_multiplier) + take_profit = current_price + (atr * tp_multiplier) + else: + stop_loss = current_price + (atr * sl_multiplier) + take_profit = current_price - (atr * tp_multiplier) + + position_size_factor = min(prediction.confidence, 1.0) + if prediction.confidence > 0.8: + position_size_factor = 1.0 + elif prediction.confidence > 0.7: + position_size_factor = 0.75 + elif prediction.confidence > 0.6: + position_size_factor = 0.5 + else: + position_size_factor = 0.25 + + return Signal( + direction=direction, + confidence=prediction.confidence, + entry_price=current_price, + stop_loss=stop_loss, + take_profit=take_profit, + strategy_name=prediction.strategy_name, + timestamp=prediction.timestamp, + risk_reward_ratio=risk_reward, + position_size_factor=position_size_factor, + metadata={ + 'direction_raw': prediction.direction, + 'magnitude': prediction.magnitude, + 'atr': atr + } + ) + + @property + def is_loaded(self) -> bool: + """Check if model is loaded.""" + return self._is_loaded + + +class PVAAdapter(BaseStrategy): + """ + Adapter for PVA (Price Variation Attention) model. + + PVA uses transformer attention for sequence representation + combined with XGBoost for prediction. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + super().__init__(model_path, device) + self.strategy_name = "PVA" + + def load_model(self) -> bool: + """Load PVA model.""" + if self.model_path is None: + logger.warning("PVA: No model path provided, using mock model") + self._is_loaded = True + return True + + try: + from ..models.strategies.pva.model import PVAModel + self.model = PVAModel.load(self.model_path, device=self.device) + self._is_loaded = True + logger.info(f"PVA model loaded from {self.model_path}") + return True + except Exception as e: + logger.error(f"Failed to load PVA model: {e}") + return False + + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """Generate PVA prediction.""" + if isinstance(features, pd.DataFrame): + features = features.values + + if self.model is not None and hasattr(self.model, 'predict'): + try: + pva_pred = self.model.predict(features) + return Prediction( + direction=pva_pred.direction, + magnitude=pva_pred.magnitude, + confidence=pva_pred.confidence, + raw_output={'raw_prediction': pva_pred.raw_prediction}, + strategy_name=self.strategy_name + ) + except Exception as e: + logger.error(f"PVA prediction error: {e}") + + direction = np.tanh(np.mean(features[-1] if features.ndim > 1 else features) * 0.1) + return Prediction( + direction=float(direction), + magnitude=abs(float(direction)) * 0.01, + confidence=0.5 + abs(float(direction)) * 0.3, + raw_output={}, + strategy_name=self.strategy_name + ) + + +class MRDAdapter(BaseStrategy): + """ + Adapter for MRD (Momentum Regime Detection) model. + + MRD uses HMM for regime detection combined with LSTM + for sequence modeling and XGBoost for final prediction. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + super().__init__(model_path, device) + self.strategy_name = "MRD" + + def load_model(self) -> bool: + """Load MRD model.""" + if self.model_path is None: + logger.warning("MRD: No model path provided, using mock model") + self._is_loaded = True + return True + + try: + from ..models.strategies.mrd.model import MRDModel + self.model = MRDModel() + self.model.load(self.model_path) + self._is_loaded = True + logger.info(f"MRD model loaded from {self.model_path}") + return True + except Exception as e: + logger.error(f"Failed to load MRD model: {e}") + return False + + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """Generate MRD prediction.""" + if isinstance(features, np.ndarray): + df = pd.DataFrame(features) + if features.shape[1] >= 5: + df.columns = ['open', 'high', 'low', 'close', 'volume'] + \ + [f'feature_{i}' for i in range(features.shape[1] - 5)] + else: + df = features + + if self.model is not None and hasattr(self.model, 'predict'): + try: + mrd_pred = self.model.predict(df) + regime_direction = {0: -0.8, 1: 0.0, 2: 0.8}.get(mrd_pred.regime, 0.0) + direction = regime_direction * mrd_pred.continuation_prob + return Prediction( + direction=direction, + magnitude=abs(direction) * 0.01, + confidence=mrd_pred.regime_confidence, + raw_output={ + 'regime': mrd_pred.regime, + 'regime_name': mrd_pred.regime_name, + 'duration': mrd_pred.duration, + 'continuation_prob': mrd_pred.continuation_prob + }, + strategy_name=self.strategy_name + ) + except Exception as e: + logger.error(f"MRD prediction error: {e}") + + if isinstance(df, pd.DataFrame) and 'close' in df.columns: + returns = df['close'].pct_change().iloc[-20:].mean() if len(df) > 20 else 0 + direction = np.tanh(returns * 100) + else: + direction = 0.0 + + return Prediction( + direction=float(direction), + magnitude=abs(float(direction)) * 0.01, + confidence=0.5 + abs(float(direction)) * 0.25, + raw_output={'regime': 1, 'regime_name': 'range'}, + strategy_name=self.strategy_name + ) + + +class VBPAdapter(BaseStrategy): + """ + Adapter for VBP (Volatility Breakout Predictor) model. + + VBP uses CNN encoder for temporal patterns combined with + XGBoost for breakout classification and magnitude prediction. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + super().__init__(model_path, device) + self.strategy_name = "VBP" + + def load_model(self) -> bool: + """Load VBP model.""" + if self.model_path is None: + logger.warning("VBP: No model path provided, using mock model") + self._is_loaded = True + return True + + try: + from ..models.strategies.vbp.model import VBPModel + self.model = VBPModel() + self.model.load(self.model_path) + self._is_loaded = True + logger.info(f"VBP model loaded from {self.model_path}") + return True + except Exception as e: + logger.error(f"Failed to load VBP model: {e}") + return False + + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """Generate VBP prediction.""" + if isinstance(features, np.ndarray): + df = pd.DataFrame(features) + if features.shape[1] >= 5: + df.columns = ['open', 'high', 'low', 'close', 'volume'] + \ + [f'feature_{i}' for i in range(features.shape[1] - 5)] + else: + df = features + + if self.model is not None and hasattr(self.model, 'predict_single'): + try: + vbp_pred = self.model.predict_single(df) + direction = vbp_pred.direction * vbp_pred.breakout_probability + return Prediction( + direction=direction, + magnitude=vbp_pred.magnitude, + confidence=vbp_pred.confidence, + raw_output={ + 'breakout_probability': vbp_pred.breakout_probability, + 'breakout_class': vbp_pred.breakout_class, + 'is_breakout': vbp_pred.is_breakout, + 'is_high_confidence': vbp_pred.is_high_confidence + }, + strategy_name=self.strategy_name + ) + except Exception as e: + logger.error(f"VBP prediction error: {e}") + + if isinstance(df, pd.DataFrame) and 'high' in df.columns and 'low' in df.columns: + atr = (df['high'] - df['low']).iloc[-14:].mean() if len(df) > 14 else 0.01 + recent_range = df['high'].iloc[-5:].max() - df['low'].iloc[-5:].min() if len(df) > 5 else atr + breakout_prob = min(recent_range / (atr * 2), 1.0) if atr > 0 else 0.5 + direction = 0.0 + else: + breakout_prob = 0.3 + direction = 0.0 + + return Prediction( + direction=float(direction), + magnitude=breakout_prob * 0.02, + confidence=breakout_prob, + raw_output={'breakout_probability': breakout_prob, 'is_breakout': False}, + strategy_name=self.strategy_name + ) + + +class MSAAdapter(BaseStrategy): + """ + Adapter for MSA (Market Structure Analysis) model. + + MSA uses XGBoost for BOS direction, POI reaction, and + structure continuation predictions. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + super().__init__(model_path, device) + self.strategy_name = "MSA" + + def load_model(self) -> bool: + """Load MSA model.""" + if self.model_path is None: + logger.warning("MSA: No model path provided, using mock model") + self._is_loaded = True + return True + + try: + from ..models.strategies.msa.model import MSAModel + self.model = MSAModel() + self.model.load(self.model_path) + self._is_loaded = True + logger.info(f"MSA model loaded from {self.model_path}") + return True + except Exception as e: + logger.error(f"Failed to load MSA model: {e}") + return False + + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """Generate MSA prediction.""" + if isinstance(features, pd.DataFrame): + features_arr = features.values + else: + features_arr = features + + if self.model is not None and hasattr(self.model, 'predict_single'): + try: + msa_pred = self.model.predict_single(pd.DataFrame(features_arr)) + direction_map = {'bullish': 0.8, 'bearish': -0.8, 'neutral': 0.0} + direction = direction_map.get(msa_pred.next_bos_direction, 0.0) + direction = direction * msa_pred.bos_confidence + return Prediction( + direction=direction, + magnitude=abs(direction) * 0.015, + confidence=msa_pred.bos_confidence, + raw_output={ + 'next_bos_direction': msa_pred.next_bos_direction, + 'poi_reaction_prob': msa_pred.poi_reaction_prob, + 'structure_continuation_prob': msa_pred.structure_continuation_prob, + 'trading_bias': msa_pred.trading_bias + }, + strategy_name=self.strategy_name + ) + except Exception as e: + logger.error(f"MSA prediction error: {e}") + + direction = 0.0 + return Prediction( + direction=float(direction), + magnitude=0.01, + confidence=0.5, + raw_output={'next_bos_direction': 'neutral'}, + strategy_name=self.strategy_name + ) + + +class MTSAdapter(BaseStrategy): + """ + Adapter for MTS (Multi-Timeframe Synthesis) model. + + MTS uses hierarchical attention to combine multiple timeframes + and produces unified direction, confidence, and optimal entry TF. + """ + + def __init__(self, model_path: Optional[str] = None, device: str = "cpu"): + super().__init__(model_path, device) + self.strategy_name = "MTS" + + def load_model(self) -> bool: + """Load MTS model.""" + if self.model_path is None: + logger.warning("MTS: No model path provided, using mock model") + self._is_loaded = True + return True + + try: + from ..models.strategies.mts.model import MTSModel + self.model = MTSModel() + self.model.load(self.model_path) + self._is_loaded = True + logger.info(f"MTS model loaded from {self.model_path}") + return True + except Exception as e: + logger.error(f"Failed to load MTS model: {e}") + return False + + def predict(self, features: Union[np.ndarray, pd.DataFrame]) -> Prediction: + """Generate MTS prediction.""" + if isinstance(features, np.ndarray): + df = pd.DataFrame(features) + if features.shape[1] >= 5: + df.columns = ['open', 'high', 'low', 'close', 'volume'] + \ + [f'feature_{i}' for i in range(features.shape[1] - 5)] + else: + df = features + + if self.model is not None and hasattr(self.model, 'predict'): + try: + mts_pred = self.model.predict(df) + return Prediction( + direction=mts_pred.unified_direction, + magnitude=0.01, + confidence=mts_pred.confidence, + raw_output={ + 'direction_class': mts_pred.direction_class, + 'confidence_by_alignment': mts_pred.confidence_by_alignment, + 'optimal_entry_tf': mts_pred.optimal_entry_tf, + 'tf_contributions': mts_pred.tf_contributions, + 'recommended_action': mts_pred.recommended_action + }, + strategy_name=self.strategy_name + ) + except Exception as e: + logger.error(f"MTS prediction error: {e}") + + if isinstance(df, pd.DataFrame) and 'close' in df.columns: + sma_short = df['close'].iloc[-10:].mean() if len(df) > 10 else df['close'].iloc[-1] + sma_long = df['close'].iloc[-50:].mean() if len(df) > 50 else sma_short + direction = np.tanh((sma_short - sma_long) / sma_long * 100) if sma_long != 0 else 0 + else: + direction = 0.0 + + return Prediction( + direction=float(direction), + magnitude=abs(float(direction)) * 0.01, + confidence=0.5 + abs(float(direction)) * 0.3, + raw_output={'direction_class': 'neutral', 'optimal_entry_tf': '15m'}, + strategy_name=self.strategy_name + ) + + +class StrategyAdapter: + """ + Factory class to load and manage strategy adapters. + + Provides a unified interface to load any of the 5 strategies + and generate predictions or signals. + """ + + STRATEGY_MAP = { + 'pva': PVAAdapter, + 'mrd': MRDAdapter, + 'vbp': VBPAdapter, + 'msa': MSAAdapter, + 'mts': MTSAdapter + } + + def __init__(self, models_dir: Optional[str] = None, device: str = "cpu"): + """ + Initialize strategy adapter factory. + + Args: + models_dir: Base directory for model files + device: Device for inference + """ + self.models_dir = Path(models_dir) if models_dir else None + self.device = device + self._strategies: Dict[str, BaseStrategy] = {} + + def load_strategy( + self, + strategy_name: str, + symbol: Optional[str] = None + ) -> BaseStrategy: + """ + Load a specific strategy. + + Args: + strategy_name: Name of strategy ('pva', 'mrd', 'vbp', 'msa', 'mts') + symbol: Optional symbol for symbol-specific models + + Returns: + Loaded strategy adapter + """ + strategy_name = strategy_name.lower() + + if strategy_name not in self.STRATEGY_MAP: + raise ValueError(f"Unknown strategy: {strategy_name}. " + f"Available: {list(self.STRATEGY_MAP.keys())}") + + cache_key = f"{strategy_name}_{symbol}" if symbol else strategy_name + + if cache_key in self._strategies and self._strategies[cache_key].is_loaded: + return self._strategies[cache_key] + + model_path = None + if self.models_dir: + if symbol: + model_path = self.models_dir / strategy_name / symbol + else: + model_path = self.models_dir / strategy_name + + if model_path.exists(): + model_path = str(model_path) + else: + model_path = None + + strategy_class = self.STRATEGY_MAP[strategy_name] + strategy = strategy_class(model_path=model_path, device=self.device) + strategy.load_model() + + self._strategies[cache_key] = strategy + + logger.info(f"Loaded strategy: {strategy_name}" + + (f" for {symbol}" if symbol else "")) + + return strategy + + def load_all_strategies( + self, + symbol: Optional[str] = None + ) -> Dict[str, BaseStrategy]: + """ + Load all available strategies. + + Args: + symbol: Optional symbol for symbol-specific models + + Returns: + Dictionary of loaded strategies + """ + strategies = {} + for name in self.STRATEGY_MAP.keys(): + try: + strategies[name] = self.load_strategy(name, symbol) + except Exception as e: + logger.error(f"Failed to load {name}: {e}") + + return strategies + + def get_loaded_strategies(self) -> List[str]: + """Get names of currently loaded strategies.""" + return [k.split('_')[0] for k, v in self._strategies.items() if v.is_loaded] + + def unload_strategy(self, strategy_name: str, symbol: Optional[str] = None): + """Unload a strategy from memory.""" + cache_key = f"{strategy_name}_{symbol}" if symbol else strategy_name + if cache_key in self._strategies: + del self._strategies[cache_key] + logger.info(f"Unloaded strategy: {cache_key}") + + +if __name__ == "__main__": + print("Testing Strategy Adapter...") + print("=" * 60) + + np.random.seed(42) + n_samples = 100 + + df = pd.DataFrame({ + 'open': np.random.randn(n_samples).cumsum() + 100, + 'high': np.random.randn(n_samples).cumsum() + 101, + 'low': np.random.randn(n_samples).cumsum() + 99, + 'close': np.random.randn(n_samples).cumsum() + 100, + 'volume': np.random.randint(1000, 10000, n_samples) + }) + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + adapter = StrategyAdapter(device='cpu') + + strategies = adapter.load_all_strategies() + print(f"\nLoaded strategies: {list(strategies.keys())}") + + print("\n--- Strategy Predictions ---") + for name, strategy in strategies.items(): + prediction = strategy.predict(df) + print(f"{name.upper()}: direction={prediction.direction:.3f}, " + f"magnitude={prediction.magnitude:.4f}, " + f"confidence={prediction.confidence:.3f}") + + print("\n--- Signal Generation ---") + current_price = df['close'].iloc[-1] + atr = (df['high'] - df['low']).iloc[-14:].mean() + + for name, strategy in strategies.items(): + prediction = strategy.predict(df) + signal = strategy.get_signal(prediction, current_price, atr, threshold=0.3) + + if signal and signal.is_valid: + print(f"{name.upper()}: {signal.direction.name} @ {signal.entry_price:.2f}, " + f"SL={signal.stop_loss:.2f}, TP={signal.take_profit:.2f}, " + f"confidence={signal.confidence:.3f}") + else: + print(f"{name.upper()}: No valid signal") + + print("\n" + "=" * 60) + print("Strategy Adapter tests complete!") diff --git a/src/backtesting/trade.py b/src/backtesting/trade.py new file mode 100644 index 0000000..2439b5a --- /dev/null +++ b/src/backtesting/trade.py @@ -0,0 +1,421 @@ +""" +Trade data structures for backtesting engine. + +This module provides data classes and enums for representing trades, +their directions, and status throughout the backtesting process. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional, Dict, Any + + +class TradeDirection(Enum): + """Direction of a trade.""" + + LONG = "long" + SHORT = "short" + + def __str__(self) -> str: + """Return string representation.""" + return self.value + + @classmethod + def from_string(cls, value: str) -> "TradeDirection": + """ + Create TradeDirection from string. + + Args: + value: String value ('long' or 'short') + + Returns: + TradeDirection enum value + + Raises: + ValueError: If value is not valid + """ + value_lower = value.lower() + if value_lower == "long": + return cls.LONG + elif value_lower == "short": + return cls.SHORT + else: + raise ValueError(f"Invalid trade direction: {value}") + + +class TradeStatus(Enum): + """Status of a trade throughout its lifecycle.""" + + OPEN = "open" + CLOSED = "closed" + STOPPED_OUT = "stopped_out" + TAKE_PROFIT = "take_profit" + TIMEOUT = "timeout" + CANCELLED = "cancelled" + + def __str__(self) -> str: + """Return string representation.""" + return self.value + + @property + def is_closed(self) -> bool: + """Check if trade is in a closed state.""" + return self in ( + TradeStatus.CLOSED, + TradeStatus.STOPPED_OUT, + TradeStatus.TAKE_PROFIT, + TradeStatus.TIMEOUT, + TradeStatus.CANCELLED + ) + + @property + def is_profitable_exit(self) -> bool: + """Check if exit status typically indicates profit.""" + return self == TradeStatus.TAKE_PROFIT + + +@dataclass +class Trade: + """ + Represents a single trade in the backtesting system. + + This class captures all information about a trade from entry to exit, + including pricing, timing, sizing, and profit/loss calculations. + + Attributes: + trade_id: Unique identifier for the trade + symbol: Trading instrument symbol (e.g., 'EURUSD', 'BTCUSD') + direction: Trade direction (LONG or SHORT) + entry_price: Price at which the trade was entered + exit_price: Price at which the trade was closed (None if still open) + entry_time: Timestamp when trade was opened + exit_time: Timestamp when trade was closed (None if still open) + size: Position size in units/lots + stop_loss: Stop loss price level + take_profit: Take profit price level + status: Current status of the trade + pnl: Realized profit/loss in account currency + pnl_pips: Profit/loss in pips (for forex) + pnl_percent: Profit/loss as percentage of entry value + commission: Commission paid for the trade + slippage: Slippage incurred on the trade + strategy_name: Name of the strategy that generated this trade + timeframe: Timeframe on which the trade was generated + signal_confidence: Confidence score of the entry signal + metadata: Additional trade metadata + """ + + trade_id: int + symbol: str + direction: TradeDirection + entry_price: float + entry_time: datetime + size: float + + exit_price: Optional[float] = None + exit_time: Optional[datetime] = None + stop_loss: Optional[float] = None + take_profit: Optional[float] = None + status: TradeStatus = TradeStatus.OPEN + + pnl: float = 0.0 + pnl_pips: float = 0.0 + pnl_percent: float = 0.0 + commission: float = 0.0 + slippage: float = 0.0 + + strategy_name: str = "default" + timeframe: str = "1H" + signal_confidence: float = 0.0 + + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + """Validate trade data after initialization.""" + if self.entry_price <= 0: + raise ValueError("Entry price must be positive") + if self.size <= 0: + raise ValueError("Position size must be positive") + if isinstance(self.direction, str): + self.direction = TradeDirection.from_string(self.direction) + if isinstance(self.status, str): + self.status = TradeStatus(self.status) + + @property + def is_open(self) -> bool: + """Check if trade is still open.""" + return self.status == TradeStatus.OPEN + + @property + def is_closed(self) -> bool: + """Check if trade is closed.""" + return self.status.is_closed + + @property + def is_long(self) -> bool: + """Check if this is a long trade.""" + return self.direction == TradeDirection.LONG + + @property + def is_short(self) -> bool: + """Check if this is a short trade.""" + return self.direction == TradeDirection.SHORT + + @property + def is_profitable(self) -> bool: + """Check if trade is profitable.""" + return self.pnl > 0 + + @property + def duration_seconds(self) -> Optional[float]: + """Get trade duration in seconds.""" + if self.exit_time is None: + return None + return (self.exit_time - self.entry_time).total_seconds() + + @property + def duration_minutes(self) -> Optional[float]: + """Get trade duration in minutes.""" + seconds = self.duration_seconds + if seconds is None: + return None + return seconds / 60 + + @property + def duration_hours(self) -> Optional[float]: + """Get trade duration in hours.""" + seconds = self.duration_seconds + if seconds is None: + return None + return seconds / 3600 + + @property + def risk_reward_achieved(self) -> Optional[float]: + """Calculate achieved risk/reward ratio.""" + if self.stop_loss is None or self.exit_price is None: + return None + + if self.is_long: + risk = self.entry_price - self.stop_loss + reward = self.exit_price - self.entry_price + else: + risk = self.stop_loss - self.entry_price + reward = self.entry_price - self.exit_price + + if risk <= 0: + return None + + return reward / risk + + def close( + self, + exit_price: float, + exit_time: datetime, + status: TradeStatus = TradeStatus.CLOSED, + commission: float = 0.0, + slippage: float = 0.0 + ) -> float: + """ + Close the trade and calculate P&L. + + Args: + exit_price: Price at which to close the trade + exit_time: Time of trade closure + status: Exit status (CLOSED, STOPPED_OUT, TAKE_PROFIT, TIMEOUT) + commission: Commission for closing the trade + slippage: Slippage incurred on exit + + Returns: + Realized profit/loss + + Raises: + ValueError: If trade is already closed + """ + if self.is_closed: + raise ValueError(f"Trade {self.trade_id} is already closed") + + self.exit_price = exit_price + self.exit_time = exit_time + self.status = status + self.commission += commission + self.slippage += slippage + + self._calculate_pnl() + + return self.pnl + + def _calculate_pnl(self) -> None: + """Calculate profit/loss based on entry and exit prices.""" + if self.exit_price is None: + return + + price_diff = self.exit_price - self.entry_price + + if self.is_short: + price_diff = -price_diff + + gross_pnl = price_diff * self.size + self.pnl = gross_pnl - self.commission - self.slippage + + entry_value = self.entry_price * self.size + if entry_value > 0: + self.pnl_percent = (self.pnl / entry_value) * 100 + + pip_value = self._get_pip_value() + if pip_value > 0: + self.pnl_pips = price_diff / pip_value + + def _get_pip_value(self) -> float: + """ + Get pip value for the symbol. + + Returns: + Pip value (0.0001 for most forex, 0.01 for JPY pairs, 1.0 for crypto) + """ + symbol_upper = self.symbol.upper() + + if "JPY" in symbol_upper: + return 0.01 + elif any(crypto in symbol_upper for crypto in ["BTC", "ETH", "XRP", "LTC"]): + return 1.0 + else: + return 0.0001 + + def calculate_unrealized_pnl(self, current_price: float) -> float: + """ + Calculate unrealized P&L at current price. + + Args: + current_price: Current market price + + Returns: + Unrealized profit/loss + """ + if self.is_closed: + return self.pnl + + price_diff = current_price - self.entry_price + + if self.is_short: + price_diff = -price_diff + + return price_diff * self.size + + def should_stop_out(self, current_price: float) -> bool: + """ + Check if current price triggers stop loss. + + Args: + current_price: Current market price + + Returns: + True if stop loss should be triggered + """ + if self.stop_loss is None: + return False + + if self.is_long: + return current_price <= self.stop_loss + else: + return current_price >= self.stop_loss + + def should_take_profit(self, current_price: float) -> bool: + """ + Check if current price triggers take profit. + + Args: + current_price: Current market price + + Returns: + True if take profit should be triggered + """ + if self.take_profit is None: + return False + + if self.is_long: + return current_price >= self.take_profit + else: + return current_price <= self.take_profit + + def to_dict(self) -> Dict[str, Any]: + """ + Convert trade to dictionary representation. + + Returns: + Dictionary with all trade attributes + """ + return { + "trade_id": self.trade_id, + "symbol": self.symbol, + "direction": str(self.direction), + "entry_price": self.entry_price, + "exit_price": self.exit_price, + "entry_time": self.entry_time.isoformat() if self.entry_time else None, + "exit_time": self.exit_time.isoformat() if self.exit_time else None, + "size": self.size, + "stop_loss": self.stop_loss, + "take_profit": self.take_profit, + "status": str(self.status), + "pnl": self.pnl, + "pnl_pips": self.pnl_pips, + "pnl_percent": self.pnl_percent, + "commission": self.commission, + "slippage": self.slippage, + "strategy_name": self.strategy_name, + "timeframe": self.timeframe, + "signal_confidence": self.signal_confidence, + "duration_minutes": self.duration_minutes, + "risk_reward_achieved": self.risk_reward_achieved, + "metadata": self.metadata + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Trade": + """ + Create Trade instance from dictionary. + + Args: + data: Dictionary with trade data + + Returns: + Trade instance + """ + entry_time = data.get("entry_time") + if isinstance(entry_time, str): + entry_time = datetime.fromisoformat(entry_time) + + exit_time = data.get("exit_time") + if isinstance(exit_time, str): + exit_time = datetime.fromisoformat(exit_time) + + return cls( + trade_id=data["trade_id"], + symbol=data["symbol"], + direction=TradeDirection.from_string(data["direction"]), + entry_price=data["entry_price"], + entry_time=entry_time, + size=data["size"], + exit_price=data.get("exit_price"), + exit_time=exit_time, + stop_loss=data.get("stop_loss"), + take_profit=data.get("take_profit"), + status=TradeStatus(data.get("status", "open")), + pnl=data.get("pnl", 0.0), + pnl_pips=data.get("pnl_pips", 0.0), + pnl_percent=data.get("pnl_percent", 0.0), + commission=data.get("commission", 0.0), + slippage=data.get("slippage", 0.0), + strategy_name=data.get("strategy_name", "default"), + timeframe=data.get("timeframe", "1H"), + signal_confidence=data.get("signal_confidence", 0.0), + metadata=data.get("metadata", {}) + ) + + def __repr__(self) -> str: + """Return string representation of trade.""" + status_str = "OPEN" if self.is_open else f"CLOSED ({self.pnl:+.2f})" + return ( + f"Trade(id={self.trade_id}, {self.symbol}, {self.direction.value.upper()}, " + f"entry={self.entry_price:.5f}, size={self.size:.4f}, {status_str})" + ) diff --git a/src/backtesting/visualization.py b/src/backtesting/visualization.py new file mode 100644 index 0000000..1328d60 --- /dev/null +++ b/src/backtesting/visualization.py @@ -0,0 +1,1055 @@ +""" +Backtesting Visualization Utilities +==================================== +Data preparation utilities for backtesting visualizations. + +Prepares data structures suitable for frontend charting libraries +(e.g., recharts, plotly, lightweight-charts). + +Note: This module does NOT perform actual plotting. It prepares +data in formats ready for consumption by visualization libraries. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Union, Tuple +from dataclasses import dataclass, field +import numpy as np +import pandas as pd +from loguru import logger + + +@dataclass +class ChartDataPoint: + """Single data point for charting.""" + + x: Union[float, str, datetime] + y: float + label: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +@dataclass +class ChartSeries: + """Series of data points for a chart.""" + + name: str + data: List[ChartDataPoint] + color: Optional[str] = None + chart_type: str = "line" + + +@dataclass +class VisualizationData: + """ + Container for visualization data. + + Attributes: + chart_type: Type of chart (line, bar, scatter, heatmap, histogram) + title: Chart title + x_label: X-axis label + y_label: Y-axis label + series: List of data series + annotations: Optional annotations/markers + config: Additional chart configuration + """ + + chart_type: str + title: str + x_label: str + y_label: str + series: List[ChartSeries] = field(default_factory=list) + annotations: List[Dict[str, Any]] = field(default_factory=list) + config: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary suitable for JSON serialization.""" + return { + 'chartType': self.chart_type, + 'title': self.title, + 'xLabel': self.x_label, + 'yLabel': self.y_label, + 'series': [ + { + 'name': s.name, + 'data': [ + { + 'x': dp.x.isoformat() if isinstance(dp.x, datetime) else dp.x, + 'y': dp.y, + 'label': dp.label, + 'metadata': dp.metadata + } + for dp in s.data + ], + 'color': s.color, + 'type': s.chart_type + } + for s in self.series + ], + 'annotations': self.annotations, + 'config': self.config + } + + def to_recharts_format(self) -> Dict[str, Any]: + """Convert to format suitable for Recharts library.""" + if not self.series: + return {'data': [], 'config': self.config} + + data = [] + first_series = self.series[0] + + for i, dp in enumerate(first_series.data): + point = { + 'x': dp.x.isoformat() if isinstance(dp.x, datetime) else dp.x, + first_series.name: dp.y + } + + for other_series in self.series[1:]: + if i < len(other_series.data): + point[other_series.name] = other_series.data[i].y + + data.append(point) + + return { + 'data': data, + 'series': [{'dataKey': s.name, 'stroke': s.color, 'name': s.name} for s in self.series], + 'xAxisKey': 'x', + 'config': self.config + } + + def to_plotly_format(self) -> Dict[str, Any]: + """Convert to format suitable for Plotly library.""" + traces = [] + + for series in self.series: + trace = { + 'name': series.name, + 'x': [dp.x.isoformat() if isinstance(dp.x, datetime) else dp.x for dp in series.data], + 'y': [dp.y for dp in series.data], + 'type': self._map_chart_type_to_plotly(series.chart_type) + } + if series.color: + trace['marker'] = {'color': series.color} + traces.append(trace) + + layout = { + 'title': self.title, + 'xaxis': {'title': self.x_label}, + 'yaxis': {'title': self.y_label} + } + + return {'data': traces, 'layout': layout} + + def _map_chart_type_to_plotly(self, chart_type: str) -> str: + """Map internal chart type to Plotly type.""" + mapping = { + 'line': 'scatter', + 'scatter': 'scatter', + 'bar': 'bar', + 'area': 'scatter', + 'histogram': 'histogram' + } + return mapping.get(chart_type, 'scatter') + + +def prepare_equity_curve_data( + backtest_result: Any, + include_benchmark: bool = False, + benchmark_returns: Optional[Union[pd.Series, np.ndarray]] = None, + initial_capital: float = 10000.0 +) -> VisualizationData: + """ + Prepare equity curve data for visualization. + + Args: + backtest_result: BacktestResult object or dictionary + include_benchmark: Whether to include benchmark comparison + benchmark_returns: Optional benchmark returns for comparison + initial_capital: Initial capital for normalization + + Returns: + VisualizationData ready for charting + """ + result_dict = _normalize_result(backtest_result) + equity_curve = _extract_equity_curve(result_dict) + + if equity_curve is None or len(equity_curve) == 0: + logger.warning("No equity curve data available") + return VisualizationData( + chart_type="line", + title="Equity Curve", + x_label="Time", + y_label="Equity ($)", + series=[], + config={'empty': True} + ) + + timestamps = _generate_timestamps(len(equity_curve), result_dict) + + strategy_data = [ + ChartDataPoint(x=t, y=float(v), metadata={'index': i}) + for i, (t, v) in enumerate(zip(timestamps, equity_curve)) + ] + + series = [ + ChartSeries( + name="Strategy", + data=strategy_data, + color="#2196F3", + chart_type="line" + ) + ] + + if include_benchmark and benchmark_returns is not None: + benchmark_equity = _compute_equity_from_returns(benchmark_returns, initial_capital) + benchmark_data = [ + ChartDataPoint(x=t, y=float(v)) + for t, v in zip(timestamps[:len(benchmark_equity)], benchmark_equity) + ] + series.append( + ChartSeries( + name="Benchmark", + data=benchmark_data, + color="#9E9E9E", + chart_type="line" + ) + ) + + annotations = [] + if len(equity_curve) > 1: + max_idx = int(np.argmax(equity_curve)) + min_idx = int(np.argmin(equity_curve)) + + annotations.append({ + 'type': 'point', + 'x': timestamps[max_idx], + 'y': float(equity_curve[max_idx]), + 'label': f"Peak: ${equity_curve[max_idx]:,.2f}" + }) + + annotations.append({ + 'type': 'point', + 'x': timestamps[min_idx], + 'y': float(equity_curve[min_idx]), + 'label': f"Trough: ${equity_curve[min_idx]:,.2f}" + }) + + return VisualizationData( + chart_type="line", + title="Equity Curve", + x_label="Time", + y_label="Equity ($)", + series=series, + annotations=annotations, + config={ + 'initial_capital': initial_capital, + 'final_equity': float(equity_curve[-1]) if len(equity_curve) > 0 else initial_capital, + 'total_return_pct': ((equity_curve[-1] - initial_capital) / initial_capital * 100) if len(equity_curve) > 0 else 0 + } + ) + + +def prepare_drawdown_chart_data( + backtest_result: Any, + as_percentage: bool = True +) -> VisualizationData: + """ + Prepare drawdown chart data for visualization. + + Args: + backtest_result: BacktestResult object or dictionary + as_percentage: Whether to show as percentage (True) or absolute (False) + + Returns: + VisualizationData for drawdown chart + """ + result_dict = _normalize_result(backtest_result) + equity_curve = _extract_equity_curve(result_dict) + + if equity_curve is None or len(equity_curve) < 2: + logger.warning("Insufficient equity curve data for drawdown") + return VisualizationData( + chart_type="area", + title="Drawdown", + x_label="Time", + y_label="Drawdown (%)" if as_percentage else "Drawdown ($)", + series=[], + config={'empty': True} + ) + + running_max = np.maximum.accumulate(equity_curve) + drawdown_abs = running_max - equity_curve + drawdown_pct = np.where(running_max > 0, drawdown_abs / running_max * 100, 0) + + drawdown = drawdown_pct if as_percentage else drawdown_abs + drawdown = -np.abs(drawdown) + + timestamps = _generate_timestamps(len(equity_curve), result_dict) + + drawdown_data = [ + ChartDataPoint( + x=t, + y=float(v), + metadata={ + 'absolute': float(drawdown_abs[i]), + 'percentage': float(drawdown_pct[i]), + 'peak': float(running_max[i]) + } + ) + for i, (t, v) in enumerate(zip(timestamps, drawdown)) + ] + + series = [ + ChartSeries( + name="Drawdown", + data=drawdown_data, + color="#F44336", + chart_type="area" + ) + ] + + max_dd_idx = int(np.argmax(np.abs(drawdown))) + annotations = [ + { + 'type': 'point', + 'x': timestamps[max_dd_idx], + 'y': float(drawdown[max_dd_idx]), + 'label': f"Max DD: {drawdown_pct[max_dd_idx]:.1f}%" + } + ] + + in_drawdown = drawdown < -0.01 + dd_periods = _count_drawdown_periods(in_drawdown) + avg_dd_duration = _compute_avg_drawdown_duration(in_drawdown) + + return VisualizationData( + chart_type="area", + title="Drawdown", + x_label="Time", + y_label="Drawdown (%)" if as_percentage else "Drawdown ($)", + series=series, + annotations=annotations, + config={ + 'max_drawdown_pct': float(np.max(drawdown_pct)), + 'max_drawdown_abs': float(np.max(drawdown_abs)), + 'avg_drawdown_pct': float(np.mean(drawdown_pct[drawdown_pct > 0])) if np.any(drawdown_pct > 0) else 0, + 'drawdown_periods': dd_periods, + 'avg_drawdown_duration': avg_dd_duration, + 'fill_color': 'rgba(244, 67, 54, 0.3)' + } + ) + + +def prepare_monthly_returns_heatmap_data( + backtest_result: Any, + trades: Optional[List[Any]] = None +) -> VisualizationData: + """ + Prepare monthly returns heatmap data. + + Args: + backtest_result: BacktestResult object or dictionary + trades: Optional list of trades for P&L aggregation + + Returns: + VisualizationData for heatmap + """ + result_dict = _normalize_result(backtest_result) + equity_curve = _extract_equity_curve(result_dict) + trade_list = trades if trades else result_dict.get('trades', []) + + monthly_returns = {} + + if trade_list: + for t in trade_list: + exit_time = _get_trade_field(t, 'exit_time') + pnl = _get_trade_field(t, 'pnl', 0) + + if exit_time and pnl: + if isinstance(exit_time, str): + try: + exit_time = datetime.fromisoformat(exit_time.replace('Z', '+00:00')) + except Exception: + continue + + year = exit_time.year + month = exit_time.month + + if year not in monthly_returns: + monthly_returns[year] = {} + monthly_returns[year][month] = monthly_returns[year].get(month, 0) + pnl + + elif equity_curve is not None and len(equity_curve) > 30: + timestamps = _generate_timestamps(len(equity_curve), result_dict) + for i in range(1, len(equity_curve)): + ret = (equity_curve[i] - equity_curve[i-1]) / equity_curve[i-1] * 100 if equity_curve[i-1] > 0 else 0 + ts = timestamps[i] + if isinstance(ts, (int, float)): + continue + year = ts.year + month = ts.month + + if year not in monthly_returns: + monthly_returns[year] = {} + monthly_returns[year][month] = monthly_returns[year].get(month, 0) + ret + + if not monthly_returns: + return VisualizationData( + chart_type="heatmap", + title="Monthly Returns", + x_label="Month", + y_label="Year", + series=[], + config={'empty': True} + ) + + years = sorted(monthly_returns.keys()) + months = list(range(1, 13)) + month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + + heatmap_data = [] + for year in years: + for month in months: + value = monthly_returns.get(year, {}).get(month, None) + heatmap_data.append(ChartDataPoint( + x=month_names[month - 1], + y=float(value) if value is not None else 0, + label=str(year), + metadata={ + 'year': year, + 'month': month, + 'has_data': value is not None + } + )) + + series = [ + ChartSeries( + name="Monthly Return", + data=heatmap_data, + chart_type="heatmap" + ) + ] + + all_values = [dp.y for dp in heatmap_data if dp.metadata.get('has_data')] + min_val = min(all_values) if all_values else -10 + max_val = max(all_values) if all_values else 10 + + return VisualizationData( + chart_type="heatmap", + title="Monthly Returns", + x_label="Month", + y_label="Year", + series=series, + config={ + 'years': years, + 'months': month_names, + 'color_scale': { + 'negative': '#F44336', + 'zero': '#FFFFFF', + 'positive': '#4CAF50' + }, + 'min_value': min_val, + 'max_value': max_val, + 'format': '${:.2f}' if trade_list else '{:.1f}%' + } + ) + + +def prepare_trade_distribution_data( + trades: List[Any], + bins: int = 30, + as_percentage: bool = False +) -> VisualizationData: + """ + Prepare trade profit/loss distribution data. + + Args: + trades: List of trade objects or dictionaries + bins: Number of histogram bins + as_percentage: Whether to show as percentage returns + + Returns: + VisualizationData for histogram + """ + if not trades: + return VisualizationData( + chart_type="histogram", + title="Trade P&L Distribution", + x_label="P&L ($)" if not as_percentage else "Return (%)", + y_label="Frequency", + series=[], + config={'empty': True} + ) + + pnl_values = [] + for t in trades: + if as_percentage: + pnl = _get_trade_field(t, 'pnl_pct', _get_trade_field(t, 'profit_loss_pct', 0)) + else: + pnl = _get_trade_field(t, 'pnl', _get_trade_field(t, 'profit_loss', 0)) + + if pnl is not None: + pnl_values.append(float(pnl)) + + if not pnl_values: + return VisualizationData( + chart_type="histogram", + title="Trade P&L Distribution", + x_label="P&L ($)" if not as_percentage else "Return (%)", + y_label="Frequency", + series=[], + config={'empty': True, 'reason': 'no_valid_pnl'} + ) + + pnl_array = np.array(pnl_values) + hist, bin_edges = np.histogram(pnl_array, bins=bins) + + histogram_data = [] + for i in range(len(hist)): + bin_center = (bin_edges[i] + bin_edges[i + 1]) / 2 + is_positive = bin_center >= 0 + + histogram_data.append(ChartDataPoint( + x=float(bin_center), + y=int(hist[i]), + metadata={ + 'bin_start': float(bin_edges[i]), + 'bin_end': float(bin_edges[i + 1]), + 'is_positive': is_positive + } + )) + + series = [ + ChartSeries( + name="Trade Distribution", + data=histogram_data, + chart_type="histogram" + ) + ] + + winning_trades = [p for p in pnl_values if p > 0] + losing_trades = [p for p in pnl_values if p < 0] + + annotations = [ + { + 'type': 'line', + 'x': 0, + 'label': 'Break-even', + 'color': '#000000' + }, + { + 'type': 'line', + 'x': np.mean(pnl_values), + 'label': f'Mean: {"$" if not as_percentage else ""}{np.mean(pnl_values):.2f}{"%" if as_percentage else ""}', + 'color': '#2196F3' + } + ] + + return VisualizationData( + chart_type="histogram", + title="Trade P&L Distribution", + x_label="P&L ($)" if not as_percentage else "Return (%)", + y_label="Frequency", + series=series, + annotations=annotations, + config={ + 'total_trades': len(pnl_values), + 'winning_trades': len(winning_trades), + 'losing_trades': len(losing_trades), + 'win_rate': len(winning_trades) / len(pnl_values) if pnl_values else 0, + 'mean': float(np.mean(pnl_values)), + 'median': float(np.median(pnl_values)), + 'std': float(np.std(pnl_values)), + 'skewness': float(_compute_skewness(pnl_array)), + 'kurtosis': float(_compute_kurtosis(pnl_array)), + 'positive_color': '#4CAF50', + 'negative_color': '#F44336' + } + ) + + +def prepare_strategy_weights_data( + ensemble_result: Any, + strategy_weights: Optional[Dict[str, float]] = None, + weight_history: Optional[List[Dict[str, float]]] = None +) -> VisualizationData: + """ + Prepare strategy weights visualization data. + + Args: + ensemble_result: Ensemble backtest result + strategy_weights: Static strategy weights + weight_history: Optional time-varying weights history + + Returns: + VisualizationData for weight visualization + """ + result_dict = _normalize_result(ensemble_result) + + if strategy_weights is None: + strategy_weights = result_dict.get('strategy_weights', {}) + + if not strategy_weights: + return VisualizationData( + chart_type="pie", + title="Strategy Weights", + x_label="Strategy", + y_label="Weight", + series=[], + config={'empty': True} + ) + + colors = ['#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', + '#00BCD4', '#FFEB3B', '#795548', '#607D8B', '#3F51B5'] + + pie_data = [] + for i, (strategy, weight) in enumerate(strategy_weights.items()): + pie_data.append(ChartDataPoint( + x=strategy, + y=float(weight) * 100, + metadata={ + 'strategy': strategy, + 'weight': float(weight), + 'color': colors[i % len(colors)] + } + )) + + pie_series = ChartSeries( + name="Strategy Weights", + data=pie_data, + chart_type="pie" + ) + + series = [pie_series] + + if weight_history: + strategies = list(strategy_weights.keys()) + for strategy in strategies: + history_data = [] + for i, weights in enumerate(weight_history): + weight = weights.get(strategy, 0) + history_data.append(ChartDataPoint( + x=i, + y=float(weight) * 100, + label=strategy + )) + + series.append(ChartSeries( + name=f"{strategy} Weight History", + data=history_data, + chart_type="line" + )) + + return VisualizationData( + chart_type="pie" if not weight_history else "multi", + title="Strategy Weights", + x_label="Strategy", + y_label="Weight (%)", + series=series, + config={ + 'static_weights': strategy_weights, + 'has_history': weight_history is not None, + 'total_strategies': len(strategy_weights), + 'colors': colors[:len(strategy_weights)] + } + ) + + +def prepare_calibration_plot_data( + predictions: Union[np.ndarray, List[float]], + actuals: Union[np.ndarray, List[int]], + n_bins: int = 10, + strategy: str = 'uniform' +) -> VisualizationData: + """ + Prepare calibration plot data (reliability diagram). + + Args: + predictions: Predicted probabilities (0 to 1) + actuals: Actual binary outcomes (0 or 1) + n_bins: Number of bins for calibration + strategy: Binning strategy ('uniform' or 'quantile') + + Returns: + VisualizationData for calibration plot + """ + predictions = np.asarray(predictions).ravel() + actuals = np.asarray(actuals).ravel() + + if len(predictions) != len(actuals): + raise ValueError("predictions and actuals must have same length") + + if len(predictions) == 0: + return VisualizationData( + chart_type="scatter", + title="Calibration Plot", + x_label="Mean Predicted Probability", + y_label="Fraction of Positives", + series=[], + config={'empty': True} + ) + + if strategy == 'quantile': + percentiles = np.linspace(0, 100, n_bins + 1) + bin_edges = np.percentile(predictions, percentiles) + else: + bin_edges = np.linspace(0, 1, n_bins + 1) + + bin_centers = [] + bin_accuracies = [] + bin_counts = [] + bin_confidence_intervals = [] + + for i in range(len(bin_edges) - 1): + in_bin = (predictions >= bin_edges[i]) & (predictions < bin_edges[i + 1]) + if i == len(bin_edges) - 2: + in_bin = (predictions >= bin_edges[i]) & (predictions <= bin_edges[i + 1]) + + count = np.sum(in_bin) + if count > 0: + mean_pred = np.mean(predictions[in_bin]) + mean_actual = np.mean(actuals[in_bin]) + + p = mean_actual + n = count + ci = 1.96 * np.sqrt(p * (1 - p) / n) if n > 0 and 0 < p < 1 else 0 + + bin_centers.append(mean_pred) + bin_accuracies.append(mean_actual) + bin_counts.append(count) + bin_confidence_intervals.append(ci) + + calibration_data = [ + ChartDataPoint( + x=float(center), + y=float(acc), + metadata={ + 'count': int(count), + 'ci': float(ci) + } + ) + for center, acc, count, ci in zip(bin_centers, bin_accuracies, bin_counts, bin_confidence_intervals) + ] + + calibration_series = ChartSeries( + name="Calibration", + data=calibration_data, + color="#2196F3", + chart_type="scatter" + ) + + perfect_line = [ + ChartDataPoint(x=0.0, y=0.0), + ChartDataPoint(x=1.0, y=1.0) + ] + + perfect_series = ChartSeries( + name="Perfect Calibration", + data=perfect_line, + color="#9E9E9E", + chart_type="line" + ) + + ece = _compute_ece(predictions, actuals, bin_edges) + + annotations = [ + { + 'type': 'text', + 'x': 0.05, + 'y': 0.95, + 'label': f'ECE: {ece:.4f}' + } + ] + + histogram_data = [] + hist, _ = np.histogram(predictions, bins=bin_edges) + for i, count in enumerate(hist): + center = (bin_edges[i] + bin_edges[i + 1]) / 2 + histogram_data.append(ChartDataPoint( + x=float(center), + y=int(count), + metadata={'bin_start': float(bin_edges[i]), 'bin_end': float(bin_edges[i + 1])} + )) + + histogram_series = ChartSeries( + name="Prediction Distribution", + data=histogram_data, + color="#BBDEFB", + chart_type="bar" + ) + + return VisualizationData( + chart_type="multi", + title="Calibration Plot", + x_label="Mean Predicted Probability", + y_label="Fraction of Positives", + series=[calibration_series, perfect_series, histogram_series], + annotations=annotations, + config={ + 'ece': float(ece), + 'n_bins': n_bins, + 'total_samples': len(predictions), + 'positive_rate': float(np.mean(actuals)), + 'mean_prediction': float(np.mean(predictions)), + 'show_histogram': True, + 'histogram_opacity': 0.3 + } + ) + + +def prepare_rolling_metrics_data( + backtest_result: Any, + window: int = 30, + metrics: Optional[List[str]] = None +) -> VisualizationData: + """ + Prepare rolling performance metrics data. + + Args: + backtest_result: BacktestResult object or dictionary + window: Rolling window size + metrics: List of metrics to compute (default: sharpe, sortino, volatility) + + Returns: + VisualizationData for rolling metrics + """ + if metrics is None: + metrics = ['sharpe_ratio', 'volatility', 'win_rate'] + + result_dict = _normalize_result(backtest_result) + equity_curve = _extract_equity_curve(result_dict) + + if equity_curve is None or len(equity_curve) < window + 1: + return VisualizationData( + chart_type="line", + title="Rolling Metrics", + x_label="Time", + y_label="Value", + series=[], + config={'empty': True, 'reason': 'insufficient_data'} + ) + + returns = np.diff(equity_curve) / equity_curve[:-1] + returns = np.nan_to_num(returns, nan=0.0, posinf=0.0, neginf=0.0) + + timestamps = _generate_timestamps(len(returns), result_dict) + + series = [] + colors = {'sharpe_ratio': '#2196F3', 'volatility': '#FF9800', 'win_rate': '#4CAF50', + 'sortino_ratio': '#9C27B0', 'calmar_ratio': '#E91E63'} + + for metric_name in metrics: + rolling_values = _compute_rolling_metric(returns, window, metric_name) + valid_start = window - 1 + + metric_data = [ + ChartDataPoint( + x=timestamps[i + valid_start] if i + valid_start < len(timestamps) else i + valid_start, + y=float(v), + metadata={'window': window} + ) + for i, v in enumerate(rolling_values) + ] + + series.append(ChartSeries( + name=metric_name.replace('_', ' ').title(), + data=metric_data, + color=colors.get(metric_name, '#000000'), + chart_type="line" + )) + + return VisualizationData( + chart_type="line", + title=f"Rolling Metrics (Window: {window})", + x_label="Time", + y_label="Value", + series=series, + config={ + 'window': window, + 'metrics': metrics, + 'total_periods': len(returns) + } + ) + + +def _normalize_result(result: Any) -> Dict[str, Any]: + """Normalize backtest result to dictionary.""" + if isinstance(result, dict): + return result + if hasattr(result, 'to_dict'): + return result.to_dict() + if hasattr(result, '__dict__'): + return vars(result) + return {'result': result} + + +def _extract_equity_curve(result_dict: Dict[str, Any]) -> Optional[np.ndarray]: + """Extract equity curve from result dictionary.""" + if 'equity_curve' in result_dict: + ec = result_dict['equity_curve'] + if isinstance(ec, pd.Series): + return ec.values + if isinstance(ec, np.ndarray): + return ec + if isinstance(ec, list): + return np.array(ec) + return None + + +def _generate_timestamps(length: int, result_dict: Dict[str, Any]) -> List[Union[datetime, int]]: + """Generate timestamps for data points.""" + start_date = result_dict.get('start_date') + end_date = result_dict.get('end_date') + + if start_date and end_date: + if isinstance(start_date, str): + try: + start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + except Exception: + start_date = None + if isinstance(end_date, str): + try: + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + except Exception: + end_date = None + + if start_date and end_date and length > 1: + delta = (end_date - start_date) / (length - 1) + return [start_date + delta * i for i in range(length)] + + return list(range(length)) + + +def _compute_equity_from_returns( + returns: Union[pd.Series, np.ndarray], + initial_capital: float +) -> np.ndarray: + """Compute equity curve from returns.""" + returns = np.asarray(returns) + equity = np.zeros(len(returns) + 1) + equity[0] = initial_capital + for i, ret in enumerate(returns): + equity[i + 1] = equity[i] * (1 + ret) + return equity + + +def _get_trade_field(trade: Any, field: str, default: Any = None) -> Any: + """Get field from trade object or dictionary.""" + if hasattr(trade, field): + return getattr(trade, field) + if isinstance(trade, dict): + return trade.get(field, default) + return default + + +def _count_drawdown_periods(in_drawdown: np.ndarray) -> int: + """Count number of drawdown periods.""" + changes = np.diff(in_drawdown.astype(int)) + return int(np.sum(changes == 1)) + + +def _compute_avg_drawdown_duration(in_drawdown: np.ndarray) -> float: + """Compute average drawdown duration.""" + durations = [] + current_duration = 0 + + for in_dd in in_drawdown: + if in_dd: + current_duration += 1 + elif current_duration > 0: + durations.append(current_duration) + current_duration = 0 + + if current_duration > 0: + durations.append(current_duration) + + return float(np.mean(durations)) if durations else 0 + + +def _compute_skewness(data: np.ndarray) -> float: + """Compute skewness of data.""" + n = len(data) + if n < 3: + return 0.0 + mean = np.mean(data) + std = np.std(data) + if std == 0: + return 0.0 + return float(np.mean(((data - mean) / std) ** 3)) + + +def _compute_kurtosis(data: np.ndarray) -> float: + """Compute excess kurtosis of data.""" + n = len(data) + if n < 4: + return 0.0 + mean = np.mean(data) + std = np.std(data) + if std == 0: + return 0.0 + return float(np.mean(((data - mean) / std) ** 4) - 3) + + +def _compute_ece( + predictions: np.ndarray, + actuals: np.ndarray, + bin_edges: np.ndarray +) -> float: + """Compute Expected Calibration Error.""" + ece = 0.0 + n = len(predictions) + + for i in range(len(bin_edges) - 1): + in_bin = (predictions >= bin_edges[i]) & (predictions < bin_edges[i + 1]) + if i == len(bin_edges) - 2: + in_bin = (predictions >= bin_edges[i]) & (predictions <= bin_edges[i + 1]) + + prop_in_bin = np.mean(in_bin) + if prop_in_bin > 0: + avg_confidence = np.mean(predictions[in_bin]) + avg_accuracy = np.mean(actuals[in_bin]) + ece += np.abs(avg_confidence - avg_accuracy) * prop_in_bin + + return ece + + +def _compute_rolling_metric( + returns: np.ndarray, + window: int, + metric: str +) -> np.ndarray: + """Compute rolling metric values.""" + n = len(returns) + result = [] + + for i in range(window - 1, n): + window_returns = returns[i - window + 1:i + 1] + + if metric == 'sharpe_ratio': + mean_ret = np.mean(window_returns) + std_ret = np.std(window_returns) + value = np.sqrt(252) * mean_ret / std_ret if std_ret > 0 else 0 + elif metric == 'sortino_ratio': + mean_ret = np.mean(window_returns) + neg_returns = window_returns[window_returns < 0] + downside_std = np.std(neg_returns) if len(neg_returns) > 0 else 0.0001 + value = np.sqrt(252) * mean_ret / downside_std if downside_std > 0 else 0 + elif metric == 'volatility': + value = np.std(window_returns) * np.sqrt(252) * 100 + elif metric == 'win_rate': + value = np.mean(window_returns > 0) * 100 + elif metric == 'calmar_ratio': + total_ret = np.sum(window_returns) + running_max = np.maximum.accumulate(np.cumsum(window_returns)) + max_dd = np.max(running_max - np.cumsum(window_returns)) + value = total_ret / max_dd if max_dd > 0 else 0 + else: + value = 0 + + result.append(value) + + return np.array(result) diff --git a/src/backtesting/walk_forward.py b/src/backtesting/walk_forward.py new file mode 100644 index 0000000..3ce83c4 --- /dev/null +++ b/src/backtesting/walk_forward.py @@ -0,0 +1,652 @@ +""" +Walk-Forward Validation for Backtesting +========================================= +Implements walk-forward validation methodology for robust strategy evaluation. + +Walk-forward validation simulates real trading conditions by: +1. Training on historical data +2. Testing on unseen future data +3. Rolling forward and repeating + +This approach provides more realistic performance estimates than +standard train/test splits. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Generator, Any, Union, Callable +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +import json +from loguru import logger + +from .rr_backtester import BacktestConfig, BacktestResult +from .metrics import TradingMetrics, TradeRecord, MetricsCalculator + + +@dataclass +class WalkForwardConfig: + """Configuration for walk-forward validation.""" + + n_splits: int = 5 + train_ratio: float = 0.8 + gap_bars: int = 0 + expanding_window: bool = False + min_train_samples: int = 1000 + min_test_samples: int = 200 + overlap_allowed: bool = False + + +@dataclass +class WalkForwardSplit: + """Single walk-forward split with train/test indices.""" + + split_id: int + train_start_idx: int + train_end_idx: int + test_start_idx: int + test_end_idx: int + train_start_date: Optional[datetime] = None + train_end_date: Optional[datetime] = None + test_start_date: Optional[datetime] = None + test_end_date: Optional[datetime] = None + + @property + def train_size(self) -> int: + """Number of training samples.""" + return self.train_end_idx - self.train_start_idx + + @property + def test_size(self) -> int: + """Number of test samples.""" + return self.test_end_idx - self.test_start_idx + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'split_id': self.split_id, + 'train_start_idx': self.train_start_idx, + 'train_end_idx': self.train_end_idx, + 'test_start_idx': self.test_start_idx, + 'test_end_idx': self.test_end_idx, + 'train_start_date': str(self.train_start_date) if self.train_start_date else None, + 'train_end_date': str(self.train_end_date) if self.train_end_date else None, + 'test_start_date': str(self.test_start_date) if self.test_start_date else None, + 'test_end_date': str(self.test_end_date) if self.test_end_date else None, + 'train_size': self.train_size, + 'test_size': self.test_size + } + + +@dataclass +class AggregatedResult: + """ + Aggregated results from walk-forward validation. + + Contains mean and standard deviation of all metrics across splits. + """ + + total_trades: float + total_trades_std: float + winrate: float + winrate_std: float + profit_factor: float + profit_factor_std: float + sharpe_ratio: float + sharpe_ratio_std: float + sortino_ratio: float + sortino_ratio_std: float + max_drawdown_pct: float + max_drawdown_pct_std: float + net_profit: float + net_profit_std: float + avg_trade_pnl: float + avg_trade_pnl_std: float + n_splits: int = 0 + split_results: List[BacktestResult] = field(default_factory=list) + per_split_metrics: List[Dict[str, float]] = field(default_factory=list) + consistency_score: float = 0.0 + robustness_score: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'total_trades': {'mean': self.total_trades, 'std': self.total_trades_std}, + 'winrate': {'mean': self.winrate, 'std': self.winrate_std}, + 'profit_factor': {'mean': self.profit_factor, 'std': self.profit_factor_std}, + 'sharpe_ratio': {'mean': self.sharpe_ratio, 'std': self.sharpe_ratio_std}, + 'sortino_ratio': {'mean': self.sortino_ratio, 'std': self.sortino_ratio_std}, + 'max_drawdown_pct': {'mean': self.max_drawdown_pct, 'std': self.max_drawdown_pct_std}, + 'net_profit': {'mean': self.net_profit, 'std': self.net_profit_std}, + 'avg_trade_pnl': {'mean': self.avg_trade_pnl, 'std': self.avg_trade_pnl_std}, + 'n_splits': self.n_splits, + 'consistency_score': self.consistency_score, + 'robustness_score': self.robustness_score, + 'per_split_metrics': self.per_split_metrics + } + + def print_summary(self): + """Print formatted summary.""" + print("\n" + "=" * 60) + print("WALK-FORWARD VALIDATION RESULTS") + print("=" * 60) + print(f"Number of Splits: {self.n_splits}") + print(f"\n--- Performance Metrics (Mean +/- Std) ---") + print(f"Win Rate: {self.winrate:.2%} +/- {self.winrate_std:.2%}") + print(f"Profit Factor: {self.profit_factor:.2f} +/- {self.profit_factor_std:.2f}") + print(f"Sharpe Ratio: {self.sharpe_ratio:.2f} +/- {self.sharpe_ratio_std:.2f}") + print(f"Sortino Ratio: {self.sortino_ratio:.2f} +/- {self.sortino_ratio_std:.2f}") + print(f"Max Drawdown: {self.max_drawdown_pct:.2%} +/- {self.max_drawdown_pct_std:.2%}") + print(f"Net Profit: ${self.net_profit:,.2f} +/- ${self.net_profit_std:,.2f}") + print(f"Total Trades: {self.total_trades:.0f} +/- {self.total_trades_std:.0f}") + print(f"Avg Trade P&L: ${self.avg_trade_pnl:.2f} +/- ${self.avg_trade_pnl_std:.2f}") + print(f"\n--- Quality Scores ---") + print(f"Consistency: {self.consistency_score:.2%}") + print(f"Robustness: {self.robustness_score:.2%}") + print("=" * 60) + + +class WalkForwardValidator: + """ + Walk-forward validation for trading strategies. + + Implements time-series cross-validation that respects temporal order + and simulates realistic trading conditions. + + Usage: + validator = WalkForwardValidator(n_splits=5, train_ratio=0.8) + + # Generate splits + for train, test in validator.split(data): + # Train on train, evaluate on test + model.fit(train) + result = backtester.run(test, model) + + # Or run complete walk-forward + results = validator.run_walk_forward(strategy, data) + aggregated = validator.aggregate_results(results) + """ + + def __init__( + self, + n_splits: int = 5, + train_ratio: float = 0.8, + config: Optional[WalkForwardConfig] = None + ): + """ + Initialize walk-forward validator. + + Args: + n_splits: Number of train/test splits + train_ratio: Ratio of training data in each split + config: Full configuration (overrides n_splits and train_ratio) + """ + if config is not None: + self.config = config + else: + self.config = WalkForwardConfig( + n_splits=n_splits, + train_ratio=train_ratio + ) + + self._splits: List[WalkForwardSplit] = [] + self._results: List[BacktestResult] = [] + self.metrics_calculator = MetricsCalculator() + + def split( + self, + data: pd.DataFrame + ) -> Generator[Tuple[pd.DataFrame, pd.DataFrame], None, None]: + """ + Generate train/test splits for walk-forward validation. + + Args: + data: Complete DataFrame with temporal index + + Yields: + Tuple of (train_df, test_df) for each split + """ + n_samples = len(data) + + if n_samples < self.config.min_train_samples + self.config.min_test_samples: + raise ValueError( + f"Insufficient data: {n_samples} samples, need at least " + f"{self.config.min_train_samples + self.config.min_test_samples}" + ) + + total_test_size = int(n_samples * (1 - self.config.train_ratio)) + test_size_per_split = max( + total_test_size // self.config.n_splits, + self.config.min_test_samples + ) + + if self.config.expanding_window: + initial_train_size = self.config.min_train_samples + else: + step_size = (n_samples - self.config.min_train_samples) // (self.config.n_splits + 1) + initial_train_size = self.config.min_train_samples + + self._splits = [] + + for split_idx in range(self.config.n_splits): + if self.config.expanding_window: + train_start = 0 + test_end = int(n_samples * (1 - (self.config.n_splits - split_idx - 1) * + (1 - self.config.train_ratio) / self.config.n_splits)) + test_start = test_end - test_size_per_split + train_end = test_start - self.config.gap_bars + else: + step = (n_samples - test_size_per_split - self.config.min_train_samples) // max(self.config.n_splits - 1, 1) + train_start = split_idx * step + train_end = train_start + int((n_samples - train_start) * self.config.train_ratio) + train_end = min(train_end, n_samples - test_size_per_split - self.config.gap_bars) + test_start = train_end + self.config.gap_bars + test_end = min(test_start + test_size_per_split, n_samples) + + if train_end - train_start < self.config.min_train_samples: + continue + if test_end - test_start < self.config.min_test_samples: + continue + + train_start_date = data.index[train_start] if hasattr(data.index, '__getitem__') else None + train_end_date = data.index[train_end - 1] if hasattr(data.index, '__getitem__') else None + test_start_date = data.index[test_start] if hasattr(data.index, '__getitem__') else None + test_end_date = data.index[test_end - 1] if hasattr(data.index, '__getitem__') else None + + split = WalkForwardSplit( + split_id=split_idx + 1, + train_start_idx=train_start, + train_end_idx=train_end, + test_start_idx=test_start, + test_end_idx=test_end, + train_start_date=train_start_date, + train_end_date=train_end_date, + test_start_date=test_start_date, + test_end_date=test_end_date + ) + self._splits.append(split) + + train_data = data.iloc[train_start:train_end].copy() + test_data = data.iloc[test_start:test_end].copy() + + logger.info(f"Split {split.split_id}: Train[{train_start}:{train_end}] " + f"({split.train_size} samples), " + f"Test[{test_start}:{test_end}] ({split.test_size} samples)") + + yield train_data, test_data + + logger.info(f"Generated {len(self._splits)} walk-forward splits") + + def run_walk_forward( + self, + strategy: Any, + data: pd.DataFrame, + backtest_config: Optional[BacktestConfig] = None, + train_callback: Optional[Callable] = None + ) -> List[BacktestResult]: + """ + Run complete walk-forward validation. + + Args: + strategy: Strategy adapter with predict() and get_signal() methods + data: Complete price data with features + backtest_config: Configuration for backtesting + train_callback: Optional callback for strategy training on each split + + Returns: + List of BacktestResult for each split + """ + from .rr_backtester import RRBacktester + + if backtest_config is None: + backtest_config = BacktestConfig() + + self._results = [] + + for split_idx, (train_data, test_data) in enumerate(self.split(data)): + split = self._splits[split_idx] + logger.info(f"\n{'='*50}") + logger.info(f"Walk-Forward Split {split.split_id}/{self.config.n_splits}") + logger.info(f"{'='*50}") + + if train_callback is not None: + logger.info("Training strategy on current split...") + train_callback(strategy, train_data) + + logger.info("Running backtest on test period...") + + backtester = RRBacktester(backtest_config) + + signals_df = self._generate_signals_df(strategy, test_data) + + result = backtester.run_backtest(test_data, signals_df) + + result.metrics.start_date = split.test_start_date + result.metrics.end_date = split.test_end_date + + self._results.append(result) + + logger.info(f"Split {split.split_id} Results: " + f"Trades={result.metrics.total_trades}, " + f"WR={result.metrics.winrate:.2%}, " + f"PF={result.metrics.profit_factor:.2f}, " + f"Net=${result.metrics.net_profit:,.2f}") + + return self._results + + def _generate_signals_df( + self, + strategy: Any, + data: pd.DataFrame + ) -> pd.DataFrame: + """ + Generate signals DataFrame from strategy predictions. + + Args: + strategy: Strategy with predict() method + data: Price data + + Returns: + DataFrame with signal columns for backtesting + """ + signals = pd.DataFrame(index=data.index) + + signals['prob_tp_first'] = np.nan + signals['direction'] = 'long' + signals['horizon'] = '15m' + signals['rr_config'] = 'rr_2_1' + signals['confidence'] = 0.0 + + lookback = min(100, len(data) // 2) + + for i in range(lookback, len(data)): + features = data.iloc[i-lookback:i] + + try: + prediction = strategy.predict(features) + + if abs(prediction.direction) > 0.1 and prediction.confidence > 0.5: + signals.loc[data.index[i], 'prob_tp_first'] = prediction.confidence + signals.loc[data.index[i], 'direction'] = 'long' if prediction.direction > 0 else 'short' + signals.loc[data.index[i], 'confidence'] = prediction.confidence + except Exception as e: + logger.debug(f"Signal generation error at index {i}: {e}") + continue + + valid_signals = signals['prob_tp_first'].notna().sum() + logger.info(f"Generated {valid_signals} valid signals from {len(data)} bars") + + return signals + + def aggregate_results( + self, + results: Optional[List[BacktestResult]] = None + ) -> AggregatedResult: + """ + Aggregate results from all walk-forward splits. + + Args: + results: List of BacktestResult (uses stored results if None) + + Returns: + AggregatedResult with mean and std of all metrics + """ + if results is None: + results = self._results + + if not results: + raise ValueError("No results to aggregate") + + total_trades_list = [] + winrate_list = [] + profit_factor_list = [] + sharpe_list = [] + sortino_list = [] + max_dd_list = [] + net_profit_list = [] + avg_trade_list = [] + per_split_metrics = [] + + for i, result in enumerate(results): + metrics = result.metrics + + total_trades_list.append(metrics.total_trades) + winrate_list.append(metrics.winrate) + profit_factor_list.append(min(metrics.profit_factor, 10.0)) + sharpe_list.append(np.clip(metrics.sharpe_ratio, -10, 10) if not np.isinf(metrics.sharpe_ratio) else 0) + sortino_list.append(np.clip(metrics.sortino_ratio, -10, 10) if not np.isinf(metrics.sortino_ratio) else 0) + max_dd_list.append(abs(metrics.max_drawdown_pct)) + net_profit_list.append(metrics.net_profit) + avg_trade_list.append(metrics.avg_trade if metrics.total_trades > 0 else 0) + + per_split_metrics.append({ + 'split': i + 1, + 'total_trades': metrics.total_trades, + 'winrate': metrics.winrate, + 'profit_factor': metrics.profit_factor, + 'sharpe_ratio': metrics.sharpe_ratio, + 'net_profit': metrics.net_profit, + 'max_drawdown_pct': metrics.max_drawdown_pct + }) + + profitable_splits = sum(1 for pnl in net_profit_list if pnl > 0) + consistency_score = profitable_splits / len(results) if results else 0 + + winrate_std = np.std(winrate_list) if len(winrate_list) > 1 else 0 + pf_std = np.std(profit_factor_list) if len(profit_factor_list) > 1 else 0 + winrate_coef_var = winrate_std / np.mean(winrate_list) if np.mean(winrate_list) > 0 else 1 + pf_coef_var = pf_std / np.mean(profit_factor_list) if np.mean(profit_factor_list) > 0 else 1 + robustness_score = max(0, 1 - (winrate_coef_var + pf_coef_var) / 2) + + return AggregatedResult( + total_trades=float(np.mean(total_trades_list)), + total_trades_std=float(np.std(total_trades_list)) if len(total_trades_list) > 1 else 0.0, + winrate=float(np.mean(winrate_list)), + winrate_std=float(np.std(winrate_list)) if len(winrate_list) > 1 else 0.0, + profit_factor=float(np.mean(profit_factor_list)), + profit_factor_std=float(np.std(profit_factor_list)) if len(profit_factor_list) > 1 else 0.0, + sharpe_ratio=float(np.mean(sharpe_list)), + sharpe_ratio_std=float(np.std(sharpe_list)) if len(sharpe_list) > 1 else 0.0, + sortino_ratio=float(np.mean(sortino_list)), + sortino_ratio_std=float(np.std(sortino_list)) if len(sortino_list) > 1 else 0.0, + max_drawdown_pct=float(np.mean(max_dd_list)), + max_drawdown_pct_std=float(np.std(max_dd_list)) if len(max_dd_list) > 1 else 0.0, + net_profit=float(np.mean(net_profit_list)), + net_profit_std=float(np.std(net_profit_list)) if len(net_profit_list) > 1 else 0.0, + avg_trade_pnl=float(np.mean(avg_trade_list)), + avg_trade_pnl_std=float(np.std(avg_trade_list)) if len(avg_trade_list) > 1 else 0.0, + n_splits=len(results), + split_results=results, + per_split_metrics=per_split_metrics, + consistency_score=consistency_score, + robustness_score=robustness_score + ) + + def get_splits(self) -> List[WalkForwardSplit]: + """Get the generated splits.""" + return self._splits + + def save_results(self, path: str): + """ + Save validation results to JSON file. + + Args: + path: File path for saving + """ + if not self._results: + logger.warning("No results to save") + return + + aggregated = self.aggregate_results() + + save_data = { + 'config': { + 'n_splits': self.config.n_splits, + 'train_ratio': self.config.train_ratio, + 'expanding_window': self.config.expanding_window + }, + 'splits': [s.to_dict() for s in self._splits], + 'aggregated': aggregated.to_dict(), + 'saved_at': datetime.now().isoformat() + } + + save_path = Path(path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w') as f: + json.dump(save_data, f, indent=2, default=str) + + logger.info(f"Saved walk-forward results to {save_path}") + + def plot_results( + self, + save_path: Optional[str] = None + ): + """ + Plot walk-forward validation results. + + Args: + save_path: Optional path to save the plot + """ + try: + import matplotlib.pyplot as plt + except ImportError: + logger.warning("Matplotlib not available for plotting") + return + + if not self._results: + logger.warning("No results to plot") + return + + aggregated = self.aggregate_results() + + fig, axes = plt.subplots(2, 3, figsize=(15, 10)) + fig.suptitle('Walk-Forward Validation Results', fontsize=14, fontweight='bold') + + splits = list(range(1, len(self._results) + 1)) + + winrates = [m['winrate'] for m in aggregated.per_split_metrics] + axes[0, 0].bar(splits, winrates, color='steelblue', alpha=0.7) + axes[0, 0].axhline(y=aggregated.winrate, color='red', linestyle='--', label=f'Mean: {aggregated.winrate:.2%}') + axes[0, 0].set_xlabel('Split') + axes[0, 0].set_ylabel('Win Rate') + axes[0, 0].set_title('Win Rate by Split') + axes[0, 0].legend() + axes[0, 0].set_ylim(0, 1) + + pfs = [min(m['profit_factor'], 5) for m in aggregated.per_split_metrics] + axes[0, 1].bar(splits, pfs, color='forestgreen', alpha=0.7) + axes[0, 1].axhline(y=min(aggregated.profit_factor, 5), color='red', linestyle='--', + label=f'Mean: {aggregated.profit_factor:.2f}') + axes[0, 1].axhline(y=1.0, color='black', linestyle='-', alpha=0.5) + axes[0, 1].set_xlabel('Split') + axes[0, 1].set_ylabel('Profit Factor') + axes[0, 1].set_title('Profit Factor by Split') + axes[0, 1].legend() + + net_profits = [m['net_profit'] for m in aggregated.per_split_metrics] + colors = ['green' if p > 0 else 'red' for p in net_profits] + axes[0, 2].bar(splits, net_profits, color=colors, alpha=0.7) + axes[0, 2].axhline(y=0, color='black', linestyle='-', alpha=0.5) + axes[0, 2].axhline(y=aggregated.net_profit, color='blue', linestyle='--', + label=f'Mean: ${aggregated.net_profit:,.0f}') + axes[0, 2].set_xlabel('Split') + axes[0, 2].set_ylabel('Net Profit ($)') + axes[0, 2].set_title('Net Profit by Split') + axes[0, 2].legend() + + sharpes = [m['sharpe_ratio'] for m in aggregated.per_split_metrics] + sharpes = [np.clip(s, -5, 5) for s in sharpes] + axes[1, 0].bar(splits, sharpes, color='coral', alpha=0.7) + axes[1, 0].axhline(y=aggregated.sharpe_ratio, color='red', linestyle='--', + label=f'Mean: {aggregated.sharpe_ratio:.2f}') + axes[1, 0].axhline(y=0, color='black', linestyle='-', alpha=0.5) + axes[1, 0].set_xlabel('Split') + axes[1, 0].set_ylabel('Sharpe Ratio') + axes[1, 0].set_title('Sharpe Ratio by Split') + axes[1, 0].legend() + + max_dds = [abs(m['max_drawdown_pct']) for m in aggregated.per_split_metrics] + axes[1, 1].bar(splits, max_dds, color='crimson', alpha=0.7) + axes[1, 1].axhline(y=aggregated.max_drawdown_pct, color='blue', linestyle='--', + label=f'Mean: {aggregated.max_drawdown_pct:.2%}') + axes[1, 1].set_xlabel('Split') + axes[1, 1].set_ylabel('Max Drawdown') + axes[1, 1].set_title('Max Drawdown by Split') + axes[1, 1].legend() + + trades = [m['total_trades'] for m in aggregated.per_split_metrics] + axes[1, 2].bar(splits, trades, color='purple', alpha=0.7) + axes[1, 2].axhline(y=aggregated.total_trades, color='red', linestyle='--', + label=f'Mean: {aggregated.total_trades:.0f}') + axes[1, 2].set_xlabel('Split') + axes[1, 2].set_ylabel('Number of Trades') + axes[1, 2].set_title('Trade Count by Split') + axes[1, 2].legend() + + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=150, bbox_inches='tight') + logger.info(f"Plot saved to {save_path}") + + plt.show() + + +if __name__ == "__main__": + print("Testing Walk-Forward Validator...") + print("=" * 60) + + np.random.seed(42) + n_samples = 5000 + + dates = pd.date_range(start='2023-01-01', periods=n_samples, freq='5min') + base_price = 2000 + returns = np.random.randn(n_samples) * 0.001 + prices = base_price * np.cumprod(1 + returns) + + df = pd.DataFrame({ + 'open': prices * (1 + np.random.randn(n_samples) * 0.0005), + 'high': prices * (1 + np.abs(np.random.randn(n_samples)) * 0.001), + 'low': prices * (1 - np.abs(np.random.randn(n_samples)) * 0.001), + 'close': prices, + 'volume': np.random.randint(1000, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + validator = WalkForwardValidator(n_splits=5, train_ratio=0.8) + + print("\n--- Generated Splits ---") + splits_gen = list(validator.split(df)) + print(f"Total splits: {len(splits_gen)}") + + for split in validator.get_splits(): + print(f"Split {split.split_id}: " + f"Train={split.train_size} samples, " + f"Test={split.test_size} samples") + + print("\n--- Expanding Window Mode ---") + expanding_config = WalkForwardConfig( + n_splits=3, + train_ratio=0.7, + expanding_window=True, + min_train_samples=500, + min_test_samples=100 + ) + expanding_validator = WalkForwardValidator(config=expanding_config) + + expanding_splits = list(expanding_validator.split(df)) + for split in expanding_validator.get_splits(): + print(f"Split {split.split_id}: " + f"Train[{split.train_start_idx}:{split.train_end_idx}] = {split.train_size}, " + f"Test[{split.test_start_idx}:{split.test_end_idx}] = {split.test_size}") + + print("\n" + "=" * 60) + print("Walk-Forward Validator tests complete!") diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 0000000..7482592 --- /dev/null +++ b/src/data/__init__.py @@ -0,0 +1,86 @@ +""" +Data Module for ML Engine +========================= +Provides database access, data loading, datasets, and validation utilities. + +Components: +- database: PostgreSQL connection and query utilities +- training_loader: Efficient data loading for ML training +- dataset: PyTorch Dataset classes for sequence and time series models +- validators: Data quality validation tools + +Usage: + from data import ( + # Database access + PostgreSQLConnection, + DatabaseManager, + load_ohlcv_from_postgres, + + # Training data loading + TrainingDataLoader, + TrainingDataConfig, + load_training_data, + + # PyTorch datasets + TradingDataset, + TimeSeriesDataset, + DatasetConfig, + create_train_val_datasets, + + # Validation + DataValidator, + QualityReport, + validate_trading_data, + ) +""" + +from .database import ( + PostgreSQLConnection, + MySQLConnection, # Alias for backward compatibility + DatabaseManager, + load_ohlcv_from_postgres, +) + +from .training_loader import ( + TrainingDataLoader, + TrainingDataConfig, + load_training_data, +) + +from .dataset import ( + TradingDataset, + TimeSeriesDataset, + DatasetConfig, + create_train_val_datasets, +) + +from .validators import ( + DataValidator, + ValidationIssue, + ValidationSeverity, + QualityReport, + validate_trading_data, +) + +__all__ = [ + # Database + 'PostgreSQLConnection', + 'MySQLConnection', + 'DatabaseManager', + 'load_ohlcv_from_postgres', + # Training loader + 'TrainingDataLoader', + 'TrainingDataConfig', + 'load_training_data', + # Datasets + 'TradingDataset', + 'TimeSeriesDataset', + 'DatasetConfig', + 'create_train_val_datasets', + # Validators + 'DataValidator', + 'ValidationIssue', + 'ValidationSeverity', + 'QualityReport', + 'validate_trading_data', +] diff --git a/src/data/database.py b/src/data/database.py new file mode 100644 index 0000000..3b1003f --- /dev/null +++ b/src/data/database.py @@ -0,0 +1,356 @@ +""" +Database Access Module for ML Engine +===================================== +Provides PostgreSQL access with the same interface as the legacy MySQL access. + +This module replaces the MySQL-based data access with PostgreSQL, +allowing training scripts to work with the local trading_platform database. + +Usage: + from data.database import DatabaseManager, PostgreSQLConnection + + # Simple usage + db = PostgreSQLConnection() + df = db.get_ticker_data('XAUUSD', limit=50000) + + # Via DatabaseManager + manager = DatabaseManager() + df = manager.db.get_ticker_data('XAUUSD') + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +""" + +import os +import yaml +from pathlib import Path +from typing import Optional, Dict, Any +import pandas as pd +from sqlalchemy import create_engine, text +from sqlalchemy.pool import QueuePool +from loguru import logger + + +class PostgreSQLConnection: + """ + PostgreSQL connection for ML Engine. + + Provides the same interface as the legacy MySQLConnection + but uses PostgreSQL with the local trading_platform database. + """ + + def __init__(self, config_path: Optional[str] = None): + """ + Initialize PostgreSQL connection. + + Args: + config_path: Path to database.yaml config file + """ + self.config = self._load_config(config_path) + self.engine = self._create_engine() + logger.info(f"Connected to PostgreSQL: {self.config['host']}:{self.config['port']}/{self.config['database']}") + + def _load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]: + """Load configuration from YAML or environment variables.""" + # First try environment variables + config = { + 'host': os.getenv('DB_HOST', 'localhost'), + 'port': int(os.getenv('DB_PORT', '5432')), + 'database': os.getenv('DB_NAME', 'trading_platform'), + 'user': os.getenv('DB_USER', 'trading_user'), + 'password': os.getenv('DB_PASSWORD', 'trading_dev_2026'), + 'pool_size': int(os.getenv('DB_POOL_SIZE', '10')), + } + + # Try to load from YAML config + if config_path: + yaml_path = Path(config_path) + else: + yaml_path = Path(__file__).parent.parent.parent / 'config' / 'database.yaml' + + if yaml_path.exists(): + with open(yaml_path, 'r') as f: + yaml_config = yaml.safe_load(f) + + if 'postgres' in yaml_config: + pg_config = yaml_config['postgres'] + # Override with YAML values (expand env vars) + for key in ['host', 'port', 'database', 'user', 'password']: + if key in pg_config: + value = str(pg_config[key]) + # Expand environment variable references + if value.startswith('${') and '}' in value: + env_var = value[2:value.index('}')] + default = value[value.index(':-')+2:-1] if ':-' in value else None + config[key] = os.getenv(env_var, default) or config[key] + else: + config[key] = value if key != 'port' else int(value) + + return config + + def _create_engine(self): + """Create SQLAlchemy engine for PostgreSQL.""" + connection_string = ( + f"postgresql://{self.config['user']}:{self.config['password']}" + f"@{self.config['host']}:{self.config['port']}/{self.config['database']}" + ) + + return create_engine( + connection_string, + poolclass=QueuePool, + pool_size=self.config.get('pool_size', 10), + max_overflow=20, + pool_pre_ping=True, + echo=False + ) + + def get_ticker_data( + self, + symbol: str, + timeframe: str = '5m', + limit: int = 500000, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> pd.DataFrame: + """ + Get OHLCV data for a ticker. + + Args: + symbol: Trading symbol (e.g., 'XAUUSD', 'EURUSD') + timeframe: Timeframe ('5m', '15m', '1h', '4h', 'd') + limit: Maximum number of records + start_date: Start date filter (YYYY-MM-DD) + end_date: End date filter (YYYY-MM-DD) + + Returns: + DataFrame with OHLCV data indexed by timestamp + """ + # Determine table based on timeframe + table_map = { + '5m': 'ohlcv_5m', + '15m': 'ohlcv_15m', + '1h': 'ohlcv_1h', + '4h': 'ohlcv_4h', + 'd': 'ohlcv_daily', + '1d': 'ohlcv_daily', + } + + table = table_map.get(timeframe.lower(), 'ohlcv_5m') + + # Normalize symbol (remove Polygon prefixes) + clean_symbol = symbol + if symbol.startswith('C:') or symbol.startswith('X:') or symbol.startswith('I:'): + clean_symbol = symbol[2:] + + # Build query + query = f""" + SELECT + o.timestamp, + o.open, + o.high, + o.low, + o.close, + o.volume, + o.vwap + FROM market_data.{table} o + JOIN market_data.tickers t ON t.id = o.ticker_id + WHERE UPPER(t.symbol) = UPPER(:symbol) + """ + + params = {'symbol': clean_symbol} + + if start_date: + query += " AND o.timestamp >= :start_date" + params['start_date'] = start_date + + if end_date: + query += " AND o.timestamp <= :end_date" + params['end_date'] = end_date + + query += f" ORDER BY o.timestamp ASC LIMIT {limit}" + + try: + df = pd.read_sql(text(query), self.engine, params=params) + + if df.empty: + logger.warning(f"No data found for {symbol} ({timeframe})") + return df + + # Set timestamp as index + df['timestamp'] = pd.to_datetime(df['timestamp']) + df.set_index('timestamp', inplace=True) + + logger.info(f"Loaded {len(df)} bars for {symbol} ({timeframe})") + return df + + except Exception as e: + logger.error(f"Error loading data for {symbol}: {e}") + return pd.DataFrame() + + def get_all_tickers(self) -> pd.DataFrame: + """Get all active tickers.""" + query = """ + SELECT id, symbol, asset_type, polygon_ticker, is_active + FROM market_data.tickers + WHERE is_active = true + ORDER BY symbol + """ + + return pd.read_sql(text(query), self.engine) + + def execute(self, query: str, params: Optional[Dict] = None) -> pd.DataFrame: + """Execute a raw SQL query.""" + return pd.read_sql(text(query), self.engine, params=params or {}) + + def execute_query(self, query: str, params: Optional[Dict] = None) -> pd.DataFrame: + """ + Execute a raw SQL query with MySQL-to-PostgreSQL translation. + + This method handles queries written for the old MySQL schema + and translates them to work with the PostgreSQL schema. + + Args: + query: SQL query (may use old MySQL table/column names) + params: Query parameters + + Returns: + DataFrame with query results + """ + # Translate MySQL table/column names to PostgreSQL + translated_query = self._translate_mysql_query(query) + + # Handle parameter format differences + translated_params = self._translate_params(params) + + try: + return pd.read_sql(text(translated_query), self.engine, params=translated_params) + except Exception as e: + logger.error(f"Query error: {e}") + logger.debug(f"Original query: {query}") + logger.debug(f"Translated query: {translated_query}") + raise + + def _translate_mysql_query(self, query: str) -> str: + """ + Translate MySQL query to PostgreSQL query. + + Handles: + - tickers_agg_data -> market_data.ohlcv_5m with JOIN + - date_agg -> timestamp + - ticker column -> JOIN with tickers table + """ + import re + + # Check if query uses the old MySQL table + if 'tickers_agg_data' in query.lower(): + # This is a MySQL-style query that needs translation + # Replace table and column references + translated = query + + # Replace table name with subquery that joins properly + # The old query selects from tickers_agg_data WHERE ticker = :symbol + # We need to translate to: FROM market_data.ohlcv_5m o JOIN market_data.tickers t ON ... + + # Extract what columns are being selected + # For now, use a simple approach: replace the FROM clause + + # Check if it's a simple SELECT ... FROM tickers_agg_data WHERE ticker = ... + if re.search(r'FROM\s+tickers_agg_data', query, re.IGNORECASE): + # Build the translated query + translated = re.sub( + r'FROM\s+tickers_agg_data', + '''FROM ( + SELECT + o.timestamp as date_agg, + o.open, + o.high, + o.low, + o.close, + o.volume, + o.vwap, + t.polygon_ticker as ticker + FROM market_data.ohlcv_5m o + JOIN market_data.tickers t ON t.id = o.ticker_id + ) AS tickers_agg_data''', + translated, + flags=re.IGNORECASE + ) + + return translated + + return query + + def _translate_params(self, params: Optional[Dict]) -> Optional[Dict]: + """Translate parameter format if needed.""" + if not params: + return params + + # Parameters should work as-is for most cases + return params + + +# Aliases for backward compatibility +MySQLConnection = PostgreSQLConnection +DatabaseConnection = PostgreSQLConnection + + +class DatabaseManager: + """ + Database manager for ML Engine. + + Provides centralized access to database connections. + Maintains backward compatibility with existing training scripts. + """ + + def __init__(self, config_path: Optional[str] = None): + """ + Initialize database manager. + + Args: + config_path: Path to database configuration + """ + self.db = PostgreSQLConnection(config_path) + self._engine = self.db.engine + + @property + def engine(self): + """Get SQLAlchemy engine.""" + return self._engine + + def get_ticker_data( + self, + symbol: str, + timeframe: str = '5m', + limit: int = 500000 + ) -> pd.DataFrame: + """Get ticker data - delegates to db connection.""" + return self.db.get_ticker_data(symbol, timeframe, limit) + + +def load_ohlcv_from_postgres( + symbol: str, + timeframe: str = '5m', + start_date: Optional[str] = None, + end_date: Optional[str] = None +) -> pd.DataFrame: + """ + Load OHLCV data from PostgreSQL. + + Drop-in replacement for load_ohlcv_from_mysql functions. + + Args: + symbol: Trading symbol (e.g., 'XAUUSD', 'BTCUSD') + timeframe: Timeframe ('5m', '15m') + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + + Returns: + DataFrame with OHLCV data + """ + db = PostgreSQLConnection() + return db.get_ticker_data( + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date + ) diff --git a/src/data/dataset.py b/src/data/dataset.py new file mode 100644 index 0000000..5313bb5 --- /dev/null +++ b/src/data/dataset.py @@ -0,0 +1,633 @@ +""" +PyTorch Dataset Classes for Trading Data +========================================= +Provides Dataset implementations for training ML models with trading data. + +This module implements: +- TradingDataset: For attention and transformer models with sequences +- TimeSeriesDataset: For time series models with sliding windows +- Sequential data loading with configurable sequence lengths + +Author: ML Pipeline (NEXUS v4.0) +Created: 2026-01-25 +""" + +from typing import Optional, List, Tuple, Dict, Any, Union +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd +from loguru import logger + +try: + import torch + from torch.utils.data import Dataset, DataLoader + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + logger.warning("PyTorch not available. Dataset classes will raise errors if used.") + + +@dataclass +class DatasetConfig: + """Configuration for trading datasets.""" + + # Sequence parameters + sequence_length: int = 60 + target_horizon: int = 12 + stride: int = 1 + + # Feature generation + compute_returns: bool = True + return_horizons: List[int] = field(default_factory=lambda: [1, 5, 12]) + normalize_features: bool = True + normalization_method: str = 'zscore' # 'zscore', 'minmax', 'robust' + + # Target configuration + target_type: str = 'return' # 'return', 'direction', 'multi_horizon' + classification_threshold: float = 0.0 + + # Data type + dtype: str = 'float32' + + +def _check_torch(): + """Check if PyTorch is available.""" + if not TORCH_AVAILABLE: + raise ImportError( + "PyTorch is required for Dataset classes. " + "Install with: pip install torch" + ) + + +class TradingDataset: + """ + PyTorch Dataset for sequence-based trading data. + + This dataset is designed for attention and transformer models + that require fixed-length sequences of historical data. + + Features: + - Configurable sequence length + - Automatic return computation + - Feature normalization + - Supports both regression and classification targets + + Usage: + config = DatasetConfig(sequence_length=60, target_horizon=12) + dataset = TradingDataset(df, config) + dataloader = DataLoader(dataset, batch_size=32, shuffle=True) + + for features, targets in dataloader: + # features shape: (batch_size, sequence_length, num_features) + # targets shape: (batch_size,) or (batch_size, num_targets) + predictions = model(features) + """ + + def __init__( + self, + data: pd.DataFrame, + config: Optional[DatasetConfig] = None, + feature_columns: Optional[List[str]] = None, + target_column: Optional[str] = None + ): + """ + Initialize the trading dataset. + + Args: + data: DataFrame with OHLCV data (indexed by timestamp) + config: Dataset configuration + feature_columns: Columns to use as features (None = auto-detect) + target_column: Column to use as target (None = compute from close) + """ + _check_torch() + + self.config = config or DatasetConfig() + self.data = data.copy() + self.feature_columns = feature_columns + self.target_column = target_column + + # Normalization statistics + self._feature_means: Optional[np.ndarray] = None + self._feature_stds: Optional[np.ndarray] = None + self._feature_mins: Optional[np.ndarray] = None + self._feature_maxs: Optional[np.ndarray] = None + + # Prepare data + self._prepare_data() + + logger.info(f"TradingDataset initialized") + logger.info(f" Samples: {len(self)}") + logger.info(f" Sequence length: {self.config.sequence_length}") + logger.info(f" Features: {self.features.shape[1]}") + + def _prepare_data(self): + """Prepare data for training.""" + df = self.data + + # Compute returns if requested + if self.config.compute_returns: + df = self._compute_returns(df) + + # Determine feature columns + if self.feature_columns is None: + # Auto-detect: exclude OHLCV base columns and target + exclude_cols = {'open', 'high', 'low', 'close', 'volume', 'vwap', 'timestamp'} + self.feature_columns = [ + col for col in df.columns + if col.lower() not in exclude_cols + ] + + # If no derived features, use OHLCV + if not self.feature_columns: + self.feature_columns = ['open', 'high', 'low', 'close', 'volume'] + + # Extract features + self.features = df[self.feature_columns].values.astype(np.float32) + + # Compute or extract target + if self.target_column and self.target_column in df.columns: + self.targets = df[self.target_column].values.astype(np.float32) + else: + self.targets = self._compute_target(df) + + # Normalize features + if self.config.normalize_features: + self._normalize_features() + + # Handle NaN values + self._handle_nan() + + # Calculate valid indices (sequences that don't include NaN or exceed bounds) + self._calculate_valid_indices() + + def _compute_returns(self, df: pd.DataFrame) -> pd.DataFrame: + """Compute return features from price data.""" + df = df.copy() + + if 'close' not in df.columns: + return df + + # Simple returns + for horizon in self.config.return_horizons: + df[f'return_{horizon}'] = df['close'].pct_change(horizon) + + # Log return + df['log_return'] = np.log(df['close'] / df['close'].shift(1)) + + # Volatility (rolling std of returns) + df['volatility'] = df['log_return'].rolling(window=20).std() + + # Normalized high-low range + df['range_norm'] = (df['high'] - df['low']) / df['close'] + + # Body ratio + df['body_ratio'] = (df['close'] - df['open']) / (df['high'] - df['low'] + 1e-10) + + return df + + def _compute_target(self, df: pd.DataFrame) -> np.ndarray: + """Compute target values based on configuration.""" + if 'close' not in df.columns: + raise ValueError("DataFrame must have 'close' column for target computation") + + horizon = self.config.target_horizon + + if self.config.target_type == 'return': + # Future return + target = df['close'].pct_change(horizon).shift(-horizon).values + + elif self.config.target_type == 'direction': + # Binary direction (1 = up, 0 = down) + future_return = df['close'].pct_change(horizon).shift(-horizon) + target = (future_return > self.config.classification_threshold).astype(np.float32).values + + elif self.config.target_type == 'multi_horizon': + # Multiple horizon targets + targets = [] + for h in self.config.return_horizons: + ret = df['close'].pct_change(h).shift(-h).values + targets.append(ret) + target = np.stack(targets, axis=1) + + else: + raise ValueError(f"Unknown target_type: {self.config.target_type}") + + return target.astype(np.float32) + + def _normalize_features(self): + """Normalize features using the specified method.""" + method = self.config.normalization_method + + if method == 'zscore': + self._feature_means = np.nanmean(self.features, axis=0) + self._feature_stds = np.nanstd(self.features, axis=0) + 1e-10 + self.features = (self.features - self._feature_means) / self._feature_stds + + elif method == 'minmax': + self._feature_mins = np.nanmin(self.features, axis=0) + self._feature_maxs = np.nanmax(self.features, axis=0) + range_vals = self._feature_maxs - self._feature_mins + 1e-10 + self.features = (self.features - self._feature_mins) / range_vals + + elif method == 'robust': + # Use median and IQR for robustness to outliers + median = np.nanmedian(self.features, axis=0) + q75 = np.nanpercentile(self.features, 75, axis=0) + q25 = np.nanpercentile(self.features, 25, axis=0) + iqr = q75 - q25 + 1e-10 + self.features = (self.features - median) / iqr + self._feature_means = median + self._feature_stds = iqr + + def _handle_nan(self): + """Handle NaN values in features and targets.""" + # Replace NaN with 0 in features (after normalization, 0 is neutral) + nan_mask = np.isnan(self.features) + if nan_mask.any(): + logger.debug(f"Replacing {nan_mask.sum()} NaN values in features") + self.features = np.nan_to_num(self.features, nan=0.0) + + def _calculate_valid_indices(self): + """Calculate indices of valid sequences.""" + n_samples = len(self.features) + seq_len = self.config.sequence_length + horizon = self.config.target_horizon + stride = self.config.stride + + # Valid range: from seq_len-1 to n_samples - horizon - 1 + min_idx = seq_len - 1 + max_idx = n_samples - horizon - 1 + + if max_idx <= min_idx: + self.valid_indices = np.array([], dtype=np.int64) + logger.warning("No valid sequences available") + return + + # Generate indices with stride + self.valid_indices = np.arange(min_idx, max_idx + 1, stride) + + # Filter out indices with NaN targets + if self.targets.ndim == 1: + valid_target_mask = ~np.isnan(self.targets[self.valid_indices]) + else: + valid_target_mask = ~np.isnan(self.targets[self.valid_indices]).any(axis=1) + + self.valid_indices = self.valid_indices[valid_target_mask] + + def __len__(self) -> int: + """Return the number of valid samples.""" + return len(self.valid_indices) + + def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Get a single sample. + + Args: + idx: Sample index + + Returns: + Tuple of (features, target) as torch tensors + - features: shape (sequence_length, num_features) + - target: shape () for single target, (num_targets,) for multi + """ + # Get the actual data index + data_idx = self.valid_indices[idx] + + # Extract sequence + seq_start = data_idx - self.config.sequence_length + 1 + seq_end = data_idx + 1 + sequence = self.features[seq_start:seq_end] + + # Extract target + target = self.targets[data_idx] + + # Convert to tensors + sequence_tensor = torch.from_numpy(sequence) + target_tensor = torch.tensor(target) + + return sequence_tensor, target_tensor + + def get_feature_names(self) -> List[str]: + """Get the list of feature column names.""" + return self.feature_columns.copy() + + def get_normalization_params(self) -> Dict[str, np.ndarray]: + """Get normalization parameters for inference.""" + return { + 'means': self._feature_means, + 'stds': self._feature_stds, + 'mins': self._feature_mins, + 'maxs': self._feature_maxs, + 'method': self.config.normalization_method + } + + def create_dataloader( + self, + batch_size: int = 32, + shuffle: bool = True, + num_workers: int = 0, + pin_memory: bool = True + ) -> 'DataLoader': + """ + Create a PyTorch DataLoader for this dataset. + + Args: + batch_size: Batch size + shuffle: Whether to shuffle data + num_workers: Number of worker processes + pin_memory: Whether to pin memory (for GPU) + + Returns: + PyTorch DataLoader + """ + return DataLoader( + self, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + pin_memory=pin_memory + ) + + +class TimeSeriesDataset: + """ + PyTorch Dataset for sliding window time series data. + + This dataset is optimized for traditional time series models + that use fixed-size sliding windows. + + Features: + - Configurable window size and stride + - Multiple target horizons + - Efficient memory usage with views + + Usage: + dataset = TimeSeriesDataset(df, window_size=100, stride=1) + dataloader = DataLoader(dataset, batch_size=64) + + for window, targets in dataloader: + # window shape: (batch_size, window_size, num_features) + predictions = model(window) + """ + + def __init__( + self, + data: pd.DataFrame, + window_size: int = 100, + stride: int = 1, + target_horizons: Optional[List[int]] = None, + feature_columns: Optional[List[str]] = None, + normalize: bool = True + ): + """ + Initialize the time series dataset. + + Args: + data: DataFrame with OHLCV data + window_size: Size of each sliding window + stride: Step size between windows + target_horizons: List of future horizons for targets + feature_columns: Columns to use as features + normalize: Whether to normalize features + """ + _check_torch() + + self.data = data.copy() + self.window_size = window_size + self.stride = stride + self.target_horizons = target_horizons or [1, 5, 12] + self.feature_columns = feature_columns + self.normalize = normalize + + # Statistics for normalization + self._means: Optional[np.ndarray] = None + self._stds: Optional[np.ndarray] = None + + self._prepare_data() + + logger.info(f"TimeSeriesDataset initialized") + logger.info(f" Windows: {len(self)}") + logger.info(f" Window size: {window_size}") + logger.info(f" Features: {self.features.shape[1]}") + + def _prepare_data(self): + """Prepare sliding window data.""" + df = self.data + + # Determine feature columns + if self.feature_columns is None: + # Use OHLCV by default + base_cols = ['open', 'high', 'low', 'close', 'volume'] + self.feature_columns = [c for c in base_cols if c in df.columns] + + # Extract features + self.features = df[self.feature_columns].values.astype(np.float32) + + # Compute targets (future returns at multiple horizons) + if 'close' in df.columns: + targets = [] + for horizon in self.target_horizons: + ret = df['close'].pct_change(horizon).shift(-horizon).values + targets.append(ret) + self.targets = np.stack(targets, axis=1).astype(np.float32) + else: + # No targets available + self.targets = np.zeros((len(df), len(self.target_horizons)), dtype=np.float32) + + # Normalize + if self.normalize: + self._means = np.nanmean(self.features, axis=0) + self._stds = np.nanstd(self.features, axis=0) + 1e-10 + self.features = (self.features - self._means) / self._stds + + # Handle NaN + self.features = np.nan_to_num(self.features, nan=0.0) + + # Calculate valid window start indices + max_horizon = max(self.target_horizons) + n_samples = len(self.features) + max_start = n_samples - self.window_size - max_horizon + + if max_start < 0: + self.window_starts = np.array([], dtype=np.int64) + else: + self.window_starts = np.arange(0, max_start + 1, self.stride) + + def __len__(self) -> int: + """Return number of windows.""" + return len(self.window_starts) + + def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Get a single window and its targets. + + Args: + idx: Window index + + Returns: + Tuple of (window, targets) as torch tensors + - window: shape (window_size, num_features) + - targets: shape (num_horizons,) + """ + start = self.window_starts[idx] + end = start + self.window_size + + window = self.features[start:end] + target_idx = end - 1 # Target is computed from last element of window + targets = self.targets[target_idx] + + return torch.from_numpy(window), torch.from_numpy(targets) + + def get_full_sequence( + self, + start_idx: int, + length: int + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Get a continuous sequence of windows. + + Useful for sequential prediction where windows overlap. + + Args: + start_idx: Starting window index + length: Number of consecutive windows + + Returns: + Tuple of (windows, targets) + - windows: shape (length, window_size, num_features) + - targets: shape (length, num_horizons) + """ + end_idx = min(start_idx + length, len(self)) + actual_length = end_idx - start_idx + + windows = [] + targets = [] + + for i in range(start_idx, end_idx): + w, t = self[i] + windows.append(w) + targets.append(t) + + return torch.stack(windows), torch.stack(targets) + + def create_dataloader( + self, + batch_size: int = 64, + shuffle: bool = True, + num_workers: int = 0 + ) -> 'DataLoader': + """Create a DataLoader for this dataset.""" + return DataLoader( + self, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers + ) + + +def create_train_val_datasets( + data: pd.DataFrame, + val_ratio: float = 0.15, + config: Optional[DatasetConfig] = None +) -> Tuple[TradingDataset, TradingDataset]: + """ + Create training and validation datasets with temporal split. + + Args: + data: Full DataFrame with OHLCV data + val_ratio: Ratio of data to use for validation (from end) + config: Dataset configuration + + Returns: + Tuple of (train_dataset, val_dataset) + """ + n_samples = len(data) + split_idx = int(n_samples * (1 - val_ratio)) + + train_data = data.iloc[:split_idx] + val_data = data.iloc[split_idx:] + + logger.info(f"Creating train/val split:") + logger.info(f" Train: {len(train_data):,} samples") + logger.info(f" Val: {len(val_data):,} samples") + + train_dataset = TradingDataset(train_data, config) + val_dataset = TradingDataset(val_data, config) + + return train_dataset, val_dataset + + +if __name__ == "__main__": + # Test the dataset classes + print("Testing Dataset Classes...") + + # Create sample data + np.random.seed(42) + n = 5000 + + dates = pd.date_range('2024-01-01', periods=n, freq='5min') + price = 2650 + np.cumsum(np.random.randn(n) * 0.5) + + df = pd.DataFrame({ + 'open': price, + 'high': price + np.abs(np.random.randn(n)) * 2, + 'low': price - np.abs(np.random.randn(n)) * 2, + 'close': price + np.random.randn(n) * 0.5, + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Test TradingDataset + print("\n" + "=" * 60) + print("Testing TradingDataset") + print("=" * 60) + + config = DatasetConfig( + sequence_length=60, + target_horizon=12, + target_type='return' + ) + + dataset = TradingDataset(df, config) + print(f"Dataset length: {len(dataset)}") + + # Get a sample + features, target = dataset[0] + print(f"Features shape: {features.shape}") + print(f"Target shape: {target.shape}") + print(f"Feature names: {dataset.get_feature_names()}") + + # Test DataLoader + loader = dataset.create_dataloader(batch_size=32) + for batch_features, batch_targets in loader: + print(f"Batch features shape: {batch_features.shape}") + print(f"Batch targets shape: {batch_targets.shape}") + break + + # Test TimeSeriesDataset + print("\n" + "=" * 60) + print("Testing TimeSeriesDataset") + print("=" * 60) + + ts_dataset = TimeSeriesDataset( + df, + window_size=100, + stride=5, + target_horizons=[1, 5, 12] + ) + print(f"Dataset length: {len(ts_dataset)}") + + window, targets = ts_dataset[0] + print(f"Window shape: {window.shape}") + print(f"Targets shape: {targets.shape}") + + # Test train/val split + print("\n" + "=" * 60) + print("Testing Train/Val Split") + print("=" * 60) + + train_ds, val_ds = create_train_val_datasets(df, val_ratio=0.15, config=config) + print(f"Train dataset: {len(train_ds)} samples") + print(f"Val dataset: {len(val_ds)} samples") + + print("\nTest complete!") diff --git a/src/data/training_loader.py b/src/data/training_loader.py new file mode 100644 index 0000000..2f66f6f --- /dev/null +++ b/src/data/training_loader.py @@ -0,0 +1,611 @@ +""" +Training Data Loader for ML Engine +=================================== +Provides efficient data loading from PostgreSQL for ML training. + +This module implements: +- Batch loading for large datasets +- Streaming support for memory-efficient processing +- Filtering by symbol, timeframe, and date range +- Feature and target extraction for model training + +Author: ML Pipeline (NEXUS v4.0) +Created: 2026-01-25 +""" + +from typing import Optional, Dict, Any, List, Iterator, Tuple +from datetime import datetime, timedelta +from dataclasses import dataclass, field + +import pandas as pd +import numpy as np +from sqlalchemy import text +from loguru import logger + +from .database import PostgreSQLConnection, DatabaseManager + + +@dataclass +class TrainingDataConfig: + """Configuration for training data loading.""" + + # Database connection + host: str = 'localhost' + port: int = 5432 + database: str = 'trading_platform' + user: str = 'trading_user' + password: str = 'trading_dev_2026' + + # Default query parameters + default_batch_size: int = 50000 + default_timeframe: str = '5m' + + # Feature generation + return_horizons: List[int] = field(default_factory=lambda: [1, 5, 12, 24]) + volatility_window: int = 20 + + # Streaming configuration + stream_chunk_size: int = 10000 + prefetch_chunks: int = 2 + + +class TrainingDataLoader: + """ + Efficient data loader for ML model training. + + Provides batch loading, streaming, and feature extraction + from the PostgreSQL trading_platform database. + + Usage: + loader = TrainingDataLoader() + + # Simple batch loading + df = loader.get_training_data('XAUUSD', '2023-01-01', '2024-12-31') + + # Streaming for large datasets + for batch in loader.stream_training_data('XAUUSD', batch_size=50000): + process_batch(batch) + + # Get features and targets + X, y = loader.get_features_and_targets('XAUUSD', '5m') + """ + + def __init__( + self, + config: Optional[TrainingDataConfig] = None, + db_connection: Optional[PostgreSQLConnection] = None + ): + """ + Initialize the training data loader. + + Args: + config: Configuration object for data loading + db_connection: Existing database connection (creates new if None) + """ + self.config = config or TrainingDataConfig() + self.db = db_connection or PostgreSQLConnection() + self._cache: Dict[str, pd.DataFrame] = {} + + logger.info("TrainingDataLoader initialized") + logger.info(f" Database: {self.config.database}") + logger.info(f" Default batch size: {self.config.default_batch_size}") + + def get_training_data( + self, + symbol: str, + start_date: str, + end_date: str, + timeframe: str = '5m', + batch_size: Optional[int] = None, + include_features: bool = True + ) -> pd.DataFrame: + """ + Load training data for a symbol within a date range. + + Args: + symbol: Trading symbol (e.g., 'XAUUSD', 'EURUSD') + start_date: Start date (YYYY-MM-DD format) + end_date: End date (YYYY-MM-DD format) + timeframe: Data timeframe ('5m', '15m', '1h', '4h', 'd') + batch_size: Number of records per batch (None for all at once) + include_features: Whether to compute derived features + + Returns: + DataFrame with OHLCV data and optional features, indexed by timestamp + """ + logger.info(f"Loading training data for {symbol} ({timeframe})") + logger.info(f" Date range: {start_date} to {end_date}") + + if batch_size is None: + # Load all data at once + df = self.db.get_ticker_data( + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + limit=5000000 # High limit for all data + ) + else: + # Load in batches and concatenate + batches = [] + for batch_df in self._load_batches( + symbol, start_date, end_date, timeframe, batch_size + ): + batches.append(batch_df) + + if not batches: + logger.warning(f"No data found for {symbol}") + return pd.DataFrame() + + df = pd.concat(batches, axis=0) + df = df.sort_index() + df = df[~df.index.duplicated(keep='first')] + + if df.empty: + logger.warning(f"No data loaded for {symbol}") + return df + + logger.info(f" Loaded {len(df):,} records") + + if include_features: + df = self._compute_basic_features(df) + logger.info(f" Added {len(df.columns) - 6} derived features") + + return df + + def _load_batches( + self, + symbol: str, + start_date: str, + end_date: str, + timeframe: str, + batch_size: int + ) -> Iterator[pd.DataFrame]: + """ + Load data in batches using offset pagination. + + Args: + symbol: Trading symbol + start_date: Start date + end_date: End date + timeframe: Timeframe + batch_size: Records per batch + + Yields: + DataFrames for each batch + """ + table_map = { + '5m': 'ohlcv_5m', + '15m': 'ohlcv_15m', + '1h': 'ohlcv_1h', + '4h': 'ohlcv_4h', + 'd': 'ohlcv_daily', + '1d': 'ohlcv_daily', + } + table = table_map.get(timeframe.lower(), 'ohlcv_5m') + + clean_symbol = symbol + if symbol.startswith('C:') or symbol.startswith('X:') or symbol.startswith('I:'): + clean_symbol = symbol[2:] + + offset = 0 + total_loaded = 0 + + while True: + query = f""" + SELECT + o.timestamp, + o.open, + o.high, + o.low, + o.close, + o.volume, + o.vwap + FROM market_data.{table} o + JOIN market_data.tickers t ON t.id = o.ticker_id + WHERE UPPER(t.symbol) = UPPER(:symbol) + AND o.timestamp >= :start_date + AND o.timestamp <= :end_date + ORDER BY o.timestamp ASC + LIMIT :limit OFFSET :offset + """ + + params = { + 'symbol': clean_symbol, + 'start_date': start_date, + 'end_date': end_date, + 'limit': batch_size, + 'offset': offset + } + + batch_df = pd.read_sql(text(query), self.db.engine, params=params) + + if batch_df.empty: + break + + batch_df['timestamp'] = pd.to_datetime(batch_df['timestamp']) + batch_df.set_index('timestamp', inplace=True) + + total_loaded += len(batch_df) + logger.debug(f" Loaded batch: {len(batch_df)} records (total: {total_loaded:,})") + + yield batch_df + + if len(batch_df) < batch_size: + break + + offset += batch_size + + def stream_training_data( + self, + symbol: str, + timeframe: str = '5m', + start_date: Optional[str] = None, + end_date: Optional[str] = None, + batch_size: Optional[int] = None + ) -> Iterator[pd.DataFrame]: + """ + Stream training data in chunks for memory-efficient processing. + + This method is ideal for processing very large datasets that + don't fit in memory. Each chunk is loaded, processed, and + can be discarded before loading the next. + + Args: + symbol: Trading symbol + timeframe: Data timeframe + start_date: Start date (defaults to 5 years ago) + end_date: End date (defaults to today) + batch_size: Records per chunk (defaults to config) + + Yields: + DataFrames for each chunk with computed features + """ + if start_date is None: + start_date = (datetime.now() - timedelta(days=365 * 5)).strftime('%Y-%m-%d') + if end_date is None: + end_date = datetime.now().strftime('%Y-%m-%d') + if batch_size is None: + batch_size = self.config.stream_chunk_size + + logger.info(f"Streaming data for {symbol} ({timeframe})") + logger.info(f" Date range: {start_date} to {end_date}") + logger.info(f" Chunk size: {batch_size:,}") + + chunk_count = 0 + total_records = 0 + + for batch_df in self._load_batches( + symbol, start_date, end_date, timeframe, batch_size + ): + chunk_count += 1 + total_records += len(batch_df) + + # Compute features for this chunk + batch_df = self._compute_basic_features(batch_df) + + logger.debug(f" Yielding chunk {chunk_count}: {len(batch_df)} records") + yield batch_df + + logger.info(f" Streamed {chunk_count} chunks, {total_records:,} total records") + + def get_features_and_targets( + self, + symbol: str, + timeframe: str = '5m', + start_date: Optional[str] = None, + end_date: Optional[str] = None, + target_horizon: int = 12, + target_type: str = 'return' + ) -> Tuple[pd.DataFrame, pd.Series]: + """ + Get feature matrix and target vector for model training. + + Args: + symbol: Trading symbol + timeframe: Data timeframe + start_date: Start date (defaults to 3 years ago) + end_date: End date (defaults to today) + target_horizon: Number of bars ahead for target + target_type: 'return' for % returns, 'direction' for up/down + + Returns: + Tuple of (X features DataFrame, y target Series) + """ + if start_date is None: + start_date = (datetime.now() - timedelta(days=365 * 3)).strftime('%Y-%m-%d') + if end_date is None: + end_date = datetime.now().strftime('%Y-%m-%d') + + logger.info(f"Preparing features and targets for {symbol} ({timeframe})") + logger.info(f" Target: {target_type} at horizon {target_horizon}") + + # Load data with features + df = self.get_training_data( + symbol=symbol, + start_date=start_date, + end_date=end_date, + timeframe=timeframe, + include_features=True + ) + + if df.empty: + return pd.DataFrame(), pd.Series() + + # Compute target + if target_type == 'return': + target = df['close'].pct_change(target_horizon).shift(-target_horizon) + elif target_type == 'direction': + future_return = df['close'].pct_change(target_horizon).shift(-target_horizon) + target = (future_return > 0).astype(int) + else: + raise ValueError(f"Unknown target_type: {target_type}") + + # Select feature columns (exclude OHLCV and target-related) + feature_cols = [ + col for col in df.columns + if col not in ['open', 'high', 'low', 'close', 'volume', 'vwap'] + and not col.startswith('target_') + ] + + X = df[feature_cols] + y = target + + # Remove rows with NaN targets (end of series) + valid_mask = ~y.isna() & ~X.isna().any(axis=1) + X = X[valid_mask] + y = y[valid_mask] + + logger.info(f" Features shape: {X.shape}") + logger.info(f" Feature columns: {list(X.columns)}") + + return X, y + + def _compute_basic_features(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Compute basic derived features for the DataFrame. + + Features computed: + - Returns at multiple horizons + - Volatility (rolling std) + - Range and body percentages + - Volume ratios + + Args: + df: DataFrame with OHLCV columns + + Returns: + DataFrame with additional feature columns + """ + df = df.copy() + + # Price returns at different horizons + for horizon in self.config.return_horizons: + df[f'return_{horizon}'] = df['close'].pct_change(horizon) + + # Log returns (more suitable for ML) + df['log_return'] = np.log(df['close'] / df['close'].shift(1)) + + # Volatility (rolling standard deviation of returns) + window = self.config.volatility_window + df['volatility'] = df['log_return'].rolling(window=window).std() + df['volatility_pct'] = df['volatility'] * np.sqrt(252 * 12) # Annualized for 5m + + # High-Low range as percentage + df['range_pct'] = (df['high'] - df['low']) / df['close'] + + # Body as percentage of range + df['body_pct'] = np.abs(df['close'] - df['open']) / (df['high'] - df['low'] + 1e-10) + + # Upper and lower wicks + body_high = df[['open', 'close']].max(axis=1) + body_low = df[['open', 'close']].min(axis=1) + df['upper_wick_pct'] = (df['high'] - body_high) / (df['high'] - df['low'] + 1e-10) + df['lower_wick_pct'] = (body_low - df['low']) / (df['high'] - df['low'] + 1e-10) + + # Bullish/Bearish candle + df['is_bullish'] = (df['close'] > df['open']).astype(int) + + # Volume features + if 'volume' in df.columns and df['volume'].sum() > 0: + df['volume_sma'] = df['volume'].rolling(window=window).mean() + df['volume_ratio'] = df['volume'] / (df['volume_sma'] + 1e-10) + df['volume_std'] = df['volume'].rolling(window=window).std() + else: + df['volume_sma'] = 0 + df['volume_ratio'] = 1 + df['volume_std'] = 0 + + # Price momentum indicators + df['momentum_5'] = df['close'] - df['close'].shift(5) + df['momentum_12'] = df['close'] - df['close'].shift(12) + + # Moving average distances + df['sma_10'] = df['close'].rolling(window=10).mean() + df['sma_20'] = df['close'].rolling(window=20).mean() + df['dist_sma_10'] = (df['close'] - df['sma_10']) / df['close'] + df['dist_sma_20'] = (df['close'] - df['sma_20']) / df['close'] + + return df + + def get_multi_symbol_data( + self, + symbols: List[str], + start_date: str, + end_date: str, + timeframe: str = '5m' + ) -> Dict[str, pd.DataFrame]: + """ + Load training data for multiple symbols. + + Args: + symbols: List of trading symbols + start_date: Start date + end_date: End date + timeframe: Data timeframe + + Returns: + Dictionary mapping symbol to DataFrame + """ + logger.info(f"Loading data for {len(symbols)} symbols") + + data_dict = {} + for symbol in symbols: + df = self.get_training_data( + symbol=symbol, + start_date=start_date, + end_date=end_date, + timeframe=timeframe, + include_features=True + ) + if not df.empty: + data_dict[symbol] = df + logger.info(f" {symbol}: {len(df):,} records") + else: + logger.warning(f" {symbol}: No data found") + + return data_dict + + def get_data_summary( + self, + symbol: str, + timeframe: str = '5m' + ) -> Dict[str, Any]: + """ + Get summary statistics for available data. + + Args: + symbol: Trading symbol + timeframe: Data timeframe + + Returns: + Dictionary with summary statistics + """ + table_map = { + '5m': 'ohlcv_5m', + '15m': 'ohlcv_15m', + '1h': 'ohlcv_1h', + '4h': 'ohlcv_4h', + 'd': 'ohlcv_daily', + } + table = table_map.get(timeframe.lower(), 'ohlcv_5m') + + clean_symbol = symbol + if symbol.startswith('C:') or symbol.startswith('X:'): + clean_symbol = symbol[2:] + + query = f""" + SELECT + COUNT(*) as total_records, + MIN(o.timestamp) as first_date, + MAX(o.timestamp) as last_date, + AVG(o.close) as avg_price, + STDDEV(o.close) as std_price, + AVG(o.volume) as avg_volume + FROM market_data.{table} o + JOIN market_data.tickers t ON t.id = o.ticker_id + WHERE UPPER(t.symbol) = UPPER(:symbol) + """ + + result = pd.read_sql(text(query), self.db.engine, params={'symbol': clean_symbol}) + + if result.empty or result['total_records'].iloc[0] == 0: + return {'symbol': symbol, 'timeframe': timeframe, 'error': 'No data found'} + + row = result.iloc[0] + return { + 'symbol': symbol, + 'timeframe': timeframe, + 'total_records': int(row['total_records']), + 'first_date': str(row['first_date']), + 'last_date': str(row['last_date']), + 'avg_price': float(row['avg_price']) if row['avg_price'] else 0, + 'std_price': float(row['std_price']) if row['std_price'] else 0, + 'avg_volume': float(row['avg_volume']) if row['avg_volume'] else 0, + } + + def clear_cache(self): + """Clear the internal data cache.""" + self._cache.clear() + logger.info("Data cache cleared") + + +def load_training_data( + symbol: str, + start_date: str, + end_date: str, + timeframe: str = '5m' +) -> pd.DataFrame: + """ + Convenience function to load training data. + + Args: + symbol: Trading symbol + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + timeframe: Data timeframe + + Returns: + DataFrame with OHLCV data and features + """ + loader = TrainingDataLoader() + return loader.get_training_data(symbol, start_date, end_date, timeframe) + + +if __name__ == "__main__": + # Test the training data loader + print("Testing TrainingDataLoader...") + + loader = TrainingDataLoader() + + # Test data summary + print("\nData summary for XAUUSD:") + summary = loader.get_data_summary('XAUUSD', '5m') + for key, value in summary.items(): + print(f" {key}: {value}") + + # Test batch loading + print("\nTesting batch loading:") + df = loader.get_training_data( + symbol='XAUUSD', + start_date='2024-01-01', + end_date='2024-12-31', + timeframe='5m', + batch_size=50000 + ) + print(f" Loaded {len(df):,} records") + print(f" Columns: {list(df.columns)}") + + # Test features and targets + print("\nTesting features and targets:") + X, y = loader.get_features_and_targets( + symbol='XAUUSD', + timeframe='5m', + start_date='2024-01-01', + end_date='2024-06-30', + target_horizon=12 + ) + print(f" X shape: {X.shape}") + print(f" y shape: {y.shape}") + print(f" Features: {list(X.columns)}") + + # Test streaming + print("\nTesting streaming:") + chunk_count = 0 + total_records = 0 + for chunk in loader.stream_training_data( + symbol='XAUUSD', + timeframe='5m', + start_date='2024-01-01', + end_date='2024-03-31', + batch_size=10000 + ): + chunk_count += 1 + total_records += len(chunk) + if chunk_count >= 3: + print(f" (stopped after {chunk_count} chunks for test)") + break + + print(f" Chunks: {chunk_count}, Records: {total_records:,}") + + print("\nTest complete!") diff --git a/src/data/validators.py b/src/data/validators.py new file mode 100644 index 0000000..9054194 --- /dev/null +++ b/src/data/validators.py @@ -0,0 +1,726 @@ +""" +Data Quality Validators for ML Engine +====================================== +Provides validation utilities to ensure data quality before ML training. + +This module implements: +- Gap detection in time series data +- Outlier detection using statistical methods +- OHLC consistency validation +- Comprehensive quality reports + +Author: ML Pipeline (NEXUS v4.0) +Created: 2026-01-25 +""" + +from typing import Optional, List, Dict, Any, Tuple +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import Enum + +import numpy as np +import pandas as pd +from loguru import logger + + +class ValidationSeverity(Enum): + """Severity levels for validation issues.""" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + +@dataclass +class ValidationIssue: + """Represents a single validation issue found in the data.""" + severity: ValidationSeverity + category: str + message: str + affected_rows: int = 0 + affected_columns: List[str] = field(default_factory=list) + details: Dict[str, Any] = field(default_factory=dict) + + def __repr__(self) -> str: + return f"[{self.severity.value.upper()}] {self.category}: {self.message}" + + +@dataclass +class QualityReport: + """Comprehensive data quality report.""" + timestamp: str + total_rows: int + total_columns: int + date_range: Tuple[str, str] + issues: List[ValidationIssue] + metrics: Dict[str, Any] + is_valid: bool + + def __repr__(self) -> str: + status = "VALID" if self.is_valid else "INVALID" + issues_by_severity = {} + for issue in self.issues: + sev = issue.severity.value + issues_by_severity[sev] = issues_by_severity.get(sev, 0) + 1 + + issue_summary = ", ".join([f"{k}: {v}" for k, v in issues_by_severity.items()]) + + return ( + f"QualityReport({status})\n" + f" Rows: {self.total_rows:,}, Columns: {self.total_columns}\n" + f" Date range: {self.date_range[0]} to {self.date_range[1]}\n" + f" Issues: {issue_summary or 'None'}" + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert report to dictionary.""" + return { + 'timestamp': self.timestamp, + 'total_rows': self.total_rows, + 'total_columns': self.total_columns, + 'date_range': self.date_range, + 'is_valid': self.is_valid, + 'issues': [ + { + 'severity': i.severity.value, + 'category': i.category, + 'message': i.message, + 'affected_rows': i.affected_rows, + 'affected_columns': i.affected_columns, + 'details': i.details + } + for i in self.issues + ], + 'metrics': self.metrics + } + + +class DataValidator: + """ + Comprehensive data quality validator for trading data. + + Provides methods to detect: + - Temporal gaps in time series + - Statistical outliers + - OHLC consistency issues + - Missing values and data quality problems + + Usage: + validator = DataValidator() + + # Individual validations + gaps = validator.validate_gaps(df) + outliers = validator.validate_outliers(df, ['close', 'volume']) + consistency = validator.validate_consistency(df) + + # Full quality report + report = validator.generate_quality_report(df) + if not report.is_valid: + for issue in report.issues: + print(issue) + """ + + def __init__( + self, + expected_frequency: str = '5min', + outlier_threshold: float = 3.0, + max_gap_tolerance: int = 3, + min_volume_threshold: float = 0.0 + ): + """ + Initialize the data validator. + + Args: + expected_frequency: Expected time frequency (e.g., '5min', '15min', '1h') + outlier_threshold: Z-score threshold for outlier detection + max_gap_tolerance: Maximum allowed consecutive gaps before warning + min_volume_threshold: Minimum acceptable volume (0 to disable) + """ + self.expected_frequency = expected_frequency + self.outlier_threshold = outlier_threshold + self.max_gap_tolerance = max_gap_tolerance + self.min_volume_threshold = min_volume_threshold + + # Frequency mapping to timedelta + self._freq_to_delta = { + '1min': timedelta(minutes=1), + '5min': timedelta(minutes=5), + '15min': timedelta(minutes=15), + '30min': timedelta(minutes=30), + '1h': timedelta(hours=1), + '4h': timedelta(hours=4), + '1d': timedelta(days=1), + 'd': timedelta(days=1), + } + + def validate_gaps( + self, + df: pd.DataFrame, + frequency: Optional[str] = None + ) -> List[ValidationIssue]: + """ + Detect gaps in time series data. + + Args: + df: DataFrame with DatetimeIndex + frequency: Expected frequency (uses default if None) + + Returns: + List of ValidationIssue objects for detected gaps + """ + issues = [] + + if not isinstance(df.index, pd.DatetimeIndex): + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="gaps", + message="DataFrame does not have a DatetimeIndex", + details={'index_type': str(type(df.index))} + )) + return issues + + if len(df) < 2: + return issues + + freq = frequency or self.expected_frequency + expected_delta = self._freq_to_delta.get(freq) + + if expected_delta is None: + logger.warning(f"Unknown frequency: {freq}") + expected_delta = df.index.to_series().diff().median() + + # Calculate time differences + time_diffs = df.index.to_series().diff() + + # Identify gaps (differences larger than expected) + tolerance = expected_delta * 1.5 # Allow some tolerance + gap_mask = time_diffs > tolerance + gaps = df.index[gap_mask] + + if len(gaps) == 0: + return issues + + # Analyze gaps + gap_sizes = time_diffs[gap_mask] + total_gaps = len(gaps) + max_gap = gap_sizes.max() + avg_gap = gap_sizes.mean() + + # Count consecutive gaps + gap_indices = np.where(gap_mask)[0] + consecutive_groups = np.split( + gap_indices, + np.where(np.diff(gap_indices) != 1)[0] + 1 + ) + max_consecutive = max(len(g) for g in consecutive_groups) if consecutive_groups else 0 + + # Determine severity + if max_consecutive > self.max_gap_tolerance * 3: + severity = ValidationSeverity.ERROR + elif max_consecutive > self.max_gap_tolerance: + severity = ValidationSeverity.WARNING + else: + severity = ValidationSeverity.INFO + + # Create detailed gap report + gap_details = [] + for i, (gap_time, gap_size) in enumerate(zip(gaps, gap_sizes)): + if i < 10: # Limit detailed reporting + gap_details.append({ + 'timestamp': str(gap_time), + 'gap_size': str(gap_size), + 'gap_minutes': gap_size.total_seconds() / 60 + }) + + issues.append(ValidationIssue( + severity=severity, + category="gaps", + message=f"Detected {total_gaps} gaps in time series (max consecutive: {max_consecutive})", + affected_rows=total_gaps, + details={ + 'total_gaps': total_gaps, + 'max_gap': str(max_gap), + 'avg_gap': str(avg_gap), + 'max_consecutive': max_consecutive, + 'expected_frequency': freq, + 'sample_gaps': gap_details + } + )) + + return issues + + def validate_outliers( + self, + df: pd.DataFrame, + columns: Optional[List[str]] = None, + threshold: Optional[float] = None, + method: str = 'zscore' + ) -> List[ValidationIssue]: + """ + Detect statistical outliers in specified columns. + + Args: + df: DataFrame with data + columns: Columns to check (defaults to numeric columns) + threshold: Z-score threshold for outlier detection + method: Detection method ('zscore', 'iqr', 'mad') + + Returns: + List of ValidationIssue objects for detected outliers + """ + issues = [] + threshold = threshold or self.outlier_threshold + + # Determine columns to check + if columns is None: + columns = df.select_dtypes(include=[np.number]).columns.tolist() + + for col in columns: + if col not in df.columns: + continue + + series = df[col].dropna() + if len(series) < 10: + continue + + # Detect outliers based on method + if method == 'zscore': + outlier_mask = self._detect_zscore_outliers(series, threshold) + elif method == 'iqr': + outlier_mask = self._detect_iqr_outliers(series, threshold) + elif method == 'mad': + outlier_mask = self._detect_mad_outliers(series, threshold) + else: + outlier_mask = self._detect_zscore_outliers(series, threshold) + + n_outliers = outlier_mask.sum() + + if n_outliers == 0: + continue + + outlier_pct = n_outliers / len(series) * 100 + + # Determine severity based on percentage + if outlier_pct > 5: + severity = ValidationSeverity.ERROR + elif outlier_pct > 1: + severity = ValidationSeverity.WARNING + else: + severity = ValidationSeverity.INFO + + # Get outlier statistics + outlier_values = series[outlier_mask] + + issues.append(ValidationIssue( + severity=severity, + category="outliers", + message=f"Column '{col}' has {n_outliers} outliers ({outlier_pct:.2f}%)", + affected_rows=n_outliers, + affected_columns=[col], + details={ + 'method': method, + 'threshold': threshold, + 'outlier_count': n_outliers, + 'outlier_percentage': outlier_pct, + 'min_outlier': float(outlier_values.min()), + 'max_outlier': float(outlier_values.max()), + 'column_mean': float(series.mean()), + 'column_std': float(series.std()), + 'sample_outliers': outlier_values.head(10).tolist() + } + )) + + return issues + + def _detect_zscore_outliers( + self, + series: pd.Series, + threshold: float + ) -> pd.Series: + """Detect outliers using Z-score method.""" + mean = series.mean() + std = series.std() + if std == 0: + return pd.Series(False, index=series.index) + z_scores = np.abs((series - mean) / std) + return z_scores > threshold + + def _detect_iqr_outliers( + self, + series: pd.Series, + threshold: float + ) -> pd.Series: + """Detect outliers using IQR method.""" + q1 = series.quantile(0.25) + q3 = series.quantile(0.75) + iqr = q3 - q1 + lower_bound = q1 - threshold * iqr + upper_bound = q3 + threshold * iqr + return (series < lower_bound) | (series > upper_bound) + + def _detect_mad_outliers( + self, + series: pd.Series, + threshold: float + ) -> pd.Series: + """Detect outliers using Median Absolute Deviation.""" + median = series.median() + mad = np.median(np.abs(series - median)) + if mad == 0: + return pd.Series(False, index=series.index) + modified_z = 0.6745 * (series - median) / mad + return np.abs(modified_z) > threshold + + def validate_consistency( + self, + df: pd.DataFrame + ) -> List[ValidationIssue]: + """ + Verify OHLC data consistency. + + Checks: + - High >= Low for all rows + - High >= Open and High >= Close + - Low <= Open and Low <= Close + - Non-negative volume + + Args: + df: DataFrame with OHLC columns + + Returns: + List of ValidationIssue objects for consistency violations + """ + issues = [] + + required_cols = ['open', 'high', 'low', 'close'] + missing_cols = [c for c in required_cols if c not in df.columns] + + if missing_cols: + issues.append(ValidationIssue( + severity=ValidationSeverity.WARNING, + category="consistency", + message=f"Missing OHLC columns: {missing_cols}", + affected_columns=missing_cols + )) + return issues + + # Check High >= Low + hl_violations = df['high'] < df['low'] + if hl_violations.any(): + n_violations = hl_violations.sum() + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="consistency", + message=f"High < Low in {n_violations} rows", + affected_rows=n_violations, + affected_columns=['high', 'low'], + details={ + 'violation_indices': df.index[hl_violations][:10].tolist() + } + )) + + # Check High >= Open and High >= Close + ho_violations = df['high'] < df['open'] + hc_violations = df['high'] < df['close'] + high_violations = ho_violations | hc_violations + + if high_violations.any(): + n_violations = high_violations.sum() + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="consistency", + message=f"High is not the highest price in {n_violations} rows", + affected_rows=n_violations, + affected_columns=['high', 'open', 'close'], + details={ + 'high_lt_open': int(ho_violations.sum()), + 'high_lt_close': int(hc_violations.sum()) + } + )) + + # Check Low <= Open and Low <= Close + lo_violations = df['low'] > df['open'] + lc_violations = df['low'] > df['close'] + low_violations = lo_violations | lc_violations + + if low_violations.any(): + n_violations = low_violations.sum() + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="consistency", + message=f"Low is not the lowest price in {n_violations} rows", + affected_rows=n_violations, + affected_columns=['low', 'open', 'close'], + details={ + 'low_gt_open': int(lo_violations.sum()), + 'low_gt_close': int(lc_violations.sum()) + } + )) + + # Check non-negative volume + if 'volume' in df.columns: + neg_volume = df['volume'] < 0 + if neg_volume.any(): + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="consistency", + message=f"Negative volume in {neg_volume.sum()} rows", + affected_rows=int(neg_volume.sum()), + affected_columns=['volume'] + )) + + # Check zero volume (warning only) + zero_volume = df['volume'] == 0 + if zero_volume.any() and self.min_volume_threshold > 0: + pct_zero = zero_volume.sum() / len(df) * 100 + issues.append(ValidationIssue( + severity=ValidationSeverity.WARNING, + category="consistency", + message=f"Zero volume in {zero_volume.sum()} rows ({pct_zero:.1f}%)", + affected_rows=int(zero_volume.sum()), + affected_columns=['volume'] + )) + + # Check for duplicate timestamps + if isinstance(df.index, pd.DatetimeIndex): + duplicates = df.index.duplicated() + if duplicates.any(): + n_dups = duplicates.sum() + issues.append(ValidationIssue( + severity=ValidationSeverity.ERROR, + category="consistency", + message=f"Found {n_dups} duplicate timestamps", + affected_rows=n_dups, + details={ + 'duplicate_timestamps': df.index[duplicates][:10].tolist() + } + )) + + return issues + + def validate_missing_values( + self, + df: pd.DataFrame + ) -> List[ValidationIssue]: + """ + Check for missing values in the data. + + Args: + df: DataFrame to check + + Returns: + List of ValidationIssue objects for missing value issues + """ + issues = [] + + # Check each column for missing values + missing_counts = df.isnull().sum() + total_rows = len(df) + + for col, missing in missing_counts.items(): + if missing == 0: + continue + + pct_missing = missing / total_rows * 100 + + # Determine severity + if pct_missing > 10: + severity = ValidationSeverity.ERROR + elif pct_missing > 1: + severity = ValidationSeverity.WARNING + else: + severity = ValidationSeverity.INFO + + issues.append(ValidationIssue( + severity=severity, + category="missing_values", + message=f"Column '{col}' has {missing} missing values ({pct_missing:.2f}%)", + affected_rows=missing, + affected_columns=[col], + details={ + 'missing_count': missing, + 'missing_percentage': pct_missing + } + )) + + return issues + + def generate_quality_report( + self, + df: pd.DataFrame, + symbol: str = 'unknown', + timeframe: str = 'unknown' + ) -> QualityReport: + """ + Generate a comprehensive data quality report. + + Args: + df: DataFrame to analyze + symbol: Symbol identifier for the report + timeframe: Timeframe identifier for the report + + Returns: + QualityReport with all validation results + """ + logger.info(f"Generating quality report for {symbol} ({timeframe})") + + all_issues = [] + + # Run all validations + all_issues.extend(self.validate_gaps(df)) + all_issues.extend(self.validate_outliers(df)) + all_issues.extend(self.validate_consistency(df)) + all_issues.extend(self.validate_missing_values(df)) + + # Calculate metrics + metrics = self._calculate_metrics(df) + metrics['symbol'] = symbol + metrics['timeframe'] = timeframe + + # Determine date range + if isinstance(df.index, pd.DatetimeIndex) and len(df) > 0: + date_range = (str(df.index.min()), str(df.index.max())) + else: + date_range = ('N/A', 'N/A') + + # Determine if data is valid (no ERROR or CRITICAL issues) + is_valid = not any( + issue.severity in (ValidationSeverity.ERROR, ValidationSeverity.CRITICAL) + for issue in all_issues + ) + + report = QualityReport( + timestamp=datetime.now().isoformat(), + total_rows=len(df), + total_columns=len(df.columns), + date_range=date_range, + issues=all_issues, + metrics=metrics, + is_valid=is_valid + ) + + # Log summary + logger.info(f"Quality report generated:") + logger.info(f" Valid: {is_valid}") + logger.info(f" Issues: {len(all_issues)}") + + return report + + def _calculate_metrics(self, df: pd.DataFrame) -> Dict[str, Any]: + """Calculate summary metrics for the data.""" + metrics = { + 'row_count': len(df), + 'column_count': len(df.columns), + 'memory_mb': df.memory_usage(deep=True).sum() / 1024 / 1024, + 'columns': list(df.columns), + } + + # Numeric column statistics + numeric_cols = df.select_dtypes(include=[np.number]).columns + for col in numeric_cols[:10]: # Limit to first 10 + metrics[f'{col}_mean'] = float(df[col].mean()) if not df[col].isna().all() else None + metrics[f'{col}_std'] = float(df[col].std()) if not df[col].isna().all() else None + + # Time-based metrics if DatetimeIndex + if isinstance(df.index, pd.DatetimeIndex) and len(df) > 1: + time_diffs = df.index.to_series().diff().dropna() + metrics['avg_interval_minutes'] = time_diffs.mean().total_seconds() / 60 + metrics['trading_days'] = len(df.index.normalize().unique()) + + return metrics + + +def validate_trading_data( + df: pd.DataFrame, + symbol: str = 'unknown', + timeframe: str = '5min' +) -> QualityReport: + """ + Convenience function to validate trading data. + + Args: + df: DataFrame with OHLCV data + symbol: Symbol identifier + timeframe: Data timeframe + + Returns: + QualityReport with validation results + """ + validator = DataValidator(expected_frequency=timeframe) + return validator.generate_quality_report(df, symbol, timeframe) + + +if __name__ == "__main__": + # Test the validators + print("Testing DataValidator...") + + # Create sample data with some issues + np.random.seed(42) + n = 1000 + + dates = pd.date_range('2024-01-01', periods=n, freq='5min') + price = 2650 + np.cumsum(np.random.randn(n) * 0.5) + + df = pd.DataFrame({ + 'open': price, + 'high': price + np.abs(np.random.randn(n)) * 2, + 'low': price - np.abs(np.random.randn(n)) * 2, + 'close': price + np.random.randn(n) * 0.5, + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Introduce some issues + # Add gaps + df = df.drop(df.index[100:110]) # Create a gap + + # Add outliers + df.iloc[200, df.columns.get_loc('close')] = 5000 # Outlier + + # Add OHLC inconsistency + df.iloc[300, df.columns.get_loc('high')] = df.iloc[300]['low'] - 1 # High < Low + + # Add missing values + df.iloc[400:405, df.columns.get_loc('volume')] = np.nan + + # Test individual validators + validator = DataValidator(expected_frequency='5min') + + print("\n" + "=" * 60) + print("Testing Gap Detection") + print("=" * 60) + gaps = validator.validate_gaps(df) + for issue in gaps: + print(issue) + + print("\n" + "=" * 60) + print("Testing Outlier Detection") + print("=" * 60) + outliers = validator.validate_outliers(df, ['close', 'volume']) + for issue in outliers: + print(issue) + + print("\n" + "=" * 60) + print("Testing Consistency Validation") + print("=" * 60) + consistency = validator.validate_consistency(df) + for issue in consistency: + print(issue) + + print("\n" + "=" * 60) + print("Testing Missing Values") + print("=" * 60) + missing = validator.validate_missing_values(df) + for issue in missing: + print(issue) + + print("\n" + "=" * 60) + print("Full Quality Report") + print("=" * 60) + report = validator.generate_quality_report(df, 'XAUUSD', '5min') + print(report) + + print("\nAll Issues:") + for issue in report.issues: + print(f" {issue}") + + print("\nTest complete!") diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 0000000..b2a71b0 --- /dev/null +++ b/src/llm/__init__.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +LLM Integration Module +====================== + +Integration between ML predictions and LLM-based trading decisions. + +This module provides the bridge between the ML pipeline (metamodel, strategies) +and an LLM agent that makes trading decisions based on the ML signals. + +Components: +- prompts: Prompt templates for the trading decision agent +- signal_formatter: Formats ML predictions for LLM consumption +- decision_parser: Parses LLM responses into structured decisions +- signal_logger: Logs signals and results for feedback/fine-tuning +- llm_client: Client for LLM providers (Ollama, Claude) +- integration: Main orchestration class + +Usage: + from src.llm import MLLLMIntegration, create_integration + + # Create integration with default settings + integration = create_integration( + llm_provider="ollama", + llm_model="llama3:8b" + ) + + # Get trading decision + recommendation = integration.analyze_and_decide( + df=price_data, + symbol="XAUUSD", + account_info={"balance": 1000.0, ...} + ) + + if recommendation.action == "TRADE": + execute_trade(recommendation) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Prompts +from .prompts.trading_decision import ( + TradingDecisionPrompt, + SYSTEM_PROMPT, + EXPECTED_RESPONSE_FORMAT, + MetamodelOutput, + StrategyOutput, + MarketContext, + AccountInfo, +) + +# Signal Formatter +from .signal_formatter import ( + SignalFormatter, + FormattedSignal, + FormattedStrategy, +) + +# Decision Parser +from .decision_parser import ( + DecisionParser, + TradingDecision, + parse_llm_response, + is_valid_trade_decision, +) + +# Signal Logger +from .signal_logger import ( + LLMSignalLogger, + TradeResult, +) + +# LLM Client +from .llm_client import ( + LLMClient, + LLMClientConfig, + LLMProvider, + OllamaProvider, + ClaudeProvider, + create_client, + get_ollama_client, + get_claude_client, +) + +# Integration +from .integration import ( + MLLLMIntegration, + IntegrationConfig, + TradingRecommendation, + create_integration, +) + + +# ============================================================ +# MODULE EXPORTS +# ============================================================ + +__all__ = [ + # Main integration + 'MLLLMIntegration', + 'IntegrationConfig', + 'TradingRecommendation', + 'create_integration', + + # Prompts + 'TradingDecisionPrompt', + 'SYSTEM_PROMPT', + 'EXPECTED_RESPONSE_FORMAT', + 'MetamodelOutput', + 'StrategyOutput', + 'MarketContext', + 'AccountInfo', + + # Signal formatting + 'SignalFormatter', + 'FormattedSignal', + 'FormattedStrategy', + + # Decision parsing + 'DecisionParser', + 'TradingDecision', + 'parse_llm_response', + 'is_valid_trade_decision', + + # Logging + 'LLMSignalLogger', + 'TradeResult', + + # LLM clients + 'LLMClient', + 'LLMClientConfig', + 'LLMProvider', + 'OllamaProvider', + 'ClaudeProvider', + 'create_client', + 'get_ollama_client', + 'get_claude_client', +] + +__version__ = '1.0.0' diff --git a/src/llm/decision_parser.py b/src/llm/decision_parser.py new file mode 100644 index 0000000..2044b62 --- /dev/null +++ b/src/llm/decision_parser.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +""" +Decision Parser +=============== + +Parses LLM responses into structured trading decisions. +Extracts decision components (action, entry, stop loss, take profit) +from the LLM's JSON response and validates them. + +Key Components: +- TradingDecision: Dataclass representing a parsed decision +- DecisionParser: Parser with extraction and validation logic + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import re +import json +from typing import Dict, List, Optional, Any, Union, Tuple +from dataclasses import dataclass, field, asdict +from loguru import logger + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class TradingDecision: + """ + Structured trading decision extracted from LLM response. + + Represents the complete output of the LLM trading agent, + including the action to take and all trade parameters. + """ + # Core decision + action: str # "TRADE", "NO_TRADE", "WAIT" + direction: Optional[str] = None # "LONG", "SHORT", None + + # Trade parameters + entry: Optional[Union[float, str]] = None # Price or "MARKET" + stop_loss: Optional[float] = None + take_profit: List[float] = field(default_factory=list) + position_size: float = 1.0 # Percentage of account to risk + + # Reasoning + reasoning: str = "" + + # Raw response for debugging + raw_response: str = "" + + # Validation status + is_valid: bool = True + validation_errors: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to serializable dictionary""" + return asdict(self) + + def get_primary_take_profit(self) -> Optional[float]: + """Get the first (primary) take profit level""" + return self.take_profit[0] if self.take_profit else None + + def get_risk_reward_ratio(self) -> Optional[float]: + """ + Calculate the risk/reward ratio if entry, SL, and TP are available. + + Returns: + Risk/reward ratio or None if cannot be calculated + """ + if not all([self.entry, self.stop_loss, self.take_profit]): + return None + + entry = self.entry if isinstance(self.entry, (int, float)) else None + if entry is None: + return None + + tp = self.take_profit[0] + sl = self.stop_loss + + risk = abs(entry - sl) + reward = abs(tp - entry) + + if risk <= 0: + return None + + return reward / risk + + +# ============================================================ +# DECISION PARSER CLASS +# ============================================================ + +class DecisionParser: + """ + Parser for LLM trading decision responses. + + Extracts structured trading decisions from LLM responses, + handling various response formats and performing validation. + + Usage: + parser = DecisionParser() + + # Parse LLM response + decision = parser.parse_response(llm_response_text) + + # Check if valid + if decision.is_valid: + execute_trade(decision) + else: + logger.warning(f"Invalid decision: {decision.validation_errors}") + """ + + # Valid values for enum fields + VALID_ACTIONS = {"TRADE", "NO_TRADE", "WAIT"} + VALID_DIRECTIONS = {"LONG", "SHORT", None} + + # Validation rules + MIN_POSITION_SIZE = 0.1 # 0.1% + MAX_POSITION_SIZE = 5.0 # 5% + MIN_RISK_REWARD = 1.0 # Minimum acceptable R:R + + def __init__( + self, + strict_mode: bool = False, + min_risk_reward: float = 1.0, + max_position_size: float = 5.0 + ): + """ + Initialize the decision parser. + + Args: + strict_mode: If True, reject decisions with any validation warnings + min_risk_reward: Minimum acceptable risk/reward ratio + max_position_size: Maximum position size as percentage + """ + self.strict_mode = strict_mode + self.min_risk_reward = min_risk_reward + self.max_position_size = max_position_size + + def parse_response(self, llm_response: str) -> TradingDecision: + """ + Parse an LLM response into a TradingDecision. + + Handles various response formats: + - Pure JSON + - JSON wrapped in markdown code blocks + - JSON with surrounding text + + Args: + llm_response: Raw text response from the LLM + + Returns: + TradingDecision with extracted values (check is_valid) + """ + # Store raw response + raw = llm_response + + # Try to extract JSON from the response + json_str = self._extract_json(llm_response) + + if not json_str: + return TradingDecision( + action="NO_TRADE", + reasoning="Failed to parse LLM response - no valid JSON found", + raw_response=raw, + is_valid=False, + validation_errors=["No valid JSON found in response"] + ) + + try: + data = json.loads(json_str) + except json.JSONDecodeError as e: + return TradingDecision( + action="NO_TRADE", + reasoning=f"JSON parsing error: {e}", + raw_response=raw, + is_valid=False, + validation_errors=[f"JSON decode error: {str(e)}"] + ) + + # Extract fields + decision = self._extract_decision(data) + entry = self._extract_entry(data) + stop_loss = self._extract_stop_loss(data) + take_profit = self._extract_take_profit(data) + position_size = self._extract_position_size(data) + reasoning = self._extract_reasoning(data) + direction = self._extract_direction(data) + + # Build TradingDecision + trading_decision = TradingDecision( + action=decision, + direction=direction, + entry=entry, + stop_loss=stop_loss, + take_profit=take_profit, + position_size=position_size, + reasoning=reasoning, + raw_response=raw + ) + + # Validate + self._validate_decision(trading_decision) + + return trading_decision + + def _extract_json(self, text: str) -> Optional[str]: + """ + Extract JSON from text, handling various formats. + + Tries multiple extraction strategies: + 1. Pure JSON (entire text is JSON) + 2. JSON in markdown code block + 3. JSON object anywhere in text + + Args: + text: Text potentially containing JSON + + Returns: + Extracted JSON string or None + """ + text = text.strip() + + # Strategy 1: Pure JSON + if text.startswith("{") and text.endswith("}"): + return text + + # Strategy 2: Markdown code block + # Match ```json ... ``` or ``` ... ``` + code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```" + match = re.search(code_block_pattern, text) + if match: + return match.group(1).strip() + + # Strategy 3: Find JSON object anywhere + # Look for { ... } pattern + brace_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}" + match = re.search(brace_pattern, text) + if match: + return match.group(0) + + return None + + def _extract_decision(self, data: Dict[str, Any]) -> str: + """Extract and normalize the decision/action field""" + # Try multiple field names + for field_name in ["decision", "action", "trade_action"]: + if field_name in data: + value = str(data[field_name]).upper().strip() + # Normalize variations + if value in ("BUY", "SELL", "ENTER", "EXECUTE"): + return "TRADE" + if value in ("SKIP", "PASS", "REJECT", "NO"): + return "NO_TRADE" + if value in self.VALID_ACTIONS: + return value + + return "NO_TRADE" + + def _extract_direction(self, data: Dict[str, Any]) -> Optional[str]: + """Extract and normalize the direction field""" + for field_name in ["direction", "side", "trade_direction", "type"]: + if field_name in data and data[field_name] is not None: + value = str(data[field_name]).upper().strip() + # Normalize variations + if value in ("BUY", "BULLISH", "UP"): + return "LONG" + if value in ("SELL", "BEARISH", "DOWN"): + return "SHORT" + if value in ("LONG", "SHORT"): + return value + + return None + + def _extract_entry(self, data: Dict[str, Any]) -> Optional[Union[float, str]]: + """Extract the entry price or "MARKET" """ + for field_name in ["entry", "entry_price", "price", "entry_level"]: + if field_name in data: + value = data[field_name] + + if value is None: + continue + + # Check for market order + if isinstance(value, str): + if value.upper() in ("MARKET", "MKT", "NOW"): + return "MARKET" + try: + return float(value) + except ValueError: + continue + + if isinstance(value, (int, float)): + return float(value) + + return None + + def _extract_stop_loss(self, data: Dict[str, Any]) -> Optional[float]: + """Extract the stop loss price""" + for field_name in ["stop_loss", "sl", "stop", "stoploss"]: + if field_name in data and data[field_name] is not None: + try: + return float(data[field_name]) + except (ValueError, TypeError): + continue + + return None + + def _extract_take_profit(self, data: Dict[str, Any]) -> List[float]: + """Extract take profit level(s)""" + take_profits = [] + + # Try array field first + for field_name in ["take_profit", "tp", "take_profits", "targets"]: + if field_name in data: + value = data[field_name] + + if value is None: + continue + + # Handle list + if isinstance(value, list): + for tp in value: + try: + take_profits.append(float(tp)) + except (ValueError, TypeError): + continue + + # Handle single value + elif isinstance(value, (int, float)): + take_profits.append(float(value)) + + elif isinstance(value, str): + try: + take_profits.append(float(value)) + except ValueError: + pass + + # Also check individual TP fields + for i in range(1, 4): + for field_name in [f"take_profit_{i}", f"tp{i}", f"tp_{i}"]: + if field_name in data and data[field_name] is not None: + try: + take_profits.append(float(data[field_name])) + except (ValueError, TypeError): + continue + + return take_profits + + def _extract_position_size(self, data: Dict[str, Any]) -> float: + """Extract position size as percentage""" + for field_name in ["position_size", "size", "risk", "risk_percent", "risk_pct"]: + if field_name in data and data[field_name] is not None: + try: + value = float(data[field_name]) + # Clamp to valid range + return max(self.MIN_POSITION_SIZE, min(self.max_position_size, value)) + except (ValueError, TypeError): + continue + + return 1.0 # Default 1% + + def _extract_reasoning(self, data: Dict[str, Any]) -> str: + """Extract the reasoning/explanation""" + for field_name in ["reasoning", "reason", "explanation", "rationale", "analysis"]: + if field_name in data and data[field_name] is not None: + return str(data[field_name]) + + return "" + + def _validate_decision(self, decision: TradingDecision) -> None: + """ + Validate the parsed decision and populate validation errors. + + Performs sanity checks on the decision: + - Action is valid + - TRADE decisions have required fields + - Risk/reward is acceptable + - Position size is within limits + + Args: + decision: TradingDecision to validate (modified in place) + """ + errors = [] + + # Check action is valid + if decision.action not in self.VALID_ACTIONS: + errors.append(f"Invalid action: {decision.action}") + decision.action = "NO_TRADE" + + # For TRADE decisions, validate required fields + if decision.action == "TRADE": + # Direction is required + if decision.direction is None: + errors.append("TRADE decision missing direction") + + # Stop loss is required + if decision.stop_loss is None: + errors.append("TRADE decision missing stop_loss") + + # At least one take profit is recommended + if not decision.take_profit: + errors.append("TRADE decision missing take_profit (warning)") + + # Validate risk/reward if we have the data + rr = decision.get_risk_reward_ratio() + if rr is not None and rr < self.min_risk_reward: + errors.append(f"Risk/reward ratio {rr:.2f} below minimum {self.min_risk_reward}") + + # Validate entry/SL/TP relationship for LONG + if decision.direction == "LONG" and decision.entry and decision.stop_loss: + entry = decision.entry if isinstance(decision.entry, (int, float)) else None + if entry and decision.stop_loss >= entry: + errors.append("LONG: stop_loss should be below entry") + + # Validate entry/SL/TP relationship for SHORT + if decision.direction == "SHORT" and decision.entry and decision.stop_loss: + entry = decision.entry if isinstance(decision.entry, (int, float)) else None + if entry and decision.stop_loss <= entry: + errors.append("SHORT: stop_loss should be above entry") + + # Position size validation + if decision.position_size > self.max_position_size: + errors.append(f"Position size {decision.position_size}% exceeds max {self.max_position_size}%") + decision.position_size = self.max_position_size + + if decision.position_size < self.MIN_POSITION_SIZE: + errors.append(f"Position size {decision.position_size}% below minimum {self.MIN_POSITION_SIZE}%") + decision.position_size = self.MIN_POSITION_SIZE + + # Set validation status + decision.validation_errors = errors + + # In strict mode, any error invalidates the decision + if self.strict_mode: + decision.is_valid = len(errors) == 0 + else: + # In non-strict mode, only critical errors invalidate + critical_errors = [e for e in errors if "missing" in e.lower() and "warning" not in e.lower()] + decision.is_valid = len(critical_errors) == 0 + + def validate_decision(self, decision: TradingDecision) -> bool: + """ + Public method to validate a decision. + + Can be used to re-validate a decision after modifications. + + Args: + decision: TradingDecision to validate + + Returns: + True if decision is valid + """ + self._validate_decision(decision) + return decision.is_valid + + +# ============================================================ +# CONVENIENCE FUNCTIONS +# ============================================================ + +def parse_llm_response(response: str) -> TradingDecision: + """ + Convenience function to parse an LLM response. + + Args: + response: Raw LLM response text + + Returns: + Parsed TradingDecision + """ + parser = DecisionParser() + return parser.parse_response(response) + + +def is_valid_trade_decision(decision: TradingDecision) -> Tuple[bool, List[str]]: + """ + Check if a trade decision is valid for execution. + + Args: + decision: TradingDecision to check + + Returns: + Tuple of (is_valid, list of reasons if invalid) + """ + if not decision.is_valid: + return False, decision.validation_errors + + if decision.action != "TRADE": + return False, [f"Decision is {decision.action}, not TRADE"] + + if decision.direction is None: + return False, ["No direction specified"] + + if decision.stop_loss is None: + return False, ["No stop loss specified"] + + return True, [] + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'TradingDecision', + 'DecisionParser', + 'parse_llm_response', + 'is_valid_trade_decision', +] diff --git a/src/llm/integration.py b/src/llm/integration.py new file mode 100644 index 0000000..4aabc8d --- /dev/null +++ b/src/llm/integration.py @@ -0,0 +1,709 @@ +#!/usr/bin/env python3 +""" +ML-LLM Integration +================== + +Orchestrates the complete flow from ML prediction to trading decision: +1. Get predictions from metamodel +2. Get predictions from individual strategies +3. Format signals for LLM consumption +4. Query LLM for trading decision +5. Parse LLM response +6. Log everything for feedback and fine-tuning + +This is the main integration point between the ML pipeline and LLM agent. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import pandas as pd +import numpy as np +from datetime import datetime +from typing import Dict, List, Optional, Any, Tuple +from dataclasses import dataclass, field +from loguru import logger + +# Local imports +from .prompts.trading_decision import ( + TradingDecisionPrompt, + MetamodelOutput, + StrategyOutput, + MarketContext, + AccountInfo, + SYSTEM_PROMPT, +) +from .signal_formatter import SignalFormatter, FormattedSignal +from .decision_parser import DecisionParser, TradingDecision +from .signal_logger import LLMSignalLogger, TradeResult +from .llm_client import LLMClient, LLMClientConfig + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class IntegrationConfig: + """Configuration for ML-LLM integration""" + # LLM settings + llm_provider: str = "ollama" + llm_model: str = "llama3:8b" + llm_temperature: float = 0.3 + + # Decision thresholds + min_confidence: float = 0.60 # Minimum ML confidence to consider trading + min_risk_reward: float = 1.5 # Minimum acceptable R:R ratio + + # Risk settings + default_position_size: float = 1.0 # Default risk % per trade + max_position_size: float = 2.0 # Maximum risk % per trade + + # Logging + enable_logging: bool = True + + # Validation + strict_validation: bool = False + + +@dataclass +class TradingRecommendation: + """ + Complete trading recommendation with all context. + + This is the final output of the integration, containing: + - The ML predictions + - The LLM decision + - All context needed for execution + """ + # Core recommendation + action: str # "TRADE", "NO_TRADE", "WAIT" + direction: Optional[str] = None # "LONG", "SHORT" + + # Trade parameters (if action == "TRADE") + entry: Optional[float] = None + stop_loss: Optional[float] = None + take_profit: List[float] = field(default_factory=list) + position_size: float = 1.0 + + # ML context + ml_direction: str = "NEUTRAL" + ml_confidence: float = 0.0 + ml_magnitude: float = 0.0 + + # Strategy alignment + aligned_strategies: int = 0 + total_strategies: int = 0 + + # Reasoning + reasoning: str = "" + + # Metadata + symbol: str = "" + timestamp: str = "" + signal_id: Optional[str] = None + + # Quality indicators + is_high_confidence: bool = False + risk_reward_ratio: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return { + "action": self.action, + "direction": self.direction, + "entry": self.entry, + "stop_loss": self.stop_loss, + "take_profit": self.take_profit, + "position_size": self.position_size, + "ml_context": { + "direction": self.ml_direction, + "confidence": self.ml_confidence, + "magnitude": self.ml_magnitude, + }, + "strategy_alignment": { + "aligned": self.aligned_strategies, + "total": self.total_strategies, + }, + "reasoning": self.reasoning, + "metadata": { + "symbol": self.symbol, + "timestamp": self.timestamp, + "signal_id": self.signal_id, + }, + "quality": { + "is_high_confidence": self.is_high_confidence, + "risk_reward_ratio": self.risk_reward_ratio, + } + } + + +# ============================================================ +# ML-LLM INTEGRATION CLASS +# ============================================================ + +class MLLLMIntegration: + """ + Main integration class between ML pipeline and LLM agent. + + Orchestrates the complete flow: + 1. ML Predict: Get predictions from metamodel and strategies + 2. Format Signal: Convert to LLM-friendly format + 3. LLM Decide: Query LLM for trading decision + 4. Parse Response: Extract structured decision + 5. Log: Save for feedback and fine-tuning + + Usage: + integration = MLLLMIntegration(metamodel=my_metamodel) + + # Full flow with LLM decision + recommendation = integration.analyze_and_decide( + df=price_data, + symbol="XAUUSD", + account_info=account + ) + + # Just get recommendation without LLM (ML-only) + recommendation = integration.get_recommendation( + df=price_data, + symbol="XAUUSD" + ) + """ + + def __init__( + self, + metamodel: Optional[Any] = None, + strategy_models: Optional[Dict[str, Any]] = None, + llm_client: Optional[LLMClient] = None, + signal_logger: Optional[LLMSignalLogger] = None, + config: Optional[IntegrationConfig] = None + ): + """ + Initialize the ML-LLM integration. + + Args: + metamodel: Trained AssetMetamodel or similar + strategy_models: Dict of strategy name -> model (optional) + llm_client: Pre-configured LLM client (creates default if None) + signal_logger: Pre-configured logger (creates default if None) + config: Integration configuration + """ + self.config = config or IntegrationConfig() + self.metamodel = metamodel + self.strategy_models = strategy_models or {} + + # Initialize components + self.signal_formatter = SignalFormatter() + self.decision_parser = DecisionParser( + strict_mode=self.config.strict_validation, + min_risk_reward=self.config.min_risk_reward, + max_position_size=self.config.max_position_size + ) + self.prompt_builder = TradingDecisionPrompt() + + # LLM client + if llm_client: + self.llm_client = llm_client + else: + self.llm_client = LLMClient( + provider=self.config.llm_provider, + model=self.config.llm_model, + enable_fallback=True + ) + + # Signal logger + if signal_logger: + self.signal_logger = signal_logger + elif self.config.enable_logging: + try: + self.signal_logger = LLMSignalLogger() + except Exception as e: + logger.warning(f"Failed to initialize signal logger: {e}") + self.signal_logger = None + else: + self.signal_logger = None + + logger.info("MLLLMIntegration initialized") + + def analyze_and_decide( + self, + df: pd.DataFrame, + symbol: str, + account_info: Optional[Dict[str, Any]] = None, + strategy_predictions: Optional[List[Dict[str, Any]]] = None, + current_price: Optional[float] = None, + atr: Optional[float] = None + ) -> TradingRecommendation: + """ + Complete flow: ML predict -> format -> LLM decide -> parse -> log. + + This is the main entry point for getting a trading decision. + + Args: + df: OHLCV DataFrame with price data + symbol: Trading symbol (e.g., "XAUUSD") + account_info: Account/risk information (optional) + strategy_predictions: Pre-computed strategy predictions (optional) + current_price: Current price (uses last close if None) + atr: ATR value (calculates if None) + + Returns: + TradingRecommendation with complete decision context + """ + timestamp = datetime.utcnow().isoformat() + + # Get current price and ATR if not provided + if current_price is None: + current_price = float(df["close"].iloc[-1]) + + if atr is None: + atr = self._calculate_atr(df) + + # Step 1: Get ML predictions + ml_prediction = self._get_ml_prediction(df, symbol) + + if ml_prediction is None: + logger.warning("Failed to get ML prediction") + return TradingRecommendation( + action="NO_TRADE", + reasoning="ML prediction unavailable", + symbol=symbol, + timestamp=timestamp + ) + + # Step 2: Get strategy predictions (if not provided) + if strategy_predictions is None: + strategy_predictions = self._get_strategy_predictions(df, symbol) + + # Step 3: Format signal for LLM + formatted_signal = self.signal_formatter.format_for_llm( + metamodel_prediction=ml_prediction, + strategy_predictions=strategy_predictions, + market_context={ + "current_price": current_price, + "atr": atr, + "market_phase": ml_prediction.get("market_phase", "unknown"), + "timestamp": timestamp + }, + symbol=symbol + ) + + # Add risk context if provided + if account_info: + self.signal_formatter.add_risk_context(formatted_signal, account_info) + + # Check minimum confidence threshold + if formatted_signal.confidence_value < self.config.min_confidence: + logger.info(f"ML confidence {formatted_signal.confidence_value:.1%} below threshold") + return TradingRecommendation( + action="NO_TRADE", + reasoning=f"ML confidence ({formatted_signal.confidence_value:.1%}) below minimum threshold ({self.config.min_confidence:.1%})", + ml_direction=formatted_signal.direction, + ml_confidence=formatted_signal.confidence_value, + symbol=symbol, + timestamp=timestamp + ) + + # Step 4: Build prompt and query LLM + prompt = self._build_prompt( + symbol=symbol, + formatted_signal=formatted_signal, + account_info=account_info or self._default_account_info() + ) + + try: + llm_response = self.llm_client.get_trading_decision( + prompt=prompt, + temperature=self.config.llm_temperature + ) + except Exception as e: + logger.error(f"LLM query failed: {e}") + return TradingRecommendation( + action="NO_TRADE", + reasoning=f"LLM query failed: {e}", + ml_direction=formatted_signal.direction, + ml_confidence=formatted_signal.confidence_value, + symbol=symbol, + timestamp=timestamp + ) + + # Step 5: Parse LLM response + decision = self.decision_parser.parse_response(llm_response) + + # Step 6: Log signal and decision + signal_id = None + if self.signal_logger: + try: + signal_id = self.signal_logger.log_signal( + signal=formatted_signal, + decision=decision, + llm_prompt=prompt, + llm_response=llm_response + ) + except Exception as e: + logger.warning(f"Failed to log signal: {e}") + + # Step 7: Build recommendation + recommendation = self._build_recommendation( + decision=decision, + formatted_signal=formatted_signal, + symbol=symbol, + timestamp=timestamp, + signal_id=signal_id, + strategy_predictions=strategy_predictions + ) + + return recommendation + + def get_recommendation( + self, + df: pd.DataFrame, + symbol: str, + current_price: Optional[float] = None, + atr: Optional[float] = None + ) -> TradingRecommendation: + """ + Get ML-only recommendation without LLM decision. + + Useful for quick analysis or when LLM is unavailable. + + Args: + df: OHLCV DataFrame + symbol: Trading symbol + current_price: Current price (optional) + atr: ATR value (optional) + + Returns: + TradingRecommendation based on ML signals only + """ + timestamp = datetime.utcnow().isoformat() + + if current_price is None: + current_price = float(df["close"].iloc[-1]) + + if atr is None: + atr = self._calculate_atr(df) + + # Get ML prediction + ml_prediction = self._get_ml_prediction(df, symbol) + + if ml_prediction is None: + return TradingRecommendation( + action="NO_TRADE", + reasoning="ML prediction unavailable", + symbol=symbol, + timestamp=timestamp + ) + + # Format signal + formatted_signal = self.signal_formatter.format_for_llm( + metamodel_prediction=ml_prediction, + strategy_predictions=[], + market_context={ + "current_price": current_price, + "atr": atr, + "timestamp": timestamp + }, + symbol=symbol + ) + + # Simple ML-based decision + if formatted_signal.confidence_value >= self.config.min_confidence: + if formatted_signal.direction == "BULLISH": + action = "TRADE" + direction = "LONG" + entry = current_price + stop_loss = current_price - (atr * 1.5) + take_profit = [current_price + (atr * 2)] + elif formatted_signal.direction == "BEARISH": + action = "TRADE" + direction = "SHORT" + entry = current_price + stop_loss = current_price + (atr * 1.5) + take_profit = [current_price - (atr * 2)] + else: + action = "NO_TRADE" + direction = None + entry = None + stop_loss = None + take_profit = [] + else: + action = "NO_TRADE" + direction = None + entry = None + stop_loss = None + take_profit = [] + + return TradingRecommendation( + action=action, + direction=direction, + entry=entry, + stop_loss=stop_loss, + take_profit=take_profit, + position_size=self.config.default_position_size, + ml_direction=formatted_signal.direction, + ml_confidence=formatted_signal.confidence_value, + ml_magnitude=formatted_signal.magnitude_value, + reasoning=f"ML-only decision: {formatted_signal.direction} with {formatted_signal.confidence_value:.1%} confidence", + symbol=symbol, + timestamp=timestamp, + is_high_confidence=formatted_signal.confidence_value >= 0.7 + ) + + def log_trade_result( + self, + signal_id: str, + result: TradeResult + ) -> bool: + """ + Log the result of a trade for feedback. + + Args: + signal_id: The signal_id from the recommendation + result: TradeResult with outcome data + + Returns: + True if logged successfully + """ + if self.signal_logger: + return self.signal_logger.log_trade_result(signal_id, result) + return False + + def _get_ml_prediction( + self, + df: pd.DataFrame, + symbol: str + ) -> Optional[Dict[str, Any]]: + """Get prediction from metamodel""" + if self.metamodel is None: + # Return mock prediction for testing + logger.warning("No metamodel configured, using mock prediction") + return { + "delta_high_final": 0.5, + "delta_low_final": 0.3, + "confidence": 1, + "confidence_proba": 0.65, + "strategy_weights": {} + } + + try: + # This assumes metamodel has a predict method + # Adjust based on actual metamodel interface + prediction = self.metamodel.predict(df) + + if hasattr(prediction, "to_dict"): + return prediction.to_dict() + elif hasattr(prediction, "delta_high_final"): + return { + "delta_high_final": prediction.delta_high_final, + "delta_low_final": prediction.delta_low_final, + "confidence": prediction.confidence, + "confidence_proba": prediction.confidence_proba + } + else: + return dict(prediction) + + except Exception as e: + logger.error(f"Metamodel prediction failed: {e}") + return None + + def _get_strategy_predictions( + self, + df: pd.DataFrame, + symbol: str + ) -> List[Dict[str, Any]]: + """Get predictions from individual strategies""" + predictions = [] + + # Default strategy names if no models configured + default_strategies = ["PVA", "MRD", "VBP", "MTS", "MSA"] + + if not self.strategy_models: + # Return placeholder predictions + for name in default_strategies: + predictions.append({ + "name": name, + "direction": 0, + "confidence": 0.5, + "weight": 1.0 / len(default_strategies) + }) + return predictions + + for name, model in self.strategy_models.items(): + try: + result = model.predict(df) + predictions.append({ + "name": name, + "direction": getattr(result, "direction", 0), + "confidence": getattr(result, "confidence", 0.5), + "weight": getattr(result, "weight", 1.0 / len(self.strategy_models)) + }) + except Exception as e: + logger.warning(f"Strategy {name} prediction failed: {e}") + + return predictions + + def _calculate_atr(self, df: pd.DataFrame, period: int = 14) -> float: + """Calculate ATR from DataFrame""" + if len(df) < period: + return 0.0 + + high = df["high"] + low = df["low"] + close = df["close"] + + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + atr = tr.rolling(window=period).mean().iloc[-1] + + return float(atr) if not pd.isna(atr) else 0.0 + + def _build_prompt( + self, + symbol: str, + formatted_signal: FormattedSignal, + account_info: Dict[str, Any] + ) -> str: + """Build the complete prompt for the LLM""" + # Convert formatted signal to prompt builder format + metamodel_output = MetamodelOutput( + direction=formatted_signal.direction, + magnitude=formatted_signal.magnitude_value, + confidence=formatted_signal.confidence_value, + strategy_weights=formatted_signal.strategy_weights, + delta_high=formatted_signal.delta_high, + delta_low=formatted_signal.delta_low + ) + + strategy_outputs = [ + StrategyOutput( + name=s.name, + direction=s.direction, + confidence=s.confidence_value, + weight=s.weight + ) + for s in formatted_signal.strategies + ] + + market_context = MarketContext( + current_price=formatted_signal.current_price, + atr=formatted_signal.atr, + volatility_regime=formatted_signal.volatility_regime, + market_phase=formatted_signal.market_phase, + trading_session=formatted_signal.trading_session, + timestamp=formatted_signal.timestamp, + timeframe=formatted_signal.timeframe + ) + + account = AccountInfo( + balance=account_info.get("balance", 1000.0), + available_margin=account_info.get("available_margin", 1000.0), + current_drawdown=account_info.get("current_drawdown", 0.0), + open_positions=account_info.get("open_positions", 0), + max_risk_per_trade=account_info.get("max_risk_per_trade", 2.0), + daily_pnl=account_info.get("daily_pnl", 0.0), + daily_pnl_pct=account_info.get("daily_pnl_pct", 0.0) + ) + + return self.prompt_builder.build_full_prompt( + symbol=symbol, + metamodel_output=metamodel_output, + strategy_outputs=strategy_outputs, + market_context=market_context, + account_info=account + ) + + def _build_recommendation( + self, + decision: TradingDecision, + formatted_signal: FormattedSignal, + symbol: str, + timestamp: str, + signal_id: Optional[str], + strategy_predictions: List[Dict[str, Any]] + ) -> TradingRecommendation: + """Build the final recommendation from all components""" + # Count aligned strategies + aligned = sum( + 1 for s in formatted_signal.strategies + if s.direction == formatted_signal.direction and s.confidence_value > 0.5 + ) + + return TradingRecommendation( + action=decision.action, + direction=decision.direction, + entry=decision.entry if isinstance(decision.entry, (int, float)) else None, + stop_loss=decision.stop_loss, + take_profit=decision.take_profit, + position_size=decision.position_size, + ml_direction=formatted_signal.direction, + ml_confidence=formatted_signal.confidence_value, + ml_magnitude=formatted_signal.magnitude_value, + aligned_strategies=aligned, + total_strategies=len(formatted_signal.strategies), + reasoning=decision.reasoning, + symbol=symbol, + timestamp=timestamp, + signal_id=signal_id, + is_high_confidence=formatted_signal.confidence_value >= 0.7, + risk_reward_ratio=decision.get_risk_reward_ratio() + ) + + def _default_account_info(self) -> Dict[str, Any]: + """Return default account info""" + return { + "balance": 1000.0, + "available_margin": 1000.0, + "current_drawdown": 0.0, + "open_positions": 0, + "max_risk_per_trade": 2.0, + "daily_pnl": 0.0, + "daily_pnl_pct": 0.0 + } + + +# ============================================================ +# CONVENIENCE FUNCTIONS +# ============================================================ + +def create_integration( + llm_provider: str = "ollama", + llm_model: str = "llama3:8b", + metamodel: Optional[Any] = None +) -> MLLLMIntegration: + """ + Create a configured ML-LLM integration instance. + + Args: + llm_provider: LLM provider ("ollama" or "claude") + llm_model: Model name + metamodel: Trained metamodel (optional) + + Returns: + Configured MLLLMIntegration instance + """ + config = IntegrationConfig( + llm_provider=llm_provider, + llm_model=llm_model + ) + + return MLLLMIntegration( + metamodel=metamodel, + config=config + ) + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'MLLLMIntegration', + 'IntegrationConfig', + 'TradingRecommendation', + 'create_integration', +] diff --git a/src/llm/llm_client.py b/src/llm/llm_client.py new file mode 100644 index 0000000..72790f4 --- /dev/null +++ b/src/llm/llm_client.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +""" +LLM Client +========== + +Client for interacting with LLM providers (Ollama, Claude API). +Handles request formatting, retry logic, timeout handling, and fallback. + +Supported Providers: +- Ollama (local): Default, uses llama3:8b model +- Claude API: Fallback when Ollama fails + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import os +import time +import json +from typing import Optional, Dict, Any, Callable +from dataclasses import dataclass +from abc import ABC, abstractmethod +from loguru import logger + +# HTTP client - try httpx first, fall back to requests +try: + import httpx + HAS_HTTPX = True +except ImportError: + HAS_HTTPX = False + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +if not HAS_HTTPX and not HAS_REQUESTS: + logger.error("Neither httpx nor requests available - LLM client will not work") + + +# ============================================================ +# CONFIGURATION +# ============================================================ + +@dataclass +class LLMClientConfig: + """Configuration for LLM client""" + # Ollama settings + ollama_host: str = "http://localhost:11434" + ollama_model: str = "llama3:8b" + ollama_timeout: int = 120 # seconds + + # Claude API settings + claude_api_key: Optional[str] = None + claude_model: str = "claude-3-haiku-20240307" + claude_timeout: int = 60 # seconds + + # Retry settings + max_retries: int = 3 + retry_delay: float = 1.0 # seconds + retry_backoff: float = 2.0 # multiplier + + # Fallback + enable_fallback: bool = True + + +# ============================================================ +# ABSTRACT BASE CLASS +# ============================================================ + +class LLMProvider(ABC): + """Abstract base class for LLM providers""" + + @abstractmethod + def generate( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7 + ) -> str: + """Generate a response from the LLM""" + pass + + @abstractmethod + def is_available(self) -> bool: + """Check if the provider is available""" + pass + + +# ============================================================ +# OLLAMA PROVIDER +# ============================================================ + +class OllamaProvider(LLMProvider): + """ + Ollama LLM provider for local inference. + + Uses the Ollama API to run local LLMs like Llama 3. + + Requirements: + - Ollama running locally (ollama serve) + - Model pulled (ollama pull llama3:8b) + """ + + def __init__( + self, + host: str = "http://localhost:11434", + model: str = "llama3:8b", + timeout: int = 120 + ): + self.host = host.rstrip("/") + self.model = model + self.timeout = timeout + self._available = None # Cache availability check + + def is_available(self) -> bool: + """Check if Ollama is running and model is available""" + if self._available is not None: + return self._available + + try: + url = f"{self.host}/api/tags" + + if HAS_HTTPX: + with httpx.Client(timeout=5) as client: + response = client.get(url) + elif HAS_REQUESTS: + response = requests.get(url, timeout=5) + else: + return False + + if response.status_code == 200: + data = response.json() + models = [m.get("name", "") for m in data.get("models", [])] + # Check if our model (or a variant) is available + model_base = self.model.split(":")[0] + self._available = any(model_base in m for m in models) + if self._available: + logger.info(f"Ollama available with model {self.model}") + else: + logger.warning(f"Ollama running but model {self.model} not found") + return self._available + + except Exception as e: + logger.debug(f"Ollama not available: {e}") + + self._available = False + return False + + def generate( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7 + ) -> str: + """ + Generate a response using Ollama. + + Args: + prompt: User prompt + system_prompt: System prompt (optional) + temperature: Sampling temperature + + Returns: + Generated response text + + Raises: + RuntimeError: If generation fails + """ + url = f"{self.host}/api/generate" + + payload = { + "model": self.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": temperature, + "num_predict": 1024, # Max tokens + } + } + + if system_prompt: + payload["system"] = system_prompt + + try: + if HAS_HTTPX: + with httpx.Client(timeout=self.timeout) as client: + response = client.post(url, json=payload) + elif HAS_REQUESTS: + response = requests.post(url, json=payload, timeout=self.timeout) + else: + raise RuntimeError("No HTTP client available") + + if response.status_code != 200: + raise RuntimeError(f"Ollama error: {response.status_code} - {response.text}") + + data = response.json() + return data.get("response", "") + + except Exception as e: + raise RuntimeError(f"Ollama generation failed: {e}") + + +# ============================================================ +# CLAUDE PROVIDER +# ============================================================ + +class ClaudeProvider(LLMProvider): + """ + Claude API provider for cloud inference. + + Uses the Anthropic Claude API. + + Requirements: + - ANTHROPIC_API_KEY environment variable + """ + + API_URL = "https://api.anthropic.com/v1/messages" + + def __init__( + self, + api_key: Optional[str] = None, + model: str = "claude-3-haiku-20240307", + timeout: int = 60 + ): + self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + self.model = model + self.timeout = timeout + + def is_available(self) -> bool: + """Check if Claude API key is configured""" + return bool(self.api_key) + + def generate( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.7 + ) -> str: + """ + Generate a response using Claude API. + + Args: + prompt: User prompt + system_prompt: System prompt (optional) + temperature: Sampling temperature + + Returns: + Generated response text + + Raises: + RuntimeError: If generation fails + """ + if not self.api_key: + raise RuntimeError("Claude API key not configured") + + headers = { + "Content-Type": "application/json", + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01" + } + + payload = { + "model": self.model, + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": temperature + } + + if system_prompt: + payload["system"] = system_prompt + + try: + if HAS_HTTPX: + with httpx.Client(timeout=self.timeout) as client: + response = client.post(self.API_URL, headers=headers, json=payload) + elif HAS_REQUESTS: + response = requests.post( + self.API_URL, + headers=headers, + json=payload, + timeout=self.timeout + ) + else: + raise RuntimeError("No HTTP client available") + + if response.status_code != 200: + raise RuntimeError(f"Claude API error: {response.status_code} - {response.text}") + + data = response.json() + content = data.get("content", []) + + if content and len(content) > 0: + return content[0].get("text", "") + + return "" + + except Exception as e: + raise RuntimeError(f"Claude generation failed: {e}") + + +# ============================================================ +# LLM CLIENT (MAIN CLASS) +# ============================================================ + +class LLMClient: + """ + Main LLM client with provider abstraction and fallback. + + Usage: + client = LLMClient(provider="ollama", model="llama3:8b") + + response = client.get_trading_decision(prompt) + + # Or with automatic fallback + client = LLMClient(enable_fallback=True) + response = client.get_trading_decision(prompt) + """ + + def __init__( + self, + provider: str = "ollama", + model: Optional[str] = None, + config: Optional[LLMClientConfig] = None, + enable_fallback: bool = True + ): + """ + Initialize the LLM client. + + Args: + provider: Primary provider ("ollama" or "claude") + model: Model to use (provider-specific default if None) + config: Full configuration object (optional) + enable_fallback: Enable fallback to other providers + """ + self.config = config or LLMClientConfig() + self.enable_fallback = enable_fallback + + # Initialize providers + self.providers: Dict[str, LLMProvider] = {} + + # Set up Ollama + ollama_model = model if provider == "ollama" and model else self.config.ollama_model + self.providers["ollama"] = OllamaProvider( + host=self.config.ollama_host, + model=ollama_model, + timeout=self.config.ollama_timeout + ) + + # Set up Claude + claude_model = model if provider == "claude" and model else self.config.claude_model + self.providers["claude"] = ClaudeProvider( + api_key=self.config.claude_api_key, + model=claude_model, + timeout=self.config.claude_timeout + ) + + # Set primary provider + self.primary_provider = provider + + # Determine fallback order + if provider == "ollama": + self.fallback_order = ["ollama", "claude"] + else: + self.fallback_order = ["claude", "ollama"] + + logger.info(f"LLM client initialized with primary provider: {provider}") + + def _get_available_provider(self) -> Optional[LLMProvider]: + """Get the first available provider""" + for provider_name in self.fallback_order: + provider = self.providers.get(provider_name) + if provider and provider.is_available(): + return provider + + return None + + def _generate_with_retry( + self, + provider: LLMProvider, + prompt: str, + system_prompt: Optional[str], + temperature: float + ) -> str: + """Generate with retry logic""" + last_error = None + delay = self.config.retry_delay + + for attempt in range(self.config.max_retries): + try: + return provider.generate(prompt, system_prompt, temperature) + except Exception as e: + last_error = e + logger.warning(f"Attempt {attempt + 1} failed: {e}") + + if attempt < self.config.max_retries - 1: + time.sleep(delay) + delay *= self.config.retry_backoff + + raise RuntimeError(f"All retry attempts failed: {last_error}") + + def get_trading_decision( + self, + prompt: str, + system_prompt: Optional[str] = None, + temperature: float = 0.3 # Lower for more consistent trading decisions + ) -> str: + """ + Get a trading decision from the LLM. + + Args: + prompt: The trading analysis prompt + system_prompt: System prompt (optional, uses default if None) + temperature: Sampling temperature (default 0.3 for consistency) + + Returns: + Raw LLM response text + + Raises: + RuntimeError: If all providers fail + """ + # Import default system prompt if needed + if system_prompt is None: + from .prompts.trading_decision import SYSTEM_PROMPT + system_prompt = SYSTEM_PROMPT + + providers_to_try = self.fallback_order if self.enable_fallback else [self.primary_provider] + + for provider_name in providers_to_try: + provider = self.providers.get(provider_name) + + if not provider or not provider.is_available(): + logger.debug(f"Provider {provider_name} not available, skipping") + continue + + try: + logger.info(f"Generating with {provider_name}...") + response = self._generate_with_retry( + provider, prompt, system_prompt, temperature + ) + + if response: + logger.debug(f"Got response from {provider_name}") + return response + + except Exception as e: + logger.warning(f"Provider {provider_name} failed: {e}") + + if not self.enable_fallback: + raise + + raise RuntimeError("All LLM providers failed") + + def check_availability(self) -> Dict[str, bool]: + """ + Check availability of all providers. + + Returns: + Dict mapping provider names to availability status + """ + return { + name: provider.is_available() + for name, provider in self.providers.items() + } + + def test_connection(self) -> bool: + """ + Test connection to the primary provider. + + Returns: + True if connection successful + """ + provider = self.providers.get(self.primary_provider) + + if not provider: + return False + + try: + if not provider.is_available(): + return False + + # Try a simple generation + response = provider.generate( + "Say 'OK' if you're working.", + temperature=0.0 + ) + + return bool(response) + + except Exception as e: + logger.error(f"Connection test failed: {e}") + return False + + +# ============================================================ +# CONVENIENCE FUNCTIONS +# ============================================================ + +def create_client( + provider: str = "ollama", + model: Optional[str] = None +) -> LLMClient: + """ + Create an LLM client with default settings. + + Args: + provider: Provider name ("ollama" or "claude") + model: Model name (optional) + + Returns: + Configured LLMClient + """ + return LLMClient(provider=provider, model=model) + + +def get_ollama_client(model: str = "llama3:8b") -> LLMClient: + """Create an Ollama client""" + return LLMClient(provider="ollama", model=model) + + +def get_claude_client(model: str = "claude-3-haiku-20240307") -> LLMClient: + """Create a Claude client""" + return LLMClient(provider="claude", model=model) + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'LLMClient', + 'LLMClientConfig', + 'LLMProvider', + 'OllamaProvider', + 'ClaudeProvider', + 'create_client', + 'get_ollama_client', + 'get_claude_client', +] diff --git a/src/llm/prompts/__init__.py b/src/llm/prompts/__init__.py new file mode 100644 index 0000000..9ada5c3 --- /dev/null +++ b/src/llm/prompts/__init__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" +LLM Prompts Module +================== + +Prompt templates for the LLM trading decision agent. + +Exports: +- TradingDecisionPrompt: Main prompt builder for trading decisions +- SYSTEM_PROMPT: Core system prompt for the trading agent +""" + +from .trading_decision import ( + TradingDecisionPrompt, + SYSTEM_PROMPT, + EXPECTED_RESPONSE_FORMAT, +) + +__all__ = [ + 'TradingDecisionPrompt', + 'SYSTEM_PROMPT', + 'EXPECTED_RESPONSE_FORMAT', +] + +__version__ = '1.0.0' diff --git a/src/llm/prompts/trading_decision.py b/src/llm/prompts/trading_decision.py new file mode 100644 index 0000000..84b3686 --- /dev/null +++ b/src/llm/prompts/trading_decision.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +""" +Trading Decision Prompts +======================== + +Prompt templates for the LLM trading decision agent. +Formats ML predictions and market context into structured prompts +that guide the LLM to make informed trading decisions. + +Key Components: +- SYSTEM_PROMPT: Core identity and rules for the trading agent +- TradingDecisionPrompt: Builder class for constructing complete prompts + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from typing import Dict, List, Optional, Any +from dataclasses import dataclass + + +# ============================================================ +# SYSTEM PROMPT - Core Identity for Trading Agent +# ============================================================ + +SYSTEM_PROMPT = """You are an expert algorithmic trading agent specializing in ML-assisted decision making. + +## Your Role +You analyze predictions from an ML ensemble system and decide whether to execute trades. +The ML system provides: +- Directional predictions (BULLISH/BEARISH/NEUTRAL) +- Magnitude predictions (expected price movement %) +- Confidence scores from multiple strategies +- Strategy weights learned from historical performance + +## Risk Management Rules (MANDATORY) +1. Maximum 2% risk per trade ($20 on $1,000 account) +2. Maximum 2 simultaneous positions +3. Stop loss is ALWAYS required +4. Minimum risk:reward ratio of 1.5:1 +5. Maximum drawdown allowed: 15% +6. Never trade against strong consensus (3+ strategies agreeing) + +## Decision Framework +For each signal, evaluate: +1. ML Confidence: Is ensemble confidence above threshold (60%)? +2. Strategy Alignment: Do multiple strategies agree on direction? +3. Risk/Reward: Does the predicted magnitude justify the stop distance? +4. Market Context: Is the current volatility suitable for trading? +5. Account Status: Can we afford this risk given current drawdown? + +## Output Format +You MUST respond with a JSON object containing your decision. +Do not include any text outside the JSON block. + +```json +{ + "decision": "TRADE" | "NO_TRADE" | "WAIT", + "direction": "LONG" | "SHORT" | null, + "entry": , + "stop_loss": , + "take_profit": [, ], + "position_size": , + "reasoning": "" +} +``` + +## Decision Types +- TRADE: Execute the trade with specified parameters +- NO_TRADE: Do not trade - conditions are unfavorable +- WAIT: Conditions are promising but need confirmation + +Be concise, precise, and always prioritize capital preservation.""" + + +# ============================================================ +# EXPECTED RESPONSE FORMAT +# ============================================================ + +EXPECTED_RESPONSE_FORMAT = """{ + "decision": "TRADE" | "NO_TRADE" | "WAIT", + "direction": "LONG" | "SHORT" | null, + "entry": , + "stop_loss": , + "take_profit": [, ], + "position_size": , + "reasoning": "" +}""" + + +# ============================================================ +# PROMPT TEMPLATES +# ============================================================ + +PREDICTION_SUMMARY_TEMPLATE = """## ML Ensemble Prediction for {symbol} +**Timestamp:** {timestamp} +**Timeframe:** {timeframe} + +### Ensemble Output +- **Direction:** {direction} (net score: {net_score:+.2f}) +- **Predicted Move:** {magnitude} +- **Ensemble Confidence:** {confidence:.1%} +- **Strategies Aligned:** {aligned_count}/{total_strategies} +""" + +STRATEGY_TABLE_TEMPLATE = """### Individual Strategy Signals +| Strategy | Direction | Confidence | Weight | +|----------|-----------|------------|--------| +{rows} +""" + +MARKET_CONTEXT_TEMPLATE = """### Market Context +- **Current Price:** {current_price} +- **ATR (14):** {atr} ({atr_pct:.2%} of price) +- **Volatility Regime:** {volatility_regime} +- **Market Phase:** {market_phase} +- **Session:** {trading_session} +""" + +RISK_PARAMETERS_TEMPLATE = """### Account & Risk Parameters +- **Account Balance:** ${balance:.2f} +- **Available Margin:** ${available_margin:.2f} +- **Current Drawdown:** {drawdown:.1%} +- **Open Positions:** {open_positions} +- **Max Risk Per Trade:** {max_risk_pct:.1%} (${max_risk_usd:.2f}) +- **Today's P&L:** ${daily_pnl:+.2f} ({daily_pnl_pct:+.1%}) +""" + +DECISION_TASK_TEMPLATE = """### Your Task +Based on the ML predictions and market context above, decide whether to: +1. **TRADE** - Enter a position with your recommended parameters +2. **NO_TRADE** - Skip this signal (explain why) +3. **WAIT** - Signal is promising but needs confirmation + +If you decide to TRADE, provide: +- Entry price (or "MARKET" for market order) +- Stop loss level +- Take profit target(s) +- Position size as % of account to risk + +Remember: Capital preservation is paramount. When in doubt, NO_TRADE. +""" + + +# ============================================================ +# TRADING DECISION PROMPT BUILDER +# ============================================================ + +@dataclass +class MetamodelOutput: + """Output from the metamodel""" + direction: str # "BULLISH", "BEARISH", "NEUTRAL" + magnitude: float # Expected move in percentage + confidence: float # 0-1 confidence score + strategy_weights: Dict[str, float] # Strategy name -> weight + delta_high: float # Predicted high delta + delta_low: float # Predicted low delta + + +@dataclass +class StrategyOutput: + """Output from an individual strategy""" + name: str + direction: str # "BULLISH", "BEARISH", "NEUTRAL" + confidence: float # 0-1 + weight: float # Current weight from metamodel + details: Optional[Dict[str, Any]] = None + + +@dataclass +class MarketContext: + """Current market context""" + current_price: float + atr: float + volatility_regime: str # "low", "medium", "high" + market_phase: str # "accumulation", "distribution", "manipulation", "trend" + trading_session: str # "asian", "london", "new_york", "overlap" + timestamp: str + timeframe: str + + +@dataclass +class AccountInfo: + """Account and risk information""" + balance: float + available_margin: float + current_drawdown: float + open_positions: int + max_risk_per_trade: float # Percentage + daily_pnl: float + daily_pnl_pct: float + + +class TradingDecisionPrompt: + """ + Builder class for constructing trading decision prompts. + + Formats ML predictions and market context into a structured prompt + that guides the LLM to make informed trading decisions. + + Usage: + prompt_builder = TradingDecisionPrompt() + full_prompt = prompt_builder.build_full_prompt( + symbol="XAUUSD", + metamodel_output=metamodel_result, + strategy_outputs=strategy_results, + market_context=context, + account_info=account + ) + """ + + def __init__(self, system_prompt: str = None): + """ + Initialize the prompt builder. + + Args: + system_prompt: Custom system prompt (uses default if None) + """ + self.system_prompt = system_prompt or SYSTEM_PROMPT + + def format_prediction_summary( + self, + symbol: str, + metamodel_output: MetamodelOutput, + strategy_outputs: List[StrategyOutput], + timestamp: str = "now", + timeframe: str = "5m" + ) -> str: + """ + Format the ML prediction summary section. + + Args: + symbol: Trading symbol (e.g., "XAUUSD") + metamodel_output: Output from the metamodel + strategy_outputs: List of individual strategy outputs + timestamp: Current timestamp + timeframe: Analysis timeframe + + Returns: + Formatted prediction summary string + """ + # Calculate net score based on direction + direction_scores = { + "BULLISH": 1.0, + "BEARISH": -1.0, + "NEUTRAL": 0.0 + } + net_score = direction_scores.get(metamodel_output.direction, 0.0) * metamodel_output.confidence + + # Count aligned strategies + aligned_count = sum( + 1 for s in strategy_outputs + if s.direction == metamodel_output.direction and s.confidence > 0.5 + ) + + # Format magnitude + if metamodel_output.direction == "BULLISH": + magnitude = f"+{metamodel_output.magnitude:.2f}%" + elif metamodel_output.direction == "BEARISH": + magnitude = f"-{metamodel_output.magnitude:.2f}%" + else: + magnitude = f"{metamodel_output.magnitude:.2f}% (range)" + + return PREDICTION_SUMMARY_TEMPLATE.format( + symbol=symbol, + timestamp=timestamp, + timeframe=timeframe, + direction=metamodel_output.direction, + net_score=net_score, + magnitude=magnitude, + confidence=metamodel_output.confidence, + aligned_count=aligned_count, + total_strategies=len(strategy_outputs) + ) + + def format_strategy_table( + self, + strategy_outputs: List[StrategyOutput], + weights: Optional[Dict[str, float]] = None + ) -> str: + """ + Format the strategy signals as a markdown table. + + Args: + strategy_outputs: List of individual strategy outputs + weights: Optional weight override (uses strategy.weight if None) + + Returns: + Formatted markdown table string + """ + rows = [] + for strategy in strategy_outputs: + weight = weights.get(strategy.name, strategy.weight) if weights else strategy.weight + + # Format direction with emoji indicator + direction_display = { + "BULLISH": "BULLISH", + "BEARISH": "BEARISH", + "NEUTRAL": "NEUTRAL" + }.get(strategy.direction, strategy.direction) + + row = f"| {strategy.name} | {direction_display} | {strategy.confidence:.1%} | {weight:.1%} |" + rows.append(row) + + return STRATEGY_TABLE_TEMPLATE.format(rows="\n".join(rows)) + + def format_market_context(self, context: MarketContext) -> str: + """ + Format the market context section. + + Args: + context: MarketContext dataclass with current market info + + Returns: + Formatted market context string + """ + atr_pct = context.atr / context.current_price if context.current_price > 0 else 0 + + return MARKET_CONTEXT_TEMPLATE.format( + current_price=f"{context.current_price:.4f}" if context.current_price < 100 else f"{context.current_price:.2f}", + atr=f"{context.atr:.4f}" if context.atr < 1 else f"{context.atr:.2f}", + atr_pct=atr_pct, + volatility_regime=context.volatility_regime.upper(), + market_phase=context.market_phase.title(), + trading_session=context.trading_session.replace("_", " ").title() + ) + + def format_risk_parameters(self, account_info: AccountInfo) -> str: + """ + Format the risk parameters section. + + Args: + account_info: AccountInfo dataclass with account details + + Returns: + Formatted risk parameters string + """ + max_risk_usd = account_info.balance * (account_info.max_risk_per_trade / 100) + + return RISK_PARAMETERS_TEMPLATE.format( + balance=account_info.balance, + available_margin=account_info.available_margin, + drawdown=account_info.current_drawdown, + open_positions=account_info.open_positions, + max_risk_pct=account_info.max_risk_per_trade, + max_risk_usd=max_risk_usd, + daily_pnl=account_info.daily_pnl, + daily_pnl_pct=account_info.daily_pnl_pct + ) + + def build_full_prompt( + self, + symbol: str, + metamodel_output: MetamodelOutput, + strategy_outputs: List[StrategyOutput], + market_context: MarketContext, + account_info: AccountInfo + ) -> str: + """ + Build the complete prompt for the LLM. + + Combines all sections into a comprehensive prompt that includes: + - ML prediction summary + - Strategy table + - Market context + - Risk parameters + - Decision task + + Args: + symbol: Trading symbol + metamodel_output: Output from the metamodel + strategy_outputs: List of individual strategy outputs + market_context: Current market context + account_info: Account and risk information + + Returns: + Complete formatted prompt string + """ + sections = [ + self.format_prediction_summary( + symbol=symbol, + metamodel_output=metamodel_output, + strategy_outputs=strategy_outputs, + timestamp=market_context.timestamp, + timeframe=market_context.timeframe + ), + self.format_strategy_table( + strategy_outputs=strategy_outputs, + weights=metamodel_output.strategy_weights + ), + self.format_market_context(market_context), + self.format_risk_parameters(account_info), + DECISION_TASK_TEMPLATE + ] + + return "\n".join(sections) + + def get_system_prompt(self) -> str: + """Get the system prompt for the LLM.""" + return self.system_prompt + + @staticmethod + def create_from_dict( + symbol: str, + metamodel_dict: Dict[str, Any], + strategies_dict: List[Dict[str, Any]], + context_dict: Dict[str, Any], + account_dict: Dict[str, Any] + ) -> str: + """ + Convenience method to create a prompt from dictionaries. + + Useful when data comes from JSON/API responses. + + Args: + symbol: Trading symbol + metamodel_dict: Metamodel output as dict + strategies_dict: Strategy outputs as list of dicts + context_dict: Market context as dict + account_dict: Account info as dict + + Returns: + Complete formatted prompt string + """ + builder = TradingDecisionPrompt() + + metamodel = MetamodelOutput( + direction=metamodel_dict.get("direction", "NEUTRAL"), + magnitude=metamodel_dict.get("magnitude", 0.0), + confidence=metamodel_dict.get("confidence", 0.5), + strategy_weights=metamodel_dict.get("strategy_weights", {}), + delta_high=metamodel_dict.get("delta_high", 0.0), + delta_low=metamodel_dict.get("delta_low", 0.0) + ) + + strategies = [ + StrategyOutput( + name=s.get("name", "Unknown"), + direction=s.get("direction", "NEUTRAL"), + confidence=s.get("confidence", 0.5), + weight=s.get("weight", 0.2), + details=s.get("details") + ) + for s in strategies_dict + ] + + context = MarketContext( + current_price=context_dict.get("current_price", 0.0), + atr=context_dict.get("atr", 0.0), + volatility_regime=context_dict.get("volatility_regime", "medium"), + market_phase=context_dict.get("market_phase", "unknown"), + trading_session=context_dict.get("trading_session", "unknown"), + timestamp=context_dict.get("timestamp", "now"), + timeframe=context_dict.get("timeframe", "5m") + ) + + account = AccountInfo( + balance=account_dict.get("balance", 1000.0), + available_margin=account_dict.get("available_margin", 1000.0), + current_drawdown=account_dict.get("current_drawdown", 0.0), + open_positions=account_dict.get("open_positions", 0), + max_risk_per_trade=account_dict.get("max_risk_per_trade", 2.0), + daily_pnl=account_dict.get("daily_pnl", 0.0), + daily_pnl_pct=account_dict.get("daily_pnl_pct", 0.0) + ) + + return builder.build_full_prompt( + symbol=symbol, + metamodel_output=metamodel, + strategy_outputs=strategies, + market_context=context, + account_info=account + ) + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'SYSTEM_PROMPT', + 'EXPECTED_RESPONSE_FORMAT', + 'TradingDecisionPrompt', + 'MetamodelOutput', + 'StrategyOutput', + 'MarketContext', + 'AccountInfo', +] diff --git a/src/llm/signal_formatter.py b/src/llm/signal_formatter.py new file mode 100644 index 0000000..b946ff3 --- /dev/null +++ b/src/llm/signal_formatter.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +""" +Signal Formatter +================ + +Formats ML predictions from the metamodel and individual strategies +into a standardized format suitable for LLM consumption. + +This module bridges the gap between raw ML outputs and the structured +format expected by the LLM trading agent. + +Key Functions: +- Format metamodel predictions (direction, magnitude, confidence) +- Format individual strategy signals +- Add risk context from account information +- Serialize to dictionary for API transport + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +from datetime import datetime +from typing import Dict, List, Optional, Any, Tuple +from dataclasses import dataclass, field, asdict +from loguru import logger + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class FormattedStrategy: + """Formatted output from a single strategy""" + name: str + direction: str # "BULLISH", "BEARISH", "NEUTRAL" + confidence: str # "HIGH", "MEDIUM", "LOW" with percentage + confidence_value: float # Raw 0-1 value + weight: float # Strategy weight from metamodel + details: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class FormattedSignal: + """Fully formatted signal ready for LLM consumption""" + # Identification + symbol: str + timestamp: str + timeframe: str + + # Metamodel outputs + direction: str # "BULLISH", "BEARISH", "NEUTRAL" + magnitude: str # "+0.5%" or "-0.3%" + magnitude_value: float # Raw percentage value + confidence: str # "HIGH", "MEDIUM", "LOW" with percentage + confidence_value: float # Raw 0-1 value + delta_high: float # Predicted high delta + delta_low: float # Predicted low delta + + # Strategy weights + strategy_weights: Dict[str, float] = field(default_factory=dict) + + # Individual strategies + strategies: List[FormattedStrategy] = field(default_factory=list) + + # Market context + current_price: float = 0.0 + atr: float = 0.0 + volatility_regime: str = "MEDIUM" + market_phase: str = "unknown" + trading_session: str = "unknown" + + # Risk context (optional, added via add_risk_context) + account_balance: Optional[float] = None + available_margin: Optional[float] = None + current_drawdown: Optional[float] = None + max_risk_per_trade: Optional[float] = None + open_positions: Optional[int] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to serializable dictionary""" + result = { + "symbol": self.symbol, + "timestamp": self.timestamp, + "timeframe": self.timeframe, + "direction": self.direction, + "magnitude": self.magnitude, + "magnitude_value": self.magnitude_value, + "confidence": self.confidence, + "confidence_value": self.confidence_value, + "delta_high": self.delta_high, + "delta_low": self.delta_low, + "strategy_weights": self.strategy_weights, + "strategies": [s.to_dict() for s in self.strategies], + "market_context": { + "current_price": self.current_price, + "atr": self.atr, + "volatility_regime": self.volatility_regime, + "market_phase": self.market_phase, + "trading_session": self.trading_session + } + } + + # Add risk context if present + if self.account_balance is not None: + result["risk_context"] = { + "account_balance": self.account_balance, + "available_margin": self.available_margin, + "current_drawdown": self.current_drawdown, + "max_risk_per_trade": self.max_risk_per_trade, + "open_positions": self.open_positions + } + + return result + + +# ============================================================ +# SIGNAL FORMATTER CLASS +# ============================================================ + +class SignalFormatter: + """ + Formats ML predictions for LLM consumption. + + Converts raw numerical outputs from the metamodel and strategies + into human-readable, structured formats that help the LLM make + informed trading decisions. + + Usage: + formatter = SignalFormatter() + + signal = formatter.format_for_llm( + metamodel_prediction=metamodel_result, + strategy_predictions=strategy_results, + market_context=context_dict, + symbol="XAUUSD" + ) + + # Add account info for risk context + signal = formatter.add_risk_context(signal, account_info) + + # Serialize for API + signal_dict = signal.to_dict() + """ + + # Confidence thresholds + CONFIDENCE_HIGH_THRESHOLD = 0.70 + CONFIDENCE_MEDIUM_THRESHOLD = 0.50 + + # Volatility thresholds (ATR as % of price) + VOLATILITY_HIGH_THRESHOLD = 0.015 # 1.5% + VOLATILITY_LOW_THRESHOLD = 0.005 # 0.5% + + def __init__( + self, + high_confidence_threshold: float = 0.70, + medium_confidence_threshold: float = 0.50 + ): + """ + Initialize the signal formatter. + + Args: + high_confidence_threshold: Threshold for HIGH confidence (default 0.70) + medium_confidence_threshold: Threshold for MEDIUM confidence (default 0.50) + """ + self.high_threshold = high_confidence_threshold + self.medium_threshold = medium_confidence_threshold + + def format_direction(self, value: float) -> str: + """ + Format a directional score into a string. + + Args: + value: Directional value (-1 to 1, or 0/1 for binary) + Positive = BULLISH, Negative = BEARISH, Near 0 = NEUTRAL + + Returns: + "BULLISH", "BEARISH", or "NEUTRAL" + """ + if value > 0.15: + return "BULLISH" + elif value < -0.15: + return "BEARISH" + else: + return "NEUTRAL" + + def format_confidence(self, value: float) -> Tuple[str, str]: + """ + Format a confidence value into a string with level. + + Args: + value: Confidence value (0-1) + + Returns: + Tuple of (formatted string, level) + e.g., ("85.0% (HIGH)", "HIGH") + """ + # Clamp to valid range + value = max(0.0, min(1.0, value)) + + if value >= self.high_threshold: + level = "HIGH" + elif value >= self.medium_threshold: + level = "MEDIUM" + else: + level = "LOW" + + formatted = f"{value * 100:.1f}% ({level})" + return formatted, level + + def format_magnitude(self, value: float) -> str: + """ + Format a magnitude value into a signed percentage string. + + Args: + value: Magnitude as percentage (e.g., 0.5 for 0.5%) + + Returns: + Formatted string like "+0.50%" or "-0.30%" + """ + if value >= 0: + return f"+{value:.2f}%" + else: + return f"{value:.2f}%" + + def determine_volatility_regime( + self, + atr: float, + current_price: float + ) -> str: + """ + Determine the volatility regime based on ATR. + + Args: + atr: Average True Range value + current_price: Current price + + Returns: + "HIGH", "MEDIUM", or "LOW" + """ + if current_price <= 0: + return "MEDIUM" + + atr_pct = atr / current_price + + if atr_pct >= self.VOLATILITY_HIGH_THRESHOLD: + return "HIGH" + elif atr_pct <= self.VOLATILITY_LOW_THRESHOLD: + return "LOW" + else: + return "MEDIUM" + + def determine_trading_session(self, timestamp: Optional[datetime] = None) -> str: + """ + Determine the current trading session based on time. + + Args: + timestamp: Datetime to check (uses current time if None) + + Returns: + "asian", "london", "new_york", or "london_ny_overlap" + """ + if timestamp is None: + timestamp = datetime.utcnow() + + hour = timestamp.hour + + # Session times in UTC + # Asian: 00:00 - 08:00 + # London: 08:00 - 16:00 + # New York: 13:00 - 22:00 + # Overlap: 13:00 - 16:00 + + if 13 <= hour < 16: + return "london_ny_overlap" + elif 8 <= hour < 16: + return "london" + elif 13 <= hour < 22: + return "new_york" + else: + return "asian" + + def format_for_llm( + self, + metamodel_prediction: Dict[str, Any], + strategy_predictions: List[Dict[str, Any]], + market_context: Dict[str, Any], + symbol: str = "UNKNOWN", + timeframe: str = "5m" + ) -> FormattedSignal: + """ + Format ML predictions for LLM consumption. + + This is the main method that converts raw ML outputs into + a structured format suitable for the LLM trading agent. + + Args: + metamodel_prediction: Dict with keys: + - delta_high_final: float (predicted high delta) + - delta_low_final: float (predicted low delta) + - confidence: int (0 or 1) + - confidence_proba: float (0-1) + - strategy_weights: Dict[str, float] (optional) + strategy_predictions: List of dicts with keys: + - name: str (strategy name) + - direction: float or str (-1/0/1 or "BULLISH"/"NEUTRAL"/"BEARISH") + - confidence: float (0-1) + - weight: float (optional, uses equal weight if missing) + market_context: Dict with keys: + - current_price: float + - atr: float + - market_phase: str (optional) + - timestamp: str or datetime (optional) + symbol: Trading symbol + timeframe: Analysis timeframe + + Returns: + FormattedSignal ready for LLM consumption + """ + # Extract metamodel outputs + delta_high = metamodel_prediction.get("delta_high_final", 0.0) + delta_low = metamodel_prediction.get("delta_low_final", 0.0) + confidence_proba = metamodel_prediction.get("confidence_proba", 0.5) + strategy_weights = metamodel_prediction.get("strategy_weights", {}) + + # Handle numpy arrays (take first element) + if hasattr(delta_high, "__iter__") and not isinstance(delta_high, str): + delta_high = float(delta_high[0]) if len(delta_high) > 0 else 0.0 + if hasattr(delta_low, "__iter__") and not isinstance(delta_low, str): + delta_low = float(delta_low[0]) if len(delta_low) > 0 else 0.0 + if hasattr(confidence_proba, "__iter__") and not isinstance(confidence_proba, str): + confidence_proba = float(confidence_proba[0]) if len(confidence_proba) > 0 else 0.5 + + # Determine direction based on delta asymmetry + if delta_high > delta_low * 1.2: + direction = "BULLISH" + magnitude = delta_high + elif delta_low > delta_high * 1.2: + direction = "BEARISH" + magnitude = -delta_low + else: + direction = "NEUTRAL" + magnitude = (delta_high + delta_low) / 2 + + # Format confidence + confidence_str, _ = self.format_confidence(confidence_proba) + + # Extract market context + current_price = market_context.get("current_price", 0.0) + atr = market_context.get("atr", 0.0) + market_phase = market_context.get("market_phase", "unknown") + + # Determine volatility regime + volatility_regime = self.determine_volatility_regime(atr, current_price) + + # Handle timestamp + timestamp = market_context.get("timestamp") + if isinstance(timestamp, datetime): + timestamp_str = timestamp.isoformat() + trading_session = self.determine_trading_session(timestamp) + elif isinstance(timestamp, str): + timestamp_str = timestamp + trading_session = self.determine_trading_session() + else: + timestamp_str = datetime.utcnow().isoformat() + trading_session = self.determine_trading_session() + + # Format individual strategies + formatted_strategies = [] + n_strategies = len(strategy_predictions) if strategy_predictions else 1 + + for i, strat in enumerate(strategy_predictions or []): + strat_name = strat.get("name", f"Strategy_{i}") + strat_direction = strat.get("direction", 0) + strat_confidence = strat.get("confidence", 0.5) + strat_weight = strat.get("weight", 1.0 / n_strategies) + + # Convert numeric direction to string + if isinstance(strat_direction, (int, float)): + strat_direction_str = self.format_direction(strat_direction) + else: + strat_direction_str = strat_direction.upper() if strat_direction else "NEUTRAL" + + # Format confidence + conf_str, _ = self.format_confidence(strat_confidence) + + formatted_strategies.append(FormattedStrategy( + name=strat_name, + direction=strat_direction_str, + confidence=conf_str, + confidence_value=strat_confidence, + weight=strat_weight, + details=strat.get("details") + )) + + # Build the formatted signal + return FormattedSignal( + symbol=symbol, + timestamp=timestamp_str, + timeframe=timeframe, + direction=direction, + magnitude=self.format_magnitude(magnitude), + magnitude_value=magnitude, + confidence=confidence_str, + confidence_value=confidence_proba, + delta_high=float(delta_high), + delta_low=float(delta_low), + strategy_weights=strategy_weights, + strategies=formatted_strategies, + current_price=current_price, + atr=atr, + volatility_regime=volatility_regime, + market_phase=market_phase, + trading_session=trading_session + ) + + def add_risk_context( + self, + signal: FormattedSignal, + account_info: Dict[str, Any] + ) -> FormattedSignal: + """ + Add risk context from account information to a signal. + + Args: + signal: FormattedSignal to augment + account_info: Dict with account details: + - balance: float + - available_margin: float + - current_drawdown: float (as decimal, e.g., 0.05 for 5%) + - max_risk_per_trade: float (as percentage, e.g., 2.0 for 2%) + - open_positions: int + + Returns: + Signal with risk context added (mutates and returns same object) + """ + signal.account_balance = account_info.get("balance") + signal.available_margin = account_info.get("available_margin") + signal.current_drawdown = account_info.get("current_drawdown") + signal.max_risk_per_trade = account_info.get("max_risk_per_trade") + signal.open_positions = account_info.get("open_positions") + + return signal + + @staticmethod + def from_metamodel_output( + metamodel_output: Any, + symbol: str, + current_price: float, + atr: float, + timeframe: str = "5m", + strategy_names: Optional[List[str]] = None + ) -> FormattedSignal: + """ + Convenience method to create a FormattedSignal directly from a MetamodelPrediction. + + Args: + metamodel_output: MetamodelPrediction or similar object with: + - delta_high_final + - delta_low_final + - confidence + - confidence_proba + symbol: Trading symbol + current_price: Current market price + atr: Current ATR value + timeframe: Analysis timeframe + strategy_names: Optional list of strategy names + + Returns: + FormattedSignal ready for LLM consumption + """ + formatter = SignalFormatter() + + metamodel_dict = { + "delta_high_final": getattr(metamodel_output, "delta_high_final", 0.0), + "delta_low_final": getattr(metamodel_output, "delta_low_final", 0.0), + "confidence": getattr(metamodel_output, "confidence", 0), + "confidence_proba": getattr(metamodel_output, "confidence_proba", 0.5), + "strategy_weights": getattr(metamodel_output, "strategy_weights", {}) + } + + # If alpha weights are available (from NeuralGating), use them + if hasattr(metamodel_output, "alpha_high"): + metamodel_dict["strategy_weights"] = { + "5m_model": float(getattr(metamodel_output, "alpha_high", 0.5)), + "15m_model": 1.0 - float(getattr(metamodel_output, "alpha_high", 0.5)) + } + + market_context = { + "current_price": current_price, + "atr": atr, + "market_phase": "unknown" + } + + # Create placeholder strategies if names provided + strategy_predictions = [] + if strategy_names: + n = len(strategy_names) + for name in strategy_names: + strategy_predictions.append({ + "name": name, + "direction": 0, + "confidence": 0.5, + "weight": 1.0 / n + }) + + return formatter.format_for_llm( + metamodel_prediction=metamodel_dict, + strategy_predictions=strategy_predictions, + market_context=market_context, + symbol=symbol, + timeframe=timeframe + ) + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'SignalFormatter', + 'FormattedSignal', + 'FormattedStrategy', +] diff --git a/src/llm/signal_logger.py b/src/llm/signal_logger.py new file mode 100644 index 0000000..81c1ffb --- /dev/null +++ b/src/llm/signal_logger.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +""" +LLM Signal Logger +================= + +Logs ML signals and LLM decisions to PostgreSQL for: +- Feedback and performance tracking +- Fine-tuning dataset generation +- Trade result correlation + +Uses the ml.llm_signals table in PostgreSQL. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import os +import json +import uuid +from datetime import datetime +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, asdict +from pathlib import Path + +import pandas as pd +from loguru import logger + +try: + from sqlalchemy import create_engine, text + from sqlalchemy.pool import QueuePool + HAS_SQLALCHEMY = True +except ImportError: + HAS_SQLALCHEMY = False + logger.warning("SQLAlchemy not available - database logging disabled") + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class TradeResult: + """Result of a trade for logging""" + signal_id: str + result: str # "WIN", "LOSS", "BREAKEVEN", "PARTIAL" + pnl: float # P&L in USD + pnl_pct: float # P&L as percentage + entry_price: float + exit_price: float + stop_loss_hit: bool + take_profit_hit: bool + duration_minutes: int + exit_reason: str # "TP1", "TP2", "SL", "MANUAL", "TRAILING" + notes: Optional[str] = None + + +# ============================================================ +# SIGNAL LOGGER CLASS +# ============================================================ + +class LLMSignalLogger: + """ + Logger for ML signals and LLM decisions. + + Persists signals to PostgreSQL for: + - Performance tracking and feedback + - Trade result correlation + - Fine-tuning dataset generation + + Usage: + logger = LLMSignalLogger() + + # Log a signal + signal_id = logger.log_signal( + signal=formatted_signal, + decision=trading_decision + ) + + # Update with trade result + logger.log_trade_result(signal_id, trade_result) + + # Export for fine-tuning + logger.export_for_finetuning("training_data.jsonl") + """ + + # Default connection parameters + DEFAULT_HOST = "localhost" + DEFAULT_PORT = 5432 + DEFAULT_DATABASE = "trading_platform" + DEFAULT_USER = "trading_user" + DEFAULT_PASSWORD = "trading_dev_2026" + + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + database: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + create_table: bool = True + ): + """ + Initialize the signal logger. + + Args: + host: Database host (default: localhost) + port: Database port (default: 5432) + database: Database name (default: trading_platform) + user: Database user (default: trading_user) + password: Database password + create_table: Create table if it doesn't exist + """ + self.host = host or os.getenv("DB_HOST", self.DEFAULT_HOST) + self.port = port or int(os.getenv("DB_PORT", self.DEFAULT_PORT)) + self.database = database or os.getenv("DB_NAME", self.DEFAULT_DATABASE) + self.user = user or os.getenv("DB_USER", self.DEFAULT_USER) + self.password = password or os.getenv("DB_PASSWORD", self.DEFAULT_PASSWORD) + + self._engine = None + self._connected = False + + if HAS_SQLALCHEMY: + self._connect() + if create_table and self._connected: + self._ensure_table_exists() + else: + logger.warning("SQLAlchemy not available - using file-based fallback") + self._fallback_dir = Path("logs/llm_signals") + self._fallback_dir.mkdir(parents=True, exist_ok=True) + + def _connect(self) -> None: + """Establish database connection""" + try: + connection_string = ( + f"postgresql://{self.user}:{self.password}" + f"@{self.host}:{self.port}/{self.database}" + ) + + self._engine = create_engine( + connection_string, + poolclass=QueuePool, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + echo=False + ) + + # Test connection + with self._engine.connect() as conn: + conn.execute(text("SELECT 1")) + + self._connected = True + logger.info(f"Connected to PostgreSQL: {self.host}:{self.port}/{self.database}") + + except Exception as e: + logger.error(f"Failed to connect to database: {e}") + self._connected = False + + def _ensure_table_exists(self) -> None: + """Create the ml.llm_signals table if it doesn't exist""" + create_schema_sql = "CREATE SCHEMA IF NOT EXISTS ml;" + + create_table_sql = """ + CREATE TABLE IF NOT EXISTS ml.llm_signals ( + id SERIAL PRIMARY KEY, + signal_id UUID NOT NULL UNIQUE, + symbol VARCHAR(20) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + ml_prediction JSONB, + strategy_predictions JSONB, + llm_prompt TEXT, + llm_response TEXT, + decision JSONB, + trade_result JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_llm_signals_symbol ON ml.llm_signals(symbol); + CREATE INDEX IF NOT EXISTS idx_llm_signals_timestamp ON ml.llm_signals(timestamp); + CREATE INDEX IF NOT EXISTS idx_llm_signals_created_at ON ml.llm_signals(created_at); + """ + + try: + with self._engine.connect() as conn: + conn.execute(text(create_schema_sql)) + conn.execute(text(create_table_sql)) + conn.commit() + logger.info("ml.llm_signals table ready") + except Exception as e: + logger.error(f"Failed to create table: {e}") + + def log_signal( + self, + signal: Any, + decision: Any, + llm_prompt: Optional[str] = None, + llm_response: Optional[str] = None + ) -> str: + """ + Log a signal and its decision. + + Args: + signal: FormattedSignal or dict with signal data + decision: TradingDecision or dict with decision data + llm_prompt: The prompt sent to the LLM (optional) + llm_response: Raw LLM response (optional) + + Returns: + signal_id (UUID string) for later reference + """ + signal_id = str(uuid.uuid4()) + + # Convert to dicts if needed + signal_dict = signal.to_dict() if hasattr(signal, "to_dict") else dict(signal) + decision_dict = decision.to_dict() if hasattr(decision, "to_dict") else dict(decision) + + # Extract key fields + symbol = signal_dict.get("symbol", "UNKNOWN") + timestamp = signal_dict.get("timestamp", datetime.utcnow().isoformat()) + + # Separate ML prediction and strategy predictions + ml_prediction = { + "direction": signal_dict.get("direction"), + "magnitude": signal_dict.get("magnitude_value"), + "confidence": signal_dict.get("confidence_value"), + "delta_high": signal_dict.get("delta_high"), + "delta_low": signal_dict.get("delta_low"), + "volatility_regime": signal_dict.get("volatility_regime"), + "market_phase": signal_dict.get("market_phase"), + } + + strategy_predictions = signal_dict.get("strategies", []) + + if self._connected and self._engine: + return self._log_to_database( + signal_id=signal_id, + symbol=symbol, + timestamp=timestamp, + ml_prediction=ml_prediction, + strategy_predictions=strategy_predictions, + llm_prompt=llm_prompt, + llm_response=llm_response, + decision=decision_dict + ) + else: + return self._log_to_file( + signal_id=signal_id, + symbol=symbol, + timestamp=timestamp, + ml_prediction=ml_prediction, + strategy_predictions=strategy_predictions, + llm_prompt=llm_prompt, + llm_response=llm_response, + decision=decision_dict + ) + + def _log_to_database( + self, + signal_id: str, + symbol: str, + timestamp: str, + ml_prediction: Dict, + strategy_predictions: List, + llm_prompt: Optional[str], + llm_response: Optional[str], + decision: Dict + ) -> str: + """Log signal to PostgreSQL database""" + insert_sql = """ + INSERT INTO ml.llm_signals ( + signal_id, symbol, timestamp, ml_prediction, + strategy_predictions, llm_prompt, llm_response, decision + ) VALUES ( + :signal_id, :symbol, :timestamp, :ml_prediction::jsonb, + :strategy_predictions::jsonb, :llm_prompt, :llm_response, :decision::jsonb + ) + """ + + try: + with self._engine.connect() as conn: + conn.execute( + text(insert_sql), + { + "signal_id": signal_id, + "symbol": symbol, + "timestamp": timestamp, + "ml_prediction": json.dumps(ml_prediction), + "strategy_predictions": json.dumps(strategy_predictions), + "llm_prompt": llm_prompt, + "llm_response": llm_response, + "decision": json.dumps(decision) + } + ) + conn.commit() + + logger.debug(f"Logged signal {signal_id} for {symbol}") + return signal_id + + except Exception as e: + logger.error(f"Failed to log signal: {e}") + # Fall back to file + return self._log_to_file( + signal_id, symbol, timestamp, ml_prediction, + strategy_predictions, llm_prompt, llm_response, decision + ) + + def _log_to_file( + self, + signal_id: str, + symbol: str, + timestamp: str, + ml_prediction: Dict, + strategy_predictions: List, + llm_prompt: Optional[str], + llm_response: Optional[str], + decision: Dict + ) -> str: + """Fallback: log signal to JSONL file""" + record = { + "signal_id": signal_id, + "symbol": symbol, + "timestamp": timestamp, + "ml_prediction": ml_prediction, + "strategy_predictions": strategy_predictions, + "llm_prompt": llm_prompt, + "llm_response": llm_response, + "decision": decision, + "created_at": datetime.utcnow().isoformat() + } + + filepath = self._fallback_dir / f"signals_{datetime.utcnow().strftime('%Y%m%d')}.jsonl" + + with open(filepath, "a", encoding="utf-8") as f: + f.write(json.dumps(record, default=str) + "\n") + + logger.debug(f"Logged signal {signal_id} to file") + return signal_id + + def log_trade_result( + self, + signal_id: str, + result: TradeResult + ) -> bool: + """ + Update a signal with its trade result. + + Args: + signal_id: The signal_id returned from log_signal + result: TradeResult with outcome data + + Returns: + True if successful + """ + result_dict = asdict(result) + + if self._connected and self._engine: + update_sql = """ + UPDATE ml.llm_signals + SET trade_result = :trade_result::jsonb, + updated_at = NOW() + WHERE signal_id = :signal_id + """ + + try: + with self._engine.connect() as conn: + result_obj = conn.execute( + text(update_sql), + { + "signal_id": signal_id, + "trade_result": json.dumps(result_dict) + } + ) + conn.commit() + + if result_obj.rowcount > 0: + logger.info(f"Updated signal {signal_id} with trade result: {result.result}") + return True + else: + logger.warning(f"Signal {signal_id} not found for update") + return False + + except Exception as e: + logger.error(f"Failed to update trade result: {e}") + return False + else: + # File fallback - append to a results file + result_record = { + "signal_id": signal_id, + "trade_result": result_dict, + "updated_at": datetime.utcnow().isoformat() + } + + filepath = self._fallback_dir / "trade_results.jsonl" + with open(filepath, "a", encoding="utf-8") as f: + f.write(json.dumps(result_record, default=str) + "\n") + + return True + + def get_signals_for_training( + self, + symbol: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + only_with_results: bool = True + ) -> pd.DataFrame: + """ + Get signals for training/fine-tuning. + + Args: + symbol: Filter by symbol (optional) + start_date: Start date filter (YYYY-MM-DD) + end_date: End date filter (YYYY-MM-DD) + only_with_results: Only include signals with trade results + + Returns: + DataFrame with signals and results + """ + if not self._connected or not self._engine: + logger.warning("Database not connected - returning empty DataFrame") + return pd.DataFrame() + + query = """ + SELECT + signal_id, + symbol, + timestamp, + ml_prediction, + strategy_predictions, + llm_prompt, + llm_response, + decision, + trade_result, + created_at + FROM ml.llm_signals + WHERE 1=1 + """ + + params = {} + + if symbol: + query += " AND symbol = :symbol" + params["symbol"] = symbol + + if start_date: + query += " AND timestamp >= :start_date" + params["start_date"] = start_date + + if end_date: + query += " AND timestamp <= :end_date" + params["end_date"] = end_date + + if only_with_results: + query += " AND trade_result IS NOT NULL" + + query += " ORDER BY timestamp ASC" + + try: + df = pd.read_sql(text(query), self._engine, params=params) + logger.info(f"Retrieved {len(df)} signals for training") + return df + except Exception as e: + logger.error(f"Failed to get signals: {e}") + return pd.DataFrame() + + def export_for_finetuning( + self, + output_path: str, + format: str = "jsonl", + symbol: Optional[str] = None, + only_successful: bool = False + ) -> Path: + """ + Export signals in a format suitable for LLM fine-tuning. + + Args: + output_path: Output file path + format: Output format ("jsonl", "openai", "anthropic") + symbol: Filter by symbol (optional) + only_successful: Only include winning trades + + Returns: + Path to the output file + """ + df = self.get_signals_for_training(symbol=symbol, only_with_results=True) + + if df.empty: + logger.warning("No signals to export") + return Path(output_path) + + # Filter successful if requested + if only_successful: + df = df[df["trade_result"].apply( + lambda x: x.get("result") == "WIN" if isinstance(x, dict) else False + )] + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if format == "openai": + return self._export_openai_format(df, output_path) + elif format == "anthropic": + return self._export_anthropic_format(df, output_path) + else: + return self._export_jsonl_format(df, output_path) + + def _export_jsonl_format(self, df: pd.DataFrame, output_path: Path) -> Path: + """Export in generic JSONL format""" + with open(output_path, "w", encoding="utf-8") as f: + for _, row in df.iterrows(): + record = { + "id": str(row["signal_id"]), + "symbol": row["symbol"], + "timestamp": str(row["timestamp"]), + "prompt": row["llm_prompt"], + "response": row["llm_response"], + "decision": row["decision"], + "result": row["trade_result"] + } + f.write(json.dumps(record, default=str) + "\n") + + logger.info(f"Exported {len(df)} signals to {output_path}") + return output_path + + def _export_openai_format(self, df: pd.DataFrame, output_path: Path) -> Path: + """Export in OpenAI fine-tuning format""" + from .prompts.trading_decision import SYSTEM_PROMPT + + with open(output_path, "w", encoding="utf-8") as f: + for _, row in df.iterrows(): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": row["llm_prompt"] or ""}, + {"role": "assistant", "content": row["llm_response"] or ""} + ] + record = {"messages": messages} + f.write(json.dumps(record, default=str) + "\n") + + logger.info(f"Exported {len(df)} signals in OpenAI format to {output_path}") + return output_path + + def _export_anthropic_format(self, df: pd.DataFrame, output_path: Path) -> Path: + """Export in Anthropic fine-tuning format""" + from .prompts.trading_decision import SYSTEM_PROMPT + + with open(output_path, "w", encoding="utf-8") as f: + for _, row in df.iterrows(): + record = { + "system": SYSTEM_PROMPT, + "messages": [ + {"role": "user", "content": row["llm_prompt"] or ""}, + {"role": "assistant", "content": row["llm_response"] or ""} + ] + } + f.write(json.dumps(record, default=str) + "\n") + + logger.info(f"Exported {len(df)} signals in Anthropic format to {output_path}") + return output_path + + def get_statistics( + self, + symbol: Optional[str] = None, + days: int = 30 + ) -> Dict[str, Any]: + """ + Get statistics about logged signals. + + Args: + symbol: Filter by symbol (optional) + days: Number of days to look back + + Returns: + Dictionary with statistics + """ + if not self._connected or not self._engine: + return {"error": "Database not connected"} + + query = """ + SELECT + COUNT(*) as total_signals, + COUNT(trade_result) as signals_with_results, + COUNT(CASE WHEN (trade_result->>'result')::text = 'WIN' THEN 1 END) as wins, + COUNT(CASE WHEN (trade_result->>'result')::text = 'LOSS' THEN 1 END) as losses, + AVG((trade_result->>'pnl')::float) as avg_pnl + FROM ml.llm_signals + WHERE timestamp >= NOW() - INTERVAL '{days} days' + """ + + params = {"days": days} + + if symbol: + query = query.replace("WHERE", "WHERE symbol = :symbol AND") + params["symbol"] = symbol + + try: + with self._engine.connect() as conn: + result = conn.execute(text(query.format(days=days)), params) + row = result.fetchone() + + if row: + total = row[0] or 0 + with_results = row[1] or 0 + wins = row[2] or 0 + losses = row[3] or 0 + avg_pnl = row[4] or 0 + + return { + "total_signals": total, + "signals_with_results": with_results, + "wins": wins, + "losses": losses, + "win_rate": wins / with_results if with_results > 0 else 0, + "avg_pnl": avg_pnl, + "period_days": days + } + else: + return {"total_signals": 0} + + except Exception as e: + logger.error(f"Failed to get statistics: {e}") + return {"error": str(e)} + + +# ============================================================ +# EXPORTS +# ============================================================ + +__all__ = [ + 'LLMSignalLogger', + 'TradeResult', +] diff --git a/src/models/__init__.py b/src/models/__init__.py index bd1d3e7..b52c557 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -14,6 +14,25 @@ Models: - MovementMagnitudePredictor: Predicts USD movement magnitude for asymmetric opportunities - TPSLClassifier: Take Profit / Stop Loss probability - StrategyEnsemble: Combined multi-model analysis + +Attention Module: +- PriceFocusedAttention: Time-agnostic self-attention on price returns +- MultiHeadAttention: Core multi-head attention mechanism +- LearnablePositionalEncoding: Learnable position embeddings +- AttentionExtractor: Attention analysis and visualization utilities + +MRD Strategy (Momentum Regime Detection): +- MRDModel: Complete model combining HMM + LSTM + XGBoost +- RegimeHMM: Hidden Markov Model for regime detection (3 states) +- MRDFeatureEngineer: Momentum indicators (RSI, MACD, ROC, ADX, etc.) +- MRDTrainer: Walk-forward training pipeline + +Neural Gating Metamodel (Strategy Ensemble): +- NeuralGatingMetamodel: Combines 5 strategies (PVA, MRD, VBP, MSA, MTS) with learned weighting +- GatingNetwork: MLP that learns context-dependent strategy weights +- EnsemblePipeline: Orchestrates predictions from all strategies +- ConfidenceCalibrator: Calibrates confidence scores (isotonic, Platt, temperature scaling) +- MetamodelTrainer: Walk-forward training with entropy regularization """ from .range_predictor import RangePredictor, RangePrediction, RangeModelMetrics @@ -45,6 +64,48 @@ from .strategy_ensemble import ( SignalStrength ) +# Price-Focused Attention Architecture +from .attention import ( + PriceFocusedAttention, + PriceAttentionConfig, + PriceAttentionEncoder, + MultiHeadAttention, + LearnablePositionalEncoding, + AttentionExtractor, + AttentionScores, + compute_return_features, +) + +# MRD Strategy - Momentum Regime Detection +from .strategies.mrd import ( + MRDModel, + MRDModelConfig, + MRDPrediction, + MRDFeatureEngineer, + MRDFeatureConfig, + RegimeHMM, + RegimeHMMConfig, + MarketRegime, + MRDTrainer, + TrainerConfig as MRDTrainerConfig, +) + +# Neural Gating Metamodel - Combines 5 strategies with learned weighting +from .metamodel import ( + NeuralGatingMetamodel, + MetamodelConfig, + MetamodelPrediction, + GatingNetwork, + GatingConfig, + EnsemblePipeline, + EnsemblePrediction, + ConfidenceCalibrator, + CalibrationMetrics, + MetamodelTrainer, + TrainerConfig as MetamodelTrainerConfig, + TrainingMetrics as MetamodelTrainingMetrics, +) + __all__ = [ # Range Predictor (legacy) 'RangePredictor', @@ -85,4 +146,37 @@ __all__ = [ 'ModelSignal', 'TradeAction', 'SignalStrength', + # Price-Focused Attention Architecture + 'PriceFocusedAttention', + 'PriceAttentionConfig', + 'PriceAttentionEncoder', + 'MultiHeadAttention', + 'LearnablePositionalEncoding', + 'AttentionExtractor', + 'AttentionScores', + 'compute_return_features', + # MRD Strategy - Momentum Regime Detection + 'MRDModel', + 'MRDModelConfig', + 'MRDPrediction', + 'MRDFeatureEngineer', + 'MRDFeatureConfig', + 'RegimeHMM', + 'RegimeHMMConfig', + 'MarketRegime', + 'MRDTrainer', + 'MRDTrainerConfig', + # Neural Gating Metamodel - Strategy Ensemble with Learned Weighting + 'NeuralGatingMetamodel', + 'MetamodelConfig', + 'MetamodelPrediction', + 'GatingNetwork', + 'GatingConfig', + 'EnsemblePipeline', + 'EnsemblePrediction', + 'ConfidenceCalibrator', + 'CalibrationMetrics', + 'MetamodelTrainer', + 'MetamodelTrainerConfig', + 'MetamodelTrainingMetrics', ] diff --git a/src/models/attention/__init__.py b/src/models/attention/__init__.py new file mode 100644 index 0000000..1217dee --- /dev/null +++ b/src/models/attention/__init__.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Price-Focused Attention Architecture Module +============================================ + +This module provides a time-agnostic self-attention architecture +for analyzing price movement sequences in financial markets. + +Key Design Principles: +1. Time-Agnostic: No real timestamps, only sequence positions +2. Price-Focused: Operates on returns and price derivatives +3. Multi-Head: 8 attention heads for diverse pattern learning +4. Transformer-Style: 4 layers of self-attention with feed-forward + +Main Components: +- PriceFocusedAttention: Main transformer encoder model +- MultiHeadAttention: Core multi-head attention mechanism +- LearnablePositionalEncoding: Time-agnostic position embeddings +- AttentionExtractor: Utilities for attention analysis + +Architecture Defaults: +- d_model: 256 (model dimension) +- n_heads: 8 (attention heads) +- d_k: 32 (key/query dimension per head) +- n_layers: 4 (transformer encoder layers) +- d_ff: 1024 (feed-forward dimension) +- dropout: 0.1 +- max_seq_len: 512 + +Example Usage: + >>> from src.models.attention import ( + ... PriceFocusedAttention, + ... PriceAttentionConfig, + ... AttentionExtractor, + ... compute_return_features + ... ) + >>> + >>> # Configure the model + >>> config = PriceAttentionConfig( + ... d_model=256, + ... n_heads=8, + ... n_layers=4 + ... ) + >>> + >>> # Create model + >>> model = PriceFocusedAttention(config, input_features=4) + >>> + >>> # Prepare input (returns-based features) + >>> import torch + >>> prices = torch.randn(32, 100, 4) # OHLC + >>> features = compute_return_features(prices) + >>> + >>> # Forward pass + >>> output, attentions = model(features) + >>> print(f"Output shape: {output.shape}") # (32, 100, 256) + >>> + >>> # Extract and visualize attention + >>> extractor = AttentionExtractor() + >>> scores = extractor.get_attention_scores(model, features) + >>> extractor.visualize_attention(scores, save_path='attention.png') + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Core attention mechanism +from .multi_head_attention import ( + MultiHeadAttention, + ScaledDotProductAttention, + create_causal_mask, + create_padding_mask, +) + +# Positional encoding +from .positional_encoding import ( + LearnablePositionalEncoding, + SinusoidalPositionalEncoding, + RelativePositionalEncoding, +) + +# Main price-focused attention model +from .price_attention import ( + PriceFocusedAttention, + PriceAttentionConfig, + PriceAttentionEncoder, + PriceAttentionEncoderLayer, + FeedForwardBlock, + compute_return_features, +) + +# Attention analysis and extraction +from .attention_extractor import ( + AttentionExtractor, + AttentionScores, + extract_attention_for_analysis, +) + + +__all__ = [ + # Core attention + 'MultiHeadAttention', + 'ScaledDotProductAttention', + 'create_causal_mask', + 'create_padding_mask', + + # Positional encoding + 'LearnablePositionalEncoding', + 'SinusoidalPositionalEncoding', + 'RelativePositionalEncoding', + + # Price-focused attention + 'PriceFocusedAttention', + 'PriceAttentionConfig', + 'PriceAttentionEncoder', + 'PriceAttentionEncoderLayer', + 'FeedForwardBlock', + 'compute_return_features', + + # Attention extraction + 'AttentionExtractor', + 'AttentionScores', + 'extract_attention_for_analysis', +] + + +# Module version +__version__ = '1.0.0' diff --git a/src/models/attention/attention_extractor.py b/src/models/attention/attention_extractor.py new file mode 100644 index 0000000..d532b94 --- /dev/null +++ b/src/models/attention/attention_extractor.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +""" +Attention Extractor Module +=========================== +Provides utilities to extract and visualize attention weights +from Price-Focused Attention models. + +This module enables: +1. Extracting attention weights from trained models +2. Producing attention heatmaps +3. Analyzing attention patterns +4. Persisting attention scores to database + +Key Features: +- Extract attention from any layer/head +- Generate matplotlib heatmaps +- Compute attention statistics +- Save to PostgreSQL database + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import json +from datetime import datetime +from typing import List, Optional, Dict, Any, Tuple, Union +from dataclasses import dataclass, field + +import numpy as np +import torch +import torch.nn as nn + +try: + import matplotlib.pyplot as plt + import matplotlib.colors as mcolors + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + +try: + from sqlalchemy import create_engine, text + from sqlalchemy.engine import Engine + HAS_SQLALCHEMY = True +except ImportError: + HAS_SQLALCHEMY = False + + +@dataclass +class AttentionScores: + """ + Container for extracted attention scores. + + Attributes: + scores: Attention weights tensor (batch, n_heads, seq_len, seq_len) + layer_idx: Which layer the scores came from + head_idx: Optional head index if single-head extraction + sequence_len: Length of the input sequence + n_heads: Number of attention heads + metadata: Additional metadata (symbol, timestamp, etc.) + """ + scores: np.ndarray + layer_idx: int + head_idx: Optional[int] = None + sequence_len: int = 0 + n_heads: int = 8 + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + if self.sequence_len == 0: + self.sequence_len = self.scores.shape[-1] + + @property + def shape(self) -> Tuple[int, ...]: + """Get shape of attention scores.""" + return self.scores.shape + + def mean_attention(self) -> np.ndarray: + """ + Get mean attention across all heads. + + Returns: + Mean attention of shape (batch, seq_len, seq_len) + """ + if len(self.scores.shape) == 4: + return self.scores.mean(axis=1) + return self.scores + + def head_attention(self, head_idx: int) -> np.ndarray: + """ + Get attention for a specific head. + + Args: + head_idx: Head index (0 to n_heads - 1) + + Returns: + Attention for that head of shape (batch, seq_len, seq_len) + """ + if len(self.scores.shape) != 4: + raise ValueError("Scores must have 4 dimensions: (batch, heads, seq, seq)") + return self.scores[:, head_idx, :, :] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + 'scores': self.scores.tolist(), + 'layer_idx': self.layer_idx, + 'head_idx': self.head_idx, + 'sequence_len': self.sequence_len, + 'n_heads': self.n_heads, + 'metadata': self.metadata + } + + +class AttentionExtractor: + """ + Utility class for extracting and analyzing attention weights. + + This class provides methods to: + 1. Extract attention weights from PriceFocusedAttention models + 2. Generate attention heatmaps for visualization + 3. Compute attention statistics + 4. Save attention data to database + + Example: + >>> extractor = AttentionExtractor() + >>> scores = extractor.get_attention_scores(model, input_data) + >>> extractor.visualize_attention(scores, save_path='attention.png') + >>> extractor.save_to_database(scores, symbol='XAUUSD', timestamp=datetime.now()) + """ + + def __init__( + self, + db_connection_string: Optional[str] = None, + default_colormap: str = 'viridis' + ) -> None: + """ + Initialize the AttentionExtractor. + + Args: + db_connection_string: Optional database connection string + Format: postgresql://user:pass@host:port/db + default_colormap: Default matplotlib colormap for heatmaps + """ + self.db_connection_string = db_connection_string + self.default_colormap = default_colormap + self._engine: Optional[Engine] = None + + if db_connection_string and HAS_SQLALCHEMY: + self._engine = create_engine(db_connection_string) + + def get_attention_scores( + self, + model: nn.Module, + x: torch.Tensor, + layer_idx: int = -1, + head_idx: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> AttentionScores: + """ + Extract attention scores from a model. + + Args: + model: PriceFocusedAttention model or compatible model + x: Input tensor of shape (batch, seq_len, features) + layer_idx: Layer index to extract from (-1 for last layer) + head_idx: Optional specific head to extract (None for all heads) + metadata: Optional metadata to attach to the scores + + Returns: + AttentionScores object containing the extracted weights + """ + # Ensure model is in evaluation mode + was_training = model.training + model.eval() + + with torch.no_grad(): + # Forward pass to get attention weights + if hasattr(model, 'forward'): + # Most attention models return (output, attentions) + output, attentions = model(x, return_all_attentions=True) + else: + raise ValueError("Model must have a forward method") + + # Restore training mode if needed + if was_training: + model.train() + + # Get specified layer's attention + if not attentions: + raise ValueError("Model did not return attention weights") + + n_layers = len(attentions) + if layer_idx < 0: + layer_idx = n_layers + layer_idx + + if layer_idx < 0 or layer_idx >= n_layers: + raise ValueError(f"layer_idx {layer_idx} out of range [0, {n_layers})") + + attention_tensor = attentions[layer_idx] + + # Convert to numpy + scores_np = attention_tensor.cpu().numpy() + + # Extract specific head if requested + if head_idx is not None: + scores_np = scores_np[:, head_idx:head_idx+1, :, :] + + return AttentionScores( + scores=scores_np, + layer_idx=layer_idx, + head_idx=head_idx, + n_heads=attention_tensor.shape[1], + metadata=metadata or {} + ) + + def visualize_attention( + self, + scores: Union[AttentionScores, np.ndarray, torch.Tensor], + head_idx: int = 0, + batch_idx: int = 0, + title: Optional[str] = None, + save_path: Optional[str] = None, + figsize: Tuple[int, int] = (10, 8), + colormap: Optional[str] = None, + show_colorbar: bool = True, + x_labels: Optional[List[str]] = None, + y_labels: Optional[List[str]] = None + ) -> Optional[Any]: + """ + Visualize attention weights as a heatmap. + + Args: + scores: AttentionScores object, numpy array, or tensor + head_idx: Attention head to visualize (default: 0) + batch_idx: Batch sample to visualize (default: 0) + title: Optional plot title + save_path: If provided, save figure to this path + figsize: Figure size (width, height) + colormap: Matplotlib colormap name + show_colorbar: Whether to show colorbar + x_labels: Optional labels for x-axis (key positions) + y_labels: Optional labels for y-axis (query positions) + + Returns: + Matplotlib figure object if matplotlib is available, None otherwise + """ + if not HAS_MATPLOTLIB: + print("matplotlib not available. Cannot visualize attention.") + return None + + # Extract numpy array from scores + if isinstance(scores, AttentionScores): + attention = scores.scores + elif isinstance(scores, torch.Tensor): + attention = scores.cpu().numpy() + else: + attention = scores + + # Get specific batch and head + if len(attention.shape) == 4: + attention_2d = attention[batch_idx, head_idx, :, :] + elif len(attention.shape) == 3: + attention_2d = attention[batch_idx, :, :] + else: + attention_2d = attention + + # Create figure + fig, ax = plt.subplots(figsize=figsize) + + # Create heatmap + cmap = colormap or self.default_colormap + im = ax.imshow(attention_2d, cmap=cmap, aspect='auto') + + # Add colorbar + if show_colorbar: + cbar = plt.colorbar(im, ax=ax) + cbar.set_label('Attention Weight') + + # Set title + if title: + ax.set_title(title) + else: + ax.set_title(f'Attention Heatmap (Head {head_idx})') + + # Set axis labels + ax.set_xlabel('Key Position') + ax.set_ylabel('Query Position') + + # Add custom tick labels if provided + if x_labels is not None: + ax.set_xticks(range(len(x_labels))) + ax.set_xticklabels(x_labels, rotation=45, ha='right') + + if y_labels is not None: + ax.set_yticks(range(len(y_labels))) + ax.set_yticklabels(y_labels) + + plt.tight_layout() + + # Save if path provided + if save_path: + plt.savefig(save_path, dpi=150, bbox_inches='tight') + + return fig + + def visualize_all_heads( + self, + scores: Union[AttentionScores, np.ndarray], + batch_idx: int = 0, + save_path: Optional[str] = None, + figsize_per_head: Tuple[int, int] = (4, 3) + ) -> Optional[Any]: + """ + Visualize attention from all heads in a grid. + + Args: + scores: AttentionScores object or numpy array + batch_idx: Batch sample to visualize + save_path: Optional path to save the figure + figsize_per_head: Size of each subplot + + Returns: + Matplotlib figure object + """ + if not HAS_MATPLOTLIB: + print("matplotlib not available.") + return None + + # Extract numpy array + if isinstance(scores, AttentionScores): + attention = scores.scores + n_heads = scores.n_heads + else: + attention = scores + n_heads = attention.shape[1] + + # Calculate grid dimensions + n_cols = min(4, n_heads) + n_rows = (n_heads + n_cols - 1) // n_cols + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=(figsize_per_head[0] * n_cols, figsize_per_head[1] * n_rows) + ) + axes = np.atleast_2d(axes) + + for head_idx in range(n_heads): + row = head_idx // n_cols + col = head_idx % n_cols + ax = axes[row, col] + + attention_2d = attention[batch_idx, head_idx, :, :] + im = ax.imshow(attention_2d, cmap=self.default_colormap, aspect='auto') + ax.set_title(f'Head {head_idx}') + ax.set_xlabel('Key') + ax.set_ylabel('Query') + + # Hide unused subplots + for idx in range(n_heads, n_rows * n_cols): + row = idx // n_cols + col = idx % n_cols + axes[row, col].axis('off') + + plt.suptitle('Attention Patterns Across All Heads') + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=150, bbox_inches='tight') + + return fig + + def compute_attention_statistics( + self, + scores: Union[AttentionScores, np.ndarray] + ) -> Dict[str, Any]: + """ + Compute statistics about attention patterns. + + Args: + scores: AttentionScores object or numpy array + + Returns: + Dictionary with attention statistics + """ + if isinstance(scores, AttentionScores): + attention = scores.scores + else: + attention = scores + + # Flatten for statistics + flat = attention.flatten() + + # Compute per-head statistics + head_stats = [] + for h in range(attention.shape[1]): + head_attn = attention[:, h, :, :] + head_stats.append({ + 'head': h, + 'mean': float(head_attn.mean()), + 'std': float(head_attn.std()), + 'max': float(head_attn.max()), + 'entropy': float(self._compute_entropy(head_attn)) + }) + + # Compute diagonal attention (self-attention strength) + batch_size, n_heads, seq_len, _ = attention.shape + diagonal_attention = np.zeros((batch_size, n_heads, seq_len)) + for i in range(seq_len): + diagonal_attention[:, :, i] = attention[:, :, i, i] + + return { + 'global': { + 'mean': float(flat.mean()), + 'std': float(flat.std()), + 'max': float(flat.max()), + 'min': float(flat.min()) + }, + 'per_head': head_stats, + 'diagonal_attention_mean': float(diagonal_attention.mean()), + 'sparsity': float((attention < 0.01).sum() / attention.size) + } + + def _compute_entropy(self, attention: np.ndarray) -> float: + """Compute entropy of attention distribution.""" + # Ensure attention is a probability distribution + attention = attention.clip(min=1e-10) + # Normalize if not already + attention = attention / attention.sum(axis=-1, keepdims=True) + # Compute entropy + entropy = -np.sum(attention * np.log(attention + 1e-10), axis=-1) + return float(entropy.mean()) + + def save_to_database( + self, + scores: AttentionScores, + symbol: str, + timestamp: datetime, + table_name: str = 'ml.attention_scores', + additional_data: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Save attention scores to PostgreSQL database. + + Args: + scores: AttentionScores object + symbol: Trading symbol (e.g., 'XAUUSD') + timestamp: Timestamp of the prediction + table_name: Database table name (default: ml.attention_scores) + additional_data: Additional data to store + + Returns: + True if save was successful, False otherwise + """ + if not HAS_SQLALCHEMY or self._engine is None: + print("SQLAlchemy not available or no database connection configured") + return False + + try: + # Prepare data for insertion + data = { + 'symbol': symbol, + 'timestamp': timestamp, + 'layer_idx': scores.layer_idx, + 'head_idx': scores.head_idx, + 'sequence_len': scores.sequence_len, + 'n_heads': scores.n_heads, + 'scores_json': json.dumps(scores.scores.tolist()), + 'metadata': json.dumps(scores.metadata), + 'statistics': json.dumps(self.compute_attention_statistics(scores)), + 'created_at': datetime.now() + } + + # Add additional data + if additional_data: + data['additional_data'] = json.dumps(additional_data) + + # Create insert query + columns = ', '.join(data.keys()) + placeholders = ', '.join([f':{k}' for k in data.keys()]) + query = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})" + + with self._engine.connect() as conn: + conn.execute(text(query), data) + conn.commit() + + return True + + except Exception as e: + print(f"Error saving to database: {e}") + return False + + def load_from_database( + self, + symbol: str, + start_timestamp: datetime, + end_timestamp: Optional[datetime] = None, + table_name: str = 'ml.attention_scores', + limit: int = 100 + ) -> List[AttentionScores]: + """ + Load attention scores from database. + + Args: + symbol: Trading symbol + start_timestamp: Start of time range + end_timestamp: End of time range (default: now) + table_name: Database table name + limit: Maximum number of records + + Returns: + List of AttentionScores objects + """ + if not HAS_SQLALCHEMY or self._engine is None: + print("SQLAlchemy not available or no database connection") + return [] + + end_timestamp = end_timestamp or datetime.now() + + query = f""" + SELECT symbol, timestamp, layer_idx, head_idx, sequence_len, + n_heads, scores_json, metadata + FROM {table_name} + WHERE symbol = :symbol + AND timestamp >= :start_timestamp + AND timestamp <= :end_timestamp + ORDER BY timestamp DESC + LIMIT :limit + """ + + try: + with self._engine.connect() as conn: + result = conn.execute(text(query), { + 'symbol': symbol, + 'start_timestamp': start_timestamp, + 'end_timestamp': end_timestamp, + 'limit': limit + }) + + scores_list = [] + for row in result: + scores = AttentionScores( + scores=np.array(json.loads(row.scores_json)), + layer_idx=row.layer_idx, + head_idx=row.head_idx, + sequence_len=row.sequence_len, + n_heads=row.n_heads, + metadata=json.loads(row.metadata) if row.metadata else {} + ) + scores_list.append(scores) + + return scores_list + + except Exception as e: + print(f"Error loading from database: {e}") + return [] + + +def extract_attention_for_analysis( + model: nn.Module, + x: torch.Tensor, + normalize: bool = True +) -> Dict[str, np.ndarray]: + """ + Extract attention weights for detailed analysis. + + Convenience function that extracts attention from all layers + and computes useful summary statistics. + + Args: + model: PriceFocusedAttention model + x: Input tensor (batch, seq_len, features) + normalize: Whether to normalize attention weights + + Returns: + Dictionary with: + - 'all_layers': List of attention arrays per layer + - 'mean_attention': Mean attention across all layers + - 'last_layer': Attention from the last layer + - 'head_importance': Importance score per head + """ + extractor = AttentionExtractor() + + # Get number of layers by doing a forward pass + model.eval() + with torch.no_grad(): + _, all_attentions = model(x, return_all_attentions=True) + + n_layers = len(all_attentions) + + # Extract attention from each layer + layer_attentions = [] + for layer_idx in range(n_layers): + scores = extractor.get_attention_scores(model, x, layer_idx=layer_idx) + layer_attentions.append(scores.scores) + + # Stack and compute mean + stacked = np.stack(layer_attentions, axis=0) # (n_layers, batch, heads, seq, seq) + mean_attention = stacked.mean(axis=0) # (batch, heads, seq, seq) + + # Compute head importance (variance of attention patterns) + head_importance = [] + for h in range(mean_attention.shape[1]): + head_attn = mean_attention[:, h, :, :] + importance = float(head_attn.std()) + head_importance.append(importance) + + # Normalize importance + head_importance = np.array(head_importance) + if normalize and head_importance.sum() > 0: + head_importance = head_importance / head_importance.sum() + + return { + 'all_layers': layer_attentions, + 'mean_attention': mean_attention, + 'last_layer': layer_attentions[-1], + 'head_importance': head_importance.tolist(), + 'n_layers': n_layers, + 'n_heads': mean_attention.shape[1] + } diff --git a/src/models/attention/multi_head_attention.py b/src/models/attention/multi_head_attention.py new file mode 100644 index 0000000..80e4eff --- /dev/null +++ b/src/models/attention/multi_head_attention.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Multi-Head Attention Implementation +==================================== +Implements scaled dot-product multi-head attention mechanism +following the Transformer architecture (Vaswani et al., 2017). + +This module provides a configurable multi-head attention layer +suitable for financial time series analysis. + +Key Features: +1. Configurable number of heads, d_model, d_k, d_v +2. Returns both output and attention weights +3. Supports optional masking +4. Uses einsum operations for clarity + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import math +from typing import Tuple, Optional + +import torch +import torch.nn as nn +import torch.nn.functional as F + + +class MultiHeadAttention(nn.Module): + """ + Multi-Head Attention mechanism with scaled dot-product attention. + + Implements the attention mechanism from "Attention Is All You Need": + Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) * V + + Then applies multi-head split and linear projections: + MultiHead(Q, K, V) = Concat(head_1, ..., head_h) * W^O + where head_i = Attention(Q*W_i^Q, K*W_i^K, V*W_i^V) + + Args: + d_model: Total dimension of the model (default: 256) + n_heads: Number of attention heads (default: 8) + d_k: Dimension of keys/queries per head (default: None, computed as d_model // n_heads) + d_v: Dimension of values per head (default: None, same as d_k) + dropout: Dropout probability on attention weights (default: 0.1) + bias: Whether to use bias in linear projections (default: True) + + Example: + >>> mha = MultiHeadAttention(d_model=256, n_heads=8) + >>> x = torch.randn(32, 100, 256) # (batch, seq_len, d_model) + >>> output, attn_weights = mha(x, x, x) + >>> print(output.shape) # (32, 100, 256) + >>> print(attn_weights.shape) # (32, 8, 100, 100) + """ + + def __init__( + self, + d_model: int = 256, + n_heads: int = 8, + d_k: Optional[int] = None, + d_v: Optional[int] = None, + dropout: float = 0.1, + bias: bool = True + ) -> None: + super().__init__() + + self.d_model = d_model + self.n_heads = n_heads + self.d_k = d_k if d_k is not None else d_model // n_heads + self.d_v = d_v if d_v is not None else self.d_k + + # Validate dimensions + if self.d_k * n_heads != d_model: + raise ValueError( + f"d_k ({self.d_k}) * n_heads ({n_heads}) must equal d_model ({d_model}) " + f"when using standard multi-head attention. " + f"Got {self.d_k * n_heads} != {d_model}" + ) + + # Linear projections for Q, K, V + self.w_q = nn.Linear(d_model, n_heads * self.d_k, bias=bias) + self.w_k = nn.Linear(d_model, n_heads * self.d_k, bias=bias) + self.w_v = nn.Linear(d_model, n_heads * self.d_v, bias=bias) + + # Output projection + self.w_o = nn.Linear(n_heads * self.d_v, d_model, bias=bias) + + # Dropout on attention weights + self.dropout = nn.Dropout(dropout) + + # Scaling factor for dot-product attention + self.scale = math.sqrt(self.d_k) + + # Initialize weights + self._init_weights() + + def _init_weights(self) -> None: + """Initialize weights using Xavier uniform initialization.""" + nn.init.xavier_uniform_(self.w_q.weight) + nn.init.xavier_uniform_(self.w_k.weight) + nn.init.xavier_uniform_(self.w_v.weight) + nn.init.xavier_uniform_(self.w_o.weight) + + if self.w_q.bias is not None: + nn.init.zeros_(self.w_q.bias) + nn.init.zeros_(self.w_k.bias) + nn.init.zeros_(self.w_v.bias) + nn.init.zeros_(self.w_o.bias) + + def forward( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + mask: Optional[torch.Tensor] = None, + return_attention: bool = True + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + Forward pass of multi-head attention. + + Args: + query: Query tensor of shape (batch, seq_len_q, d_model) + key: Key tensor of shape (batch, seq_len_k, d_model) + value: Value tensor of shape (batch, seq_len_v, d_model) + Note: seq_len_k must equal seq_len_v + mask: Optional attention mask of shape (batch, 1, 1, seq_len_k) + or (batch, 1, seq_len_q, seq_len_k) or (batch, n_heads, seq_len_q, seq_len_k) + Positions with True/1 will be masked (set to -inf before softmax) + return_attention: Whether to return attention weights (default: True) + + Returns: + output: Attended output of shape (batch, seq_len_q, d_model) + attention_weights: Attention weights of shape (batch, n_heads, seq_len_q, seq_len_k) + Returns None if return_attention is False + """ + batch_size, seq_len_q, _ = query.shape + seq_len_k = key.shape[1] + seq_len_v = value.shape[1] + + # Validate key and value sequence lengths match + if seq_len_k != seq_len_v: + raise ValueError( + f"Key sequence length ({seq_len_k}) must match value sequence length ({seq_len_v})" + ) + + # Project to Q, K, V + # Shape: (batch, seq_len, n_heads * d_k) -> (batch, seq_len, n_heads, d_k) + q = self.w_q(query).view(batch_size, seq_len_q, self.n_heads, self.d_k) + k = self.w_k(key).view(batch_size, seq_len_k, self.n_heads, self.d_k) + v = self.w_v(value).view(batch_size, seq_len_v, self.n_heads, self.d_v) + + # Transpose for attention: (batch, n_heads, seq_len, d_k/d_v) + q = q.transpose(1, 2) # (batch, n_heads, seq_len_q, d_k) + k = k.transpose(1, 2) # (batch, n_heads, seq_len_k, d_k) + v = v.transpose(1, 2) # (batch, n_heads, seq_len_v, d_v) + + # Compute attention scores: QK^T / sqrt(d_k) + # (batch, n_heads, seq_len_q, d_k) @ (batch, n_heads, d_k, seq_len_k) + # -> (batch, n_heads, seq_len_q, seq_len_k) + scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale + + # Apply mask if provided + if mask is not None: + # Convert boolean mask to float mask if needed + if mask.dtype == torch.bool: + scores = scores.masked_fill(mask, float('-inf')) + else: + # Assume mask contains 0s for keep and 1s for mask + scores = scores.masked_fill(mask > 0.5, float('-inf')) + + # Apply softmax to get attention weights + attention_weights = F.softmax(scores, dim=-1) + + # Apply dropout to attention weights + attention_weights = self.dropout(attention_weights) + + # Apply attention to values + # (batch, n_heads, seq_len_q, seq_len_k) @ (batch, n_heads, seq_len_v, d_v) + # -> (batch, n_heads, seq_len_q, d_v) + attended = torch.matmul(attention_weights, v) + + # Transpose and reshape: (batch, n_heads, seq_len_q, d_v) -> (batch, seq_len_q, n_heads * d_v) + attended = attended.transpose(1, 2).contiguous().view(batch_size, seq_len_q, -1) + + # Final linear projection + output = self.w_o(attended) + + if return_attention: + return output, attention_weights + else: + return output, None + + +class ScaledDotProductAttention(nn.Module): + """ + Scaled Dot-Product Attention (single head). + + This is the core attention mechanism without multi-head split. + Useful for understanding or debugging attention patterns. + + Computes: + Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) * V + + Args: + d_k: Dimension of keys/queries (for scaling) + dropout: Dropout probability (default: 0.1) + """ + + def __init__(self, d_k: int, dropout: float = 0.1) -> None: + super().__init__() + self.scale = math.sqrt(d_k) + self.dropout = nn.Dropout(dropout) + + def forward( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + mask: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Compute scaled dot-product attention. + + Args: + q: Query tensor (batch, ..., seq_len_q, d_k) + k: Key tensor (batch, ..., seq_len_k, d_k) + v: Value tensor (batch, ..., seq_len_v, d_v) + mask: Optional mask (batch, ..., seq_len_q, seq_len_k) + + Returns: + output: Attended values (batch, ..., seq_len_q, d_v) + attention_weights: Attention weights (batch, ..., seq_len_q, seq_len_k) + """ + # Compute attention scores + scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale + + # Apply mask if provided + if mask is not None: + if mask.dtype == torch.bool: + scores = scores.masked_fill(mask, float('-inf')) + else: + scores = scores.masked_fill(mask > 0.5, float('-inf')) + + # Softmax and dropout + attention_weights = F.softmax(scores, dim=-1) + attention_weights = self.dropout(attention_weights) + + # Apply attention to values + output = torch.matmul(attention_weights, v) + + return output, attention_weights + + +def create_causal_mask(seq_len: int, device: torch.device = None) -> torch.Tensor: + """ + Create a causal (lower triangular) attention mask. + + This mask prevents attention to future positions, suitable for + autoregressive models or sequence prediction tasks. + + Args: + seq_len: Length of the sequence + device: Device to create the mask on + + Returns: + mask: Boolean mask of shape (1, 1, seq_len, seq_len) + True values indicate positions to mask (future positions) + """ + # Create upper triangular matrix (True = mask) + mask = torch.triu(torch.ones(seq_len, seq_len, dtype=torch.bool, device=device), diagonal=1) + # Add batch and head dimensions + return mask.unsqueeze(0).unsqueeze(0) + + +def create_padding_mask( + lengths: torch.Tensor, + max_len: int, + device: torch.device = None +) -> torch.Tensor: + """ + Create a padding mask from sequence lengths. + + Args: + lengths: Tensor of shape (batch,) containing sequence lengths + max_len: Maximum sequence length + device: Device to create the mask on + + Returns: + mask: Boolean mask of shape (batch, 1, 1, max_len) + True values indicate padding positions to mask + """ + batch_size = lengths.shape[0] + # Create position indices + positions = torch.arange(max_len, device=device).unsqueeze(0).expand(batch_size, -1) + # Create mask where positions >= lengths + mask = positions >= lengths.unsqueeze(1) + # Add head and query dimensions + return mask.unsqueeze(1).unsqueeze(2) diff --git a/src/models/attention/positional_encoding.py b/src/models/attention/positional_encoding.py new file mode 100644 index 0000000..1bcf376 --- /dev/null +++ b/src/models/attention/positional_encoding.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +""" +Positional Encoding Module +=========================== +Implements learnable positional encoding for sequence models. + +This module provides a time-agnostic positional encoding that learns +relative positions in the sequence rather than using fixed sinusoidal +patterns or real timestamp information. + +Key Features: +1. Fully learnable position embeddings +2. NO dependency on real timestamps (time-agnostic) +3. Only encodes relative position in sequence +4. Configurable maximum sequence length +5. Optional dropout for regularization + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import math +from typing import Optional + +import torch +import torch.nn as nn + + +class LearnablePositionalEncoding(nn.Module): + """ + Learnable Positional Encoding layer. + + Unlike sinusoidal positional encoding, this module learns position + embeddings directly from data. This is more flexible for tasks where + the optimal positional representation is unknown. + + IMPORTANT: This encoding is time-AGNOSTIC. It does NOT use any + real timestamp information. It only encodes the relative position + of each element within the input sequence (0, 1, 2, ..., seq_len-1). + + This is crucial for our price-focused attention architecture where + we want the model to learn patterns based on price movements alone, + without any temporal bias. + + Args: + d_model: Dimension of the model embeddings (default: 256) + max_seq_len: Maximum sequence length supported (default: 512) + dropout: Dropout probability (default: 0.1) + + Example: + >>> pos_enc = LearnablePositionalEncoding(d_model=256, max_seq_len=512) + >>> x = torch.randn(32, 100, 256) # (batch, seq_len, d_model) + >>> x_encoded = pos_enc(x) + >>> print(x_encoded.shape) # (32, 100, 256) + """ + + def __init__( + self, + d_model: int = 256, + max_seq_len: int = 512, + dropout: float = 0.1 + ) -> None: + super().__init__() + + self.d_model = d_model + self.max_seq_len = max_seq_len + + # Learnable position embeddings + # Shape: (max_seq_len, d_model) + self.position_embeddings = nn.Parameter( + torch.zeros(max_seq_len, d_model) + ) + + # Dropout for regularization + self.dropout = nn.Dropout(p=dropout) + + # Initialize position embeddings + self._init_embeddings() + + def _init_embeddings(self) -> None: + """ + Initialize position embeddings using truncated normal distribution. + + Uses a standard deviation of 0.02, which is commonly used for + transformer position embeddings (as in BERT). + """ + nn.init.trunc_normal_(self.position_embeddings, std=0.02) + + def forward( + self, + x: torch.Tensor, + offset: int = 0 + ) -> torch.Tensor: + """ + Add positional encoding to input tensor. + + Args: + x: Input tensor of shape (batch, seq_len, d_model) + offset: Starting position offset (useful for incremental decoding) + Default is 0, meaning positions start from 0. + + Returns: + Tensor with positional encoding added, same shape as input + + Raises: + ValueError: If seq_len + offset exceeds max_seq_len + """ + batch_size, seq_len, d_model = x.shape + + # Validate sequence length + if seq_len + offset > self.max_seq_len: + raise ValueError( + f"Sequence length ({seq_len}) + offset ({offset}) = {seq_len + offset} " + f"exceeds maximum sequence length ({self.max_seq_len})" + ) + + # Get position embeddings for the sequence + # Shape: (seq_len, d_model) + positions = self.position_embeddings[offset:offset + seq_len] + + # Add positional encoding to input + # Broadcasting: (batch, seq_len, d_model) + (seq_len, d_model) + x = x + positions.unsqueeze(0) + + # Apply dropout + return self.dropout(x) + + def get_position_embedding(self, position: int) -> torch.Tensor: + """ + Get the embedding for a specific position. + + Args: + position: Position index (0 to max_seq_len - 1) + + Returns: + Position embedding tensor of shape (d_model,) + """ + if position < 0 or position >= self.max_seq_len: + raise ValueError( + f"Position {position} out of range [0, {self.max_seq_len})" + ) + return self.position_embeddings[position] + + +class SinusoidalPositionalEncoding(nn.Module): + """ + Sinusoidal Positional Encoding (fixed, not learnable). + + This is the original positional encoding from "Attention Is All You Need". + Provided for comparison/reference but NOT recommended for price-focused + attention since sinusoidal patterns may not be optimal for financial data. + + Formula: + PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) + PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model)) + + Args: + d_model: Dimension of the model embeddings + max_seq_len: Maximum sequence length + dropout: Dropout probability + """ + + def __init__( + self, + d_model: int = 256, + max_seq_len: int = 512, + dropout: float = 0.1 + ) -> None: + super().__init__() + + self.d_model = d_model + self.max_seq_len = max_seq_len + self.dropout = nn.Dropout(p=dropout) + + # Precompute sinusoidal positional encodings + pe = self._compute_sinusoidal_encoding(max_seq_len, d_model) + + # Register as buffer (not a parameter, but saved with model) + self.register_buffer('pe', pe) + + def _compute_sinusoidal_encoding( + self, + max_seq_len: int, + d_model: int + ) -> torch.Tensor: + """Compute sinusoidal positional encoding matrix.""" + # Create position indices: (max_seq_len, 1) + position = torch.arange(max_seq_len, dtype=torch.float).unsqueeze(1) + + # Create dimension indices for division term + div_term = torch.exp( + torch.arange(0, d_model, 2, dtype=torch.float) * + (-math.log(10000.0) / d_model) + ) + + # Compute sinusoidal encoding + pe = torch.zeros(max_seq_len, d_model) + pe[:, 0::2] = torch.sin(position * div_term) + pe[:, 1::2] = torch.cos(position * div_term) + + # Add batch dimension: (1, max_seq_len, d_model) + return pe.unsqueeze(0) + + def forward( + self, + x: torch.Tensor, + offset: int = 0 + ) -> torch.Tensor: + """ + Add sinusoidal positional encoding to input tensor. + + Args: + x: Input tensor of shape (batch, seq_len, d_model) + offset: Starting position offset + + Returns: + Tensor with positional encoding added + """ + seq_len = x.shape[1] + + if seq_len + offset > self.max_seq_len: + raise ValueError( + f"Sequence length ({seq_len}) + offset ({offset}) exceeds " + f"maximum sequence length ({self.max_seq_len})" + ) + + # Add positional encoding (buffer is already on correct device) + x = x + self.pe[:, offset:offset + seq_len, :] + + return self.dropout(x) + + +class RelativePositionalEncoding(nn.Module): + """ + Relative Positional Encoding for attention mechanisms. + + Instead of absolute positions, this encodes the relative distance + between query and key positions. This is useful when the absolute + position is less meaningful than the relative order. + + For price sequences, relative position (e.g., "3 candles ago") + is often more meaningful than absolute position. + + Args: + d_model: Dimension of position embeddings + max_distance: Maximum relative distance to encode (default: 128) + Distances beyond this are clipped. + n_heads: Number of attention heads (for head-specific biases) + """ + + def __init__( + self, + d_model: int = 256, + max_distance: int = 128, + n_heads: int = 8 + ) -> None: + super().__init__() + + self.d_model = d_model + self.max_distance = max_distance + self.n_heads = n_heads + + # Total number of relative positions: [-max_distance, ..., 0, ..., max_distance] + num_positions = 2 * max_distance + 1 + + # Learnable relative position embeddings + # Shape: (num_positions, d_model) + self.relative_embeddings = nn.Parameter( + torch.zeros(num_positions, d_model) + ) + + # Optional: per-head bias for relative positions + # Shape: (n_heads, num_positions) + self.head_bias = nn.Parameter( + torch.zeros(n_heads, num_positions) + ) + + # Initialize + nn.init.trunc_normal_(self.relative_embeddings, std=0.02) + nn.init.zeros_(self.head_bias) + + def get_relative_positions( + self, + seq_len_q: int, + seq_len_k: int, + device: torch.device + ) -> torch.Tensor: + """ + Compute relative position matrix. + + Args: + seq_len_q: Query sequence length + seq_len_k: Key sequence length + device: Device for the tensor + + Returns: + Relative position indices of shape (seq_len_q, seq_len_k) + Values are in range [0, 2*max_distance] after clipping and shifting + """ + # Create position indices + q_positions = torch.arange(seq_len_q, device=device).unsqueeze(1) + k_positions = torch.arange(seq_len_k, device=device).unsqueeze(0) + + # Compute relative positions (q_pos - k_pos) + relative_positions = q_positions - k_positions + + # Clip to [-max_distance, max_distance] + relative_positions = torch.clamp( + relative_positions, + -self.max_distance, + self.max_distance + ) + + # Shift to [0, 2*max_distance] for indexing + relative_positions = relative_positions + self.max_distance + + return relative_positions + + def forward( + self, + seq_len_q: int, + seq_len_k: int, + device: torch.device + ) -> torch.Tensor: + """ + Get relative position embeddings for attention. + + Args: + seq_len_q: Query sequence length + seq_len_k: Key sequence length + device: Device for the tensor + + Returns: + Relative position embeddings of shape (seq_len_q, seq_len_k, d_model) + """ + # Get relative position indices + rel_pos = self.get_relative_positions(seq_len_q, seq_len_k, device) + + # Gather embeddings + # rel_pos: (seq_len_q, seq_len_k) + # relative_embeddings: (num_positions, d_model) + embeddings = self.relative_embeddings[rel_pos] + + return embeddings + + def get_attention_bias( + self, + seq_len_q: int, + seq_len_k: int, + device: torch.device + ) -> torch.Tensor: + """ + Get per-head attention bias based on relative positions. + + This bias is added to attention scores before softmax. + + Args: + seq_len_q: Query sequence length + seq_len_k: Key sequence length + device: Device for the tensor + + Returns: + Attention bias of shape (n_heads, seq_len_q, seq_len_k) + """ + # Get relative position indices + rel_pos = self.get_relative_positions(seq_len_q, seq_len_k, device) + + # Gather per-head biases + # head_bias: (n_heads, num_positions) + # rel_pos: (seq_len_q, seq_len_k) + bias = self.head_bias[:, rel_pos.view(-1)].view( + self.n_heads, seq_len_q, seq_len_k + ) + + return bias diff --git a/src/models/attention/price_attention.py b/src/models/attention/price_attention.py new file mode 100644 index 0000000..13db203 --- /dev/null +++ b/src/models/attention/price_attention.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +""" +Price-Focused Attention Architecture +===================================== +Implements self-attention mechanism focused purely on price movements +(returns and derivatives) without any temporal bias. + +This module provides a transformer-based architecture that: +1. Operates on sequences of returns (not raw prices) +2. Is completely time-agnostic (no real timestamps) +3. Uses only price variation information +4. Learns to attend to significant price movements + +Key Features: +- Self-attention on return sequences +- Multi-head attention with 8 heads, d_model=256 +- 4 transformer encoder layers +- Feed-forward dimension d_ff=1024 +- Learnable positional encoding (sequence position only) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from typing import Tuple, List, Optional, Dict, Any +from dataclasses import dataclass + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .multi_head_attention import MultiHeadAttention, create_causal_mask +from .positional_encoding import LearnablePositionalEncoding + + +@dataclass +class PriceAttentionConfig: + """ + Configuration for Price-Focused Attention model. + + Default architecture: + - d_model: 256 (model dimension) + - n_heads: 8 (attention heads) + - d_k: 32 (key/query dimension per head) + - n_layers: 4 (transformer encoder layers) + - d_ff: 1024 (feed-forward dimension) + - dropout: 0.1 + - max_seq_len: 512 + """ + # Model dimensions + d_model: int = 256 + n_heads: int = 8 + d_k: int = 32 + d_v: int = 32 + + # Architecture + n_layers: int = 4 + d_ff: int = 1024 + max_seq_len: int = 512 + + # Regularization + dropout: float = 0.1 + attention_dropout: float = 0.1 + + # Input features + input_features: int = 4 # Default: returns, log_returns, range_pct, velocity + + # Activation + activation: str = "gelu" # "relu" or "gelu" + + # Pre-layer norm (more stable training) + pre_norm: bool = True + + +class FeedForwardBlock(nn.Module): + """ + Position-wise Feed-Forward Network. + + Implements: FFN(x) = max(0, xW1 + b1)W2 + b2 + With optional GELU activation instead of ReLU. + + Args: + d_model: Input and output dimension + d_ff: Hidden layer dimension + dropout: Dropout probability + activation: Activation function ("relu" or "gelu") + """ + + def __init__( + self, + d_model: int = 256, + d_ff: int = 1024, + dropout: float = 0.1, + activation: str = "gelu" + ) -> None: + super().__init__() + + self.linear1 = nn.Linear(d_model, d_ff) + self.linear2 = nn.Linear(d_ff, d_model) + self.dropout = nn.Dropout(dropout) + + if activation == "gelu": + self.activation = nn.GELU() + elif activation == "relu": + self.activation = nn.ReLU() + else: + raise ValueError(f"Unknown activation: {activation}") + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Forward pass. + + Args: + x: Input tensor (batch, seq_len, d_model) + + Returns: + Output tensor (batch, seq_len, d_model) + """ + x = self.linear1(x) + x = self.activation(x) + x = self.dropout(x) + x = self.linear2(x) + x = self.dropout(x) + return x + + +class PriceAttentionEncoderLayer(nn.Module): + """ + Single layer of the Price-Focused Attention encoder. + + Architecture (pre-norm): + x -> LayerNorm -> MultiHeadAttention -> Dropout -> + x + x -> LayerNorm -> FeedForward -> Dropout -> + x + + Args: + config: PriceAttentionConfig object + """ + + def __init__(self, config: PriceAttentionConfig) -> None: + super().__init__() + + self.config = config + + # Layer normalization + self.norm1 = nn.LayerNorm(config.d_model) + self.norm2 = nn.LayerNorm(config.d_model) + + # Self-attention + self.self_attention = MultiHeadAttention( + d_model=config.d_model, + n_heads=config.n_heads, + d_k=config.d_k, + d_v=config.d_v, + dropout=config.attention_dropout + ) + + # Feed-forward + self.feed_forward = FeedForwardBlock( + d_model=config.d_model, + d_ff=config.d_ff, + dropout=config.dropout, + activation=config.activation + ) + + # Dropout for residual connections + self.dropout = nn.Dropout(config.dropout) + + self.pre_norm = config.pre_norm + + def forward( + self, + x: torch.Tensor, + mask: Optional[torch.Tensor] = None, + return_attention: bool = True + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + Forward pass of encoder layer. + + Args: + x: Input tensor (batch, seq_len, d_model) + mask: Optional attention mask + return_attention: Whether to return attention weights + + Returns: + output: Output tensor (batch, seq_len, d_model) + attention_weights: Attention weights if return_attention is True + """ + if self.pre_norm: + # Pre-norm: LayerNorm before attention/ff + # Self-attention block + residual = x + x = self.norm1(x) + attn_output, attention_weights = self.self_attention( + x, x, x, mask=mask, return_attention=return_attention + ) + x = residual + self.dropout(attn_output) + + # Feed-forward block + residual = x + x = self.norm2(x) + ff_output = self.feed_forward(x) + x = residual + ff_output + else: + # Post-norm: LayerNorm after attention/ff + # Self-attention block + attn_output, attention_weights = self.self_attention( + x, x, x, mask=mask, return_attention=return_attention + ) + x = self.norm1(x + self.dropout(attn_output)) + + # Feed-forward block + ff_output = self.feed_forward(x) + x = self.norm2(x + ff_output) + + return x, attention_weights + + +class PriceFocusedAttention(nn.Module): + """ + Price-Focused Self-Attention model. + + This model implements a transformer encoder that operates on sequences + of price returns and derivatives. It is designed to be completely + time-agnostic, focusing only on price variation patterns. + + The model does NOT use: + - Real timestamps + - Session indicators + - Time-of-day features + - Day-of-week features + + The model ONLY uses: + - Returns (price changes) + - Log returns + - Range percentages + - Price velocity/momentum + - Other price-derived features + + Args: + config: PriceAttentionConfig object (optional, uses defaults if None) + input_features: Number of input features per timestep + Overrides config.input_features if provided + + Example: + >>> config = PriceAttentionConfig(d_model=256, n_heads=8, n_layers=4) + >>> model = PriceFocusedAttention(config, input_features=4) + >>> x = torch.randn(32, 100, 4) # (batch, seq_len, features) + >>> output, attentions = model(x) + >>> print(output.shape) # (32, 100, 256) + """ + + def __init__( + self, + config: Optional[PriceAttentionConfig] = None, + input_features: Optional[int] = None + ) -> None: + super().__init__() + + self.config = config or PriceAttentionConfig() + + # Override input features if provided + if input_features is not None: + self.config.input_features = input_features + + # Input projection: features -> d_model + self.input_projection = nn.Linear( + self.config.input_features, + self.config.d_model + ) + + # Learnable positional encoding (time-agnostic) + self.positional_encoding = LearnablePositionalEncoding( + d_model=self.config.d_model, + max_seq_len=self.config.max_seq_len, + dropout=self.config.dropout + ) + + # Encoder layers + self.encoder_layers = nn.ModuleList([ + PriceAttentionEncoderLayer(self.config) + for _ in range(self.config.n_layers) + ]) + + # Final layer normalization (for pre-norm architecture) + if self.config.pre_norm: + self.final_norm = nn.LayerNorm(self.config.d_model) + else: + self.final_norm = nn.Identity() + + # Initialize weights + self._init_weights() + + def _init_weights(self) -> None: + """Initialize model weights using Xavier initialization.""" + for name, param in self.named_parameters(): + if 'weight' in name and param.dim() > 1: + nn.init.xavier_uniform_(param) + elif 'bias' in name: + nn.init.zeros_(param) + + def forward( + self, + x: torch.Tensor, + mask: Optional[torch.Tensor] = None, + return_all_attentions: bool = False + ) -> Tuple[torch.Tensor, List[torch.Tensor]]: + """ + Forward pass of the Price-Focused Attention model. + + Args: + x: Input tensor of shape (batch, seq_len, input_features) + Contains return-based features (NO timestamps!) + mask: Optional attention mask of shape (batch, 1, seq_len, seq_len) + or (batch, 1, 1, seq_len) for causal masking + return_all_attentions: If True, returns attention from all layers + + Returns: + output: Encoded output of shape (batch, seq_len, d_model) + attentions: List of attention weight tensors, one per layer + Each has shape (batch, n_heads, seq_len, seq_len) + """ + batch_size, seq_len, _ = x.shape + + # Project input features to d_model + x = self.input_projection(x) + + # Add positional encoding (sequence position only, no timestamps!) + x = self.positional_encoding(x) + + # Pass through encoder layers + all_attentions = [] + for layer in self.encoder_layers: + x, attention_weights = layer( + x, mask=mask, return_attention=True + ) + if return_all_attentions and attention_weights is not None: + all_attentions.append(attention_weights) + + # Final layer normalization + x = self.final_norm(x) + + # If not returning all attentions, just return the last layer's + if not return_all_attentions and all_attentions: + all_attentions = [all_attentions[-1]] + + return x, all_attentions + + def get_attention_scores( + self, + x: torch.Tensor, + layer_idx: int = -1 + ) -> torch.Tensor: + """ + Get attention scores for a specific layer. + + Args: + x: Input tensor (batch, seq_len, input_features) + layer_idx: Layer index to extract attention from + -1 means last layer (default) + + Returns: + Attention scores of shape (batch, n_heads, seq_len, seq_len) + """ + _, all_attentions = self.forward(x, return_all_attentions=True) + + if not all_attentions: + raise ValueError("No attention scores available") + + return all_attentions[layer_idx] + + def encode_sequence( + self, + x: torch.Tensor, + pooling: str = "last" + ) -> torch.Tensor: + """ + Encode a sequence to a fixed-size representation. + + Args: + x: Input tensor (batch, seq_len, input_features) + pooling: Pooling method - "last", "first", "mean", or "max" + + Returns: + Encoded representation of shape (batch, d_model) + """ + output, _ = self.forward(x, return_all_attentions=False) + + if pooling == "last": + return output[:, -1, :] + elif pooling == "first": + return output[:, 0, :] + elif pooling == "mean": + return output.mean(dim=1) + elif pooling == "max": + return output.max(dim=1)[0] + else: + raise ValueError(f"Unknown pooling method: {pooling}") + + +class PriceAttentionEncoder(nn.Module): + """ + Complete Price-Focused Attention Encoder with prediction heads. + + This is a higher-level wrapper that adds task-specific output heads + to the base PriceFocusedAttention model. + + Supports multiple output modes: + - "sequence": Full sequence output (batch, seq_len, d_model) + - "last": Last position output (batch, d_model) + - "mean": Mean pooled output (batch, d_model) + + Args: + config: PriceAttentionConfig object + input_features: Number of input features + output_dim: Output dimension (default: d_model) + output_mode: Output pooling mode + """ + + def __init__( + self, + config: Optional[PriceAttentionConfig] = None, + input_features: int = 4, + output_dim: Optional[int] = None, + output_mode: str = "last" + ) -> None: + super().__init__() + + self.config = config or PriceAttentionConfig() + self.output_mode = output_mode + + # Base attention model + self.attention = PriceFocusedAttention( + config=self.config, + input_features=input_features + ) + + # Output projection if needed + if output_dim is not None and output_dim != self.config.d_model: + self.output_projection = nn.Linear(self.config.d_model, output_dim) + else: + self.output_projection = nn.Identity() + output_dim = self.config.d_model + + self.output_dim = output_dim + + def forward( + self, + x: torch.Tensor, + mask: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, List[torch.Tensor]]: + """ + Forward pass. + + Args: + x: Input tensor (batch, seq_len, input_features) + mask: Optional attention mask + + Returns: + output: Encoded output, shape depends on output_mode + attentions: List of attention weight tensors + """ + # Get full sequence encoding + encoded, attentions = self.attention(x, mask=mask, return_all_attentions=True) + + # Apply pooling based on output_mode + if self.output_mode == "sequence": + output = encoded + elif self.output_mode == "last": + output = encoded[:, -1, :] + elif self.output_mode == "first": + output = encoded[:, 0, :] + elif self.output_mode == "mean": + output = encoded.mean(dim=1) + elif self.output_mode == "max": + output = encoded.max(dim=1)[0] + else: + raise ValueError(f"Unknown output_mode: {self.output_mode}") + + # Apply output projection + output = self.output_projection(output) + + return output, attentions + + +def compute_return_features( + prices: torch.Tensor, + epsilon: float = 1e-8 +) -> torch.Tensor: + """ + Compute return-based features from price sequence. + + This function converts raw OHLC prices to return-based features + suitable for the Price-Focused Attention model. + + Args: + prices: Price tensor of shape (batch, seq_len, 4) + Expected order: [open, high, low, close] + epsilon: Small value for numerical stability + + Returns: + Features tensor of shape (batch, seq_len, 4): + - returns: (close_t - close_{t-1}) / close_{t-1} + - log_returns: log(close_t / close_{t-1}) + - range_pct: (high - low) / close + - velocity: close - open / close + """ + batch_size, seq_len, _ = prices.shape + + open_price = prices[:, :, 0] + high = prices[:, :, 1] + low = prices[:, :, 2] + close = prices[:, :, 3] + + # Simple returns + returns = torch.zeros_like(close) + returns[:, 1:] = (close[:, 1:] - close[:, :-1]) / (close[:, :-1] + epsilon) + + # Log returns + log_returns = torch.zeros_like(close) + log_returns[:, 1:] = torch.log((close[:, 1:] + epsilon) / (close[:, :-1] + epsilon)) + + # Range percentage + range_pct = (high - low) / (close + epsilon) + + # Velocity (intra-candle movement) + velocity = (close - open_price) / (close + epsilon) + + # Stack features + features = torch.stack([returns, log_returns, range_pct, velocity], dim=-1) + + return features diff --git a/src/models/metamodel/__init__.py b/src/models/metamodel/__init__.py new file mode 100644 index 0000000..7564c3d --- /dev/null +++ b/src/models/metamodel/__init__.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Neural Gating Metamodel Package +================================ +Combines 5 trading strategies (PVA, MRD, VBP, MSA, MTS) using a learned +neural gating network that adapts weights based on market context. + +Components: +- GatingNetwork: MLP-based network that outputs strategy weights +- EnsemblePipeline: Orchestrates predictions from all strategies +- ConfidenceCalibrator: Calibrates confidence scores to match accuracy +- NeuralGatingMetamodel: Complete metamodel integrating all components +- MetamodelTrainer: Training pipeline with walk-forward validation + +Key Features: +- Dynamic strategy weighting based on market context +- Entropy regularization prevents collapse to single strategy +- Calibrated confidence scores +- Interpretable strategy contributions +- Walk-forward training support + +Usage: + from src.models.metamodel import NeuralGatingMetamodel, MetamodelConfig + + # Initialize + config = MetamodelConfig() + model = NeuralGatingMetamodel(config) + + # Load strategies + model.load_strategies(['EURUSD', 'GBPUSD']) + + # Train + trainer = MetamodelTrainer(model) + trainer.walk_forward_train(df, 'EURUSD') + + # Predict + prediction = model.predict(df_ohlcv) + print(f"Direction: {prediction.direction:.3f}") + print(f"Confidence: {prediction.confidence:.3f}") + print(f"Dominant: {prediction.dominant_strategy}") + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Gating Network +from .gating_network import ( + GatingNetwork, + GatingConfig, + ContextEncoder, + StrategyOutputEncoder, +) + +# Ensemble Pipeline +from .ensemble_pipeline import ( + EnsemblePipeline, + EnsemblePrediction, + StrategyPredictions, +) + +# Calibration +from .calibration import ( + ConfidenceCalibrator, + CalibrationMetrics, + TemperatureScalingNN, + calibrate_ensemble_confidence, +) + +# Complete Metamodel +from .model import ( + NeuralGatingMetamodel, + MetamodelConfig, + MetamodelPrediction, +) + +# Trainer +from .trainer import ( + MetamodelTrainer, + TrainerConfig, + TrainingMetrics, +) + +__all__ = [ + # Gating Network + 'GatingNetwork', + 'GatingConfig', + 'ContextEncoder', + 'StrategyOutputEncoder', + # Ensemble Pipeline + 'EnsemblePipeline', + 'EnsemblePrediction', + 'StrategyPredictions', + # Calibration + 'ConfidenceCalibrator', + 'CalibrationMetrics', + 'TemperatureScalingNN', + 'calibrate_ensemble_confidence', + # Complete Metamodel + 'NeuralGatingMetamodel', + 'MetamodelConfig', + 'MetamodelPrediction', + # Trainer + 'MetamodelTrainer', + 'TrainerConfig', + 'TrainingMetrics', +] + +__version__ = '1.0.0' diff --git a/src/models/metamodel/calibration.py b/src/models/metamodel/calibration.py new file mode 100644 index 0000000..fa4aa8c --- /dev/null +++ b/src/models/metamodel/calibration.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +""" +Confidence Calibration Module +============================== +Calibrates model confidence scores to match empirical accuracy. + +Implements multiple calibration methods: +1. Isotonic Regression - Non-parametric, monotonic calibration +2. Platt Scaling - Parametric sigmoid calibration +3. Temperature Scaling - Simple division by learned temperature + +Includes evaluation metrics: +- Expected Calibration Error (ECE) +- Maximum Calibration Error (MCE) +- Reliability diagrams + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import torch +import torch.nn as nn +import torch.optim as optim +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass +from loguru import logger +from scipy.optimize import minimize +from sklearn.isotonic import IsotonicRegression +import warnings + + +@dataclass +class CalibrationMetrics: + """Metrics for evaluating calibration quality.""" + + ece: float # Expected Calibration Error + mce: float # Maximum Calibration Error + ace: float # Average Calibration Error + brier_score: float # Brier Score (for probabilistic accuracy) + reliability_data: Dict[str, np.ndarray] # Data for reliability diagram + + def to_dict(self) -> Dict[str, Any]: + return { + 'ece': float(self.ece), + 'mce': float(self.mce), + 'ace': float(self.ace), + 'brier_score': float(self.brier_score) + } + + +class ConfidenceCalibrator: + """ + Calibrates confidence scores to match empirical accuracy. + + Supports multiple calibration methods: + - isotonic: Non-parametric isotonic regression + - platt: Parametric Platt scaling (sigmoid) + - temperature: Temperature scaling (division by T) + - beta: Beta calibration (generalized sigmoid) + + Usage: + calibrator = ConfidenceCalibrator(method='isotonic') + calibrator.fit(y_true, y_prob) + + calibrated_probs = calibrator.calibrate(new_probs) + + metrics = calibrator.evaluate(y_true, y_prob) + print(f"ECE: {metrics.ece:.4f}") + """ + + SUPPORTED_METHODS = ['isotonic', 'platt', 'temperature', 'beta', 'ensemble'] + + def __init__( + self, + method: str = 'isotonic', + n_bins: int = 15, + device: str = 'cpu' + ): + """ + Initialize the calibrator. + + Args: + method: Calibration method ('isotonic', 'platt', 'temperature', 'beta', 'ensemble') + n_bins: Number of bins for ECE calculation + device: PyTorch device for temperature scaling + """ + if method not in self.SUPPORTED_METHODS: + raise ValueError(f"Unknown method: {method}. Supported: {self.SUPPORTED_METHODS}") + + self.method = method + self.n_bins = n_bins + self.device = device + + # Calibration models (initialized during fit) + self._isotonic: Optional[IsotonicRegression] = None + self._platt_params: Optional[Tuple[float, float]] = None # (a, b) for sigmoid + self._temperature: Optional[float] = None + self._beta_params: Optional[Tuple[float, float, float]] = None # (a, b, c) + + self._is_fitted = False + + logger.info(f"ConfidenceCalibrator initialized with method={method}") + + def fit( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + sample_weight: Optional[np.ndarray] = None + ) -> 'ConfidenceCalibrator': + """ + Fit the calibration model. + + Args: + y_true: True binary labels (0 or 1) + y_prob: Predicted probabilities (0 to 1) + sample_weight: Optional sample weights + + Returns: + Self for chaining + """ + y_true = np.asarray(y_true).ravel() + y_prob = np.asarray(y_prob).ravel() + + # Clip probabilities to avoid numerical issues + y_prob = np.clip(y_prob, 1e-8, 1 - 1e-8) + + if len(y_true) != len(y_prob): + raise ValueError("y_true and y_prob must have same length") + + if self.method == 'isotonic': + self._fit_isotonic(y_true, y_prob, sample_weight) + elif self.method == 'platt': + self._fit_platt(y_true, y_prob, sample_weight) + elif self.method == 'temperature': + self._fit_temperature(y_true, y_prob) + elif self.method == 'beta': + self._fit_beta(y_true, y_prob) + elif self.method == 'ensemble': + self._fit_ensemble(y_true, y_prob, sample_weight) + + self._is_fitted = True + logger.info(f"Calibrator fitted with {len(y_true)} samples") + + return self + + def _fit_isotonic( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + sample_weight: Optional[np.ndarray] + ): + """Fit isotonic regression calibrator.""" + self._isotonic = IsotonicRegression( + y_min=0.0, + y_max=1.0, + out_of_bounds='clip' + ) + self._isotonic.fit(y_prob, y_true, sample_weight=sample_weight) + + def _fit_platt( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + sample_weight: Optional[np.ndarray] + ): + """ + Fit Platt scaling (sigmoid calibration). + + P(y=1|f) = 1 / (1 + exp(a*f + b)) + where f = log(p / (1-p)) is the logit of original probability + """ + # Convert to logits + logits = np.log(y_prob / (1 - y_prob)) + + # Optimize sigmoid parameters using NLL loss + def nll_loss(params): + a, b = params + calibrated = 1.0 / (1.0 + np.exp(a * logits + b)) + calibrated = np.clip(calibrated, 1e-8, 1 - 1e-8) + + loss = -np.mean( + y_true * np.log(calibrated) + + (1 - y_true) * np.log(1 - calibrated) + ) + + if sample_weight is not None: + loss = -np.average( + y_true * np.log(calibrated) + + (1 - y_true) * np.log(1 - calibrated), + weights=sample_weight + ) + + return loss + + # Initialize and optimize + result = minimize( + nll_loss, + x0=[1.0, 0.0], + method='L-BFGS-B', + bounds=[(-10, 10), (-10, 10)] + ) + + self._platt_params = (result.x[0], result.x[1]) + logger.debug(f"Platt params: a={result.x[0]:.4f}, b={result.x[1]:.4f}") + + def _fit_temperature(self, y_true: np.ndarray, y_prob: np.ndarray): + """ + Fit temperature scaling. + + calibrated_prob = softmax(logits / T) + For binary case: calibrated = sigmoid(logit / T) + """ + # Convert to logits + logits = np.log(y_prob / (1 - y_prob)) + + # Find optimal temperature using NLL + def nll_loss(temperature): + t = temperature[0] + if t <= 0: + return 1e10 + + calibrated = 1.0 / (1.0 + np.exp(-logits / t)) + calibrated = np.clip(calibrated, 1e-8, 1 - 1e-8) + + loss = -np.mean( + y_true * np.log(calibrated) + + (1 - y_true) * np.log(1 - calibrated) + ) + return loss + + result = minimize( + nll_loss, + x0=[1.0], + method='L-BFGS-B', + bounds=[(0.01, 10.0)] + ) + + self._temperature = result.x[0] + logger.debug(f"Temperature: T={self._temperature:.4f}") + + def _fit_beta(self, y_true: np.ndarray, y_prob: np.ndarray): + """ + Fit beta calibration. + + calibrated = 1 / (1 + 1/(exp(c) * (p^a / (1-p)^b))) + + This is a more flexible version of Platt scaling. + """ + # Avoid log(0) + p = np.clip(y_prob, 1e-8, 1 - 1e-8) + + def nll_loss(params): + a, b, c = params + + # Beta calibration formula + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ratio = np.exp(c) * np.power(p, a) / np.power(1 - p, b) + + calibrated = ratio / (1 + ratio) + calibrated = np.clip(calibrated, 1e-8, 1 - 1e-8) + + loss = -np.mean( + y_true * np.log(calibrated) + + (1 - y_true) * np.log(1 - calibrated) + ) + return loss + + # Try multiple initializations + best_result = None + best_loss = float('inf') + + for init in [(1, 1, 0), (0.5, 0.5, 0), (2, 2, 0)]: + try: + result = minimize( + nll_loss, + x0=init, + method='L-BFGS-B', + bounds=[(0.01, 10), (0.01, 10), (-5, 5)] + ) + if result.fun < best_loss: + best_loss = result.fun + best_result = result + except Exception: + continue + + if best_result is not None: + self._beta_params = tuple(best_result.x) + logger.debug(f"Beta params: a={best_result.x[0]:.4f}, " + f"b={best_result.x[1]:.4f}, c={best_result.x[2]:.4f}") + else: + # Fallback to identity + self._beta_params = (1.0, 1.0, 0.0) + + def _fit_ensemble( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + sample_weight: Optional[np.ndarray] + ): + """Fit ensemble of calibrators and use best one.""" + # Fit all methods + self._fit_isotonic(y_true, y_prob, sample_weight) + self._fit_platt(y_true, y_prob, sample_weight) + self._fit_temperature(y_true, y_prob) + + # Evaluate each on training data + methods = ['isotonic', 'platt', 'temperature'] + eces = [] + + for method in methods: + calibrated = self._calibrate_with_method(y_prob, method) + ece = self._compute_ece(y_true, calibrated) + eces.append(ece) + + # Select best method + best_idx = np.argmin(eces) + self._ensemble_best = methods[best_idx] + logger.info(f"Ensemble selected: {self._ensemble_best} (ECE={eces[best_idx]:.4f})") + + def calibrate(self, probabilities: np.ndarray) -> np.ndarray: + """ + Calibrate probabilities using fitted model. + + Args: + probabilities: Uncalibrated probabilities (0 to 1) + + Returns: + Calibrated probabilities + """ + if not self._is_fitted: + raise RuntimeError("Calibrator must be fitted before calibrating") + + probabilities = np.asarray(probabilities).ravel() + probabilities = np.clip(probabilities, 1e-8, 1 - 1e-8) + + if self.method == 'ensemble': + return self._calibrate_with_method(probabilities, self._ensemble_best) + + return self._calibrate_with_method(probabilities, self.method) + + def _calibrate_with_method( + self, + probabilities: np.ndarray, + method: str + ) -> np.ndarray: + """Calibrate using specified method.""" + probabilities = np.clip(probabilities, 1e-8, 1 - 1e-8) + + if method == 'isotonic': + if self._isotonic is None: + return probabilities + return self._isotonic.predict(probabilities) + + elif method == 'platt': + if self._platt_params is None: + return probabilities + a, b = self._platt_params + logits = np.log(probabilities / (1 - probabilities)) + return 1.0 / (1.0 + np.exp(a * logits + b)) + + elif method == 'temperature': + if self._temperature is None: + return probabilities + logits = np.log(probabilities / (1 - probabilities)) + return 1.0 / (1.0 + np.exp(-logits / self._temperature)) + + elif method == 'beta': + if self._beta_params is None: + return probabilities + a, b, c = self._beta_params + p = probabilities + ratio = np.exp(c) * np.power(p, a) / np.power(1 - p, b) + return ratio / (1 + ratio) + + else: + return probabilities + + def _compute_ece( + self, + y_true: np.ndarray, + y_prob: np.ndarray + ) -> float: + """Compute Expected Calibration Error.""" + bin_boundaries = np.linspace(0, 1, self.n_bins + 1) + bin_lowers = bin_boundaries[:-1] + bin_uppers = bin_boundaries[1:] + + ece = 0.0 + for bin_lower, bin_upper in zip(bin_lowers, bin_uppers): + in_bin = (y_prob > bin_lower) & (y_prob <= bin_upper) + prop_in_bin = in_bin.mean() + + if prop_in_bin > 0: + avg_confidence = y_prob[in_bin].mean() + avg_accuracy = y_true[in_bin].mean() + ece += np.abs(avg_confidence - avg_accuracy) * prop_in_bin + + return ece + + def compute_expected_calibration_error( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + calibrated: bool = True + ) -> float: + """ + Compute ECE (Expected Calibration Error). + + ECE = sum_b (|B_b|/n) * |acc(B_b) - conf(B_b)| + + Args: + y_true: True labels + y_prob: Predicted probabilities + calibrated: If True, calibrate probabilities first + + Returns: + ECE value (lower is better) + """ + y_true = np.asarray(y_true).ravel() + y_prob = np.asarray(y_prob).ravel() + + if calibrated and self._is_fitted: + y_prob = self.calibrate(y_prob) + + return self._compute_ece(y_true, y_prob) + + def compute_reliability_diagram( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + calibrated: bool = True + ) -> Dict[str, np.ndarray]: + """ + Compute data for reliability diagram. + + Args: + y_true: True labels + y_prob: Predicted probabilities + calibrated: If True, calibrate probabilities first + + Returns: + Dictionary with: + - bin_centers: Center of each bin + - bin_accuracies: Average accuracy in each bin + - bin_confidences: Average confidence in each bin + - bin_counts: Number of samples in each bin + - perfect_calibration: Diagonal line (for reference) + """ + y_true = np.asarray(y_true).ravel() + y_prob = np.asarray(y_prob).ravel() + + if calibrated and self._is_fitted: + y_prob = self.calibrate(y_prob) + + bin_boundaries = np.linspace(0, 1, self.n_bins + 1) + bin_lowers = bin_boundaries[:-1] + bin_uppers = bin_boundaries[1:] + bin_centers = (bin_lowers + bin_uppers) / 2 + + bin_accuracies = [] + bin_confidences = [] + bin_counts = [] + + for bin_lower, bin_upper in zip(bin_lowers, bin_uppers): + in_bin = (y_prob > bin_lower) & (y_prob <= bin_upper) + count = in_bin.sum() + + if count > 0: + bin_accuracies.append(y_true[in_bin].mean()) + bin_confidences.append(y_prob[in_bin].mean()) + else: + bin_accuracies.append(np.nan) + bin_confidences.append(np.nan) + + bin_counts.append(count) + + return { + 'bin_centers': bin_centers, + 'bin_accuracies': np.array(bin_accuracies), + 'bin_confidences': np.array(bin_confidences), + 'bin_counts': np.array(bin_counts), + 'perfect_calibration': np.linspace(0, 1, 100) + } + + def evaluate( + self, + y_true: np.ndarray, + y_prob: np.ndarray, + calibrated: bool = True + ) -> CalibrationMetrics: + """ + Evaluate calibration quality with multiple metrics. + + Args: + y_true: True labels + y_prob: Predicted probabilities + calibrated: If True, evaluate calibrated probabilities + + Returns: + CalibrationMetrics object + """ + y_true = np.asarray(y_true).ravel() + y_prob = np.asarray(y_prob).ravel() + + if calibrated and self._is_fitted: + y_prob_cal = self.calibrate(y_prob) + else: + y_prob_cal = y_prob + + # ECE + ece = self._compute_ece(y_true, y_prob_cal) + + # MCE (Maximum Calibration Error) + bin_boundaries = np.linspace(0, 1, self.n_bins + 1) + bin_lowers = bin_boundaries[:-1] + bin_uppers = bin_boundaries[1:] + + bin_errors = [] + for bin_lower, bin_upper in zip(bin_lowers, bin_uppers): + in_bin = (y_prob_cal > bin_lower) & (y_prob_cal <= bin_upper) + if in_bin.sum() > 0: + avg_conf = y_prob_cal[in_bin].mean() + avg_acc = y_true[in_bin].mean() + bin_errors.append(np.abs(avg_conf - avg_acc)) + + mce = max(bin_errors) if bin_errors else 0.0 + + # ACE (Average Calibration Error - unweighted ECE) + ace = np.mean(bin_errors) if bin_errors else 0.0 + + # Brier Score + brier_score = np.mean((y_prob_cal - y_true) ** 2) + + # Reliability diagram data + reliability_data = self.compute_reliability_diagram(y_true, y_prob, calibrated) + + return CalibrationMetrics( + ece=ece, + mce=mce, + ace=ace, + brier_score=brier_score, + reliability_data=reliability_data + ) + + +class TemperatureScalingNN(nn.Module): + """ + Temperature scaling implemented as a PyTorch module. + + Useful for end-to-end training with neural networks. + """ + + def __init__(self, init_temperature: float = 1.5): + """ + Initialize temperature scaling module. + + Args: + init_temperature: Initial temperature value + """ + super().__init__() + self.temperature = nn.Parameter(torch.tensor([init_temperature])) + + def forward(self, logits: torch.Tensor) -> torch.Tensor: + """ + Apply temperature scaling to logits. + + Args: + logits: Raw logits from model + + Returns: + Temperature-scaled logits + """ + return logits / self.temperature + + def calibrate_probs(self, probs: torch.Tensor) -> torch.Tensor: + """ + Calibrate probabilities (convert to logits, scale, convert back). + + Args: + probs: Probability tensor + + Returns: + Calibrated probabilities + """ + # Convert to logits + probs = torch.clamp(probs, 1e-8, 1 - 1e-8) + logits = torch.log(probs / (1 - probs)) + + # Scale + scaled_logits = self.forward(logits) + + # Convert back to probabilities + return torch.sigmoid(scaled_logits) + + +def calibrate_ensemble_confidence( + predictions: List[Dict[str, float]], + y_true: np.ndarray, + method: str = 'isotonic' +) -> ConfidenceCalibrator: + """ + Convenience function to calibrate ensemble confidence scores. + + Args: + predictions: List of prediction dictionaries with 'confidence' key + y_true: True binary outcomes + method: Calibration method + + Returns: + Fitted ConfidenceCalibrator + """ + confidences = np.array([p.get('confidence', 0.5) for p in predictions]) + calibrator = ConfidenceCalibrator(method=method) + calibrator.fit(y_true, confidences) + return calibrator + + +if __name__ == "__main__": + # Test calibration module + np.random.seed(42) + + print("Testing ConfidenceCalibrator") + print("=" * 60) + + # Generate synthetic uncalibrated probabilities + # Overconfident model + n_samples = 1000 + true_probs = np.random.beta(2, 2, n_samples) + y_true = (np.random.rand(n_samples) < true_probs).astype(int) + + # Simulate overconfident predictions + y_prob = np.clip(true_probs * 1.3, 0.01, 0.99) + + print(f"Samples: {n_samples}") + print(f"True positive rate: {y_true.mean():.3f}") + print(f"Mean predicted probability: {y_prob.mean():.3f}") + + # Test each calibration method + methods = ['isotonic', 'platt', 'temperature', 'beta', 'ensemble'] + + for method in methods: + print(f"\n--- {method.upper()} Calibration ---") + + calibrator = ConfidenceCalibrator(method=method) + calibrator.fit(y_true[:800], y_prob[:800]) + + # Evaluate on held-out data + y_test = y_true[800:] + prob_test = y_prob[800:] + + # Before calibration + before_metrics = calibrator.evaluate(y_test, prob_test, calibrated=False) + print(f"Before: ECE={before_metrics.ece:.4f}, Brier={before_metrics.brier_score:.4f}") + + # After calibration + after_metrics = calibrator.evaluate(y_test, prob_test, calibrated=True) + print(f"After: ECE={after_metrics.ece:.4f}, Brier={after_metrics.brier_score:.4f}") + + improvement = (before_metrics.ece - after_metrics.ece) / before_metrics.ece * 100 + print(f"ECE Improvement: {improvement:.1f}%") + + # Test reliability diagram + print("\n--- Reliability Diagram Data ---") + calibrator = ConfidenceCalibrator(method='isotonic') + calibrator.fit(y_true[:800], y_prob[:800]) + + diagram_data = calibrator.compute_reliability_diagram(y_true[800:], y_prob[800:]) + print(f"Bin centers: {diagram_data['bin_centers'][:5]}...") + print(f"Bin accuracies: {diagram_data['bin_accuracies'][:5]}...") + print(f"Bin counts: {diagram_data['bin_counts'][:5]}...") + + print("\nConfidenceCalibrator test complete!") diff --git a/src/models/metamodel/ensemble_pipeline.py b/src/models/metamodel/ensemble_pipeline.py new file mode 100644 index 0000000..1b997be --- /dev/null +++ b/src/models/metamodel/ensemble_pipeline.py @@ -0,0 +1,799 @@ +#!/usr/bin/env python3 +""" +Ensemble Pipeline +================== +Orchestrates the 5 trading strategies and combines their predictions +using the neural gating network. + +Pipeline Steps: +1. Load all 5 strategy models for given symbols +2. Generate predictions from each strategy +3. Extract market context features +4. Apply gating network to compute weights +5. Combine predictions into final ensemble output + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +import torch +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +from loguru import logger +from concurrent.futures import ThreadPoolExecutor +import time + +from .gating_network import GatingNetwork, GatingConfig, ContextEncoder, StrategyOutputEncoder + + +@dataclass +class EnsemblePrediction: + """Final ensemble prediction combining all strategies.""" + + # Core predictions + direction: float # -1 (bearish) to 1 (bullish) + magnitude: float # Expected return magnitude (%) + confidence: float # 0 to 1, calibrated confidence + + # Strategy weights + strategy_weights: Dict[str, float] # Weight per strategy + dominant_strategy: str # Strategy with highest weight + + # Individual strategy predictions + strategy_predictions: Dict[str, Dict[str, float]] + + # Metadata + timestamp: Optional[pd.Timestamp] = None + symbol: Optional[str] = None + processing_time_ms: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'direction': float(self.direction), + 'magnitude': float(self.magnitude), + 'confidence': float(self.confidence), + 'strategy_weights': self.strategy_weights, + 'dominant_strategy': self.dominant_strategy, + 'strategy_predictions': self.strategy_predictions, + 'timestamp': str(self.timestamp) if self.timestamp else None, + 'symbol': self.symbol, + 'processing_time_ms': self.processing_time_ms + } + + @property + def signal(self) -> str: + """Get trading signal string.""" + if self.confidence < 0.5: + return 'HOLD' + if self.direction > 0.3: + return 'BUY' + elif self.direction < -0.3: + return 'SELL' + return 'HOLD' + + @property + def signal_strength(self) -> str: + """Get signal strength classification.""" + strength = abs(self.direction) * self.confidence + if strength > 0.7: + return 'STRONG' + elif strength > 0.4: + return 'MODERATE' + elif strength > 0.2: + return 'WEAK' + return 'NEUTRAL' + + +@dataclass +class StrategyPredictions: + """Container for all strategy predictions.""" + + pva: Optional[Dict[str, float]] = None + mrd: Optional[Dict[str, float]] = None + vbp: Optional[Dict[str, float]] = None + msa: Optional[Dict[str, float]] = None + mts: Optional[Dict[str, float]] = None + + def to_dict(self) -> Dict[str, Optional[Dict[str, float]]]: + return { + 'pva': self.pva, + 'mrd': self.mrd, + 'vbp': self.vbp, + 'msa': self.msa, + 'mts': self.mts + } + + def get_available(self) -> Dict[str, Dict[str, float]]: + """Get only non-None predictions.""" + return { + k: v for k, v in self.to_dict().items() + if v is not None + } + + +class EnsemblePipeline: + """ + Pipeline for ensemble prediction using 5 trading strategies. + + Orchestrates: + - PVA (Price Variation Attention) + - MRD (Momentum Regime Detection) + - VBP (Volatility Breakout Predictor) + - MSA (Market Structure Analysis) + - MTS (Multi-Timeframe Synthesis) + + Usage: + pipeline = EnsemblePipeline() + pipeline.load_strategies(symbols=['EURUSD', 'GBPUSD']) + + prediction = pipeline.predict(df_ohlcv) + print(f"Direction: {prediction.direction:.3f}") + print(f"Dominant strategy: {prediction.dominant_strategy}") + """ + + def __init__( + self, + gating_config: Optional[GatingConfig] = None, + models_dir: Optional[str] = None, + device: Optional[str] = None, + parallel_inference: bool = True + ): + """ + Initialize the ensemble pipeline. + + Args: + gating_config: Configuration for gating network + models_dir: Directory containing saved strategy models + device: PyTorch device ('cuda' or 'cpu') + parallel_inference: Run strategy inference in parallel + """ + self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + self.models_dir = Path(models_dir) if models_dir else None + self.parallel_inference = parallel_inference + + # Initialize components + self.gating_config = gating_config or GatingConfig() + self.gating_network = GatingNetwork(self.gating_config).to(self.device) + self.context_encoder = ContextEncoder().to(self.device) + self.strategy_encoder = StrategyOutputEncoder().to(self.device) + + # Strategy models (loaded lazily) + self.strategies: Dict[str, Any] = {} + self.loaded_symbols: List[str] = [] + + # Performance tracking + self._prediction_times: List[float] = [] + + logger.info( + f"EnsemblePipeline initialized: device={self.device}, " + f"parallel={parallel_inference}" + ) + + def load_strategies( + self, + symbols: List[str], + strategies_to_load: Optional[List[str]] = None + ) -> Dict[str, bool]: + """ + Load strategy models for given symbols. + + Args: + symbols: List of trading symbols to load models for + strategies_to_load: Specific strategies to load (default: all) + + Returns: + Dictionary mapping strategy name to load success status + """ + strategy_names = strategies_to_load or ['pva', 'mrd', 'vbp', 'msa', 'mts'] + load_status = {} + + for strategy in strategy_names: + try: + model = self._load_strategy_model(strategy, symbols) + if model is not None: + self.strategies[strategy] = model + load_status[strategy] = True + logger.info(f"Loaded {strategy.upper()} model") + else: + load_status[strategy] = False + logger.warning(f"Failed to load {strategy.upper()} model") + except Exception as e: + load_status[strategy] = False + logger.error(f"Error loading {strategy.upper()}: {e}") + + self.loaded_symbols = symbols + logger.info(f"Loaded {sum(load_status.values())}/{len(strategy_names)} strategies") + + return load_status + + def _load_strategy_model( + self, + strategy_name: str, + symbols: List[str] + ) -> Optional[Any]: + """ + Load a specific strategy model. + + Args: + strategy_name: Name of strategy to load + symbols: Symbols the model should support + + Returns: + Loaded model or None if loading fails + """ + if self.models_dir is None: + # Return mock model for testing + return self._create_mock_model(strategy_name) + + model_path = self.models_dir / strategy_name + + if not model_path.exists(): + logger.warning(f"Model path does not exist: {model_path}") + return self._create_mock_model(strategy_name) + + try: + # Import and load strategy-specific model + if strategy_name == 'pva': + from ..strategies.pva import PVAModel + model = PVAModel.load(str(model_path), device=self.device) + elif strategy_name == 'mrd': + from ..strategies.mrd import MRDModel + model = MRDModel() + model.load(str(model_path)) + elif strategy_name == 'vbp': + from ..strategies.vbp import VBPModel + model = VBPModel() + model.load(str(model_path)) + elif strategy_name == 'msa': + from ..strategies.msa import MSAModel + model = MSAModel() + model.load(str(model_path)) + elif strategy_name == 'mts': + from ..strategies.mts import MTSModel + model = MTSModel() + model.load(str(model_path)) + else: + return None + + return model + + except Exception as e: + logger.error(f"Failed to load {strategy_name}: {e}") + return self._create_mock_model(strategy_name) + + def _create_mock_model(self, strategy_name: str) -> 'MockStrategyModel': + """Create a mock model for testing when real model is unavailable.""" + return MockStrategyModel(strategy_name) + + def predict_all_strategies( + self, + df: pd.DataFrame, + symbol: Optional[str] = None + ) -> StrategyPredictions: + """ + Generate predictions from all loaded strategies. + + Args: + df: OHLCV DataFrame with required data + symbol: Trading symbol (optional) + + Returns: + StrategyPredictions container with all predictions + """ + predictions = StrategyPredictions() + + if self.parallel_inference and len(self.strategies) > 1: + # Run predictions in parallel + with ThreadPoolExecutor(max_workers=5) as executor: + futures = { + name: executor.submit(self._predict_strategy, name, model, df) + for name, model in self.strategies.items() + } + + for name, future in futures.items(): + try: + result = future.result(timeout=10.0) + setattr(predictions, name, result) + except Exception as e: + logger.warning(f"Strategy {name} prediction failed: {e}") + else: + # Sequential prediction + for name, model in self.strategies.items(): + try: + result = self._predict_strategy(name, model, df) + setattr(predictions, name, result) + except Exception as e: + logger.warning(f"Strategy {name} prediction failed: {e}") + + return predictions + + def _predict_strategy( + self, + strategy_name: str, + model: Any, + df: pd.DataFrame + ) -> Dict[str, float]: + """ + Generate prediction from a single strategy. + + Args: + strategy_name: Name of strategy + model: Strategy model instance + df: OHLCV DataFrame + + Returns: + Dictionary with prediction values + """ + try: + if strategy_name == 'pva': + return self._predict_pva(model, df) + elif strategy_name == 'mrd': + return self._predict_mrd(model, df) + elif strategy_name == 'vbp': + return self._predict_vbp(model, df) + elif strategy_name == 'msa': + return self._predict_msa(model, df) + elif strategy_name == 'mts': + return self._predict_mts(model, df) + else: + raise ValueError(f"Unknown strategy: {strategy_name}") + except Exception as e: + logger.error(f"Error in {strategy_name} prediction: {e}") + return self._get_neutral_prediction(strategy_name) + + def _predict_pva(self, model: Any, df: pd.DataFrame) -> Dict[str, float]: + """Generate PVA prediction.""" + if isinstance(model, MockStrategyModel): + return model.predict(df) + + pred = model.predict(df) + return { + 'direction': float(pred.direction), + 'magnitude': float(pred.magnitude), + 'confidence': float(pred.confidence), + 'raw_prediction': float(pred.raw_prediction) + } + + def _predict_mrd(self, model: Any, df: pd.DataFrame) -> Dict[str, float]: + """Generate MRD prediction.""" + if isinstance(model, MockStrategyModel): + return model.predict(df) + + pred = model.predict(df) + # Convert regime to direction + regime_to_direction = {0: -1.0, 1: 0.0, 2: 1.0} # down, range, up + return { + 'regime': float(pred.regime), + 'duration': float(pred.duration), + 'continuation_prob': float(pred.continuation_prob), + 'reversal_prob': float(pred.reversal_prob), + 'regime_confidence': float(pred.regime_confidence) + } + + def _predict_vbp(self, model: Any, df: pd.DataFrame) -> Dict[str, float]: + """Generate VBP prediction.""" + if isinstance(model, MockStrategyModel): + return model.predict(df) + + preds = model.predict(df) + pred = preds[-1] if isinstance(preds, list) else preds + return { + 'breakout_prob': float(pred.breakout_probability), + 'direction': float(pred.direction), + 'magnitude': float(pred.magnitude), + 'confidence': float(pred.confidence), + 'is_breakout': float(pred.is_breakout) + } + + def _predict_msa(self, model: Any, df: pd.DataFrame) -> Dict[str, float]: + """Generate MSA prediction.""" + if isinstance(model, MockStrategyModel): + return model.predict(df) + + preds = model.predict(df) + pred = preds[-1] if isinstance(preds, list) else preds + + # Map direction to float + direction_map = {'neutral': 0.0, 'bullish': 1.0, 'bearish': -1.0} + direction = direction_map.get(pred.next_bos_direction, 0.0) + + return { + 'bos_conf': float(pred.bos_confidence), + 'poi_reaction_prob': float(pred.poi_reaction_prob), + 'structure_continuation_prob': float(pred.structure_continuation_prob), + 'is_bullish': 1.0 if direction > 0 else 0.0, + 'is_bearish': 1.0 if direction < 0 else 0.0 + } + + def _predict_mts(self, model: Any, df: pd.DataFrame) -> Dict[str, float]: + """Generate MTS prediction.""" + if isinstance(model, MockStrategyModel): + return model.predict(df) + + pred = model.predict(df) + return { + 'unified_direction': float(pred.unified_direction), + 'confidence': float(pred.confidence), + 'confidence_by_alignment': float(pred.confidence_by_alignment), + 'signal_strength': float(pred.signal_strength), + 'is_buy': 1.0 if pred.recommended_action == 'buy' else 0.0 + } + + def _get_neutral_prediction(self, strategy_name: str) -> Dict[str, float]: + """Get neutral prediction for a strategy.""" + neutral_predictions = { + 'pva': {'direction': 0.0, 'magnitude': 0.0, 'confidence': 0.0, 'raw_prediction': 0.0}, + 'mrd': {'regime': 1.0, 'duration': 0.0, 'continuation_prob': 0.5, + 'reversal_prob': 0.5, 'regime_confidence': 0.0}, + 'vbp': {'breakout_prob': 0.0, 'direction': 0.0, 'magnitude': 0.0, + 'confidence': 0.0, 'is_breakout': 0.0}, + 'msa': {'bos_conf': 0.0, 'poi_reaction_prob': 0.0, + 'structure_continuation_prob': 0.5, 'is_bullish': 0.0, 'is_bearish': 0.0}, + 'mts': {'unified_direction': 0.0, 'confidence': 0.0, + 'confidence_by_alignment': 0.0, 'signal_strength': 0.0, 'is_buy': 0.0} + } + return neutral_predictions.get(strategy_name, {}) + + def extract_market_context(self, df: pd.DataFrame) -> torch.Tensor: + """ + Extract market context features from OHLCV data. + + Features include: + - Volatility metrics + - Trend indicators + - Volume characteristics + - Recent performance + + Args: + df: OHLCV DataFrame + + Returns: + Context tensor (1, context_dim) + """ + features = [] + + # Price-based features + close = df['close'].values + high = df['high'].values + low = df['low'].values + volume = df['volume'].values if 'volume' in df.columns else np.ones(len(df)) + + # Returns + returns = np.diff(close) / close[:-1] + returns = np.concatenate([[0], returns]) + + # Volatility (multiple windows) + for window in [10, 20, 50]: + if len(returns) >= window: + vol = pd.Series(returns).rolling(window).std().iloc[-1] + features.append(vol if not np.isnan(vol) else 0.0) + else: + features.append(0.0) + + # ATR + tr = np.maximum(high - low, + np.maximum(np.abs(high - np.roll(close, 1)), + np.abs(low - np.roll(close, 1)))) + for window in [14, 28]: + if len(tr) >= window: + atr = pd.Series(tr).rolling(window).mean().iloc[-1] + features.append(atr / close[-1] if close[-1] > 0 else 0.0) + else: + features.append(0.0) + + # Trend strength (simple moving average slope) + for window in [10, 20, 50]: + if len(close) >= window: + sma = pd.Series(close).rolling(window).mean() + slope = (sma.iloc[-1] - sma.iloc[-min(5, window)]) / close[-1] + features.append(slope if not np.isnan(slope) else 0.0) + else: + features.append(0.0) + + # Momentum + for period in [5, 10, 20]: + if len(close) > period: + mom = (close[-1] - close[-period-1]) / close[-period-1] + features.append(mom) + else: + features.append(0.0) + + # Volume features + if len(volume) >= 20: + vol_ma = pd.Series(volume).rolling(20).mean().iloc[-1] + vol_ratio = volume[-1] / vol_ma if vol_ma > 0 else 1.0 + features.append(vol_ratio) + else: + features.append(1.0) + + # Recent performance (cumulative returns) + for period in [5, 10, 20]: + if len(returns) >= period: + cum_ret = np.sum(returns[-period:]) + features.append(cum_ret) + else: + features.append(0.0) + + # Range position (0-1) + if len(close) >= 20: + recent_high = high[-20:].max() + recent_low = low[-20:].min() + range_pos = (close[-1] - recent_low) / (recent_high - recent_low + 1e-8) + features.append(range_pos) + else: + features.append(0.5) + + # Pad to expected dimension + expected_dim = 50 + while len(features) < expected_dim: + features.append(0.0) + + features = features[:expected_dim] + + # Convert to tensor + context = torch.tensor(features, dtype=torch.float32, device=self.device) + context = context.unsqueeze(0) # Add batch dimension + + # Encode context + with torch.no_grad(): + encoded = self.context_encoder(context) + + return encoded + + def apply_gating( + self, + strategy_predictions: StrategyPredictions, + context: torch.Tensor + ) -> Tuple[torch.Tensor, Dict[str, float]]: + """ + Apply gating network to combine strategy predictions. + + Args: + strategy_predictions: All strategy predictions + context: Encoded market context + + Returns: + Tuple of (weights tensor, weights dict) + """ + # Encode strategy outputs to tensor format + strategy_tensors = {} + available = strategy_predictions.get_available() + + for name, pred in available.items(): + # Convert prediction dict to tensor + values = list(pred.values())[:5] # Take first 5 values + while len(values) < 5: + values.append(0.0) + + tensor = torch.tensor(values, dtype=torch.float32, device=self.device) + tensor = tensor.unsqueeze(0) # Add batch dimension + + # Encode to common dimension + encoded = self.strategy_encoder(name, tensor) + strategy_tensors[name] = encoded + + # Compute gating weights + self.gating_network.eval() + with torch.no_grad(): + weights = self.gating_network(strategy_tensors, context) + + # Convert to dict + weights_dict = { + name: float(weights[0, i].item()) + for i, name in enumerate(self.gating_network.STRATEGY_NAMES) + } + + return weights, weights_dict + + def predict( + self, + df: pd.DataFrame, + symbol: Optional[str] = None + ) -> EnsemblePrediction: + """ + Generate ensemble prediction combining all strategies. + + Args: + df: OHLCV DataFrame + symbol: Trading symbol (optional) + + Returns: + EnsemblePrediction with combined prediction + """ + start_time = time.time() + + # Step 1: Get predictions from all strategies + strategy_preds = self.predict_all_strategies(df, symbol) + + # Step 2: Extract market context + context = self.extract_market_context(df) + + # Step 3: Apply gating to get weights + weights_tensor, weights_dict = self.apply_gating(strategy_preds, context) + + # Step 4: Combine predictions + available = strategy_preds.get_available() + + # Compute weighted direction + total_direction = 0.0 + total_magnitude = 0.0 + total_confidence = 0.0 + total_weight = 0.0 + + for name, pred in available.items(): + weight = weights_dict.get(name, 0.0) + + # Extract direction from each strategy + if name == 'pva': + direction = pred.get('direction', 0.0) + magnitude = pred.get('magnitude', 0.0) + confidence = pred.get('confidence', 0.0) + elif name == 'mrd': + # Map regime to direction + regime = pred.get('regime', 1.0) + direction = {0: -1.0, 1: 0.0, 2: 1.0}.get(int(regime), 0.0) + direction *= pred.get('continuation_prob', 0.5) + magnitude = 0.01 # MRD doesn't predict magnitude directly + confidence = pred.get('regime_confidence', 0.0) + elif name == 'vbp': + direction = pred.get('direction', 0.0) + magnitude = pred.get('magnitude', 0.0) + confidence = pred.get('confidence', 0.0) * pred.get('breakout_prob', 0.0) + elif name == 'msa': + is_bull = pred.get('is_bullish', 0.0) + is_bear = pred.get('is_bearish', 0.0) + direction = is_bull - is_bear + direction *= pred.get('bos_conf', 0.0) + magnitude = 0.01 + confidence = pred.get('bos_conf', 0.0) + elif name == 'mts': + direction = pred.get('unified_direction', 0.0) + magnitude = 0.01 + confidence = pred.get('confidence', 0.0) + else: + continue + + total_direction += direction * weight + total_magnitude += magnitude * weight + total_confidence += confidence * weight + total_weight += weight + + # Normalize + if total_weight > 0: + total_direction /= total_weight + total_magnitude /= total_weight + total_confidence /= total_weight + + # Clamp values + total_direction = np.clip(total_direction, -1.0, 1.0) + total_confidence = np.clip(total_confidence, 0.0, 1.0) + + # Find dominant strategy + dominant_strategy = max(weights_dict.items(), key=lambda x: x[1])[0] + + # Calculate processing time + processing_time = (time.time() - start_time) * 1000 + + return EnsemblePrediction( + direction=float(total_direction), + magnitude=float(total_magnitude), + confidence=float(total_confidence), + strategy_weights=weights_dict, + dominant_strategy=dominant_strategy, + strategy_predictions={k: v for k, v in available.items()}, + timestamp=df.index[-1] if hasattr(df.index, '__getitem__') else None, + symbol=symbol, + processing_time_ms=processing_time + ) + + +class MockStrategyModel: + """Mock strategy model for testing when real models are unavailable.""" + + def __init__(self, strategy_name: str): + self.strategy_name = strategy_name + self._is_trained = True + + def predict(self, df: pd.DataFrame) -> Dict[str, float]: + """Generate random mock prediction.""" + np.random.seed(hash(str(df.index[-1])) % (2**32 - 1) if len(df) > 0 else 42) + + if self.strategy_name == 'pva': + return { + 'direction': np.random.uniform(-1, 1), + 'magnitude': np.random.uniform(0, 0.02), + 'confidence': np.random.uniform(0.3, 0.8), + 'raw_prediction': np.random.uniform(-0.01, 0.01) + } + elif self.strategy_name == 'mrd': + return { + 'regime': float(np.random.choice([0, 1, 2])), + 'duration': float(np.random.randint(1, 50)), + 'continuation_prob': np.random.uniform(0.4, 0.8), + 'reversal_prob': np.random.uniform(0.2, 0.6), + 'regime_confidence': np.random.uniform(0.5, 0.9) + } + elif self.strategy_name == 'vbp': + return { + 'breakout_prob': np.random.uniform(0, 1), + 'direction': np.random.choice([-1.0, 0.0, 1.0]), + 'magnitude': np.random.uniform(0, 0.03), + 'confidence': np.random.uniform(0.3, 0.8), + 'is_breakout': float(np.random.random() > 0.7) + } + elif self.strategy_name == 'msa': + return { + 'bos_conf': np.random.uniform(0.3, 0.9), + 'poi_reaction_prob': np.random.uniform(0.2, 0.8), + 'structure_continuation_prob': np.random.uniform(0.3, 0.7), + 'is_bullish': float(np.random.random() > 0.5), + 'is_bearish': float(np.random.random() > 0.5) + } + elif self.strategy_name == 'mts': + return { + 'unified_direction': np.random.uniform(-1, 1), + 'confidence': np.random.uniform(0.4, 0.9), + 'confidence_by_alignment': np.random.uniform(0.3, 0.8), + 'signal_strength': np.random.uniform(0.2, 0.7), + 'is_buy': float(np.random.random() > 0.5) + } + else: + return {'value': 0.0} + + +if __name__ == "__main__": + # Test the ensemble pipeline + torch.manual_seed(42) + np.random.seed(42) + + print("Testing EnsemblePipeline") + print("=" * 60) + + # Create sample OHLCV data + n_samples = 200 + dates = pd.date_range('2025-01-01', periods=n_samples, freq='5min') + + np.random.seed(42) + price = 100 * np.cumprod(1 + np.random.randn(n_samples) * 0.001) + + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(n_samples) * 0.001), + 'high': price * (1 + np.abs(np.random.randn(n_samples) * 0.002)), + 'low': price * (1 - np.abs(np.random.randn(n_samples) * 0.002)), + 'close': price, + 'volume': np.random.randint(1000, 10000, n_samples) + }, index=dates) + + # Initialize pipeline + pipeline = EnsemblePipeline() + + # Load strategies (will use mock models) + load_status = pipeline.load_strategies(['EURUSD']) + print(f"\nLoad status: {load_status}") + + # Generate prediction + prediction = pipeline.predict(df, symbol='EURUSD') + + print(f"\n--- Ensemble Prediction ---") + print(f"Direction: {prediction.direction:.4f}") + print(f"Magnitude: {prediction.magnitude:.4f}") + print(f"Confidence: {prediction.confidence:.4f}") + print(f"Signal: {prediction.signal}") + print(f"Signal Strength: {prediction.signal_strength}") + print(f"Dominant Strategy: {prediction.dominant_strategy}") + print(f"Processing Time: {prediction.processing_time_ms:.2f} ms") + + print(f"\n--- Strategy Weights ---") + for name, weight in prediction.strategy_weights.items(): + print(f" {name.upper()}: {weight:.4f}") + + print(f"\n--- Individual Predictions ---") + for name, pred in prediction.strategy_predictions.items(): + print(f" {name.upper()}: {pred}") + + print("\nEnsemblePipeline test complete!") diff --git a/src/models/metamodel/gating_network.py b/src/models/metamodel/gating_network.py new file mode 100644 index 0000000..2c94c41 --- /dev/null +++ b/src/models/metamodel/gating_network.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +""" +Neural Gating Network +====================== +MLP-based gating network that learns to weight different strategy predictions +based on market context. Outputs softmax weights that sum to 1. + +Architecture: + Input: [strategy_outputs (n_strategies * d_strategy), market_context (d_context)] + -> Linear(input_dim, 256) + BatchNorm + ReLU + Dropout(0.3) + -> Linear(256, 128) + BatchNorm + ReLU + Dropout(0.3) + -> Linear(128, n_strategies) + -> Softmax + Output: weights (n_strategies,) that sum to 1 + +The gating network learns which strategy performs best in different market conditions. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import torch +import torch.nn as nn +import torch.nn.functional as F +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass +import numpy as np +from loguru import logger + + +@dataclass +class GatingConfig: + """Configuration for the Gating Network.""" + + n_strategies: int = 5 # PVA, MRD, VBP, MSA, MTS + strategy_output_dim: int = 8 # Features per strategy output + context_dim: int = 32 # Market context features + hidden_dims: Tuple[int, ...] = (256, 128) + dropout: float = 0.3 + temperature: float = 1.0 # Softmax temperature + entropy_weight: float = 0.1 # Regularization weight for entropy + min_weight: float = 0.02 # Minimum weight per strategy (anti-collapse) + + +class GatingNetwork(nn.Module): + """ + Neural Gating Network for strategy weighting. + + Learns to dynamically weight different trading strategies based on + current market context. Uses entropy regularization to prevent + collapse to a single strategy. + + Architecture: + - 3-layer MLP with BatchNorm and Dropout + - Softmax output with temperature scaling + - Optional entropy bonus for regularization + + Usage: + config = GatingConfig(n_strategies=5) + gating = GatingNetwork(config) + + # Strategy outputs: dict mapping strategy name to encoded features + strategy_outputs = { + 'pva': torch.randn(batch, 8), + 'mrd': torch.randn(batch, 8), + ... + } + context = torch.randn(batch, 32) # Market context features + + weights = gating(strategy_outputs, context) + # weights: (batch, n_strategies) summing to 1 + """ + + STRATEGY_NAMES = ['pva', 'mrd', 'vbp', 'msa', 'mts'] + + def __init__(self, config: Optional[GatingConfig] = None): + """ + Initialize the Gating Network. + + Args: + config: Gating configuration + """ + super().__init__() + + self.config = config or GatingConfig() + + # Calculate input dimension + strategy_input_dim = self.config.n_strategies * self.config.strategy_output_dim + total_input_dim = strategy_input_dim + self.config.context_dim + + # Build MLP layers + layers = [] + in_dim = total_input_dim + + for hidden_dim in self.config.hidden_dims: + layers.extend([ + nn.Linear(in_dim, hidden_dim), + nn.BatchNorm1d(hidden_dim), + nn.ReLU(inplace=True), + nn.Dropout(self.config.dropout) + ]) + in_dim = hidden_dim + + # Output layer + layers.append(nn.Linear(in_dim, self.config.n_strategies)) + + self.mlp = nn.Sequential(*layers) + + # Strategy feature encoders (project each strategy output to common dim) + self.strategy_encoders = nn.ModuleDict({ + name: nn.Linear(self.config.strategy_output_dim, self.config.strategy_output_dim) + for name in self.STRATEGY_NAMES + }) + + # Context encoder + self.context_encoder = nn.Sequential( + nn.Linear(self.config.context_dim, self.config.context_dim), + nn.LayerNorm(self.config.context_dim), + nn.ReLU() + ) + + # Initialize weights + self._init_weights() + + logger.info( + f"GatingNetwork initialized: " + f"input_dim={total_input_dim}, " + f"n_strategies={self.config.n_strategies}, " + f"hidden_dims={self.config.hidden_dims}" + ) + + def _init_weights(self): + """Initialize network weights.""" + for module in self.modules(): + if isinstance(module, nn.Linear): + nn.init.kaiming_normal_(module.weight, mode='fan_out', nonlinearity='relu') + if module.bias is not None: + nn.init.zeros_(module.bias) + elif isinstance(module, nn.BatchNorm1d): + nn.init.ones_(module.weight) + nn.init.zeros_(module.bias) + + # Initialize output layer to produce uniform weights initially + with torch.no_grad(): + for layer in self.mlp: + if isinstance(layer, nn.Linear) and layer.out_features == self.config.n_strategies: + nn.init.zeros_(layer.weight) + nn.init.zeros_(layer.bias) + + def forward( + self, + strategy_outputs: Dict[str, torch.Tensor], + market_context: torch.Tensor, + return_logits: bool = False + ) -> torch.Tensor: + """ + Forward pass to compute strategy weights. + + Args: + strategy_outputs: Dictionary mapping strategy name to output tensor + Each tensor should be (batch, strategy_output_dim) + market_context: Market context features (batch, context_dim) + return_logits: If True, return raw logits instead of softmax weights + + Returns: + weights: Strategy weights (batch, n_strategies) summing to 1 + or logits if return_logits=True + """ + batch_size = market_context.size(0) + device = market_context.device + + # Encode and concatenate strategy outputs + encoded_strategies = [] + for name in self.STRATEGY_NAMES: + if name in strategy_outputs: + encoded = self.strategy_encoders[name](strategy_outputs[name]) + else: + # Use zeros for missing strategies + encoded = torch.zeros( + batch_size, self.config.strategy_output_dim, device=device + ) + encoded_strategies.append(encoded) + + # Concatenate all strategy outputs: (batch, n_strategies * strategy_output_dim) + strategy_features = torch.cat(encoded_strategies, dim=1) + + # Encode context + context_features = self.context_encoder(market_context) + + # Concatenate strategy and context features + combined = torch.cat([strategy_features, context_features], dim=1) + + # Forward through MLP + logits = self.mlp(combined) + + if return_logits: + return logits + + # Apply temperature-scaled softmax + weights = F.softmax(logits / self.config.temperature, dim=1) + + # Apply minimum weight constraint (anti-collapse) + weights = self._apply_min_weight(weights) + + return weights + + def _apply_min_weight(self, weights: torch.Tensor) -> torch.Tensor: + """ + Apply minimum weight constraint to prevent strategy collapse. + + Ensures each strategy has at least min_weight contribution. + + Args: + weights: Raw softmax weights (batch, n_strategies) + + Returns: + Adjusted weights still summing to 1 + """ + min_w = self.config.min_weight + n = self.config.n_strategies + + # Calculate available weight for redistribution + floor = min_w * n # Total minimum allocation + if floor >= 1.0: + # If minimum total exceeds 1, return uniform + return torch.ones_like(weights) / n + + # Clamp to minimum and rescale remaining weight + clamped = torch.clamp(weights, min=min_w) + excess = (clamped.sum(dim=1, keepdim=True) - 1.0) + + # Redistribute excess proportionally from weights above minimum + above_min = clamped - min_w + above_min_sum = above_min.sum(dim=1, keepdim=True) + 1e-8 + adjusted = clamped - excess * (above_min / above_min_sum) + + # Ensure non-negative and renormalize + adjusted = F.relu(adjusted) + 1e-8 + adjusted = adjusted / adjusted.sum(dim=1, keepdim=True) + + return adjusted + + def compute_entropy_loss(self, weights: torch.Tensor) -> torch.Tensor: + """ + Compute entropy bonus/penalty for regularization. + + Higher entropy = more uniform distribution = less specialization + Lower entropy = more concentrated = stronger specialization + + We want moderate entropy to prevent collapse while allowing specialization. + + Args: + weights: Strategy weights (batch, n_strategies) + + Returns: + Negative entropy (to minimize) scaled by entropy_weight + """ + # Entropy: -sum(p * log(p)) + entropy = -torch.sum(weights * torch.log(weights + 1e-8), dim=1) + + # Target entropy: ~70% of max entropy (balanced specialization) + max_entropy = np.log(self.config.n_strategies) + target_entropy = 0.7 * max_entropy + + # Loss: penalize deviation from target entropy + entropy_loss = (entropy - target_entropy).pow(2).mean() + + return self.config.entropy_weight * entropy_loss + + def get_strategy_weights( + self, + strategy_outputs: Dict[str, torch.Tensor], + market_context: torch.Tensor + ) -> Dict[str, float]: + """ + Get strategy weights as a dictionary. + + Args: + strategy_outputs: Strategy output tensors + market_context: Market context tensor + + Returns: + Dictionary mapping strategy name to weight + """ + self.eval() + with torch.no_grad(): + weights = self.forward(strategy_outputs, market_context) + weights = weights.mean(dim=0).cpu().numpy() + + return { + name: float(weights[i]) + for i, name in enumerate(self.STRATEGY_NAMES) + } + + +class ContextEncoder(nn.Module): + """ + Encoder for market context features. + + Takes raw market data and produces a context embedding that captures + current market conditions relevant for strategy selection. + + Features extracted: + - Volatility metrics (ATR, realized vol, implied vol proxy) + - Trend strength (ADX, directional movement) + - Market structure (range vs trending) + - Volume characteristics + - Recent performance metrics + """ + + def __init__( + self, + input_dim: int = 50, + output_dim: int = 32, + hidden_dim: int = 64 + ): + """ + Initialize context encoder. + + Args: + input_dim: Number of raw context features + output_dim: Output embedding dimension + hidden_dim: Hidden layer dimension + """ + super().__init__() + + self.encoder = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.LayerNorm(hidden_dim), + nn.ReLU(), + nn.Dropout(0.2), + nn.Linear(hidden_dim, hidden_dim), + nn.LayerNorm(hidden_dim), + nn.ReLU(), + nn.Dropout(0.2), + nn.Linear(hidden_dim, output_dim), + nn.LayerNorm(output_dim) + ) + + self.output_dim = output_dim + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Encode market context. + + Args: + x: Raw context features (batch, input_dim) + + Returns: + Context embedding (batch, output_dim) + """ + return self.encoder(x) + + +class StrategyOutputEncoder(nn.Module): + """ + Encodes strategy prediction outputs to a common representation. + + Each strategy produces different outputs: + - PVA: direction, magnitude, confidence + - MRD: regime, duration, continuation_prob + - VBP: breakout_prob, direction, magnitude + - MSA: next_bos_direction, poi_reaction_prob, structure_continuation + - MTS: unified_direction, confidence_by_alignment, optimal_entry_tf + + This encoder normalizes them to a common embedding space. + """ + + STRATEGY_INPUT_DIMS = { + 'pva': 4, # direction, magnitude, confidence, raw_prediction + 'mrd': 5, # regime, duration, continuation_prob, reversal_prob, regime_confidence + 'vbp': 5, # breakout_prob, direction, magnitude, confidence, is_breakout + 'msa': 5, # bos_conf, poi_reaction_prob, structure_continuation_prob, is_bullish, is_bearish + 'mts': 5, # unified_direction, confidence, confidence_by_alignment, signal_strength, is_buy + } + + def __init__(self, output_dim: int = 8): + """ + Initialize strategy output encoder. + + Args: + output_dim: Common output dimension for all strategies + """ + super().__init__() + + self.output_dim = output_dim + + # Create encoder for each strategy + self.encoders = nn.ModuleDict({ + name: nn.Sequential( + nn.Linear(in_dim, 16), + nn.LayerNorm(16), + nn.ReLU(), + nn.Linear(16, output_dim), + nn.LayerNorm(output_dim) + ) + for name, in_dim in self.STRATEGY_INPUT_DIMS.items() + }) + + def forward( + self, + strategy_name: str, + features: torch.Tensor + ) -> torch.Tensor: + """ + Encode strategy output to common space. + + Args: + strategy_name: Name of the strategy + features: Strategy output features + + Returns: + Encoded features (batch, output_dim) + """ + if strategy_name not in self.encoders: + raise ValueError(f"Unknown strategy: {strategy_name}") + + return self.encoders[strategy_name](features) + + def encode_all( + self, + strategy_outputs: Dict[str, torch.Tensor] + ) -> Dict[str, torch.Tensor]: + """ + Encode all strategy outputs. + + Args: + strategy_outputs: Dictionary of strategy outputs + + Returns: + Dictionary of encoded outputs + """ + return { + name: self.forward(name, features) + for name, features in strategy_outputs.items() + if name in self.encoders + } + + +if __name__ == "__main__": + # Test the gating network + torch.manual_seed(42) + + print("Testing GatingNetwork") + print("=" * 60) + + config = GatingConfig( + n_strategies=5, + strategy_output_dim=8, + context_dim=32 + ) + + gating = GatingNetwork(config) + + # Create dummy inputs + batch_size = 16 + + strategy_outputs = { + 'pva': torch.randn(batch_size, 8), + 'mrd': torch.randn(batch_size, 8), + 'vbp': torch.randn(batch_size, 8), + 'msa': torch.randn(batch_size, 8), + 'mts': torch.randn(batch_size, 8), + } + + context = torch.randn(batch_size, 32) + + # Forward pass + weights = gating(strategy_outputs, context) + + print(f"Input batch size: {batch_size}") + print(f"Output weights shape: {weights.shape}") + print(f"Weights sum: {weights.sum(dim=1)}") + print(f"Sample weights: {weights[0].detach().numpy()}") + + # Check entropy loss + entropy_loss = gating.compute_entropy_loss(weights) + print(f"Entropy loss: {entropy_loss.item():.4f}") + + # Test with missing strategy + print("\n--- Testing with missing strategy ---") + partial_outputs = {k: v for k, v in strategy_outputs.items() if k != 'mts'} + weights_partial = gating(partial_outputs, context) + print(f"Partial weights: {weights_partial[0].detach().numpy()}") + + # Test minimum weight constraint + print("\n--- Testing minimum weight constraint ---") + print(f"Min weight per strategy: {config.min_weight}") + print(f"All weights >= min: {(weights >= config.min_weight - 0.01).all()}") + + print("\nGatingNetwork test complete!") diff --git a/src/models/metamodel/model.py b/src/models/metamodel/model.py new file mode 100644 index 0000000..76ef8bc --- /dev/null +++ b/src/models/metamodel/model.py @@ -0,0 +1,960 @@ +#!/usr/bin/env python3 +""" +Neural Gating Metamodel - Complete Model +========================================= +Full metamodel that integrates: +1. GatingNetwork - Learns strategy weights from market context +2. EnsemblePipeline - Orchestrates 5 strategy predictions +3. ConfidenceCalibrator - Calibrates final confidence scores + +Produces interpretable predictions with: +- direction: -1 to 1 (bearish to bullish) +- magnitude: Expected return percentage +- confidence: 0 to 1 (calibrated) +- strategy_weights: Contribution of each strategy +- dominant_strategy: Most influential strategy + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from loguru import logger +import json +import time + +from .gating_network import GatingNetwork, GatingConfig, ContextEncoder, StrategyOutputEncoder +from .ensemble_pipeline import EnsemblePipeline, EnsemblePrediction, StrategyPredictions +from .calibration import ConfidenceCalibrator, CalibrationMetrics + + +@dataclass +class MetamodelConfig: + """Configuration for the Neural Gating Metamodel.""" + + # Gating network configuration + n_strategies: int = 5 + strategy_output_dim: int = 8 + context_dim: int = 32 + gating_hidden_dims: Tuple[int, ...] = (256, 128) + gating_dropout: float = 0.3 + gating_temperature: float = 1.0 + entropy_weight: float = 0.1 + min_weight: float = 0.02 + + # Calibration configuration + calibration_method: str = 'isotonic' + calibration_bins: int = 15 + + # Model paths + models_dir: Optional[str] = None + + # Inference settings + parallel_inference: bool = True + device: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'n_strategies': self.n_strategies, + 'strategy_output_dim': self.strategy_output_dim, + 'context_dim': self.context_dim, + 'gating_hidden_dims': self.gating_hidden_dims, + 'gating_dropout': self.gating_dropout, + 'gating_temperature': self.gating_temperature, + 'entropy_weight': self.entropy_weight, + 'min_weight': self.min_weight, + 'calibration_method': self.calibration_method, + 'calibration_bins': self.calibration_bins, + 'models_dir': self.models_dir, + 'parallel_inference': self.parallel_inference, + 'device': self.device + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> 'MetamodelConfig': + return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class MetamodelPrediction: + """Complete prediction from the Neural Gating Metamodel.""" + + # Core predictions + direction: float # -1 (bearish) to 1 (bullish) + magnitude: float # Expected return percentage + confidence: float # 0 to 1, calibrated + raw_confidence: float # Before calibration + + # Strategy analysis + strategy_weights: Dict[str, float] + dominant_strategy: str + strategy_contributions: Dict[str, float] # Weighted contribution to final + + # Individual predictions + strategy_predictions: Dict[str, Dict[str, float]] + + # Metadata + timestamp: Optional[pd.Timestamp] = None + symbol: Optional[str] = None + processing_time_ms: float = 0.0 + calibration_applied: bool = True + + def to_dict(self) -> Dict[str, Any]: + return { + 'direction': float(self.direction), + 'magnitude': float(self.magnitude), + 'confidence': float(self.confidence), + 'raw_confidence': float(self.raw_confidence), + 'strategy_weights': self.strategy_weights, + 'dominant_strategy': self.dominant_strategy, + 'strategy_contributions': self.strategy_contributions, + 'strategy_predictions': self.strategy_predictions, + 'timestamp': str(self.timestamp) if self.timestamp else None, + 'symbol': self.symbol, + 'processing_time_ms': self.processing_time_ms, + 'calibration_applied': self.calibration_applied + } + + @property + def signal(self) -> str: + """Get trading signal.""" + if self.confidence < 0.5: + return 'HOLD' + if self.direction > 0.3: + return 'BUY' + elif self.direction < -0.3: + return 'SELL' + return 'HOLD' + + @property + def signal_strength(self) -> float: + """Get signal strength (0 to 1).""" + return abs(self.direction) * self.confidence + + @property + def risk_adjusted_signal(self) -> float: + """Get risk-adjusted signal considering confidence.""" + return self.direction * self.confidence + + def get_trading_recommendation(self) -> Dict[str, Any]: + """Get structured trading recommendation.""" + return { + 'action': self.signal, + 'direction': 'LONG' if self.direction > 0 else 'SHORT' if self.direction < 0 else 'NEUTRAL', + 'strength': self.signal_strength, + 'confidence': self.confidence, + 'dominant_strategy': self.dominant_strategy, + 'risk_level': 'LOW' if self.confidence > 0.7 else 'MEDIUM' if self.confidence > 0.5 else 'HIGH' + } + + +class NeuralGatingMetamodel: + """ + Neural Gating Metamodel - Combines 5 trading strategies with learned weighting. + + This model learns which strategy performs best in different market conditions + and dynamically adjusts weights. It uses entropy regularization to prevent + collapse to a single strategy. + + Components: + 1. EnsemblePipeline: Orchestrates 5 strategies (PVA, MRD, VBP, MSA, MTS) + 2. GatingNetwork: Learns context-dependent strategy weights + 3. ConfidenceCalibrator: Ensures confidence scores are well-calibrated + + Key Features: + - Dynamic strategy weighting based on market context + - Entropy regularization prevents strategy collapse + - Calibrated confidence scores + - Interpretable strategy contributions + - Support for walk-forward training + + Usage: + model = NeuralGatingMetamodel() + model.fit(training_data, training_targets) + + prediction = model.predict(df_ohlcv) + print(f"Direction: {prediction.direction:.3f}") + print(f"Confidence: {prediction.confidence:.3f}") + print(f"Dominant: {prediction.dominant_strategy}") + """ + + STRATEGY_NAMES = ['pva', 'mrd', 'vbp', 'msa', 'mts'] + + def __init__(self, config: Optional[MetamodelConfig] = None): + """ + Initialize the Neural Gating Metamodel. + + Args: + config: Model configuration + """ + self.config = config or MetamodelConfig() + self.device = self.config.device or ('cuda' if torch.cuda.is_available() else 'cpu') + + # Initialize gating configuration + gating_config = GatingConfig( + n_strategies=self.config.n_strategies, + strategy_output_dim=self.config.strategy_output_dim, + context_dim=self.config.context_dim, + hidden_dims=self.config.gating_hidden_dims, + dropout=self.config.gating_dropout, + temperature=self.config.gating_temperature, + entropy_weight=self.config.entropy_weight, + min_weight=self.config.min_weight + ) + + # Initialize components + self.ensemble_pipeline = EnsemblePipeline( + gating_config=gating_config, + models_dir=self.config.models_dir, + device=self.device, + parallel_inference=self.config.parallel_inference + ) + + self.gating_network = GatingNetwork(gating_config).to(self.device) + self.context_encoder = ContextEncoder(output_dim=self.config.context_dim).to(self.device) + self.strategy_encoder = StrategyOutputEncoder( + output_dim=self.config.strategy_output_dim + ).to(self.device) + + self.calibrator = ConfidenceCalibrator( + method=self.config.calibration_method, + n_bins=self.config.calibration_bins + ) + + # Training state + self._is_trained = False + self._training_metrics: Dict[str, float] = {} + self._loaded_symbols: List[str] = [] + + logger.info(f"NeuralGatingMetamodel initialized: device={self.device}") + + def load_strategies( + self, + symbols: List[str], + strategies_to_load: Optional[List[str]] = None + ) -> Dict[str, bool]: + """ + Load strategy models for given symbols. + + Args: + symbols: Trading symbols to load + strategies_to_load: Specific strategies (default: all) + + Returns: + Load status per strategy + """ + status = self.ensemble_pipeline.load_strategies(symbols, strategies_to_load) + self._loaded_symbols = symbols + return status + + def fit( + self, + training_data: List[Tuple[pd.DataFrame, np.ndarray]], + val_data: Optional[List[Tuple[pd.DataFrame, np.ndarray]]] = None, + epochs: int = 50, + batch_size: int = 32, + learning_rate: float = 1e-3, + verbose: bool = True + ) -> Dict[str, float]: + """ + Train the complete metamodel. + + This method: + 1. Generates predictions from all 5 strategies + 2. Trains the gating network to weight strategies + 3. Trains the confidence calibrator + + Args: + training_data: List of (df_ohlcv, y_direction) tuples + val_data: Optional validation data + epochs: Training epochs for gating network + batch_size: Batch size + learning_rate: Learning rate + verbose: Print progress + + Returns: + Training metrics + """ + logger.info(f"Training metamodel with {len(training_data)} samples...") + start_time = time.time() + + # Step 1: Generate strategy predictions for all training samples + all_strategy_preds = [] + all_contexts = [] + all_targets = [] + + for i, (df, y_target) in enumerate(training_data): + if verbose and (i + 1) % 100 == 0: + logger.info(f"Processing sample {i + 1}/{len(training_data)}") + + # Get strategy predictions + strategy_preds = self.ensemble_pipeline.predict_all_strategies(df) + all_strategy_preds.append(strategy_preds) + + # Get context + context = self.ensemble_pipeline.extract_market_context(df) + all_contexts.append(context) + + # Store target + all_targets.append(y_target) + + # Step 2: Train gating network + gating_metrics = self._train_gating( + all_strategy_preds, + all_contexts, + all_targets, + epochs, + batch_size, + learning_rate, + verbose + ) + + # Step 3: Train calibrator + cal_metrics = self._train_calibrator( + all_strategy_preds, + all_contexts, + all_targets + ) + + # Combine metrics + self._training_metrics = { + **gating_metrics, + **cal_metrics, + 'training_time_s': time.time() - start_time + } + + self._is_trained = True + + if verbose: + logger.info(f"Training complete in {self._training_metrics['training_time_s']:.1f}s") + logger.info(f"Gating loss: {gating_metrics.get('final_loss', 'N/A'):.4f}") + logger.info(f"Calibration ECE: {cal_metrics.get('calibration_ece', 'N/A'):.4f}") + + return self._training_metrics + + def _train_gating( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[np.ndarray], + epochs: int, + batch_size: int, + learning_rate: float, + verbose: bool + ) -> Dict[str, float]: + """Train the gating network.""" + logger.info("Training gating network...") + + self.gating_network.train() + self.context_encoder.train() + self.strategy_encoder.train() + + # Combine all parameters + all_params = ( + list(self.gating_network.parameters()) + + list(self.context_encoder.parameters()) + + list(self.strategy_encoder.parameters()) + ) + + optimizer = torch.optim.AdamW(all_params, lr=learning_rate, weight_decay=1e-4) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs) + + # Prepare data + n_samples = len(strategy_preds) + best_loss = float('inf') + history = [] + + for epoch in range(epochs): + epoch_loss = 0.0 + epoch_direction_loss = 0.0 + epoch_entropy_loss = 0.0 + + # Shuffle indices + indices = np.random.permutation(n_samples) + + for batch_start in range(0, n_samples, batch_size): + batch_indices = indices[batch_start:batch_start + batch_size] + + optimizer.zero_grad() + + batch_loss = 0.0 + batch_direction_loss = 0.0 + batch_entropy_loss = 0.0 + + for idx in batch_indices: + strategy_pred = strategy_preds[idx] + context = contexts[idx] + target = targets[idx] + + # Encode strategy outputs + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + + # Get gating weights + weights = self.gating_network(strategy_tensors, context) + + # Compute weighted direction prediction + pred_direction = self._compute_weighted_direction( + strategy_pred, + weights + ) + + # Direction loss (MSE to target direction) + target_direction = torch.tensor( + [float(target)], + device=self.device, + dtype=torch.float32 + ) + direction_loss = nn.functional.mse_loss(pred_direction, target_direction) + + # Entropy loss (regularization) + entropy_loss = self.gating_network.compute_entropy_loss(weights) + + # Total loss + loss = direction_loss + entropy_loss + + batch_loss += loss + batch_direction_loss += direction_loss.item() + batch_entropy_loss += entropy_loss.item() + + # Average over batch + batch_loss = batch_loss / len(batch_indices) + batch_loss.backward() + + # Gradient clipping + torch.nn.utils.clip_grad_norm_(all_params, max_norm=1.0) + + optimizer.step() + + epoch_loss += batch_loss.item() + epoch_direction_loss += batch_direction_loss / len(batch_indices) + epoch_entropy_loss += batch_entropy_loss / len(batch_indices) + + # Average over epoch + n_batches = (n_samples + batch_size - 1) // batch_size + epoch_loss /= n_batches + epoch_direction_loss /= n_batches + epoch_entropy_loss /= n_batches + + scheduler.step() + history.append(epoch_loss) + + if epoch_loss < best_loss: + best_loss = epoch_loss + + if verbose and (epoch + 1) % 10 == 0: + logger.info( + f"Epoch {epoch + 1}/{epochs} - " + f"Loss: {epoch_loss:.4f}, " + f"Dir: {epoch_direction_loss:.4f}, " + f"Entropy: {epoch_entropy_loss:.4f}" + ) + + self.gating_network.eval() + self.context_encoder.eval() + self.strategy_encoder.eval() + + return { + 'final_loss': history[-1] if history else 0.0, + 'best_loss': best_loss, + 'gating_epochs': epochs + } + + def _encode_strategy_predictions( + self, + strategy_preds: StrategyPredictions + ) -> Dict[str, torch.Tensor]: + """Convert strategy predictions to tensor format for gating.""" + strategy_tensors = {} + available = strategy_preds.get_available() + + for name, pred in available.items(): + values = list(pred.values())[:5] + while len(values) < 5: + values.append(0.0) + + tensor = torch.tensor( + values, + dtype=torch.float32, + device=self.device + ).unsqueeze(0) + + encoded = self.strategy_encoder(name, tensor) + strategy_tensors[name] = encoded + + return strategy_tensors + + def _compute_weighted_direction( + self, + strategy_preds: StrategyPredictions, + weights: torch.Tensor + ) -> torch.Tensor: + """Compute weighted direction prediction.""" + available = strategy_preds.get_available() + direction = torch.zeros(1, device=self.device) + + for i, name in enumerate(self.STRATEGY_NAMES): + if name not in available: + continue + + pred = available[name] + weight = weights[0, i] + + # Extract direction from each strategy + if name == 'pva': + strat_dir = pred.get('direction', 0.0) + elif name == 'mrd': + regime = pred.get('regime', 1.0) + strat_dir = {0: -1.0, 1: 0.0, 2: 1.0}.get(int(regime), 0.0) + strat_dir *= pred.get('continuation_prob', 0.5) + elif name == 'vbp': + strat_dir = pred.get('direction', 0.0) * pred.get('breakout_prob', 0.0) + elif name == 'msa': + strat_dir = pred.get('is_bullish', 0.0) - pred.get('is_bearish', 0.0) + strat_dir *= pred.get('bos_conf', 0.0) + elif name == 'mts': + strat_dir = pred.get('unified_direction', 0.0) + else: + strat_dir = 0.0 + + direction += weight * strat_dir + + return direction + + def _train_calibrator( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[np.ndarray] + ) -> Dict[str, float]: + """Train the confidence calibrator.""" + logger.info("Training confidence calibrator...") + + # Generate raw confidence scores + raw_confidences = [] + binary_targets = [] + + self.gating_network.eval() + + with torch.no_grad(): + for i in range(len(strategy_preds)): + strategy_pred = strategy_preds[i] + context = contexts[i] + target = targets[i] + + # Get gating weights + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + weights = self.gating_network(strategy_tensors, context) + + # Compute raw confidence (weighted average of individual confidences) + raw_conf = self._compute_raw_confidence(strategy_pred, weights) + raw_confidences.append(raw_conf) + + # Binary target: 1 if prediction direction matches target + pred_dir = self._compute_weighted_direction(strategy_pred, weights) + correct = (pred_dir.item() > 0) == (target > 0) + binary_targets.append(int(correct)) + + # Fit calibrator + raw_confidences = np.array(raw_confidences) + binary_targets = np.array(binary_targets) + + self.calibrator.fit(binary_targets, raw_confidences) + + # Evaluate + metrics = self.calibrator.evaluate(binary_targets, raw_confidences, calibrated=True) + + return { + 'calibration_ece': metrics.ece, + 'calibration_mce': metrics.mce, + 'calibration_brier': metrics.brier_score + } + + def _compute_raw_confidence( + self, + strategy_preds: StrategyPredictions, + weights: torch.Tensor + ) -> float: + """Compute raw confidence from strategy predictions.""" + available = strategy_preds.get_available() + confidence = 0.0 + + for i, name in enumerate(self.STRATEGY_NAMES): + if name not in available: + continue + + pred = available[name] + weight = weights[0, i].item() + + # Extract confidence from each strategy + if name == 'pva': + conf = pred.get('confidence', 0.0) + elif name == 'mrd': + conf = pred.get('regime_confidence', 0.0) + elif name == 'vbp': + conf = pred.get('confidence', 0.0) * pred.get('breakout_prob', 0.0) + elif name == 'msa': + conf = pred.get('bos_conf', 0.0) + elif name == 'mts': + conf = pred.get('confidence', 0.0) + else: + conf = 0.0 + + confidence += weight * conf + + return float(np.clip(confidence, 0.0, 1.0)) + + def predict( + self, + df: pd.DataFrame, + symbol: Optional[str] = None + ) -> MetamodelPrediction: + """ + Generate prediction from the metamodel. + + Args: + df: OHLCV DataFrame + symbol: Trading symbol (optional) + + Returns: + MetamodelPrediction with full analysis + """ + start_time = time.time() + + # Get strategy predictions + strategy_preds = self.ensemble_pipeline.predict_all_strategies(df, symbol) + + # Get context + context = self.ensemble_pipeline.extract_market_context(df) + + # Get gating weights + self.gating_network.eval() + with torch.no_grad(): + strategy_tensors = self._encode_strategy_predictions(strategy_preds) + weights = self.gating_network(strategy_tensors, context) + + # Compute weighted direction + direction = self._compute_weighted_direction(strategy_preds, weights) + direction = float(direction.item()) + + # Compute raw confidence + raw_confidence = self._compute_raw_confidence(strategy_preds, weights) + + # Calibrate confidence + if self._is_trained and self.calibrator._is_fitted: + confidence = float(self.calibrator.calibrate(np.array([raw_confidence]))[0]) + calibration_applied = True + else: + confidence = raw_confidence + calibration_applied = False + + # Extract weights as dict + weights_dict = { + name: float(weights[0, i].item()) + for i, name in enumerate(self.STRATEGY_NAMES) + } + + # Compute strategy contributions + contributions = self._compute_strategy_contributions( + strategy_preds, + weights_dict + ) + + # Compute magnitude (weighted average) + magnitude = self._compute_weighted_magnitude(strategy_preds, weights_dict) + + # Find dominant strategy + dominant_strategy = max(weights_dict.items(), key=lambda x: x[1])[0] + + # Processing time + processing_time = (time.time() - start_time) * 1000 + + return MetamodelPrediction( + direction=float(np.clip(direction, -1.0, 1.0)), + magnitude=magnitude, + confidence=float(np.clip(confidence, 0.0, 1.0)), + raw_confidence=raw_confidence, + strategy_weights=weights_dict, + dominant_strategy=dominant_strategy, + strategy_contributions=contributions, + strategy_predictions={k: v for k, v in strategy_preds.get_available().items()}, + timestamp=df.index[-1] if hasattr(df.index, '__getitem__') else None, + symbol=symbol, + processing_time_ms=processing_time, + calibration_applied=calibration_applied + ) + + def _compute_strategy_contributions( + self, + strategy_preds: StrategyPredictions, + weights_dict: Dict[str, float] + ) -> Dict[str, float]: + """Compute each strategy's contribution to the final prediction.""" + available = strategy_preds.get_available() + contributions = {} + + for name in self.STRATEGY_NAMES: + if name not in available: + contributions[name] = 0.0 + continue + + pred = available[name] + weight = weights_dict.get(name, 0.0) + + # Direction contribution + if name == 'pva': + dir_contrib = abs(pred.get('direction', 0.0)) + elif name == 'mrd': + regime = pred.get('regime', 1.0) + dir_contrib = abs({0: -1.0, 1: 0.0, 2: 1.0}.get(int(regime), 0.0)) + elif name == 'vbp': + dir_contrib = abs(pred.get('direction', 0.0)) + elif name == 'msa': + dir_contrib = abs(pred.get('is_bullish', 0.0) - pred.get('is_bearish', 0.0)) + elif name == 'mts': + dir_contrib = abs(pred.get('unified_direction', 0.0)) + else: + dir_contrib = 0.0 + + contributions[name] = weight * dir_contrib + + # Normalize contributions to sum to 1 + total = sum(contributions.values()) + if total > 0: + contributions = {k: v / total for k, v in contributions.items()} + + return contributions + + def _compute_weighted_magnitude( + self, + strategy_preds: StrategyPredictions, + weights_dict: Dict[str, float] + ) -> float: + """Compute weighted magnitude prediction.""" + available = strategy_preds.get_available() + magnitude = 0.0 + total_weight = 0.0 + + for name in self.STRATEGY_NAMES: + if name not in available: + continue + + pred = available[name] + weight = weights_dict.get(name, 0.0) + + # Extract magnitude + if name == 'pva': + mag = pred.get('magnitude', 0.0) + elif name == 'vbp': + mag = pred.get('magnitude', 0.0) + else: + mag = 0.01 # Default 1% for strategies without magnitude + + magnitude += weight * mag + total_weight += weight + + if total_weight > 0: + magnitude /= total_weight + + return float(magnitude) + + def get_strategy_contributions(self) -> Dict[str, float]: + """Get current strategy weight distribution.""" + if not hasattr(self, '_last_weights'): + return {name: 1.0 / len(self.STRATEGY_NAMES) for name in self.STRATEGY_NAMES} + return self._last_weights + + def save(self, path: str) -> None: + """ + Save the metamodel to disk. + + Args: + path: Directory path to save model + """ + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save configuration + config_path = path / 'config.json' + with open(config_path, 'w') as f: + json.dump(self.config.to_dict(), f, indent=2) + + # Save gating network + gating_path = path / 'gating_network.pt' + torch.save(self.gating_network.state_dict(), gating_path) + + # Save context encoder + context_path = path / 'context_encoder.pt' + torch.save(self.context_encoder.state_dict(), context_path) + + # Save strategy encoder + strategy_path = path / 'strategy_encoder.pt' + torch.save(self.strategy_encoder.state_dict(), strategy_path) + + # Save calibrator + calibrator_path = path / 'calibrator.joblib' + joblib.dump(self.calibrator, calibrator_path) + + # Save training state + state_path = path / 'state.json' + with open(state_path, 'w') as f: + json.dump({ + 'is_trained': self._is_trained, + 'training_metrics': self._training_metrics, + 'loaded_symbols': self._loaded_symbols + }, f, indent=2) + + logger.info(f"Metamodel saved to {path}") + + @classmethod + def load(cls, path: str, device: Optional[str] = None) -> 'NeuralGatingMetamodel': + """ + Load metamodel from disk. + + Args: + path: Directory containing saved model + device: Device to load to (overrides saved config) + + Returns: + Loaded NeuralGatingMetamodel + """ + path = Path(path) + + # Load configuration + config_path = path / 'config.json' + with open(config_path, 'r') as f: + config_dict = json.load(f) + + if device: + config_dict['device'] = device + + config = MetamodelConfig.from_dict(config_dict) + + # Create model + model = cls(config) + + # Load gating network + gating_path = path / 'gating_network.pt' + model.gating_network.load_state_dict( + torch.load(gating_path, map_location=model.device) + ) + + # Load context encoder + context_path = path / 'context_encoder.pt' + model.context_encoder.load_state_dict( + torch.load(context_path, map_location=model.device) + ) + + # Load strategy encoder + strategy_path = path / 'strategy_encoder.pt' + model.strategy_encoder.load_state_dict( + torch.load(strategy_path, map_location=model.device) + ) + + # Load calibrator + calibrator_path = path / 'calibrator.joblib' + if calibrator_path.exists(): + model.calibrator = joblib.load(calibrator_path) + + # Load training state + state_path = path / 'state.json' + if state_path.exists(): + with open(state_path, 'r') as f: + state = json.load(f) + model._is_trained = state.get('is_trained', False) + model._training_metrics = state.get('training_metrics', {}) + model._loaded_symbols = state.get('loaded_symbols', []) + + logger.info(f"Metamodel loaded from {path}") + return model + + +if __name__ == "__main__": + # Test the Neural Gating Metamodel + torch.manual_seed(42) + np.random.seed(42) + + print("Testing NeuralGatingMetamodel") + print("=" * 60) + + # Create sample data + n_samples = 100 + dates = pd.date_range('2025-01-01', periods=200, freq='5min') + + price = 100 * np.cumprod(1 + np.random.randn(200) * 0.001) + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(200) * 0.001), + 'high': price * (1 + np.abs(np.random.randn(200) * 0.002)), + 'low': price * (1 - np.abs(np.random.randn(200) * 0.002)), + 'close': price, + 'volume': np.random.randint(1000, 10000, 200) + }, index=dates) + + # Initialize model + config = MetamodelConfig() + model = NeuralGatingMetamodel(config) + + # Load strategies (mock) + load_status = model.load_strategies(['EURUSD']) + print(f"Load status: {load_status}") + + # Generate synthetic training data + training_data = [] + for i in range(50): + start_idx = i * 3 + end_idx = start_idx + 100 + if end_idx > len(df): + break + sample_df = df.iloc[start_idx:end_idx].copy() + target = np.random.choice([-1.0, 0.0, 1.0]) # Random direction + training_data.append((sample_df, target)) + + print(f"\nTraining with {len(training_data)} samples...") + + # Train model + metrics = model.fit(training_data, epochs=10, verbose=True) + + print(f"\n--- Training Metrics ---") + for k, v in metrics.items(): + print(f" {k}: {v}") + + # Make prediction + print(f"\n--- Making Prediction ---") + prediction = model.predict(df.tail(100), symbol='EURUSD') + + print(f"Direction: {prediction.direction:.4f}") + print(f"Magnitude: {prediction.magnitude:.4f}") + print(f"Confidence: {prediction.confidence:.4f} (raw: {prediction.raw_confidence:.4f})") + print(f"Signal: {prediction.signal}") + print(f"Dominant Strategy: {prediction.dominant_strategy}") + print(f"Processing Time: {prediction.processing_time_ms:.2f} ms") + + print(f"\n--- Strategy Weights ---") + for name, weight in prediction.strategy_weights.items(): + print(f" {name.upper()}: {weight:.4f}") + + print(f"\n--- Strategy Contributions ---") + for name, contrib in prediction.strategy_contributions.items(): + print(f" {name.upper()}: {contrib:.4f}") + + # Test save/load + print(f"\n--- Testing Save/Load ---") + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + save_path = Path(tmpdir) / 'metamodel' + model.save(str(save_path)) + + loaded_model = NeuralGatingMetamodel.load(str(save_path)) + loaded_pred = loaded_model.predict(df.tail(100), symbol='EURUSD') + + print(f"Original direction: {prediction.direction:.4f}") + print(f"Loaded direction: {loaded_pred.direction:.4f}") + + print("\nNeuralGatingMetamodel test complete!") diff --git a/src/models/metamodel/trainer.py b/src/models/metamodel/trainer.py new file mode 100644 index 0000000..dfd5c5d --- /dev/null +++ b/src/models/metamodel/trainer.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +""" +Metamodel Trainer +================== +Training pipeline for the Neural Gating Metamodel including: +1. Strategy prediction generation +2. Gating network training with walk-forward validation +3. Confidence calibration +4. Entropy regularization to prevent strategy collapse + +Supports: +- Walk-forward training (time-series cross-validation) +- Online learning (incremental updates) +- Multi-symbol training + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +import torch.optim as optim +from torch.utils.data import Dataset, DataLoader +from typing import Dict, List, Optional, Tuple, Any, Generator +from dataclasses import dataclass, field +from pathlib import Path +from loguru import logger +import time +from collections import defaultdict +import json + +from .model import NeuralGatingMetamodel, MetamodelConfig, MetamodelPrediction +from .gating_network import GatingNetwork, GatingConfig +from .calibration import ConfidenceCalibrator +from .ensemble_pipeline import EnsemblePipeline, StrategyPredictions + + +@dataclass +class TrainerConfig: + """Configuration for the Metamodel Trainer.""" + + # Training parameters + epochs: int = 50 + batch_size: int = 32 + learning_rate: float = 1e-3 + weight_decay: float = 1e-4 + gradient_clip: float = 1.0 + + # Walk-forward parameters + n_folds: int = 5 + min_train_size: int = 500 + validation_ratio: float = 0.2 + + # Regularization + entropy_weight: float = 0.1 + entropy_target: float = 0.7 # Target entropy as fraction of max + diversity_weight: float = 0.05 # Penalize correlation between strategies + + # Early stopping + early_stopping_patience: int = 10 + early_stopping_min_delta: float = 1e-4 + + # Data parameters + sequence_length: int = 100 + prediction_horizon: int = 5 # Candles ahead to predict + + # Device + device: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'epochs': self.epochs, + 'batch_size': self.batch_size, + 'learning_rate': self.learning_rate, + 'weight_decay': self.weight_decay, + 'gradient_clip': self.gradient_clip, + 'n_folds': self.n_folds, + 'min_train_size': self.min_train_size, + 'validation_ratio': self.validation_ratio, + 'entropy_weight': self.entropy_weight, + 'entropy_target': self.entropy_target, + 'diversity_weight': self.diversity_weight, + 'early_stopping_patience': self.early_stopping_patience, + 'early_stopping_min_delta': self.early_stopping_min_delta, + 'sequence_length': self.sequence_length, + 'prediction_horizon': self.prediction_horizon, + 'device': self.device + } + + +@dataclass +class TrainingMetrics: + """Metrics from training run.""" + + # Loss metrics + final_loss: float = 0.0 + best_loss: float = float('inf') + direction_loss: float = 0.0 + entropy_loss: float = 0.0 + diversity_loss: float = 0.0 + + # Accuracy metrics + direction_accuracy: float = 0.0 + calibration_ece: float = 0.0 + + # Walk-forward metrics + fold_losses: List[float] = field(default_factory=list) + fold_accuracies: List[float] = field(default_factory=list) + + # Strategy weight statistics + weight_means: Dict[str, float] = field(default_factory=dict) + weight_stds: Dict[str, float] = field(default_factory=dict) + + # Training info + epochs_trained: int = 0 + training_time_s: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + return { + 'final_loss': self.final_loss, + 'best_loss': self.best_loss, + 'direction_loss': self.direction_loss, + 'entropy_loss': self.entropy_loss, + 'diversity_loss': self.diversity_loss, + 'direction_accuracy': self.direction_accuracy, + 'calibration_ece': self.calibration_ece, + 'fold_losses': self.fold_losses, + 'fold_accuracies': self.fold_accuracies, + 'weight_means': self.weight_means, + 'weight_stds': self.weight_stds, + 'epochs_trained': self.epochs_trained, + 'training_time_s': self.training_time_s + } + + +class StrategyPredictionDataset(Dataset): + """PyTorch Dataset for strategy predictions.""" + + def __init__( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float], + device: str = 'cpu' + ): + """ + Initialize dataset. + + Args: + strategy_preds: List of strategy predictions + contexts: List of context tensors + targets: Target direction labels + device: Device for tensors + """ + self.strategy_preds = strategy_preds + self.contexts = contexts + self.targets = targets + self.device = device + + def __len__(self) -> int: + return len(self.targets) + + def __getitem__(self, idx: int) -> Tuple[StrategyPredictions, torch.Tensor, float]: + return self.strategy_preds[idx], self.contexts[idx], self.targets[idx] + + +class MetamodelTrainer: + """ + Trainer for the Neural Gating Metamodel. + + Implements: + - Strategy prediction generation + - Walk-forward training + - Entropy regularization + - Confidence calibration + + Usage: + trainer = MetamodelTrainer(model) + metrics = trainer.train(df_train, symbol='EURUSD') + + # Walk-forward evaluation + wf_metrics = trainer.walk_forward_train(df, symbol, n_folds=5) + """ + + STRATEGY_NAMES = ['pva', 'mrd', 'vbp', 'msa', 'mts'] + + def __init__( + self, + model: NeuralGatingMetamodel, + config: Optional[TrainerConfig] = None + ): + """ + Initialize the trainer. + + Args: + model: NeuralGatingMetamodel instance + config: Trainer configuration + """ + self.model = model + self.config = config or TrainerConfig() + self.device = self.config.device or model.device + + # Training history + self._history: Dict[str, List[float]] = defaultdict(list) + + logger.info(f"MetamodelTrainer initialized: device={self.device}") + + def generate_strategy_predictions( + self, + df: pd.DataFrame, + symbols: List[str], + verbose: bool = True + ) -> Tuple[List[StrategyPredictions], List[torch.Tensor], List[float]]: + """ + Generate predictions from all strategies for training data. + + Args: + df: OHLCV DataFrame + symbols: Symbols to process + verbose: Print progress + + Returns: + Tuple of (strategy_predictions, contexts, targets) + """ + logger.info(f"Generating strategy predictions for {len(df)} samples...") + + # Ensure strategies are loaded + if not self.model.ensemble_pipeline.strategies: + self.model.load_strategies(symbols) + + all_strategy_preds = [] + all_contexts = [] + all_targets = [] + + seq_len = self.config.sequence_length + horizon = self.config.prediction_horizon + + n_samples = len(df) - seq_len - horizon + if n_samples <= 0: + raise ValueError(f"Not enough data: need at least {seq_len + horizon} rows") + + for i in range(n_samples): + if verbose and (i + 1) % 100 == 0: + logger.info(f"Processing sample {i + 1}/{n_samples}") + + # Extract window + start_idx = i + end_idx = start_idx + seq_len + sample_df = df.iloc[start_idx:end_idx].copy() + + # Get strategy predictions + strategy_preds = self.model.ensemble_pipeline.predict_all_strategies(sample_df) + all_strategy_preds.append(strategy_preds) + + # Get context + context = self.model.ensemble_pipeline.extract_market_context(sample_df) + all_contexts.append(context) + + # Compute target (future return direction) + current_close = df['close'].iloc[end_idx - 1] + future_close = df['close'].iloc[end_idx + horizon - 1] + target = 1.0 if future_close > current_close else -1.0 if future_close < current_close else 0.0 + all_targets.append(target) + + logger.info(f"Generated {len(all_strategy_preds)} prediction samples") + + return all_strategy_preds, all_contexts, all_targets + + def train_gating( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float], + val_strategy_preds: Optional[List[StrategyPredictions]] = None, + val_contexts: Optional[List[torch.Tensor]] = None, + val_targets: Optional[List[float]] = None, + verbose: bool = True + ) -> TrainingMetrics: + """ + Train the gating network. + + Args: + strategy_preds: Training strategy predictions + contexts: Training context tensors + targets: Training targets + val_strategy_preds: Optional validation predictions + val_contexts: Optional validation contexts + val_targets: Optional validation targets + verbose: Print progress + + Returns: + TrainingMetrics object + """ + logger.info(f"Training gating network with {len(strategy_preds)} samples...") + start_time = time.time() + + # Set up optimizer + all_params = ( + list(self.model.gating_network.parameters()) + + list(self.model.context_encoder.parameters()) + + list(self.model.strategy_encoder.parameters()) + ) + + optimizer = optim.AdamW( + all_params, + lr=self.config.learning_rate, + weight_decay=self.config.weight_decay + ) + + scheduler = optim.lr_scheduler.CosineAnnealingLR( + optimizer, + T_max=self.config.epochs + ) + + # Training loop + best_loss = float('inf') + patience_counter = 0 + metrics = TrainingMetrics() + + for epoch in range(self.config.epochs): + self.model.gating_network.train() + self.model.context_encoder.train() + self.model.strategy_encoder.train() + + epoch_losses = { + 'total': 0.0, + 'direction': 0.0, + 'entropy': 0.0, + 'diversity': 0.0 + } + + # Shuffle indices + n_samples = len(strategy_preds) + indices = np.random.permutation(n_samples) + + n_batches = (n_samples + self.config.batch_size - 1) // self.config.batch_size + + for batch_idx in range(n_batches): + batch_start = batch_idx * self.config.batch_size + batch_end = min(batch_start + self.config.batch_size, n_samples) + batch_indices = indices[batch_start:batch_end] + + optimizer.zero_grad() + + batch_loss, batch_dir_loss, batch_ent_loss, batch_div_loss = \ + self._compute_batch_loss( + strategy_preds, + contexts, + targets, + batch_indices + ) + + batch_loss.backward() + + # Gradient clipping + torch.nn.utils.clip_grad_norm_(all_params, self.config.gradient_clip) + + optimizer.step() + + epoch_losses['total'] += batch_loss.item() + epoch_losses['direction'] += batch_dir_loss + epoch_losses['entropy'] += batch_ent_loss + epoch_losses['diversity'] += batch_div_loss + + # Average losses + for key in epoch_losses: + epoch_losses[key] /= n_batches + self._history[f'train_{key}'].append(epoch_losses[key]) + + scheduler.step() + + # Validation + val_loss = None + if val_strategy_preds is not None: + val_loss = self._compute_validation_loss( + val_strategy_preds, + val_contexts, + val_targets + ) + self._history['val_loss'].append(val_loss) + + # Early stopping check + current_loss = val_loss if val_loss is not None else epoch_losses['total'] + if current_loss < best_loss - self.config.early_stopping_min_delta: + best_loss = current_loss + patience_counter = 0 + else: + patience_counter += 1 + + if patience_counter >= self.config.early_stopping_patience: + logger.info(f"Early stopping at epoch {epoch + 1}") + break + + if verbose and (epoch + 1) % 5 == 0: + val_str = f", Val: {val_loss:.4f}" if val_loss else "" + logger.info( + f"Epoch {epoch + 1}/{self.config.epochs} - " + f"Loss: {epoch_losses['total']:.4f} " + f"(Dir: {epoch_losses['direction']:.4f}, " + f"Ent: {epoch_losses['entropy']:.4f}, " + f"Div: {epoch_losses['diversity']:.4f})" + f"{val_str}" + ) + + metrics.epochs_trained = epoch + 1 + + # Finalize metrics + metrics.final_loss = self._history['train_total'][-1] + metrics.best_loss = best_loss + metrics.direction_loss = self._history['train_direction'][-1] + metrics.entropy_loss = self._history['train_entropy'][-1] + metrics.diversity_loss = self._history['train_diversity'][-1] + metrics.training_time_s = time.time() - start_time + + # Compute accuracy + accuracy = self._compute_direction_accuracy(strategy_preds, contexts, targets) + metrics.direction_accuracy = accuracy + + # Compute weight statistics + weight_stats = self._compute_weight_statistics(strategy_preds, contexts) + metrics.weight_means = weight_stats['means'] + metrics.weight_stds = weight_stats['stds'] + + self.model.gating_network.eval() + self.model.context_encoder.eval() + self.model.strategy_encoder.eval() + + logger.info(f"Gating training complete in {metrics.training_time_s:.1f}s") + + return metrics + + def _compute_batch_loss( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float], + indices: np.ndarray + ) -> Tuple[torch.Tensor, float, float, float]: + """Compute loss for a batch of samples.""" + total_loss = torch.tensor(0.0, device=self.device, requires_grad=True) + direction_loss_sum = 0.0 + entropy_loss_sum = 0.0 + diversity_loss_sum = 0.0 + + all_weights = [] + + for idx in indices: + strategy_pred = strategy_preds[idx] + context = contexts[idx] + target = targets[idx] + + # Encode strategy outputs + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + + # Get gating weights + weights = self.model.gating_network(strategy_tensors, context) + all_weights.append(weights) + + # Direction prediction + pred_direction = self._compute_weighted_direction(strategy_pred, weights) + + # Direction loss + target_tensor = torch.tensor( + [float(target)], + device=self.device, + dtype=torch.float32 + ) + dir_loss = nn.functional.mse_loss(pred_direction, target_tensor) + + # Entropy loss + ent_loss = self.model.gating_network.compute_entropy_loss(weights) + + # Accumulate + sample_loss = dir_loss + ent_loss + total_loss = total_loss + sample_loss + + direction_loss_sum += dir_loss.item() + entropy_loss_sum += ent_loss.item() + + # Diversity loss (across batch) + if len(all_weights) > 1: + weights_stacked = torch.cat(all_weights, dim=0) # (batch, n_strategies) + diversity_loss = self._compute_diversity_loss(weights_stacked) + total_loss = total_loss + self.config.diversity_weight * diversity_loss + diversity_loss_sum = diversity_loss.item() + + # Average + batch_size = len(indices) + total_loss = total_loss / batch_size + + return ( + total_loss, + direction_loss_sum / batch_size, + entropy_loss_sum / batch_size, + diversity_loss_sum + ) + + def _compute_diversity_loss(self, weights: torch.Tensor) -> torch.Tensor: + """ + Compute diversity loss to encourage different weights across samples. + + Penalizes high correlation between strategy weights across batch. + """ + if weights.size(0) < 2: + return torch.tensor(0.0, device=self.device) + + # Normalize weights + weights_centered = weights - weights.mean(dim=0, keepdim=True) + + # Compute correlation matrix + weights_norm = weights_centered / (weights_centered.std(dim=0, keepdim=True) + 1e-8) + corr_matrix = torch.mm(weights_norm.T, weights_norm) / weights.size(0) + + # Penalize off-diagonal correlations + n_strategies = weights.size(1) + eye = torch.eye(n_strategies, device=self.device) + off_diag = corr_matrix * (1 - eye) + + diversity_loss = off_diag.abs().mean() + + return diversity_loss + + def _encode_strategy_predictions( + self, + strategy_preds: StrategyPredictions + ) -> Dict[str, torch.Tensor]: + """Convert strategy predictions to tensor format.""" + strategy_tensors = {} + available = strategy_preds.get_available() + + for name, pred in available.items(): + values = list(pred.values())[:5] + while len(values) < 5: + values.append(0.0) + + tensor = torch.tensor( + values, + dtype=torch.float32, + device=self.device + ).unsqueeze(0) + + encoded = self.model.strategy_encoder(name, tensor) + strategy_tensors[name] = encoded + + return strategy_tensors + + def _compute_weighted_direction( + self, + strategy_preds: StrategyPredictions, + weights: torch.Tensor + ) -> torch.Tensor: + """Compute weighted direction prediction.""" + available = strategy_preds.get_available() + direction = torch.zeros(1, device=self.device) + + for i, name in enumerate(self.STRATEGY_NAMES): + if name not in available: + continue + + pred = available[name] + weight = weights[0, i] + + # Extract direction + if name == 'pva': + strat_dir = pred.get('direction', 0.0) + elif name == 'mrd': + regime = pred.get('regime', 1.0) + strat_dir = {0: -1.0, 1: 0.0, 2: 1.0}.get(int(regime), 0.0) + strat_dir *= pred.get('continuation_prob', 0.5) + elif name == 'vbp': + strat_dir = pred.get('direction', 0.0) * pred.get('breakout_prob', 0.0) + elif name == 'msa': + strat_dir = pred.get('is_bullish', 0.0) - pred.get('is_bearish', 0.0) + strat_dir *= pred.get('bos_conf', 0.0) + elif name == 'mts': + strat_dir = pred.get('unified_direction', 0.0) + else: + strat_dir = 0.0 + + direction = direction + weight * strat_dir + + return direction + + def _compute_validation_loss( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float] + ) -> float: + """Compute validation loss.""" + self.model.gating_network.eval() + + total_loss = 0.0 + n_samples = len(strategy_preds) + + with torch.no_grad(): + for i in range(n_samples): + strategy_pred = strategy_preds[i] + context = contexts[i] + target = targets[i] + + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + weights = self.model.gating_network(strategy_tensors, context) + pred_direction = self._compute_weighted_direction(strategy_pred, weights) + + target_tensor = torch.tensor( + [float(target)], + device=self.device, + dtype=torch.float32 + ) + loss = nn.functional.mse_loss(pred_direction, target_tensor) + total_loss += loss.item() + + return total_loss / n_samples + + def _compute_direction_accuracy( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float] + ) -> float: + """Compute direction prediction accuracy.""" + self.model.gating_network.eval() + + correct = 0 + total = 0 + + with torch.no_grad(): + for i in range(len(strategy_preds)): + strategy_pred = strategy_preds[i] + context = contexts[i] + target = targets[i] + + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + weights = self.model.gating_network(strategy_tensors, context) + pred_direction = self._compute_weighted_direction(strategy_pred, weights) + + pred_sign = 1 if pred_direction.item() > 0 else -1 if pred_direction.item() < 0 else 0 + target_sign = 1 if target > 0 else -1 if target < 0 else 0 + + if pred_sign == target_sign: + correct += 1 + total += 1 + + return correct / total if total > 0 else 0.0 + + def _compute_weight_statistics( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor] + ) -> Dict[str, Dict[str, float]]: + """Compute statistics of strategy weights.""" + self.model.gating_network.eval() + + all_weights = {name: [] for name in self.STRATEGY_NAMES} + + with torch.no_grad(): + for i in range(len(strategy_preds)): + strategy_pred = strategy_preds[i] + context = contexts[i] + + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + weights = self.model.gating_network(strategy_tensors, context) + + for j, name in enumerate(self.STRATEGY_NAMES): + all_weights[name].append(weights[0, j].item()) + + means = {name: np.mean(weights) for name, weights in all_weights.items()} + stds = {name: np.std(weights) for name, weights in all_weights.items()} + + return {'means': means, 'stds': stds} + + def train_calibrator( + self, + strategy_preds: List[StrategyPredictions], + contexts: List[torch.Tensor], + targets: List[float] + ) -> Dict[str, float]: + """ + Train the confidence calibrator. + + Args: + strategy_preds: Strategy predictions + contexts: Context tensors + targets: Target directions + + Returns: + Calibration metrics + """ + logger.info("Training confidence calibrator...") + + raw_confidences = [] + binary_targets = [] + + self.model.gating_network.eval() + + with torch.no_grad(): + for i in range(len(strategy_preds)): + strategy_pred = strategy_preds[i] + context = contexts[i] + target = targets[i] + + strategy_tensors = self._encode_strategy_predictions(strategy_pred) + weights = self.model.gating_network(strategy_tensors, context) + + # Compute raw confidence + raw_conf = self._compute_raw_confidence(strategy_pred, weights) + raw_confidences.append(raw_conf) + + # Binary target + pred_dir = self._compute_weighted_direction(strategy_pred, weights) + correct = (pred_dir.item() > 0) == (target > 0) + binary_targets.append(int(correct)) + + # Fit calibrator + raw_confidences = np.array(raw_confidences) + binary_targets = np.array(binary_targets) + + self.model.calibrator.fit(binary_targets, raw_confidences) + + # Evaluate + metrics = self.model.calibrator.evaluate(binary_targets, raw_confidences, calibrated=True) + + return { + 'calibration_ece': metrics.ece, + 'calibration_mce': metrics.mce, + 'calibration_brier': metrics.brier_score + } + + def _compute_raw_confidence( + self, + strategy_preds: StrategyPredictions, + weights: torch.Tensor + ) -> float: + """Compute raw confidence from strategy predictions.""" + available = strategy_preds.get_available() + confidence = 0.0 + + for i, name in enumerate(self.STRATEGY_NAMES): + if name not in available: + continue + + pred = available[name] + weight = weights[0, i].item() + + if name == 'pva': + conf = pred.get('confidence', 0.0) + elif name == 'mrd': + conf = pred.get('regime_confidence', 0.0) + elif name == 'vbp': + conf = pred.get('confidence', 0.0) * pred.get('breakout_prob', 0.0) + elif name == 'msa': + conf = pred.get('bos_conf', 0.0) + elif name == 'mts': + conf = pred.get('confidence', 0.0) + else: + conf = 0.0 + + confidence += weight * conf + + return float(np.clip(confidence, 0.0, 1.0)) + + def walk_forward_train( + self, + df: pd.DataFrame, + symbol: str, + n_folds: Optional[int] = None, + verbose: bool = True + ) -> TrainingMetrics: + """ + Train using walk-forward validation. + + This is the recommended training method for time-series data. + Each fold trains on past data and validates on future data. + + Args: + df: Full OHLCV DataFrame + symbol: Trading symbol + n_folds: Number of folds (default from config) + verbose: Print progress + + Returns: + Aggregated training metrics + """ + n_folds = n_folds or self.config.n_folds + + logger.info(f"Walk-forward training with {n_folds} folds...") + + total_samples = len(df) + fold_size = (total_samples - self.config.min_train_size) // n_folds + + metrics = TrainingMetrics() + all_fold_losses = [] + all_fold_accuracies = [] + + for fold in range(n_folds): + if verbose: + logger.info(f"\n--- Fold {fold + 1}/{n_folds} ---") + + # Calculate indices + train_end = self.config.min_train_size + fold * fold_size + val_end = train_end + fold_size + + train_df = df.iloc[:train_end] + val_df = df.iloc[train_end:val_end] + + if verbose: + logger.info(f"Train: {len(train_df)}, Val: {len(val_df)}") + + # Generate predictions + train_preds, train_contexts, train_targets = self.generate_strategy_predictions( + train_df, [symbol], verbose=False + ) + val_preds, val_contexts, val_targets = self.generate_strategy_predictions( + val_df, [symbol], verbose=False + ) + + # Train gating + fold_metrics = self.train_gating( + train_preds, train_contexts, train_targets, + val_preds, val_contexts, val_targets, + verbose=verbose + ) + + all_fold_losses.append(fold_metrics.best_loss) + all_fold_accuracies.append(fold_metrics.direction_accuracy) + + if verbose: + logger.info( + f"Fold {fold + 1} - Loss: {fold_metrics.best_loss:.4f}, " + f"Accuracy: {fold_metrics.direction_accuracy:.4f}" + ) + + # Train calibrator on final fold + cal_metrics = self.train_calibrator(train_preds, train_contexts, train_targets) + + # Aggregate metrics + metrics.fold_losses = all_fold_losses + metrics.fold_accuracies = all_fold_accuracies + metrics.final_loss = np.mean(all_fold_losses) + metrics.best_loss = np.min(all_fold_losses) + metrics.direction_accuracy = np.mean(all_fold_accuracies) + metrics.calibration_ece = cal_metrics['calibration_ece'] + + # Weight statistics from last fold + weight_stats = self._compute_weight_statistics(train_preds, train_contexts) + metrics.weight_means = weight_stats['means'] + metrics.weight_stds = weight_stats['stds'] + + self.model._is_trained = True + + logger.info(f"\nWalk-forward complete: " + f"Avg Loss: {metrics.final_loss:.4f}, " + f"Avg Accuracy: {metrics.direction_accuracy:.4f}") + + return metrics + + +if __name__ == "__main__": + # Test the MetamodelTrainer + torch.manual_seed(42) + np.random.seed(42) + + print("Testing MetamodelTrainer") + print("=" * 60) + + # Create sample data + n_samples = 500 + dates = pd.date_range('2025-01-01', periods=n_samples, freq='5min') + + price = 100 * np.cumprod(1 + np.random.randn(n_samples) * 0.001) + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(n_samples) * 0.001), + 'high': price * (1 + np.abs(np.random.randn(n_samples) * 0.002)), + 'low': price * (1 - np.abs(np.random.randn(n_samples) * 0.002)), + 'close': price, + 'volume': np.random.randint(1000, 10000, n_samples) + }, index=dates) + + # Initialize model and trainer + model_config = MetamodelConfig() + model = NeuralGatingMetamodel(model_config) + model.load_strategies(['EURUSD']) + + trainer_config = TrainerConfig( + epochs=10, + n_folds=2, + min_train_size=100, + sequence_length=50 + ) + trainer = MetamodelTrainer(model, trainer_config) + + # Generate predictions + print("\n1. Generating strategy predictions...") + preds, contexts, targets = trainer.generate_strategy_predictions( + df, ['EURUSD'], verbose=True + ) + print(f" Generated {len(preds)} samples") + + # Train gating + print("\n2. Training gating network...") + split_idx = int(len(preds) * 0.8) + metrics = trainer.train_gating( + preds[:split_idx], contexts[:split_idx], targets[:split_idx], + preds[split_idx:], contexts[split_idx:], targets[split_idx:], + verbose=True + ) + + print(f"\n Final loss: {metrics.final_loss:.4f}") + print(f" Direction accuracy: {metrics.direction_accuracy:.4f}") + print(f" Weight means: {metrics.weight_means}") + + # Train calibrator + print("\n3. Training calibrator...") + cal_metrics = trainer.train_calibrator(preds, contexts, targets) + print(f" ECE: {cal_metrics['calibration_ece']:.4f}") + + # Walk-forward training + print("\n4. Testing walk-forward training...") + wf_metrics = trainer.walk_forward_train(df, 'EURUSD', n_folds=2, verbose=True) + print(f"\n Walk-forward metrics:") + print(f" Avg Loss: {wf_metrics.final_loss:.4f}") + print(f" Avg Accuracy: {wf_metrics.direction_accuracy:.4f}") + print(f" Fold losses: {wf_metrics.fold_losses}") + + print("\nMetamodelTrainer test complete!") diff --git a/src/models/strategies/__init__.py b/src/models/strategies/__init__.py new file mode 100644 index 0000000..c8e1fbd --- /dev/null +++ b/src/models/strategies/__init__.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +ML Trading Strategies Module +============================ + +Collection of ML-based trading strategies for the trading platform. + +Available Strategies: +- PVA (Price Variation Attention): Transformer-based price movement prediction +- MRD (Market Regime Detection): HMM-based regime classification +- MSA (Market Structure Analysis): Structure break detection +- MTS (Multi-Timeframe Synthesis): Hierarchical attention across timeframes +- VBP (Volatility Breakout Predictor): CNN encoder + XGBoost for breakout detection + +Each strategy provides: +- Feature engineering tailored to the strategy +- Model architecture with attention mechanisms +- Training pipeline with walk-forward validation + +Usage: + from src.models.strategies import pva, vbp + + # Or import specific components + from src.models.strategies.pva import PVAModel, PVATrainer + from src.models.strategies.vbp import VBPModel, VBPTrainer + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.1.0 +Created: 2026-01-25 +""" + +from . import pva +from . import mrd +from . import vbp +from . import msa +from . import mts + +__all__ = [ + 'pva', + 'mrd', + 'vbp', + 'msa', + 'mts', +] + +__version__ = '1.3.0' diff --git a/src/models/strategies/mrd/__init__.py b/src/models/strategies/mrd/__init__.py new file mode 100644 index 0000000..22b88ad --- /dev/null +++ b/src/models/strategies/mrd/__init__.py @@ -0,0 +1,109 @@ +""" +MRD Strategy - Momentum Regime Detection +======================================== + +Momentum-based regime detection strategy using Hidden Markov Models +to identify market states (Trend Up, Range, Trend Down). + +Components: +- MRDFeatureEngineer: Computes momentum indicators (RSI, MACD, ROC, ADX, etc.) +- RegimeHMM: Hidden Markov Model for unsupervised regime detection +- MRDModel: Complete model combining HMM + LSTM + XGBoost +- MRDTrainer: Training pipeline with walk-forward validation + +The strategy detects three market regimes: +1. Trend Up: Bullish momentum, favor long positions +2. Range: Low momentum, favor mean reversion or avoid +3. Trend Down: Bearish momentum, favor short positions + +Example Usage: + from src.models.strategies.mrd import ( + MRDModel, + MRDTrainer, + MRDFeatureEngineer, + RegimeHMM + ) + + # Full model training + model = MRDModel() + metrics = model.fit(df_train, df_val) + + # Make predictions + prediction = model.predict(df_test) + print(f"Regime: {prediction.regime_name}") + print(f"Continuation prob: {prediction.continuation_prob}") + + # Walk-forward training + trainer = MRDTrainer() + results = trainer.walk_forward_train(df, symbol='EURUSD', n_folds=5) + + # Individual components + engineer = MRDFeatureEngineer() + features = engineer.compute_all_features(df) + + hmm = RegimeHMM() + hmm.fit(features) + regimes = hmm.predict_regime(features) + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +# Feature Engineering +from .feature_engineering import ( + MRDFeatureEngineer, + MRDFeatureConfig, +) + +# HMM Regime Detection +from .hmm_regime import ( + RegimeHMM, + RegimeHMMConfig, + RegimePrediction, + MarketRegime, +) + +# Complete Model +from .model import ( + MRDModel, + MRDModelConfig, + MRDPrediction, + MRDLSTMEncoder, +) + +# Trainer +from .trainer import ( + MRDTrainer, + TrainerConfig, + TrainingResults, + FoldResult, +) + + +__all__ = [ + # Feature Engineering + 'MRDFeatureEngineer', + 'MRDFeatureConfig', + + # HMM + 'RegimeHMM', + 'RegimeHMMConfig', + 'RegimePrediction', + 'MarketRegime', + + # Model + 'MRDModel', + 'MRDModelConfig', + 'MRDPrediction', + 'MRDLSTMEncoder', + + # Trainer + 'MRDTrainer', + 'TrainerConfig', + 'TrainingResults', + 'FoldResult', +] + + +__version__ = '1.0.0' diff --git a/src/models/strategies/mrd/feature_engineering.py b/src/models/strategies/mrd/feature_engineering.py new file mode 100644 index 0000000..c6bd8b4 --- /dev/null +++ b/src/models/strategies/mrd/feature_engineering.py @@ -0,0 +1,632 @@ +""" +MRD Feature Engineering - Momentum Regime Detection Features +============================================================= + +Feature engineering module for momentum-based indicators used in +regime detection. Computes technical indicators that capture +momentum, trend strength, and market conditions. + +Features computed: +- RSI (Relative Strength Index) at multiple periods +- MACD (Moving Average Convergence Divergence) +- ROC (Rate of Change) at multiple periods +- ADX (Average Directional Index) with +DI/-DI +- EMA crossover signals +- Trend strength relative to EMA 200 + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Union +from dataclasses import dataclass, field +from loguru import logger + + +@dataclass +class MRDFeatureConfig: + """Configuration for MRD feature engineering.""" + + # RSI periods + rsi_periods: List[int] = field(default_factory=lambda: [14, 28]) + + # MACD parameters + macd_fast: int = 12 + macd_slow: int = 26 + macd_signal: int = 9 + + # ROC periods + roc_periods: List[int] = field(default_factory=lambda: [5, 10, 20]) + + # ADX period + adx_period: int = 14 + + # EMA crossover pairs (short, long) + ema_pairs: List[Tuple[int, int]] = field( + default_factory=lambda: [(9, 21), (21, 50)] + ) + + # Trend EMA + trend_ema_period: int = 200 + + # Smoothing for stability + smooth_period: int = 3 + + +class MRDFeatureEngineer: + """ + Feature engineer for Momentum Regime Detection. + + Computes technical indicators that capture market momentum + and regime characteristics for HMM-based regime detection. + + Features are designed to be stationary and suitable for + Hidden Markov Model training. + + Usage: + engineer = MRDFeatureEngineer() + features = engineer.compute_all_features(df) + + # Or compute individual features: + rsi = engineer.compute_rsi(df) + macd, signal, hist = engineer.compute_macd(df) + """ + + def __init__(self, config: Optional[MRDFeatureConfig] = None): + """ + Initialize the feature engineer. + + Args: + config: Feature configuration + """ + self.config = config or MRDFeatureConfig() + self._feature_names: List[str] = [] + + def compute_all_features( + self, + df: pd.DataFrame, + include_raw_price: bool = False + ) -> pd.DataFrame: + """ + Compute all MRD features from OHLCV data. + + Args: + df: DataFrame with OHLCV columns (open, high, low, close, volume) + include_raw_price: Whether to include raw price features + + Returns: + DataFrame with computed features + """ + logger.info(f"Computing MRD features for {len(df)} samples") + + features = pd.DataFrame(index=df.index) + + # 1. RSI features + rsi_features = self.compute_rsi(df) + features = pd.concat([features, rsi_features], axis=1) + + # 2. MACD features + macd, signal, histogram = self.compute_macd(df) + features['macd'] = macd + features['macd_signal'] = signal + features['macd_histogram'] = histogram + features['macd_histogram_change'] = histogram.diff() + + # 3. ROC features + roc_features = self.compute_roc(df) + features = pd.concat([features, roc_features], axis=1) + + # 4. ADX features + adx, plus_di, minus_di = self.compute_adx(df) + features['adx'] = adx + features['plus_di'] = plus_di + features['minus_di'] = minus_di + features['di_spread'] = plus_di - minus_di + features['di_ratio'] = plus_di / (minus_di + 1e-8) + + # 5. EMA crossover features + crossover_features = self.compute_ema_crossovers(df) + features = pd.concat([features, crossover_features], axis=1) + + # 6. Trend strength + trend_features = self.compute_trend_strength(df) + features = pd.concat([features, trend_features], axis=1) + + # 7. Additional momentum features + momentum_features = self._compute_additional_momentum(df) + features = pd.concat([features, momentum_features], axis=1) + + # 8. Volatility-adjusted features + vol_features = self._compute_volatility_features(df) + features = pd.concat([features, vol_features], axis=1) + + # Store feature names + self._feature_names = features.columns.tolist() + + # Fill NaN values with forward fill then zero + features = features.ffill().fillna(0) + + logger.info(f"Computed {len(self._feature_names)} features") + + return features + + def compute_rsi( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute RSI (Relative Strength Index) at multiple periods. + + RSI measures the magnitude of recent price changes to evaluate + overbought or oversold conditions. + + Args: + df: DataFrame with 'close' column + periods: List of periods for RSI calculation + + Returns: + DataFrame with RSI columns + """ + periods = periods or self.config.rsi_periods + close = df['close'] + + rsi_features = pd.DataFrame(index=df.index) + + for period in periods: + delta = close.diff() + + gain = delta.where(delta > 0, 0) + loss = (-delta).where(delta < 0, 0) + + # Use EMA for more responsive RSI + avg_gain = gain.ewm(span=period, adjust=False).mean() + avg_loss = loss.ewm(span=period, adjust=False).mean() + + rs = avg_gain / (avg_loss + 1e-10) + rsi = 100 - (100 / (1 + rs)) + + # Normalize RSI to [-1, 1] for HMM + rsi_normalized = (rsi - 50) / 50 + + rsi_features[f'rsi_{period}'] = rsi + rsi_features[f'rsi_{period}_norm'] = rsi_normalized + + # RSI rate of change + rsi_features[f'rsi_{period}_roc'] = rsi.diff(3) / 100 + + return rsi_features + + def compute_macd( + self, + df: pd.DataFrame, + fast: Optional[int] = None, + slow: Optional[int] = None, + signal: Optional[int] = None + ) -> Tuple[pd.Series, pd.Series, pd.Series]: + """ + Compute MACD (Moving Average Convergence Divergence). + + MACD shows the relationship between two EMAs of a security's price. + + Args: + df: DataFrame with 'close' column + fast: Fast EMA period + slow: Slow EMA period + signal: Signal line period + + Returns: + Tuple of (macd, signal_line, histogram) + """ + fast = fast or self.config.macd_fast + slow = slow or self.config.macd_slow + signal_period = signal or self.config.macd_signal + + close = df['close'] + + # Calculate EMAs + ema_fast = close.ewm(span=fast, adjust=False).mean() + ema_slow = close.ewm(span=slow, adjust=False).mean() + + # MACD line + macd_line = ema_fast - ema_slow + + # Signal line + signal_line = macd_line.ewm(span=signal_period, adjust=False).mean() + + # Histogram + histogram = macd_line - signal_line + + # Normalize by price for comparability across assets + price_scale = close.rolling(slow).mean() + macd_normalized = macd_line / (price_scale + 1e-8) * 100 + signal_normalized = signal_line / (price_scale + 1e-8) * 100 + histogram_normalized = histogram / (price_scale + 1e-8) * 100 + + return macd_normalized, signal_normalized, histogram_normalized + + def compute_roc( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute ROC (Rate of Change) at multiple periods. + + ROC measures the percentage change in price over a given period. + + Args: + df: DataFrame with 'close' column + periods: List of periods for ROC calculation + + Returns: + DataFrame with ROC columns + """ + periods = periods or self.config.roc_periods + close = df['close'] + + roc_features = pd.DataFrame(index=df.index) + + for period in periods: + # Standard ROC + roc = (close - close.shift(period)) / (close.shift(period) + 1e-10) * 100 + roc_features[f'roc_{period}'] = roc + + # Smoothed ROC for stability + roc_smooth = roc.rolling(self.config.smooth_period).mean() + roc_features[f'roc_{period}_smooth'] = roc_smooth + + # ROC acceleration (momentum of momentum) + roc_features[f'roc_{period}_accel'] = roc.diff() + + return roc_features + + def compute_adx( + self, + df: pd.DataFrame, + period: Optional[int] = None + ) -> Tuple[pd.Series, pd.Series, pd.Series]: + """ + Compute ADX (Average Directional Index) with +DI and -DI. + + ADX measures trend strength regardless of direction. + +DI and -DI indicate the direction of the trend. + + Args: + df: DataFrame with 'high', 'low', 'close' columns + period: ADX period + + Returns: + Tuple of (adx, plus_di, minus_di) + """ + period = period or self.config.adx_period + + high = df['high'] + low = df['low'] + close = df['close'] + + # True Range + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + # Directional Movement + plus_dm = high.diff() + minus_dm = -low.diff() + + # Only count if positive and greater than the other + plus_dm = plus_dm.where( + (plus_dm > minus_dm) & (plus_dm > 0), 0 + ) + minus_dm = minus_dm.where( + (minus_dm > plus_dm) & (minus_dm > 0), 0 + ) + + # Smoothed averages using Wilder's smoothing + atr = true_range.ewm(span=period, adjust=False).mean() + plus_di_smooth = plus_dm.ewm(span=period, adjust=False).mean() + minus_di_smooth = minus_dm.ewm(span=period, adjust=False).mean() + + # Directional Indicators + plus_di = 100 * plus_di_smooth / (atr + 1e-10) + minus_di = 100 * minus_di_smooth / (atr + 1e-10) + + # DX and ADX + di_diff = abs(plus_di - minus_di) + di_sum = plus_di + minus_di + dx = 100 * di_diff / (di_sum + 1e-10) + + adx = dx.ewm(span=period, adjust=False).mean() + + return adx, plus_di, minus_di + + def compute_ema_crossovers( + self, + df: pd.DataFrame, + ema_pairs: Optional[List[Tuple[int, int]]] = None + ) -> pd.DataFrame: + """ + Compute EMA crossover signals and distances. + + Generates signals for EMA crosses (9/21, 21/50) and measures + the distance between EMAs as a trend indicator. + + Args: + df: DataFrame with 'close' column + ema_pairs: List of (short_period, long_period) tuples + + Returns: + DataFrame with crossover features + """ + ema_pairs = ema_pairs or self.config.ema_pairs + close = df['close'] + + crossover_features = pd.DataFrame(index=df.index) + + for short_period, long_period in ema_pairs: + ema_short = close.ewm(span=short_period, adjust=False).mean() + ema_long = close.ewm(span=long_period, adjust=False).mean() + + # Distance between EMAs (normalized by price) + ema_distance = (ema_short - ema_long) / (close + 1e-10) * 100 + crossover_features[f'ema_{short_period}_{long_period}_dist'] = ema_distance + + # Crossover signal (1 = bullish cross, -1 = bearish cross) + cross_up = (ema_short > ema_long) & (ema_short.shift(1) <= ema_long.shift(1)) + cross_down = (ema_short < ema_long) & (ema_short.shift(1) >= ema_long.shift(1)) + crossover_signal = cross_up.astype(int) - cross_down.astype(int) + crossover_features[f'ema_{short_period}_{long_period}_cross'] = crossover_signal + + # Cumulative crossover state (1 = above, -1 = below) + state = (ema_short > ema_long).astype(int) * 2 - 1 + crossover_features[f'ema_{short_period}_{long_period}_state'] = state + + # Time since last crossover (capped) + cross_any = cross_up | cross_down + crossover_features[f'ema_{short_period}_{long_period}_bars_since'] = ( + cross_any.groupby((cross_any != cross_any.shift()).cumsum()).cumcount() + ).clip(upper=100) + + # Rate of change of distance + crossover_features[f'ema_{short_period}_{long_period}_dist_roc'] = ( + ema_distance.diff(3) + ) + + return crossover_features + + def compute_trend_strength( + self, + df: pd.DataFrame, + ema_period: Optional[int] = None + ) -> pd.DataFrame: + """ + Compute trend strength relative to EMA 200. + + Measures how far price is from long-term trend and + the consistency of the trend direction. + + Args: + df: DataFrame with 'close' column + ema_period: EMA period for trend reference (default 200) + + Returns: + DataFrame with trend strength features + """ + ema_period = ema_period or self.config.trend_ema_period + close = df['close'] + + trend_features = pd.DataFrame(index=df.index) + + # EMA 200 + ema_200 = close.ewm(span=ema_period, adjust=False).mean() + + # Distance from EMA 200 (normalized) + distance = (close - ema_200) / (ema_200 + 1e-10) * 100 + trend_features['trend_distance'] = distance + + # Trend state (above/below EMA 200) + trend_features['trend_state'] = (close > ema_200).astype(int) * 2 - 1 + + # EMA 200 slope (normalized) + ema_slope = ema_200.diff(5) / (ema_200 + 1e-10) * 100 + trend_features['trend_slope'] = ema_slope + + # Price position in recent range relative to EMA + rolling_high = df['high'].rolling(20).max() + rolling_low = df['low'].rolling(20).min() + ema_position = (ema_200 - rolling_low) / (rolling_high - rolling_low + 1e-10) + trend_features['ema_position_in_range'] = ema_position + + # Trend consistency (how many bars in a row above/below EMA) + above = close > ema_200 + trend_features['trend_consistency'] = ( + above.groupby((above != above.shift()).cumsum()).cumcount() + ).clip(upper=50) * trend_features['trend_state'] + + # Multi-period trend alignment + ema_50 = close.ewm(span=50, adjust=False).mean() + ema_100 = close.ewm(span=100, adjust=False).mean() + + # Trend alignment score (-3 to +3) + alignment = ( + (close > ema_50).astype(int) + + (close > ema_100).astype(int) + + (close > ema_200).astype(int) + + (ema_50 > ema_100).astype(int) + + (ema_100 > ema_200).astype(int) + ) + trend_features['trend_alignment'] = alignment - 2.5 # Center around 0 + + return trend_features + + def _compute_additional_momentum(self, df: pd.DataFrame) -> pd.DataFrame: + """Compute additional momentum indicators.""" + close = df['close'] + high = df['high'] + low = df['low'] + + features = pd.DataFrame(index=df.index) + + # Williams %R + highest_high = high.rolling(14).max() + lowest_low = low.rolling(14).min() + williams_r = (highest_high - close) / (highest_high - lowest_low + 1e-10) * -100 + features['williams_r'] = williams_r + features['williams_r_norm'] = (williams_r + 50) / 50 # Normalize to [-1, 1] + + # Stochastic Oscillator + stoch_k = (close - lowest_low) / (highest_high - lowest_low + 1e-10) * 100 + stoch_d = stoch_k.rolling(3).mean() + features['stoch_k'] = stoch_k + features['stoch_d'] = stoch_d + features['stoch_diff'] = stoch_k - stoch_d + + # CCI (Commodity Channel Index) + typical_price = (high + low + close) / 3 + sma_tp = typical_price.rolling(20).mean() + mean_deviation = typical_price.rolling(20).apply( + lambda x: np.abs(x - x.mean()).mean() + ) + cci = (typical_price - sma_tp) / (0.015 * mean_deviation + 1e-10) + features['cci'] = cci + features['cci_norm'] = cci / 200 # Normalize roughly to [-1, 1] + + # Ultimate Oscillator + bp = close - pd.concat([low, close.shift(1)], axis=1).min(axis=1) + tr = pd.concat([ + high - low, + abs(high - close.shift(1)), + abs(low - close.shift(1)) + ], axis=1).max(axis=1) + + avg7 = bp.rolling(7).sum() / (tr.rolling(7).sum() + 1e-10) + avg14 = bp.rolling(14).sum() / (tr.rolling(14).sum() + 1e-10) + avg28 = bp.rolling(28).sum() / (tr.rolling(28).sum() + 1e-10) + + uo = 100 * (4 * avg7 + 2 * avg14 + avg28) / 7 + features['ultimate_osc'] = uo + features['ultimate_osc_norm'] = (uo - 50) / 50 + + return features + + def _compute_volatility_features(self, df: pd.DataFrame) -> pd.DataFrame: + """Compute volatility-adjusted momentum features.""" + close = df['close'] + high = df['high'] + low = df['low'] + + features = pd.DataFrame(index=df.index) + + # ATR for volatility normalization + tr = pd.concat([ + high - low, + abs(high - close.shift(1)), + abs(low - close.shift(1)) + ], axis=1).max(axis=1) + atr_14 = tr.rolling(14).mean() + + # Volatility ratio (current vs average) + atr_50 = tr.rolling(50).mean() + features['vol_ratio'] = atr_14 / (atr_50 + 1e-10) + + # Normalized range + features['norm_range'] = (high - low) / (atr_14 + 1e-10) + + # Bollinger Band position + sma_20 = close.rolling(20).mean() + std_20 = close.rolling(20).std() + bb_upper = sma_20 + 2 * std_20 + bb_lower = sma_20 - 2 * std_20 + bb_width = (bb_upper - bb_lower) / (sma_20 + 1e-10) + bb_position = (close - bb_lower) / (bb_upper - bb_lower + 1e-10) + + features['bb_width'] = bb_width + features['bb_position'] = bb_position + features['bb_position_norm'] = bb_position * 2 - 1 # Normalize to [-1, 1] + + # Keltner Channel position + ema_20 = close.ewm(span=20, adjust=False).mean() + kc_upper = ema_20 + 2 * atr_14 + kc_lower = ema_20 - 2 * atr_14 + kc_position = (close - kc_lower) / (kc_upper - kc_lower + 1e-10) + features['kc_position'] = kc_position + features['kc_position_norm'] = kc_position * 2 - 1 + + # Squeeze indicator (BB inside KC) + squeeze = (bb_lower > kc_lower) & (bb_upper < kc_upper) + features['squeeze'] = squeeze.astype(int) + + return features + + def get_feature_names(self) -> List[str]: + """Get list of computed feature names.""" + return self._feature_names.copy() + + def get_feature_groups(self) -> Dict[str, List[str]]: + """Get features organized by group.""" + groups = { + 'rsi': [n for n in self._feature_names if n.startswith('rsi')], + 'macd': [n for n in self._feature_names if n.startswith('macd')], + 'roc': [n for n in self._feature_names if n.startswith('roc')], + 'adx': [n for n in self._feature_names if any(x in n for x in ['adx', 'di_'])], + 'ema_crossover': [n for n in self._feature_names if n.startswith('ema_')], + 'trend': [n for n in self._feature_names if n.startswith('trend')], + 'momentum': [n for n in self._feature_names if any( + x in n for x in ['williams', 'stoch', 'cci', 'ultimate'] + )], + 'volatility': [n for n in self._feature_names if any( + x in n for x in ['vol_', 'bb_', 'kc_', 'squeeze', 'norm_range'] + )], + } + return groups + + +if __name__ == "__main__": + # Test feature engineering + import numpy as np + + np.random.seed(42) + n_samples = 1000 + + # Create sample OHLCV data + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + price = 2000 + np.cumsum(np.random.randn(n_samples) * 0.5) + + df = pd.DataFrame({ + 'open': price + np.random.randn(n_samples) * 0.3, + 'high': price + np.abs(np.random.randn(n_samples)) * 1.0, + 'low': price - np.abs(np.random.randn(n_samples)) * 1.0, + 'close': price + np.random.randn(n_samples) * 0.3, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + # Ensure OHLC consistency + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Create feature engineer + engineer = MRDFeatureEngineer() + + # Compute all features + features = engineer.compute_all_features(df) + + print(f"\n{'='*60}") + print("MRD Feature Engineering Test") + print(f"{'='*60}") + print(f"Input samples: {len(df)}") + print(f"Output features: {len(engineer.get_feature_names())}") + print(f"\nFeature groups:") + + for group, names in engineer.get_feature_groups().items(): + print(f" {group}: {len(names)} features") + + print(f"\nSample feature values (last row):") + for col in features.columns[:10]: + print(f" {col}: {features[col].iloc[-1]:.4f}") + + print(f"\nFeature statistics:") + print(features.describe().T[['mean', 'std', 'min', 'max']].head(15)) diff --git a/src/models/strategies/mrd/hmm_regime.py b/src/models/strategies/mrd/hmm_regime.py new file mode 100644 index 0000000..b8bb263 --- /dev/null +++ b/src/models/strategies/mrd/hmm_regime.py @@ -0,0 +1,607 @@ +""" +MRD HMM Regime Detection - Hidden Markov Model for Market Regimes +================================================================= + +Implements a Hidden Markov Model for detecting market regimes +(Trend Up, Range, Trend Down) based on momentum features. + +The HMM learns to identify distinct market states from +observable momentum indicators without explicit labels. + +Key Features: +- 3-state Gaussian HMM for regime classification +- Probability-based regime assignment +- Regime duration tracking +- Regime transition analysis + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Union, Any +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from loguru import logger +from enum import IntEnum + +try: + from hmmlearn.hmm import GaussianHMM + HAS_HMMLEARN = True +except ImportError: + HAS_HMMLEARN = False + logger.warning("hmmlearn not available. Install with: pip install hmmlearn") + + +class MarketRegime(IntEnum): + """Market regime states.""" + TREND_DOWN = 0 + RANGE = 1 + TREND_UP = 2 + + +@dataclass +class RegimeHMMConfig: + """Configuration for Regime HMM.""" + + # Number of hidden states + n_states: int = 3 + + # HMM parameters + n_iterations: int = 100 + convergence_threshold: float = 1e-4 + covariance_type: str = 'full' # 'full', 'tied', 'diag', 'spherical' + + # Random state for reproducibility + random_state: int = 42 + + # Feature selection for HMM + use_features: List[str] = field(default_factory=lambda: [ + 'rsi_14_norm', + 'macd_histogram', + 'roc_10', + 'adx', + 'di_spread', + 'trend_distance', + 'trend_alignment', + 'bb_position_norm' + ]) + + # Regime classification thresholds + regime_prob_threshold: float = 0.6 + + +@dataclass +class RegimePrediction: + """Prediction result from the HMM.""" + regime: int + regime_name: str + probabilities: Dict[str, float] + confidence: float + duration: int + transition_prob_to_up: float + transition_prob_to_down: float + + def to_dict(self) -> Dict: + return { + 'regime': self.regime, + 'regime_name': self.regime_name, + 'probabilities': self.probabilities, + 'confidence': self.confidence, + 'duration': self.duration, + 'transition_prob_to_up': self.transition_prob_to_up, + 'transition_prob_to_down': self.transition_prob_to_down + } + + +class RegimeHMM: + """ + Hidden Markov Model for Market Regime Detection. + + Uses a Gaussian HMM with 3 hidden states to model market regimes: + - State 0: Trend Down (bearish momentum) + - State 1: Range (consolidation/low momentum) + - State 2: Trend Up (bullish momentum) + + The model learns regime patterns from momentum features without + requiring explicit regime labels. + + Usage: + hmm = RegimeHMM() + hmm.fit(features) + + # Predict current regime + regime = hmm.predict_regime(features) + + # Get regime probabilities + probs = hmm.get_regime_probabilities(features) + """ + + REGIME_NAMES = { + 0: 'trend_down', + 1: 'range', + 2: 'trend_up' + } + + def __init__(self, config: Optional[RegimeHMMConfig] = None): + """ + Initialize the Regime HMM. + + Args: + config: HMM configuration + """ + if not HAS_HMMLEARN: + raise ImportError( + "hmmlearn is required for RegimeHMM. " + "Install with: pip install hmmlearn" + ) + + self.config = config or RegimeHMMConfig() + self.model: Optional[GaussianHMM] = None + self.feature_scaler_mean: Optional[np.ndarray] = None + self.feature_scaler_std: Optional[np.ndarray] = None + self._is_fitted = False + self._regime_order: Optional[List[int]] = None + + def _init_model(self) -> GaussianHMM: + """Initialize the HMM model.""" + return GaussianHMM( + n_components=self.config.n_states, + covariance_type=self.config.covariance_type, + n_iter=self.config.n_iterations, + tol=self.config.convergence_threshold, + random_state=self.config.random_state, + init_params='stmc', # Initialize all parameters + params='stmc' # Train all parameters + ) + + def _preprocess_features( + self, + features: pd.DataFrame, + fit_scaler: bool = False + ) -> np.ndarray: + """ + Preprocess features for HMM. + + Args: + features: DataFrame with feature columns + fit_scaler: Whether to fit the scaler (True for training) + + Returns: + Scaled feature array + """ + # Select relevant features + use_cols = [c for c in self.config.use_features if c in features.columns] + if not use_cols: + use_cols = features.columns.tolist() + logger.warning(f"No configured features found, using all: {len(use_cols)}") + + X = features[use_cols].values.astype(np.float64) + + # Handle NaN values + X = np.nan_to_num(X, nan=0.0) + + # Scale features + if fit_scaler: + self.feature_scaler_mean = np.mean(X, axis=0) + self.feature_scaler_std = np.std(X, axis=0) + 1e-8 + self._feature_columns = use_cols + + if self.feature_scaler_mean is not None: + X = (X - self.feature_scaler_mean) / self.feature_scaler_std + + # Clip extreme values + X = np.clip(X, -5, 5) + + return X + + def fit( + self, + features: pd.DataFrame, + lengths: Optional[List[int]] = None + ) -> 'RegimeHMM': + """ + Fit the HMM to feature data. + + Args: + features: DataFrame with momentum features + lengths: Optional sequence lengths for multiple sequences + + Returns: + Self (fitted model) + """ + logger.info(f"Fitting RegimeHMM on {len(features)} samples") + + # Preprocess features + X = self._preprocess_features(features, fit_scaler=True) + + # Initialize model + self.model = self._init_model() + + # Fit the model + if lengths is not None: + self.model.fit(X, lengths) + else: + self.model.fit(X) + + self._is_fitted = True + + # Order regimes by mean ROC/momentum (so state 0 = bearish, state 2 = bullish) + self._order_regimes(X) + + # Log model statistics + self._log_model_stats() + + logger.info("RegimeHMM fitted successfully") + + return self + + def _order_regimes(self, X: np.ndarray): + """ + Order regime states so that state 0=bearish, 1=range, 2=bullish. + + Uses the mean of momentum-related features in each state + to determine the ordering. + """ + # Predict states for all data + states = self.model.predict(X) + + # Find momentum-related feature indices + momentum_cols = ['roc_10', 'macd_histogram', 'di_spread', 'trend_distance'] + momentum_indices = [ + i for i, col in enumerate(self._feature_columns) + if any(m in col for m in momentum_cols) + ] + + if not momentum_indices: + momentum_indices = list(range(min(3, X.shape[1]))) + + # Calculate mean momentum for each state + state_momentum = {} + for state in range(self.config.n_states): + mask = states == state + if mask.sum() > 0: + state_momentum[state] = np.mean(X[mask][:, momentum_indices]) + else: + state_momentum[state] = 0 + + # Order states by momentum (lowest = bearish, highest = bullish) + ordered_states = sorted(state_momentum.keys(), key=lambda s: state_momentum[s]) + self._regime_order = ordered_states + + logger.info(f"Regime ordering by momentum: {self._regime_order}") + logger.info(f"State momentums: {state_momentum}") + + def _map_state(self, state: int) -> int: + """Map internal HMM state to ordered regime.""" + if self._regime_order is None: + return state + return self._regime_order.index(state) + + def _unmap_state(self, regime: int) -> int: + """Map ordered regime back to internal HMM state.""" + if self._regime_order is None: + return regime + return self._regime_order[regime] + + def _log_model_stats(self): + """Log model statistics after fitting.""" + if self.model is None: + return + + logger.info("HMM Model Statistics:") + logger.info(f" States: {self.config.n_states}") + logger.info(f" Converged: {self.model.monitor_.converged}") + logger.info(f" Iterations: {len(self.model.monitor_.history)}") + + # Starting probabilities (mapped to ordered regimes) + start_probs = {} + for i, name in self.REGIME_NAMES.items(): + internal_state = self._unmap_state(i) + start_probs[name] = self.model.startprob_[internal_state] + logger.info(f" Starting probabilities: {start_probs}") + + # Transition matrix (reordered) + logger.info(" Transition matrix (rows=from, cols=to):") + ordered_trans = self._get_ordered_transition_matrix() + for i, row in enumerate(ordered_trans): + logger.info(f" {self.REGIME_NAMES[i]}: {row.round(3)}") + + def _get_ordered_transition_matrix(self) -> np.ndarray: + """Get transition matrix with reordered states.""" + if self._regime_order is None: + return self.model.transmat_ + + n = self.config.n_states + ordered_trans = np.zeros((n, n)) + + for i in range(n): + for j in range(n): + from_internal = self._unmap_state(i) + to_internal = self._unmap_state(j) + ordered_trans[i, j] = self.model.transmat_[from_internal, to_internal] + + return ordered_trans + + def predict_regime( + self, + features: pd.DataFrame + ) -> np.ndarray: + """ + Predict the market regime for each sample. + + Args: + features: DataFrame with momentum features + + Returns: + Array of regime labels (0=trend_down, 1=range, 2=trend_up) + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before prediction") + + X = self._preprocess_features(features, fit_scaler=False) + internal_states = self.model.predict(X) + + # Map to ordered regimes + regimes = np.array([self._map_state(s) for s in internal_states]) + + return regimes + + def get_regime_probabilities( + self, + features: pd.DataFrame + ) -> np.ndarray: + """ + Get probability distribution over regimes for each sample. + + Args: + features: DataFrame with momentum features + + Returns: + Array of shape (n_samples, n_states) with probabilities + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before prediction") + + X = self._preprocess_features(features, fit_scaler=False) + + # Get posterior probabilities + _, posteriors = self.model.score_samples(X) + + # Reorder columns to match regime order + ordered_posteriors = np.zeros_like(posteriors) + for i in range(self.config.n_states): + internal_state = self._unmap_state(i) + ordered_posteriors[:, i] = posteriors[:, internal_state] + + return ordered_posteriors + + def get_regime_duration( + self, + states: np.ndarray + ) -> np.ndarray: + """ + Calculate the duration of the current regime at each point. + + Args: + states: Array of regime states + + Returns: + Array of durations (how many bars in current regime) + """ + durations = np.ones_like(states) + current_duration = 1 + + for i in range(1, len(states)): + if states[i] == states[i - 1]: + current_duration += 1 + else: + current_duration = 1 + durations[i] = current_duration + + return durations + + def predict_with_details( + self, + features: pd.DataFrame + ) -> List[RegimePrediction]: + """ + Get detailed regime predictions including probabilities and durations. + + Args: + features: DataFrame with momentum features + + Returns: + List of RegimePrediction objects + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before prediction") + + # Get regimes and probabilities + regimes = self.predict_regime(features) + probabilities = self.get_regime_probabilities(features) + durations = self.get_regime_duration(regimes) + + # Get transition matrix for transition probabilities + trans_matrix = self._get_ordered_transition_matrix() + + predictions = [] + for i in range(len(regimes)): + regime = int(regimes[i]) + regime_name = self.REGIME_NAMES[regime] + + probs_dict = { + self.REGIME_NAMES[j]: float(probabilities[i, j]) + for j in range(self.config.n_states) + } + + confidence = float(probabilities[i, regime]) + + pred = RegimePrediction( + regime=regime, + regime_name=regime_name, + probabilities=probs_dict, + confidence=confidence, + duration=int(durations[i]), + transition_prob_to_up=float(trans_matrix[regime, MarketRegime.TREND_UP]), + transition_prob_to_down=float(trans_matrix[regime, MarketRegime.TREND_DOWN]) + ) + predictions.append(pred) + + return predictions + + def get_regime_statistics( + self, + features: pd.DataFrame + ) -> Dict[str, Any]: + """ + Get statistics about regimes in the data. + + Args: + features: DataFrame with momentum features + + Returns: + Dictionary with regime statistics + """ + regimes = self.predict_regime(features) + durations = self.get_regime_duration(regimes) + + stats = { + 'regime_counts': {}, + 'regime_percentages': {}, + 'avg_duration': {}, + 'max_duration': {}, + 'transition_matrix': self._get_ordered_transition_matrix().tolist() + } + + for regime_id, regime_name in self.REGIME_NAMES.items(): + mask = regimes == regime_id + count = mask.sum() + stats['regime_counts'][regime_name] = int(count) + stats['regime_percentages'][regime_name] = float(count / len(regimes) * 100) + + if count > 0: + stats['avg_duration'][regime_name] = float(durations[mask].mean()) + stats['max_duration'][regime_name] = int(durations[mask].max()) + else: + stats['avg_duration'][regime_name] = 0 + stats['max_duration'][regime_name] = 0 + + return stats + + def save(self, path: str): + """Save the model to disk.""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save HMM model + joblib.dump(self.model, path / 'hmm_model.joblib') + + # Save metadata + metadata = { + 'config': self.config.__dict__, + 'feature_scaler_mean': self.feature_scaler_mean, + 'feature_scaler_std': self.feature_scaler_std, + 'feature_columns': self._feature_columns, + 'regime_order': self._regime_order, + 'version': '1.0.0' + } + joblib.dump(metadata, path / 'metadata.joblib') + + logger.info(f"Saved RegimeHMM to {path}") + + def load(self, path: str): + """Load the model from disk.""" + path = Path(path) + + self.model = joblib.load(path / 'hmm_model.joblib') + + metadata = joblib.load(path / 'metadata.joblib') + self.config = RegimeHMMConfig(**metadata['config']) + self.feature_scaler_mean = metadata['feature_scaler_mean'] + self.feature_scaler_std = metadata['feature_scaler_std'] + self._feature_columns = metadata['feature_columns'] + self._regime_order = metadata['regime_order'] + + self._is_fitted = True + logger.info(f"Loaded RegimeHMM from {path}") + + +if __name__ == "__main__": + # Test HMM regime detection + from feature_engineering import MRDFeatureEngineer + + np.random.seed(42) + n_samples = 2000 + + # Create sample OHLCV data with trend patterns + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + + # Create price with regime changes + price = np.zeros(n_samples) + price[0] = 2000 + + regime_changes = [0, 500, 1000, 1500] + regimes_true = ['up', 'down', 'range', 'up'] + + for i in range(1, n_samples): + # Determine current regime + regime_idx = sum(1 for r in regime_changes if i >= r) - 1 + regime = regimes_true[regime_idx] + + if regime == 'up': + drift = 0.0002 + elif regime == 'down': + drift = -0.0002 + else: + drift = 0 + + price[i] = price[i-1] * (1 + drift + np.random.randn() * 0.0005) + + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(n_samples) * 0.0002), + 'high': price * (1 + np.abs(np.random.randn(n_samples)) * 0.0005), + 'low': price * (1 - np.abs(np.random.randn(n_samples)) * 0.0005), + 'close': price, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + # Ensure OHLC consistency + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Compute features + engineer = MRDFeatureEngineer() + features = engineer.compute_all_features(df) + + # Train HMM + print(f"\n{'='*60}") + print("Testing RegimeHMM") + print(f"{'='*60}") + + hmm = RegimeHMM() + hmm.fit(features) + + # Get predictions + regimes = hmm.predict_regime(features) + probs = hmm.get_regime_probabilities(features) + durations = hmm.get_regime_duration(regimes) + + # Statistics + stats = hmm.get_regime_statistics(features) + print(f"\nRegime Statistics:") + for regime, count in stats['regime_counts'].items(): + pct = stats['regime_percentages'][regime] + avg_dur = stats['avg_duration'][regime] + print(f" {regime}: {count} samples ({pct:.1f}%), avg duration: {avg_dur:.1f}") + + # Detailed predictions for last few samples + print(f"\nLast 5 predictions:") + predictions = hmm.predict_with_details(features.tail(5)) + for i, pred in enumerate(predictions): + print(f" {i}: {pred.regime_name} (conf={pred.confidence:.2f}, dur={pred.duration})") + + print("\nTest complete!") diff --git a/src/models/strategies/mrd/model.py b/src/models/strategies/mrd/model.py new file mode 100644 index 0000000..bb1d2aa --- /dev/null +++ b/src/models/strategies/mrd/model.py @@ -0,0 +1,875 @@ +""" +MRD Model - Complete Momentum Regime Detection Model +==================================================== + +Full MRD model that combines: +1. HMM for regime detection +2. LSTM for sequence modeling +3. XGBoost for final prediction + +The model predicts: +- Current market regime (trend up, range, trend down) +- Regime duration +- Probability of regime continuation + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from loguru import logger + +try: + import torch + import torch.nn as nn + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + logger.warning("PyTorch not available for LSTM component") + +try: + from xgboost import XGBClassifier, XGBRegressor + XGBOOST_AVAILABLE = True +except ImportError: + XGBOOST_AVAILABLE = False + logger.warning("XGBoost not available") + +from .feature_engineering import MRDFeatureEngineer, MRDFeatureConfig +from .hmm_regime import RegimeHMM, RegimeHMMConfig, MarketRegime + + +@dataclass +class MRDModelConfig: + """Configuration for the complete MRD model.""" + + # Feature engineering config + feature_config: Optional[MRDFeatureConfig] = None + + # HMM config + hmm_config: Optional[RegimeHMMConfig] = None + + # LSTM parameters + lstm_hidden_size: int = 128 + lstm_num_layers: int = 2 + lstm_dropout: float = 0.2 + lstm_sequence_length: int = 60 + lstm_bidirectional: bool = False + + # XGBoost parameters + xgb_n_estimators: int = 200 + xgb_max_depth: int = 5 + xgb_learning_rate: float = 0.05 + xgb_subsample: float = 0.8 + xgb_colsample_bytree: float = 0.8 + + # Training parameters + batch_size: int = 64 + learning_rate: float = 0.001 + weight_decay: float = 1e-5 + + # Device + device: str = 'cuda' if TORCH_AVAILABLE and torch.cuda.is_available() else 'cpu' + + +@dataclass +class MRDPrediction: + """Complete MRD prediction output.""" + regime: int + regime_name: str + regime_probabilities: Dict[str, float] + regime_confidence: float + duration: int + continuation_prob: float + reversal_prob: float + lstm_hidden_state: Optional[np.ndarray] = None + feature_importance: Optional[Dict[str, float]] = None + + def to_dict(self) -> Dict: + return { + 'regime': self.regime, + 'regime_name': self.regime_name, + 'regime_probabilities': self.regime_probabilities, + 'regime_confidence': self.regime_confidence, + 'duration': self.duration, + 'continuation_prob': self.continuation_prob, + 'reversal_prob': self.reversal_prob + } + + @property + def is_trending(self) -> bool: + """Check if currently in a trending regime.""" + return self.regime != MarketRegime.RANGE + + @property + def is_bullish(self) -> bool: + """Check if bullish trend.""" + return self.regime == MarketRegime.TREND_UP and self.regime_confidence > 0.6 + + @property + def is_bearish(self) -> bool: + """Check if bearish trend.""" + return self.regime == MarketRegime.TREND_DOWN and self.regime_confidence > 0.6 + + @property + def should_trade(self) -> bool: + """Check if conditions favor trading.""" + return self.is_trending and self.continuation_prob > 0.5 + + +class MRDLSTMEncoder(nn.Module): + """ + LSTM encoder for sequence modeling in MRD. + + Takes momentum features as input and produces: + - Hidden state representation + - Regime continuation prediction + """ + + def __init__( + self, + input_size: int, + hidden_size: int = 128, + num_layers: int = 2, + dropout: float = 0.2, + bidirectional: bool = False, + n_regimes: int = 3 + ): + """ + Initialize LSTM encoder. + + Args: + input_size: Number of input features + hidden_size: LSTM hidden size + num_layers: Number of LSTM layers + dropout: Dropout probability + bidirectional: Whether to use bidirectional LSTM + n_regimes: Number of regime classes + """ + super().__init__() + + self.hidden_size = hidden_size + self.num_layers = num_layers + self.bidirectional = bidirectional + self.n_directions = 2 if bidirectional else 1 + + # Input projection + self.input_proj = nn.Linear(input_size, hidden_size) + self.input_norm = nn.LayerNorm(hidden_size) + + # LSTM layers + self.lstm = nn.LSTM( + input_size=hidden_size, + hidden_size=hidden_size, + num_layers=num_layers, + batch_first=True, + dropout=dropout if num_layers > 1 else 0, + bidirectional=bidirectional + ) + + # Output dimension + output_dim = hidden_size * self.n_directions + + # Regime head (predicts regime from hidden state) + self.regime_head = nn.Sequential( + nn.Linear(output_dim, hidden_size), + nn.ReLU(), + nn.Dropout(dropout), + nn.Linear(hidden_size, n_regimes) + ) + + # Continuation head (predicts continuation probability) + self.continuation_head = nn.Sequential( + nn.Linear(output_dim + n_regimes + 1, hidden_size // 2), + nn.ReLU(), + nn.Dropout(dropout), + nn.Linear(hidden_size // 2, 1), + nn.Sigmoid() + ) + + # Feature extraction for XGBoost + self.feature_extractor = nn.Linear(output_dim, hidden_size // 2) + + def forward( + self, + x: torch.Tensor, + regime_labels: Optional[torch.Tensor] = None, + durations: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Forward pass. + + Args: + x: Input features (batch_size, seq_len, input_size) + regime_labels: Optional regime labels (batch_size,) + durations: Optional regime durations (batch_size,) + + Returns: + Tuple of: + - regime_logits: (batch_size, n_regimes) + - continuation_prob: (batch_size, 1) + - lstm_features: (batch_size, hidden_size//2) + - hidden_state: Final hidden state + """ + batch_size = x.size(0) + + # Input projection + x = self.input_proj(x) + x = self.input_norm(x) + + # LSTM forward + lstm_out, (hidden, cell) = self.lstm(x) + + # Take last output + last_output = lstm_out[:, -1, :] # (batch_size, hidden_size * n_directions) + + # Regime prediction + regime_logits = self.regime_head(last_output) + + # Prepare continuation input + if regime_labels is not None and durations is not None: + # One-hot encode regime + regime_onehot = torch.zeros(batch_size, 3, device=x.device) + regime_onehot.scatter_(1, regime_labels.unsqueeze(1), 1) + duration_norm = durations.unsqueeze(1) / 100.0 # Normalize duration + else: + # Use predicted regime probabilities + regime_onehot = torch.softmax(regime_logits, dim=1) + duration_norm = torch.zeros(batch_size, 1, device=x.device) + + continuation_input = torch.cat([last_output, regime_onehot, duration_norm], dim=1) + continuation_prob = self.continuation_head(continuation_input) + + # Extract features for XGBoost + lstm_features = self.feature_extractor(last_output) + + # Reshape hidden for return + if self.bidirectional: + hidden = hidden.view(self.num_layers, 2, batch_size, self.hidden_size) + hidden = torch.cat([hidden[:, 0, :, :], hidden[:, 1, :, :]], dim=2) + hidden_state = hidden[-1] # Last layer + + return regime_logits, continuation_prob, lstm_features, hidden_state + + +class MRDModel: + """ + Complete Momentum Regime Detection Model. + + Combines: + 1. MRDFeatureEngineer: Computes momentum indicators + 2. RegimeHMM: Detects market regimes + 3. MRDLSTMEncoder: Models sequences and predicts continuation + 4. XGBoost: Final ensemble prediction + + The model outputs: + - Current regime (trend up, range, trend down) + - Regime duration + - Probability of regime continuation + + Usage: + model = MRDModel() + model.fit(df_train) + + prediction = model.predict(df_test) + print(f"Regime: {prediction.regime_name}") + print(f"Continuation prob: {prediction.continuation_prob:.2f}") + """ + + def __init__(self, config: Optional[MRDModelConfig] = None): + """ + Initialize the MRD model. + + Args: + config: Model configuration + """ + self.config = config or MRDModelConfig() + + # Initialize components + self.feature_engineer = MRDFeatureEngineer( + self.config.feature_config or MRDFeatureConfig() + ) + + self.hmm = RegimeHMM( + self.config.hmm_config or RegimeHMMConfig() + ) + + self.lstm: Optional[MRDLSTMEncoder] = None + self.xgb_continuation: Optional[XGBRegressor] = None + self.xgb_regime: Optional[XGBClassifier] = None + + self._is_fitted = False + self._feature_columns: List[str] = [] + self._n_features: int = 0 + + self.device = torch.device(self.config.device) if TORCH_AVAILABLE else None + + def _init_lstm(self, n_features: int): + """Initialize LSTM model.""" + if not TORCH_AVAILABLE: + logger.warning("PyTorch not available, LSTM will be skipped") + return + + self.lstm = MRDLSTMEncoder( + input_size=n_features, + hidden_size=self.config.lstm_hidden_size, + num_layers=self.config.lstm_num_layers, + dropout=self.config.lstm_dropout, + bidirectional=self.config.lstm_bidirectional, + n_regimes=3 + ).to(self.device) + + def _init_xgboost(self): + """Initialize XGBoost models.""" + if not XGBOOST_AVAILABLE: + logger.warning("XGBoost not available") + return + + base_params = { + 'n_estimators': self.config.xgb_n_estimators, + 'max_depth': self.config.xgb_max_depth, + 'learning_rate': self.config.xgb_learning_rate, + 'subsample': self.config.xgb_subsample, + 'colsample_bytree': self.config.xgb_colsample_bytree, + 'tree_method': 'hist', + 'random_state': 42 + } + + # Regression for continuation probability + self.xgb_continuation = XGBRegressor(**base_params) + + # Classification for regime + self.xgb_regime = XGBClassifier( + **base_params, + objective='multi:softprob', + num_class=3 + ) + + def fit( + self, + df: pd.DataFrame, + df_val: Optional[pd.DataFrame] = None, + epochs: int = 50, + verbose: bool = True + ) -> Dict[str, Any]: + """ + Fit the complete MRD model. + + Args: + df: Training DataFrame with OHLCV data + df_val: Optional validation DataFrame + epochs: Number of LSTM training epochs + verbose: Print training progress + + Returns: + Dictionary with training metrics + """ + logger.info(f"Fitting MRD Model on {len(df)} samples") + + # Step 1: Compute features + logger.info("Step 1: Computing features...") + features = self.feature_engineer.compute_all_features(df) + self._feature_columns = features.columns.tolist() + self._n_features = len(self._feature_columns) + + # Step 2: Fit HMM for regime detection + logger.info("Step 2: Fitting HMM...") + self.hmm.fit(features) + + # Get regime labels + regimes = self.hmm.predict_regime(features) + durations = self.hmm.get_regime_duration(regimes) + + # Step 3: Initialize and train LSTM + logger.info("Step 3: Training LSTM...") + self._init_lstm(self._n_features) + + lstm_metrics = {} + if self.lstm is not None: + lstm_metrics = self._train_lstm( + features, regimes, durations, epochs, verbose + ) + + # Step 4: Train XGBoost ensemble + logger.info("Step 4: Training XGBoost ensemble...") + self._init_xgboost() + + xgb_metrics = {} + if self.xgb_continuation is not None: + xgb_metrics = self._train_xgboost(features, regimes, durations) + + self._is_fitted = True + + # Validation metrics + val_metrics = {} + if df_val is not None: + val_metrics = self._validate(df_val) + + metrics = { + 'lstm': lstm_metrics, + 'xgb': xgb_metrics, + 'validation': val_metrics, + 'hmm': self.hmm.get_regime_statistics(features) + } + + logger.info("MRD Model training complete") + return metrics + + def _train_lstm( + self, + features: pd.DataFrame, + regimes: np.ndarray, + durations: np.ndarray, + epochs: int, + verbose: bool + ) -> Dict[str, float]: + """Train the LSTM component.""" + if self.lstm is None: + return {} + + # Prepare sequences + X, y_regime, y_duration = self._prepare_sequences( + features.values, regimes, durations + ) + + # Create continuation labels (1 if same regime next period, 0 otherwise) + y_continuation = (regimes[self.config.lstm_sequence_length:] == + regimes[self.config.lstm_sequence_length - 1:-1]).astype(np.float32) + y_continuation = y_continuation[:len(X)] + + # Convert to tensors + X_tensor = torch.FloatTensor(X).to(self.device) + regime_tensor = torch.LongTensor(y_regime).to(self.device) + duration_tensor = torch.FloatTensor(y_duration).to(self.device) + continuation_tensor = torch.FloatTensor(y_continuation).to(self.device) + + # Create dataset and loader + dataset = torch.utils.data.TensorDataset( + X_tensor, regime_tensor, duration_tensor, continuation_tensor + ) + loader = torch.utils.data.DataLoader( + dataset, batch_size=self.config.batch_size, shuffle=True + ) + + # Optimizer + optimizer = torch.optim.AdamW( + self.lstm.parameters(), + lr=self.config.learning_rate, + weight_decay=self.config.weight_decay + ) + + # Loss functions + regime_criterion = nn.CrossEntropyLoss() + continuation_criterion = nn.BCELoss() + + # Training loop + self.lstm.train() + best_loss = float('inf') + history = {'loss': [], 'regime_loss': [], 'continuation_loss': []} + + for epoch in range(epochs): + epoch_loss = 0 + epoch_regime_loss = 0 + epoch_cont_loss = 0 + + for batch_x, batch_regime, batch_duration, batch_cont in loader: + optimizer.zero_grad() + + # Forward pass + regime_logits, cont_prob, _, _ = self.lstm( + batch_x, batch_regime, batch_duration + ) + + # Losses + regime_loss = regime_criterion(regime_logits, batch_regime) + cont_loss = continuation_criterion(cont_prob.squeeze(), batch_cont) + + # Combined loss + loss = regime_loss + cont_loss + + # Backward pass + loss.backward() + torch.nn.utils.clip_grad_norm_(self.lstm.parameters(), 1.0) + optimizer.step() + + epoch_loss += loss.item() + epoch_regime_loss += regime_loss.item() + epoch_cont_loss += cont_loss.item() + + # Average losses + n_batches = len(loader) + avg_loss = epoch_loss / n_batches + avg_regime = epoch_regime_loss / n_batches + avg_cont = epoch_cont_loss / n_batches + + history['loss'].append(avg_loss) + history['regime_loss'].append(avg_regime) + history['continuation_loss'].append(avg_cont) + + if avg_loss < best_loss: + best_loss = avg_loss + + if verbose and (epoch + 1) % 10 == 0: + logger.info( + f"Epoch {epoch+1}/{epochs} - Loss: {avg_loss:.4f} " + f"(Regime: {avg_regime:.4f}, Cont: {avg_cont:.4f})" + ) + + self.lstm.eval() + + return { + 'final_loss': history['loss'][-1], + 'best_loss': best_loss, + 'regime_loss': history['regime_loss'][-1], + 'continuation_loss': history['continuation_loss'][-1] + } + + def _train_xgboost( + self, + features: pd.DataFrame, + regimes: np.ndarray, + durations: np.ndarray + ) -> Dict[str, float]: + """Train XGBoost ensemble.""" + if self.xgb_continuation is None: + return {} + + # Prepare features with LSTM outputs if available + X = features.values + seq_start = self.config.lstm_sequence_length + + # Get LSTM features if available + if self.lstm is not None: + lstm_features = self._get_lstm_features(features) + # Combine with raw features + X_combined = np.hstack([X[seq_start:], lstm_features]) + else: + X_combined = X[seq_start:] + + # Add regime and duration as features + regime_features = regimes[seq_start:].reshape(-1, 1) + duration_features = durations[seq_start:].reshape(-1, 1) + X_combined = np.hstack([X_combined, regime_features, duration_features]) + + # Create targets + # Continuation: 1 if same regime continues, 0 otherwise + y_cont = (regimes[seq_start:] == regimes[seq_start - 1:-1]).astype(int) + y_cont = np.append(y_cont, 0) # Pad last element + + # Regime targets + y_regime = regimes[seq_start:] + + # Train continuation model + valid_mask = ~np.isnan(X_combined).any(axis=1) + X_valid = X_combined[valid_mask] + y_cont_valid = y_cont[valid_mask] + y_regime_valid = y_regime[valid_mask] + + self.xgb_continuation.fit(X_valid, y_cont_valid) + self.xgb_regime.fit(X_valid, y_regime_valid) + + # Calculate training metrics + cont_pred = self.xgb_continuation.predict(X_valid) + regime_pred = self.xgb_regime.predict(X_valid) + + cont_accuracy = np.mean((cont_pred > 0.5) == y_cont_valid) + regime_accuracy = np.mean(regime_pred == y_regime_valid) + + return { + 'continuation_accuracy': float(cont_accuracy), + 'regime_accuracy': float(regime_accuracy) + } + + def _prepare_sequences( + self, + features: np.ndarray, + regimes: np.ndarray, + durations: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Prepare sequences for LSTM training.""" + seq_len = self.config.lstm_sequence_length + n_samples = len(features) - seq_len + + X = np.zeros((n_samples, seq_len, features.shape[1])) + y_regime = np.zeros(n_samples, dtype=np.int64) + y_duration = np.zeros(n_samples, dtype=np.float32) + + for i in range(n_samples): + X[i] = features[i:i + seq_len] + y_regime[i] = regimes[i + seq_len - 1] + y_duration[i] = durations[i + seq_len - 1] + + return X, y_regime, y_duration + + def _get_lstm_features(self, features: pd.DataFrame) -> np.ndarray: + """Extract LSTM hidden features for XGBoost.""" + if self.lstm is None: + return np.zeros((len(features) - self.config.lstm_sequence_length, 64)) + + self.lstm.eval() + + X, _, _ = self._prepare_sequences( + features.values, + np.zeros(len(features)), # Dummy regimes + np.zeros(len(features)) # Dummy durations + ) + + X_tensor = torch.FloatTensor(X).to(self.device) + + with torch.no_grad(): + _, _, lstm_features, _ = self.lstm(X_tensor) + + return lstm_features.cpu().numpy() + + def _validate(self, df_val: pd.DataFrame) -> Dict[str, float]: + """Validate on held-out data.""" + predictions = self.predict_batch(df_val) + + # Calculate metrics + features = self.feature_engineer.compute_all_features(df_val) + true_regimes = self.hmm.predict_regime(features) + true_durations = self.hmm.get_regime_duration(true_regimes) + + pred_regimes = np.array([p.regime for p in predictions]) + regime_accuracy = np.mean( + pred_regimes == true_regimes[self.config.lstm_sequence_length:] + ) + + return { + 'regime_accuracy': float(regime_accuracy), + 'n_predictions': len(predictions) + } + + def forward( + self, + features: pd.DataFrame + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Forward pass through all components. + + Args: + features: DataFrame with computed features + + Returns: + Tuple of (regime, duration, continuation_prob) + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before forward pass") + + # HMM regime detection + regimes = self.hmm.predict_regime(features) + durations = self.hmm.get_regime_duration(regimes) + regime_probs = self.hmm.get_regime_probabilities(features) + + # For samples with enough history, use full pipeline + seq_len = self.config.lstm_sequence_length + n_samples = len(features) + continuation_probs = np.zeros(n_samples) + + if n_samples >= seq_len and self.lstm is not None: + X, _, _ = self._prepare_sequences( + features.values, regimes, durations + ) + + X_tensor = torch.FloatTensor(X).to(self.device) + + with torch.no_grad(): + self.lstm.eval() + regime_tensor = torch.LongTensor(regimes[seq_len - 1:-1]).to(self.device) + duration_tensor = torch.FloatTensor(durations[seq_len - 1:-1]).to(self.device) + + _, cont_prob, _, _ = self.lstm( + X_tensor, regime_tensor, duration_tensor + ) + + continuation_probs[seq_len:] = cont_prob.squeeze().cpu().numpy() + + # For early samples, use HMM transition probabilities + for i in range(min(seq_len, n_samples)): + trans_matrix = self.hmm._get_ordered_transition_matrix() + continuation_probs[i] = trans_matrix[regimes[i], regimes[i]] + + return regimes, durations, continuation_probs + + def predict(self, df: pd.DataFrame) -> MRDPrediction: + """ + Make a single prediction for the most recent data point. + + Args: + df: DataFrame with OHLCV data (needs enough history) + + Returns: + MRDPrediction object + """ + predictions = self.predict_batch(df.tail(self.config.lstm_sequence_length + 10)) + return predictions[-1] if predictions else None + + def predict_batch(self, df: pd.DataFrame) -> List[MRDPrediction]: + """ + Make predictions for all data points. + + Args: + df: DataFrame with OHLCV data + + Returns: + List of MRDPrediction objects + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before prediction") + + # Compute features + features = self.feature_engineer.compute_all_features(df) + + # Get predictions + regimes, durations, continuation_probs = self.forward(features) + regime_probs = self.hmm.get_regime_probabilities(features) + + # Build prediction objects + predictions = [] + regime_names = {0: 'trend_down', 1: 'range', 2: 'trend_up'} + + for i in range(len(regimes)): + regime = int(regimes[i]) + duration = int(durations[i]) + cont_prob = float(continuation_probs[i]) + + probs_dict = { + regime_names[j]: float(regime_probs[i, j]) + for j in range(3) + } + + pred = MRDPrediction( + regime=regime, + regime_name=regime_names[regime], + regime_probabilities=probs_dict, + regime_confidence=float(regime_probs[i, regime]), + duration=duration, + continuation_prob=cont_prob, + reversal_prob=1.0 - cont_prob + ) + predictions.append(pred) + + return predictions + + def save(self, path: str): + """Save the complete model.""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save HMM + self.hmm.save(path / 'hmm') + + # Save LSTM + if self.lstm is not None: + torch.save(self.lstm.state_dict(), path / 'lstm.pt') + + # Save XGBoost models + if self.xgb_continuation is not None: + joblib.dump(self.xgb_continuation, path / 'xgb_continuation.joblib') + if self.xgb_regime is not None: + joblib.dump(self.xgb_regime, path / 'xgb_regime.joblib') + + # Save metadata + metadata = { + 'config': self.config.__dict__, + 'feature_columns': self._feature_columns, + 'n_features': self._n_features, + 'version': '1.0.0' + } + joblib.dump(metadata, path / 'metadata.joblib') + + logger.info(f"Saved MRD Model to {path}") + + def load(self, path: str): + """Load the complete model.""" + path = Path(path) + + # Load metadata + metadata = joblib.load(path / 'metadata.joblib') + self._feature_columns = metadata['feature_columns'] + self._n_features = metadata['n_features'] + + # Load HMM + self.hmm.load(path / 'hmm') + + # Load LSTM + if (path / 'lstm.pt').exists() and TORCH_AVAILABLE: + self._init_lstm(self._n_features) + self.lstm.load_state_dict(torch.load(path / 'lstm.pt')) + self.lstm.eval() + + # Load XGBoost models + if (path / 'xgb_continuation.joblib').exists(): + self.xgb_continuation = joblib.load(path / 'xgb_continuation.joblib') + if (path / 'xgb_regime.joblib').exists(): + self.xgb_regime = joblib.load(path / 'xgb_regime.joblib') + + self._is_fitted = True + logger.info(f"Loaded MRD Model from {path}") + + +if __name__ == "__main__": + # Test complete MRD model + np.random.seed(42) + n_samples = 3000 + + # Create sample data with regime patterns + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + + price = np.zeros(n_samples) + price[0] = 2000 + + # Create regime changes + for i in range(1, n_samples): + phase = (i // 500) % 3 + if phase == 0: # Uptrend + drift = 0.0003 + elif phase == 1: # Range + drift = 0 + else: # Downtrend + drift = -0.0003 + + price[i] = price[i-1] * (1 + drift + np.random.randn() * 0.0005) + + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(n_samples) * 0.0002), + 'high': price * (1 + np.abs(np.random.randn(n_samples)) * 0.0005), + 'low': price * (1 - np.abs(np.random.randn(n_samples)) * 0.0005), + 'close': price, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Split data + train_size = 2400 + df_train = df.iloc[:train_size] + df_val = df.iloc[train_size:] + + # Train model + print(f"\n{'='*60}") + print("Testing MRD Model") + print(f"{'='*60}") + + model = MRDModel() + metrics = model.fit(df_train, df_val, epochs=30, verbose=True) + + print(f"\nTraining Metrics:") + print(f" LSTM Loss: {metrics['lstm'].get('final_loss', 'N/A')}") + print(f" XGB Regime Accuracy: {metrics['xgb'].get('regime_accuracy', 'N/A')}") + print(f" Validation Accuracy: {metrics['validation'].get('regime_accuracy', 'N/A')}") + + # Make predictions + predictions = model.predict_batch(df_val.tail(100)) + print(f"\nSample Predictions (last 5):") + for i, pred in enumerate(predictions[-5:]): + print(f" {i}: {pred.regime_name} (conf={pred.regime_confidence:.2f}, " + f"dur={pred.duration}, cont={pred.continuation_prob:.2f})") + + print("\nTest complete!") diff --git a/src/models/strategies/mrd/trainer.py b/src/models/strategies/mrd/trainer.py new file mode 100644 index 0000000..3d3ceeb --- /dev/null +++ b/src/models/strategies/mrd/trainer.py @@ -0,0 +1,876 @@ +""" +MRD Trainer - Training Pipeline for Momentum Regime Detection +============================================================== + +Implements the training pipeline for the MRD model including: +- HMM training for unsupervised regime detection +- LSTM training for sequence modeling +- XGBoost training for final ensemble +- Walk-forward validation for robust evaluation + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Union, Callable +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from datetime import datetime +from loguru import logger + +try: + import torch + import torch.nn as nn + from torch.utils.data import DataLoader, TensorDataset + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + +try: + from xgboost import XGBClassifier, XGBRegressor + XGBOOST_AVAILABLE = True +except ImportError: + XGBOOST_AVAILABLE = False + +from sklearn.metrics import ( + accuracy_score, f1_score, precision_recall_fscore_support, + mean_squared_error, mean_absolute_error, classification_report +) + +from .feature_engineering import MRDFeatureEngineer, MRDFeatureConfig +from .hmm_regime import RegimeHMM, RegimeHMMConfig, MarketRegime +from .model import MRDModel, MRDModelConfig, MRDLSTMEncoder + + +@dataclass +class TrainerConfig: + """Configuration for MRD trainer.""" + + # Model configuration + model_config: Optional[MRDModelConfig] = None + + # Training parameters + lstm_epochs: int = 50 + lstm_batch_size: int = 64 + lstm_learning_rate: float = 0.001 + lstm_weight_decay: float = 1e-5 + lstm_early_stopping_patience: int = 10 + + # Walk-forward parameters + n_folds: int = 5 + test_ratio: float = 0.15 + gap_samples: int = 12 # Gap between train and test to avoid look-ahead + min_train_samples: int = 5000 + expanding_window: bool = True # True = expanding, False = sliding + + # XGBoost parameters + xgb_early_stopping_rounds: int = 20 + + # Output + save_models: bool = True + model_dir: str = "models/mrd" + + # Logging + verbose: bool = True + log_every: int = 10 + + +@dataclass +class FoldResult: + """Results from a single walk-forward fold.""" + fold_id: int + train_start: int + train_end: int + test_start: int + test_end: int + train_samples: int + test_samples: int + hmm_metrics: Dict[str, Any] + lstm_metrics: Dict[str, float] + xgb_metrics: Dict[str, float] + overall_metrics: Dict[str, float] + predictions: Optional[np.ndarray] = None + true_regimes: Optional[np.ndarray] = None + + def to_dict(self) -> Dict: + return { + 'fold_id': self.fold_id, + 'train_range': (self.train_start, self.train_end), + 'test_range': (self.test_start, self.test_end), + 'train_samples': self.train_samples, + 'test_samples': self.test_samples, + 'metrics': self.overall_metrics + } + + +@dataclass +class TrainingResults: + """Complete training results.""" + symbol: str + timeframe: str + n_folds: int + fold_results: List[FoldResult] + avg_metrics: Dict[str, float] + std_metrics: Dict[str, float] + best_fold: int + timestamp: str + config: Dict + + def to_dict(self) -> Dict: + return { + 'symbol': self.symbol, + 'timeframe': self.timeframe, + 'n_folds': self.n_folds, + 'folds': [f.to_dict() for f in self.fold_results], + 'avg_metrics': self.avg_metrics, + 'std_metrics': self.std_metrics, + 'best_fold': self.best_fold, + 'timestamp': self.timestamp + } + + +class MRDTrainer: + """ + Trainer for Momentum Regime Detection model. + + Handles the complete training pipeline: + 1. Feature engineering + 2. HMM training for regime detection + 3. LSTM training for sequence modeling + 4. XGBoost training for ensemble + 5. Walk-forward validation + + Usage: + trainer = MRDTrainer() + + # Train on single split + metrics = trainer.train(df_train, df_val) + + # Walk-forward training + results = trainer.walk_forward_train(df, symbol='EURUSD', n_folds=5) + """ + + def __init__(self, config: Optional[TrainerConfig] = None): + """ + Initialize the trainer. + + Args: + config: Training configuration + """ + self.config = config or TrainerConfig() + self.model_config = self.config.model_config or MRDModelConfig() + + self.feature_engineer = MRDFeatureEngineer() + self._current_model: Optional[MRDModel] = None + + # Training history + self.history: Dict[str, List] = { + 'train_loss': [], + 'val_loss': [], + 'regime_accuracy': [], + 'continuation_accuracy': [] + } + + def train_hmm( + self, + data: pd.DataFrame, + features: Optional[pd.DataFrame] = None + ) -> Tuple[RegimeHMM, Dict[str, Any]]: + """ + Train the HMM component. + + Args: + data: DataFrame with OHLCV data + features: Pre-computed features (optional) + + Returns: + Tuple of (trained HMM, training metrics) + """ + logger.info("Training HMM for regime detection...") + + # Compute features if not provided + if features is None: + features = self.feature_engineer.compute_all_features(data) + + # Initialize and train HMM + hmm = RegimeHMM(self.model_config.hmm_config or RegimeHMMConfig()) + hmm.fit(features) + + # Get regime statistics + stats = hmm.get_regime_statistics(features) + + metrics = { + 'regime_distribution': stats['regime_percentages'], + 'avg_durations': stats['avg_duration'], + 'transition_matrix': stats['transition_matrix'] + } + + logger.info(f"HMM trained - Regime distribution: {stats['regime_percentages']}") + + return hmm, metrics + + def train_lstm( + self, + data: pd.DataFrame, + regimes: np.ndarray, + features: Optional[pd.DataFrame] = None, + val_data: Optional[pd.DataFrame] = None, + val_regimes: Optional[np.ndarray] = None, + val_features: Optional[pd.DataFrame] = None + ) -> Tuple[MRDLSTMEncoder, Dict[str, float]]: + """ + Train the LSTM component. + + Args: + data: Training DataFrame + regimes: Regime labels for training data + features: Pre-computed features (optional) + val_data: Validation DataFrame (optional) + val_regimes: Validation regime labels + val_features: Validation features + + Returns: + Tuple of (trained LSTM, training metrics) + """ + if not TORCH_AVAILABLE: + logger.warning("PyTorch not available, skipping LSTM training") + return None, {} + + logger.info("Training LSTM encoder...") + + # Compute features if needed + if features is None: + features = self.feature_engineer.compute_all_features(data) + + n_features = features.shape[1] + seq_len = self.model_config.lstm_sequence_length + device = torch.device(self.model_config.device) + + # Initialize LSTM + lstm = MRDLSTMEncoder( + input_size=n_features, + hidden_size=self.model_config.lstm_hidden_size, + num_layers=self.model_config.lstm_num_layers, + dropout=self.model_config.lstm_dropout, + bidirectional=self.model_config.lstm_bidirectional, + n_regimes=3 + ).to(device) + + # Prepare training data + durations = self._compute_durations(regimes) + X_train, y_regime, y_duration = self._prepare_sequences( + features.values, regimes, durations, seq_len + ) + + # Continuation labels + y_continuation = self._compute_continuation_labels(regimes, seq_len) + + # Convert to tensors + X_tensor = torch.FloatTensor(X_train).to(device) + regime_tensor = torch.LongTensor(y_regime).to(device) + duration_tensor = torch.FloatTensor(y_duration).to(device) + continuation_tensor = torch.FloatTensor(y_continuation).to(device) + + # Create data loader + train_dataset = TensorDataset( + X_tensor, regime_tensor, duration_tensor, continuation_tensor + ) + train_loader = DataLoader( + train_dataset, + batch_size=self.config.lstm_batch_size, + shuffle=True + ) + + # Validation data + val_loader = None + if val_features is not None and val_regimes is not None: + val_durations = self._compute_durations(val_regimes) + X_val, y_val_regime, y_val_duration = self._prepare_sequences( + val_features.values, val_regimes, val_durations, seq_len + ) + y_val_cont = self._compute_continuation_labels(val_regimes, seq_len) + + val_dataset = TensorDataset( + torch.FloatTensor(X_val).to(device), + torch.LongTensor(y_val_regime).to(device), + torch.FloatTensor(y_val_duration).to(device), + torch.FloatTensor(y_val_cont).to(device) + ) + val_loader = DataLoader(val_dataset, batch_size=self.config.lstm_batch_size) + + # Training setup + optimizer = torch.optim.AdamW( + lstm.parameters(), + lr=self.config.lstm_learning_rate, + weight_decay=self.config.lstm_weight_decay + ) + + scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, mode='min', factor=0.5, patience=5 + ) + + regime_criterion = nn.CrossEntropyLoss() + continuation_criterion = nn.BCELoss() + + # Training loop + best_val_loss = float('inf') + patience_counter = 0 + best_state = None + + for epoch in range(self.config.lstm_epochs): + # Training phase + lstm.train() + train_loss = 0 + train_regime_correct = 0 + train_total = 0 + + for batch in train_loader: + batch_x, batch_regime, batch_duration, batch_cont = batch + + optimizer.zero_grad() + + regime_logits, cont_prob, _, _ = lstm( + batch_x, batch_regime, batch_duration + ) + + regime_loss = regime_criterion(regime_logits, batch_regime) + cont_loss = continuation_criterion(cont_prob.squeeze(), batch_cont) + loss = regime_loss + cont_loss + + loss.backward() + torch.nn.utils.clip_grad_norm_(lstm.parameters(), 1.0) + optimizer.step() + + train_loss += loss.item() + train_regime_correct += (regime_logits.argmax(1) == batch_regime).sum().item() + train_total += batch_regime.size(0) + + avg_train_loss = train_loss / len(train_loader) + train_accuracy = train_regime_correct / train_total + + # Validation phase + val_loss = 0 + val_accuracy = 0 + + if val_loader is not None: + lstm.eval() + val_regime_correct = 0 + val_total = 0 + + with torch.no_grad(): + for batch in val_loader: + batch_x, batch_regime, batch_duration, batch_cont = batch + + regime_logits, cont_prob, _, _ = lstm( + batch_x, batch_regime, batch_duration + ) + + regime_loss = regime_criterion(regime_logits, batch_regime) + cont_loss = continuation_criterion(cont_prob.squeeze(), batch_cont) + val_loss += (regime_loss + cont_loss).item() + + val_regime_correct += (regime_logits.argmax(1) == batch_regime).sum().item() + val_total += batch_regime.size(0) + + val_loss = val_loss / len(val_loader) + val_accuracy = val_regime_correct / val_total + + scheduler.step(val_loss) + + # Early stopping + if val_loss < best_val_loss: + best_val_loss = val_loss + best_state = lstm.state_dict().copy() + patience_counter = 0 + else: + patience_counter += 1 + + if patience_counter >= self.config.lstm_early_stopping_patience: + logger.info(f"Early stopping at epoch {epoch + 1}") + break + + # Logging + if self.config.verbose and (epoch + 1) % self.config.log_every == 0: + logger.info( + f"Epoch {epoch+1}/{self.config.lstm_epochs} - " + f"Train Loss: {avg_train_loss:.4f}, Train Acc: {train_accuracy:.4f}, " + f"Val Loss: {val_loss:.4f}, Val Acc: {val_accuracy:.4f}" + ) + + # Load best state + if best_state is not None: + lstm.load_state_dict(best_state) + + lstm.eval() + + metrics = { + 'final_train_loss': avg_train_loss, + 'final_train_accuracy': train_accuracy, + 'best_val_loss': best_val_loss, + 'final_val_accuracy': val_accuracy, + 'epochs_trained': epoch + 1 + } + + logger.info(f"LSTM trained - Best val loss: {best_val_loss:.4f}") + + return lstm, metrics + + def train_xgboost( + self, + lstm_outputs: np.ndarray, + targets: np.ndarray, + regimes: np.ndarray, + durations: np.ndarray, + val_lstm_outputs: Optional[np.ndarray] = None, + val_targets: Optional[np.ndarray] = None, + val_regimes: Optional[np.ndarray] = None, + val_durations: Optional[np.ndarray] = None + ) -> Tuple[Any, Any, Dict[str, float]]: + """ + Train XGBoost models for final prediction. + + Args: + lstm_outputs: LSTM hidden features + targets: Target continuation labels + regimes: Regime labels + durations: Duration values + val_*: Validation data (optional) + + Returns: + Tuple of (continuation model, regime model, metrics) + """ + if not XGBOOST_AVAILABLE: + logger.warning("XGBoost not available") + return None, None, {} + + logger.info("Training XGBoost ensemble...") + + # Combine features + X_train = np.hstack([ + lstm_outputs, + regimes.reshape(-1, 1), + durations.reshape(-1, 1) + ]) + + # Initialize models + xgb_params = { + 'n_estimators': self.model_config.xgb_n_estimators, + 'max_depth': self.model_config.xgb_max_depth, + 'learning_rate': self.model_config.xgb_learning_rate, + 'subsample': self.model_config.xgb_subsample, + 'colsample_bytree': self.model_config.xgb_colsample_bytree, + 'tree_method': 'hist', + 'random_state': 42 + } + + xgb_continuation = XGBRegressor(**xgb_params) + xgb_regime = XGBClassifier(**xgb_params, num_class=3) + + # Prepare validation data + eval_set = None + if val_lstm_outputs is not None: + X_val = np.hstack([ + val_lstm_outputs, + val_regimes.reshape(-1, 1), + val_durations.reshape(-1, 1) + ]) + eval_set = [(X_val, val_targets)] + + # Train continuation model + xgb_continuation.fit( + X_train, targets, + eval_set=eval_set, + verbose=False + ) + + # Train regime model + xgb_regime.fit( + X_train, regimes, + eval_set=[(X_val, val_regimes)] if eval_set else None, + verbose=False + ) + + # Calculate metrics + cont_pred = xgb_continuation.predict(X_train) + regime_pred = xgb_regime.predict(X_train) + + train_metrics = { + 'continuation_mse': float(mean_squared_error(targets, cont_pred)), + 'regime_accuracy': float(accuracy_score(regimes, regime_pred)) + } + + if eval_set: + val_cont_pred = xgb_continuation.predict(X_val) + val_regime_pred = xgb_regime.predict(X_val) + train_metrics['val_continuation_mse'] = float(mean_squared_error(val_targets, val_cont_pred)) + train_metrics['val_regime_accuracy'] = float(accuracy_score(val_regimes, val_regime_pred)) + + logger.info(f"XGBoost trained - Regime Acc: {train_metrics['regime_accuracy']:.4f}") + + return xgb_continuation, xgb_regime, train_metrics + + def walk_forward_train( + self, + data: pd.DataFrame, + symbol: str = 'UNKNOWN', + timeframe: str = '5min', + n_folds: Optional[int] = None + ) -> TrainingResults: + """ + Perform walk-forward training and validation. + + Args: + data: Complete DataFrame with OHLCV data + symbol: Symbol being trained + timeframe: Timeframe of the data + n_folds: Number of folds (overrides config) + + Returns: + TrainingResults with metrics for all folds + """ + n_folds = n_folds or self.config.n_folds + n_samples = len(data) + + logger.info(f"Starting walk-forward training: {n_folds} folds on {n_samples} samples") + + # Calculate fold boundaries + folds = self._create_walk_forward_splits(n_samples, n_folds) + fold_results = [] + + for fold_idx, (train_start, train_end, test_start, test_end) in enumerate(folds): + logger.info(f"\n{'='*60}") + logger.info(f"Fold {fold_idx + 1}/{n_folds}") + logger.info(f"Train: [{train_start}:{train_end}] ({train_end - train_start} samples)") + logger.info(f"Test: [{test_start}:{test_end}] ({test_end - test_start} samples)") + logger.info(f"{'='*60}") + + # Split data + df_train = data.iloc[train_start:train_end].copy() + df_test = data.iloc[test_start:test_end].copy() + + # Train model + fold_result = self._train_single_fold( + df_train, df_test, fold_idx + 1, + train_start, train_end, test_start, test_end + ) + fold_results.append(fold_result) + + logger.info(f"Fold {fold_idx + 1} - Regime Accuracy: {fold_result.overall_metrics.get('regime_accuracy', 0):.4f}") + + # Aggregate results + results = self._aggregate_results( + fold_results, symbol, timeframe, n_folds + ) + + # Save results if configured + if self.config.save_models: + self._save_results(results) + + logger.info(f"\nWalk-Forward Training Complete") + logger.info(f"Average Regime Accuracy: {results.avg_metrics['regime_accuracy']:.4f} " + f"(+/- {results.std_metrics['regime_accuracy']:.4f})") + + return results + + def _create_walk_forward_splits( + self, + n_samples: int, + n_folds: int + ) -> List[Tuple[int, int, int, int]]: + """Create walk-forward validation splits.""" + folds = [] + test_size = int(n_samples * self.config.test_ratio / n_folds) + + for i in range(n_folds): + if self.config.expanding_window: + train_start = 0 + else: + train_start = i * test_size + + train_end = int(n_samples * (1 - self.config.test_ratio) + i * test_size / n_folds) + test_start = train_end + self.config.gap_samples + test_end = min(test_start + test_size, n_samples) + + if train_end - train_start >= self.config.min_train_samples: + folds.append((train_start, train_end, test_start, test_end)) + + return folds + + def _train_single_fold( + self, + df_train: pd.DataFrame, + df_test: pd.DataFrame, + fold_id: int, + train_start: int, + train_end: int, + test_start: int, + test_end: int + ) -> FoldResult: + """Train model for a single fold.""" + # Compute features + train_features = self.feature_engineer.compute_all_features(df_train) + test_features = self.feature_engineer.compute_all_features(df_test) + + # Train HMM + hmm, hmm_metrics = self.train_hmm(df_train, train_features) + + # Get regimes + train_regimes = hmm.predict_regime(train_features) + test_regimes = hmm.predict_regime(test_features) + + train_durations = self._compute_durations(train_regimes) + test_durations = self._compute_durations(test_regimes) + + # Train LSTM + lstm, lstm_metrics = self.train_lstm( + df_train, train_regimes, train_features, + df_test, test_regimes, test_features + ) + + # Get LSTM features + xgb_metrics = {} + if lstm is not None: + seq_len = self.model_config.lstm_sequence_length + + train_lstm_out = self._get_lstm_features(lstm, train_features, seq_len) + test_lstm_out = self._get_lstm_features(lstm, test_features, seq_len) + + # Continuation labels + train_cont = self._compute_continuation_labels(train_regimes, seq_len) + test_cont = self._compute_continuation_labels(test_regimes, seq_len) + + # Train XGBoost + xgb_cont, xgb_regime, xgb_metrics = self.train_xgboost( + train_lstm_out, train_cont, train_regimes[seq_len:], train_durations[seq_len:], + test_lstm_out, test_cont, test_regimes[seq_len:], test_durations[seq_len:] + ) + + # Calculate overall metrics + overall_metrics = self._calculate_fold_metrics( + test_regimes, test_features, hmm, lstm + ) + + return FoldResult( + fold_id=fold_id, + train_start=train_start, + train_end=train_end, + test_start=test_start, + test_end=test_end, + train_samples=train_end - train_start, + test_samples=test_end - test_start, + hmm_metrics=hmm_metrics, + lstm_metrics=lstm_metrics, + xgb_metrics=xgb_metrics, + overall_metrics=overall_metrics, + predictions=None, + true_regimes=test_regimes + ) + + def _get_lstm_features( + self, + lstm: MRDLSTMEncoder, + features: pd.DataFrame, + seq_len: int + ) -> np.ndarray: + """Extract LSTM features.""" + device = torch.device(self.model_config.device) + regimes = np.zeros(len(features)) + durations = np.zeros(len(features)) + + X, _, _ = self._prepare_sequences(features.values, regimes, durations, seq_len) + X_tensor = torch.FloatTensor(X).to(device) + + lstm.eval() + with torch.no_grad(): + _, _, lstm_features, _ = lstm(X_tensor) + + return lstm_features.cpu().numpy() + + def _calculate_fold_metrics( + self, + true_regimes: np.ndarray, + features: pd.DataFrame, + hmm: RegimeHMM, + lstm: Optional[MRDLSTMEncoder] + ) -> Dict[str, float]: + """Calculate metrics for a fold.""" + # HMM regime prediction + pred_regimes = hmm.predict_regime(features) + + # Basic metrics + regime_accuracy = accuracy_score(true_regimes, pred_regimes) + regime_f1 = f1_score(true_regimes, pred_regimes, average='weighted') + + metrics = { + 'regime_accuracy': float(regime_accuracy), + 'regime_f1': float(regime_f1) + } + + # Per-class metrics + precision, recall, f1, _ = precision_recall_fscore_support( + true_regimes, pred_regimes, average=None + ) + for i, name in enumerate(['trend_down', 'range', 'trend_up']): + if i < len(f1): + metrics[f'{name}_f1'] = float(f1[i]) + + return metrics + + def _aggregate_results( + self, + fold_results: List[FoldResult], + symbol: str, + timeframe: str, + n_folds: int + ) -> TrainingResults: + """Aggregate results from all folds.""" + # Collect metrics + metrics_keys = fold_results[0].overall_metrics.keys() + all_metrics = {key: [] for key in metrics_keys} + + for fold in fold_results: + for key in metrics_keys: + all_metrics[key].append(fold.overall_metrics.get(key, 0)) + + # Calculate average and std + avg_metrics = {key: np.mean(values) for key, values in all_metrics.items()} + std_metrics = {key: np.std(values) for key, values in all_metrics.items()} + + # Find best fold + best_fold = max( + range(len(fold_results)), + key=lambda i: fold_results[i].overall_metrics.get('regime_accuracy', 0) + ) + + return TrainingResults( + symbol=symbol, + timeframe=timeframe, + n_folds=n_folds, + fold_results=fold_results, + avg_metrics=avg_metrics, + std_metrics=std_metrics, + best_fold=best_fold + 1, + timestamp=datetime.now().isoformat(), + config=self.config.__dict__ + ) + + def _save_results(self, results: TrainingResults): + """Save training results.""" + save_dir = Path(self.config.model_dir) / results.symbol + save_dir.mkdir(parents=True, exist_ok=True) + + # Save results + results_path = save_dir / f"results_{results.timestamp.replace(':', '-')}.joblib" + joblib.dump(results.to_dict(), results_path) + logger.info(f"Saved results to {results_path}") + + def _compute_durations(self, regimes: np.ndarray) -> np.ndarray: + """Compute regime durations.""" + durations = np.ones_like(regimes, dtype=np.float32) + current_duration = 1 + + for i in range(1, len(regimes)): + if regimes[i] == regimes[i - 1]: + current_duration += 1 + else: + current_duration = 1 + durations[i] = current_duration + + return durations + + def _compute_continuation_labels( + self, + regimes: np.ndarray, + seq_len: int + ) -> np.ndarray: + """Compute continuation labels.""" + n = len(regimes) - seq_len + labels = np.zeros(n, dtype=np.float32) + + for i in range(n): + idx = seq_len + i + if idx < len(regimes) - 1: + labels[i] = float(regimes[idx] == regimes[idx + 1]) + + return labels + + def _prepare_sequences( + self, + features: np.ndarray, + regimes: np.ndarray, + durations: np.ndarray, + seq_len: int + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Prepare sequences for LSTM.""" + n_samples = len(features) - seq_len + + X = np.zeros((n_samples, seq_len, features.shape[1])) + y_regime = np.zeros(n_samples, dtype=np.int64) + y_duration = np.zeros(n_samples, dtype=np.float32) + + for i in range(n_samples): + X[i] = features[i:i + seq_len] + y_regime[i] = regimes[i + seq_len - 1] + y_duration[i] = durations[i + seq_len - 1] + + return X, y_regime, y_duration + + +if __name__ == "__main__": + # Test the trainer + np.random.seed(42) + n_samples = 5000 + + # Create sample data + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + price = np.zeros(n_samples) + price[0] = 2000 + + for i in range(1, n_samples): + phase = (i // 500) % 3 + if phase == 0: + drift = 0.0003 + elif phase == 1: + drift = 0 + else: + drift = -0.0003 + price[i] = price[i-1] * (1 + drift + np.random.randn() * 0.0005) + + df = pd.DataFrame({ + 'open': price * (1 + np.random.randn(n_samples) * 0.0002), + 'high': price * (1 + np.abs(np.random.randn(n_samples)) * 0.0005), + 'low': price * (1 - np.abs(np.random.randn(n_samples)) * 0.0005), + 'close': price, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Create trainer + config = TrainerConfig( + n_folds=3, + lstm_epochs=20, + save_models=False, + verbose=True + ) + + trainer = MRDTrainer(config) + + # Walk-forward training + print(f"\n{'='*60}") + print("Testing MRD Trainer - Walk Forward") + print(f"{'='*60}") + + results = trainer.walk_forward_train(df, symbol='TEST', n_folds=3) + + print(f"\nFinal Results:") + print(f" Average Regime Accuracy: {results.avg_metrics['regime_accuracy']:.4f}") + print(f" Std Regime Accuracy: {results.std_metrics['regime_accuracy']:.4f}") + print(f" Best Fold: {results.best_fold}") + + print("\nTest complete!") diff --git a/src/models/strategies/msa/__init__.py b/src/models/strategies/msa/__init__.py new file mode 100644 index 0000000..1439405 --- /dev/null +++ b/src/models/strategies/msa/__init__.py @@ -0,0 +1,128 @@ +""" +MSA (Market Structure Analysis) Strategy Module +================================================ + +Market Structure Analysis strategy for the ml-engine. +Implements ICT/SMC (Inner Circle Trader / Smart Money Concepts) methodology. + +Components: +- StructureDetector: Detection of market structure elements + - Swing points (HH, HL, LH, LL) + - Break of Structure (BOS) + - Change of Character (CHoCH) + - Fair Value Gaps (FVG) + - Order Blocks (OB) + - Premium/Discount Zones + +- MSAFeatureEngineer: Feature extraction from structure analysis + - Structure features + - POI distance features + - Trend features + - Liquidity features + +- MSAModel: XGBoost-based classifier for structure predictions + - BOS direction prediction + - POI reaction probability + - Structure continuation probability + - Optional GNN for swing relationships + +- MSATrainer: Training utilities + - Data preparation + - Walk-forward validation + - Evaluation metrics + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 + +Usage: + from src.models.strategies.msa import ( + StructureDetector, + MSAFeatureEngineer, + MSAModel, + MSATrainer, + TrainingConfig + ) + + # Detection + detector = StructureDetector(swing_order=5) + analysis = detector.analyze(df) + + # Feature engineering + engineer = MSAFeatureEngineer() + features = engineer.compute_structure_features(df) + + # Training + config = TrainingConfig(n_folds=5) + trainer = MSATrainer(config) + metrics = trainer.train(df, symbol="BTCUSD") + + # Prediction + predictions = trainer.model.predict(features) +""" + +# Structure Detection +from .structure_detector import ( + StructureDetector, + SwingPoint, + SwingType, + StructureEvent, + StructureType, + FairValueGap, + OrderBlock, + LiquidityLevel, + PremiumDiscountZone, + POIType, +) + +# Feature Engineering +from .feature_engineering import ( + MSAFeatureEngineer, + PointOfInterest, +) + +# Model +from .model import ( + MSAModel, + MSAPrediction, + MSAMetrics, + BOSDirection, +) + +# Trainer +from .trainer import ( + MSATrainer, + TrainingConfig, + WalkForwardResult, +) + +__all__ = [ + # Structure Detection + 'StructureDetector', + 'SwingPoint', + 'SwingType', + 'StructureEvent', + 'StructureType', + 'FairValueGap', + 'OrderBlock', + 'LiquidityLevel', + 'PremiumDiscountZone', + 'POIType', + + # Feature Engineering + 'MSAFeatureEngineer', + 'PointOfInterest', + + # Model + 'MSAModel', + 'MSAPrediction', + 'MSAMetrics', + 'BOSDirection', + + # Trainer + 'MSATrainer', + 'TrainingConfig', + 'WalkForwardResult', +] + +__version__ = '1.0.0' diff --git a/src/models/strategies/msa/feature_engineering.py b/src/models/strategies/msa/feature_engineering.py new file mode 100644 index 0000000..7165b7e --- /dev/null +++ b/src/models/strategies/msa/feature_engineering.py @@ -0,0 +1,859 @@ +""" +MSA (Market Structure Analysis) - Feature Engineering +====================================================== +Feature engineering for MSA-based ML models. + +Converts structure analysis into numerical features: +- Structure features (swing points, BOS, CHoCH) +- Distance to Points of Interest (POI) +- POI type classification +- Liquidity level features +- Structure reaction labels + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import pandas as pd +import numpy as np +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass +from loguru import logger + +from .structure_detector import ( + StructureDetector, + SwingPoint, + StructureEvent, + FairValueGap, + OrderBlock, + LiquidityLevel, + PremiumDiscountZone, + StructureType, + SwingType, + POIType +) + + +@dataclass +class PointOfInterest: + """Generic Point of Interest for distance calculations""" + poi_type: POIType + price_low: float + price_high: float + index: int + strength: float = 0.5 + active: bool = True + + @property + def midpoint(self) -> float: + return (self.price_low + self.price_high) / 2 + + +class MSAFeatureEngineer: + """ + Feature Engineering for Market Structure Analysis. + + Generates numerical features from structure analysis results + suitable for ML model training. + + Features generated: + - Swing point features (distance, type, count) + - BOS/CHoCH features (recent events, direction) + - FVG features (count, distance, filled ratio) + - Order Block features (count, distance, validity) + - Premium/Discount zone features + - Liquidity level features + - POI distance features + - Trend strength features + """ + + def __init__( + self, + swing_order: int = 5, + lookback_periods: int = 50, + max_pois: int = 10 + ): + """ + Initialize MSA Feature Engineer. + + Args: + swing_order: Order for swing point detection + lookback_periods: Periods to look back for feature calculation + max_pois: Maximum number of POIs to track + """ + self.swing_order = swing_order + self.lookback_periods = lookback_periods + self.max_pois = max_pois + self.detector = StructureDetector(swing_order=swing_order) + + logger.info( + f"MSAFeatureEngineer initialized: order={swing_order}, " + f"lookback={lookback_periods}" + ) + + def compute_structure_features( + self, + df: pd.DataFrame + ) -> pd.DataFrame: + """ + Compute all structure-based features. + + Args: + df: OHLCV DataFrame + + Returns: + DataFrame with structure features + """ + features = pd.DataFrame(index=df.index) + + # Get structure analysis + swing_highs, swing_lows = self.detector.detect_swing_points(df) + classified_highs, classified_lows = self.detector.detect_higher_highs_lower_lows( + swing_highs, swing_lows + ) + bos_events = self.detector.detect_break_of_structure(df, classified_highs, classified_lows) + choch_events = self.detector.detect_change_of_character(df, classified_highs, classified_lows) + fvgs = self.detector.detect_fair_value_gaps(df) + order_blocks = self.detector.detect_order_blocks(df, bos_events) + zones = self.detector.compute_premium_discount_zone(df, classified_highs, classified_lows) + liquidity_levels = self.detector.detect_liquidity_levels(classified_highs, classified_lows, df) + + # Compute feature categories + features = self._add_swing_features(df, features, classified_highs, classified_lows) + features = self._add_bos_choch_features(df, features, bos_events, choch_events) + features = self._add_fvg_features(df, features, fvgs) + features = self._add_order_block_features(df, features, order_blocks) + features = self._add_zone_features(df, features, zones) + features = self._add_liquidity_features(df, features, liquidity_levels) + features = self._add_trend_features(df, features, classified_highs, classified_lows) + + # Collect POIs for distance features + pois = self._collect_pois(fvgs, order_blocks, classified_highs, classified_lows, liquidity_levels) + features = self._add_poi_distance_features(df, features, pois) + + # Fill NaN with 0 + features = features.fillna(0) + + logger.debug(f"Generated {len(features.columns)} structure features") + return features + + def _add_swing_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint] + ) -> pd.DataFrame: + """Add swing point features""" + close = df['close'].values + + # Initialize arrays + dist_to_swing_high = np.zeros(len(df)) + dist_to_swing_low = np.zeros(len(df)) + hh_count = np.zeros(len(df)) + hl_count = np.zeros(len(df)) + lh_count = np.zeros(len(df)) + ll_count = np.zeros(len(df)) + last_swing_type = np.zeros(len(df)) # 1=high, -1=low + + # Create index mappings + high_indices = {sh.index: sh for sh in swing_highs} + low_indices = {sl.index: sl for sl in swing_lows} + + # Calculate features for each bar + last_high = None + last_low = None + + for i in range(len(df)): + # Update last swing if at this index + if i in high_indices: + last_high = high_indices[i] + last_swing_type[i] = 1 + if i in low_indices: + last_low = low_indices[i] + last_swing_type[i] = -1 + + # Distance to last swing high + if last_high is not None: + dist_to_swing_high[i] = (close[i] - last_high.price) / close[i] + + # Distance to last swing low + if last_low is not None: + dist_to_swing_low[i] = (close[i] - last_low.price) / close[i] + + # Count swing types in lookback window + lookback_start = max(0, i - self.lookback_periods) + for sh in swing_highs: + if lookback_start <= sh.index <= i: + if sh.swing_type == SwingType.HIGHER_HIGH: + hh_count[i] += 1 + else: + lh_count[i] += 1 + + for sl in swing_lows: + if lookback_start <= sl.index <= i: + if sl.swing_type == SwingType.HIGHER_LOW: + hl_count[i] += 1 + else: + ll_count[i] += 1 + + features['dist_to_swing_high'] = dist_to_swing_high + features['dist_to_swing_low'] = dist_to_swing_low + features['hh_count'] = hh_count + features['hl_count'] = hl_count + features['lh_count'] = lh_count + features['ll_count'] = ll_count + features['last_swing_type'] = last_swing_type + + # Swing structure score (positive = bullish, negative = bearish) + features['swing_structure_score'] = (hh_count + hl_count - lh_count - ll_count) / 10 + + return features + + def _add_bos_choch_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + bos_events: List[StructureEvent], + choch_events: List[StructureEvent] + ) -> pd.DataFrame: + """Add BOS and CHoCH features""" + # Initialize arrays + bos_bullish_count = np.zeros(len(df)) + bos_bearish_count = np.zeros(len(df)) + choch_bullish_count = np.zeros(len(df)) + choch_bearish_count = np.zeros(len(df)) + bars_since_bos = np.full(len(df), 999) + bars_since_choch = np.full(len(df), 999) + last_bos_direction = np.zeros(len(df)) # 1=bullish, -1=bearish + last_choch_direction = np.zeros(len(df)) + + # Track events + last_bos_idx = -999 + last_choch_idx = -999 + last_bos_dir = 0 + last_choch_dir = 0 + + for i in range(len(df)): + # Update from BOS events + for bos in bos_events: + if bos.index == i: + last_bos_idx = i + if bos.event_type == StructureType.BOS_BULLISH: + last_bos_dir = 1 + else: + last_bos_dir = -1 + + # Count in lookback + if i - self.lookback_periods <= bos.index <= i: + if bos.event_type == StructureType.BOS_BULLISH: + bos_bullish_count[i] += 1 + else: + bos_bearish_count[i] += 1 + + # Update from CHoCH events + for choch in choch_events: + if choch.index == i: + last_choch_idx = i + if choch.event_type == StructureType.CHOCH_BULLISH: + last_choch_dir = 1 + else: + last_choch_dir = -1 + + if i - self.lookback_periods <= choch.index <= i: + if choch.event_type == StructureType.CHOCH_BULLISH: + choch_bullish_count[i] += 1 + else: + choch_bearish_count[i] += 1 + + bars_since_bos[i] = min(999, i - last_bos_idx) if last_bos_idx >= 0 else 999 + bars_since_choch[i] = min(999, i - last_choch_idx) if last_choch_idx >= 0 else 999 + last_bos_direction[i] = last_bos_dir + last_choch_direction[i] = last_choch_dir + + features['bos_bullish_count'] = bos_bullish_count + features['bos_bearish_count'] = bos_bearish_count + features['choch_bullish_count'] = choch_bullish_count + features['choch_bearish_count'] = choch_bearish_count + features['bars_since_bos'] = bars_since_bos / 100 # Normalize + features['bars_since_choch'] = bars_since_choch / 100 + features['last_bos_direction'] = last_bos_direction + features['last_choch_direction'] = last_choch_direction + + # Net structure bias + features['bos_net_bias'] = bos_bullish_count - bos_bearish_count + features['has_recent_choch'] = (bars_since_choch < 20).astype(float) + + return features + + def _add_fvg_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + fvgs: List[FairValueGap] + ) -> pd.DataFrame: + """Add Fair Value Gap features""" + close = df['close'].values + + # Initialize arrays + bullish_fvg_count = np.zeros(len(df)) + bearish_fvg_count = np.zeros(len(df)) + unfilled_fvg_count = np.zeros(len(df)) + dist_to_nearest_fvg = np.full(len(df), 999.0) + nearest_fvg_type = np.zeros(len(df)) # 1=bullish, -1=bearish + in_fvg = np.zeros(len(df)) + fvg_fill_rate = np.zeros(len(df)) + + for i in range(len(df)): + bullish_count = 0 + bearish_count = 0 + unfilled_count = 0 + filled_count = 0 + min_dist = 999.0 + min_dist_type = 0 + + for fvg in fvgs: + if fvg.index <= i: + if fvg.gap_type == 'bullish': + bullish_count += 1 + else: + bearish_count += 1 + + if not fvg.filled: + unfilled_count += 1 + # Distance to FVG + dist_to_fvg = min( + abs(close[i] - fvg.high), + abs(close[i] - fvg.low) + ) / close[i] + + if dist_to_fvg < min_dist: + min_dist = dist_to_fvg + min_dist_type = 1 if fvg.gap_type == 'bullish' else -1 + + # Check if price is inside FVG + if fvg.low <= close[i] <= fvg.high: + in_fvg[i] = 1 if fvg.gap_type == 'bullish' else -1 + else: + filled_count += 1 + + bullish_fvg_count[i] = bullish_count + bearish_fvg_count[i] = bearish_count + unfilled_fvg_count[i] = unfilled_count + dist_to_nearest_fvg[i] = min_dist + nearest_fvg_type[i] = min_dist_type + + if bullish_count + bearish_count > 0: + fvg_fill_rate[i] = filled_count / (bullish_count + bearish_count) + + features['bullish_fvg_count'] = bullish_fvg_count + features['bearish_fvg_count'] = bearish_fvg_count + features['unfilled_fvg_count'] = unfilled_fvg_count + features['dist_to_nearest_fvg'] = np.clip(dist_to_nearest_fvg, 0, 1) + features['nearest_fvg_type'] = nearest_fvg_type + features['in_fvg'] = in_fvg + features['fvg_fill_rate'] = fvg_fill_rate + features['fvg_net_bias'] = bullish_fvg_count - bearish_fvg_count + + return features + + def _add_order_block_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + order_blocks: List[OrderBlock] + ) -> pd.DataFrame: + """Add Order Block features""" + close = df['close'].values + + # Initialize arrays + bullish_ob_count = np.zeros(len(df)) + bearish_ob_count = np.zeros(len(df)) + valid_ob_count = np.zeros(len(df)) + dist_to_nearest_ob = np.full(len(df), 999.0) + nearest_ob_type = np.zeros(len(df)) + in_ob = np.zeros(len(df)) + ob_strength_avg = np.zeros(len(df)) + + for i in range(len(df)): + bullish_count = 0 + bearish_count = 0 + valid_count = 0 + min_dist = 999.0 + min_dist_type = 0 + strength_sum = 0.0 + ob_count = 0 + + for ob in order_blocks: + if ob.index <= i: + if ob.ob_type == 'bullish': + bullish_count += 1 + else: + bearish_count += 1 + + if ob.valid: + valid_count += 1 + ob_count += 1 + strength_sum += ob.strength + + # Distance to OB + dist_to_ob = min( + abs(close[i] - ob.high), + abs(close[i] - ob.low) + ) / close[i] + + if dist_to_ob < min_dist: + min_dist = dist_to_ob + min_dist_type = 1 if ob.ob_type == 'bullish' else -1 + + # Check if price is inside OB + if ob.low <= close[i] <= ob.high: + in_ob[i] = 1 if ob.ob_type == 'bullish' else -1 + + bullish_ob_count[i] = bullish_count + bearish_ob_count[i] = bearish_count + valid_ob_count[i] = valid_count + dist_to_nearest_ob[i] = min_dist + nearest_ob_type[i] = min_dist_type + + if ob_count > 0: + ob_strength_avg[i] = strength_sum / ob_count + + features['bullish_ob_count'] = bullish_ob_count + features['bearish_ob_count'] = bearish_ob_count + features['valid_ob_count'] = valid_ob_count + features['dist_to_nearest_ob'] = np.clip(dist_to_nearest_ob, 0, 1) + features['nearest_ob_type'] = nearest_ob_type + features['in_ob'] = in_ob + features['ob_strength_avg'] = ob_strength_avg + features['ob_net_bias'] = bullish_ob_count - bearish_ob_count + + return features + + def _add_zone_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + zones: PremiumDiscountZone + ) -> pd.DataFrame: + """Add Premium/Discount zone features""" + close = df['close'].values + + # Price position relative to zones + range_size = zones.premium_high - zones.discount_low + if range_size > 0: + position_in_range = (close - zones.discount_low) / range_size + else: + position_in_range = np.full(len(df), 0.5) + + features['position_in_range'] = np.clip(position_in_range, 0, 1) + features['dist_to_equilibrium'] = (close - zones.equilibrium) / close + features['in_premium'] = (close >= zones.premium_low).astype(float) + features['in_discount'] = (close <= zones.discount_high).astype(float) + features['dist_to_premium'] = np.clip((zones.premium_low - close) / close, -1, 1) + features['dist_to_discount'] = np.clip((close - zones.discount_high) / close, -1, 1) + + return features + + def _add_liquidity_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + liquidity_levels: List[LiquidityLevel] + ) -> pd.DataFrame: + """Add liquidity level features""" + close = df['close'].values + + # Initialize arrays + equal_highs_count = np.zeros(len(df)) + equal_lows_count = np.zeros(len(df)) + dist_to_nearest_liquidity = np.full(len(df), 999.0) + liquidity_above = np.zeros(len(df)) + liquidity_below = np.zeros(len(df)) + + for i in range(len(df)): + min_dist = 999.0 + liq_above = 0 + liq_below = 0 + + for ll in liquidity_levels: + if not ll.swept: + # Distance to liquidity + dist = abs(close[i] - ll.price) / close[i] + if dist < min_dist: + min_dist = dist + + if ll.price > close[i]: + liq_above += ll.strength + else: + liq_below += ll.strength + + if ll.level_type == 'equal_highs': + equal_highs_count[i] += 1 + else: + equal_lows_count[i] += 1 + + dist_to_nearest_liquidity[i] = min_dist + liquidity_above[i] = liq_above + liquidity_below[i] = liq_below + + features['equal_highs_count'] = equal_highs_count + features['equal_lows_count'] = equal_lows_count + features['dist_to_nearest_liquidity'] = np.clip(dist_to_nearest_liquidity, 0, 1) + features['liquidity_above'] = liquidity_above + features['liquidity_below'] = liquidity_below + features['liquidity_imbalance'] = (liquidity_above - liquidity_below) / (liquidity_above + liquidity_below + 1) + + return features + + def _add_trend_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint] + ) -> pd.DataFrame: + """Add trend strength features based on structure""" + # Trend strength based on swing sequence + hh_hl_score = np.zeros(len(df)) + lh_ll_score = np.zeros(len(df)) + + for i in range(len(df)): + # Count recent swing patterns + recent_highs = [sh for sh in swing_highs if i - self.lookback_periods <= sh.index <= i] + recent_lows = [sl for sl in swing_lows if i - self.lookback_periods <= sl.index <= i] + + # Bullish structure: HH + HL + hh = sum(1 for sh in recent_highs if sh.swing_type == SwingType.HIGHER_HIGH) + hl = sum(1 for sl in recent_lows if sl.swing_type == SwingType.HIGHER_LOW) + hh_hl_score[i] = (hh + hl) / max(1, len(recent_highs) + len(recent_lows)) + + # Bearish structure: LH + LL + lh = sum(1 for sh in recent_highs if sh.swing_type == SwingType.LOWER_HIGH) + ll = sum(1 for sl in recent_lows if sl.swing_type == SwingType.LOWER_LOW) + lh_ll_score[i] = (lh + ll) / max(1, len(recent_highs) + len(recent_lows)) + + features['bullish_structure_score'] = hh_hl_score + features['bearish_structure_score'] = lh_ll_score + features['structure_trend_bias'] = hh_hl_score - lh_ll_score + + return features + + def _collect_pois( + self, + fvgs: List[FairValueGap], + order_blocks: List[OrderBlock], + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint], + liquidity_levels: List[LiquidityLevel] + ) -> List[PointOfInterest]: + """Collect all Points of Interest for distance calculations""" + pois = [] + + # Add unfilled FVGs + for fvg in fvgs: + if not fvg.filled: + pois.append(PointOfInterest( + poi_type=POIType.FAIR_VALUE_GAP, + price_low=fvg.low, + price_high=fvg.high, + index=fvg.index, + strength=1.0 - fvg.fill_pct, + active=True + )) + + # Add valid Order Blocks + for ob in order_blocks: + if ob.valid: + pois.append(PointOfInterest( + poi_type=POIType.ORDER_BLOCK, + price_low=ob.low, + price_high=ob.high, + index=ob.index, + strength=ob.strength, + active=not ob.broken + )) + + # Add recent swing highs + for sh in swing_highs[-self.max_pois:]: + pois.append(PointOfInterest( + poi_type=POIType.SWING_HIGH, + price_low=sh.price * 0.999, + price_high=sh.price * 1.001, + index=sh.index, + strength=0.5, + active=True + )) + + # Add recent swing lows + for sl in swing_lows[-self.max_pois:]: + pois.append(PointOfInterest( + poi_type=POIType.SWING_LOW, + price_low=sl.price * 0.999, + price_high=sl.price * 1.001, + index=sl.index, + strength=0.5, + active=True + )) + + # Add liquidity levels + for ll in liquidity_levels: + if not ll.swept: + pois.append(PointOfInterest( + poi_type=POIType.LIQUIDITY_LEVEL, + price_low=ll.price * 0.999, + price_high=ll.price * 1.001, + index=min(ll.indices) if ll.indices else 0, + strength=float(ll.strength) / 5, + active=True + )) + + return pois + + def _add_poi_distance_features( + self, + df: pd.DataFrame, + features: pd.DataFrame, + pois: List[PointOfInterest] + ) -> pd.DataFrame: + """Add distance to POI features""" + close = df['close'].values + + dist_to_nearest_poi = np.full(len(df), 999.0) + nearest_poi_type = np.zeros(len(df)) # 0=none, 1=OB, 2=FVG, 3=swing, 4=liquidity + in_poi = np.zeros(len(df)) + poi_count_nearby = np.zeros(len(df)) + + poi_type_map = { + POIType.ORDER_BLOCK: 1, + POIType.FAIR_VALUE_GAP: 2, + POIType.SWING_HIGH: 3, + POIType.SWING_LOW: 3, + POIType.LIQUIDITY_LEVEL: 4, + POIType.NONE: 0 + } + + for i in range(len(df)): + min_dist = 999.0 + min_type = 0 + nearby_count = 0 + + for poi in pois: + if poi.active and poi.index <= i: + # Distance to POI + dist = min( + abs(close[i] - poi.price_high), + abs(close[i] - poi.price_low) + ) / close[i] + + if dist < min_dist: + min_dist = dist + min_type = poi_type_map.get(poi.poi_type, 0) + + # Count POIs within 1% range + if dist < 0.01: + nearby_count += 1 + + # Check if inside POI + if poi.price_low <= close[i] <= poi.price_high: + in_poi[i] = poi_type_map.get(poi.poi_type, 0) + + dist_to_nearest_poi[i] = min_dist + nearest_poi_type[i] = min_type + poi_count_nearby[i] = nearby_count + + features['dist_to_nearest_poi'] = np.clip(dist_to_nearest_poi, 0, 1) + features['nearest_poi_type'] = nearest_poi_type + features['in_poi'] = in_poi + features['poi_count_nearby'] = poi_count_nearby + + return features + + def compute_distance_to_poi( + self, + df: pd.DataFrame, + pois: List[PointOfInterest] + ) -> pd.Series: + """ + Compute distance to nearest Point of Interest. + + Args: + df: OHLCV DataFrame + pois: List of Points of Interest + + Returns: + Series with distance to nearest POI (as fraction of price) + """ + close = df['close'].values + distances = np.full(len(df), 999.0) + + for i in range(len(df)): + min_dist = 999.0 + for poi in pois: + if poi.active and poi.index <= i: + dist = min( + abs(close[i] - poi.price_high), + abs(close[i] - poi.price_low) + ) / close[i] + min_dist = min(min_dist, dist) + distances[i] = min_dist + + return pd.Series(distances, index=df.index, name='dist_to_poi') + + def compute_poi_type( + self, + df: pd.DataFrame, + pois: List[PointOfInterest] + ) -> pd.Series: + """ + Compute the type of nearest POI. + + Args: + df: OHLCV DataFrame + pois: List of Points of Interest + + Returns: + Series with POI type (categorical) + """ + close = df['close'].values + poi_types = [] + + for i in range(len(df)): + min_dist = 999.0 + nearest_type = POIType.NONE + + for poi in pois: + if poi.active and poi.index <= i: + dist = min( + abs(close[i] - poi.price_high), + abs(close[i] - poi.price_low) + ) / close[i] + if dist < min_dist: + min_dist = dist + nearest_type = poi.poi_type + + poi_types.append(nearest_type.value) + + return pd.Series(poi_types, index=df.index, name='poi_type') + + def label_structure_reactions( + self, + df: pd.DataFrame, + pois: List[PointOfInterest], + reaction_threshold_pct: float = 0.005, + forward_bars: int = 10 + ) -> pd.Series: + """ + Label whether price reacted (bounced) at POI. + + A reaction is defined as price touching a POI and then + moving away by at least reaction_threshold_pct. + + Args: + df: OHLCV DataFrame + pois: List of Points of Interest + reaction_threshold_pct: Minimum move to count as reaction + forward_bars: Bars to look forward for reaction + + Returns: + Series with labels: 1=bullish reaction, -1=bearish reaction, 0=no reaction + """ + close = df['close'].values + high = df['high'].values + low = df['low'].values + labels = np.zeros(len(df)) + + for i in range(len(df) - forward_bars): + for poi in pois: + if poi.active and poi.index <= i: + # Check if price touched POI + touched = (low[i] <= poi.price_high and high[i] >= poi.price_low) + + if touched: + # Check for reaction in next bars + future_high = high[i + 1:i + forward_bars + 1].max() + future_low = low[i + 1:i + forward_bars + 1].min() + + # Bullish reaction (touched from below and bounced up) + if close[i] <= poi.midpoint: + reaction = (future_high - close[i]) / close[i] + if reaction >= reaction_threshold_pct: + labels[i] = 1 + break + + # Bearish reaction (touched from above and dropped) + if close[i] >= poi.midpoint: + reaction = (close[i] - future_low) / close[i] + if reaction >= reaction_threshold_pct: + labels[i] = -1 + break + + return pd.Series(labels, index=df.index, name='poi_reaction') + + def compute_liquidity_levels( + self, + df: pd.DataFrame + ) -> pd.DataFrame: + """ + Compute liquidity level features. + + Args: + df: OHLCV DataFrame + + Returns: + DataFrame with liquidity level features + """ + swing_highs, swing_lows = self.detector.detect_swing_points(df) + classified_highs, classified_lows = self.detector.detect_higher_highs_lower_lows( + swing_highs, swing_lows + ) + liquidity_levels = self.detector.detect_liquidity_levels( + classified_highs, classified_lows, df + ) + + features = pd.DataFrame(index=df.index) + features = self._add_liquidity_features(df, features, liquidity_levels) + + return features + + +if __name__ == "__main__": + # Test MSAFeatureEngineer + import numpy as np + + np.random.seed(42) + + # Create sample OHLCV data + n_samples = 500 + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + + price = 2000.0 + prices = [price] + for i in range(1, n_samples): + trend = 0.5 * np.sin(i / 50) + np.random.randn() * 0.3 + price = price + trend + prices.append(price) + + prices = np.array(prices) + + df = pd.DataFrame({ + 'open': prices + np.random.randn(n_samples) * 0.5, + 'high': prices + abs(np.random.randn(n_samples)) * 1.5, + 'low': prices - abs(np.random.randn(n_samples)) * 1.5, + 'close': prices + np.random.randn(n_samples) * 0.5, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Create feature engineer and compute features + engineer = MSAFeatureEngineer(swing_order=5) + features = engineer.compute_structure_features(df) + + print("=== MSA Feature Engineering ===") + print(f"Generated {len(features.columns)} features") + print(f"\nFeature columns:\n{list(features.columns)}") + print(f"\nSample features (last 5 rows):\n{features.tail()}") + print(f"\nFeature statistics:\n{features.describe().T[['mean', 'std', 'min', 'max']]}") diff --git a/src/models/strategies/msa/model.py b/src/models/strategies/msa/model.py new file mode 100644 index 0000000..067b70c --- /dev/null +++ b/src/models/strategies/msa/model.py @@ -0,0 +1,732 @@ +""" +MSA (Market Structure Analysis) - Model +======================================== +XGBoost-based model for market structure predictions. + +Predictions: +- Next BOS direction (bullish/bearish/neutral) +- POI reaction probability +- Structure continuation probability + +Optional GNN component for swing point relationships. + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +from loguru import logger +import joblib +from enum import IntEnum + +try: + from xgboost import XGBClassifier + HAS_XGBOOST = True +except ImportError: + HAS_XGBOOST = False + logger.warning("XGBoost not available, MSAModel will have limited functionality") + +try: + import torch + import torch.nn as nn + import torch.nn.functional as F + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + logger.warning("PyTorch not available, GNN component disabled") + +from sklearn.metrics import ( + accuracy_score, f1_score, precision_recall_fscore_support, + confusion_matrix, classification_report +) + + +class BOSDirection(IntEnum): + """Break of Structure direction""" + NEUTRAL = 0 + BULLISH = 1 + BEARISH = 2 + + +@dataclass +class MSAPrediction: + """MSA model prediction result""" + # BOS prediction + next_bos_direction: str # 'bullish', 'bearish', 'neutral' + bos_confidence: float + bos_probabilities: Dict[str, float] + + # POI reaction + poi_reaction_prob: float + poi_reaction_direction: str # 'bullish', 'bearish', 'none' + + # Structure continuation + structure_continuation_prob: float + expected_structure: str # 'bullish_continuation', 'bearish_continuation', 'reversal' + + # Metadata + timestamp: Optional[Any] = None + features_used: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + 'next_bos_direction': self.next_bos_direction, + 'bos_confidence': float(self.bos_confidence), + 'bos_probabilities': self.bos_probabilities, + 'poi_reaction_prob': float(self.poi_reaction_prob), + 'poi_reaction_direction': self.poi_reaction_direction, + 'structure_continuation_prob': float(self.structure_continuation_prob), + 'expected_structure': self.expected_structure, + 'timestamp': str(self.timestamp) if self.timestamp else None + } + + @property + def trading_bias(self) -> str: + """Get overall trading bias""" + if self.bos_confidence >= 0.7: + if self.next_bos_direction == 'bullish': + return 'LONG_BIAS' + elif self.next_bos_direction == 'bearish': + return 'SHORT_BIAS' + return 'NEUTRAL' + + @property + def signal_strength(self) -> float: + """Calculate overall signal strength 0-1""" + # Combine BOS confidence, POI reaction, and continuation + bos_factor = self.bos_confidence if self.next_bos_direction != 'neutral' else 0 + poi_factor = self.poi_reaction_prob * 0.5 + cont_factor = self.structure_continuation_prob * 0.3 + + return min(1.0, bos_factor + poi_factor + cont_factor) + + +@dataclass +class MSAMetrics: + """Metrics for MSA model evaluation""" + # BOS direction metrics + bos_accuracy: float = 0.0 + bos_macro_f1: float = 0.0 + bos_weighted_f1: float = 0.0 + bos_per_class_f1: Dict[str, float] = field(default_factory=dict) + bos_confusion_matrix: Optional[np.ndarray] = None + + # POI reaction metrics + poi_accuracy: float = 0.0 + poi_precision: float = 0.0 + poi_recall: float = 0.0 + poi_f1: float = 0.0 + + # Structure continuation metrics + continuation_accuracy: float = 0.0 + continuation_f1: float = 0.0 + + n_samples: int = 0 + + +class SwingPointGNN(nn.Module): + """ + Graph Neural Network for swing point relationships. + + Models the relationships between swing points to capture + market structure patterns. + """ + + def __init__( + self, + input_dim: int = 8, + hidden_dim: int = 32, + output_dim: int = 16, + n_layers: int = 2, + dropout: float = 0.1 + ): + """ + Initialize Swing Point GNN. + + Args: + input_dim: Input feature dimension per node + hidden_dim: Hidden layer dimension + output_dim: Output embedding dimension + n_layers: Number of GNN layers + dropout: Dropout probability + """ + super().__init__() + + if not HAS_TORCH: + raise ImportError("PyTorch is required for SwingPointGNN") + + self.input_dim = input_dim + self.hidden_dim = hidden_dim + self.output_dim = output_dim + self.n_layers = n_layers + + # Node embedding + self.node_embed = nn.Linear(input_dim, hidden_dim) + + # GNN layers (simple message passing) + self.gnn_layers = nn.ModuleList([ + nn.Linear(hidden_dim * 2, hidden_dim) + for _ in range(n_layers) + ]) + + # Layer norms + self.layer_norms = nn.ModuleList([ + nn.LayerNorm(hidden_dim) + for _ in range(n_layers) + ]) + + # Output projection + self.output_proj = nn.Linear(hidden_dim, output_dim) + + self.dropout = nn.Dropout(dropout) + self.activation = nn.GELU() + + def forward( + self, + node_features: torch.Tensor, + edge_index: torch.Tensor + ) -> torch.Tensor: + """ + Forward pass. + + Args: + node_features: [n_nodes, input_dim] node feature matrix + edge_index: [2, n_edges] edge index tensor + + Returns: + [n_nodes, output_dim] node embeddings + """ + # Initial embedding + x = self.node_embed(node_features) + x = self.activation(x) + + # Message passing layers + for i, (gnn_layer, layer_norm) in enumerate(zip(self.gnn_layers, self.layer_norms)): + # Aggregate neighbor features + row, col = edge_index + neighbor_features = x[col] + + # Create edge features by concatenating source and target + source_features = x[row] + edge_features = torch.cat([source_features, neighbor_features], dim=-1) + + # Transform + messages = gnn_layer(edge_features) + messages = self.activation(messages) + + # Aggregate messages per node + aggregated = torch.zeros_like(x) + aggregated.index_add_(0, row, messages) + + # Combine with residual + x = x + self.dropout(aggregated) + x = layer_norm(x) + + # Output projection + return self.output_proj(x) + + +class MSAModel: + """ + Market Structure Analysis Model. + + Uses XGBoost as the main classifier with optional GNN + for swing point relationship modeling. + + Predictions: + - next_bos_direction: Direction of next BOS (bullish/bearish/neutral) + - poi_reaction_prob: Probability of price reacting at current POI + - structure_continuation: Probability of structure continuation vs reversal + """ + + BOS_CLASSES = {0: 'neutral', 1: 'bullish', 2: 'bearish'} + + def __init__( + self, + config: Optional[Dict[str, Any]] = None, + use_gpu: bool = True, + use_gnn: bool = False, + gnn_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize MSA Model. + + Args: + config: XGBoost configuration + use_gpu: Use GPU acceleration for XGBoost + use_gnn: Enable GNN component for swing point relationships + gnn_config: GNN configuration (if use_gnn=True) + """ + if not HAS_XGBOOST: + raise ImportError("XGBoost is required for MSAModel") + + self.config = config or self._default_config() + self.use_gpu = use_gpu + self.use_gnn = use_gnn and HAS_TORCH + + # Initialize XGBoost classifiers + self.bos_classifier: Optional[XGBClassifier] = None + self.poi_classifier: Optional[XGBClassifier] = None + self.continuation_classifier: Optional[XGBClassifier] = None + + # GNN component + self.gnn: Optional[SwingPointGNN] = None + if self.use_gnn: + gnn_cfg = gnn_config or {} + self.gnn = SwingPointGNN(**gnn_cfg) + logger.info("GNN component enabled for swing point relationships") + + # Feature columns + self.feature_columns: List[str] = [] + + # Training state + self._is_trained = False + self.metrics: Optional[MSAMetrics] = None + + self._init_classifiers() + logger.info(f"MSAModel initialized: use_gpu={use_gpu}, use_gnn={use_gnn}") + + def _default_config(self) -> Dict[str, Any]: + """Default XGBoost configuration""" + return { + 'n_estimators': 200, + 'max_depth': 6, + 'learning_rate': 0.05, + 'subsample': 0.8, + 'colsample_bytree': 0.8, + 'min_child_weight': 3, + 'gamma': 0.1, + 'reg_alpha': 0.1, + 'reg_lambda': 1.0, + 'tree_method': 'hist', + 'random_state': 42 + } + + def _init_classifiers(self): + """Initialize XGBoost classifiers""" + params = self.config.copy() + + # GPU configuration + if self.use_gpu: + try: + import torch + if torch.cuda.is_available(): + params['device'] = 'cuda' + params['tree_method'] = 'gpu_hist' + logger.info("GPU acceleration enabled for MSAModel") + except ImportError: + pass + + # BOS direction classifier (3 classes) + bos_params = params.copy() + bos_params['objective'] = 'multi:softprob' + bos_params['num_class'] = 3 + self.bos_classifier = XGBClassifier(**bos_params) + + # POI reaction classifier (binary) + poi_params = params.copy() + poi_params['objective'] = 'binary:logistic' + poi_params.pop('num_class', None) + self.poi_classifier = XGBClassifier(**poi_params) + + # Structure continuation classifier (binary) + cont_params = params.copy() + cont_params['objective'] = 'binary:logistic' + cont_params.pop('num_class', None) + self.continuation_classifier = XGBClassifier(**cont_params) + + def forward( + self, + X: Union[pd.DataFrame, np.ndarray], + swing_graph: Optional[Tuple[torch.Tensor, torch.Tensor]] = None + ) -> List[MSAPrediction]: + """ + Generate predictions for input features. + + Args: + X: Feature matrix [n_samples, n_features] + swing_graph: Optional tuple of (node_features, edge_index) for GNN + + Returns: + List of MSAPrediction objects + """ + if not self._is_trained: + raise RuntimeError("Model must be trained before prediction") + + if isinstance(X, pd.DataFrame): + X_arr = X.values + else: + X_arr = X + + # Get classifier predictions + bos_proba = self.bos_classifier.predict_proba(X_arr) + poi_proba = self.poi_classifier.predict_proba(X_arr)[:, 1] + cont_proba = self.continuation_classifier.predict_proba(X_arr)[:, 1] + + # Optional: Incorporate GNN features + if self.use_gnn and swing_graph is not None and self.gnn is not None: + node_features, edge_index = swing_graph + with torch.no_grad(): + gnn_embeddings = self.gnn(node_features, edge_index) + # Could use GNN embeddings to adjust predictions + # For now, just log + logger.debug(f"GNN embeddings shape: {gnn_embeddings.shape}") + + # Generate predictions + predictions = [] + for i in range(len(X_arr)): + # BOS direction + bos_class = int(np.argmax(bos_proba[i])) + bos_direction = self.BOS_CLASSES[bos_class] + bos_conf = float(bos_proba[i].max()) + + # POI reaction + poi_prob = float(poi_proba[i]) + if poi_prob > 0.5: + # Determine direction based on structure features + poi_direction = 'bullish' if bos_direction == 'bullish' else 'bearish' + else: + poi_direction = 'none' + + # Structure continuation + cont_prob = float(cont_proba[i]) + if cont_prob > 0.5: + expected = f"{bos_direction}_continuation" if bos_direction != 'neutral' else 'neutral' + else: + expected = 'reversal' + + pred = MSAPrediction( + next_bos_direction=bos_direction, + bos_confidence=bos_conf, + bos_probabilities={ + 'neutral': float(bos_proba[i][0]), + 'bullish': float(bos_proba[i][1]), + 'bearish': float(bos_proba[i][2]) + }, + poi_reaction_prob=poi_prob, + poi_reaction_direction=poi_direction, + structure_continuation_prob=cont_prob, + expected_structure=expected, + timestamp=X.index[i] if isinstance(X, pd.DataFrame) else None, + features_used=self.feature_columns + ) + predictions.append(pred) + + return predictions + + def fit( + self, + X_train: Union[pd.DataFrame, np.ndarray], + y_bos: np.ndarray, + y_poi: np.ndarray, + y_continuation: np.ndarray, + X_val: Optional[Union[pd.DataFrame, np.ndarray]] = None, + y_bos_val: Optional[np.ndarray] = None, + y_poi_val: Optional[np.ndarray] = None, + y_continuation_val: Optional[np.ndarray] = None, + sample_weight: Optional[np.ndarray] = None, + verbose: bool = True + ) -> MSAMetrics: + """ + Train all MSA classifiers. + + Args: + X_train: Training features + y_bos: BOS direction labels (0=neutral, 1=bullish, 2=bearish) + y_poi: POI reaction labels (0/1) + y_continuation: Structure continuation labels (0/1) + X_val: Validation features (optional) + y_bos_val: Validation BOS labels + y_poi_val: Validation POI labels + y_continuation_val: Validation continuation labels + sample_weight: Sample weights for training + verbose: Print training progress + + Returns: + Training metrics + """ + if isinstance(X_train, pd.DataFrame): + self.feature_columns = X_train.columns.tolist() + X_train_arr = X_train.values + else: + X_train_arr = X_train + self.feature_columns = [f"feature_{i}" for i in range(X_train.shape[1])] + + logger.info(f"Training MSAModel on {len(X_train_arr)} samples with {X_train_arr.shape[1]} features") + + # Prepare fit parameters + fit_params = {} + if sample_weight is not None: + fit_params['sample_weight'] = sample_weight + + # Add validation set if provided + eval_set = None + if X_val is not None: + X_val_arr = X_val.values if isinstance(X_val, pd.DataFrame) else X_val + eval_set = [(X_val_arr, None)] # Placeholder, updated per classifier + + # Train BOS classifier + logger.info("Training BOS direction classifier...") + bos_fit_params = fit_params.copy() + if X_val is not None and y_bos_val is not None: + bos_fit_params['eval_set'] = [(X_val.values if isinstance(X_val, pd.DataFrame) else X_val, y_bos_val)] + self.bos_classifier.fit(X_train_arr, y_bos, **bos_fit_params) + + # Train POI classifier + logger.info("Training POI reaction classifier...") + poi_fit_params = fit_params.copy() + if X_val is not None and y_poi_val is not None: + poi_fit_params['eval_set'] = [(X_val.values if isinstance(X_val, pd.DataFrame) else X_val, y_poi_val)] + self.poi_classifier.fit(X_train_arr, y_poi, **poi_fit_params) + + # Train continuation classifier + logger.info("Training structure continuation classifier...") + cont_fit_params = fit_params.copy() + if X_val is not None and y_continuation_val is not None: + cont_fit_params['eval_set'] = [(X_val.values if isinstance(X_val, pd.DataFrame) else X_val, y_continuation_val)] + self.continuation_classifier.fit(X_train_arr, y_continuation, **cont_fit_params) + + self._is_trained = True + + # Calculate metrics on training data + self.metrics = self._calculate_metrics( + X_train_arr, y_bos, y_poi, y_continuation + ) + + if verbose: + self._print_metrics(self.metrics, "Training") + + # Calculate validation metrics if provided + if X_val is not None: + val_metrics = self._calculate_metrics( + X_val.values if isinstance(X_val, pd.DataFrame) else X_val, + y_bos_val, y_poi_val, y_continuation_val + ) + if verbose: + self._print_metrics(val_metrics, "Validation") + + return self.metrics + + def predict( + self, + X: Union[pd.DataFrame, np.ndarray] + ) -> List[MSAPrediction]: + """ + Generate predictions (alias for forward). + + Args: + X: Feature matrix + + Returns: + List of MSAPrediction objects + """ + return self.forward(X) + + def predict_single( + self, + X: Union[pd.DataFrame, np.ndarray] + ) -> MSAPrediction: + """ + Predict for the last row of features. + + Args: + X: Feature matrix (uses last row) + + Returns: + Single MSAPrediction + """ + if isinstance(X, pd.DataFrame): + X = X.tail(1) + else: + X = X[-1:, :] + + predictions = self.forward(X) + return predictions[-1] if predictions else None + + def _calculate_metrics( + self, + X: np.ndarray, + y_bos: np.ndarray, + y_poi: np.ndarray, + y_continuation: np.ndarray + ) -> MSAMetrics: + """Calculate metrics for all classifiers""" + # BOS metrics + bos_pred = self.bos_classifier.predict(X) + bos_precision, bos_recall, bos_f1, _ = precision_recall_fscore_support( + y_bos, bos_pred, average=None, zero_division=0 + ) + + bos_per_class_f1 = {} + for i, name in self.BOS_CLASSES.items(): + if i < len(bos_f1): + bos_per_class_f1[name] = float(bos_f1[i]) + + # POI metrics + poi_pred = self.poi_classifier.predict(X) + poi_precision, poi_recall, poi_f1, _ = precision_recall_fscore_support( + y_poi, poi_pred, average='binary', zero_division=0 + ) + + # Continuation metrics + cont_pred = self.continuation_classifier.predict(X) + cont_f1 = f1_score(y_continuation, cont_pred, zero_division=0) + + return MSAMetrics( + bos_accuracy=accuracy_score(y_bos, bos_pred), + bos_macro_f1=f1_score(y_bos, bos_pred, average='macro', zero_division=0), + bos_weighted_f1=f1_score(y_bos, bos_pred, average='weighted', zero_division=0), + bos_per_class_f1=bos_per_class_f1, + bos_confusion_matrix=confusion_matrix(y_bos, bos_pred), + poi_accuracy=accuracy_score(y_poi, poi_pred), + poi_precision=float(poi_precision), + poi_recall=float(poi_recall), + poi_f1=float(poi_f1), + continuation_accuracy=accuracy_score(y_continuation, cont_pred), + continuation_f1=float(cont_f1), + n_samples=len(X) + ) + + def _print_metrics(self, metrics: MSAMetrics, prefix: str = ""): + """Print metrics summary""" + logger.info(f"\n{prefix} Metrics (n={metrics.n_samples}):") + logger.info(f" BOS Direction:") + logger.info(f" Accuracy: {metrics.bos_accuracy:.2%}") + logger.info(f" Macro F1: {metrics.bos_macro_f1:.4f}") + logger.info(f" Per-class F1: {metrics.bos_per_class_f1}") + logger.info(f" POI Reaction:") + logger.info(f" Accuracy: {metrics.poi_accuracy:.2%}") + logger.info(f" F1: {metrics.poi_f1:.4f}") + logger.info(f" Structure Continuation:") + logger.info(f" Accuracy: {metrics.continuation_accuracy:.2%}") + logger.info(f" F1: {metrics.continuation_f1:.4f}") + + def get_feature_importance(self, top_n: int = 20) -> Dict[str, Dict[str, float]]: + """ + Get feature importance from all classifiers. + + Args: + top_n: Number of top features to return + + Returns: + Dictionary with feature importance per classifier + """ + if not self._is_trained: + return {} + + result = {} + + # BOS classifier importance + bos_imp = dict(zip(self.feature_columns, self.bos_classifier.feature_importances_)) + result['bos'] = dict(sorted(bos_imp.items(), key=lambda x: x[1], reverse=True)[:top_n]) + + # POI classifier importance + poi_imp = dict(zip(self.feature_columns, self.poi_classifier.feature_importances_)) + result['poi'] = dict(sorted(poi_imp.items(), key=lambda x: x[1], reverse=True)[:top_n]) + + # Continuation classifier importance + cont_imp = dict(zip(self.feature_columns, self.continuation_classifier.feature_importances_)) + result['continuation'] = dict(sorted(cont_imp.items(), key=lambda x: x[1], reverse=True)[:top_n]) + + return result + + def save(self, path: str): + """Save model to disk""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save classifiers + joblib.dump(self.bos_classifier, path / 'bos_classifier.joblib') + joblib.dump(self.poi_classifier, path / 'poi_classifier.joblib') + joblib.dump(self.continuation_classifier, path / 'continuation_classifier.joblib') + + # Save GNN if present + if self.use_gnn and self.gnn is not None: + torch.save(self.gnn.state_dict(), path / 'gnn.pt') + + # Save metadata + metadata = { + 'config': self.config, + 'feature_columns': self.feature_columns, + 'use_gnn': self.use_gnn, + 'metrics': { + 'bos_accuracy': self.metrics.bos_accuracy if self.metrics else None, + 'bos_macro_f1': self.metrics.bos_macro_f1 if self.metrics else None, + 'poi_f1': self.metrics.poi_f1 if self.metrics else None, + 'continuation_f1': self.metrics.continuation_f1 if self.metrics else None + }, + 'version': '1.0.0' + } + joblib.dump(metadata, path / 'metadata.joblib') + + logger.info(f"Saved MSAModel to {path}") + + def load(self, path: str): + """Load model from disk""" + path = Path(path) + + # Load classifiers + self.bos_classifier = joblib.load(path / 'bos_classifier.joblib') + self.poi_classifier = joblib.load(path / 'poi_classifier.joblib') + self.continuation_classifier = joblib.load(path / 'continuation_classifier.joblib') + + # Load GNN if present + if self.use_gnn and (path / 'gnn.pt').exists(): + self.gnn.load_state_dict(torch.load(path / 'gnn.pt')) + + # Load metadata + metadata = joblib.load(path / 'metadata.joblib') + self.config = metadata['config'] + self.feature_columns = metadata['feature_columns'] + + self._is_trained = True + logger.info(f"Loaded MSAModel from {path}") + + +if __name__ == "__main__": + # Test MSAModel + np.random.seed(42) + + # Create sample data + n_samples = 1000 + n_features = 50 + + X = np.random.randn(n_samples, n_features) + y_bos = np.random.randint(0, 3, n_samples) # 0=neutral, 1=bullish, 2=bearish + y_poi = np.random.randint(0, 2, n_samples) # 0=no reaction, 1=reaction + y_continuation = np.random.randint(0, 2, n_samples) # 0=reversal, 1=continuation + + # Split data + train_size = 800 + X_train, X_val = X[:train_size], X[train_size:] + y_bos_train, y_bos_val = y_bos[:train_size], y_bos[train_size:] + y_poi_train, y_poi_val = y_poi[:train_size], y_poi[train_size:] + y_cont_train, y_cont_val = y_continuation[:train_size], y_continuation[train_size:] + + # Create and train model + model = MSAModel(use_gpu=False, use_gnn=False) + metrics = model.fit( + X_train, y_bos_train, y_poi_train, y_cont_train, + X_val, y_bos_val, y_poi_val, y_cont_val + ) + + # Generate predictions + predictions = model.predict(X_val[:5]) + + print("\n=== Sample Predictions ===") + for i, pred in enumerate(predictions): + print(f"Sample {i}: BOS={pred.next_bos_direction} ({pred.bos_confidence:.2f}), " + f"POI_react={pred.poi_reaction_prob:.2f}, " + f"Continuation={pred.structure_continuation_prob:.2f}, " + f"Bias={pred.trading_bias}") + + # Feature importance + print("\n=== Top Features (BOS) ===") + importance = model.get_feature_importance(10) + for feat, imp in list(importance['bos'].items())[:5]: + print(f" {feat}: {imp:.4f}") diff --git a/src/models/strategies/msa/structure_detector.py b/src/models/strategies/msa/structure_detector.py new file mode 100644 index 0000000..f07c999 --- /dev/null +++ b/src/models/strategies/msa/structure_detector.py @@ -0,0 +1,955 @@ +""" +MSA (Market Structure Analysis) - Structure Detector +==================================================== +Detection of market structure using ICT/SMC concepts: +- Swing Points (HH, HL, LH, LL) +- Break of Structure (BOS) +- Change of Character (CHoCH) +- Fair Value Gaps (FVG) +- Order Blocks (OB) +- Premium/Discount Zones + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import pandas as pd +import numpy as np +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from loguru import logger + + +class SwingType(str, Enum): + """Swing point type""" + HIGHER_HIGH = "HH" + HIGHER_LOW = "HL" + LOWER_HIGH = "LH" + LOWER_LOW = "LL" + + +class StructureType(str, Enum): + """Market structure event type""" + BOS_BULLISH = "bos_bullish" + BOS_BEARISH = "bos_bearish" + CHOCH_BULLISH = "choch_bullish" + CHOCH_BEARISH = "choch_bearish" + + +class POIType(str, Enum): + """Point of Interest type""" + ORDER_BLOCK = "order_block" + FAIR_VALUE_GAP = "fair_value_gap" + SWING_HIGH = "swing_high" + SWING_LOW = "swing_low" + LIQUIDITY_LEVEL = "liquidity_level" + NONE = "none" + + +@dataclass +class SwingPoint: + """Swing point data structure""" + index: int + price: float + swing_type: SwingType + timestamp: Optional[datetime] = None + confirmed: bool = True + + def to_dict(self) -> Dict[str, Any]: + return { + 'index': self.index, + 'price': self.price, + 'swing_type': self.swing_type.value, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'confirmed': self.confirmed + } + + +@dataclass +class StructureEvent: + """Break of Structure or Change of Character event""" + event_type: StructureType + index: int + break_price: float + previous_swing_price: float + timestamp: Optional[datetime] = None + confirmed: bool = True + + def to_dict(self) -> Dict[str, Any]: + return { + 'event_type': self.event_type.value, + 'index': self.index, + 'break_price': self.break_price, + 'previous_swing_price': self.previous_swing_price, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'confirmed': self.confirmed + } + + +@dataclass +class FairValueGap: + """Fair Value Gap (3-candle imbalance)""" + gap_type: str # 'bullish' or 'bearish' + index: int + high: float + low: float + size: float + size_pct: float + timestamp: Optional[datetime] = None + filled: bool = False + fill_pct: float = 0.0 + + @property + def midpoint(self) -> float: + return (self.high + self.low) / 2 + + def to_dict(self) -> Dict[str, Any]: + return { + 'gap_type': self.gap_type, + 'index': self.index, + 'high': self.high, + 'low': self.low, + 'midpoint': self.midpoint, + 'size': self.size, + 'size_pct': self.size_pct, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'filled': self.filled, + 'fill_pct': self.fill_pct + } + + +@dataclass +class OrderBlock: + """Order Block (last opposite candle before BOS)""" + ob_type: str # 'bullish' or 'bearish' + index: int + high: float + low: float + open_price: float + close_price: float + volume: float + strength: float + timestamp: Optional[datetime] = None + valid: bool = True + touched: bool = False + broken: bool = False + + @property + def midpoint(self) -> float: + return (self.high + self.low) / 2 + + def to_dict(self) -> Dict[str, Any]: + return { + 'ob_type': self.ob_type, + 'index': self.index, + 'high': self.high, + 'low': self.low, + 'midpoint': self.midpoint, + 'open': self.open_price, + 'close': self.close_price, + 'volume': self.volume, + 'strength': self.strength, + 'timestamp': self.timestamp.isoformat() if self.timestamp else None, + 'valid': self.valid, + 'touched': self.touched, + 'broken': self.broken + } + + +@dataclass +class LiquidityLevel: + """Liquidity level (equal highs/lows)""" + level_type: str # 'equal_highs' or 'equal_lows' + price: float + indices: List[int] = field(default_factory=list) + strength: int = 0 # Number of touches + swept: bool = False + + def to_dict(self) -> Dict[str, Any]: + return { + 'level_type': self.level_type, + 'price': self.price, + 'indices': self.indices, + 'strength': self.strength, + 'swept': self.swept + } + + +@dataclass +class PremiumDiscountZone: + """Premium/Discount zone based on recent swing range""" + premium_low: float + premium_high: float + discount_low: float + discount_high: float + equilibrium: float + current_zone: str # 'premium', 'discount', 'equilibrium' + + def to_dict(self) -> Dict[str, Any]: + return { + 'premium': {'low': self.premium_low, 'high': self.premium_high}, + 'discount': {'low': self.discount_low, 'high': self.discount_high}, + 'equilibrium': self.equilibrium, + 'current_zone': self.current_zone + } + + +class StructureDetector: + """ + Market Structure Analysis Detector + + Detects and analyzes market structure using ICT/SMC concepts: + - Swing points detection with configurable order + - Higher Highs (HH), Higher Lows (HL), Lower Highs (LH), Lower Lows (LL) + - Break of Structure (BOS) - continuation pattern + - Change of Character (CHoCH) - reversal signal + - Fair Value Gaps (FVG) - 3-candle imbalances + - Order Blocks (OB) - institutional accumulation/distribution zones + - Premium/Discount zones for optimal entry identification + """ + + def __init__( + self, + swing_order: int = 5, + fvg_min_size_pct: float = 0.001, + ob_min_size_pct: float = 0.001, + liquidity_tolerance_pct: float = 0.0005, + max_ob_lookback: int = 10 + ): + """ + Initialize Structure Detector. + + Args: + swing_order: Number of bars on each side for swing detection + fvg_min_size_pct: Minimum FVG size as percentage of price + ob_min_size_pct: Minimum Order Block size as percentage of price + liquidity_tolerance_pct: Tolerance for equal highs/lows detection + max_ob_lookback: Maximum lookback for Order Block detection before BOS + """ + self.swing_order = swing_order + self.fvg_min_size_pct = fvg_min_size_pct + self.ob_min_size_pct = ob_min_size_pct + self.liquidity_tolerance_pct = liquidity_tolerance_pct + self.max_ob_lookback = max_ob_lookback + + logger.info( + f"StructureDetector initialized: order={swing_order}, " + f"fvg_min={fvg_min_size_pct:.4f}, ob_min={ob_min_size_pct:.4f}" + ) + + def detect_swing_points( + self, + df: pd.DataFrame, + order: Optional[int] = None + ) -> Tuple[List[SwingPoint], List[SwingPoint]]: + """ + Detect swing highs and swing lows. + + Uses a rolling window approach where a swing high is the highest point + within 'order' bars on each side, and swing low is the lowest point. + + Args: + df: OHLCV DataFrame + order: Number of bars on each side (uses default if None) + + Returns: + Tuple of (swing_highs, swing_lows) as lists of SwingPoint + """ + order = order or self.swing_order + swing_highs = [] + swing_lows = [] + + if len(df) < order * 2 + 1: + logger.warning(f"Insufficient data for swing detection: {len(df)} < {order * 2 + 1}") + return swing_highs, swing_lows + + high = df['high'].values + low = df['low'].values + + # Detect swing points + for i in range(order, len(df) - order): + # Swing High: highest point in the window + window_high = high[i - order:i + order + 1] + if high[i] == window_high.max() and high[i] == window_high[order]: + ts = df.index[i] if isinstance(df.index, pd.DatetimeIndex) else None + swing_highs.append(SwingPoint( + index=i, + price=float(high[i]), + swing_type=SwingType.HIGHER_HIGH, # Will be updated later + timestamp=ts, + confirmed=True + )) + + # Swing Low: lowest point in the window + window_low = low[i - order:i + order + 1] + if low[i] == window_low.min() and low[i] == window_low[order]: + ts = df.index[i] if isinstance(df.index, pd.DatetimeIndex) else None + swing_lows.append(SwingPoint( + index=i, + price=float(low[i]), + swing_type=SwingType.HIGHER_LOW, # Will be updated later + timestamp=ts, + confirmed=True + )) + + logger.debug(f"Detected {len(swing_highs)} swing highs and {len(swing_lows)} swing lows") + return swing_highs, swing_lows + + def detect_higher_highs_lower_lows( + self, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint] + ) -> Tuple[List[SwingPoint], List[SwingPoint]]: + """ + Classify swing points as HH, HL, LH, LL. + + - HH (Higher High): Current swing high > previous swing high + - HL (Higher Low): Current swing low > previous swing low + - LH (Lower High): Current swing high < previous swing high + - LL (Lower Low): Current swing low < previous swing low + + Args: + swing_highs: List of swing high points + swing_lows: List of swing low points + + Returns: + Tuple of (classified_highs, classified_lows) + """ + classified_highs = [] + classified_lows = [] + + # Classify swing highs + for i, sh in enumerate(swing_highs): + if i == 0: + # First swing cannot be classified relative to previous + sh.swing_type = SwingType.HIGHER_HIGH + else: + prev_high = swing_highs[i - 1].price + if sh.price > prev_high: + sh.swing_type = SwingType.HIGHER_HIGH + else: + sh.swing_type = SwingType.LOWER_HIGH + classified_highs.append(sh) + + # Classify swing lows + for i, sl in enumerate(swing_lows): + if i == 0: + sl.swing_type = SwingType.HIGHER_LOW + else: + prev_low = swing_lows[i - 1].price + if sl.price > prev_low: + sl.swing_type = SwingType.HIGHER_LOW + else: + sl.swing_type = SwingType.LOWER_LOW + classified_lows.append(sl) + + return classified_highs, classified_lows + + def detect_break_of_structure( + self, + df: pd.DataFrame, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint] + ) -> List[StructureEvent]: + """ + Detect Break of Structure (BOS) events. + + BOS occurs when: + - Bullish BOS: Price breaks above a previous swing high in an uptrend + - Bearish BOS: Price breaks below a previous swing low in a downtrend + + BOS confirms trend continuation. + + Args: + df: OHLCV DataFrame + swing_highs: List of swing high points + swing_lows: List of swing low points + + Returns: + List of BOS StructureEvent + """ + bos_events = [] + close = df['close'].values + high = df['high'].values + low = df['low'].values + + # Track the most recent significant swings + last_swing_high = None + last_swing_low = None + trend = 'neutral' # 'bullish', 'bearish', 'neutral' + + # Combine and sort all swings by index + all_swings = ( + [(sh.index, sh.price, 'high', sh) for sh in swing_highs] + + [(sl.index, sl.price, 'low', sl) for sl in swing_lows] + ) + all_swings.sort(key=lambda x: x[0]) + + for i in range(1, len(all_swings)): + idx, price, swing_kind, swing_obj = all_swings[i] + + # Look for breaks after the swing is formed + for j in range(idx + 1, min(idx + 50, len(df))): + if swing_kind == 'high' and last_swing_high is not None: + # Check for bullish BOS (close above previous swing high) + if close[j] > last_swing_high.price and trend in ['bullish', 'neutral']: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + bos_events.append(StructureEvent( + event_type=StructureType.BOS_BULLISH, + index=j, + break_price=float(close[j]), + previous_swing_price=last_swing_high.price, + timestamp=ts, + confirmed=True + )) + trend = 'bullish' + break + + if swing_kind == 'low' and last_swing_low is not None: + # Check for bearish BOS (close below previous swing low) + if close[j] < last_swing_low.price and trend in ['bearish', 'neutral']: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + bos_events.append(StructureEvent( + event_type=StructureType.BOS_BEARISH, + index=j, + break_price=float(close[j]), + previous_swing_price=last_swing_low.price, + timestamp=ts, + confirmed=True + )) + trend = 'bearish' + break + + # Update last swings + if swing_kind == 'high': + last_swing_high = swing_obj + else: + last_swing_low = swing_obj + + logger.debug(f"Detected {len(bos_events)} BOS events") + return bos_events + + def detect_change_of_character( + self, + df: pd.DataFrame, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint] + ) -> List[StructureEvent]: + """ + Detect Change of Character (CHoCH) events. + + CHoCH occurs when: + - Bullish CHoCH: Price breaks above swing high after a series of LH/LL + - Bearish CHoCH: Price breaks below swing low after a series of HH/HL + + CHoCH signals potential trend reversal. + + Args: + df: OHLCV DataFrame + swing_highs: List of swing high points + swing_lows: List of swing low points + + Returns: + List of CHoCH StructureEvent + """ + choch_events = [] + close = df['close'].values + + # Classify swings first + classified_highs, classified_lows = self.detect_higher_highs_lower_lows( + swing_highs.copy(), swing_lows.copy() + ) + + # Detect bearish to bullish CHoCH + # Look for sequence of LH followed by break above recent LH + for i in range(2, len(classified_highs)): + curr = classified_highs[i] + prev = classified_highs[i - 1] + prev2 = classified_highs[i - 2] + + # Sequence of Lower Highs (bearish structure) + if (prev.swing_type == SwingType.LOWER_HIGH and + prev2.swing_type == SwingType.LOWER_HIGH): + # Look for break above the most recent LH + for j in range(curr.index + 1, min(curr.index + 30, len(df))): + if close[j] > prev.price: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + choch_events.append(StructureEvent( + event_type=StructureType.CHOCH_BULLISH, + index=j, + break_price=float(close[j]), + previous_swing_price=prev.price, + timestamp=ts, + confirmed=True + )) + break + + # Detect bullish to bearish CHoCH + # Look for sequence of HL followed by break below recent HL + for i in range(2, len(classified_lows)): + curr = classified_lows[i] + prev = classified_lows[i - 1] + prev2 = classified_lows[i - 2] + + # Sequence of Higher Lows (bullish structure) + if (prev.swing_type == SwingType.HIGHER_LOW and + prev2.swing_type == SwingType.HIGHER_LOW): + # Look for break below the most recent HL + for j in range(curr.index + 1, min(curr.index + 30, len(df))): + if close[j] < prev.price: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + choch_events.append(StructureEvent( + event_type=StructureType.CHOCH_BEARISH, + index=j, + break_price=float(close[j]), + previous_swing_price=prev.price, + timestamp=ts, + confirmed=True + )) + break + + logger.debug(f"Detected {len(choch_events)} CHoCH events") + return choch_events + + def detect_fair_value_gaps( + self, + df: pd.DataFrame + ) -> List[FairValueGap]: + """ + Detect Fair Value Gaps (FVG) - 3-candle imbalances. + + Bullish FVG: Gap between candle 1 high and candle 3 low (price skipped up) + Bearish FVG: Gap between candle 3 high and candle 1 low (price skipped down) + + Args: + df: OHLCV DataFrame + + Returns: + List of FairValueGap objects + """ + fvgs = [] + high = df['high'].values + low = df['low'].values + close = df['close'].values + + for i in range(2, len(df)): + # Bullish FVG: Low of candle 3 > High of candle 1 + if low[i] > high[i - 2]: + gap_size = low[i] - high[i - 2] + gap_pct = gap_size / close[i] + + if gap_pct >= self.fvg_min_size_pct: + ts = df.index[i] if isinstance(df.index, pd.DatetimeIndex) else None + + # Check if gap was filled + filled = False + fill_pct = 0.0 + for j in range(i + 1, len(df)): + if low[j] <= high[i - 2]: + filled = True + fill_pct = 1.0 + break + elif low[j] < low[i]: + fill_pct = max(fill_pct, (low[i] - low[j]) / gap_size) + + fvgs.append(FairValueGap( + gap_type='bullish', + index=i, + high=float(low[i]), + low=float(high[i - 2]), + size=float(gap_size), + size_pct=float(gap_pct * 100), + timestamp=ts, + filled=filled, + fill_pct=float(fill_pct) + )) + + # Bearish FVG: High of candle 3 < Low of candle 1 + if high[i] < low[i - 2]: + gap_size = low[i - 2] - high[i] + gap_pct = gap_size / close[i] + + if gap_pct >= self.fvg_min_size_pct: + ts = df.index[i] if isinstance(df.index, pd.DatetimeIndex) else None + + # Check if gap was filled + filled = False + fill_pct = 0.0 + for j in range(i + 1, len(df)): + if high[j] >= low[i - 2]: + filled = True + fill_pct = 1.0 + break + elif high[j] > high[i]: + fill_pct = max(fill_pct, (high[j] - high[i]) / gap_size) + + fvgs.append(FairValueGap( + gap_type='bearish', + index=i, + high=float(low[i - 2]), + low=float(high[i]), + size=float(gap_size), + size_pct=float(gap_pct * 100), + timestamp=ts, + filled=filled, + fill_pct=float(fill_pct) + )) + + logger.debug(f"Detected {len(fvgs)} FVGs (Bullish: {sum(1 for f in fvgs if f.gap_type == 'bullish')}, Bearish: {sum(1 for f in fvgs if f.gap_type == 'bearish')})") + return fvgs + + def detect_order_blocks( + self, + df: pd.DataFrame, + bos_events: List[StructureEvent] + ) -> List[OrderBlock]: + """ + Detect Order Blocks - last opposite candle before BOS. + + Bullish OB: Last bearish candle before a bullish BOS + Bearish OB: Last bullish candle before a bearish BOS + + Args: + df: OHLCV DataFrame + bos_events: List of BOS events from detect_break_of_structure + + Returns: + List of OrderBlock objects + """ + order_blocks = [] + open_prices = df['open'].values + high = df['high'].values + low = df['low'].values + close = df['close'].values + volume = df['volume'].values if 'volume' in df.columns else np.ones(len(df)) + + volume_ma = pd.Series(volume).rolling(20).mean().values + + for bos in bos_events: + bos_idx = bos.index + + if bos.event_type == StructureType.BOS_BULLISH: + # Look for last bearish candle before BOS + for j in range(bos_idx - 1, max(0, bos_idx - self.max_ob_lookback), -1): + if close[j] < open_prices[j]: # Bearish candle + ob_size_pct = (high[j] - low[j]) / close[j] + if ob_size_pct >= self.ob_min_size_pct: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + + # Calculate strength + vol_ratio = volume[j] / volume_ma[j] if volume_ma[j] > 0 else 1.0 + move_after = (bos.break_price - low[j]) / close[j] + strength = min(1.0, (move_after * 5 + vol_ratio * 0.3) / 2) + + # Check status + valid = True + touched = False + broken = False + for k in range(j + 1, len(df)): + if low[k] <= high[j]: + touched = True + if close[k] < low[j]: + broken = True + valid = False + break + + order_blocks.append(OrderBlock( + ob_type='bullish', + index=j, + high=float(high[j]), + low=float(low[j]), + open_price=float(open_prices[j]), + close_price=float(close[j]), + volume=float(volume[j]), + strength=float(strength), + timestamp=ts, + valid=valid, + touched=touched, + broken=broken + )) + break + + elif bos.event_type == StructureType.BOS_BEARISH: + # Look for last bullish candle before BOS + for j in range(bos_idx - 1, max(0, bos_idx - self.max_ob_lookback), -1): + if close[j] > open_prices[j]: # Bullish candle + ob_size_pct = (high[j] - low[j]) / close[j] + if ob_size_pct >= self.ob_min_size_pct: + ts = df.index[j] if isinstance(df.index, pd.DatetimeIndex) else None + + # Calculate strength + vol_ratio = volume[j] / volume_ma[j] if volume_ma[j] > 0 else 1.0 + move_after = (high[j] - bos.break_price) / close[j] + strength = min(1.0, (move_after * 5 + vol_ratio * 0.3) / 2) + + # Check status + valid = True + touched = False + broken = False + for k in range(j + 1, len(df)): + if high[k] >= low[j]: + touched = True + if close[k] > high[j]: + broken = True + valid = False + break + + order_blocks.append(OrderBlock( + ob_type='bearish', + index=j, + high=float(high[j]), + low=float(low[j]), + open_price=float(open_prices[j]), + close_price=float(close[j]), + volume=float(volume[j]), + strength=float(strength), + timestamp=ts, + valid=valid, + touched=touched, + broken=broken + )) + break + + logger.debug(f"Detected {len(order_blocks)} Order Blocks") + return order_blocks + + def compute_premium_discount_zone( + self, + df: pd.DataFrame, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint], + lookback: int = 5 + ) -> PremiumDiscountZone: + """ + Compute Premium/Discount zones based on recent swing range. + + - Premium Zone: Upper 38.2% of the range (0.618 - 1.0 Fibonacci) + - Discount Zone: Lower 38.2% of the range (0.0 - 0.382 Fibonacci) + - Equilibrium: 50% level of the range + + Args: + df: OHLCV DataFrame + swing_highs: List of swing highs + swing_lows: List of swing lows + lookback: Number of recent swings to consider + + Returns: + PremiumDiscountZone object + """ + current_price = df['close'].iloc[-1] + + if not swing_highs or not swing_lows: + # Use recent range if no swings + recent_high = df['high'].iloc[-20:].max() + recent_low = df['low'].iloc[-20:].min() + else: + # Use recent swings + recent_high = max(sh.price for sh in swing_highs[-lookback:]) + recent_low = min(sl.price for sl in swing_lows[-lookback:]) + + range_size = recent_high - recent_low + if range_size <= 0: + range_size = recent_high * 0.01 # Minimum 1% range + + equilibrium = recent_low + range_size * 0.5 + + # Premium zone: 61.8% - 100% + premium_low = recent_low + range_size * 0.618 + premium_high = recent_high + + # Discount zone: 0% - 38.2% + discount_low = recent_low + discount_high = recent_low + range_size * 0.382 + + # Determine current zone + if current_price >= premium_low: + current_zone = 'premium' + elif current_price <= discount_high: + current_zone = 'discount' + else: + current_zone = 'equilibrium' + + return PremiumDiscountZone( + premium_low=float(premium_low), + premium_high=float(premium_high), + discount_low=float(discount_low), + discount_high=float(discount_high), + equilibrium=float(equilibrium), + current_zone=current_zone + ) + + def detect_liquidity_levels( + self, + swing_highs: List[SwingPoint], + swing_lows: List[SwingPoint], + df: pd.DataFrame + ) -> List[LiquidityLevel]: + """ + Detect liquidity levels (equal highs/lows). + + Equal highs/lows are areas where stop losses cluster, + making them targets for liquidity sweeps. + + Args: + swing_highs: List of swing highs + swing_lows: List of swing lows + df: OHLCV DataFrame + + Returns: + List of LiquidityLevel objects + """ + liquidity_levels = [] + tolerance = df['close'].iloc[-1] * self.liquidity_tolerance_pct + + # Detect equal highs + high_prices = [(sh.index, sh.price) for sh in swing_highs] + for i in range(len(high_prices)): + matching_indices = [high_prices[i][0]] + base_price = high_prices[i][1] + + for j in range(i + 1, len(high_prices)): + if abs(high_prices[j][1] - base_price) <= tolerance: + matching_indices.append(high_prices[j][0]) + + if len(matching_indices) >= 2: + # Check if already added + existing = [ll for ll in liquidity_levels if ll.level_type == 'equal_highs' and abs(ll.price - base_price) <= tolerance] + if not existing: + swept = df['high'].iloc[-1] > base_price + liquidity_levels.append(LiquidityLevel( + level_type='equal_highs', + price=float(base_price), + indices=matching_indices, + strength=len(matching_indices), + swept=swept + )) + + # Detect equal lows + low_prices = [(sl.index, sl.price) for sl in swing_lows] + for i in range(len(low_prices)): + matching_indices = [low_prices[i][0]] + base_price = low_prices[i][1] + + for j in range(i + 1, len(low_prices)): + if abs(low_prices[j][1] - base_price) <= tolerance: + matching_indices.append(low_prices[j][0]) + + if len(matching_indices) >= 2: + existing = [ll for ll in liquidity_levels if ll.level_type == 'equal_lows' and abs(ll.price - base_price) <= tolerance] + if not existing: + swept = df['low'].iloc[-1] < base_price + liquidity_levels.append(LiquidityLevel( + level_type='equal_lows', + price=float(base_price), + indices=matching_indices, + strength=len(matching_indices), + swept=swept + )) + + logger.debug(f"Detected {len(liquidity_levels)} liquidity levels") + return liquidity_levels + + def analyze( + self, + df: pd.DataFrame + ) -> Dict[str, Any]: + """ + Perform complete market structure analysis. + + Args: + df: OHLCV DataFrame + + Returns: + Dictionary with all structure analysis results + """ + # Detect swing points + swing_highs, swing_lows = self.detect_swing_points(df) + + # Classify swings + classified_highs, classified_lows = self.detect_higher_highs_lower_lows( + swing_highs, swing_lows + ) + + # Detect structure events + bos_events = self.detect_break_of_structure(df, classified_highs, classified_lows) + choch_events = self.detect_change_of_character(df, classified_highs, classified_lows) + + # Detect POIs + fvgs = self.detect_fair_value_gaps(df) + order_blocks = self.detect_order_blocks(df, bos_events) + liquidity_levels = self.detect_liquidity_levels(classified_highs, classified_lows, df) + + # Compute zones + zones = self.compute_premium_discount_zone(df, classified_highs, classified_lows) + + return { + 'swing_highs': [sh.to_dict() for sh in classified_highs], + 'swing_lows': [sl.to_dict() for sl in classified_lows], + 'bos_events': [bos.to_dict() for bos in bos_events], + 'choch_events': [choch.to_dict() for choch in choch_events], + 'fair_value_gaps': [fvg.to_dict() for fvg in fvgs], + 'order_blocks': [ob.to_dict() for ob in order_blocks], + 'liquidity_levels': [ll.to_dict() for ll in liquidity_levels], + 'premium_discount_zone': zones.to_dict(), + 'summary': { + 'total_swing_highs': len(classified_highs), + 'total_swing_lows': len(classified_lows), + 'total_bos': len(bos_events), + 'total_choch': len(choch_events), + 'total_fvgs': len(fvgs), + 'unfilled_fvgs': sum(1 for f in fvgs if not f.filled), + 'total_order_blocks': len(order_blocks), + 'valid_order_blocks': sum(1 for ob in order_blocks if ob.valid), + 'total_liquidity_levels': len(liquidity_levels), + 'current_zone': zones.current_zone + } + } + + +if __name__ == "__main__": + # Test StructureDetector + import numpy as np + + np.random.seed(42) + + # Create sample OHLCV data with some structure + n_samples = 500 + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + + # Generate price with trends and reversals + price = 2000.0 + prices = [price] + for i in range(1, n_samples): + # Add some trending behavior + trend = 0.5 * np.sin(i / 50) + np.random.randn() * 0.3 + price = price + trend + prices.append(price) + + prices = np.array(prices) + + df = pd.DataFrame({ + 'open': prices + np.random.randn(n_samples) * 0.5, + 'high': prices + abs(np.random.randn(n_samples)) * 1.5, + 'low': prices - abs(np.random.randn(n_samples)) * 1.5, + 'close': prices + np.random.randn(n_samples) * 0.5, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + # Ensure high > low and proper OHLC relationships + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Create detector and analyze + detector = StructureDetector(swing_order=5) + analysis = detector.analyze(df) + + print("=== Market Structure Analysis ===") + print(f"Summary: {analysis['summary']}") + print(f"\nRecent BOS Events: {analysis['bos_events'][-3:] if analysis['bos_events'] else 'None'}") + print(f"Recent CHoCH Events: {analysis['choch_events'][-3:] if analysis['choch_events'] else 'None'}") + print(f"Valid Order Blocks: {[ob for ob in analysis['order_blocks'] if ob['valid']][-3:]}") + print(f"Unfilled FVGs: {[fvg for fvg in analysis['fair_value_gaps'] if not fvg['filled']][-3:]}") + print(f"Premium/Discount Zone: {analysis['premium_discount_zone']}") diff --git a/src/models/strategies/msa/trainer.py b/src/models/strategies/msa/trainer.py new file mode 100644 index 0000000..48ff8cf --- /dev/null +++ b/src/models/strategies/msa/trainer.py @@ -0,0 +1,701 @@ +""" +MSA (Market Structure Analysis) - Trainer +========================================== +Training utilities for MSA model. + +Features: +- Data preparation with structure labels +- Walk-forward training +- Cross-validation +- Model evaluation and metrics + +Author: ML-Specialist (NEXUS v4.0) +Created: 2026-01-25 +Version: 1.0.0 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime +from loguru import logger +import json + +from .structure_detector import StructureDetector, StructureType +from .feature_engineering import MSAFeatureEngineer, PointOfInterest, POIType +from .model import MSAModel, MSAPrediction, MSAMetrics, BOSDirection + + +@dataclass +class TrainingConfig: + """Configuration for MSA training""" + # Data preparation + swing_order: int = 5 + lookback_periods: int = 50 + forward_periods: int = 20 + + # Label generation + bos_lookforward: int = 30 + poi_reaction_threshold: float = 0.005 + continuation_lookforward: int = 20 + + # Training + train_ratio: float = 0.8 + use_sample_weighting: bool = True + use_gpu: bool = True + use_gnn: bool = False + + # Walk-forward + n_folds: int = 5 + min_train_size: int = 5000 + expanding_window: bool = False + + # Model config + model_config: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + 'swing_order': self.swing_order, + 'lookback_periods': self.lookback_periods, + 'forward_periods': self.forward_periods, + 'bos_lookforward': self.bos_lookforward, + 'poi_reaction_threshold': self.poi_reaction_threshold, + 'continuation_lookforward': self.continuation_lookforward, + 'train_ratio': self.train_ratio, + 'use_sample_weighting': self.use_sample_weighting, + 'use_gpu': self.use_gpu, + 'use_gnn': self.use_gnn, + 'n_folds': self.n_folds, + 'min_train_size': self.min_train_size, + 'expanding_window': self.expanding_window, + 'model_config': self.model_config + } + + +@dataclass +class WalkForwardResult: + """Result from a single walk-forward fold""" + fold_id: int + train_start: int + train_end: int + val_start: int + val_end: int + train_metrics: MSAMetrics + val_metrics: MSAMetrics + model_path: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'fold_id': self.fold_id, + 'train_range': [self.train_start, self.train_end], + 'val_range': [self.val_start, self.val_end], + 'train_metrics': { + 'bos_accuracy': self.train_metrics.bos_accuracy, + 'bos_macro_f1': self.train_metrics.bos_macro_f1, + 'poi_f1': self.train_metrics.poi_f1, + 'continuation_f1': self.train_metrics.continuation_f1 + }, + 'val_metrics': { + 'bos_accuracy': self.val_metrics.bos_accuracy, + 'bos_macro_f1': self.val_metrics.bos_macro_f1, + 'poi_f1': self.val_metrics.poi_f1, + 'continuation_f1': self.val_metrics.continuation_f1 + }, + 'model_path': self.model_path + } + + +class MSATrainer: + """ + Trainer for MSA (Market Structure Analysis) model. + + Handles: + - Data preparation and feature extraction + - Label generation from structure analysis + - Model training with walk-forward validation + - Evaluation and metrics + """ + + def __init__( + self, + config: Optional[TrainingConfig] = None + ): + """ + Initialize MSA Trainer. + + Args: + config: Training configuration + """ + self.config = config or TrainingConfig() + + # Initialize components + self.detector = StructureDetector(swing_order=self.config.swing_order) + self.feature_engineer = MSAFeatureEngineer( + swing_order=self.config.swing_order, + lookback_periods=self.config.lookback_periods + ) + + # Training state + self.model: Optional[MSAModel] = None + self.feature_columns: List[str] = [] + self.training_history: List[Dict] = [] + + logger.info(f"MSATrainer initialized with config: {self.config.to_dict()}") + + def prepare_structure_data( + self, + df: pd.DataFrame + ) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + Prepare features and labels for training. + + Args: + df: OHLCV DataFrame + + Returns: + Tuple of (features DataFrame, labels DataFrame) + """ + logger.info(f"Preparing structure data from {len(df)} samples") + + # Extract features + features = self.feature_engineer.compute_structure_features(df) + self.feature_columns = features.columns.tolist() + + # Generate labels + labels = self._generate_labels(df) + + # Remove rows with NaN + valid_mask = features.notna().all(axis=1) & labels.notna().all(axis=1) + features = features[valid_mask] + labels = labels[valid_mask] + + # Align indices + common_idx = features.index.intersection(labels.index) + features = features.loc[common_idx] + labels = labels.loc[common_idx] + + logger.info(f"Prepared {len(features)} valid samples") + logger.info(f"Label distributions:") + for col in labels.columns: + dist = labels[col].value_counts().to_dict() + logger.info(f" {col}: {dist}") + + return features, labels + + def _generate_labels( + self, + df: pd.DataFrame + ) -> pd.DataFrame: + """ + Generate training labels from structure analysis. + + Labels: + - y_bos: Next BOS direction (0=neutral, 1=bullish, 2=bearish) + - y_poi: POI reaction (0=no, 1=yes) + - y_continuation: Structure continuation (0=reversal, 1=continuation) + """ + labels = pd.DataFrame(index=df.index) + + # Get structure analysis + swing_highs, swing_lows = self.detector.detect_swing_points(df) + classified_highs, classified_lows = self.detector.detect_higher_highs_lower_lows( + swing_highs, swing_lows + ) + bos_events = self.detector.detect_break_of_structure(df, classified_highs, classified_lows) + choch_events = self.detector.detect_change_of_character(df, classified_highs, classified_lows) + fvgs = self.detector.detect_fair_value_gaps(df) + order_blocks = self.detector.detect_order_blocks(df, bos_events) + + close = df['close'].values + high = df['high'].values + low = df['low'].values + + # Initialize label arrays + n = len(df) + y_bos = np.zeros(n, dtype=int) # 0=neutral + y_poi = np.zeros(n, dtype=int) # 0=no reaction + y_continuation = np.zeros(n, dtype=int) # 0=reversal + + # Generate BOS direction labels + # Look forward for next BOS event + bos_indices = {bos.index: bos for bos in bos_events} + + for i in range(n - self.config.bos_lookforward): + # Find next BOS in lookforward window + for j in range(i + 1, min(i + self.config.bos_lookforward + 1, n)): + if j in bos_indices: + bos = bos_indices[j] + if bos.event_type == StructureType.BOS_BULLISH: + y_bos[i] = BOSDirection.BULLISH + elif bos.event_type == StructureType.BOS_BEARISH: + y_bos[i] = BOSDirection.BEARISH + break + + # Generate POI reaction labels + # Check if price reacts at POIs (OBs and FVGs) + pois = self._collect_pois_for_labels(fvgs, order_blocks, classified_highs, classified_lows) + + for i in range(n - self.config.forward_periods): + current_price = close[i] + + for poi in pois: + if poi.index < i and poi.active: + # Check if near POI + dist = abs(current_price - poi.midpoint) / current_price + if dist < 0.005: # Within 0.5% of POI + # Check for reaction in forward window + future_high = high[i + 1:i + self.config.forward_periods + 1].max() + future_low = low[i + 1:i + self.config.forward_periods + 1].min() + + # Bullish reaction (at support) + if current_price <= poi.midpoint: + reaction = (future_high - current_price) / current_price + if reaction >= self.config.poi_reaction_threshold: + y_poi[i] = 1 + break + + # Bearish reaction (at resistance) + if current_price >= poi.midpoint: + reaction = (current_price - future_low) / current_price + if reaction >= self.config.poi_reaction_threshold: + y_poi[i] = 1 + break + + # Generate structure continuation labels + # Check if current structure continues or reverses + choch_indices = {choch.index: choch for choch in choch_events} + + for i in range(n - self.config.continuation_lookforward): + has_choch = False + for j in range(i + 1, min(i + self.config.continuation_lookforward + 1, n)): + if j in choch_indices: + has_choch = True + break + + # 1 = continuation (no CHoCH), 0 = reversal (CHoCH occurs) + y_continuation[i] = 0 if has_choch else 1 + + labels['y_bos'] = y_bos + labels['y_poi'] = y_poi + labels['y_continuation'] = y_continuation + + return labels + + def _collect_pois_for_labels( + self, + fvgs, + order_blocks, + swing_highs, + swing_lows + ) -> List[PointOfInterest]: + """Collect POIs for label generation""" + pois = [] + + for fvg in fvgs: + if not fvg.filled: + pois.append(PointOfInterest( + poi_type=POIType.FAIR_VALUE_GAP, + price_low=fvg.low, + price_high=fvg.high, + index=fvg.index, + strength=1.0, + active=True + )) + + for ob in order_blocks: + if ob.valid: + pois.append(PointOfInterest( + poi_type=POIType.ORDER_BLOCK, + price_low=ob.low, + price_high=ob.high, + index=ob.index, + strength=ob.strength, + active=True + )) + + return pois + + def train( + self, + df: pd.DataFrame, + symbol: str = "UNKNOWN", + save_path: Optional[str] = None, + verbose: bool = True + ) -> MSAMetrics: + """ + Train MSA model on provided data. + + Args: + df: OHLCV DataFrame + symbol: Trading symbol (for logging) + save_path: Path to save trained model + verbose: Print training progress + + Returns: + Training metrics + """ + logger.info(f"Training MSAModel for {symbol} on {len(df)} samples") + + # Prepare data + features, labels = self.prepare_structure_data(df) + + # Split data + train_size = int(len(features) * self.config.train_ratio) + X_train = features.iloc[:train_size] + X_val = features.iloc[train_size:] + y_train = labels.iloc[:train_size] + y_val = labels.iloc[train_size:] + + logger.info(f"Train size: {len(X_train)}, Validation size: {len(X_val)}") + + # Compute sample weights if enabled + sample_weight = None + if self.config.use_sample_weighting: + sample_weight = self._compute_sample_weights(X_train, y_train) + + # Initialize model + self.model = MSAModel( + config=self.config.model_config or None, + use_gpu=self.config.use_gpu, + use_gnn=self.config.use_gnn + ) + + # Train + metrics = self.model.fit( + X_train, + y_train['y_bos'].values, + y_train['y_poi'].values, + y_train['y_continuation'].values, + X_val, + y_val['y_bos'].values, + y_val['y_poi'].values, + y_val['y_continuation'].values, + sample_weight=sample_weight, + verbose=verbose + ) + + # Save model if path provided + if save_path: + self.model.save(save_path) + + # Record training history + self.training_history.append({ + 'timestamp': datetime.now().isoformat(), + 'symbol': symbol, + 'n_samples': len(df), + 'train_size': len(X_train), + 'val_size': len(X_val), + 'metrics': { + 'bos_accuracy': metrics.bos_accuracy, + 'bos_macro_f1': metrics.bos_macro_f1, + 'poi_f1': metrics.poi_f1, + 'continuation_f1': metrics.continuation_f1 + } + }) + + return metrics + + def walk_forward_train( + self, + df: pd.DataFrame, + symbol: str = "UNKNOWN", + n_folds: Optional[int] = None, + save_dir: Optional[str] = None, + verbose: bool = True + ) -> List[WalkForwardResult]: + """ + Train model using walk-forward validation. + + Walk-forward validation trains on expanding/sliding windows + and validates on out-of-sample data, simulating real trading. + + Args: + df: OHLCV DataFrame + symbol: Trading symbol + n_folds: Number of folds (uses config if None) + save_dir: Directory to save fold models + verbose: Print progress + + Returns: + List of WalkForwardResult for each fold + """ + n_folds = n_folds or self.config.n_folds + logger.info(f"Walk-forward training for {symbol} with {n_folds} folds") + + # Prepare all data once + features, labels = self.prepare_structure_data(df) + n_samples = len(features) + + # Calculate fold sizes + step_size = n_samples // (n_folds + 1) + if step_size < self.config.min_train_size: + logger.warning(f"Step size {step_size} < min_train_size {self.config.min_train_size}") + n_folds = max(1, n_samples // self.config.min_train_size - 1) + step_size = n_samples // (n_folds + 1) + logger.info(f"Reduced to {n_folds} folds") + + results = [] + + for fold in range(n_folds): + logger.info(f"\n=== Fold {fold + 1}/{n_folds} ===") + + # Calculate indices + if self.config.expanding_window: + train_start = 0 + else: + train_start = fold * step_size + + train_end = (fold + 1) * step_size + val_start = train_end + val_end = min(val_start + int(step_size * 0.2), n_samples) + + if val_end > n_samples or (train_end - train_start) < self.config.min_train_size: + logger.warning(f"Skipping fold {fold + 1}: insufficient data") + continue + + # Split data + X_train = features.iloc[train_start:train_end] + X_val = features.iloc[val_start:val_end] + y_train = labels.iloc[train_start:train_end] + y_val = labels.iloc[val_start:val_end] + + logger.info(f"Train: [{train_start}:{train_end}] n={len(X_train)}") + logger.info(f"Val: [{val_start}:{val_end}] n={len(X_val)}") + + # Compute sample weights + sample_weight = None + if self.config.use_sample_weighting: + sample_weight = self._compute_sample_weights(X_train, y_train) + + # Initialize model for this fold + model = MSAModel( + config=self.config.model_config or None, + use_gpu=self.config.use_gpu, + use_gnn=self.config.use_gnn + ) + + # Train + train_metrics = model.fit( + X_train, + y_train['y_bos'].values, + y_train['y_poi'].values, + y_train['y_continuation'].values, + X_val, + y_val['y_bos'].values, + y_val['y_poi'].values, + y_val['y_continuation'].values, + sample_weight=sample_weight, + verbose=verbose + ) + + # Calculate validation metrics + val_metrics = model._calculate_metrics( + X_val.values, + y_val['y_bos'].values, + y_val['y_poi'].values, + y_val['y_continuation'].values + ) + + # Save model if directory provided + model_path = None + if save_dir: + model_path = f"{save_dir}/fold_{fold + 1}" + model.save(model_path) + + result = WalkForwardResult( + fold_id=fold + 1, + train_start=train_start, + train_end=train_end, + val_start=val_start, + val_end=val_end, + train_metrics=train_metrics, + val_metrics=val_metrics, + model_path=model_path + ) + results.append(result) + + if verbose: + logger.info(f"Fold {fold + 1} Val Metrics:") + logger.info(f" BOS Accuracy: {val_metrics.bos_accuracy:.2%}") + logger.info(f" BOS Macro F1: {val_metrics.bos_macro_f1:.4f}") + logger.info(f" POI F1: {val_metrics.poi_f1:.4f}") + logger.info(f" Continuation F1: {val_metrics.continuation_f1:.4f}") + + # Print summary + if results: + self._print_walk_forward_summary(results) + + return results + + def _compute_sample_weights( + self, + X: pd.DataFrame, + y: pd.DataFrame + ) -> np.ndarray: + """Compute sample weights based on structure clarity""" + n = len(X) + weights = np.ones(n) + + # Weight samples with clear structure more heavily + if 'bos_net_bias' in X.columns: + # Higher weight for clear directional bias + bos_bias = np.abs(X['bos_net_bias'].values) + weights *= (1 + bos_bias * 0.5) + + if 'structure_trend_bias' in X.columns: + # Higher weight for clear trend + trend_bias = np.abs(X['structure_trend_bias'].values) + weights *= (1 + trend_bias * 0.3) + + # Normalize + weights = weights / weights.mean() + + return weights + + def _print_walk_forward_summary( + self, + results: List[WalkForwardResult] + ): + """Print walk-forward validation summary""" + bos_accs = [r.val_metrics.bos_accuracy for r in results] + bos_f1s = [r.val_metrics.bos_macro_f1 for r in results] + poi_f1s = [r.val_metrics.poi_f1 for r in results] + cont_f1s = [r.val_metrics.continuation_f1 for r in results] + + logger.info("\n=== Walk-Forward Summary ===") + logger.info(f"BOS Accuracy: {np.mean(bos_accs):.2%} (+/- {np.std(bos_accs):.2%})") + logger.info(f"BOS Macro F1: {np.mean(bos_f1s):.4f} (+/- {np.std(bos_f1s):.4f})") + logger.info(f"POI F1: {np.mean(poi_f1s):.4f} (+/- {np.std(poi_f1s):.4f})") + logger.info(f"Continuation F1: {np.mean(cont_f1s):.4f} (+/- {np.std(cont_f1s):.4f})") + + def evaluate_structure_prediction( + self, + y_true: np.ndarray, + y_pred: np.ndarray, + prediction_type: str = 'bos' + ) -> Dict[str, float]: + """ + Evaluate structure predictions. + + Args: + y_true: Ground truth labels + y_pred: Predicted labels + prediction_type: 'bos', 'poi', or 'continuation' + + Returns: + Dictionary of metrics + """ + from sklearn.metrics import ( + accuracy_score, precision_score, recall_score, + f1_score, classification_report + ) + + metrics = { + 'accuracy': accuracy_score(y_true, y_pred) + } + + if prediction_type == 'bos': + metrics['macro_f1'] = f1_score(y_true, y_pred, average='macro', zero_division=0) + metrics['weighted_f1'] = f1_score(y_true, y_pred, average='weighted', zero_division=0) + + # Per-class metrics + for i, name in enumerate(['neutral', 'bullish', 'bearish']): + mask = y_true == i + if mask.any(): + pred_mask = y_pred == i + metrics[f'{name}_precision'] = precision_score( + y_true == i, y_pred == i, zero_division=0 + ) + metrics[f'{name}_recall'] = recall_score( + y_true == i, y_pred == i, zero_division=0 + ) + metrics[f'{name}_f1'] = f1_score( + y_true == i, y_pred == i, zero_division=0 + ) + else: + # Binary classification + metrics['precision'] = precision_score(y_true, y_pred, zero_division=0) + metrics['recall'] = recall_score(y_true, y_pred, zero_division=0) + metrics['f1'] = f1_score(y_true, y_pred, zero_division=0) + + return metrics + + def save_training_history(self, path: str): + """Save training history to JSON""" + with open(path, 'w') as f: + json.dump(self.training_history, f, indent=2) + logger.info(f"Saved training history to {path}") + + def load_model(self, path: str): + """Load a trained model""" + self.model = MSAModel( + config=self.config.model_config or None, + use_gpu=self.config.use_gpu, + use_gnn=self.config.use_gnn + ) + self.model.load(path) + logger.info(f"Loaded model from {path}") + + +if __name__ == "__main__": + # Test MSATrainer + np.random.seed(42) + + # Create sample OHLCV data + n_samples = 2000 + dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min') + + price = 2000.0 + prices = [price] + for i in range(1, n_samples): + trend = 0.5 * np.sin(i / 100) + np.random.randn() * 0.5 + price = price + trend + prices.append(price) + + prices = np.array(prices) + + df = pd.DataFrame({ + 'open': prices + np.random.randn(n_samples) * 0.5, + 'high': prices + abs(np.random.randn(n_samples)) * 2.0, + 'low': prices - abs(np.random.randn(n_samples)) * 2.0, + 'close': prices + np.random.randn(n_samples) * 0.5, + 'volume': np.random.randint(100, 10000, n_samples) + }, index=dates) + + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Create trainer + config = TrainingConfig( + swing_order=5, + n_folds=3, + min_train_size=200, + use_gpu=False + ) + trainer = MSATrainer(config) + + # Simple train + print("=== Simple Training ===") + metrics = trainer.train(df, symbol="TEST", verbose=True) + + # Predictions + if trainer.model: + predictions = trainer.model.predict( + trainer.feature_engineer.compute_structure_features(df.tail(100)) + ) + print(f"\n=== Sample Predictions (last 5) ===") + for pred in predictions[-5:]: + print(f"BOS: {pred.next_bos_direction} ({pred.bos_confidence:.2f}), " + f"POI: {pred.poi_reaction_prob:.2f}, " + f"Cont: {pred.structure_continuation_prob:.2f}") + + # Walk-forward (with smaller dataset for speed) + print("\n=== Walk-Forward Training ===") + wf_results = trainer.walk_forward_train( + df, + symbol="TEST", + n_folds=3, + verbose=True + ) diff --git a/src/models/strategies/mts/__init__.py b/src/models/strategies/mts/__init__.py new file mode 100644 index 0000000..8506d43 --- /dev/null +++ b/src/models/strategies/mts/__init__.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +MTS Strategy - Multi-Timeframe Synthesis +========================================= +Strategy 5 of the ml-engine trading system. + +MTS combines signals from multiple timeframes (5m, 15m, 1h, 4h) using +a hierarchical attention mechanism to produce unified trading signals. + +Key Components: +1. MTSFeatureEngineer: Aggregates and computes features across timeframes +2. HierarchicalAttention: Learns relationships between timeframes +3. MTSModel: Complete model with XGBoost for final prediction +4. MTSTrainer: Training with walk-forward validation + +Outputs: +- Unified direction: -1 (bearish) to 1 (bullish) +- Confidence by alignment: 0 to 1 based on TF agreement +- Optimal entry timeframe: Best TF for trade execution + +Example Usage: + >>> from src.models.strategies.mts import MTSModel, MTSConfig + >>> + >>> # Create model + >>> config = MTSConfig(d_model=128, n_heads=4) + >>> model = MTSModel(config) + >>> + >>> # Train + >>> trainer = MTSTrainer(model) + >>> trainer.train('EURUSD', data=df_5m) + >>> + >>> # Predict + >>> prediction = model.predict(df_5m) + >>> print(f"Direction: {prediction.direction_class}") + >>> print(f"Confidence: {prediction.confidence:.2f}") + >>> print(f"Recommended: {prediction.recommended_action}") + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Feature Engineering +from .feature_engineering import ( + MTSFeatureEngineer, + TimeframeDefinition, +) + +# Hierarchical Attention +from .hierarchical_attention import ( + HierarchicalAttention, + HierarchicalAttentionWithAlignment, + HierarchicalAttentionConfig, + TimeframeEncoder, + CrossTimeframeAttention, + MultiHeadAttentionTF, + ScaledDotProductAttention, +) + +# Model +from .model import ( + MTSModel, + MTSConfig, + MTSPrediction, + MTSHead, +) + +# Trainer +from .trainer import ( + MTSTrainer, + MTSTrainerConfig, + MTSTrainingMetrics, + MTSDataset, + HierarchicalLoss, +) + + +__all__ = [ + # Feature Engineering + 'MTSFeatureEngineer', + 'TimeframeDefinition', + + # Hierarchical Attention + 'HierarchicalAttention', + 'HierarchicalAttentionWithAlignment', + 'HierarchicalAttentionConfig', + 'TimeframeEncoder', + 'CrossTimeframeAttention', + 'MultiHeadAttentionTF', + 'ScaledDotProductAttention', + + # Model + 'MTSModel', + 'MTSConfig', + 'MTSPrediction', + 'MTSHead', + + # Trainer + 'MTSTrainer', + 'MTSTrainerConfig', + 'MTSTrainingMetrics', + 'MTSDataset', + 'HierarchicalLoss', +] + + +# Module version +__version__ = '1.0.0' diff --git a/src/models/strategies/mts/feature_engineering.py b/src/models/strategies/mts/feature_engineering.py new file mode 100644 index 0000000..364ed95 --- /dev/null +++ b/src/models/strategies/mts/feature_engineering.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +""" +MTS Feature Engineering - Multi-Timeframe Synthesis Features +============================================================= +Feature engineering for multi-timeframe analysis. Aggregates base timeframe (5m) +to higher timeframes (15m, 1h, 4h) and computes alignment/conflict scores. + +Timeframe Hierarchy: +- 5m (base): Raw market data +- 15m (3x): Short-term trends +- 1h (12x): Intraday trends +- 4h (48x): Swing trends + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, field +from loguru import logger + + +@dataclass +class TimeframeDefinition: + """Definition of a timeframe for aggregation.""" + name: str + aggregation_factor: int + feature_windows: List[int] = field(default_factory=list) + weight: float = 1.0 + + +class MTSFeatureEngineer: + """ + Multi-Timeframe Synthesis Feature Engineer. + + Aggregates 5-minute base data to higher timeframes and computes + features for each timeframe along with alignment and conflict scores. + + Features computed per timeframe: + - Price returns (close-to-close) + - Range features (high-low, body size) + - Momentum indicators (RSI, MACD-like) + - Volatility measures (ATR, std) + - Volume features (if available) + - Trend strength indicators + + Cross-timeframe features: + - Alignment score: How aligned are signals across TFs + - Conflict score: How conflicting are signals across TFs + - Dominant TF: Which TF has the strongest signal + """ + + TIMEFRAMES = { + '5m': TimeframeDefinition(name='5m', aggregation_factor=1, feature_windows=[12, 24, 48], weight=0.15), + '15m': TimeframeDefinition(name='15m', aggregation_factor=3, feature_windows=[4, 8, 16], weight=0.25), + '1h': TimeframeDefinition(name='1h', aggregation_factor=12, feature_windows=[4, 8, 24], weight=0.35), + '4h': TimeframeDefinition(name='4h', aggregation_factor=48, feature_windows=[3, 6, 12], weight=0.25), + } + + def __init__( + self, + timeframes: Optional[List[str]] = None, + include_volume: bool = True, + feature_lookback: int = 100 + ): + """ + Initialize MTS Feature Engineer. + + Args: + timeframes: List of timeframes to use (default: all) + include_volume: Whether to include volume features + feature_lookback: Lookback period for feature calculation + """ + self.timeframes = timeframes or list(self.TIMEFRAMES.keys()) + self.include_volume = include_volume + self.feature_lookback = feature_lookback + + # Validate timeframes + for tf in self.timeframes: + if tf not in self.TIMEFRAMES: + raise ValueError(f"Unknown timeframe: {tf}") + + logger.info(f"Initialized MTSFeatureEngineer with timeframes: {self.timeframes}") + + def aggregate_to_timeframe( + self, + df_5m: pd.DataFrame, + target_tf: str + ) -> pd.DataFrame: + """ + Aggregate 5-minute data to a target timeframe. + + Args: + df_5m: DataFrame with 5-minute OHLCV data + target_tf: Target timeframe ('5m', '15m', '1h', '4h') + + Returns: + Aggregated DataFrame + """ + if target_tf not in self.TIMEFRAMES: + raise ValueError(f"Unknown timeframe: {target_tf}") + + tf_def = self.TIMEFRAMES[target_tf] + factor = tf_def.aggregation_factor + + if factor == 1: + return df_5m.copy() + + # Ensure we have the required columns + required_cols = ['open', 'high', 'low', 'close'] + for col in required_cols: + if col not in df_5m.columns: + raise ValueError(f"Missing required column: {col}") + + n_rows = len(df_5m) + n_aggregated = n_rows // factor + + if n_aggregated == 0: + logger.warning(f"Not enough data to aggregate to {target_tf}") + return pd.DataFrame() + + # Truncate to exact multiple of factor + df_truncated = df_5m.iloc[-(n_aggregated * factor):].copy() + + # Reshape for aggregation + idx = np.arange(len(df_truncated)) // factor + + aggregated_data = { + 'open': df_truncated.groupby(idx)['open'].first().values, + 'high': df_truncated.groupby(idx)['high'].max().values, + 'low': df_truncated.groupby(idx)['low'].min().values, + 'close': df_truncated.groupby(idx)['close'].last().values, + } + + # Volume if available + if 'volume' in df_5m.columns and self.include_volume: + aggregated_data['volume'] = df_truncated.groupby(idx)['volume'].sum().values + + # Create timestamp index + if isinstance(df_truncated.index, pd.DatetimeIndex): + timestamps = df_truncated.groupby(idx).apply(lambda x: x.index[-1]).values + df_agg = pd.DataFrame(aggregated_data, index=timestamps) + else: + df_agg = pd.DataFrame(aggregated_data) + + return df_agg + + def compute_tf_features( + self, + df: pd.DataFrame, + tf: str + ) -> pd.DataFrame: + """ + Compute standard features for a single timeframe. + + Args: + df: OHLCV DataFrame + tf: Timeframe name (for naming features) + + Returns: + DataFrame with computed features + """ + if len(df) < 10: + return pd.DataFrame() + + tf_def = self.TIMEFRAMES.get(tf, TimeframeDefinition(name=tf, aggregation_factor=1, feature_windows=[4, 8, 16])) + windows = tf_def.feature_windows + + features = {} + + # Basic price features + close = df['close'].values + high = df['high'].values + low = df['low'].values + open_price = df['open'].values + + # Returns + returns = np.zeros(len(close)) + returns[1:] = (close[1:] - close[:-1]) / close[:-1] + features[f'{tf}_return'] = returns + + # Log returns + log_returns = np.zeros(len(close)) + log_returns[1:] = np.log(close[1:] / close[:-1]) + features[f'{tf}_log_return'] = log_returns + + # Range features + range_hl = (high - low) / close + features[f'{tf}_range_hl'] = range_hl + + body_size = np.abs(close - open_price) / close + features[f'{tf}_body_size'] = body_size + + # Direction (1 = bullish, -1 = bearish) + direction = np.sign(close - open_price) + features[f'{tf}_direction'] = direction + + # Upper/lower wick ratios + upper_wick = np.where(close >= open_price, high - close, high - open_price) + lower_wick = np.where(close >= open_price, open_price - low, close - low) + total_range = high - low + 1e-10 + + features[f'{tf}_upper_wick_ratio'] = upper_wick / total_range + features[f'{tf}_lower_wick_ratio'] = lower_wick / total_range + + # Window-based features + for window in windows: + if len(close) < window: + continue + + # SMA + sma = self._rolling_mean(close, window) + features[f'{tf}_sma_{window}'] = sma + + # Price distance from SMA + sma_dist = (close - sma) / (sma + 1e-10) + features[f'{tf}_sma_dist_{window}'] = sma_dist + + # Standard deviation + std = self._rolling_std(close, window) + features[f'{tf}_std_{window}'] = std / (close + 1e-10) + + # ATR + atr = self._compute_atr(high, low, close, window) + features[f'{tf}_atr_{window}'] = atr / (close + 1e-10) + + # RSI + rsi = self._compute_rsi(close, window) + features[f'{tf}_rsi_{window}'] = rsi + + # Momentum + momentum = np.zeros(len(close)) + momentum[window:] = (close[window:] - close[:-window]) / close[:-window] + features[f'{tf}_momentum_{window}'] = momentum + + # Rate of Change (ROC) + roc = np.zeros(len(close)) + roc[window:] = 100 * (close[window:] - close[:-window]) / close[:-window] + features[f'{tf}_roc_{window}'] = roc + + # Return sum (cumulative return over window) + return_sum = self._rolling_sum(returns, window) + features[f'{tf}_return_sum_{window}'] = return_sum + + # Highest high / lowest low + rolling_high = self._rolling_max(high, window) + rolling_low = self._rolling_min(low, window) + + # Price position in range + range_position = (close - rolling_low) / (rolling_high - rolling_low + 1e-10) + features[f'{tf}_range_position_{window}'] = range_position + + # MACD-like features (fast EMA - slow EMA) + if len(close) >= max(windows): + fast_window = min(windows) + slow_window = max(windows) + + fast_ema = self._compute_ema(close, fast_window) + slow_ema = self._compute_ema(close, slow_window) + + macd = (fast_ema - slow_ema) / close + features[f'{tf}_macd'] = macd + + # MACD signal line + macd_signal = self._compute_ema(macd, min(9, len(macd) // 2)) + features[f'{tf}_macd_signal'] = macd_signal + + # MACD histogram + features[f'{tf}_macd_hist'] = macd - macd_signal + + # Trend strength (ADX-like) + adx = self._compute_adx(high, low, close, min(14, len(close) // 2)) + features[f'{tf}_adx'] = adx + + # Volume features + if 'volume' in df.columns and self.include_volume: + volume = df['volume'].values + + features[f'{tf}_volume_norm'] = volume / (self._rolling_mean(volume, 20) + 1e-10) + + for window in windows: + if len(volume) >= window: + vol_sma = self._rolling_mean(volume, window) + features[f'{tf}_vol_sma_{window}'] = vol_sma / (self._rolling_mean(volume, max(windows)) + 1e-10) + + # Convert to DataFrame + df_features = pd.DataFrame(features, index=df.index if hasattr(df, 'index') else None) + + return df_features + + def compute_tf_alignment_score( + self, + features_dict: Dict[str, pd.DataFrame] + ) -> np.ndarray: + """ + Compute alignment score across timeframes. + + Alignment is high when all timeframes agree on direction/trend. + + Args: + features_dict: Dictionary of {timeframe: features_df} + + Returns: + Array of alignment scores (0 to 1) + """ + if len(features_dict) < 2: + return np.ones(len(list(features_dict.values())[0])) + + # Get minimum length across all timeframes + min_len = min(len(df) for df in features_dict.values() if len(df) > 0) + + if min_len == 0: + return np.array([]) + + # Collect direction signals from each timeframe + directions = [] + weights = [] + + for tf, df in features_dict.items(): + if len(df) == 0: + continue + + tf_def = self.TIMEFRAMES.get(tf, TimeframeDefinition(name=tf, aggregation_factor=1, weight=0.25)) + + # Get direction feature + dir_col = f'{tf}_direction' + if dir_col in df.columns: + # Align to minimum length (take last min_len values) + dir_values = df[dir_col].values[-min_len:] + directions.append(dir_values) + weights.append(tf_def.weight) + else: + # Use momentum as proxy + momentum_cols = [c for c in df.columns if 'momentum' in c or 'return' in c] + if momentum_cols: + momentum = df[momentum_cols[0]].values[-min_len:] + directions.append(np.sign(momentum)) + weights.append(tf_def.weight) + + if len(directions) < 2: + return np.ones(min_len) + + directions = np.array(directions) + weights = np.array(weights) + weights = weights / weights.sum() + + # Compute alignment as weighted agreement + alignment_scores = np.zeros(min_len) + + for i in range(min_len): + dir_at_i = directions[:, i] + + # Count agreements + bullish = np.sum(weights[dir_at_i > 0]) + bearish = np.sum(weights[dir_at_i < 0]) + + # Alignment is max of bullish/bearish agreement + alignment_scores[i] = max(bullish, bearish) + + return alignment_scores + + def compute_conflict_score( + self, + features_dict: Dict[str, pd.DataFrame] + ) -> np.ndarray: + """ + Compute conflict score across timeframes. + + Conflict is high when timeframes disagree strongly. + + Args: + features_dict: Dictionary of {timeframe: features_df} + + Returns: + Array of conflict scores (0 to 1) + """ + alignment = self.compute_tf_alignment_score(features_dict) + + # Conflict is inverse of alignment, but normalized + # High alignment = low conflict + conflict = 1.0 - alignment + + # Scale to emphasize strong conflicts + conflict = np.power(conflict, 0.5) + + return conflict + + def compute_dominant_tf( + self, + features_dict: Dict[str, pd.DataFrame] + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Compute the dominant timeframe at each point. + + The dominant TF is the one with the strongest directional signal. + + Args: + features_dict: Dictionary of {timeframe: features_df} + + Returns: + Tuple of (dominant_tf_indices, signal_strengths) + """ + if len(features_dict) == 0: + return np.array([]), np.array([]) + + min_len = min(len(df) for df in features_dict.values() if len(df) > 0) + + if min_len == 0: + return np.array([]), np.array([]) + + tf_names = list(features_dict.keys()) + signal_matrix = np.zeros((len(tf_names), min_len)) + + for i, (tf, df) in enumerate(features_dict.items()): + if len(df) == 0: + continue + + # Compute signal strength using multiple indicators + strength = np.zeros(min_len) + count = 0 + + # RSI deviation from neutral + rsi_cols = [c for c in df.columns if 'rsi' in c] + for col in rsi_cols: + rsi = df[col].values[-min_len:] + strength += np.abs(rsi - 50) / 50 + count += 1 + + # Momentum strength + momentum_cols = [c for c in df.columns if 'momentum' in c] + for col in momentum_cols: + mom = df[col].values[-min_len:] + # Normalize momentum + mom_norm = np.abs(mom) / (np.std(mom) + 1e-10) + strength += np.clip(mom_norm, 0, 3) / 3 + count += 1 + + # MACD histogram + macd_hist_col = f'{tf}_macd_hist' + if macd_hist_col in df.columns: + macd_hist = df[macd_hist_col].values[-min_len:] + macd_norm = np.abs(macd_hist) / (np.std(macd_hist) + 1e-10) + strength += np.clip(macd_norm, 0, 3) / 3 + count += 1 + + # ADX (trend strength) + adx_col = f'{tf}_adx' + if adx_col in df.columns: + adx = df[adx_col].values[-min_len:] + strength += adx / 100 + count += 1 + + if count > 0: + signal_matrix[i] = strength / count + + # Apply timeframe weights + for i, tf in enumerate(tf_names): + tf_def = self.TIMEFRAMES.get(tf, TimeframeDefinition(name=tf, aggregation_factor=1, weight=0.25)) + signal_matrix[i] *= tf_def.weight + + # Find dominant TF at each point + dominant_indices = np.argmax(signal_matrix, axis=0) + signal_strengths = np.max(signal_matrix, axis=0) + + return dominant_indices, signal_strengths + + def prepare_hierarchical_input( + self, + dfs_dict: Dict[str, pd.DataFrame] + ) -> Dict[str, np.ndarray]: + """ + Prepare input for hierarchical attention model. + + Args: + dfs_dict: Dictionary of {timeframe: ohlcv_df} + + Returns: + Dictionary with features, alignment, conflict, and dominant TF + """ + # Compute features for each timeframe + features_dict = {} + for tf, df in dfs_dict.items(): + if tf in self.timeframes: + features_dict[tf] = self.compute_tf_features(df, tf) + + # Find common length (use last N samples where all TFs have data) + min_len = min(len(df) for df in features_dict.values() if len(df) > 0) + + # Align all features to same length + aligned_features = {} + for tf, df in features_dict.items(): + aligned_features[tf] = df.iloc[-min_len:].values + + # Compute cross-TF features + alignment = self.compute_tf_alignment_score(features_dict) + conflict = self.compute_conflict_score(features_dict) + dominant_tf, signal_strength = self.compute_dominant_tf(features_dict) + + return { + 'tf_features': aligned_features, + 'alignment': alignment[-min_len:] if len(alignment) > min_len else alignment, + 'conflict': conflict[-min_len:] if len(conflict) > min_len else conflict, + 'dominant_tf': dominant_tf[-min_len:] if len(dominant_tf) > min_len else dominant_tf, + 'signal_strength': signal_strength[-min_len:] if len(signal_strength) > min_len else signal_strength, + 'feature_names': {tf: list(features_dict[tf].columns) for tf in features_dict}, + 'length': min_len + } + + def prepare_training_data( + self, + df_5m: pd.DataFrame, + target_horizon: int = 12, + return_threshold: float = 0.001 + ) -> Tuple[Dict[str, np.ndarray], np.ndarray, np.ndarray]: + """ + Prepare training data with features and targets. + + Args: + df_5m: Base 5-minute OHLCV data + target_horizon: Forward horizon in 5m bars for target + return_threshold: Threshold for direction classification + + Returns: + Tuple of (hierarchical_input, direction_targets, magnitude_targets) + """ + # Aggregate to all timeframes + dfs_dict = {} + for tf in self.timeframes: + dfs_dict[tf] = self.aggregate_to_timeframe(df_5m, tf) + + # Prepare hierarchical input + hierarchical_input = self.prepare_hierarchical_input(dfs_dict) + + # Compute targets from 5m data + close = df_5m['close'].values + + # Forward returns + forward_returns = np.zeros(len(close)) + if target_horizon < len(close): + forward_returns[:-target_horizon] = ( + close[target_horizon:] - close[:-target_horizon] + ) / close[:-target_horizon] + + # Align targets to feature length + min_len = hierarchical_input['length'] + forward_returns = forward_returns[-min_len:] + + # Direction targets (1=up, 0=down) + direction_targets = (forward_returns > return_threshold).astype(np.float32) + + # Magnitude targets (absolute return) + magnitude_targets = np.abs(forward_returns).astype(np.float32) + + return hierarchical_input, direction_targets, magnitude_targets + + # ==================== Helper Methods ==================== + + def _rolling_mean(self, arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling mean.""" + result = np.full(len(arr), np.nan) + if len(arr) >= window: + cumsum = np.cumsum(np.insert(arr, 0, 0)) + result[window-1:] = (cumsum[window:] - cumsum[:-window]) / window + result[:window-1] = np.nan + return result + + def _rolling_std(self, arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling standard deviation.""" + result = np.full(len(arr), np.nan) + if len(arr) >= window: + for i in range(window - 1, len(arr)): + result[i] = np.std(arr[i - window + 1:i + 1]) + return result + + def _rolling_sum(self, arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling sum.""" + result = np.full(len(arr), np.nan) + if len(arr) >= window: + cumsum = np.cumsum(np.insert(arr, 0, 0)) + result[window-1:] = cumsum[window:] - cumsum[:-window] + return result + + def _rolling_max(self, arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling maximum.""" + result = np.full(len(arr), np.nan) + for i in range(window - 1, len(arr)): + result[i] = np.max(arr[i - window + 1:i + 1]) + return result + + def _rolling_min(self, arr: np.ndarray, window: int) -> np.ndarray: + """Compute rolling minimum.""" + result = np.full(len(arr), np.nan) + for i in range(window - 1, len(arr)): + result[i] = np.min(arr[i - window + 1:i + 1]) + return result + + def _compute_ema(self, arr: np.ndarray, span: int) -> np.ndarray: + """Compute exponential moving average.""" + alpha = 2 / (span + 1) + result = np.zeros(len(arr)) + result[0] = arr[0] + for i in range(1, len(arr)): + result[i] = alpha * arr[i] + (1 - alpha) * result[i - 1] + return result + + def _compute_atr( + self, + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + window: int + ) -> np.ndarray: + """Compute Average True Range.""" + tr = np.zeros(len(close)) + tr[0] = high[0] - low[0] + + for i in range(1, len(close)): + hl = high[i] - low[i] + hc = abs(high[i] - close[i - 1]) + lc = abs(low[i] - close[i - 1]) + tr[i] = max(hl, hc, lc) + + return self._rolling_mean(tr, window) + + def _compute_rsi(self, close: np.ndarray, window: int) -> np.ndarray: + """Compute Relative Strength Index.""" + delta = np.diff(close) + gain = np.where(delta > 0, delta, 0) + loss = np.where(delta < 0, -delta, 0) + + avg_gain = np.zeros(len(close)) + avg_loss = np.zeros(len(close)) + + if len(close) > window: + avg_gain[window] = np.mean(gain[:window]) + avg_loss[window] = np.mean(loss[:window]) + + for i in range(window + 1, len(close)): + avg_gain[i] = (avg_gain[i - 1] * (window - 1) + gain[i - 1]) / window + avg_loss[i] = (avg_loss[i - 1] * (window - 1) + loss[i - 1]) / window + + rs = avg_gain / (avg_loss + 1e-10) + rsi = 100 - (100 / (1 + rs)) + rsi[:window] = 50 # Neutral for initial period + + return rsi + + def _compute_adx( + self, + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + window: int + ) -> np.ndarray: + """Compute Average Directional Index.""" + if len(close) < window * 2: + return np.full(len(close), 25.0) # Neutral ADX + + # Plus and Minus Directional Movement + plus_dm = np.zeros(len(close)) + minus_dm = np.zeros(len(close)) + + for i in range(1, len(close)): + up_move = high[i] - high[i - 1] + down_move = low[i - 1] - low[i] + + if up_move > down_move and up_move > 0: + plus_dm[i] = up_move + if down_move > up_move and down_move > 0: + minus_dm[i] = down_move + + # True Range + tr = np.zeros(len(close)) + tr[0] = high[0] - low[0] + for i in range(1, len(close)): + tr[i] = max( + high[i] - low[i], + abs(high[i] - close[i - 1]), + abs(low[i] - close[i - 1]) + ) + + # Smoothed values + atr = self._compute_ema(tr, window) + plus_di = 100 * self._compute_ema(plus_dm, window) / (atr + 1e-10) + minus_di = 100 * self._compute_ema(minus_dm, window) / (atr + 1e-10) + + # DX and ADX + dx = 100 * np.abs(plus_di - minus_di) / (plus_di + minus_di + 1e-10) + adx = self._compute_ema(dx, window) + + return adx + + +if __name__ == "__main__": + # Test MTSFeatureEngineer + np.random.seed(42) + + # Create sample 5m OHLCV data + n_samples = 1000 + + # Simulate price data with trend and noise + base_price = 100.0 + returns = np.random.randn(n_samples) * 0.001 + 0.0001 # Slight upward bias + close = base_price * np.cumprod(1 + returns) + + high = close * (1 + np.abs(np.random.randn(n_samples) * 0.002)) + low = close * (1 - np.abs(np.random.randn(n_samples) * 0.002)) + open_price = close * (1 + np.random.randn(n_samples) * 0.001) + volume = np.random.randint(1000, 10000, n_samples) + + df_5m = pd.DataFrame({ + 'open': open_price, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume + }) + + # Initialize feature engineer + engineer = MTSFeatureEngineer() + + # Test aggregation + print("Testing timeframe aggregation:") + for tf in ['5m', '15m', '1h', '4h']: + df_agg = engineer.aggregate_to_timeframe(df_5m, tf) + print(f" {tf}: {len(df_agg)} bars") + + # Test feature computation + print("\nTesting feature computation:") + for tf in ['5m', '15m', '1h']: + df_agg = engineer.aggregate_to_timeframe(df_5m, tf) + features = engineer.compute_tf_features(df_agg, tf) + print(f" {tf}: {features.shape[1]} features") + + # Test hierarchical input preparation + print("\nTesting hierarchical input:") + dfs_dict = {tf: engineer.aggregate_to_timeframe(df_5m, tf) for tf in ['5m', '15m', '1h', '4h']} + hierarchical = engineer.prepare_hierarchical_input(dfs_dict) + + print(f" Aligned length: {hierarchical['length']}") + print(f" Alignment score range: [{hierarchical['alignment'].min():.3f}, {hierarchical['alignment'].max():.3f}]") + print(f" Conflict score range: [{hierarchical['conflict'].min():.3f}, {hierarchical['conflict'].max():.3f}]") + print(f" Features per TF: {[len(v) for v in hierarchical['feature_names'].values()]}") + + print("\nMTSFeatureEngineer test complete!") diff --git a/src/models/strategies/mts/hierarchical_attention.py b/src/models/strategies/mts/hierarchical_attention.py new file mode 100644 index 0000000..7c1bde9 --- /dev/null +++ b/src/models/strategies/mts/hierarchical_attention.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python3 +""" +Hierarchical Attention Network for Multi-Timeframe Synthesis +============================================================= +Implements a hierarchical attention mechanism that processes features +from multiple timeframes and learns to weight their contributions. + +Architecture: +1. Per-TF Attention: Self-attention within each timeframe +2. Cross-TF Attention: Attention across timeframes +3. TF Weighting: Learned weights per timeframe +4. Unified Representation: Final merged representation + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import math +from typing import Dict, List, Optional, Tuple, Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from dataclasses import dataclass +from loguru import logger + + +@dataclass +class HierarchicalAttentionConfig: + """Configuration for Hierarchical Attention Network.""" + d_model: int = 128 + n_heads: int = 4 + d_k: int = 32 + d_v: int = 32 + d_ff: int = 256 + n_layers: int = 2 + dropout: float = 0.1 + max_seq_len: int = 256 + timeframes: Tuple[str, ...] = ('5m', '15m', '1h', '4h') + use_cross_tf_attention: bool = True + use_learnable_tf_weights: bool = True + + +class ScaledDotProductAttention(nn.Module): + """Scaled dot-product attention mechanism.""" + + def __init__(self, d_k: int, dropout: float = 0.1): + super().__init__() + self.scale = math.sqrt(d_k) + self.dropout = nn.Dropout(dropout) + + def forward( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + mask: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Compute scaled dot-product attention. + + Args: + q: Query (batch, n_heads, seq_len_q, d_k) + k: Key (batch, n_heads, seq_len_k, d_k) + v: Value (batch, n_heads, seq_len_k, d_v) + mask: Optional attention mask + + Returns: + output: Attended values + attention_weights: Attention distribution + """ + scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale + + if mask is not None: + scores = scores.masked_fill(mask == 0, float('-inf')) + + attention_weights = F.softmax(scores, dim=-1) + attention_weights = self.dropout(attention_weights) + + output = torch.matmul(attention_weights, v) + + return output, attention_weights + + +class MultiHeadAttentionTF(nn.Module): + """Multi-head attention for a single timeframe.""" + + def __init__( + self, + d_model: int, + n_heads: int, + d_k: int, + d_v: int, + dropout: float = 0.1 + ): + super().__init__() + + self.d_model = d_model + self.n_heads = n_heads + self.d_k = d_k + self.d_v = d_v + + # Linear projections + self.w_q = nn.Linear(d_model, n_heads * d_k, bias=False) + self.w_k = nn.Linear(d_model, n_heads * d_k, bias=False) + self.w_v = nn.Linear(d_model, n_heads * d_v, bias=False) + self.w_o = nn.Linear(n_heads * d_v, d_model, bias=False) + + self.attention = ScaledDotProductAttention(d_k, dropout) + self.dropout = nn.Dropout(dropout) + + self._init_weights() + + def _init_weights(self): + """Initialize weights with Xavier uniform.""" + for module in [self.w_q, self.w_k, self.w_v, self.w_o]: + nn.init.xavier_uniform_(module.weight) + + def forward( + self, + x: torch.Tensor, + mask: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Forward pass for self-attention. + + Args: + x: Input tensor (batch, seq_len, d_model) + mask: Optional attention mask + + Returns: + output: Attended output (batch, seq_len, d_model) + attention_weights: Attention weights (batch, n_heads, seq_len, seq_len) + """ + batch_size, seq_len, _ = x.shape + + # Project to Q, K, V + q = self.w_q(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2) + k = self.w_k(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2) + v = self.w_v(x).view(batch_size, seq_len, self.n_heads, self.d_v).transpose(1, 2) + + # Apply attention + attended, attention_weights = self.attention(q, k, v, mask) + + # Concatenate heads and project + attended = attended.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) + output = self.w_o(attended) + output = self.dropout(output) + + return output, attention_weights + + +class CrossTimeframeAttention(nn.Module): + """ + Cross-timeframe attention module. + + Allows each timeframe to attend to features from other timeframes, + learning dependencies and relationships between different time scales. + """ + + def __init__( + self, + d_model: int, + n_heads: int, + n_timeframes: int, + dropout: float = 0.1 + ): + super().__init__() + + self.d_model = d_model + self.n_heads = n_heads + self.n_timeframes = n_timeframes + self.d_k = d_model // n_heads + + # Projections for cross-attention + self.q_proj = nn.Linear(d_model, d_model) + self.k_proj = nn.Linear(d_model, d_model) + self.v_proj = nn.Linear(d_model, d_model) + self.out_proj = nn.Linear(d_model, d_model) + + self.scale = math.sqrt(self.d_k) + self.dropout = nn.Dropout(dropout) + + # Layer norm for each timeframe + self.layer_norms = nn.ModuleList([ + nn.LayerNorm(d_model) for _ in range(n_timeframes) + ]) + + self._init_weights() + + def _init_weights(self): + """Initialize weights.""" + for module in [self.q_proj, self.k_proj, self.v_proj, self.out_proj]: + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + def forward( + self, + tf_representations: Dict[str, torch.Tensor] + ) -> Dict[str, torch.Tensor]: + """ + Apply cross-timeframe attention. + + Args: + tf_representations: Dictionary of {timeframe: tensor} + Each tensor has shape (batch, d_model) + + Returns: + Updated representations with cross-TF information + """ + tf_names = list(tf_representations.keys()) + batch_size = list(tf_representations.values())[0].shape[0] + + # Stack all TF representations: (batch, n_tf, d_model) + stacked = torch.stack([tf_representations[tf] for tf in tf_names], dim=1) + + # Project to Q, K, V + q = self.q_proj(stacked) # (batch, n_tf, d_model) + k = self.k_proj(stacked) + v = self.v_proj(stacked) + + # Reshape for multi-head attention + q = q.view(batch_size, len(tf_names), self.n_heads, self.d_k).transpose(1, 2) + k = k.view(batch_size, len(tf_names), self.n_heads, self.d_k).transpose(1, 2) + v = v.view(batch_size, len(tf_names), self.n_heads, self.d_k).transpose(1, 2) + + # Compute attention scores + scores = torch.matmul(q, k.transpose(-2, -1)) / self.scale + attention_weights = F.softmax(scores, dim=-1) + attention_weights = self.dropout(attention_weights) + + # Apply attention + attended = torch.matmul(attention_weights, v) + + # Reshape back + attended = attended.transpose(1, 2).contiguous().view(batch_size, len(tf_names), -1) + + # Output projection + output = self.out_proj(attended) + + # Add residual and apply layer norm + result = {} + for i, tf in enumerate(tf_names): + residual = tf_representations[tf] + updated = self.layer_norms[i](residual + output[:, i]) + result[tf] = updated + + return result + + +class TimeframeEncoder(nn.Module): + """ + Encoder for a single timeframe. + + Processes sequence of features and produces a fixed-size representation. + """ + + def __init__( + self, + input_dim: int, + d_model: int, + n_heads: int, + d_ff: int, + n_layers: int, + dropout: float = 0.1, + max_seq_len: int = 256 + ): + super().__init__() + + self.d_model = d_model + + # Input projection + self.input_proj = nn.Linear(input_dim, d_model) + + # Positional encoding + self.pos_encoding = nn.Parameter(torch.randn(1, max_seq_len, d_model) * 0.02) + + # Transformer encoder layers + self.layers = nn.ModuleList() + for _ in range(n_layers): + self.layers.append(nn.ModuleDict({ + 'attention': MultiHeadAttentionTF(d_model, n_heads, d_model // n_heads, d_model // n_heads, dropout), + 'norm1': nn.LayerNorm(d_model), + 'ff': nn.Sequential( + nn.Linear(d_model, d_ff), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(d_ff, d_model), + nn.Dropout(dropout) + ), + 'norm2': nn.LayerNorm(d_model) + })) + + # Global pooling + self.global_pool = nn.Sequential( + nn.AdaptiveAvgPool1d(1), + ) + + # Output projection + self.output_proj = nn.Linear(d_model, d_model) + + def forward( + self, + x: torch.Tensor, + mask: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Encode timeframe features. + + Args: + x: Input features (batch, seq_len, input_dim) + mask: Optional attention mask + + Returns: + representation: Fixed-size representation (batch, d_model) + sequence_output: Full sequence output (batch, seq_len, d_model) + """ + batch_size, seq_len, _ = x.shape + + # Project input + x = self.input_proj(x) + + # Add positional encoding + x = x + self.pos_encoding[:, :seq_len, :] + + # Apply transformer layers + for layer in self.layers: + # Self-attention with residual + attended, _ = layer['attention'](x, mask) + x = layer['norm1'](x + attended) + + # Feed-forward with residual + ff_out = layer['ff'](x) + x = layer['norm2'](x + ff_out) + + sequence_output = x + + # Global pooling for representation + pooled = self.global_pool(x.transpose(1, 2)).squeeze(-1) # (batch, d_model) + + # Output projection + representation = self.output_proj(pooled) + + return representation, sequence_output + + +class HierarchicalAttention(nn.Module): + """ + Hierarchical Attention Network for Multi-Timeframe Synthesis. + + Architecture: + 1. TimeframeEncoder for each TF -> per-TF representations + 2. Cross-TF Attention -> enriched representations + 3. Learnable TF weights -> weighted combination + 4. Unified representation for downstream tasks + + Input: Dictionary of {timeframe: features} + Output: Unified representation tensor + """ + + def __init__( + self, + config: HierarchicalAttentionConfig, + input_dims: Optional[Dict[str, int]] = None + ): + """ + Initialize Hierarchical Attention. + + Args: + config: Model configuration + input_dims: Dictionary of {timeframe: feature_dim} + """ + super().__init__() + + self.config = config + self.timeframes = config.timeframes + + # Default input dimensions if not provided + default_dim = 64 + self.input_dims = input_dims or {tf: default_dim for tf in self.timeframes} + + # Per-timeframe encoders + self.tf_encoders = nn.ModuleDict({ + tf: TimeframeEncoder( + input_dim=self.input_dims.get(tf, default_dim), + d_model=config.d_model, + n_heads=config.n_heads, + d_ff=config.d_ff, + n_layers=config.n_layers, + dropout=config.dropout, + max_seq_len=config.max_seq_len + ) + for tf in self.timeframes + }) + + # Cross-TF attention + if config.use_cross_tf_attention: + self.cross_tf_attention = CrossTimeframeAttention( + d_model=config.d_model, + n_heads=config.n_heads, + n_timeframes=len(self.timeframes), + dropout=config.dropout + ) + else: + self.cross_tf_attention = None + + # Learnable TF weights + if config.use_learnable_tf_weights: + self.tf_weights = nn.Parameter(torch.ones(len(self.timeframes))) + else: + self.register_buffer('tf_weights', torch.ones(len(self.timeframes))) + + # Final fusion layer + self.fusion = nn.Sequential( + nn.Linear(config.d_model * len(self.timeframes), config.d_model * 2), + nn.GELU(), + nn.Dropout(config.dropout), + nn.Linear(config.d_model * 2, config.d_model), + nn.LayerNorm(config.d_model) + ) + + # Output layer + self.output_proj = nn.Linear(config.d_model, config.d_model) + + logger.info(f"Initialized HierarchicalAttention with {len(self.timeframes)} timeframes") + + def forward( + self, + tf_features: Dict[str, torch.Tensor], + return_tf_representations: bool = False + ) -> torch.Tensor: + """ + Forward pass of hierarchical attention. + + Args: + tf_features: Dictionary of {timeframe: features} + Each features tensor has shape (batch, seq_len, feature_dim) + return_tf_representations: If True, also return per-TF representations + + Returns: + unified_representation: (batch, d_model) + tf_representations (optional): Dictionary of per-TF representations + """ + # Encode each timeframe + tf_representations = {} + tf_sequences = {} + + for tf in self.timeframes: + if tf not in tf_features: + raise ValueError(f"Missing features for timeframe: {tf}") + + features = tf_features[tf] + + # Handle numpy arrays + if not isinstance(features, torch.Tensor): + features = torch.tensor(features, dtype=torch.float32) + + # Ensure 3D: (batch, seq_len, features) + if features.dim() == 2: + features = features.unsqueeze(0) + + representation, sequence = self.tf_encoders[tf](features) + tf_representations[tf] = representation + tf_sequences[tf] = sequence + + # Apply cross-TF attention if enabled + if self.cross_tf_attention is not None: + tf_representations = self.cross_tf_attention(tf_representations) + + # Apply learnable weights + weights = F.softmax(self.tf_weights, dim=0) + + weighted_reps = [] + for i, tf in enumerate(self.timeframes): + weighted_reps.append(tf_representations[tf] * weights[i]) + + # Concatenate all representations + concatenated = torch.cat(weighted_reps, dim=-1) + + # Apply fusion + fused = self.fusion(concatenated) + + # Output projection + unified = self.output_proj(fused) + + if return_tf_representations: + return unified, tf_representations + return unified + + def get_tf_contributions(self) -> Dict[str, float]: + """ + Get the learned contribution of each timeframe. + + Returns: + Dictionary of {timeframe: weight} + """ + weights = F.softmax(self.tf_weights, dim=0) + return {tf: float(weights[i]) for i, tf in enumerate(self.timeframes)} + + def get_attention_weights( + self, + tf_features: Dict[str, torch.Tensor] + ) -> Dict[str, torch.Tensor]: + """ + Get attention weights from each timeframe encoder. + + Args: + tf_features: Dictionary of {timeframe: features} + + Returns: + Dictionary of attention weights per timeframe + """ + attention_weights = {} + + for tf in self.timeframes: + if tf not in tf_features: + continue + + features = tf_features[tf] + if not isinstance(features, torch.Tensor): + features = torch.tensor(features, dtype=torch.float32) + if features.dim() == 2: + features = features.unsqueeze(0) + + # Get attention from first layer + encoder = self.tf_encoders[tf] + x = encoder.input_proj(features) + x = x + encoder.pos_encoding[:, :features.shape[1], :] + + _, attn = encoder.layers[0]['attention'](x) + attention_weights[tf] = attn + + return attention_weights + + +class HierarchicalAttentionWithAlignment(HierarchicalAttention): + """ + Extended Hierarchical Attention with alignment/conflict handling. + + Incorporates alignment and conflict scores into the attention mechanism. + """ + + def __init__( + self, + config: HierarchicalAttentionConfig, + input_dims: Optional[Dict[str, int]] = None + ): + super().__init__(config, input_dims) + + # Alignment/conflict processing + self.alignment_proj = nn.Sequential( + nn.Linear(2, config.d_model // 4), + nn.GELU(), + nn.Linear(config.d_model // 4, config.d_model // 4) + ) + + # Modify fusion to include alignment features + self.fusion_with_alignment = nn.Sequential( + nn.Linear(config.d_model * len(self.timeframes) + config.d_model // 4, config.d_model * 2), + nn.GELU(), + nn.Dropout(config.dropout), + nn.Linear(config.d_model * 2, config.d_model), + nn.LayerNorm(config.d_model) + ) + + def forward( + self, + tf_features: Dict[str, torch.Tensor], + alignment: Optional[torch.Tensor] = None, + conflict: Optional[torch.Tensor] = None, + return_tf_representations: bool = False + ) -> torch.Tensor: + """ + Forward pass with alignment/conflict. + + Args: + tf_features: Dictionary of {timeframe: features} + alignment: Alignment scores (batch,) or (batch, seq_len) + conflict: Conflict scores (batch,) or (batch, seq_len) + return_tf_representations: If True, also return per-TF representations + + Returns: + unified_representation: (batch, d_model) + """ + # Encode each timeframe + tf_representations = {} + + for tf in self.timeframes: + if tf not in tf_features: + raise ValueError(f"Missing features for timeframe: {tf}") + + features = tf_features[tf] + if not isinstance(features, torch.Tensor): + features = torch.tensor(features, dtype=torch.float32) + if features.dim() == 2: + features = features.unsqueeze(0) + + representation, _ = self.tf_encoders[tf](features) + tf_representations[tf] = representation + + # Apply cross-TF attention if enabled + if self.cross_tf_attention is not None: + tf_representations = self.cross_tf_attention(tf_representations) + + # Apply learnable weights + weights = F.softmax(self.tf_weights, dim=0) + + weighted_reps = [] + for i, tf in enumerate(self.timeframes): + weighted_reps.append(tf_representations[tf] * weights[i]) + + # Concatenate all representations + concatenated = torch.cat(weighted_reps, dim=-1) + + # Process alignment/conflict if provided + if alignment is not None and conflict is not None: + batch_size = concatenated.shape[0] + + # Handle sequence alignment/conflict by taking mean + if alignment.dim() > 1: + alignment = alignment.mean(dim=-1) + if conflict.dim() > 1: + conflict = conflict.mean(dim=-1) + + # Ensure proper shape + alignment = alignment.view(batch_size, 1) + conflict = conflict.view(batch_size, 1) + + alignment_features = torch.cat([alignment, conflict], dim=-1) + alignment_encoded = self.alignment_proj(alignment_features) + + # Concatenate with TF features + concatenated = torch.cat([concatenated, alignment_encoded], dim=-1) + + # Apply fusion with alignment + fused = self.fusion_with_alignment(concatenated) + else: + # Use regular fusion + fused = self.fusion(concatenated) + + # Output projection + unified = self.output_proj(fused) + + if return_tf_representations: + return unified, tf_representations + return unified + + +if __name__ == "__main__": + # Test HierarchicalAttention + torch.manual_seed(42) + + # Create configuration + config = HierarchicalAttentionConfig( + d_model=128, + n_heads=4, + d_ff=256, + n_layers=2, + dropout=0.1, + timeframes=('5m', '15m', '1h', '4h') + ) + + # Define input dimensions for each timeframe + input_dims = { + '5m': 64, + '15m': 48, + '1h': 40, + '4h': 32 + } + + # Create model + model = HierarchicalAttention(config, input_dims) + + # Create sample input + batch_size = 8 + tf_features = { + '5m': torch.randn(batch_size, 100, 64), + '15m': torch.randn(batch_size, 33, 48), + '1h': torch.randn(batch_size, 8, 40), + '4h': torch.randn(batch_size, 2, 32) + } + + # Forward pass + output = model(tf_features) + + print(f"Output shape: {output.shape}") + print(f"TF contributions: {model.get_tf_contributions()}") + + # Test with alignment + model_with_alignment = HierarchicalAttentionWithAlignment(config, input_dims) + + alignment = torch.rand(batch_size) + conflict = torch.rand(batch_size) + + output_aligned = model_with_alignment(tf_features, alignment, conflict) + print(f"Output with alignment shape: {output_aligned.shape}") + + print("\nHierarchicalAttention test complete!") diff --git a/src/models/strategies/mts/model.py b/src/models/strategies/mts/model.py new file mode 100644 index 0000000..3953509 --- /dev/null +++ b/src/models/strategies/mts/model.py @@ -0,0 +1,776 @@ +#!/usr/bin/env python3 +""" +MTS Model - Multi-Timeframe Synthesis Complete Model +===================================================== +Complete MTS model combining Hierarchical Attention encoder with +XGBoost for final prediction. Predicts unified direction, confidence +based on alignment, and optimal entry timeframe. + +Architecture: +1. MTSFeatureEngineer: Compute multi-TF features +2. HierarchicalAttention: Learn TF relationships -> unified representation +3. XGBoost: Final prediction (direction, confidence, entry TF) + +Outputs: +- unified_direction: -1 (bearish) to 1 (bullish) +- confidence_by_alignment: 0 to 1 based on TF alignment +- optimal_entry_tf: Best timeframe for entry (0=5m, 1=15m, 2=1h, 3=4h) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from loguru import logger + +try: + from xgboost import XGBClassifier, XGBRegressor + HAS_XGBOOST = True +except ImportError: + HAS_XGBOOST = False + logger.warning("XGBoost not available - using PyTorch fallback") + +from .feature_engineering import MTSFeatureEngineer +from .hierarchical_attention import ( + HierarchicalAttention, + HierarchicalAttentionWithAlignment, + HierarchicalAttentionConfig +) + + +@dataclass +class MTSConfig: + """Configuration for MTS Model.""" + # Feature engineering + timeframes: Tuple[str, ...] = ('5m', '15m', '1h', '4h') + include_volume: bool = True + feature_lookback: int = 100 + + # Hierarchical attention + d_model: int = 128 + n_heads: int = 4 + d_ff: int = 256 + n_layers: int = 2 + dropout: float = 0.1 + max_seq_len: int = 256 + use_cross_tf_attention: bool = True + use_learnable_tf_weights: bool = True + + # XGBoost + xgb_n_estimators: int = 200 + xgb_max_depth: int = 6 + xgb_learning_rate: float = 0.05 + xgb_subsample: float = 0.8 + xgb_colsample_bytree: float = 0.8 + xgb_min_child_weight: int = 5 + xgb_reg_alpha: float = 0.1 + xgb_reg_lambda: float = 1.0 + + # Prediction thresholds + direction_threshold: float = 0.6 + confidence_threshold: float = 0.65 + + +@dataclass +class MTSPrediction: + """MTS Model prediction output.""" + unified_direction: float # -1 to 1 + direction_class: str # 'bullish', 'bearish', 'neutral' + confidence: float # 0 to 1 + confidence_by_alignment: float # Based on TF alignment + optimal_entry_tf: str # '5m', '15m', '1h', '4h' + tf_contributions: Dict[str, float] # Contribution of each TF + signal_strength: float # 0 to 1 + recommended_action: str # 'buy', 'sell', 'hold' + + def to_dict(self) -> Dict[str, Any]: + return { + 'unified_direction': float(self.unified_direction), + 'direction_class': self.direction_class, + 'confidence': float(self.confidence), + 'confidence_by_alignment': float(self.confidence_by_alignment), + 'optimal_entry_tf': self.optimal_entry_tf, + 'tf_contributions': self.tf_contributions, + 'signal_strength': float(self.signal_strength), + 'recommended_action': self.recommended_action + } + + +class MTSHead(nn.Module): + """ + Prediction head for MTS model. + + Takes unified representation from HierarchicalAttention and produces: + - Direction logits + - Confidence score + - Entry TF logits + """ + + def __init__(self, d_model: int, n_timeframes: int, dropout: float = 0.1): + super().__init__() + + self.d_model = d_model + self.n_timeframes = n_timeframes + + # Shared backbone + self.backbone = nn.Sequential( + nn.Linear(d_model, d_model), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(d_model, d_model // 2), + nn.GELU(), + nn.Dropout(dropout) + ) + + # Direction head (3 classes: bearish, neutral, bullish) + self.direction_head = nn.Sequential( + nn.Linear(d_model // 2, d_model // 4), + nn.GELU(), + nn.Linear(d_model // 4, 3) + ) + + # Confidence head (regression 0-1) + self.confidence_head = nn.Sequential( + nn.Linear(d_model // 2, d_model // 4), + nn.GELU(), + nn.Linear(d_model // 4, 1), + nn.Sigmoid() + ) + + # Entry TF head (classification) + self.entry_tf_head = nn.Sequential( + nn.Linear(d_model // 2, d_model // 4), + nn.GELU(), + nn.Linear(d_model // 4, n_timeframes) + ) + + def forward( + self, + x: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Forward pass. + + Args: + x: Unified representation (batch, d_model) + + Returns: + direction_logits: (batch, 3) + confidence: (batch, 1) + entry_tf_logits: (batch, n_timeframes) + """ + features = self.backbone(x) + + direction_logits = self.direction_head(features) + confidence = self.confidence_head(features) + entry_tf_logits = self.entry_tf_head(features) + + return direction_logits, confidence, entry_tf_logits + + +class MTSModel: + """ + Multi-Timeframe Synthesis Model. + + Complete model for multi-timeframe analysis combining: + 1. Feature engineering across timeframes + 2. Hierarchical attention for unified representation + 3. Prediction heads (or XGBoost) for final output + + Usage: + model = MTSModel(MTSConfig()) + model.train(symbol='EURUSD') + prediction = model.predict(df_5m) + """ + + def __init__( + self, + config: Optional[MTSConfig] = None, + use_xgboost: bool = True, + device: Optional[str] = None + ): + """ + Initialize MTS Model. + + Args: + config: Model configuration + use_xgboost: Whether to use XGBoost for final prediction + device: Device for PyTorch ('cuda', 'cpu', or None for auto) + """ + self.config = config or MTSConfig() + self.use_xgboost = use_xgboost and HAS_XGBOOST + self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + + # Initialize feature engineer + self.feature_engineer = MTSFeatureEngineer( + timeframes=list(self.config.timeframes), + include_volume=self.config.include_volume, + feature_lookback=self.config.feature_lookback + ) + + # Store input dimensions after first feature computation + self.input_dims: Optional[Dict[str, int]] = None + + # Initialize attention model (will be built on first train/predict) + self.attention_model: Optional[HierarchicalAttentionWithAlignment] = None + + # Initialize prediction components + if self.use_xgboost: + self._init_xgboost() + else: + self.prediction_head: Optional[MTSHead] = None + + self._is_trained = False + + logger.info(f"Initialized MTSModel with device={self.device}, xgboost={self.use_xgboost}") + + def _init_xgboost(self): + """Initialize XGBoost models.""" + xgb_params = { + 'n_estimators': self.config.xgb_n_estimators, + 'max_depth': self.config.xgb_max_depth, + 'learning_rate': self.config.xgb_learning_rate, + 'subsample': self.config.xgb_subsample, + 'colsample_bytree': self.config.xgb_colsample_bytree, + 'min_child_weight': self.config.xgb_min_child_weight, + 'reg_alpha': self.config.xgb_reg_alpha, + 'reg_lambda': self.config.xgb_reg_lambda, + 'random_state': 42, + 'n_jobs': -1 + } + + # Check for GPU + try: + if torch.cuda.is_available(): + xgb_params['tree_method'] = 'gpu_hist' + xgb_params['device'] = 'cuda' + except Exception: + pass + + # Direction classifier (3 classes) + self.xgb_direction = XGBClassifier( + **xgb_params, + objective='multi:softprob', + num_class=3 + ) + + # Confidence regressor + self.xgb_confidence = XGBRegressor( + **xgb_params, + objective='reg:squarederror' + ) + + # Entry TF classifier + self.xgb_entry_tf = XGBClassifier( + **xgb_params, + objective='multi:softprob', + num_class=len(self.config.timeframes) + ) + + def _build_attention_model(self, input_dims: Dict[str, int]): + """Build hierarchical attention model.""" + attention_config = HierarchicalAttentionConfig( + d_model=self.config.d_model, + n_heads=self.config.n_heads, + d_ff=self.config.d_ff, + n_layers=self.config.n_layers, + dropout=self.config.dropout, + max_seq_len=self.config.max_seq_len, + timeframes=self.config.timeframes, + use_cross_tf_attention=self.config.use_cross_tf_attention, + use_learnable_tf_weights=self.config.use_learnable_tf_weights + ) + + self.attention_model = HierarchicalAttentionWithAlignment( + attention_config, + input_dims + ).to(self.device) + + if not self.use_xgboost: + self.prediction_head = MTSHead( + self.config.d_model, + len(self.config.timeframes), + self.config.dropout + ).to(self.device) + + self.input_dims = input_dims + + def _prepare_features( + self, + df_5m: pd.DataFrame + ) -> Tuple[Dict[str, np.ndarray], np.ndarray, np.ndarray]: + """ + Prepare features from 5m data. + + Returns: + tf_features: Dictionary of {timeframe: features} + alignment: Alignment scores + conflict: Conflict scores + """ + # Aggregate to all timeframes + dfs_dict = {} + for tf in self.config.timeframes: + dfs_dict[tf] = self.feature_engineer.aggregate_to_timeframe(df_5m, tf) + + # Prepare hierarchical input + hierarchical = self.feature_engineer.prepare_hierarchical_input(dfs_dict) + + return ( + hierarchical['tf_features'], + hierarchical['alignment'], + hierarchical['conflict'] + ) + + def _extract_unified_representation( + self, + tf_features: Dict[str, np.ndarray], + alignment: np.ndarray, + conflict: np.ndarray + ) -> np.ndarray: + """ + Extract unified representation using attention model. + + Args: + tf_features: Per-TF features + alignment: Alignment scores + conflict: Conflict scores + + Returns: + Unified representation array + """ + # Determine input dimensions if not set + if self.input_dims is None: + input_dims = {tf: features.shape[-1] for tf, features in tf_features.items()} + self._build_attention_model(input_dims) + + self.attention_model.eval() + + with torch.no_grad(): + # Convert to tensors + tf_tensors = {} + for tf, features in tf_features.items(): + tensor = torch.tensor(features, dtype=torch.float32, device=self.device) + if tensor.dim() == 2: + tensor = tensor.unsqueeze(0) + tf_tensors[tf] = tensor + + alignment_tensor = torch.tensor(alignment, dtype=torch.float32, device=self.device) + conflict_tensor = torch.tensor(conflict, dtype=torch.float32, device=self.device) + + if alignment_tensor.dim() == 1: + alignment_tensor = alignment_tensor.unsqueeze(0) + if conflict_tensor.dim() == 1: + conflict_tensor = conflict_tensor.unsqueeze(0) + + # Get unified representation + unified = self.attention_model( + tf_tensors, + alignment_tensor, + conflict_tensor + ) + + return unified.cpu().numpy() + + def forward( + self, + tf_features: Dict[str, torch.Tensor], + alignment: torch.Tensor, + conflict: torch.Tensor + ) -> Dict[str, torch.Tensor]: + """ + Full forward pass (PyTorch mode). + + Args: + tf_features: Per-TF feature tensors + alignment: Alignment scores + conflict: Conflict scores + + Returns: + Dictionary with predictions + """ + if self.attention_model is None: + raise RuntimeError("Model not initialized. Call train() first.") + + # Get unified representation + unified = self.attention_model(tf_features, alignment, conflict) + + if self.use_xgboost: + # Can't use XGBoost in PyTorch forward + raise RuntimeError("Use predict() for XGBoost mode") + + # Use PyTorch prediction head + direction_logits, confidence, entry_tf_logits = self.prediction_head(unified) + + return { + 'direction_logits': direction_logits, + 'confidence': confidence, + 'entry_tf_logits': entry_tf_logits, + 'unified_representation': unified + } + + def predict( + self, + df_5m: pd.DataFrame + ) -> MTSPrediction: + """ + Make prediction from 5m data. + + Args: + df_5m: 5-minute OHLCV DataFrame + + Returns: + MTSPrediction object + """ + if not self._is_trained: + raise RuntimeError("Model must be trained before prediction") + + # Prepare features + tf_features, alignment, conflict = self._prepare_features(df_5m) + + # Get unified representation + unified_rep = self._extract_unified_representation(tf_features, alignment, conflict) + + if self.use_xgboost: + # XGBoost predictions + direction_probs = self.xgb_direction.predict_proba(unified_rep)[0] + confidence = float(self.xgb_confidence.predict(unified_rep)[0]) + entry_tf_probs = self.xgb_entry_tf.predict_proba(unified_rep)[0] + + # Direction class (0=bearish, 1=neutral, 2=bullish) + direction_class_idx = int(np.argmax(direction_probs)) + + # Unified direction (-1 to 1) + unified_direction = direction_probs[2] - direction_probs[0] + + # Optimal entry TF + optimal_tf_idx = int(np.argmax(entry_tf_probs)) + else: + # PyTorch predictions + self.attention_model.eval() + self.prediction_head.eval() + + with torch.no_grad(): + tf_tensors = { + tf: torch.tensor(features, dtype=torch.float32, device=self.device).unsqueeze(0) + for tf, features in tf_features.items() + } + alignment_tensor = torch.tensor(alignment, dtype=torch.float32, device=self.device).unsqueeze(0) + conflict_tensor = torch.tensor(conflict, dtype=torch.float32, device=self.device).unsqueeze(0) + + unified = self.attention_model(tf_tensors, alignment_tensor, conflict_tensor) + direction_logits, conf, entry_logits = self.prediction_head(unified) + + direction_probs = torch.softmax(direction_logits, dim=-1)[0].cpu().numpy() + confidence = float(conf[0].cpu().numpy()) + entry_tf_probs = torch.softmax(entry_logits, dim=-1)[0].cpu().numpy() + + direction_class_idx = int(np.argmax(direction_probs)) + unified_direction = float(direction_probs[2] - direction_probs[0]) + optimal_tf_idx = int(np.argmax(entry_tf_probs)) + + # Get TF contributions + tf_contributions = self.get_tf_contributions() + + # Confidence by alignment + confidence_by_alignment = float(np.mean(alignment)) * confidence + + # Map direction class + direction_classes = ['bearish', 'neutral', 'bullish'] + direction_class = direction_classes[direction_class_idx] + + # Optimal entry TF + optimal_entry_tf = self.config.timeframes[optimal_tf_idx] + + # Signal strength + signal_strength = abs(unified_direction) * confidence + + # Recommended action + if unified_direction > self.config.direction_threshold and confidence > self.config.confidence_threshold: + recommended_action = 'buy' + elif unified_direction < -self.config.direction_threshold and confidence > self.config.confidence_threshold: + recommended_action = 'sell' + else: + recommended_action = 'hold' + + return MTSPrediction( + unified_direction=unified_direction, + direction_class=direction_class, + confidence=confidence, + confidence_by_alignment=confidence_by_alignment, + optimal_entry_tf=optimal_entry_tf, + tf_contributions=tf_contributions, + signal_strength=signal_strength, + recommended_action=recommended_action + ) + + def get_tf_contributions(self) -> Dict[str, float]: + """Get the contribution of each timeframe.""" + if self.attention_model is None: + # Return equal weights + return {tf: 1.0 / len(self.config.timeframes) for tf in self.config.timeframes} + + return self.attention_model.get_tf_contributions() + + def train_attention( + self, + train_data: List[pd.DataFrame], + val_data: Optional[List[pd.DataFrame]] = None, + epochs: int = 50, + batch_size: int = 32, + lr: float = 0.001 + ): + """ + Train attention model (PyTorch phase). + + Args: + train_data: List of 5m DataFrames + val_data: Optional validation data + epochs: Number of training epochs + batch_size: Batch size + lr: Learning rate + """ + logger.info("Training attention model...") + + # Prepare training features + all_features = [] + all_alignments = [] + all_conflicts = [] + all_targets = [] + + for df in train_data: + tf_features, alignment, conflict = self._prepare_features(df) + + # Prepare targets (direction based on future return) + _, direction_targets, _ = self.feature_engineer.prepare_training_data(df) + + all_features.append(tf_features) + all_alignments.append(alignment) + all_conflicts.append(conflict) + all_targets.append(direction_targets) + + # Determine input dims from first sample + if self.input_dims is None: + input_dims = {tf: features.shape[-1] for tf, features in all_features[0].items()} + self._build_attention_model(input_dims) + + # Training loop + self.attention_model.train() + optimizer = torch.optim.AdamW(self.attention_model.parameters(), lr=lr) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs) + + for epoch in range(epochs): + total_loss = 0.0 + + for i in range(len(all_features)): + tf_tensors = { + tf: torch.tensor(features, dtype=torch.float32, device=self.device).unsqueeze(0) + for tf, features in all_features[i].items() + } + alignment = torch.tensor(all_alignments[i], dtype=torch.float32, device=self.device).unsqueeze(0) + conflict = torch.tensor(all_conflicts[i], dtype=torch.float32, device=self.device).unsqueeze(0) + + optimizer.zero_grad() + + # Forward pass + unified = self.attention_model(tf_tensors, alignment, conflict) + + # Simple contrastive loss based on alignment + alignment_mean = alignment.mean() + loss = -alignment_mean * unified.norm() + (1 - alignment_mean) * unified.norm() + + loss.backward() + optimizer.step() + total_loss += loss.item() + + scheduler.step() + + if (epoch + 1) % 10 == 0: + logger.info(f"Epoch {epoch + 1}/{epochs}, Loss: {total_loss / len(all_features):.4f}") + + logger.info("Attention model training complete") + + def train_xgboost( + self, + X_train: np.ndarray, + y_direction: np.ndarray, + y_confidence: np.ndarray, + y_entry_tf: np.ndarray, + X_val: Optional[np.ndarray] = None, + y_val_direction: Optional[np.ndarray] = None + ): + """ + Train XGBoost models on unified representations. + + Args: + X_train: Unified representations (n_samples, d_model) + y_direction: Direction labels (0, 1, 2) + y_confidence: Confidence targets (0-1) + y_entry_tf: Entry TF labels (0 to n_timeframes-1) + X_val: Validation features + y_val_direction: Validation direction labels + """ + if not self.use_xgboost: + raise RuntimeError("XGBoost not enabled") + + logger.info(f"Training XGBoost models on {len(X_train)} samples...") + + # Train direction classifier + eval_set = [(X_val, y_val_direction)] if X_val is not None and y_val_direction is not None else None + self.xgb_direction.fit( + X_train, y_direction, + eval_set=eval_set, + verbose=False + ) + + # Train confidence regressor + self.xgb_confidence.fit(X_train, y_confidence, verbose=False) + + # Train entry TF classifier + self.xgb_entry_tf.fit(X_train, y_entry_tf, verbose=False) + + self._is_trained = True + logger.info("XGBoost training complete") + + def save(self, path: str): + """Save model to disk.""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save config + joblib.dump(self.config, path / 'config.joblib') + + # Save attention model + if self.attention_model is not None: + torch.save(self.attention_model.state_dict(), path / 'attention_model.pt') + + # Save prediction head + if not self.use_xgboost and self.prediction_head is not None: + torch.save(self.prediction_head.state_dict(), path / 'prediction_head.pt') + + # Save XGBoost models + if self.use_xgboost: + joblib.dump(self.xgb_direction, path / 'xgb_direction.joblib') + joblib.dump(self.xgb_confidence, path / 'xgb_confidence.joblib') + joblib.dump(self.xgb_entry_tf, path / 'xgb_entry_tf.joblib') + + # Save metadata + metadata = { + 'input_dims': self.input_dims, + 'is_trained': self._is_trained, + 'use_xgboost': self.use_xgboost, + 'device': self.device + } + joblib.dump(metadata, path / 'metadata.joblib') + + logger.info(f"Saved MTSModel to {path}") + + def load(self, path: str): + """Load model from disk.""" + path = Path(path) + + # Load config + self.config = joblib.load(path / 'config.joblib') + + # Load metadata + metadata = joblib.load(path / 'metadata.joblib') + self.input_dims = metadata['input_dims'] + self._is_trained = metadata['is_trained'] + self.use_xgboost = metadata['use_xgboost'] + + # Rebuild attention model + if self.input_dims is not None: + self._build_attention_model(self.input_dims) + + # Load weights + if (path / 'attention_model.pt').exists(): + self.attention_model.load_state_dict( + torch.load(path / 'attention_model.pt', map_location=self.device) + ) + + # Load prediction head + if not self.use_xgboost and (path / 'prediction_head.pt').exists(): + self.prediction_head.load_state_dict( + torch.load(path / 'prediction_head.pt', map_location=self.device) + ) + + # Load XGBoost models + if self.use_xgboost: + self.xgb_direction = joblib.load(path / 'xgb_direction.joblib') + self.xgb_confidence = joblib.load(path / 'xgb_confidence.joblib') + self.xgb_entry_tf = joblib.load(path / 'xgb_entry_tf.joblib') + + logger.info(f"Loaded MTSModel from {path}") + + +if __name__ == "__main__": + # Test MTSModel + np.random.seed(42) + torch.manual_seed(42) + + # Create sample 5m data + n_samples = 500 + + base_price = 100.0 + returns = np.random.randn(n_samples) * 0.001 + 0.0001 + close = base_price * np.cumprod(1 + returns) + + df_5m = pd.DataFrame({ + 'open': close * (1 + np.random.randn(n_samples) * 0.001), + 'high': close * (1 + np.abs(np.random.randn(n_samples) * 0.002)), + 'low': close * (1 - np.abs(np.random.randn(n_samples) * 0.002)), + 'close': close, + 'volume': np.random.randint(1000, 10000, n_samples) + }) + + # Initialize model + config = MTSConfig( + d_model=64, + n_heads=2, + n_layers=1 + ) + + model = MTSModel(config, use_xgboost=True) + + # Prepare features + tf_features, alignment, conflict = model._prepare_features(df_5m) + + print(f"Feature shapes: {[f'{tf}: {f.shape}' for tf, f in tf_features.items()]}") + print(f"Alignment shape: {alignment.shape}") + print(f"Conflict shape: {conflict.shape}") + + # Build attention model + input_dims = {tf: features.shape[-1] for tf, features in tf_features.items()} + model._build_attention_model(input_dims) + + # Get unified representation + unified_rep = model._extract_unified_representation(tf_features, alignment, conflict) + print(f"Unified representation shape: {unified_rep.shape}") + + # Train XGBoost with synthetic targets + n_train = unified_rep.shape[0] + y_direction = np.random.randint(0, 3, n_train) + y_confidence = np.random.rand(n_train) + y_entry_tf = np.random.randint(0, 4, n_train) + + model.train_xgboost(unified_rep, y_direction, y_confidence, y_entry_tf) + + # Make prediction + prediction = model.predict(df_5m) + + print("\nPrediction:") + print(f" Direction: {prediction.direction_class} ({prediction.unified_direction:.3f})") + print(f" Confidence: {prediction.confidence:.3f}") + print(f" Confidence by Alignment: {prediction.confidence_by_alignment:.3f}") + print(f" Optimal Entry TF: {prediction.optimal_entry_tf}") + print(f" TF Contributions: {prediction.tf_contributions}") + print(f" Recommended Action: {prediction.recommended_action}") + + print("\nMTSModel test complete!") diff --git a/src/models/strategies/mts/trainer.py b/src/models/strategies/mts/trainer.py new file mode 100644 index 0000000..053d752 --- /dev/null +++ b/src/models/strategies/mts/trainer.py @@ -0,0 +1,820 @@ +#!/usr/bin/env python3 +""" +MTS Trainer - Hierarchical Training for Multi-Timeframe Synthesis +================================================================== +Training module for MTS model with hierarchical loss and walk-forward validation. + +Training Pipeline: +1. Load multi-TF data from database or files +2. Prepare features and targets +3. Train attention model (optional pre-training) +4. Train XGBoost on unified representations +5. Walk-forward validation +6. Evaluate alignment accuracy + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.utils.data import Dataset, DataLoader +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from datetime import datetime, timedelta +from loguru import logger + +from sklearn.metrics import ( + accuracy_score, + f1_score, + mean_squared_error, + mean_absolute_error, + classification_report +) +from sklearn.model_selection import TimeSeriesSplit + +from .model import MTSModel, MTSConfig, MTSPrediction +from .feature_engineering import MTSFeatureEngineer + + +@dataclass +class MTSTrainerConfig: + """Configuration for MTS Trainer.""" + # Data loading + base_timeframe: str = '5m' + target_horizon: int = 12 # 1 hour forward (12 * 5m) + return_threshold: float = 0.001 # 0.1% for direction classification + + # Attention training + attention_epochs: int = 30 + attention_batch_size: int = 32 + attention_lr: float = 0.001 + attention_weight_decay: float = 0.01 + + # Walk-forward validation + n_folds: int = 5 + test_size_ratio: float = 0.2 + min_train_samples: int = 5000 + gap_samples: int = 100 # Gap between train and test + + # Early stopping + patience: int = 10 + min_delta: float = 0.001 + + # Output + model_dir: str = 'models/mts' + save_checkpoints: bool = True + + +@dataclass +class MTSTrainingMetrics: + """Training metrics for MTS model.""" + fold: int + train_samples: int + test_samples: int + + # Direction metrics + direction_accuracy: float + direction_f1_macro: float + direction_f1_weighted: float + + # Confidence metrics + confidence_mae: float + confidence_mse: float + + # Entry TF metrics + entry_tf_accuracy: float + + # Alignment-based metrics + aligned_direction_accuracy: float # Accuracy when alignment > 0.7 + conflicting_direction_accuracy: float # Accuracy when alignment < 0.5 + + # Overall + signal_quality: float # Combined metric + + def to_dict(self) -> Dict[str, Any]: + return { + 'fold': self.fold, + 'train_samples': self.train_samples, + 'test_samples': self.test_samples, + 'direction_accuracy': float(self.direction_accuracy), + 'direction_f1_macro': float(self.direction_f1_macro), + 'direction_f1_weighted': float(self.direction_f1_weighted), + 'confidence_mae': float(self.confidence_mae), + 'confidence_mse': float(self.confidence_mse), + 'entry_tf_accuracy': float(self.entry_tf_accuracy), + 'aligned_direction_accuracy': float(self.aligned_direction_accuracy), + 'conflicting_direction_accuracy': float(self.conflicting_direction_accuracy), + 'signal_quality': float(self.signal_quality) + } + + +class MTSDataset(Dataset): + """PyTorch Dataset for MTS training.""" + + def __init__( + self, + tf_features: Dict[str, np.ndarray], + alignments: np.ndarray, + conflicts: np.ndarray, + direction_targets: np.ndarray, + confidence_targets: np.ndarray, + entry_tf_targets: np.ndarray + ): + """ + Initialize MTS Dataset. + + Args: + tf_features: Dictionary of {timeframe: features} + alignments: Alignment scores + conflicts: Conflict scores + direction_targets: Direction labels (0, 1, 2) + confidence_targets: Confidence targets (0-1) + entry_tf_targets: Entry TF labels + """ + self.tf_features = { + tf: torch.tensor(features, dtype=torch.float32) + for tf, features in tf_features.items() + } + self.alignments = torch.tensor(alignments, dtype=torch.float32) + self.conflicts = torch.tensor(conflicts, dtype=torch.float32) + self.direction_targets = torch.tensor(direction_targets, dtype=torch.long) + self.confidence_targets = torch.tensor(confidence_targets, dtype=torch.float32) + self.entry_tf_targets = torch.tensor(entry_tf_targets, dtype=torch.long) + + # Get number of samples from any feature array + self.n_samples = len(alignments) + + def __len__(self) -> int: + return self.n_samples + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + return { + 'tf_features': {tf: features[idx] for tf, features in self.tf_features.items()}, + 'alignment': self.alignments[idx], + 'conflict': self.conflicts[idx], + 'direction': self.direction_targets[idx], + 'confidence': self.confidence_targets[idx], + 'entry_tf': self.entry_tf_targets[idx] + } + + +class HierarchicalLoss(nn.Module): + """ + Hierarchical loss function for MTS training. + + Combines: + 1. Direction classification loss (CE with class weights) + 2. Confidence regression loss (MSE) + 3. Entry TF classification loss (CE) + 4. Alignment consistency loss (encourage agreement with alignment) + """ + + def __init__( + self, + direction_weight: float = 1.0, + confidence_weight: float = 0.5, + entry_tf_weight: float = 0.3, + alignment_weight: float = 0.2, + direction_class_weights: Optional[torch.Tensor] = None + ): + super().__init__() + + self.direction_weight = direction_weight + self.confidence_weight = confidence_weight + self.entry_tf_weight = entry_tf_weight + self.alignment_weight = alignment_weight + + self.direction_loss = nn.CrossEntropyLoss(weight=direction_class_weights) + self.confidence_loss = nn.MSELoss() + self.entry_tf_loss = nn.CrossEntropyLoss() + + def forward( + self, + direction_logits: torch.Tensor, + confidence_pred: torch.Tensor, + entry_tf_logits: torch.Tensor, + direction_target: torch.Tensor, + confidence_target: torch.Tensor, + entry_tf_target: torch.Tensor, + alignment: torch.Tensor + ) -> Tuple[torch.Tensor, Dict[str, float]]: + """ + Compute hierarchical loss. + + Returns: + total_loss: Combined weighted loss + loss_components: Dictionary of individual losses + """ + # Direction loss + dir_loss = self.direction_loss(direction_logits, direction_target) + + # Confidence loss + conf_loss = self.confidence_loss(confidence_pred.squeeze(), confidence_target) + + # Entry TF loss + entry_loss = self.entry_tf_loss(entry_tf_logits, entry_tf_target) + + # Alignment consistency loss + # Higher alignment should correspond to higher confidence in prediction + direction_probs = F.softmax(direction_logits, dim=-1) + direction_confidence = direction_probs.max(dim=-1)[0] + alignment_consistency = F.mse_loss(direction_confidence, alignment) + + # Combined loss + total_loss = ( + self.direction_weight * dir_loss + + self.confidence_weight * conf_loss + + self.entry_tf_weight * entry_loss + + self.alignment_weight * alignment_consistency + ) + + loss_components = { + 'direction': float(dir_loss), + 'confidence': float(conf_loss), + 'entry_tf': float(entry_loss), + 'alignment': float(alignment_consistency), + 'total': float(total_loss) + } + + return total_loss, loss_components + + +class MTSTrainer: + """ + Trainer for Multi-Timeframe Synthesis model. + + Supports: + - Multi-timeframe data loading + - Hierarchical loss training + - Walk-forward validation + - Alignment accuracy evaluation + """ + + def __init__( + self, + model: Optional[MTSModel] = None, + config: Optional[MTSTrainerConfig] = None, + device: Optional[str] = None + ): + """ + Initialize MTS Trainer. + + Args: + model: MTSModel instance (created if None) + config: Trainer configuration + device: Device for training + """ + self.config = config or MTSTrainerConfig() + self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + + self.model = model or MTSModel(device=self.device) + self.feature_engineer = MTSFeatureEngineer( + timeframes=list(self.model.config.timeframes) + ) + + self.training_history: List[Dict] = [] + self.fold_metrics: List[MTSTrainingMetrics] = [] + + logger.info(f"Initialized MTSTrainer with device={self.device}") + + def load_multi_tf_data( + self, + symbol: str, + data_source: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> pd.DataFrame: + """ + Load multi-timeframe data for a symbol. + + Args: + symbol: Trading symbol (e.g., 'EURUSD') + data_source: Path to data file or database connection + start_date: Start date for data + end_date: End date for data + + Returns: + DataFrame with 5m OHLCV data + """ + logger.info(f"Loading data for {symbol}...") + + if data_source is not None and Path(data_source).exists(): + # Load from file + df = pd.read_csv(data_source, parse_dates=['timestamp']) + df = df.set_index('timestamp') + else: + # Generate synthetic data for testing + logger.warning("No data source provided, generating synthetic data") + df = self._generate_synthetic_data(symbol, start_date, end_date) + + # Filter by date if specified + if start_date is not None: + df = df[df.index >= start_date] + if end_date is not None: + df = df[df.index <= end_date] + + logger.info(f"Loaded {len(df)} samples for {symbol}") + + return df + + def _generate_synthetic_data( + self, + symbol: str, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> pd.DataFrame: + """Generate synthetic OHLCV data for testing.""" + start_date = start_date or datetime(2024, 1, 1) + end_date = end_date or datetime(2025, 1, 1) + + # Generate timestamps + timestamps = pd.date_range(start_date, end_date, freq='5min') + n_samples = len(timestamps) + + # Generate price data with trend and seasonality + np.random.seed(42) + + # Base price with trend + trend = np.linspace(0, 0.1, n_samples) + seasonality = 0.02 * np.sin(2 * np.pi * np.arange(n_samples) / (24 * 12)) # Daily cycle + noise = np.random.randn(n_samples) * 0.001 + + returns = trend / n_samples + seasonality / n_samples + noise + close = 1.1000 * np.cumprod(1 + returns) + + # Generate OHLC from close + high = close * (1 + np.abs(np.random.randn(n_samples) * 0.002)) + low = close * (1 - np.abs(np.random.randn(n_samples) * 0.002)) + open_price = np.roll(close, 1) * (1 + np.random.randn(n_samples) * 0.0005) + open_price[0] = close[0] + + volume = np.random.randint(100, 10000, n_samples) + + return pd.DataFrame({ + 'open': open_price, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume + }, index=timestamps) + + def prepare_training_data( + self, + df_5m: pd.DataFrame + ) -> Tuple[Dict[str, np.ndarray], np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Prepare training data from 5m DataFrame. + + Returns: + tf_features: Per-TF features + alignments: Alignment scores + conflicts: Conflict scores + direction_targets: Direction labels + confidence_targets: Confidence targets + entry_tf_targets: Entry TF labels + """ + # Prepare hierarchical features + hierarchical, direction_targets, magnitude_targets = self.feature_engineer.prepare_training_data( + df_5m, + target_horizon=self.config.target_horizon, + return_threshold=self.config.return_threshold + ) + + tf_features = hierarchical['tf_features'] + alignments = hierarchical['alignment'] + conflicts = hierarchical['conflict'] + dominant_tf = hierarchical['dominant_tf'] + + # Confidence targets based on magnitude and alignment + confidence_targets = np.clip(magnitude_targets * 10 * alignments, 0, 1) + + # Entry TF targets (use dominant TF as proxy) + entry_tf_targets = dominant_tf.astype(np.int64) + + # Convert direction to 3 classes (0=bearish, 1=neutral, 2=bullish) + direction_3class = np.ones_like(direction_targets) # Start with neutral + direction_3class[direction_targets > 0.5] = 2 # Bullish + direction_3class[direction_targets < 0.5] = 0 # Bearish + + # Make magnitudes determine neutral + neutral_mask = magnitude_targets < self.config.return_threshold + direction_3class[neutral_mask] = 1 + + return ( + tf_features, + alignments.astype(np.float32), + conflicts.astype(np.float32), + direction_3class.astype(np.int64), + confidence_targets.astype(np.float32), + entry_tf_targets.astype(np.int64) + ) + + def train( + self, + symbol: str, + data: Optional[pd.DataFrame] = None, + data_source: Optional[str] = None + ) -> Dict[str, Any]: + """ + Train MTS model on a symbol. + + Args: + symbol: Trading symbol + data: Pre-loaded DataFrame (optional) + data_source: Path to data source + + Returns: + Training results dictionary + """ + logger.info(f"Training MTS model for {symbol}") + + # Load data + if data is None: + data = self.load_multi_tf_data(symbol, data_source) + + # Prepare training data + (tf_features, alignments, conflicts, direction_targets, + confidence_targets, entry_tf_targets) = self.prepare_training_data(data) + + # Build model if needed + if self.model.input_dims is None: + input_dims = {tf: features.shape[-1] for tf, features in tf_features.items()} + self.model._build_attention_model(input_dims) + + # Train attention model + self._train_attention( + tf_features, alignments, conflicts, + direction_targets, confidence_targets + ) + + # Extract unified representations + unified_reps = self.model._extract_unified_representation( + tf_features, alignments, conflicts + ) + + # Train XGBoost + self.model.train_xgboost( + unified_reps, + direction_targets, + confidence_targets, + entry_tf_targets + ) + + logger.info(f"Training complete for {symbol}") + + return { + 'symbol': symbol, + 'n_samples': len(direction_targets), + 'tf_contributions': self.model.get_tf_contributions() + } + + def _train_attention( + self, + tf_features: Dict[str, np.ndarray], + alignments: np.ndarray, + conflicts: np.ndarray, + direction_targets: np.ndarray, + confidence_targets: np.ndarray + ): + """Train attention model.""" + logger.info("Training attention model...") + + self.model.attention_model.train() + + # Prepare tensors + tf_tensors = { + tf: torch.tensor(features, dtype=torch.float32, device=self.device) + for tf, features in tf_features.items() + } + + alignment_tensor = torch.tensor(alignments, dtype=torch.float32, device=self.device) + conflict_tensor = torch.tensor(conflicts, dtype=torch.float32, device=self.device) + + # Optimizer + optimizer = torch.optim.AdamW( + self.model.attention_model.parameters(), + lr=self.config.attention_lr, + weight_decay=self.config.attention_weight_decay + ) + + scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts( + optimizer, T_0=10, T_mult=2 + ) + + best_loss = float('inf') + patience_counter = 0 + + for epoch in range(self.config.attention_epochs): + optimizer.zero_grad() + + # Forward pass (using full batch for simplicity) + unified = self.model.attention_model( + {tf: t.unsqueeze(0) for tf, t in tf_tensors.items()}, + alignment_tensor.unsqueeze(0), + conflict_tensor.unsqueeze(0) + ) + + # Compute loss (encourage representation to capture alignment) + alignment_mean = alignment_tensor.mean() + + # Representation should be more confident when alignment is high + rep_norm = unified.norm(dim=-1).mean() + loss = -alignment_mean * rep_norm + 0.1 * conflict_tensor.mean() * rep_norm + + loss.backward() + torch.nn.utils.clip_grad_norm_(self.model.attention_model.parameters(), 1.0) + optimizer.step() + scheduler.step() + + current_loss = loss.item() + + if current_loss < best_loss - self.config.min_delta: + best_loss = current_loss + patience_counter = 0 + else: + patience_counter += 1 + + if patience_counter >= self.config.patience: + logger.info(f"Early stopping at epoch {epoch + 1}") + break + + if (epoch + 1) % 10 == 0: + logger.info(f"Epoch {epoch + 1}/{self.config.attention_epochs}, Loss: {current_loss:.4f}") + + self.model.attention_model.eval() + logger.info("Attention training complete") + + def walk_forward_train( + self, + symbol: str, + data: Optional[pd.DataFrame] = None, + n_folds: Optional[int] = None + ) -> List[MTSTrainingMetrics]: + """ + Walk-forward training with multiple folds. + + Args: + symbol: Trading symbol + data: Pre-loaded DataFrame + n_folds: Number of folds (default from config) + + Returns: + List of metrics for each fold + """ + n_folds = n_folds or self.config.n_folds + + logger.info(f"Walk-forward training for {symbol} with {n_folds} folds") + + # Load data + if data is None: + data = self.load_multi_tf_data(symbol) + + # Prepare full dataset + (tf_features, alignments, conflicts, direction_targets, + confidence_targets, entry_tf_targets) = self.prepare_training_data(data) + + n_samples = len(direction_targets) + logger.info(f"Total samples: {n_samples}") + + # Time series split + tscv = TimeSeriesSplit(n_splits=n_folds) + + self.fold_metrics = [] + + for fold, (train_idx, test_idx) in enumerate(tscv.split(direction_targets)): + logger.info(f"\n{'='*60}") + logger.info(f"Fold {fold + 1}/{n_folds}") + logger.info(f"{'='*60}") + + # Apply gap + test_idx = test_idx[test_idx >= train_idx[-1] + self.config.gap_samples] + + if len(test_idx) < 100: + logger.warning(f"Skipping fold {fold + 1}: insufficient test samples") + continue + + # Split data + train_tf_features = {tf: features[train_idx] for tf, features in tf_features.items()} + test_tf_features = {tf: features[test_idx] for tf, features in tf_features.items()} + + train_alignments = alignments[train_idx] + test_alignments = alignments[test_idx] + + train_conflicts = conflicts[train_idx] + test_conflicts = conflicts[test_idx] + + train_direction = direction_targets[train_idx] + test_direction = direction_targets[test_idx] + + train_confidence = confidence_targets[train_idx] + test_confidence = confidence_targets[test_idx] + + train_entry_tf = entry_tf_targets[train_idx] + test_entry_tf = entry_tf_targets[test_idx] + + logger.info(f"Train samples: {len(train_idx)}, Test samples: {len(test_idx)}") + + # Build fresh model for this fold + input_dims = {tf: features.shape[-1] for tf, features in train_tf_features.items()} + self.model._build_attention_model(input_dims) + + # Train attention + self._train_attention( + train_tf_features, train_alignments, train_conflicts, + train_direction, train_confidence + ) + + # Extract representations + train_reps = self.model._extract_unified_representation( + train_tf_features, train_alignments, train_conflicts + ) + test_reps = self.model._extract_unified_representation( + test_tf_features, test_alignments, test_conflicts + ) + + # Train XGBoost + self.model.train_xgboost( + train_reps, train_direction, train_confidence, train_entry_tf, + X_val=test_reps, y_val_direction=test_direction + ) + + # Evaluate + metrics = self.evaluate_alignment_accuracy( + test_reps, + test_direction, + test_confidence, + test_entry_tf, + test_alignments, + fold=fold + 1 + ) + + self.fold_metrics.append(metrics) + + logger.info(f"Fold {fold + 1} - Direction Acc: {metrics.direction_accuracy:.4f}, " + f"Aligned Acc: {metrics.aligned_direction_accuracy:.4f}") + + # Log summary + self._log_walk_forward_summary() + + return self.fold_metrics + + def evaluate_alignment_accuracy( + self, + X_test: np.ndarray, + y_direction: np.ndarray, + y_confidence: np.ndarray, + y_entry_tf: np.ndarray, + alignments: np.ndarray, + fold: int = 0 + ) -> MTSTrainingMetrics: + """ + Evaluate model with alignment-aware metrics. + + Args: + X_test: Test representations + y_direction: True direction labels + y_confidence: True confidence targets + y_entry_tf: True entry TF labels + alignments: Alignment scores + fold: Fold number + + Returns: + MTSTrainingMetrics object + """ + # Predictions + pred_direction = self.model.xgb_direction.predict(X_test) + pred_confidence = self.model.xgb_confidence.predict(X_test) + pred_entry_tf = self.model.xgb_entry_tf.predict(X_test) + + # Direction metrics + direction_accuracy = accuracy_score(y_direction, pred_direction) + direction_f1_macro = f1_score(y_direction, pred_direction, average='macro') + direction_f1_weighted = f1_score(y_direction, pred_direction, average='weighted') + + # Confidence metrics + confidence_mae = mean_absolute_error(y_confidence, pred_confidence) + confidence_mse = mean_squared_error(y_confidence, pred_confidence) + + # Entry TF metrics + entry_tf_accuracy = accuracy_score(y_entry_tf, pred_entry_tf) + + # Alignment-based metrics + high_alignment_mask = alignments > 0.7 + low_alignment_mask = alignments < 0.5 + + if high_alignment_mask.sum() > 0: + aligned_direction_accuracy = accuracy_score( + y_direction[high_alignment_mask], + pred_direction[high_alignment_mask] + ) + else: + aligned_direction_accuracy = 0.0 + + if low_alignment_mask.sum() > 0: + conflicting_direction_accuracy = accuracy_score( + y_direction[low_alignment_mask], + pred_direction[low_alignment_mask] + ) + else: + conflicting_direction_accuracy = 0.0 + + # Signal quality (combined metric) + signal_quality = ( + 0.4 * direction_accuracy + + 0.3 * (1 - confidence_mae) + + 0.2 * aligned_direction_accuracy + + 0.1 * entry_tf_accuracy + ) + + return MTSTrainingMetrics( + fold=fold, + train_samples=0, # Set externally + test_samples=len(y_direction), + direction_accuracy=direction_accuracy, + direction_f1_macro=direction_f1_macro, + direction_f1_weighted=direction_f1_weighted, + confidence_mae=confidence_mae, + confidence_mse=confidence_mse, + entry_tf_accuracy=entry_tf_accuracy, + aligned_direction_accuracy=aligned_direction_accuracy, + conflicting_direction_accuracy=conflicting_direction_accuracy, + signal_quality=signal_quality + ) + + def _log_walk_forward_summary(self): + """Log summary of walk-forward validation.""" + if not self.fold_metrics: + return + + logger.info("\n" + "=" * 70) + logger.info("WALK-FORWARD VALIDATION SUMMARY") + logger.info("=" * 70) + + # Aggregate metrics + direction_accs = [m.direction_accuracy for m in self.fold_metrics] + aligned_accs = [m.aligned_direction_accuracy for m in self.fold_metrics] + signal_qualities = [m.signal_quality for m in self.fold_metrics] + + logger.info(f"\nDirection Accuracy:") + logger.info(f" Mean: {np.mean(direction_accs):.4f} (+/- {np.std(direction_accs):.4f})") + logger.info(f" Min: {np.min(direction_accs):.4f}, Max: {np.max(direction_accs):.4f}") + + logger.info(f"\nAligned Direction Accuracy:") + logger.info(f" Mean: {np.mean(aligned_accs):.4f} (+/- {np.std(aligned_accs):.4f})") + + logger.info(f"\nSignal Quality:") + logger.info(f" Mean: {np.mean(signal_qualities):.4f} (+/- {np.std(signal_qualities):.4f})") + + logger.info("\n" + "=" * 70) + + def save_results(self, path: str): + """Save training results.""" + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save model + self.model.save(str(path / 'model')) + + # Save metrics + metrics_data = [m.to_dict() for m in self.fold_metrics] + joblib.dump(metrics_data, path / 'fold_metrics.joblib') + + # Save training history + joblib.dump(self.training_history, path / 'training_history.joblib') + + logger.info(f"Saved results to {path}") + + +if __name__ == "__main__": + # Test MTSTrainer + np.random.seed(42) + torch.manual_seed(42) + + # Initialize trainer + config = MTSTrainerConfig( + attention_epochs=10, + n_folds=3 + ) + + trainer = MTSTrainer(config=config) + + # Generate synthetic data + df = trainer._generate_synthetic_data('EURUSD') + print(f"Generated {len(df)} samples") + + # Test walk-forward training + metrics = trainer.walk_forward_train('EURUSD', data=df) + + print("\nFinal Metrics:") + for m in metrics: + print(f" Fold {m.fold}: Direction={m.direction_accuracy:.4f}, " + f"Aligned={m.aligned_direction_accuracy:.4f}") + + print("\nMTSTrainer test complete!") diff --git a/src/models/strategies/pva/__init__.py b/src/models/strategies/pva/__init__.py new file mode 100644 index 0000000..edead41 --- /dev/null +++ b/src/models/strategies/pva/__init__.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +PVA (Price Variation Attention) Strategy Module +================================================ + +A time-agnostic machine learning strategy for price movement prediction +that combines transformer-based attention with XGBoost prediction heads. + +Key Components: +- PVAFeatureEngineer: Return-based feature computation +- PVAModel: Complete model with attention encoder + XGBoost head +- PVATrainer: Training pipeline with walk-forward validation + +Architecture Overview: + OHLCV Data + | + v + [PVAFeatureEngineer] + - Multi-period returns + - Acceleration (return derivatives) + - Volatility of returns + - Skewness/Kurtosis + - Price-derived features + | + v + [Sequence Preparation] + (seq_len, n_features) + | + v + [PVAModel] + ├── PriceFocusedAttention Encoder (4 layers, 256 dim) + └── XGBoost Prediction Head + | + v + Output: (direction, magnitude, confidence) + +Design Principles: +1. Time-Agnostic: No temporal features (hour, day, session) +2. Price-Focused: All features derived from price returns +3. Interpretable: Attention scores available for analysis +4. Robust: XGBoost head for reliable predictions + +Usage Example: + >>> from src.models.strategies.pva import ( + ... PVAModel, PVAConfig, PVATrainer, PVAFeatureEngineer + ... ) + >>> + >>> # Quick training + >>> trainer = PVATrainer() + >>> model, metrics = trainer.train('XAUUSD', '2023-01-01', '2024-12-31') + >>> + >>> # Make predictions + >>> prediction = model.predict(X_test) + >>> print(f"Direction: {prediction.direction}") + >>> print(f"Magnitude: {prediction.magnitude}") + >>> print(f"Confidence: {prediction.confidence}") + >>> + >>> # Walk-forward validation + >>> results = trainer.walk_forward_train('XAUUSD', n_folds=5) + >>> print(f"Avg accuracy: {results.avg_direction_accuracy:.4f}") + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Feature Engineering +from .feature_engineering import ( + PVAFeatureEngineer, + PVAFeatureConfig, + compute_pva_features, +) + +# Model +from .model import ( + PVAModel, + PVAConfig, + PVAPrediction, + create_pva_model, +) + +# Trainer +from .trainer import ( + PVATrainer, + PVATrainerConfig, + TrainingMetrics, + WalkForwardResult, + train_pva_model, +) + + +__all__ = [ + # Feature Engineering + 'PVAFeatureEngineer', + 'PVAFeatureConfig', + 'compute_pva_features', + + # Model + 'PVAModel', + 'PVAConfig', + 'PVAPrediction', + 'create_pva_model', + + # Trainer + 'PVATrainer', + 'PVATrainerConfig', + 'TrainingMetrics', + 'WalkForwardResult', + 'train_pva_model', +] + +__version__ = '1.0.0' diff --git a/src/models/strategies/pva/feature_engineering.py b/src/models/strategies/pva/feature_engineering.py new file mode 100644 index 0000000..b947c40 --- /dev/null +++ b/src/models/strategies/pva/feature_engineering.py @@ -0,0 +1,696 @@ +#!/usr/bin/env python3 +""" +PVA (Price Variation Attention) Feature Engineering +==================================================== +Computes return-based features for the PVA strategy. + +This module provides feature engineering specifically designed for +price variation analysis without temporal features. All features +are derived from price returns and their derivatives. + +Key Design Principles: +1. NO temporal features (hour, day, session) +2. Pure price-derived metrics +3. Multi-period return analysis +4. Statistical moments of returns + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from typing import List, Tuple, Optional, Dict, Any +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd +from loguru import logger + +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + + +@dataclass +class PVAFeatureConfig: + """Configuration for PVA feature engineering.""" + + # Return periods for multi-horizon analysis + return_periods: List[int] = field(default_factory=lambda: [1, 5, 10, 20]) + + # Volatility calculation window + volatility_window: int = 20 + + # Statistical moments window + stats_window: int = 50 + + # Sequence length for transformer input + sequence_length: int = 100 + + # Minimum valid data points after NaN removal + min_valid_points: int = 200 + + # Epsilon for numerical stability + epsilon: float = 1e-8 + + # Z-score clipping threshold + zscore_clip: float = 5.0 + + +class PVAFeatureEngineer: + """ + Feature engineering for PVA (Price Variation Attention) strategy. + + Computes return-based features suitable for transformer models. + NO temporal features are used - only price-derived metrics. + + Features computed: + - Multi-period returns (1, 5, 10, 20 periods) + - Return acceleration (derivative of returns) + - Volatility of returns (rolling std) + - Skewness and kurtosis of return distributions + - Normalized price range and body + - Momentum indicators derived from returns + + Usage: + engineer = PVAFeatureEngineer() + + # From DataFrame + df_features = engineer.compute_all_features(df_ohlcv) + + # Get sequences for transformer + sequences = engineer.prepare_sequences(df_features, seq_length=100) + + # Or use convenience method + X, y = engineer.prepare_training_data(df, target_horizon=12) + """ + + def __init__(self, config: Optional[PVAFeatureConfig] = None): + """ + Initialize the feature engineer. + + Args: + config: Configuration object (uses defaults if None) + """ + self.config = config or PVAFeatureConfig() + self._feature_names: List[str] = [] + + logger.info("PVAFeatureEngineer initialized") + logger.info(f" Return periods: {self.config.return_periods}") + logger.info(f" Volatility window: {self.config.volatility_window}") + logger.info(f" Stats window: {self.config.stats_window}") + + def compute_returns( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute returns at multiple time horizons. + + Returns are computed as percentage change: (P_t - P_{t-n}) / P_{t-n} + + Args: + df: DataFrame with 'close' column + periods: List of periods for return calculation + Defaults to config.return_periods + + Returns: + DataFrame with return columns added + """ + if periods is None: + periods = self.config.return_periods + + df = df.copy() + eps = self.config.epsilon + + if 'close' not in df.columns: + raise ValueError("DataFrame must have 'close' column") + + close = df['close'] + + for period in periods: + # Simple returns + returns = (close - close.shift(period)) / (close.shift(period) + eps) + df[f'return_{period}'] = returns + + # Log returns (more stable for ML) + log_returns = np.log((close + eps) / (close.shift(period) + eps)) + df[f'log_return_{period}'] = log_returns + + # Primary return (1-period) as main feature + df['return'] = df['return_1'] + df['log_return'] = df['log_return_1'] + + logger.debug(f"Computed returns for periods: {periods}") + return df + + def compute_acceleration( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute return acceleration (second derivative of price). + + Acceleration measures how fast returns are changing. + Positive acceleration = returns are increasing + Negative acceleration = returns are decreasing + + Args: + df: DataFrame with return columns + periods: Periods to compute acceleration for + + Returns: + DataFrame with acceleration columns added + """ + if periods is None: + periods = self.config.return_periods + + df = df.copy() + + for period in periods: + return_col = f'return_{period}' + if return_col not in df.columns: + continue + + # Acceleration = change in returns + returns = df[return_col] + acceleration = returns - returns.shift(1) + df[f'acceleration_{period}'] = acceleration + + # Jerk (third derivative) for higher-order dynamics + jerk = acceleration - acceleration.shift(1) + df[f'jerk_{period}'] = jerk + + logger.debug(f"Computed acceleration for periods: {periods}") + return df + + def compute_volatility_returns( + self, + df: pd.DataFrame, + window: Optional[int] = None + ) -> pd.DataFrame: + """ + Compute volatility of returns (rolling standard deviation). + + Volatility is a key feature for attention mechanisms as it + indicates periods of high uncertainty vs stability. + + Args: + df: DataFrame with return columns + window: Rolling window size (defaults to config.volatility_window) + + Returns: + DataFrame with volatility columns added + """ + if window is None: + window = self.config.volatility_window + + df = df.copy() + + # Volatility of main return + if 'log_return' in df.columns: + df['volatility'] = df['log_return'].rolling(window=window).std() + df['volatility_pct'] = df['volatility'] * np.sqrt(252 * 12) # Annualized (5m) + + # Volatility ratio (current vs historical) + long_window = window * 2 + long_vol = df['log_return'].rolling(window=long_window).std() + df['volatility_ratio'] = df['volatility'] / (long_vol + self.config.epsilon) + + # Volatility change (momentum in volatility) + df['volatility_change'] = df['volatility'] - df['volatility'].shift(window // 2) + + # Range-based volatility (Parkinson) + if all(col in df.columns for col in ['high', 'low']): + log_hl = np.log(df['high'] / df['low'] + self.config.epsilon) + parkinson = log_hl ** 2 / (4 * np.log(2)) + df['parkinson_vol'] = np.sqrt(parkinson.rolling(window=window).mean()) + + logger.debug(f"Computed volatility with window={window}") + return df + + def compute_skewness_kurtosis( + self, + df: pd.DataFrame, + window: Optional[int] = None + ) -> pd.DataFrame: + """ + Compute rolling skewness and kurtosis of returns. + + These higher-order moments capture: + - Skewness: asymmetry of return distribution + - Kurtosis: tail heaviness (fat tails = more extreme events) + + Args: + df: DataFrame with return columns + window: Rolling window size (defaults to config.stats_window) + + Returns: + DataFrame with skewness and kurtosis columns + """ + if window is None: + window = self.config.stats_window + + df = df.copy() + + if 'log_return' not in df.columns: + logger.warning("log_return column not found, skipping stats computation") + return df + + returns = df['log_return'] + + # Rolling mean and std for standardization + rolling_mean = returns.rolling(window=window).mean() + rolling_std = returns.rolling(window=window).std() + self.config.epsilon + + # Standardized returns + standardized = (returns - rolling_mean) / rolling_std + + # Rolling skewness (3rd moment) + skewness = standardized.pow(3).rolling(window=window).mean() + df['return_skewness'] = skewness + + # Rolling kurtosis (4th moment) - excess kurtosis + kurtosis = standardized.pow(4).rolling(window=window).mean() - 3.0 + df['return_kurtosis'] = kurtosis + + # Normalized skewness (bounded) + max_skew = self.config.zscore_clip + df['return_skewness_norm'] = np.clip(skewness, -max_skew, max_skew) / max_skew + + # Normalized kurtosis (bounded) + max_kurt = self.config.zscore_clip * 2 + df['return_kurtosis_norm'] = np.clip(kurtosis, -max_kurt, max_kurt) / max_kurt + + logger.debug(f"Computed skewness/kurtosis with window={window}") + return df + + def compute_price_features(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Compute price-derived features (non-temporal). + + These features capture candle characteristics: + - Range percentage (volatility proxy) + - Body percentage (direction strength) + - Wick ratios (rejection/absorption) + + Args: + df: DataFrame with OHLC columns + + Returns: + DataFrame with price features added + """ + df = df.copy() + eps = self.config.epsilon + + required_cols = ['open', 'high', 'low', 'close'] + if not all(col in df.columns for col in required_cols): + logger.warning(f"Missing OHLC columns, skipping price features") + return df + + open_p = df['open'] + high = df['high'] + low = df['low'] + close = df['close'] + + # Range as percentage of close (intrabar volatility) + df['range_pct'] = (high - low) / (close + eps) + + # Body as percentage of range (direction strength) + body = np.abs(close - open_p) + range_val = high - low + eps + df['body_pct'] = body / range_val + + # Direction: 1 for bullish, -1 for bearish, scaled by body + direction = np.sign(close - open_p) + df['direction_strength'] = direction * df['body_pct'] + + # Upper wick ratio + body_high = np.maximum(close, open_p) + df['upper_wick_pct'] = (high - body_high) / range_val + + # Lower wick ratio + body_low = np.minimum(close, open_p) + df['lower_wick_pct'] = (body_low - low) / range_val + + # Wick imbalance (positive = more upper wick) + df['wick_imbalance'] = df['upper_wick_pct'] - df['lower_wick_pct'] + + # Close position in range (0 = low, 1 = high) + df['close_position'] = (close - low) / range_val + + # Gap from previous close (if available) + prev_close = close.shift(1) + df['gap_pct'] = (open_p - prev_close) / (prev_close + eps) + + logger.debug("Computed price-derived features") + return df + + def compute_momentum_features( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute momentum-based features from returns. + + Momentum features capture trend strength and persistence. + + Args: + df: DataFrame with return columns + periods: Lookback periods for momentum + + Returns: + DataFrame with momentum features + """ + if periods is None: + periods = self.config.return_periods + + df = df.copy() + + if 'close' not in df.columns: + return df + + close = df['close'] + + for period in periods: + # Rate of Change (ROC) + roc = (close - close.shift(period)) / (close.shift(period) + self.config.epsilon) + df[f'roc_{period}'] = roc + + # Momentum (simple difference, normalized) + momentum = close - close.shift(period) + momentum_std = momentum.rolling(window=period * 2).std() + self.config.epsilon + df[f'momentum_{period}'] = momentum / momentum_std + + # Trend consistency (ratio of positive returns) + if 'return_1' in df.columns: + window = self.config.volatility_window + positive_ratio = (df['return_1'] > 0).rolling(window=window).mean() + df['trend_consistency'] = 2 * positive_ratio - 1 # Scale to [-1, 1] + + # Consecutive direction count (normalized) + direction = np.sign(df['return_1']) + direction_change = (direction != direction.shift(1)).astype(int) + + # Reset counter on direction change + groups = direction_change.cumsum() + consecutive = df.groupby(groups).cumcount() + 1 + max_consecutive = window + df['consecutive_direction'] = np.clip(consecutive, 0, max_consecutive) / max_consecutive + df['consecutive_direction'] *= direction # Keep sign + + logger.debug(f"Computed momentum features for periods: {periods}") + return df + + def compute_all_features(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Compute all PVA features from OHLCV data. + + This is the main entry point for feature computation. + Applies all feature transformations in the correct order. + + Args: + df: DataFrame with OHLCV columns (indexed by timestamp) + + Returns: + DataFrame with all computed features + """ + logger.info(f"Computing all PVA features for {len(df)} rows") + + # Step 1: Returns at multiple horizons + df = self.compute_returns(df) + + # Step 2: Acceleration (second derivative) + df = self.compute_acceleration(df) + + # Step 3: Volatility of returns + df = self.compute_volatility_returns(df) + + # Step 4: Statistical moments (skewness, kurtosis) + df = self.compute_skewness_kurtosis(df) + + # Step 5: Price-derived features + df = self.compute_price_features(df) + + # Step 6: Momentum features + df = self.compute_momentum_features(df) + + # Store feature names (exclude OHLCV and timestamp) + exclude_cols = {'open', 'high', 'low', 'close', 'volume', 'vwap', 'timestamp'} + self._feature_names = [col for col in df.columns if col not in exclude_cols] + + logger.info(f"Computed {len(self._feature_names)} features") + logger.debug(f"Feature names: {self._feature_names}") + + return df + + def prepare_sequences( + self, + df: pd.DataFrame, + seq_length: Optional[int] = None, + feature_cols: Optional[List[str]] = None, + stride: int = 1 + ) -> np.ndarray: + """ + Prepare sequences of features for transformer input. + + Converts the DataFrame to overlapping sequences suitable + for sequence-to-sequence models like transformers. + + Args: + df: DataFrame with computed features + seq_length: Sequence length (defaults to config.sequence_length) + feature_cols: Feature columns to include (defaults to all computed) + stride: Step size between sequences + + Returns: + numpy array of shape (n_sequences, seq_length, n_features) + """ + if seq_length is None: + seq_length = self.config.sequence_length + + if feature_cols is None: + feature_cols = self._feature_names + if not feature_cols: + # Auto-detect feature columns + exclude_cols = {'open', 'high', 'low', 'close', 'volume', 'vwap', 'timestamp'} + feature_cols = [col for col in df.columns if col not in exclude_cols] + + # Extract feature matrix + features = df[feature_cols].values.astype(np.float32) + + # Handle NaN values + features = np.nan_to_num(features, nan=0.0, posinf=0.0, neginf=0.0) + + n_samples = len(features) + n_features = len(feature_cols) + + # Calculate number of valid sequences + n_sequences = (n_samples - seq_length) // stride + 1 + + if n_sequences <= 0: + logger.warning(f"Not enough data for sequences. Need {seq_length}, have {n_samples}") + return np.array([]).reshape(0, seq_length, n_features) + + # Create sequences using strides + sequences = np.zeros((n_sequences, seq_length, n_features), dtype=np.float32) + + for i in range(n_sequences): + start_idx = i * stride + end_idx = start_idx + seq_length + sequences[i] = features[start_idx:end_idx] + + logger.info(f"Prepared {n_sequences} sequences of shape ({seq_length}, {n_features})") + return sequences + + def prepare_training_data( + self, + df: pd.DataFrame, + target_horizon: int = 12, + target_type: str = 'return', + seq_length: Optional[int] = None, + val_ratio: float = 0.0 + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Prepare complete training data with features and targets. + + Convenience method that computes features, creates sequences, + and generates targets in one call. + + Args: + df: DataFrame with OHLCV data + target_horizon: Number of periods ahead for target + target_type: 'return' for regression, 'direction' for classification + seq_length: Sequence length for transformer + val_ratio: If > 0, returns (X_train, y_train, X_val, y_val) + + Returns: + Tuple of (X, y) numpy arrays + X shape: (n_samples, seq_length, n_features) + y shape: (n_samples,) for single target + """ + if seq_length is None: + seq_length = self.config.sequence_length + + # Compute all features + df = self.compute_all_features(df) + + # Compute target + if target_type == 'return': + target = df['close'].pct_change(target_horizon).shift(-target_horizon) + elif target_type == 'direction': + future_return = df['close'].pct_change(target_horizon).shift(-target_horizon) + target = (future_return > 0).astype(np.float32) + elif target_type == 'magnitude': + target = np.abs(df['close'].pct_change(target_horizon).shift(-target_horizon)) + else: + raise ValueError(f"Unknown target_type: {target_type}") + + df['_target'] = target + + # Drop rows with NaN target (end of series) + valid_mask = ~df['_target'].isna() + df_valid = df[valid_mask].copy() + + # Get feature columns + feature_cols = self._feature_names + + # Prepare sequences + X = self.prepare_sequences(df_valid, seq_length=seq_length, feature_cols=feature_cols) + + # Extract targets aligned with sequences + targets = df_valid['_target'].values.astype(np.float32) + y = targets[seq_length - 1:] # Align with last position of each sequence + + # Ensure X and y have same length + min_len = min(len(X), len(y)) + X = X[:min_len] + y = y[:min_len] + + logger.info(f"Prepared training data: X={X.shape}, y={y.shape}") + + if val_ratio > 0: + split_idx = int(len(X) * (1 - val_ratio)) + X_train, X_val = X[:split_idx], X[split_idx:] + y_train, y_val = y[:split_idx], y[split_idx:] + return X_train, y_train, X_val, y_val + + return X, y + + def get_feature_names(self) -> List[str]: + """Get list of computed feature names.""" + return self._feature_names.copy() + + def get_n_features(self) -> int: + """Get number of features.""" + return len(self._feature_names) + + def to_torch( + self, + X: np.ndarray, + y: Optional[np.ndarray] = None, + device: str = 'cpu' + ) -> Tuple: + """ + Convert numpy arrays to PyTorch tensors. + + Args: + X: Feature sequences + y: Optional targets + device: Device to place tensors ('cpu' or 'cuda') + + Returns: + Tuple of (X_tensor, y_tensor) or just X_tensor if y is None + """ + if not TORCH_AVAILABLE: + raise ImportError("PyTorch not available. Install with: pip install torch") + + X_tensor = torch.from_numpy(X).to(device) + + if y is not None: + y_tensor = torch.from_numpy(y).to(device) + return X_tensor, y_tensor + + return X_tensor + + +def compute_pva_features( + df: pd.DataFrame, + return_periods: Optional[List[int]] = None, + volatility_window: int = 20, + stats_window: int = 50 +) -> pd.DataFrame: + """ + Convenience function to compute PVA features. + + Args: + df: DataFrame with OHLCV data + return_periods: Periods for return calculation + volatility_window: Window for volatility computation + stats_window: Window for statistical moments + + Returns: + DataFrame with all PVA features + """ + config = PVAFeatureConfig( + return_periods=return_periods or [1, 5, 10, 20], + volatility_window=volatility_window, + stats_window=stats_window + ) + + engineer = PVAFeatureEngineer(config) + return engineer.compute_all_features(df) + + +if __name__ == "__main__": + # Test the feature engineer + print("Testing PVAFeatureEngineer...") + + # Create sample OHLCV data + np.random.seed(42) + n = 5000 + + dates = pd.date_range('2024-01-01', periods=n, freq='5min') + price = 2650 + np.cumsum(np.random.randn(n) * 0.5) + + df = pd.DataFrame({ + 'open': price, + 'high': price + np.abs(np.random.randn(n)) * 2, + 'low': price - np.abs(np.random.randn(n)) * 2, + 'close': price + np.random.randn(n) * 0.5, + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Initialize engineer + engineer = PVAFeatureEngineer() + + # Compute all features + df_features = engineer.compute_all_features(df) + print(f"\nFeatures computed: {len(engineer.get_feature_names())}") + print(f"Feature names: {engineer.get_feature_names()[:10]}...") + + # Prepare sequences + sequences = engineer.prepare_sequences(df_features, seq_length=100) + print(f"\nSequences shape: {sequences.shape}") + + # Prepare training data + X, y = engineer.prepare_training_data(df, target_horizon=12, target_type='return') + print(f"\nTraining data:") + print(f" X shape: {X.shape}") + print(f" y shape: {y.shape}") + print(f" y mean: {y.mean():.6f}") + print(f" y std: {y.std():.6f}") + + # Test torch conversion + if TORCH_AVAILABLE: + X_tensor, y_tensor = engineer.to_torch(X, y) + print(f"\nTorch tensors:") + print(f" X: {X_tensor.shape}, dtype={X_tensor.dtype}") + print(f" y: {y_tensor.shape}, dtype={y_tensor.dtype}") + + print("\nTest complete!") diff --git a/src/models/strategies/pva/model.py b/src/models/strategies/pva/model.py new file mode 100644 index 0000000..3fa314f --- /dev/null +++ b/src/models/strategies/pva/model.py @@ -0,0 +1,921 @@ +#!/usr/bin/env python3 +""" +PVA (Price Variation Attention) Model +====================================== +Complete PVA model combining transformer attention encoder with XGBoost head. + +This module implements the full PVA architecture: +1. Transformer encoder (PriceFocusedAttention) for sequence representation +2. XGBoost head for final prediction +3. Outputs: direction, magnitude, confidence + +Architecture: + Input (seq_len, n_features) + | + v + [PriceFocusedAttention Encoder] + (4 layers, 256 d_model, 8 heads) + | + v + [Sequence Pooling: last/mean] + | + v + [XGBoost Head] + | + v + Output: (direction, magnitude, confidence) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from typing import Tuple, List, Optional, Dict, Any, Union +from dataclasses import dataclass, field +from pathlib import Path +import json + +import numpy as np +import pandas as pd +from loguru import logger + +try: + import torch + import torch.nn as nn + import torch.nn.functional as F + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + logger.warning("PyTorch not available") + +try: + import xgboost as xgb + XGB_AVAILABLE = True +except ImportError: + XGB_AVAILABLE = False + logger.warning("XGBoost not available") + +try: + import joblib + JOBLIB_AVAILABLE = True +except ImportError: + JOBLIB_AVAILABLE = False + +# Import attention module from parent +from ...attention import PriceFocusedAttention, PriceAttentionConfig + + +@dataclass +class PVAConfig: + """ + Configuration for PVA (Price Variation Attention) Model. + + Contains all hyperparameters for both the transformer encoder + and the XGBoost prediction head. + """ + + # Transformer Encoder Config + d_model: int = 256 + n_heads: int = 8 + n_layers: int = 4 + d_ff: int = 1024 + dropout: float = 0.1 + max_seq_len: int = 512 + attention_dropout: float = 0.1 + + # Input dimensions + input_features: int = 30 # Determined by feature engineering + sequence_length: int = 100 + + # Pooling strategy for sequence representation + pooling: str = "last" # "last", "mean", "max", "first" + + # XGBoost Head Config + xgb_n_estimators: int = 200 + xgb_max_depth: int = 6 + xgb_learning_rate: float = 0.05 + xgb_subsample: float = 0.8 + xgb_colsample_bytree: float = 0.8 + xgb_reg_alpha: float = 0.1 + xgb_reg_lambda: float = 1.0 + xgb_min_child_weight: int = 3 + xgb_gamma: float = 0.1 + + # Confidence calibration + confidence_temperature: float = 1.5 + + # Output configuration + output_direction: bool = True + output_magnitude: bool = True + output_confidence: bool = True + + # Training device + device: str = "cpu" + + def to_attention_config(self) -> PriceAttentionConfig: + """Convert to PriceAttentionConfig for attention module.""" + return PriceAttentionConfig( + d_model=self.d_model, + n_heads=self.n_heads, + n_layers=self.n_layers, + d_ff=self.d_ff, + dropout=self.dropout, + attention_dropout=self.attention_dropout, + max_seq_len=self.max_seq_len, + input_features=self.input_features + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert config to dictionary.""" + return { + 'd_model': self.d_model, + 'n_heads': self.n_heads, + 'n_layers': self.n_layers, + 'd_ff': self.d_ff, + 'dropout': self.dropout, + 'max_seq_len': self.max_seq_len, + 'attention_dropout': self.attention_dropout, + 'input_features': self.input_features, + 'sequence_length': self.sequence_length, + 'pooling': self.pooling, + 'xgb_n_estimators': self.xgb_n_estimators, + 'xgb_max_depth': self.xgb_max_depth, + 'xgb_learning_rate': self.xgb_learning_rate, + 'xgb_subsample': self.xgb_subsample, + 'xgb_colsample_bytree': self.xgb_colsample_bytree, + 'xgb_reg_alpha': self.xgb_reg_alpha, + 'xgb_reg_lambda': self.xgb_reg_lambda, + 'xgb_min_child_weight': self.xgb_min_child_weight, + 'xgb_gamma': self.xgb_gamma, + 'confidence_temperature': self.confidence_temperature, + 'output_direction': self.output_direction, + 'output_magnitude': self.output_magnitude, + 'output_confidence': self.output_confidence, + 'device': self.device + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> 'PVAConfig': + """Create config from dictionary.""" + return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class PVAPrediction: + """Output prediction from PVA model.""" + + direction: float # -1 to 1 (bearish to bullish) + magnitude: float # Expected return magnitude (absolute) + confidence: float # 0 to 1 confidence score + raw_prediction: float # Raw model output + attention_weights: Optional[np.ndarray] = None # Attention for interpretability + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'direction': float(self.direction), + 'magnitude': float(self.magnitude), + 'confidence': float(self.confidence), + 'raw_prediction': float(self.raw_prediction) + } + + +class PVAModel(nn.Module): + """ + PVA (Price Variation Attention) Model. + + Combines a transformer-based attention encoder for sequence + representation with an XGBoost head for robust predictions. + + The model is designed for price movement prediction without + relying on temporal features (hour, day, session). + + Architecture: + 1. PriceFocusedAttention encoder processes sequences + 2. Pooling layer extracts fixed-size representation + 3. XGBoost head predicts direction and magnitude + + Usage: + config = PVAConfig(input_features=30, sequence_length=100) + model = PVAModel(config) + + # Training (two-stage) + model.fit_encoder(X_train, y_train, X_val, y_val) + model.fit_xgboost(X_train, y_train) + + # Inference + prediction = model.predict(X_test) + direction, magnitude, confidence = prediction.direction, ... + + # Get attention scores for interpretability + attention_scores = model.get_attention_scores(X_test) + """ + + def __init__(self, config: Optional[PVAConfig] = None): + """ + Initialize PVA model. + + Args: + config: Model configuration (uses defaults if None) + """ + if not TORCH_AVAILABLE: + raise ImportError("PyTorch is required. Install with: pip install torch") + + super().__init__() + + self.config = config or PVAConfig() + self.device = torch.device(self.config.device) + + # Initialize attention encoder + attention_config = self.config.to_attention_config() + self.attention_encoder = PriceFocusedAttention( + config=attention_config, + input_features=self.config.input_features + ) + + # XGBoost head (initialized later during training) + self._xgb_direction: Optional[xgb.XGBClassifier] = None + self._xgb_magnitude: Optional[xgb.XGBRegressor] = None + + # Training state + self._is_trained = False + self._encoder_trained = False + + # Move to device + self.to(self.device) + + logger.info(f"PVAModel initialized") + logger.info(f" Device: {self.device}") + logger.info(f" Encoder: {self.config.n_layers} layers, {self.config.d_model} dim") + logger.info(f" Pooling: {self.config.pooling}") + + def forward( + self, + x: torch.Tensor, + return_attention: bool = False + ) -> Tuple[torch.Tensor, Optional[List[torch.Tensor]]]: + """ + Forward pass through attention encoder. + + Args: + x: Input tensor of shape (batch, seq_len, n_features) + return_attention: Whether to return attention weights + + Returns: + encoded: Encoded representation (batch, d_model) + attentions: Optional list of attention weight tensors + """ + # Ensure input is on correct device + x = x.to(self.device) + + # Pass through attention encoder + encoded_seq, attentions = self.attention_encoder( + x, return_all_attentions=return_attention + ) + + # Apply pooling to get fixed-size representation + if self.config.pooling == "last": + encoded = encoded_seq[:, -1, :] + elif self.config.pooling == "first": + encoded = encoded_seq[:, 0, :] + elif self.config.pooling == "mean": + encoded = encoded_seq.mean(dim=1) + elif self.config.pooling == "max": + encoded = encoded_seq.max(dim=1)[0] + else: + raise ValueError(f"Unknown pooling: {self.config.pooling}") + + if return_attention: + return encoded, attentions + return encoded, None + + def encode(self, x: Union[np.ndarray, torch.Tensor]) -> np.ndarray: + """ + Encode sequences to fixed-size representations. + + Used to generate features for XGBoost head. + + Args: + x: Input sequences (batch, seq_len, features) or (seq_len, features) + + Returns: + Encoded representations as numpy array (batch, d_model) + """ + self.eval() + + # Handle single sequence + if isinstance(x, np.ndarray): + x = torch.from_numpy(x).float() + + if x.dim() == 2: + x = x.unsqueeze(0) + + with torch.no_grad(): + x = x.to(self.device) + encoded, _ = self.forward(x, return_attention=False) + + return encoded.cpu().numpy() + + def fit_encoder( + self, + X_train: np.ndarray, + y_train: np.ndarray, + X_val: Optional[np.ndarray] = None, + y_val: Optional[np.ndarray] = None, + epochs: int = 50, + batch_size: int = 64, + learning_rate: float = 1e-4, + early_stopping_patience: int = 10 + ) -> Dict[str, List[float]]: + """ + Train the attention encoder with supervised learning. + + Uses MSE loss for return prediction as pre-training task. + + Args: + X_train: Training sequences (n_samples, seq_len, n_features) + y_train: Training targets (n_samples,) + X_val: Validation sequences (optional) + y_val: Validation targets (optional) + epochs: Number of training epochs + batch_size: Batch size + learning_rate: Learning rate + early_stopping_patience: Patience for early stopping + + Returns: + Dictionary with training history + """ + logger.info(f"Training encoder: {epochs} epochs, batch_size={batch_size}") + + # Create prediction head for encoder training + encoder_head = nn.Linear(self.config.d_model, 1).to(self.device) + + # Combine parameters + all_params = list(self.attention_encoder.parameters()) + list(encoder_head.parameters()) + optimizer = torch.optim.AdamW(all_params, lr=learning_rate, weight_decay=0.01) + scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, mode='min', factor=0.5, patience=5 + ) + criterion = nn.MSELoss() + + # Convert to tensors + X_train_t = torch.from_numpy(X_train).float() + y_train_t = torch.from_numpy(y_train).float().view(-1, 1) + + if X_val is not None and y_val is not None: + X_val_t = torch.from_numpy(X_val).float().to(self.device) + y_val_t = torch.from_numpy(y_val).float().view(-1, 1).to(self.device) + else: + X_val_t, y_val_t = None, None + + # Training loop + history = {'train_loss': [], 'val_loss': []} + best_val_loss = float('inf') + patience_counter = 0 + best_state = None + + n_batches = (len(X_train) + batch_size - 1) // batch_size + + for epoch in range(epochs): + self.train() + encoder_head.train() + + # Shuffle data + indices = np.random.permutation(len(X_train)) + epoch_loss = 0.0 + + for batch_idx in range(n_batches): + start_idx = batch_idx * batch_size + end_idx = min(start_idx + batch_size, len(X_train)) + batch_indices = indices[start_idx:end_idx] + + X_batch = X_train_t[batch_indices].to(self.device) + y_batch = y_train_t[batch_indices].to(self.device) + + optimizer.zero_grad() + + # Forward pass + encoded, _ = self.forward(X_batch) + predictions = encoder_head(encoded) + + loss = criterion(predictions, y_batch) + loss.backward() + + # Gradient clipping + torch.nn.utils.clip_grad_norm_(all_params, max_norm=1.0) + + optimizer.step() + epoch_loss += loss.item() + + epoch_loss /= n_batches + history['train_loss'].append(epoch_loss) + + # Validation + if X_val_t is not None: + self.eval() + encoder_head.eval() + + with torch.no_grad(): + encoded_val, _ = self.forward(X_val_t) + val_preds = encoder_head(encoded_val) + val_loss = criterion(val_preds, y_val_t).item() + + history['val_loss'].append(val_loss) + scheduler.step(val_loss) + + # Early stopping + if val_loss < best_val_loss: + best_val_loss = val_loss + patience_counter = 0 + best_state = { + 'encoder': self.attention_encoder.state_dict(), + 'head': encoder_head.state_dict() + } + else: + patience_counter += 1 + + if patience_counter >= early_stopping_patience: + logger.info(f"Early stopping at epoch {epoch + 1}") + break + + if (epoch + 1) % 10 == 0: + logger.info( + f"Epoch {epoch + 1}/{epochs} - " + f"Train Loss: {epoch_loss:.6f}, Val Loss: {val_loss:.6f}" + ) + else: + if (epoch + 1) % 10 == 0: + logger.info(f"Epoch {epoch + 1}/{epochs} - Train Loss: {epoch_loss:.6f}") + + # Restore best model + if best_state is not None: + self.attention_encoder.load_state_dict(best_state['encoder']) + + self._encoder_trained = True + logger.info(f"Encoder training complete. Best val loss: {best_val_loss:.6f}") + + return history + + def fit_xgboost( + self, + X_train: np.ndarray, + y_train: np.ndarray, + X_val: Optional[np.ndarray] = None, + y_val: Optional[np.ndarray] = None + ) -> Dict[str, Any]: + """ + Train XGBoost head on encoded representations. + + Trains both direction classifier and magnitude regressor. + + Args: + X_train: Training sequences (n_samples, seq_len, n_features) + y_train: Training targets (n_samples,) - signed returns + X_val: Validation sequences + y_val: Validation targets + + Returns: + Dictionary with training metrics + """ + if not XGB_AVAILABLE: + raise ImportError("XGBoost is required. Install with: pip install xgboost") + + logger.info("Training XGBoost head on encoded representations...") + + # Encode all training data + self.eval() + encoded_train = self.encode(X_train) + + if X_val is not None: + encoded_val = self.encode(X_val) + + # Prepare targets + direction_train = (y_train > 0).astype(int) + magnitude_train = np.abs(y_train) + + # Initialize XGBoost models + self._xgb_direction = xgb.XGBClassifier( + n_estimators=self.config.xgb_n_estimators, + max_depth=self.config.xgb_max_depth, + learning_rate=self.config.xgb_learning_rate, + subsample=self.config.xgb_subsample, + colsample_bytree=self.config.xgb_colsample_bytree, + reg_alpha=self.config.xgb_reg_alpha, + reg_lambda=self.config.xgb_reg_lambda, + min_child_weight=self.config.xgb_min_child_weight, + gamma=self.config.xgb_gamma, + objective='binary:logistic', + eval_metric='logloss', + use_label_encoder=False, + random_state=42, + n_jobs=-1 + ) + + self._xgb_magnitude = xgb.XGBRegressor( + n_estimators=self.config.xgb_n_estimators, + max_depth=self.config.xgb_max_depth, + learning_rate=self.config.xgb_learning_rate, + subsample=self.config.xgb_subsample, + colsample_bytree=self.config.xgb_colsample_bytree, + reg_alpha=self.config.xgb_reg_alpha, + reg_lambda=self.config.xgb_reg_lambda, + min_child_weight=self.config.xgb_min_child_weight, + gamma=self.config.xgb_gamma, + objective='reg:squarederror', + random_state=42, + n_jobs=-1 + ) + + # Train direction classifier + if X_val is not None: + direction_val = (y_val > 0).astype(int) + self._xgb_direction.fit( + encoded_train, direction_train, + eval_set=[(encoded_val, direction_val)], + verbose=False + ) + else: + self._xgb_direction.fit(encoded_train, direction_train) + + logger.info("Direction classifier trained") + + # Train magnitude regressor + if X_val is not None: + magnitude_val = np.abs(y_val) + self._xgb_magnitude.fit( + encoded_train, magnitude_train, + eval_set=[(encoded_val, magnitude_val)], + verbose=False + ) + else: + self._xgb_magnitude.fit(encoded_train, magnitude_train) + + logger.info("Magnitude regressor trained") + + self._is_trained = True + + # Compute training metrics + metrics = self._compute_training_metrics( + encoded_train, direction_train, magnitude_train, + encoded_val if X_val is not None else None, + direction_val if X_val is not None else None, + magnitude_val if X_val is not None else None + ) + + return metrics + + def _compute_training_metrics( + self, + X_train: np.ndarray, + y_dir_train: np.ndarray, + y_mag_train: np.ndarray, + X_val: Optional[np.ndarray] = None, + y_dir_val: Optional[np.ndarray] = None, + y_mag_val: Optional[np.ndarray] = None + ) -> Dict[str, Any]: + """Compute training metrics for XGBoost head.""" + from sklearn.metrics import accuracy_score, mean_squared_error, mean_absolute_error + + metrics = {} + + # Training metrics + dir_pred_train = self._xgb_direction.predict(X_train) + mag_pred_train = self._xgb_magnitude.predict(X_train) + + metrics['train_direction_accuracy'] = accuracy_score(y_dir_train, dir_pred_train) + metrics['train_magnitude_mse'] = mean_squared_error(y_mag_train, mag_pred_train) + metrics['train_magnitude_mae'] = mean_absolute_error(y_mag_train, mag_pred_train) + + # Validation metrics + if X_val is not None: + dir_pred_val = self._xgb_direction.predict(X_val) + mag_pred_val = self._xgb_magnitude.predict(X_val) + + metrics['val_direction_accuracy'] = accuracy_score(y_dir_val, dir_pred_val) + metrics['val_magnitude_mse'] = mean_squared_error(y_mag_val, mag_pred_val) + metrics['val_magnitude_mae'] = mean_absolute_error(y_mag_val, mag_pred_val) + + logger.info(f"Training metrics: {metrics}") + return metrics + + def predict( + self, + x: Union[np.ndarray, torch.Tensor], + return_attention: bool = False + ) -> Union[PVAPrediction, List[PVAPrediction]]: + """ + Make prediction for input sequence(s). + + Args: + x: Input sequence(s) (seq_len, features) or (batch, seq_len, features) + return_attention: Whether to include attention weights + + Returns: + PVAPrediction or list of PVAPrediction for batch input + """ + if not self._is_trained: + raise RuntimeError("Model not trained. Call fit_encoder() and fit_xgboost() first.") + + self.eval() + + # Handle input dimensions + single_input = False + if isinstance(x, np.ndarray): + if x.ndim == 2: + x = x[np.newaxis, ...] + single_input = True + x = torch.from_numpy(x).float() + elif x.dim() == 2: + x = x.unsqueeze(0) + single_input = True + + x = x.to(self.device) + + with torch.no_grad(): + # Get encoded representation + encoded, attentions = self.forward(x, return_attention=return_attention) + encoded_np = encoded.cpu().numpy() + + # Get attention weights if requested + attention_weights = None + if return_attention and attentions: + attention_weights = attentions[-1].cpu().numpy() + + # XGBoost predictions + direction_probs = self._xgb_direction.predict_proba(encoded_np)[:, 1] + direction = 2 * direction_probs - 1 # Map [0,1] to [-1,1] + + magnitude = self._xgb_magnitude.predict(encoded_np) + + # Compute confidence (calibrated) + confidence = self._compute_confidence(direction_probs, magnitude) + + # Raw prediction (signed return) + raw_pred = direction * magnitude + + # Create predictions + predictions = [] + for i in range(len(encoded_np)): + pred = PVAPrediction( + direction=float(direction[i]), + magnitude=float(magnitude[i]), + confidence=float(confidence[i]), + raw_prediction=float(raw_pred[i]), + attention_weights=attention_weights[i] if attention_weights is not None else None + ) + predictions.append(pred) + + if single_input: + return predictions[0] + return predictions + + def _compute_confidence( + self, + direction_probs: np.ndarray, + magnitude: np.ndarray + ) -> np.ndarray: + """ + Compute calibrated confidence score. + + Combines direction certainty and magnitude information. + + Args: + direction_probs: Probability of upward movement [0,1] + magnitude: Predicted magnitude + + Returns: + Confidence scores [0,1] + """ + # Direction certainty (how far from 0.5) + direction_certainty = np.abs(direction_probs - 0.5) * 2 + + # Temperature-scaled softmax for calibration + temp = self.config.confidence_temperature + scaled_certainty = np.exp(direction_certainty / temp) + scaled_certainty = scaled_certainty / (1 + scaled_certainty) + + # Magnitude component (higher magnitude = higher confidence, bounded) + magnitude_factor = np.tanh(magnitude * 100) # Scale for typical return magnitudes + + # Combined confidence + confidence = 0.7 * scaled_certainty + 0.3 * magnitude_factor + confidence = np.clip(confidence, 0.0, 1.0) + + return confidence + + def get_attention_scores( + self, + x: Union[np.ndarray, torch.Tensor], + layer_idx: int = -1 + ) -> np.ndarray: + """ + Get attention scores for interpretability analysis. + + Args: + x: Input sequence(s) + layer_idx: Which layer's attention to return (-1 = last) + + Returns: + Attention scores of shape (batch, n_heads, seq_len, seq_len) + """ + self.eval() + + if isinstance(x, np.ndarray): + x = torch.from_numpy(x).float() + + if x.dim() == 2: + x = x.unsqueeze(0) + + x = x.to(self.device) + + with torch.no_grad(): + _, attentions = self.forward(x, return_attention=True) + + if not attentions: + raise RuntimeError("No attention weights available") + + return attentions[layer_idx].cpu().numpy() + + def save(self, path: str) -> None: + """ + Save model to disk. + + Args: + path: Directory path to save model + """ + save_dir = Path(path) + save_dir.mkdir(parents=True, exist_ok=True) + + # Save config + config_path = save_dir / "config.json" + with open(config_path, 'w') as f: + json.dump(self.config.to_dict(), f, indent=2) + + # Save encoder state + encoder_path = save_dir / "encoder.pt" + torch.save(self.attention_encoder.state_dict(), encoder_path) + + # Save XGBoost models + if self._xgb_direction is not None: + direction_path = save_dir / "xgb_direction.json" + self._xgb_direction.save_model(str(direction_path)) + + if self._xgb_magnitude is not None: + magnitude_path = save_dir / "xgb_magnitude.json" + self._xgb_magnitude.save_model(str(magnitude_path)) + + # Save training state + state_path = save_dir / "training_state.json" + with open(state_path, 'w') as f: + json.dump({ + 'is_trained': self._is_trained, + 'encoder_trained': self._encoder_trained + }, f) + + logger.info(f"Model saved to {save_dir}") + + @classmethod + def load(cls, path: str, device: str = "cpu") -> 'PVAModel': + """ + Load model from disk. + + Args: + path: Directory path containing saved model + device: Device to load model to + + Returns: + Loaded PVAModel instance + """ + load_dir = Path(path) + + # Load config + config_path = load_dir / "config.json" + with open(config_path, 'r') as f: + config_dict = json.load(f) + + config_dict['device'] = device + config = PVAConfig.from_dict(config_dict) + + # Create model + model = cls(config) + + # Load encoder state + encoder_path = load_dir / "encoder.pt" + model.attention_encoder.load_state_dict( + torch.load(encoder_path, map_location=device) + ) + + # Load XGBoost models + direction_path = load_dir / "xgb_direction.json" + if direction_path.exists(): + model._xgb_direction = xgb.XGBClassifier() + model._xgb_direction.load_model(str(direction_path)) + + magnitude_path = load_dir / "xgb_magnitude.json" + if magnitude_path.exists(): + model._xgb_magnitude = xgb.XGBRegressor() + model._xgb_magnitude.load_model(str(magnitude_path)) + + # Load training state + state_path = load_dir / "training_state.json" + if state_path.exists(): + with open(state_path, 'r') as f: + state = json.load(f) + model._is_trained = state.get('is_trained', False) + model._encoder_trained = state.get('encoder_trained', False) + + logger.info(f"Model loaded from {load_dir}") + return model + + +def create_pva_model( + input_features: int = 30, + sequence_length: int = 100, + device: str = "cpu" +) -> PVAModel: + """ + Convenience function to create a PVA model with default configuration. + + Args: + input_features: Number of input features per timestep + sequence_length: Length of input sequences + device: Device to use ('cpu' or 'cuda') + + Returns: + Initialized PVAModel + """ + config = PVAConfig( + input_features=input_features, + sequence_length=sequence_length, + device=device + ) + return PVAModel(config) + + +if __name__ == "__main__": + # Test the PVA model + print("Testing PVAModel...") + + # Create sample data + np.random.seed(42) + n_samples = 1000 + seq_length = 100 + n_features = 30 + + X = np.random.randn(n_samples, seq_length, n_features).astype(np.float32) + y = np.random.randn(n_samples).astype(np.float32) * 0.01 # Small returns + + # Split data + split_idx = int(n_samples * 0.8) + X_train, X_val = X[:split_idx], X[split_idx:] + y_train, y_val = y[:split_idx], y[split_idx:] + + print(f"\nData shapes:") + print(f" X_train: {X_train.shape}") + print(f" X_val: {X_val.shape}") + + # Create model + config = PVAConfig( + input_features=n_features, + sequence_length=seq_length, + device="cpu", + n_layers=2, # Smaller for testing + d_model=64 + ) + model = PVAModel(config) + + # Train encoder (short training for test) + print("\nTraining encoder...") + history = model.fit_encoder( + X_train, y_train, X_val, y_val, + epochs=5, batch_size=32 + ) + print(f" Final train loss: {history['train_loss'][-1]:.6f}") + + # Train XGBoost head + print("\nTraining XGBoost head...") + metrics = model.fit_xgboost(X_train, y_train, X_val, y_val) + print(f" Direction accuracy: {metrics.get('val_direction_accuracy', 'N/A'):.4f}") + print(f" Magnitude MAE: {metrics.get('val_magnitude_mae', 'N/A'):.6f}") + + # Make predictions + print("\nMaking predictions...") + predictions = model.predict(X_val[:5]) + for i, pred in enumerate(predictions): + print(f" Sample {i}: dir={pred.direction:.3f}, mag={pred.magnitude:.6f}, conf={pred.confidence:.3f}") + + # Get attention scores + print("\nGetting attention scores...") + attention = model.get_attention_scores(X_val[:1]) + print(f" Attention shape: {attention.shape}") + + # Test save/load + print("\nTesting save/load...") + save_path = "/tmp/pva_model_test" + model.save(save_path) + + loaded_model = PVAModel.load(save_path) + loaded_pred = loaded_model.predict(X_val[0]) + print(f" Loaded model prediction: dir={loaded_pred.direction:.3f}") + + print("\nTest complete!") diff --git a/src/models/strategies/pva/trainer.py b/src/models/strategies/pva/trainer.py new file mode 100644 index 0000000..e51fc54 --- /dev/null +++ b/src/models/strategies/pva/trainer.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python3 +""" +PVA (Price Variation Attention) Trainer +======================================== +Training pipeline for the PVA strategy model. + +This module provides comprehensive training functionality: +- Single symbol training +- Walk-forward validation +- Model versioning and saving +- Training metrics and logging + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +from typing import Optional, Dict, Any, List, Tuple +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +import json + +import numpy as np +import pandas as pd +from loguru import logger + +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + +from .model import PVAModel, PVAConfig, PVAPrediction +from .feature_engineering import PVAFeatureEngineer, PVAFeatureConfig +from ...data import TrainingDataLoader, TrainingDataConfig + + +@dataclass +class PVATrainerConfig: + """Configuration for PVA model training.""" + + # Data loading + timeframe: str = '5m' + batch_size: int = 64 + + # Sequence parameters + sequence_length: int = 100 + target_horizon: int = 12 + + # Training parameters + encoder_epochs: int = 50 + encoder_learning_rate: float = 1e-4 + early_stopping_patience: int = 10 + + # Walk-forward validation + walk_forward_splits: int = 5 + walk_forward_test_size: float = 0.2 + walk_forward_gap: int = 0 + min_train_size: int = 10000 + + # Validation + val_ratio: float = 0.15 + + # Model saving + model_base_dir: str = "models/pva" + save_intermediate: bool = True + + # Feature engineering + return_periods: List[int] = field(default_factory=lambda: [1, 5, 10, 20]) + volatility_window: int = 20 + stats_window: int = 50 + + # Model architecture + d_model: int = 256 + n_heads: int = 8 + n_layers: int = 4 + d_ff: int = 1024 + dropout: float = 0.1 + + # XGBoost parameters + xgb_n_estimators: int = 200 + xgb_max_depth: int = 6 + xgb_learning_rate: float = 0.05 + + # Device + device: str = "cpu" + + +@dataclass +class TrainingMetrics: + """Metrics from a training run.""" + + symbol: str + timeframe: str + version: str + train_samples: int + val_samples: int + encoder_train_loss: float + encoder_val_loss: float + direction_accuracy: float + magnitude_mse: float + magnitude_mae: float + training_time_seconds: float + timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'symbol': self.symbol, + 'timeframe': self.timeframe, + 'version': self.version, + 'train_samples': self.train_samples, + 'val_samples': self.val_samples, + 'encoder_train_loss': self.encoder_train_loss, + 'encoder_val_loss': self.encoder_val_loss, + 'direction_accuracy': self.direction_accuracy, + 'magnitude_mse': self.magnitude_mse, + 'magnitude_mae': self.magnitude_mae, + 'training_time_seconds': self.training_time_seconds, + 'timestamp': self.timestamp + } + + +@dataclass +class WalkForwardResult: + """Results from walk-forward validation.""" + + symbol: str + n_folds: int + fold_metrics: List[Dict[str, Any]] + avg_direction_accuracy: float + std_direction_accuracy: float + avg_magnitude_mae: float + std_magnitude_mae: float + total_training_time: float + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'symbol': self.symbol, + 'n_folds': self.n_folds, + 'fold_metrics': self.fold_metrics, + 'avg_direction_accuracy': self.avg_direction_accuracy, + 'std_direction_accuracy': self.std_direction_accuracy, + 'avg_magnitude_mae': self.avg_magnitude_mae, + 'std_magnitude_mae': self.std_magnitude_mae, + 'total_training_time': self.total_training_time + } + + +class PVATrainer: + """ + Trainer for PVA (Price Variation Attention) models. + + Handles the complete training pipeline: + 1. Data loading from PostgreSQL + 2. Feature engineering + 3. Model training (encoder + XGBoost head) + 4. Validation and metrics + 5. Model versioning and saving + + Usage: + trainer = PVATrainer() + + # Train model for a single symbol + model, metrics = trainer.train('XAUUSD', '2023-01-01', '2024-12-31') + + # Walk-forward validation + results = trainer.walk_forward_train('XAUUSD', n_folds=5) + + # Save trained model + trainer.save_model(model, 'XAUUSD', 'v1.0.0') + """ + + def __init__( + self, + config: Optional[PVATrainerConfig] = None, + data_loader: Optional[TrainingDataLoader] = None + ): + """ + Initialize the PVA trainer. + + Args: + config: Trainer configuration + data_loader: Data loader for training data (creates new if None) + """ + self.config = config or PVATrainerConfig() + self.data_loader = data_loader or TrainingDataLoader() + + # Initialize feature engineer + feature_config = PVAFeatureConfig( + return_periods=self.config.return_periods, + volatility_window=self.config.volatility_window, + stats_window=self.config.stats_window, + sequence_length=self.config.sequence_length + ) + self.feature_engineer = PVAFeatureEngineer(feature_config) + + # Training history + self._training_history: List[TrainingMetrics] = [] + + logger.info("PVATrainer initialized") + logger.info(f" Timeframe: {self.config.timeframe}") + logger.info(f" Sequence length: {self.config.sequence_length}") + logger.info(f" Target horizon: {self.config.target_horizon}") + + def train( + self, + symbol: str, + start_date: str, + end_date: str, + model_config: Optional[PVAConfig] = None + ) -> Tuple[PVAModel, TrainingMetrics]: + """ + Train a PVA model for a single symbol. + + Args: + symbol: Trading symbol (e.g., 'XAUUSD') + start_date: Training start date (YYYY-MM-DD) + end_date: Training end date (YYYY-MM-DD) + model_config: Model configuration (uses trainer config if None) + + Returns: + Tuple of (trained model, training metrics) + """ + import time + start_time = time.time() + + logger.info(f"Training PVA model for {symbol}") + logger.info(f" Date range: {start_date} to {end_date}") + + # Step 1: Load data + logger.info("Loading training data...") + df = self.data_loader.get_training_data( + symbol=symbol, + start_date=start_date, + end_date=end_date, + timeframe=self.config.timeframe, + include_features=False # We'll compute our own features + ) + + if df.empty: + raise ValueError(f"No data found for {symbol} in date range") + + logger.info(f" Loaded {len(df):,} rows") + + # Step 2: Prepare training data + logger.info("Preparing features and sequences...") + X_train, y_train, X_val, y_val = self.feature_engineer.prepare_training_data( + df, + target_horizon=self.config.target_horizon, + target_type='return', + seq_length=self.config.sequence_length, + val_ratio=self.config.val_ratio + ) + + n_features = X_train.shape[2] + logger.info(f" Train samples: {len(X_train):,}") + logger.info(f" Val samples: {len(X_val):,}") + logger.info(f" Features: {n_features}") + + # Step 3: Create model + if model_config is None: + model_config = PVAConfig( + input_features=n_features, + sequence_length=self.config.sequence_length, + d_model=self.config.d_model, + n_heads=self.config.n_heads, + n_layers=self.config.n_layers, + d_ff=self.config.d_ff, + dropout=self.config.dropout, + xgb_n_estimators=self.config.xgb_n_estimators, + xgb_max_depth=self.config.xgb_max_depth, + xgb_learning_rate=self.config.xgb_learning_rate, + device=self.config.device + ) + + model = PVAModel(model_config) + + # Step 4: Train encoder + logger.info("Training attention encoder...") + encoder_history = model.fit_encoder( + X_train, y_train, X_val, y_val, + epochs=self.config.encoder_epochs, + batch_size=self.config.batch_size, + learning_rate=self.config.encoder_learning_rate, + early_stopping_patience=self.config.early_stopping_patience + ) + + # Step 5: Train XGBoost head + logger.info("Training XGBoost prediction head...") + xgb_metrics = model.fit_xgboost(X_train, y_train, X_val, y_val) + + # Step 6: Compile metrics + training_time = time.time() - start_time + + metrics = TrainingMetrics( + symbol=symbol, + timeframe=self.config.timeframe, + version=datetime.now().strftime('%Y%m%d_%H%M%S'), + train_samples=len(X_train), + val_samples=len(X_val), + encoder_train_loss=encoder_history['train_loss'][-1], + encoder_val_loss=encoder_history['val_loss'][-1] if encoder_history['val_loss'] else 0, + direction_accuracy=xgb_metrics.get('val_direction_accuracy', 0), + magnitude_mse=xgb_metrics.get('val_magnitude_mse', 0), + magnitude_mae=xgb_metrics.get('val_magnitude_mae', 0), + training_time_seconds=training_time + ) + + self._training_history.append(metrics) + + logger.info(f"Training complete in {training_time:.1f}s") + logger.info(f" Direction accuracy: {metrics.direction_accuracy:.4f}") + logger.info(f" Magnitude MAE: {metrics.magnitude_mae:.6f}") + + return model, metrics + + def validate( + self, + model: PVAModel, + val_data: pd.DataFrame + ) -> Dict[str, float]: + """ + Validate a trained model on held-out data. + + Args: + model: Trained PVA model + val_data: Validation DataFrame with OHLCV data + + Returns: + Dictionary of validation metrics + """ + from sklearn.metrics import accuracy_score, mean_squared_error, mean_absolute_error + + logger.info(f"Validating model on {len(val_data)} rows...") + + # Prepare validation data + X_val, y_val = self.feature_engineer.prepare_training_data( + val_data, + target_horizon=self.config.target_horizon, + target_type='return', + seq_length=self.config.sequence_length, + val_ratio=0.0 # Use all data for validation + ) + + if len(X_val) == 0: + logger.warning("No valid validation samples") + return {'error': 'No valid samples'} + + # Make predictions + predictions = model.predict(X_val) + + # Extract values + pred_directions = np.array([p.direction for p in predictions]) + pred_magnitudes = np.array([p.magnitude for p in predictions]) + pred_confidences = np.array([p.confidence for p in predictions]) + + # True values + true_directions = (y_val > 0).astype(int) + true_magnitudes = np.abs(y_val) + + # Binary direction predictions + pred_direction_binary = (pred_directions > 0).astype(int) + + # Compute metrics + metrics = { + 'direction_accuracy': accuracy_score(true_directions, pred_direction_binary), + 'magnitude_mse': mean_squared_error(true_magnitudes, pred_magnitudes), + 'magnitude_mae': mean_absolute_error(true_magnitudes, pred_magnitudes), + 'avg_confidence': float(np.mean(pred_confidences)), + 'n_samples': len(X_val) + } + + # Directional return metric (profit factor proxy) + signed_returns = pred_directions * y_val + metrics['avg_directional_return'] = float(np.mean(signed_returns)) + metrics['sharpe_proxy'] = float(np.mean(signed_returns) / (np.std(signed_returns) + 1e-10)) + + logger.info(f"Validation metrics: {metrics}") + return metrics + + def walk_forward_train( + self, + symbol: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + n_folds: int = 5 + ) -> WalkForwardResult: + """ + Perform walk-forward validation training. + + Walk-forward validation trains multiple models on expanding/sliding + windows of data, validating on subsequent periods. This provides + a more realistic estimate of model performance. + + Args: + symbol: Trading symbol + start_date: Start date (defaults to 3 years ago) + end_date: End date (defaults to today) + n_folds: Number of walk-forward folds + + Returns: + WalkForwardResult with metrics for each fold + """ + import time + from datetime import timedelta + + total_start = time.time() + + if start_date is None: + start_date = (datetime.now() - timedelta(days=365 * 3)).strftime('%Y-%m-%d') + if end_date is None: + end_date = datetime.now().strftime('%Y-%m-%d') + + logger.info(f"Walk-forward training for {symbol}") + logger.info(f" Date range: {start_date} to {end_date}") + logger.info(f" Folds: {n_folds}") + + # Load all data + df = self.data_loader.get_training_data( + symbol=symbol, + start_date=start_date, + end_date=end_date, + timeframe=self.config.timeframe, + include_features=False + ) + + if df.empty: + raise ValueError(f"No data found for {symbol}") + + n_samples = len(df) + logger.info(f" Total samples: {n_samples:,}") + + # Calculate fold sizes + step_size = n_samples // (n_folds + 1) + test_size = int(step_size * self.config.walk_forward_test_size) + + if step_size < self.config.min_train_size: + logger.warning(f"Step size ({step_size}) < min train size ({self.config.min_train_size})") + n_folds = max(1, n_samples // self.config.min_train_size - 1) + step_size = n_samples // (n_folds + 1) + test_size = int(step_size * self.config.walk_forward_test_size) + logger.info(f" Adjusted to {n_folds} folds") + + # Train models for each fold + fold_metrics = [] + fold_models = [] + + for fold_idx in range(n_folds): + logger.info(f"\n{'=' * 60}") + logger.info(f"FOLD {fold_idx + 1}/{n_folds}") + logger.info(f"{'=' * 60}") + + # Calculate indices + train_start = 0 # Expanding window + train_end = (fold_idx + 1) * step_size + val_start = train_end + self.config.walk_forward_gap + val_end = min(val_start + test_size, n_samples) + + if val_end > n_samples or (train_end - train_start) < self.config.min_train_size: + logger.warning(f"Skipping fold {fold_idx + 1}: insufficient data") + continue + + # Split data + train_df = df.iloc[train_start:train_end].copy() + val_df = df.iloc[val_start:val_end].copy() + + logger.info(f" Train: {len(train_df):,} samples") + logger.info(f" Val: {len(val_df):,} samples") + + # Prepare training data + X_train, y_train = self.feature_engineer.prepare_training_data( + train_df, + target_horizon=self.config.target_horizon, + target_type='return', + seq_length=self.config.sequence_length, + val_ratio=0.0 + ) + + # Prepare validation data + X_val, y_val = self.feature_engineer.prepare_training_data( + val_df, + target_horizon=self.config.target_horizon, + target_type='return', + seq_length=self.config.sequence_length, + val_ratio=0.0 + ) + + if len(X_train) == 0 or len(X_val) == 0: + logger.warning(f"Skipping fold {fold_idx + 1}: no valid sequences") + continue + + # Create and train model + n_features = X_train.shape[2] + model_config = PVAConfig( + input_features=n_features, + sequence_length=self.config.sequence_length, + d_model=self.config.d_model, + n_heads=self.config.n_heads, + n_layers=self.config.n_layers, + d_ff=self.config.d_ff, + dropout=self.config.dropout, + xgb_n_estimators=self.config.xgb_n_estimators, + xgb_max_depth=self.config.xgb_max_depth, + xgb_learning_rate=self.config.xgb_learning_rate, + device=self.config.device + ) + + model = PVAModel(model_config) + + # Split train data for encoder validation + split_idx = int(len(X_train) * 0.85) + X_train_enc, X_val_enc = X_train[:split_idx], X_train[split_idx:] + y_train_enc, y_val_enc = y_train[:split_idx], y_train[split_idx:] + + # Train encoder + fold_start = time.time() + encoder_history = model.fit_encoder( + X_train_enc, y_train_enc, X_val_enc, y_val_enc, + epochs=self.config.encoder_epochs, + batch_size=self.config.batch_size, + learning_rate=self.config.encoder_learning_rate, + early_stopping_patience=self.config.early_stopping_patience + ) + + # Train XGBoost + xgb_metrics = model.fit_xgboost(X_train, y_train, X_val, y_val) + fold_time = time.time() - fold_start + + # Validate on test set + val_metrics = self._compute_fold_metrics(model, X_val, y_val) + + fold_result = { + 'fold': fold_idx + 1, + 'train_size': len(X_train), + 'val_size': len(X_val), + 'encoder_train_loss': encoder_history['train_loss'][-1], + 'encoder_val_loss': encoder_history['val_loss'][-1] if encoder_history['val_loss'] else 0, + 'direction_accuracy': val_metrics['direction_accuracy'], + 'magnitude_mse': val_metrics['magnitude_mse'], + 'magnitude_mae': val_metrics['magnitude_mae'], + 'directional_return': val_metrics['avg_directional_return'], + 'training_time': fold_time + } + + fold_metrics.append(fold_result) + fold_models.append(model) + + logger.info(f"Fold {fold_idx + 1} complete:") + logger.info(f" Direction accuracy: {fold_result['direction_accuracy']:.4f}") + logger.info(f" Magnitude MAE: {fold_result['magnitude_mae']:.6f}") + + # Save intermediate model if configured + if self.config.save_intermediate: + model_dir = Path(self.config.model_base_dir) / symbol / f"fold_{fold_idx + 1}" + model.save(str(model_dir)) + + # Compute aggregate metrics + if not fold_metrics: + raise RuntimeError("No folds completed successfully") + + direction_accuracies = [f['direction_accuracy'] for f in fold_metrics] + magnitude_maes = [f['magnitude_mae'] for f in fold_metrics] + + total_time = time.time() - total_start + + result = WalkForwardResult( + symbol=symbol, + n_folds=len(fold_metrics), + fold_metrics=fold_metrics, + avg_direction_accuracy=float(np.mean(direction_accuracies)), + std_direction_accuracy=float(np.std(direction_accuracies)), + avg_magnitude_mae=float(np.mean(magnitude_maes)), + std_magnitude_mae=float(np.std(magnitude_maes)), + total_training_time=total_time + ) + + logger.info(f"\n{'=' * 60}") + logger.info("WALK-FORWARD VALIDATION COMPLETE") + logger.info(f"{'=' * 60}") + logger.info(f" Folds completed: {result.n_folds}") + logger.info(f" Avg direction accuracy: {result.avg_direction_accuracy:.4f} +/- {result.std_direction_accuracy:.4f}") + logger.info(f" Avg magnitude MAE: {result.avg_magnitude_mae:.6f} +/- {result.std_magnitude_mae:.6f}") + logger.info(f" Total time: {total_time:.1f}s") + + return result + + def _compute_fold_metrics( + self, + model: PVAModel, + X_val: np.ndarray, + y_val: np.ndarray + ) -> Dict[str, float]: + """Compute metrics for a single fold.""" + from sklearn.metrics import accuracy_score, mean_squared_error, mean_absolute_error + + predictions = model.predict(X_val) + + pred_directions = np.array([p.direction for p in predictions]) + pred_magnitudes = np.array([p.magnitude for p in predictions]) + + true_directions = (y_val > 0).astype(int) + true_magnitudes = np.abs(y_val) + pred_direction_binary = (pred_directions > 0).astype(int) + + metrics = { + 'direction_accuracy': accuracy_score(true_directions, pred_direction_binary), + 'magnitude_mse': mean_squared_error(true_magnitudes, pred_magnitudes), + 'magnitude_mae': mean_absolute_error(true_magnitudes, pred_magnitudes), + 'n_samples': len(X_val) + } + + signed_returns = pred_directions * y_val + metrics['avg_directional_return'] = float(np.mean(signed_returns)) + + return metrics + + def save_model( + self, + model: PVAModel, + symbol: str, + version: str + ) -> str: + """ + Save a trained model with version tracking. + + Args: + model: Trained PVA model + symbol: Symbol the model was trained on + version: Version string (e.g., 'v1.0.0') + + Returns: + Path where model was saved + """ + model_dir = Path(self.config.model_base_dir) / symbol / version + model_dir.mkdir(parents=True, exist_ok=True) + + # Save model + model.save(str(model_dir)) + + # Save training metadata + metadata = { + 'symbol': symbol, + 'version': version, + 'timeframe': self.config.timeframe, + 'sequence_length': self.config.sequence_length, + 'target_horizon': self.config.target_horizon, + 'feature_config': { + 'return_periods': self.config.return_periods, + 'volatility_window': self.config.volatility_window, + 'stats_window': self.config.stats_window + }, + 'trainer_config': { + 'd_model': self.config.d_model, + 'n_heads': self.config.n_heads, + 'n_layers': self.config.n_layers, + 'd_ff': self.config.d_ff + }, + 'saved_at': datetime.now().isoformat() + } + + metadata_path = model_dir / 'metadata.json' + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + # Save feature names + feature_names_path = model_dir / 'feature_names.json' + with open(feature_names_path, 'w') as f: + json.dump(self.feature_engineer.get_feature_names(), f) + + logger.info(f"Model saved to {model_dir}") + return str(model_dir) + + def load_model( + self, + symbol: str, + version: str, + device: str = "cpu" + ) -> PVAModel: + """ + Load a saved model. + + Args: + symbol: Symbol the model was trained on + version: Version string + device: Device to load model to + + Returns: + Loaded PVAModel + """ + model_dir = Path(self.config.model_base_dir) / symbol / version + return PVAModel.load(str(model_dir), device=device) + + def get_training_history(self) -> List[Dict[str, Any]]: + """Get history of all training runs.""" + return [m.to_dict() for m in self._training_history] + + +def train_pva_model( + symbol: str, + start_date: str, + end_date: str, + save_path: Optional[str] = None +) -> Tuple[PVAModel, Dict[str, Any]]: + """ + Convenience function to train a PVA model. + + Args: + symbol: Trading symbol + start_date: Training start date + end_date: Training end date + save_path: Optional path to save model + + Returns: + Tuple of (trained model, metrics dict) + """ + trainer = PVATrainer() + model, metrics = trainer.train(symbol, start_date, end_date) + + if save_path: + trainer.save_model(model, symbol, datetime.now().strftime('%Y%m%d_%H%M%S')) + + return model, metrics.to_dict() + + +if __name__ == "__main__": + # Test the trainer + print("Testing PVATrainer...") + + # Note: This requires database connection + # For standalone testing, we'll create synthetic data + + np.random.seed(42) + n = 10000 + + dates = pd.date_range('2024-01-01', periods=n, freq='5min') + price = 2650 + np.cumsum(np.random.randn(n) * 0.5) + + df = pd.DataFrame({ + 'open': price, + 'high': price + np.abs(np.random.randn(n)) * 2, + 'low': price - np.abs(np.random.randn(n)) * 2, + 'close': price + np.random.randn(n) * 0.5, + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Create trainer with smaller config for testing + config = PVATrainerConfig( + sequence_length=50, + target_horizon=6, + encoder_epochs=5, + d_model=64, + n_heads=4, + n_layers=2, + walk_forward_splits=3, + min_train_size=1000, + val_ratio=0.15 + ) + + # Create mock data loader + class MockDataLoader: + def get_training_data(self, **kwargs): + return df.copy() + + trainer = PVATrainer(config=config, data_loader=MockDataLoader()) + + # Test feature engineering + print("\nTesting feature engineering...") + X, y = trainer.feature_engineer.prepare_training_data( + df, target_horizon=6, seq_length=50 + ) + print(f" X shape: {X.shape}") + print(f" y shape: {y.shape}") + + # Test walk-forward (abbreviated) + print("\nTesting walk-forward validation (abbreviated)...") + result = trainer.walk_forward_train( + symbol='TEST', + n_folds=2 + ) + print(f" Folds: {result.n_folds}") + print(f" Avg accuracy: {result.avg_direction_accuracy:.4f}") + + print("\nTest complete!") diff --git a/src/models/strategies/vbp/__init__.py b/src/models/strategies/vbp/__init__.py new file mode 100644 index 0000000..7361507 --- /dev/null +++ b/src/models/strategies/vbp/__init__.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +VBP (Volatility Breakout Predictor) Strategy Module +==================================================== +Machine learning strategy for predicting volatility breakouts. + +The VBP strategy identifies potential breakout setups by detecting: +1. Volatility compression (squeeze indicators) +2. Historical volatility patterns +3. Range contraction followed by expansion + +Key Components: +- VBPFeatureEngineer: Extracts volatility-based features (ATR, BB, KC, squeeze) +- VolatilityCNN: 1D CNN with self-attention for temporal pattern encoding +- VBPModel: Complete model combining CNN encoder with XGBoost classifier +- VBPTrainer: Training system with balanced sampling and walk-forward CV + +Strategy Theory: +Breakouts typically occur after periods of low volatility (compression). +The model learns to identify these compression patterns and predict: +1. Whether a breakout will occur +2. Direction (bullish/bearish) +3. Magnitude (in ATR units) + +Class Imbalance Handling: +Since breakouts are rare events (~5-10% of data), the module implements: +- Oversampling of breakout samples (3x default) +- Class weight balancing in XGBoost +- F1 score optimization for minority classes + +Usage Example: + from src.models.strategies.vbp import ( + VBPModel, + VBPModelConfig, + VBPTrainer, + VBPTrainerConfig, + VBPFeatureEngineer + ) + + # Configure and create model + config = VBPModelConfig( + sequence_length=50, + xgb_n_estimators=200, + use_gpu=True + ) + model = VBPModel(config) + + # Train on OHLCV data + model.fit(df_train) + + # Get predictions + predictions = model.predict(df_test) + for pred in predictions: + print(f"Signal: {pred.signal}, Prob: {pred.breakout_probability:.2f}") + + # Or use trainer for walk-forward validation + trainer = VBPTrainer(VBPTrainerConfig()) + metrics = trainer.walk_forward_train('XAUUSD', df) + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +# Feature Engineering +from .feature_engineering import ( + VBPFeatureEngineer, + VBPFeatureConfig, +) + +# CNN Encoder +from .cnn_encoder import ( + VolatilityCNN, + CNNEncoderConfig, + Conv1DBlock, + SelfAttention1D, + MultiScaleVolatilityCNN, +) + +# Complete Model +from .model import ( + VBPModel, + VBPModelConfig, + VBPPrediction, + NeuralHead, +) + +# Training +from .trainer import ( + VBPTrainer, + VBPTrainerConfig, + VBPMetrics, + VBPDataset, +) + + +__all__ = [ + # Feature Engineering + 'VBPFeatureEngineer', + 'VBPFeatureConfig', + + # CNN Encoder + 'VolatilityCNN', + 'CNNEncoderConfig', + 'Conv1DBlock', + 'SelfAttention1D', + 'MultiScaleVolatilityCNN', + + # Model + 'VBPModel', + 'VBPModelConfig', + 'VBPPrediction', + 'NeuralHead', + + # Training + 'VBPTrainer', + 'VBPTrainerConfig', + 'VBPMetrics', + 'VBPDataset', +] + + +__version__ = '1.0.0' +__author__ = 'ML-Specialist (NEXUS v4.0)' diff --git a/src/models/strategies/vbp/cnn_encoder.py b/src/models/strategies/vbp/cnn_encoder.py new file mode 100644 index 0000000..7931233 --- /dev/null +++ b/src/models/strategies/vbp/cnn_encoder.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +""" +VBP CNN Encoder - 1D Convolutional Neural Network with Self-Attention +====================================================================== +Implements a 1D CNN architecture with self-attention for encoding +volatility patterns in financial time series. + +Architecture: +- Multi-scale Conv1D layers with different kernel sizes +- BatchNorm + ReLU + Dropout after each conv +- Self-attention layer to capture long-range dependencies +- Global pooling for sequence-to-vector encoding + +Key Design Choices: +1. Multiple kernel sizes (3, 5, 7) to capture patterns at different scales +2. Increasing filter counts (32, 64, 128) for hierarchical features +3. Self-attention after CNN to weight important timesteps +4. Residual connections for gradient flow + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import math +from typing import Tuple, Optional, List, Dict +from dataclasses import dataclass, field + +import torch +import torch.nn as nn +import torch.nn.functional as F +from loguru import logger + + +@dataclass +class CNNEncoderConfig: + """Configuration for the CNN encoder.""" + + # Input configuration + input_channels: int = 1 # Number of input feature channels + + # Conv layer configurations [filters, kernel_size] + conv_configs: List[Tuple[int, int]] = field(default_factory=lambda: [ + (32, 3), + (64, 5), + (128, 7) + ]) + + # Attention configuration + attention_heads: int = 4 + attention_dropout: float = 0.1 + + # General settings + dropout: float = 0.3 + use_attention: bool = True + use_residual: bool = True + + # Output dimension + output_dim: int = 128 + + +class Conv1DBlock(nn.Module): + """ + 1D Convolutional block with BatchNorm, ReLU, and Dropout. + + Applies: + 1. Conv1D with same padding + 2. BatchNorm + 3. ReLU activation + 4. Dropout + + Args: + in_channels: Number of input channels + out_channels: Number of output channels + kernel_size: Convolution kernel size + dropout: Dropout probability + use_residual: Whether to add residual connection + """ + + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int, + dropout: float = 0.3, + use_residual: bool = True + ): + super().__init__() + + self.use_residual = use_residual and (in_channels == out_channels) + + # Padding to maintain sequence length + padding = kernel_size // 2 + + self.conv = nn.Conv1d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + padding=padding, + bias=False + ) + self.bn = nn.BatchNorm1d(out_channels) + self.dropout = nn.Dropout(dropout) + + # Projection for residual if dimensions don't match + if use_residual and in_channels != out_channels: + self.residual_proj = nn.Conv1d(in_channels, out_channels, kernel_size=1) + self.use_residual = True + else: + self.residual_proj = None + + self._init_weights() + + def _init_weights(self): + """Initialize weights with Kaiming initialization.""" + nn.init.kaiming_normal_(self.conv.weight, mode='fan_out', nonlinearity='relu') + nn.init.ones_(self.bn.weight) + nn.init.zeros_(self.bn.bias) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Forward pass. + + Args: + x: Input tensor of shape (batch, channels, seq_len) + + Returns: + Output tensor of shape (batch, out_channels, seq_len) + """ + identity = x + + out = self.conv(x) + out = self.bn(out) + out = F.relu(out) + out = self.dropout(out) + + if self.use_residual: + if self.residual_proj is not None: + identity = self.residual_proj(identity) + out = out + identity + + return out + + +class SelfAttention1D(nn.Module): + """ + Self-Attention layer for 1D sequences. + + Applies multi-head self-attention to capture long-range dependencies + in the sequence after CNN encoding. + + Args: + embed_dim: Embedding dimension (same as number of channels) + num_heads: Number of attention heads + dropout: Attention dropout probability + """ + + def __init__( + self, + embed_dim: int, + num_heads: int = 4, + dropout: float = 0.1 + ): + super().__init__() + + self.embed_dim = embed_dim + self.num_heads = num_heads + self.head_dim = embed_dim // num_heads + + assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads" + + # Linear projections for Q, K, V + self.q_proj = nn.Linear(embed_dim, embed_dim) + self.k_proj = nn.Linear(embed_dim, embed_dim) + self.v_proj = nn.Linear(embed_dim, embed_dim) + + # Output projection + self.out_proj = nn.Linear(embed_dim, embed_dim) + + # Dropout + self.dropout = nn.Dropout(dropout) + + # Layer norm for pre-attention + self.norm = nn.LayerNorm(embed_dim) + + self._init_weights() + + def _init_weights(self): + """Initialize weights.""" + nn.init.xavier_uniform_(self.q_proj.weight) + nn.init.xavier_uniform_(self.k_proj.weight) + nn.init.xavier_uniform_(self.v_proj.weight) + nn.init.xavier_uniform_(self.out_proj.weight) + + nn.init.zeros_(self.q_proj.bias) + nn.init.zeros_(self.k_proj.bias) + nn.init.zeros_(self.v_proj.bias) + nn.init.zeros_(self.out_proj.bias) + + def forward( + self, + x: torch.Tensor, + return_attention: bool = False + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + Forward pass. + + Args: + x: Input tensor of shape (batch, seq_len, embed_dim) + return_attention: Whether to return attention weights + + Returns: + output: Attended output of shape (batch, seq_len, embed_dim) + attention_weights: Optional attention weights (batch, heads, seq_len, seq_len) + """ + batch_size, seq_len, _ = x.shape + + # Layer norm + x_norm = self.norm(x) + + # Project to Q, K, V + q = self.q_proj(x_norm) # (batch, seq_len, embed_dim) + k = self.k_proj(x_norm) + v = self.v_proj(x_norm) + + # Reshape for multi-head attention + # (batch, seq_len, num_heads, head_dim) -> (batch, num_heads, seq_len, head_dim) + q = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + + # Scaled dot-product attention + scale = math.sqrt(self.head_dim) + scores = torch.matmul(q, k.transpose(-2, -1)) / scale # (batch, heads, seq, seq) + + attention_weights = F.softmax(scores, dim=-1) + attention_weights = self.dropout(attention_weights) + + # Apply attention to values + attended = torch.matmul(attention_weights, v) # (batch, heads, seq, head_dim) + + # Reshape back + attended = attended.transpose(1, 2).contiguous().view(batch_size, seq_len, self.embed_dim) + + # Output projection with residual + output = self.out_proj(attended) + x + + if return_attention: + return output, attention_weights + return output, None + + +class VolatilityCNN(nn.Module): + """ + 1D CNN with Self-Attention for Volatility Pattern Encoding. + + Architecture: + 1. Multi-scale Conv1D blocks with increasing filters + 2. Self-attention layer for global context + 3. Global pooling (mean + max) + 4. Final projection to output dimension + + Input: (batch, seq_len, features) + Output: (batch, output_dim) encoded representation + + Args: + input_dim: Number of input features + config: CNN configuration + """ + + def __init__( + self, + input_dim: int, + config: Optional[CNNEncoderConfig] = None + ): + super().__init__() + + self.config = config or CNNEncoderConfig() + self.input_dim = input_dim + + # Input projection to standard dimension + first_channels = self.config.conv_configs[0][0] + self.input_proj = nn.Linear(input_dim, first_channels) + + # Build convolutional layers + self.conv_blocks = nn.ModuleList() + in_channels = first_channels + + for out_channels, kernel_size in self.config.conv_configs: + block = Conv1DBlock( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + dropout=self.config.dropout, + use_residual=self.config.use_residual + ) + self.conv_blocks.append(block) + in_channels = out_channels + + self.final_channels = in_channels + + # Self-attention layer + if self.config.use_attention: + self.attention = SelfAttention1D( + embed_dim=self.final_channels, + num_heads=self.config.attention_heads, + dropout=self.config.attention_dropout + ) + else: + self.attention = None + + # Final layer norm + self.final_norm = nn.LayerNorm(self.final_channels) + + # Output projection + # After global pooling: concat of mean and max = 2 * final_channels + pooled_dim = 2 * self.final_channels + self.output_proj = nn.Sequential( + nn.Linear(pooled_dim, self.config.output_dim), + nn.ReLU(), + nn.Dropout(self.config.dropout), + nn.Linear(self.config.output_dim, self.config.output_dim) + ) + + self._init_output_weights() + + def _init_output_weights(self): + """Initialize output projection weights.""" + for module in self.output_proj: + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + def forward( + self, + x: torch.Tensor, + return_attention: bool = False + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + Forward pass. + + Args: + x: Input tensor of shape (batch, seq_len, features) + return_attention: Whether to return attention weights + + Returns: + encoded: Encoded representation (batch, output_dim) + attention_weights: Optional attention weights + """ + batch_size, seq_len, _ = x.shape + + # Project input features + x = self.input_proj(x) # (batch, seq_len, first_channels) + + # Transpose for Conv1D: (batch, channels, seq_len) + x = x.transpose(1, 2) + + # Apply conv blocks + for conv_block in self.conv_blocks: + x = conv_block(x) + + # Transpose back: (batch, seq_len, channels) + x = x.transpose(1, 2) + + # Apply self-attention + attention_weights = None + if self.attention is not None: + x, attention_weights = self.attention(x, return_attention=return_attention) + + # Layer norm + x = self.final_norm(x) + + # Global pooling: combine mean and max + mean_pool = x.mean(dim=1) # (batch, channels) + max_pool = x.max(dim=1)[0] # (batch, channels) + pooled = torch.cat([mean_pool, max_pool], dim=-1) # (batch, 2*channels) + + # Output projection + encoded = self.output_proj(pooled) # (batch, output_dim) + + return encoded, attention_weights + + def get_feature_maps(self, x: torch.Tensor) -> List[torch.Tensor]: + """ + Get intermediate feature maps from each conv block. + + Useful for visualization and debugging. + + Args: + x: Input tensor of shape (batch, seq_len, features) + + Returns: + List of feature maps from each conv block + """ + feature_maps = [] + + # Project input + x = self.input_proj(x) + x = x.transpose(1, 2) + + # Get feature maps from each block + for conv_block in self.conv_blocks: + x = conv_block(x) + feature_maps.append(x.transpose(1, 2)) # Store in (batch, seq, channels) format + + return feature_maps + + +class MultiScaleVolatilityCNN(nn.Module): + """ + Multi-Scale CNN that processes input at different temporal scales. + + Applies separate CNN branches with different dilation rates to capture + patterns at multiple time scales, then fuses the representations. + + Args: + input_dim: Number of input features + config: CNN configuration + scales: List of dilation rates for multi-scale processing + """ + + def __init__( + self, + input_dim: int, + config: Optional[CNNEncoderConfig] = None, + scales: List[int] = None + ): + super().__init__() + + self.config = config or CNNEncoderConfig() + self.scales = scales or [1, 2, 4] # Different dilation rates + self.input_dim = input_dim + + # Create a CNN branch for each scale + self.branches = nn.ModuleList() + for scale in self.scales: + branch = self._create_branch(input_dim, scale) + self.branches.append(branch) + + # Fusion layer + branch_output_dim = self.config.output_dim + fused_dim = branch_output_dim * len(self.scales) + + self.fusion = nn.Sequential( + nn.Linear(fused_dim, self.config.output_dim * 2), + nn.ReLU(), + nn.Dropout(self.config.dropout), + nn.Linear(self.config.output_dim * 2, self.config.output_dim) + ) + + # Self-attention for temporal weighting + if self.config.use_attention: + self.temporal_attention = SelfAttention1D( + embed_dim=self.config.output_dim, + num_heads=self.config.attention_heads, + dropout=self.config.attention_dropout + ) + else: + self.temporal_attention = None + + def _create_branch(self, input_dim: int, dilation: int) -> nn.Module: + """Create a single CNN branch with given dilation.""" + layers = [] + + # Input projection + first_channels = self.config.conv_configs[0][0] + layers.append(nn.Linear(input_dim, first_channels)) + layers.append(nn.ReLU()) + + # Store as sequential for the linear part + input_proj = nn.Sequential(*layers) + + # Conv layers with dilation + conv_layers = [] + in_channels = first_channels + + for out_channels, kernel_size in self.config.conv_configs: + # Adjust padding for dilation + padding = ((kernel_size - 1) * dilation) // 2 + + conv = nn.Conv1d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + padding=padding, + dilation=dilation, + bias=False + ) + bn = nn.BatchNorm1d(out_channels) + + conv_layers.extend([conv, bn, nn.ReLU(), nn.Dropout(self.config.dropout)]) + in_channels = out_channels + + conv_sequential = nn.Sequential(*conv_layers) + + # Output projection + output_proj = nn.Linear(in_channels, self.config.output_dim) + + return nn.ModuleDict({ + 'input_proj': input_proj, + 'conv': conv_sequential, + 'output_proj': output_proj + }) + + def forward( + self, + x: torch.Tensor, + return_attention: bool = False + ) -> Tuple[torch.Tensor, Optional[Dict[str, torch.Tensor]]]: + """ + Forward pass. + + Args: + x: Input tensor of shape (batch, seq_len, features) + return_attention: Whether to return attention info + + Returns: + encoded: Encoded representation (batch, output_dim) + attention_info: Optional dict with attention weights per scale + """ + batch_size, seq_len, _ = x.shape + branch_outputs = [] + + for branch in self.branches: + # Input projection + h = branch['input_proj'](x) # (batch, seq_len, channels) + + # Conv (needs channel-first format) + h = h.transpose(1, 2) + h = branch['conv'](h) + h = h.transpose(1, 2) + + # Global pooling + h_pooled = h.mean(dim=1) # (batch, channels) + + # Output projection + h_out = branch['output_proj'](h_pooled) # (batch, output_dim) + branch_outputs.append(h_out) + + # Concatenate branch outputs + fused = torch.cat(branch_outputs, dim=-1) # (batch, num_scales * output_dim) + + # Fusion + encoded = self.fusion(fused) # (batch, output_dim) + + attention_info = None + if return_attention: + attention_info = {'branch_outputs': branch_outputs} + + return encoded, attention_info + + +if __name__ == "__main__": + # Test the CNN encoder module + print("Testing VBP CNN Encoder") + print("=" * 60) + + # Test configuration + config = CNNEncoderConfig( + input_channels=1, + conv_configs=[(32, 3), (64, 5), (128, 7)], + attention_heads=4, + dropout=0.3, + use_attention=True, + output_dim=128 + ) + + batch_size = 8 + seq_len = 100 + input_dim = 32 # Number of features from feature engineering + + # Create sample input + x = torch.randn(batch_size, seq_len, input_dim) + + print(f"\n1. Testing VolatilityCNN...") + print(f" Input shape: {x.shape}") + + model = VolatilityCNN(input_dim=input_dim, config=config) + print(f" Model parameters: {sum(p.numel() for p in model.parameters()):,}") + + # Forward pass + encoded, attention = model(x, return_attention=True) + print(f" Output shape: {encoded.shape}") + if attention is not None: + print(f" Attention shape: {attention.shape}") + + # Test feature maps + print(f"\n2. Testing feature maps extraction...") + feature_maps = model.get_feature_maps(x) + for i, fm in enumerate(feature_maps): + print(f" Layer {i+1} feature map: {fm.shape}") + + print(f"\n3. Testing MultiScaleVolatilityCNN...") + multi_scale_model = MultiScaleVolatilityCNN( + input_dim=input_dim, + config=config, + scales=[1, 2, 4] + ) + print(f" Model parameters: {sum(p.numel() for p in multi_scale_model.parameters()):,}") + + encoded_ms, attn_info = multi_scale_model(x, return_attention=True) + print(f" Output shape: {encoded_ms.shape}") + + print(f"\n4. Testing gradient flow...") + target = torch.randn(batch_size, config.output_dim) + loss = F.mse_loss(encoded, target) + loss.backward() + + # Check gradients + grad_norms = [] + for name, param in model.named_parameters(): + if param.grad is not None: + grad_norms.append((name, param.grad.norm().item())) + + print(f" Total parameters with gradients: {len(grad_norms)}") + print(f" Sample gradient norms:") + for name, norm in grad_norms[:5]: + print(f" {name}: {norm:.6f}") + + print(f"\n5. Testing inference mode...") + model.eval() + with torch.no_grad(): + encoded_eval, _ = model(x) + print(f" Inference output shape: {encoded_eval.shape}") + + print("\n" + "=" * 60) + print("All CNN encoder tests passed!") diff --git a/src/models/strategies/vbp/feature_engineering.py b/src/models/strategies/vbp/feature_engineering.py new file mode 100644 index 0000000..c4c9b53 --- /dev/null +++ b/src/models/strategies/vbp/feature_engineering.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +""" +VBP Feature Engineering - Volatility Breakout Predictor Features +================================================================= +Comprehensive feature engineering for volatility-based breakout prediction. + +Features include: +- ATR (Average True Range) at multiple periods +- Bollinger Bands (width, squeeze detection) +- Keltner Channels (for squeeze indicator) +- Compression Score (range contraction detection) +- Historical Volatility metrics +- Breakout labeling for training + +Key Concept: +Breakouts often occur after periods of low volatility (squeeze/compression). +This module extracts features that capture volatility compression and +expansion patterns to predict upcoming breakouts. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Union +from dataclasses import dataclass, field +from loguru import logger + + +@dataclass +class VBPFeatureConfig: + """Configuration for VBP feature engineering.""" + + # ATR periods + atr_periods: List[int] = field(default_factory=lambda: [5, 10, 20, 50]) + + # Bollinger Bands + bb_period: int = 20 + bb_std: float = 2.0 + + # Keltner Channels + keltner_period: int = 20 + keltner_mult: float = 1.5 + + # Compression detection + compression_lookback: int = 50 + + # Historical volatility + hv_windows: List[int] = field(default_factory=lambda: [10, 20, 50]) + + # Breakout labeling + breakout_atr_mult: float = 2.0 + forward_periods: int = 5 + + # Minimum periods for rolling calculations + min_periods_ratio: float = 0.5 + + +class VBPFeatureEngineer: + """ + Feature engineering for Volatility Breakout Prediction. + + Extracts volatility-based features designed to predict breakouts: + - ATR-based volatility at multiple timeframes + - Bollinger Band squeeze indicators + - Keltner Channel squeeze (BB inside KC) + - Range compression scores + - Historical volatility metrics + + Usage: + engineer = VBPFeatureEngineer(VBPFeatureConfig()) + features = engineer.compute_all_features(df) + labels = engineer.label_breakouts(df) + """ + + def __init__(self, config: Optional[VBPFeatureConfig] = None): + """ + Initialize VBP Feature Engineer. + + Args: + config: Feature engineering configuration + """ + self.config = config or VBPFeatureConfig() + + def _get_price_columns(self, df: pd.DataFrame) -> Tuple[str, str, str, str]: + """Get standardized column names for OHLC.""" + # Handle different column naming conventions + open_col = 'Open' if 'Open' in df.columns else 'open' + high_col = 'High' if 'High' in df.columns else 'high' + low_col = 'Low' if 'Low' in df.columns else 'low' + close_col = 'Close' if 'Close' in df.columns else 'close' + + return open_col, high_col, low_col, close_col + + def compute_true_range(self, df: pd.DataFrame) -> pd.Series: + """ + Compute True Range. + + TR = max(H-L, |H-C_prev|, |L-C_prev|) + + Args: + df: DataFrame with OHLC data + + Returns: + Series with True Range values + """ + _, high_col, low_col, close_col = self._get_price_columns(df) + + high = df[high_col] + low = df[low_col] + close_prev = df[close_col].shift(1) + + tr1 = high - low + tr2 = (high - close_prev).abs() + tr3 = (low - close_prev).abs() + + true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + return true_range + + def compute_atr( + self, + df: pd.DataFrame, + periods: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute ATR (Average True Range) at multiple periods. + + ATR is the smoothed moving average of True Range. + Multiple periods capture short-term vs long-term volatility. + + Args: + df: DataFrame with OHLC data + periods: List of ATR periods (default: [5, 10, 20, 50]) + + Returns: + DataFrame with ATR columns for each period + """ + periods = periods or self.config.atr_periods + true_range = self.compute_true_range(df) + + atr_features = pd.DataFrame(index=df.index) + + for period in periods: + min_periods = max(1, int(period * self.config.min_periods_ratio)) + + # Use EMA for ATR (Wilder's smoothing) + atr = true_range.ewm(span=period, min_periods=min_periods, adjust=False).mean() + atr_features[f'atr_{period}'] = atr + + # ATR as percentage of close + _, _, _, close_col = self._get_price_columns(df) + atr_features[f'atr_{period}_pct'] = atr / (df[close_col] + 1e-8) * 100 + + # ATR ratios (short vs long term) + if len(periods) >= 2: + short_period = periods[0] + long_period = periods[-1] + atr_features['atr_ratio_short_long'] = ( + atr_features[f'atr_{short_period}'] / + (atr_features[f'atr_{long_period}'] + 1e-8) + ) + + # ATR expansion/contraction + atr_20 = atr_features.get('atr_20', atr_features[f'atr_{periods[0]}']) + atr_features['atr_change'] = atr_20.pct_change(5) + + return atr_features + + def compute_bollinger_bands( + self, + df: pd.DataFrame, + period: Optional[int] = None, + std: Optional[float] = None + ) -> pd.DataFrame: + """ + Compute Bollinger Bands and derived features. + + BB = SMA +/- (std_mult * rolling_std) + + Key features: + - upper, lower: Band boundaries + - width: Band width normalized by middle + - squeeze: Width relative to historical width (compression indicator) + - position: Price position within bands (0-1) + + Args: + df: DataFrame with OHLC data + period: SMA period (default: 20) + std: Standard deviation multiplier (default: 2.0) + + Returns: + DataFrame with Bollinger Band features + """ + period = period or self.config.bb_period + std_mult = std or self.config.bb_std + _, _, _, close_col = self._get_price_columns(df) + + close = df[close_col] + min_periods = max(1, int(period * self.config.min_periods_ratio)) + + # Calculate bands + sma = close.rolling(window=period, min_periods=min_periods).mean() + rolling_std = close.rolling(window=period, min_periods=min_periods).std() + + bb_features = pd.DataFrame(index=df.index) + + bb_features['bb_upper'] = sma + (std_mult * rolling_std) + bb_features['bb_lower'] = sma - (std_mult * rolling_std) + bb_features['bb_middle'] = sma + + # Band width (normalized) + bb_features['bb_width'] = ( + (bb_features['bb_upper'] - bb_features['bb_lower']) / + (sma + 1e-8) + ) + + # Width relative to historical (squeeze indicator) + # Low values indicate compression/squeeze + width_ma = bb_features['bb_width'].rolling(window=50, min_periods=10).mean() + bb_features['bb_squeeze'] = bb_features['bb_width'] / (width_ma + 1e-8) + + # Price position within bands (0 = at lower, 1 = at upper) + bb_features['bb_position'] = ( + (close - bb_features['bb_lower']) / + (bb_features['bb_upper'] - bb_features['bb_lower'] + 1e-8) + ) + + # Distance from bands (useful for breakout detection) + bb_features['bb_dist_upper'] = (bb_features['bb_upper'] - close) / (close + 1e-8) + bb_features['bb_dist_lower'] = (close - bb_features['bb_lower']) / (close + 1e-8) + + # Percent B (standardized position, can be < 0 or > 1) + bb_features['bb_percent_b'] = ( + (close - bb_features['bb_lower']) / + (bb_features['bb_upper'] - bb_features['bb_lower'] + 1e-8) + ) + + return bb_features + + def compute_keltner_channels( + self, + df: pd.DataFrame, + period: Optional[int] = None, + mult: Optional[float] = None + ) -> pd.DataFrame: + """ + Compute Keltner Channels. + + KC = EMA +/- (mult * ATR) + + Used with Bollinger Bands to detect squeeze: + When BB is inside KC, volatility is compressed (squeeze). + + Args: + df: DataFrame with OHLC data + period: EMA/ATR period (default: 20) + mult: ATR multiplier (default: 1.5) + + Returns: + DataFrame with Keltner Channel features + """ + period = period or self.config.keltner_period + mult = mult or self.config.keltner_mult + _, _, _, close_col = self._get_price_columns(df) + + close = df[close_col] + min_periods = max(1, int(period * self.config.min_periods_ratio)) + + # Calculate EMA + ema = close.ewm(span=period, min_periods=min_periods, adjust=False).mean() + + # Calculate ATR + true_range = self.compute_true_range(df) + atr = true_range.ewm(span=period, min_periods=min_periods, adjust=False).mean() + + kc_features = pd.DataFrame(index=df.index) + + kc_features['kc_upper'] = ema + (mult * atr) + kc_features['kc_lower'] = ema - (mult * atr) + kc_features['kc_middle'] = ema + + # Channel width + kc_features['kc_width'] = ( + (kc_features['kc_upper'] - kc_features['kc_lower']) / + (ema + 1e-8) + ) + + # Price position within channels + kc_features['kc_position'] = ( + (close - kc_features['kc_lower']) / + (kc_features['kc_upper'] - kc_features['kc_lower'] + 1e-8) + ) + + return kc_features + + def compute_squeeze_indicator(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Compute Squeeze Indicator (TTM Squeeze concept). + + Squeeze occurs when Bollinger Bands are inside Keltner Channels. + This indicates low volatility and potential upcoming breakout. + + Returns: + - squeeze_on: Binary indicator (1 = squeeze active) + - squeeze_strength: How tight the squeeze is + - squeeze_duration: Consecutive bars in squeeze + + Args: + df: DataFrame with OHLC data + + Returns: + DataFrame with squeeze indicator features + """ + # Get BB and KC + bb = self.compute_bollinger_bands(df) + kc = self.compute_keltner_channels(df) + + squeeze_features = pd.DataFrame(index=df.index) + + # Squeeze is ON when BB is inside KC + # BB lower > KC lower AND BB upper < KC upper + squeeze_on = ( + (bb['bb_lower'] > kc['kc_lower']) & + (bb['bb_upper'] < kc['kc_upper']) + ).astype(float) + squeeze_features['squeeze_on'] = squeeze_on + + # Squeeze strength: how much BB is inside KC + # Higher = tighter squeeze + bb_width = bb['bb_upper'] - bb['bb_lower'] + kc_width = kc['kc_upper'] - kc['kc_lower'] + squeeze_features['squeeze_strength'] = 1 - (bb_width / (kc_width + 1e-8)) + squeeze_features['squeeze_strength'] = squeeze_features['squeeze_strength'].clip(0, 1) + + # Squeeze duration (consecutive bars in squeeze) + squeeze_duration = squeeze_on.copy() + for i in range(1, len(squeeze_duration)): + if squeeze_on.iloc[i] == 1: + squeeze_duration.iloc[i] = squeeze_duration.iloc[i-1] + 1 + else: + squeeze_duration.iloc[i] = 0 + squeeze_features['squeeze_duration'] = squeeze_duration + + # Squeeze release (transition from squeeze to no squeeze) + squeeze_features['squeeze_release'] = ( + (squeeze_on.shift(1) == 1) & (squeeze_on == 0) + ).astype(float) + + # Momentum indicator during squeeze (using close momentum) + _, _, _, close_col = self._get_price_columns(df) + close = df[close_col] + momentum = close - close.rolling(12).mean() + squeeze_features['squeeze_momentum'] = momentum / (close + 1e-8) + + # Momentum direction (positive = bullish breakout likely) + squeeze_features['squeeze_momentum_direction'] = np.sign(momentum) + + return squeeze_features + + def compute_compression_score( + self, + df: pd.DataFrame, + lookback: Optional[int] = None + ) -> pd.DataFrame: + """ + Compute range compression score. + + Measures how compressed current range is vs historical range. + Low score = high compression = potential breakout setup. + + Formula: current_range / max_range_in_lookback + + Args: + df: DataFrame with OHLC data + lookback: Lookback period for historical range (default: 50) + + Returns: + DataFrame with compression score features + """ + lookback = lookback or self.config.compression_lookback + _, high_col, low_col, close_col = self._get_price_columns(df) + + high = df[high_col] + low = df[low_col] + close = df[close_col] + + compression_features = pd.DataFrame(index=df.index) + + # Current range (single bar) + current_range = high - low + + # Rolling range (multi-bar) + rolling_high = high.rolling(5).max() + rolling_low = low.rolling(5).min() + rolling_range = rolling_high - rolling_low + + # Historical max range + max_range = rolling_range.rolling(lookback).max() + min_range = rolling_range.rolling(lookback).min() + + # Compression score: min/max (lower = more compressed) + compression_features['compression_score'] = ( + rolling_range / (max_range + 1e-8) + ) + + # Normalized compression (0-1 scale) + compression_features['compression_normalized'] = ( + (rolling_range - min_range) / (max_range - min_range + 1e-8) + ) + + # Range percentile (how current range ranks historically) + def rolling_percentile(series, window): + result = pd.Series(index=series.index, dtype=float) + for i in range(window, len(series)): + hist_values = series.iloc[i-window:i] + current = series.iloc[i] + percentile = (hist_values < current).sum() / window + result.iloc[i] = percentile + return result + + compression_features['range_percentile'] = rolling_percentile( + rolling_range, lookback + ) + + # Range as percentage of price + compression_features['range_pct'] = current_range / (close + 1e-8) * 100 + + # Range change (expansion/contraction trend) + compression_features['range_change_5'] = rolling_range.pct_change(5) + compression_features['range_change_10'] = rolling_range.pct_change(10) + + # Inside bars count (consecutive lower range bars) + lower_range = current_range < current_range.shift(1) + inside_count = lower_range.astype(int).copy() + for i in range(1, len(inside_count)): + if lower_range.iloc[i]: + inside_count.iloc[i] = inside_count.iloc[i-1] + 1 + else: + inside_count.iloc[i] = 0 + compression_features['inside_bar_count'] = inside_count + + return compression_features + + def compute_historical_volatility( + self, + df: pd.DataFrame, + windows: Optional[List[int]] = None + ) -> pd.DataFrame: + """ + Compute historical volatility metrics. + + Uses log returns for more accurate volatility estimation. + + Args: + df: DataFrame with OHLC data + windows: List of volatility windows (default: [10, 20, 50]) + + Returns: + DataFrame with historical volatility features + """ + windows = windows or self.config.hv_windows + _, _, _, close_col = self._get_price_columns(df) + + close = df[close_col] + + # Log returns (more accurate for volatility) + log_returns = np.log(close / close.shift(1)) + + hv_features = pd.DataFrame(index=df.index) + + for window in windows: + min_periods = max(1, int(window * self.config.min_periods_ratio)) + + # Standard deviation of log returns (annualized) + hv = log_returns.rolling(window=window, min_periods=min_periods).std() + hv_features[f'hv_{window}'] = hv + + # Annualized (assuming 252 trading days, adjusting for intraday) + hv_features[f'hv_{window}_annual'] = hv * np.sqrt(252 * 24) # for hourly + + # Volatility ratios + if len(windows) >= 2: + short_window = windows[0] + long_window = windows[-1] + hv_features['hv_ratio'] = ( + hv_features[f'hv_{short_window}'] / + (hv_features[f'hv_{long_window}'] + 1e-8) + ) + + # Volatility percentile + hv_20 = hv_features.get('hv_20', hv_features[f'hv_{windows[0]}']) + hv_rolling_max = hv_20.rolling(100).max() + hv_rolling_min = hv_20.rolling(100).min() + hv_features['hv_percentile'] = ( + (hv_20 - hv_rolling_min) / (hv_rolling_max - hv_rolling_min + 1e-8) + ) + + # Volatility regime (low/medium/high) + hv_features['hv_regime'] = pd.cut( + hv_features['hv_percentile'], + bins=[-np.inf, 0.33, 0.66, np.inf], + labels=[0, 1, 2] + ).astype(float) + + # Volatility trend + hv_features['hv_trend'] = hv_20 - hv_20.rolling(10).mean() + + # Parkinson volatility (using high-low range) + _, high_col, low_col, _ = self._get_price_columns(df) + high = df[high_col] + low = df[low_col] + + log_hl = np.log(high / low) + parkinson = log_hl.pow(2) / (4 * np.log(2)) + hv_features['parkinson_vol'] = parkinson.rolling(20).mean().pow(0.5) + + return hv_features + + def label_breakouts( + self, + df: pd.DataFrame, + atr_mult: Optional[float] = None, + forward_periods: Optional[int] = None + ) -> pd.DataFrame: + """ + Label breakouts for training. + + A breakout is defined as a move exceeding atr_mult * ATR + within forward_periods bars. + + Labels: + - 0: No breakout + - 1: Bullish breakout (upward) + - 2: Bearish breakout (downward) + + Also returns direction and magnitude for regression targets. + + Args: + df: DataFrame with OHLC data + atr_mult: ATR multiplier threshold (default: 2.0) + forward_periods: Forward look period (default: 5) + + Returns: + DataFrame with breakout labels and targets + """ + atr_mult = atr_mult or self.config.breakout_atr_mult + forward_periods = forward_periods or self.config.forward_periods + + _, high_col, low_col, close_col = self._get_price_columns(df) + + close = df[close_col] + high = df[high_col] + low = df[low_col] + + # Compute ATR for threshold + true_range = self.compute_true_range(df) + atr = true_range.rolling(20).mean() + + labels = pd.DataFrame(index=df.index) + + # Forward high and low (max/min in forward window) + forward_high = high.rolling(forward_periods).max().shift(-forward_periods) + forward_low = low.rolling(forward_periods).min().shift(-forward_periods) + + # Calculate forward moves from current close + upward_move = forward_high - close + downward_move = close - forward_low + + # Threshold for breakout + threshold = atr_mult * atr + + # Classify breakouts + bullish_breakout = upward_move > threshold + bearish_breakout = downward_move > threshold + + # Labels: 0 = no breakout, 1 = bullish, 2 = bearish + # If both directions trigger, use the larger move + labels['breakout_label'] = 0 + labels.loc[bullish_breakout, 'breakout_label'] = 1 + labels.loc[bearish_breakout, 'breakout_label'] = 2 + + # Handle cases where both are true (use stronger direction) + both_mask = bullish_breakout & bearish_breakout + labels.loc[both_mask & (upward_move >= downward_move), 'breakout_label'] = 1 + labels.loc[both_mask & (downward_move > upward_move), 'breakout_label'] = 2 + + # Binary breakout (any direction) + labels['breakout_binary'] = (labels['breakout_label'] > 0).astype(int) + + # Direction: 1 = bullish, -1 = bearish, 0 = no breakout + labels['breakout_direction'] = 0 + labels.loc[labels['breakout_label'] == 1, 'breakout_direction'] = 1 + labels.loc[labels['breakout_label'] == 2, 'breakout_direction'] = -1 + + # Magnitude (for regression) + labels['breakout_magnitude'] = np.maximum(upward_move, downward_move) / (atr + 1e-8) + + # Signed magnitude (positive for bullish, negative for bearish) + labels['breakout_signed_magnitude'] = labels['breakout_magnitude'] * labels['breakout_direction'] + + # Forward return (for additional target) + labels['forward_return'] = close.shift(-forward_periods) / close - 1 + + # Log breakout statistics + total = len(labels) + no_breakout = (labels['breakout_label'] == 0).sum() + bullish = (labels['breakout_label'] == 1).sum() + bearish = (labels['breakout_label'] == 2).sum() + + logger.info(f"Breakout labeling complete:") + logger.info(f" No breakout: {no_breakout} ({no_breakout/total*100:.1f}%)") + logger.info(f" Bullish: {bullish} ({bullish/total*100:.1f}%)") + logger.info(f" Bearish: {bearish} ({bearish/total*100:.1f}%)") + + return labels + + def compute_all_features(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Compute all VBP features. + + Combines all feature types into a single DataFrame. + + Args: + df: DataFrame with OHLC data + + Returns: + DataFrame with all VBP features + """ + logger.info(f"Computing VBP features for {len(df)} samples...") + + # Compute all feature groups + atr_features = self.compute_atr(df) + bb_features = self.compute_bollinger_bands(df) + kc_features = self.compute_keltner_channels(df) + squeeze_features = self.compute_squeeze_indicator(df) + compression_features = self.compute_compression_score(df) + hv_features = self.compute_historical_volatility(df) + + # Combine all features + all_features = pd.concat([ + atr_features, + bb_features, + kc_features, + squeeze_features, + compression_features, + hv_features + ], axis=1) + + # Remove duplicate columns (if any) + all_features = all_features.loc[:, ~all_features.columns.duplicated()] + + # Fill NaN with forward/backward fill, then 0 + all_features = all_features.fillna(method='ffill').fillna(method='bfill').fillna(0) + + logger.info(f"Computed {len(all_features.columns)} features") + + return all_features + + def get_feature_names(self) -> List[str]: + """Get list of all feature names.""" + # Create a dummy dataframe to get feature names + dummy_dates = pd.date_range('2020-01-01', periods=200, freq='1H') + dummy_df = pd.DataFrame({ + 'open': np.random.randn(200).cumsum() + 100, + 'high': np.random.randn(200).cumsum() + 101, + 'low': np.random.randn(200).cumsum() + 99, + 'close': np.random.randn(200).cumsum() + 100 + }, index=dummy_dates) + dummy_df['high'] = dummy_df[['open', 'high', 'close']].max(axis=1) + dummy_df['low'] = dummy_df[['open', 'low', 'close']].min(axis=1) + + features = self.compute_all_features(dummy_df) + return features.columns.tolist() + + +if __name__ == "__main__": + # Test the feature engineering module + print("Testing VBP Feature Engineering") + print("=" * 60) + + # Create sample OHLCV data with some volatility patterns + np.random.seed(42) + n = 500 + + dates = pd.date_range('2025-01-01', periods=n, freq='1H') + + # Simulate price with varying volatility + volatility = np.where( + (np.arange(n) % 100 > 70), # High vol periods + 0.02, + 0.005 # Low vol (compression) periods + ) + + returns = np.random.randn(n) * volatility + price = 2000 * (1 + returns).cumprod() + + df = pd.DataFrame({ + 'open': price, + 'high': price * (1 + np.abs(np.random.randn(n)) * volatility), + 'low': price * (1 - np.abs(np.random.randn(n)) * volatility), + 'close': price * (1 + np.random.randn(n) * volatility * 0.5), + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Ensure OHLC consistency + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Initialize engineer + config = VBPFeatureConfig() + engineer = VBPFeatureEngineer(config) + + # Test individual feature groups + print("\n1. Testing ATR features...") + atr_features = engineer.compute_atr(df) + print(f" ATR features: {len(atr_features.columns)}") + print(f" Columns: {list(atr_features.columns)}") + + print("\n2. Testing Bollinger Bands features...") + bb_features = engineer.compute_bollinger_bands(df) + print(f" BB features: {len(bb_features.columns)}") + print(f" Columns: {list(bb_features.columns)}") + + print("\n3. Testing Keltner Channels features...") + kc_features = engineer.compute_keltner_channels(df) + print(f" KC features: {len(kc_features.columns)}") + + print("\n4. Testing Squeeze Indicator...") + squeeze_features = engineer.compute_squeeze_indicator(df) + print(f" Squeeze features: {len(squeeze_features.columns)}") + print(f" Squeeze ON periods: {squeeze_features['squeeze_on'].sum()}") + + print("\n5. Testing Compression Score...") + compression_features = engineer.compute_compression_score(df) + print(f" Compression features: {len(compression_features.columns)}") + print(f" Avg compression score: {compression_features['compression_score'].mean():.4f}") + + print("\n6. Testing Historical Volatility...") + hv_features = engineer.compute_historical_volatility(df) + print(f" HV features: {len(hv_features.columns)}") + + print("\n7. Testing Breakout Labels...") + labels = engineer.label_breakouts(df) + print(f" Label columns: {list(labels.columns)}") + + print("\n8. Testing All Features Combined...") + all_features = engineer.compute_all_features(df) + print(f" Total features: {len(all_features.columns)}") + print(f" Sample shape: {all_features.shape}") + print(f" NaN count: {all_features.isna().sum().sum()}") + + print("\n" + "=" * 60) + print("All feature engineering tests passed!") diff --git a/src/models/strategies/vbp/model.py b/src/models/strategies/vbp/model.py new file mode 100644 index 0000000..dfaf090 --- /dev/null +++ b/src/models/strategies/vbp/model.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +""" +VBP Model - Volatility Breakout Predictor Complete Model +========================================================= +Complete model for predicting volatility breakouts combining: +1. CNN encoder for temporal pattern extraction +2. XGBoost classifier for breakout detection (handles class imbalance) +3. Separate heads for breakout probability, direction, and magnitude + +The model uses a hybrid approach: +- Neural network (CNN + Attention) for feature encoding +- XGBoost for final classification (better for tabular/imbalanced data) + +Key Features: +- Handles class imbalance (breakouts are rare ~5-10%) +- Predicts: breakout_probability, direction, magnitude +- Supports both training and inference modes + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Union, Any +from dataclasses import dataclass, field +from pathlib import Path +import joblib +from loguru import logger + +import torch +import torch.nn as nn +import torch.nn.functional as F + +try: + from xgboost import XGBClassifier, XGBRegressor + HAS_XGBOOST = True +except ImportError: + HAS_XGBOOST = False + logger.warning("XGBoost not available - some features will be limited") + +from .feature_engineering import VBPFeatureEngineer, VBPFeatureConfig +from .cnn_encoder import VolatilityCNN, CNNEncoderConfig + + +@dataclass +class VBPModelConfig: + """Configuration for the VBP model.""" + + # Feature engineering config + feature_config: VBPFeatureConfig = field(default_factory=VBPFeatureConfig) + + # CNN encoder config + cnn_config: CNNEncoderConfig = field(default_factory=lambda: CNNEncoderConfig( + conv_configs=[(32, 3), (64, 5), (128, 7)], + attention_heads=4, + dropout=0.3, + use_attention=True, + output_dim=128 + )) + + # Sequence length for CNN input + sequence_length: int = 50 + + # XGBoost configuration + xgb_n_estimators: int = 200 + xgb_max_depth: int = 6 + xgb_learning_rate: float = 0.05 + xgb_min_child_weight: int = 5 + xgb_subsample: float = 0.8 + xgb_colsample_bytree: float = 0.8 + xgb_scale_pos_weight: float = 3.0 # For class imbalance + + # Use GPU if available + use_gpu: bool = True + + # Output heads configuration + num_classes: int = 3 # 0: no breakout, 1: bullish, 2: bearish + + # Confidence thresholds + breakout_threshold: float = 0.5 + high_confidence_threshold: float = 0.7 + + +@dataclass +class VBPPrediction: + """VBP model prediction result.""" + + breakout_probability: float + breakout_class: int # 0: no breakout, 1: bullish, 2: bearish + direction: int # 1: up, -1: down, 0: no breakout + magnitude: float # Predicted magnitude in ATR units + confidence: float + class_probabilities: Dict[str, float] + is_breakout: bool + is_high_confidence: bool + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'breakout_probability': float(self.breakout_probability), + 'breakout_class': int(self.breakout_class), + 'direction': int(self.direction), + 'magnitude': float(self.magnitude), + 'confidence': float(self.confidence), + 'class_probabilities': self.class_probabilities, + 'is_breakout': bool(self.is_breakout), + 'is_high_confidence': bool(self.is_high_confidence) + } + + @property + def signal(self) -> str: + """Get trading signal string.""" + if not self.is_breakout: + return 'WAIT' + if not self.is_high_confidence: + return 'WEAK_' + ('LONG' if self.direction > 0 else 'SHORT') + return 'STRONG_' + ('LONG' if self.direction > 0 else 'SHORT') + + +class NeuralHead(nn.Module): + """ + Neural network head for regression/classification. + + Used for direction and magnitude prediction when CNN encoding + is used directly (alternative to XGBoost). + """ + + def __init__( + self, + input_dim: int, + output_dim: int, + hidden_dims: List[int] = None, + dropout: float = 0.3, + activation: str = 'relu' + ): + super().__init__() + + hidden_dims = hidden_dims or [128, 64] + + layers = [] + in_dim = input_dim + + for hidden_dim in hidden_dims: + layers.append(nn.Linear(in_dim, hidden_dim)) + layers.append(nn.BatchNorm1d(hidden_dim)) + if activation == 'relu': + layers.append(nn.ReLU()) + elif activation == 'gelu': + layers.append(nn.GELU()) + layers.append(nn.Dropout(dropout)) + in_dim = hidden_dim + + layers.append(nn.Linear(in_dim, output_dim)) + self.network = nn.Sequential(*layers) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.network(x) + + +class VBPModel: + """ + Volatility Breakout Predictor Model. + + Combines CNN-based feature encoding with XGBoost classification + for robust breakout prediction. + + Architecture: + 1. Feature Engineering: Extract volatility features + 2. CNN Encoder: Encode temporal patterns + 3. XGBoost Classifier: Predict breakout class + 4. XGBoost Regressor: Predict magnitude + + Usage: + model = VBPModel(VBPModelConfig()) + model.fit(df_train) + predictions = model.predict(df_test) + """ + + def __init__(self, config: Optional[VBPModelConfig] = None): + """ + Initialize VBP Model. + + Args: + config: Model configuration + """ + self.config = config or VBPModelConfig() + + # Initialize feature engineer + self.feature_engineer = VBPFeatureEngineer(self.config.feature_config) + + # Device selection + self.device = torch.device('cuda' if torch.cuda.is_available() and self.config.use_gpu else 'cpu') + + # Initialize CNN encoder (will be sized after first data pass) + self.cnn_encoder: Optional[VolatilityCNN] = None + + # Initialize XGBoost models + self.classifier: Optional[XGBClassifier] = None + self.regressor: Optional[XGBRegressor] = None + + # Feature info + self.feature_columns: List[str] = [] + self.n_features: int = 0 + + # Training state + self._is_fitted = False + self._label_encoder = None + + def _initialize_cnn(self, n_features: int): + """Initialize CNN encoder with correct input dimension.""" + self.n_features = n_features + self.config.cnn_config.input_channels = n_features + + self.cnn_encoder = VolatilityCNN( + input_dim=n_features, + config=self.config.cnn_config + ).to(self.device) + + def _initialize_xgboost(self, n_classes: int = 3): + """Initialize XGBoost models.""" + if not HAS_XGBOOST: + raise ImportError("XGBoost is required for VBPModel") + + # Common parameters + common_params = { + 'n_estimators': self.config.xgb_n_estimators, + 'max_depth': self.config.xgb_max_depth, + 'learning_rate': self.config.xgb_learning_rate, + 'min_child_weight': self.config.xgb_min_child_weight, + 'subsample': self.config.xgb_subsample, + 'colsample_bytree': self.config.xgb_colsample_bytree, + 'random_state': 42, + 'n_jobs': -1 + } + + # GPU settings + if self.config.use_gpu and torch.cuda.is_available(): + common_params['tree_method'] = 'hist' + common_params['device'] = 'cuda' + + # Classifier for breakout detection + classifier_params = common_params.copy() + classifier_params['objective'] = 'multi:softprob' + classifier_params['num_class'] = n_classes + classifier_params['scale_pos_weight'] = self.config.xgb_scale_pos_weight + + self.classifier = XGBClassifier(**classifier_params) + + # Regressor for magnitude prediction + regressor_params = common_params.copy() + regressor_params['objective'] = 'reg:squarederror' + + self.regressor = XGBRegressor(**regressor_params) + + def _prepare_features( + self, + df: pd.DataFrame + ) -> Tuple[np.ndarray, np.ndarray, pd.DataFrame]: + """ + Prepare features for training/prediction. + + Returns: + - X_tabular: Tabular features for XGBoost (n_samples, n_features) + - X_sequence: Sequence features for CNN (n_samples, seq_len, n_features) + - labels_df: Label DataFrame + + Args: + df: Input OHLCV DataFrame + + Returns: + Tuple of (X_tabular, X_sequence, labels_df) + """ + # Extract features + features = self.feature_engineer.compute_all_features(df) + labels = self.feature_engineer.label_breakouts(df) + + self.feature_columns = features.columns.tolist() + + # Tabular features (most recent values) + X_tabular = features.values + + # Sequence features for CNN + seq_len = self.config.sequence_length + n_samples = len(features) + n_features = len(self.feature_columns) + + # Create sequences (rolling window) + X_sequence = np.zeros((n_samples, seq_len, n_features)) + + for i in range(seq_len, n_samples): + X_sequence[i] = features.iloc[i-seq_len:i].values + + # Pad beginning samples + for i in range(min(seq_len, n_samples)): + pad_len = seq_len - i + if i > 0: + X_sequence[i, pad_len:] = features.iloc[:i].values + # First sample gets zeros (already initialized) + + return X_tabular, X_sequence, labels + + def _encode_sequences(self, X_sequence: np.ndarray) -> np.ndarray: + """ + Encode sequences using CNN encoder. + + Args: + X_sequence: Sequence array (n_samples, seq_len, n_features) + + Returns: + Encoded features (n_samples, cnn_output_dim) + """ + if self.cnn_encoder is None: + n_features = X_sequence.shape[2] + self._initialize_cnn(n_features) + + self.cnn_encoder.eval() + + # Process in batches to avoid memory issues + batch_size = 512 + n_samples = len(X_sequence) + encoded_list = [] + + with torch.no_grad(): + for i in range(0, n_samples, batch_size): + batch = X_sequence[i:i+batch_size] + batch_tensor = torch.FloatTensor(batch).to(self.device) + + encoded, _ = self.cnn_encoder(batch_tensor) + encoded_list.append(encoded.cpu().numpy()) + + return np.vstack(encoded_list) + + def _compute_class_weights(self, y: np.ndarray) -> np.ndarray: + """ + Compute class weights for imbalanced data. + + Gives higher weight to rare breakout classes. + + Args: + y: Label array + + Returns: + Sample weights array + """ + from collections import Counter + + class_counts = Counter(y) + total = len(y) + n_classes = len(class_counts) + + # Compute inverse frequency weights + class_weights = { + cls: total / (n_classes * count) + for cls, count in class_counts.items() + } + + # Apply weights to samples + sample_weights = np.array([class_weights[label] for label in y]) + + # Normalize to mean=1 + sample_weights = sample_weights / sample_weights.mean() + + logger.info(f"Class weights: {class_weights}") + logger.info(f"Sample weight range: [{sample_weights.min():.2f}, {sample_weights.max():.2f}]") + + return sample_weights + + def fit( + self, + df: pd.DataFrame, + verbose: bool = True, + val_df: Optional[pd.DataFrame] = None + ) -> Dict[str, float]: + """ + Fit the VBP model. + + Args: + df: Training DataFrame with OHLCV data + verbose: Print training progress + val_df: Optional validation DataFrame + + Returns: + Dictionary with training metrics + """ + logger.info(f"Training VBP Model on {len(df)} samples...") + + # Prepare features + X_tabular, X_sequence, labels = self._prepare_features(df) + + # Get labels + y_class = labels['breakout_label'].values + y_magnitude = labels['breakout_magnitude'].values + + # Remove samples with NaN labels (forward-looking window at end) + valid_mask = ~np.isnan(y_class) & ~np.isnan(y_magnitude) + X_tabular = X_tabular[valid_mask] + X_sequence = X_sequence[valid_mask] + y_class = y_class[valid_mask].astype(int) + y_magnitude = y_magnitude[valid_mask] + + logger.info(f"Valid samples after label filtering: {len(y_class)}") + + # Initialize CNN if needed + if self.cnn_encoder is None: + self._initialize_cnn(X_sequence.shape[2]) + + # Encode sequences with CNN + logger.info("Encoding sequences with CNN...") + X_encoded = self._encode_sequences(X_sequence) + + # Combine tabular and encoded features + X_combined = np.hstack([X_tabular, X_encoded]) + logger.info(f"Combined feature dimension: {X_combined.shape[1]}") + + # Compute class weights for imbalanced data + sample_weights = self._compute_class_weights(y_class) + + # Initialize XGBoost models + n_classes = len(np.unique(y_class)) + self._initialize_xgboost(n_classes) + + # Train classifier + logger.info("Training breakout classifier...") + self.classifier.fit( + X_combined, y_class, + sample_weight=sample_weights, + verbose=verbose + ) + + # Train magnitude regressor (only on breakout samples) + breakout_mask = y_class > 0 + if breakout_mask.sum() > 0: + logger.info(f"Training magnitude regressor on {breakout_mask.sum()} breakout samples...") + self.regressor.fit( + X_combined[breakout_mask], + y_magnitude[breakout_mask], + verbose=verbose + ) + else: + logger.warning("No breakout samples found for magnitude regressor training") + + self._is_fitted = True + + # Calculate training metrics + y_pred_class = self.classifier.predict(X_combined) + y_pred_proba = self.classifier.predict_proba(X_combined) + + from sklearn.metrics import accuracy_score, f1_score, classification_report + + metrics = { + 'accuracy': accuracy_score(y_class, y_pred_class), + 'f1_macro': f1_score(y_class, y_pred_class, average='macro', zero_division=0), + 'f1_weighted': f1_score(y_class, y_pred_class, average='weighted', zero_division=0), + 'n_samples': len(y_class), + 'n_breakouts': int(breakout_mask.sum()), + 'breakout_ratio': float(breakout_mask.sum() / len(y_class)) + } + + if verbose: + logger.info(f"\nTraining Metrics:") + logger.info(f" Accuracy: {metrics['accuracy']:.4f}") + logger.info(f" F1 (macro): {metrics['f1_macro']:.4f}") + logger.info(f" F1 (weighted): {metrics['f1_weighted']:.4f}") + logger.info(f" Breakout ratio: {metrics['breakout_ratio']:.2%}") + logger.info(f"\n{classification_report(y_class, y_pred_class, zero_division=0)}") + + return metrics + + def predict(self, df: pd.DataFrame) -> List[VBPPrediction]: + """ + Generate predictions for input data. + + Args: + df: Input DataFrame with OHLCV data + + Returns: + List of VBPPrediction objects + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted before prediction") + + # Prepare features + X_tabular, X_sequence, _ = self._prepare_features(df) + + # Encode sequences + X_encoded = self._encode_sequences(X_sequence) + + # Combine features + X_combined = np.hstack([X_tabular, X_encoded]) + + # Get predictions + y_pred_class = self.classifier.predict(X_combined) + y_pred_proba = self.classifier.predict_proba(X_combined) + + # Predict magnitude for all samples + y_pred_magnitude = self.regressor.predict(X_combined) + + # Create prediction objects + predictions = [] + for i in range(len(X_combined)): + pred_class = int(y_pred_class[i]) + proba = y_pred_proba[i] + + # Breakout probability (sum of class 1 and 2) + breakout_prob = proba[1] + proba[2] if len(proba) > 2 else proba[1] + + # Direction + if pred_class == 1: + direction = 1 # Bullish + elif pred_class == 2: + direction = -1 # Bearish + else: + direction = 0 # No breakout + + # Confidence is the probability of predicted class + confidence = float(proba[pred_class]) + + # Is breakout? + is_breakout = pred_class > 0 and breakout_prob > self.config.breakout_threshold + + # Is high confidence? + is_high_confidence = confidence > self.config.high_confidence_threshold + + pred = VBPPrediction( + breakout_probability=float(breakout_prob), + breakout_class=pred_class, + direction=direction, + magnitude=float(y_pred_magnitude[i]), + confidence=confidence, + class_probabilities={ + 'no_breakout': float(proba[0]), + 'bullish': float(proba[1]) if len(proba) > 1 else 0.0, + 'bearish': float(proba[2]) if len(proba) > 2 else 0.0 + }, + is_breakout=is_breakout, + is_high_confidence=is_high_confidence + ) + predictions.append(pred) + + return predictions + + def predict_single(self, df: pd.DataFrame) -> VBPPrediction: + """ + Get prediction for the most recent bar. + + Args: + df: DataFrame with at least sequence_length bars + + Returns: + Single VBPPrediction + """ + # Ensure enough data for sequence + min_length = self.config.sequence_length + 50 # Extra for feature calculation + if len(df) < min_length: + raise ValueError(f"Need at least {min_length} bars for prediction") + + predictions = self.predict(df.tail(min_length)) + return predictions[-1] + + def get_feature_importance(self, top_n: int = 20) -> Dict[str, float]: + """ + Get feature importance from the classifier. + + Args: + top_n: Number of top features to return + + Returns: + Dictionary of feature names to importance scores + """ + if not self._is_fitted: + raise RuntimeError("Model must be fitted first") + + # Get importance from classifier + importance = self.classifier.feature_importances_ + + # Create feature names (tabular + encoded) + feature_names = self.feature_columns.copy() + for i in range(self.config.cnn_config.output_dim): + feature_names.append(f'cnn_encoded_{i}') + + # Create importance dict + importance_dict = dict(zip(feature_names, importance)) + + # Sort by importance + sorted_importance = dict( + sorted(importance_dict.items(), key=lambda x: x[1], reverse=True)[:top_n] + ) + + return sorted_importance + + def save(self, path: str) -> None: + """ + Save model to disk. + + Args: + path: Directory path to save model + """ + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + + # Save CNN encoder + if self.cnn_encoder is not None: + torch.save( + self.cnn_encoder.state_dict(), + path / 'cnn_encoder.pt' + ) + + # Save XGBoost models + if self.classifier is not None: + joblib.dump(self.classifier, path / 'classifier.joblib') + + if self.regressor is not None: + joblib.dump(self.regressor, path / 'regressor.joblib') + + # Save metadata + metadata = { + 'config': self.config, + 'feature_columns': self.feature_columns, + 'n_features': self.n_features, + 'is_fitted': self._is_fitted + } + joblib.dump(metadata, path / 'metadata.joblib') + + logger.info(f"Model saved to {path}") + + def load(self, path: str) -> None: + """ + Load model from disk. + + Args: + path: Directory path containing saved model + """ + path = Path(path) + + # Load metadata + metadata = joblib.load(path / 'metadata.joblib') + self.config = metadata['config'] + self.feature_columns = metadata['feature_columns'] + self.n_features = metadata['n_features'] + self._is_fitted = metadata['is_fitted'] + + # Load CNN encoder + if (path / 'cnn_encoder.pt').exists(): + self._initialize_cnn(self.n_features) + self.cnn_encoder.load_state_dict( + torch.load(path / 'cnn_encoder.pt', map_location=self.device) + ) + self.cnn_encoder.eval() + + # Load XGBoost models + if (path / 'classifier.joblib').exists(): + self.classifier = joblib.load(path / 'classifier.joblib') + + if (path / 'regressor.joblib').exists(): + self.regressor = joblib.load(path / 'regressor.joblib') + + logger.info(f"Model loaded from {path}") + + +if __name__ == "__main__": + # Test the VBP model + print("Testing VBP Model") + print("=" * 60) + + # Create sample data + np.random.seed(42) + n = 1000 + + dates = pd.date_range('2025-01-01', periods=n, freq='1H') + + # Simulate price with breakout patterns + volatility = np.where( + (np.arange(n) % 100 > 85), # High vol after compression + 0.02, + 0.005 + ) + + returns = np.random.randn(n) * volatility + price = 2000 * (1 + returns).cumprod() + + df = pd.DataFrame({ + 'open': price, + 'high': price * (1 + np.abs(np.random.randn(n)) * volatility), + 'low': price * (1 - np.abs(np.random.randn(n)) * volatility), + 'close': price * (1 + np.random.randn(n) * volatility * 0.5), + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Ensure OHLC consistency + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Split data + train_size = 800 + df_train = df.iloc[:train_size] + df_test = df.iloc[train_size:] + + print(f"\n1. Initializing model...") + config = VBPModelConfig( + sequence_length=30, + xgb_n_estimators=50, # Reduced for testing + use_gpu=False # CPU for testing + ) + model = VBPModel(config) + + print(f"\n2. Training model...") + metrics = model.fit(df_train, verbose=True) + + print(f"\n3. Making predictions...") + predictions = model.predict(df_test) + print(f" Generated {len(predictions)} predictions") + + # Sample predictions + print(f"\n4. Sample predictions:") + for i in [0, 50, 100, 150]: + if i < len(predictions): + pred = predictions[i] + print(f" Sample {i}: {pred.signal} " + f"(prob={pred.breakout_probability:.3f}, " + f"dir={pred.direction}, mag={pred.magnitude:.2f})") + + print(f"\n5. Feature importance (top 10):") + importance = model.get_feature_importance(top_n=10) + for name, score in importance.items(): + print(f" {name}: {score:.4f}") + + print(f"\n6. Testing single prediction...") + single_pred = model.predict_single(df_test) + print(f" Latest signal: {single_pred.signal}") + print(f" Prediction: {single_pred.to_dict()}") + + print("\n" + "=" * 60) + print("All VBP model tests passed!") diff --git a/src/models/strategies/vbp/trainer.py b/src/models/strategies/vbp/trainer.py new file mode 100644 index 0000000..9c5da78 --- /dev/null +++ b/src/models/strategies/vbp/trainer.py @@ -0,0 +1,832 @@ +#!/usr/bin/env python3 +""" +VBP Trainer - Training Module for Volatility Breakout Predictor +================================================================ +Comprehensive training system for VBP model with: +- Balanced sampling for rare breakout events +- Walk-forward cross-validation +- Performance tracking and evaluation +- Hyperparameter optimization support + +Key Features: +1. Oversampling of breakout events (3x default) +2. Walk-forward validation for time series +3. Comprehensive breakout detection metrics +4. Early stopping and checkpointing + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Optional, Tuple, Any, Callable +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +import json +import joblib +from loguru import logger +from collections import Counter + +import torch +import torch.nn as nn +import torch.optim as optim +from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler + +from sklearn.metrics import ( + accuracy_score, precision_score, recall_score, f1_score, + classification_report, confusion_matrix, roc_auc_score, + precision_recall_curve, average_precision_score +) + +from .model import VBPModel, VBPModelConfig, VBPPrediction +from .feature_engineering import VBPFeatureEngineer, VBPFeatureConfig + + +@dataclass +class VBPTrainerConfig: + """Configuration for VBP trainer.""" + + # Oversampling configuration + oversample_breakouts: bool = True + oversample_multiplier: float = 3.0 # How much to oversample breakouts + + # Walk-forward configuration + n_folds: int = 5 + train_ratio: float = 0.8 # Ratio of training data in each fold + gap_periods: int = 10 # Gap between train and test to avoid leakage + + # Training configuration + epochs: int = 100 + batch_size: int = 64 + learning_rate: float = 1e-3 + weight_decay: float = 1e-4 + + # Early stopping + early_stopping_patience: int = 10 + early_stopping_min_delta: float = 1e-4 + + # Checkpointing + checkpoint_dir: str = './checkpoints/vbp' + save_best_only: bool = True + + # Evaluation thresholds + breakout_prob_threshold: float = 0.5 + high_confidence_threshold: float = 0.7 + + # Class balancing + use_class_weights: bool = True + class_weight_method: str = 'balanced' # 'balanced', 'sqrt', 'log' + + +@dataclass +class VBPMetrics: + """Metrics for VBP model evaluation.""" + + # Classification metrics + accuracy: float = 0.0 + precision: float = 0.0 + recall: float = 0.0 + f1: float = 0.0 + + # Per-class metrics + precision_per_class: Dict[str, float] = field(default_factory=dict) + recall_per_class: Dict[str, float] = field(default_factory=dict) + f1_per_class: Dict[str, float] = field(default_factory=dict) + + # Breakout-specific metrics + breakout_precision: float = 0.0 # Precision for breakout classes + breakout_recall: float = 0.0 # Recall for breakout classes + breakout_f1: float = 0.0 + + # Direction accuracy (when breakout is predicted) + direction_accuracy: float = 0.0 + + # Magnitude metrics (for regression) + magnitude_mae: float = 0.0 + magnitude_rmse: float = 0.0 + + # ROC-AUC (one-vs-rest) + roc_auc: float = 0.0 + + # Confusion matrix + confusion_matrix: Optional[np.ndarray] = None + + # Sample counts + n_samples: int = 0 + n_breakouts: int = 0 + n_predicted_breakouts: int = 0 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + 'accuracy': self.accuracy, + 'precision': self.precision, + 'recall': self.recall, + 'f1': self.f1, + 'precision_per_class': self.precision_per_class, + 'recall_per_class': self.recall_per_class, + 'f1_per_class': self.f1_per_class, + 'breakout_precision': self.breakout_precision, + 'breakout_recall': self.breakout_recall, + 'breakout_f1': self.breakout_f1, + 'direction_accuracy': self.direction_accuracy, + 'magnitude_mae': self.magnitude_mae, + 'magnitude_rmse': self.magnitude_rmse, + 'roc_auc': self.roc_auc, + 'n_samples': self.n_samples, + 'n_breakouts': self.n_breakouts, + 'n_predicted_breakouts': self.n_predicted_breakouts + } + + +class VBPDataset(Dataset): + """ + PyTorch Dataset for VBP training. + + Handles sequence creation and label alignment. + """ + + def __init__( + self, + features: np.ndarray, + labels: np.ndarray, + magnitudes: np.ndarray, + sequence_length: int = 50 + ): + self.features = features + self.labels = labels + self.magnitudes = magnitudes + self.sequence_length = sequence_length + + # Create valid indices (need enough history for sequence) + self.valid_indices = np.arange(sequence_length, len(features)) + + def __len__(self): + return len(self.valid_indices) + + def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + actual_idx = self.valid_indices[idx] + + # Get sequence + seq_start = actual_idx - self.sequence_length + sequence = self.features[seq_start:actual_idx] + + # Get current features (last row of sequence for tabular) + current_features = self.features[actual_idx - 1] + + # Get labels + label = self.labels[actual_idx] + magnitude = self.magnitudes[actual_idx] + + return ( + torch.FloatTensor(sequence), + torch.FloatTensor(current_features), + torch.LongTensor([label]), + torch.FloatTensor([magnitude]) + ) + + +class VBPTrainer: + """ + Trainer for Volatility Breakout Predictor. + + Handles: + - Data preparation with oversampling + - Walk-forward cross-validation + - Training with early stopping + - Comprehensive evaluation + + Usage: + trainer = VBPTrainer(VBPTrainerConfig()) + metrics = trainer.train(symbol, df) + eval_metrics = trainer.evaluate_breakout_detection(y_true, y_pred) + """ + + def __init__( + self, + config: Optional[VBPTrainerConfig] = None, + model_config: Optional[VBPModelConfig] = None + ): + """ + Initialize VBP Trainer. + + Args: + config: Trainer configuration + model_config: Model configuration + """ + self.config = config or VBPTrainerConfig() + self.model_config = model_config or VBPModelConfig() + + # Training state + self.best_metrics: Optional[VBPMetrics] = None + self.training_history: List[Dict[str, float]] = [] + self.fold_results: List[VBPMetrics] = [] + + def compute_class_weights( + self, + labels: np.ndarray + ) -> np.ndarray: + """ + Compute class weights for imbalanced data. + + Supports multiple weighting methods: + - 'balanced': Inverse frequency + - 'sqrt': Square root of inverse frequency + - 'log': Logarithm of inverse frequency + + Args: + labels: Array of class labels + + Returns: + Array of sample weights + """ + class_counts = Counter(labels) + n_samples = len(labels) + n_classes = len(class_counts) + + if self.config.class_weight_method == 'balanced': + # Standard inverse frequency weighting + class_weights = { + cls: n_samples / (n_classes * count) + for cls, count in class_counts.items() + } + elif self.config.class_weight_method == 'sqrt': + # Sqrt of inverse frequency (less aggressive) + class_weights = { + cls: np.sqrt(n_samples / (n_classes * count)) + for cls, count in class_counts.items() + } + elif self.config.class_weight_method == 'log': + # Log of inverse frequency (even less aggressive) + class_weights = { + cls: np.log1p(n_samples / (n_classes * count)) + for cls, count in class_counts.items() + } + else: + # Default to balanced + class_weights = { + cls: n_samples / (n_classes * count) + for cls, count in class_counts.items() + } + + # Apply weights to samples + sample_weights = np.array([class_weights[label] for label in labels]) + + # Normalize + sample_weights = sample_weights / sample_weights.mean() + + logger.info(f"Class distribution: {dict(class_counts)}") + logger.info(f"Class weights ({self.config.class_weight_method}): {class_weights}") + + return sample_weights + + def oversample_breakouts( + self, + df: pd.DataFrame, + labels: pd.Series, + multiplier: Optional[float] = None + ) -> Tuple[pd.DataFrame, pd.Series]: + """ + Oversample breakout events. + + Since breakouts are rare (~5-10%), we oversample them + to improve model training. + + Args: + df: DataFrame with features + labels: Series with breakout labels + multiplier: Oversampling multiplier (default: from config) + + Returns: + Tuple of (oversampled_df, oversampled_labels) + """ + multiplier = multiplier or self.config.oversample_multiplier + + # Identify breakout samples (classes 1 and 2) + breakout_mask = labels > 0 + breakout_indices = df.index[breakout_mask] + n_breakouts = len(breakout_indices) + + if n_breakouts == 0: + logger.warning("No breakout samples found for oversampling") + return df, labels + + # Calculate number of samples to add + n_oversample = int(n_breakouts * (multiplier - 1)) + + if n_oversample <= 0: + return df, labels + + # Randomly sample breakout indices with replacement + oversample_indices = np.random.choice(breakout_indices, size=n_oversample, replace=True) + + # Create oversampled dataframes + df_oversampled = pd.concat([df, df.loc[oversample_indices]]) + labels_oversampled = pd.concat([labels, labels.loc[oversample_indices]]) + + # Shuffle + shuffle_idx = np.random.permutation(len(df_oversampled)) + df_oversampled = df_oversampled.iloc[shuffle_idx].reset_index(drop=True) + labels_oversampled = labels_oversampled.iloc[shuffle_idx].reset_index(drop=True) + + original_breakout_ratio = n_breakouts / len(labels) + new_breakout_ratio = (labels_oversampled > 0).sum() / len(labels_oversampled) + + logger.info(f"Oversampling breakouts: {n_breakouts} -> {n_breakouts + n_oversample}") + logger.info(f"Breakout ratio: {original_breakout_ratio:.2%} -> {new_breakout_ratio:.2%}") + + return df_oversampled, labels_oversampled + + def create_walk_forward_splits( + self, + df: pd.DataFrame, + n_folds: Optional[int] = None + ) -> List[Tuple[pd.DataFrame, pd.DataFrame]]: + """ + Create walk-forward cross-validation splits. + + Walk-forward ensures temporal ordering: + - Train on past data, test on future data + - Each fold moves forward in time + - Gap between train and test to avoid leakage + + Args: + df: Input DataFrame + n_folds: Number of folds (default: from config) + + Returns: + List of (train_df, test_df) tuples + """ + n_folds = n_folds or self.config.n_folds + n_samples = len(df) + + # Calculate fold size + fold_size = n_samples // (n_folds + 1) # +1 for initial training data + + splits = [] + + for fold in range(n_folds): + # Training end (expanding window) + train_end = fold_size * (fold + 2) + + # Apply gap + train_end_with_gap = train_end - self.config.gap_periods + + # Test start and end + test_start = train_end + test_end = min(train_end + fold_size, n_samples) + + if test_end <= test_start: + continue + + train_df = df.iloc[:train_end_with_gap] + test_df = df.iloc[test_start:test_end] + + splits.append((train_df, test_df)) + + logger.info(f"Fold {fold + 1}: Train [{0}:{train_end_with_gap}], " + f"Test [{test_start}:{test_end}]") + + return splits + + def train( + self, + symbol: str, + df: pd.DataFrame, + verbose: bool = True + ) -> VBPMetrics: + """ + Train VBP model for a symbol. + + Args: + symbol: Trading symbol (for logging/saving) + df: OHLCV DataFrame + verbose: Print training progress + + Returns: + Training metrics + """ + logger.info(f"Training VBP model for {symbol} on {len(df)} samples...") + + # Create model + model = VBPModel(self.model_config) + + # Prepare data with oversampling if enabled + feature_engineer = VBPFeatureEngineer(self.model_config.feature_config) + features = feature_engineer.compute_all_features(df) + labels = feature_engineer.label_breakouts(df) + + # Get labels + y_class = labels['breakout_label'].values + y_magnitude = labels['breakout_magnitude'].values + + # Handle oversampling + if self.config.oversample_breakouts: + # Create temporary df with features and labels aligned + features['_label'] = y_class + features['_magnitude'] = y_magnitude + + # Remove NaN labels + valid_mask = ~np.isnan(features['_label']) + features = features[valid_mask] + + # Oversample + label_series = features['_label'] + features_clean, labels_oversampled = self.oversample_breakouts( + features.drop(columns=['_label', '_magnitude']), + label_series + ) + + # Update data + df_for_training = df[valid_mask] + + # Train model + metrics_dict = model.fit(df, verbose=verbose) + + # Create metrics object + metrics = VBPMetrics( + accuracy=metrics_dict.get('accuracy', 0), + f1=metrics_dict.get('f1_macro', 0), + n_samples=metrics_dict.get('n_samples', 0), + n_breakouts=metrics_dict.get('n_breakouts', 0) + ) + + # Save model + checkpoint_path = Path(self.config.checkpoint_dir) / symbol + model.save(str(checkpoint_path)) + + self.best_metrics = metrics + return metrics + + def walk_forward_train( + self, + symbol: str, + df: pd.DataFrame, + n_folds: Optional[int] = None, + verbose: bool = True + ) -> VBPMetrics: + """ + Train with walk-forward cross-validation. + + More robust evaluation for time series data. + + Args: + symbol: Trading symbol + df: OHLCV DataFrame + n_folds: Number of CV folds + verbose: Print progress + + Returns: + Aggregated metrics across all folds + """ + n_folds = n_folds or self.config.n_folds + + logger.info(f"Walk-forward training for {symbol} with {n_folds} folds...") + + # Create splits + splits = self.create_walk_forward_splits(df, n_folds) + + self.fold_results = [] + all_y_true = [] + all_y_pred = [] + all_y_proba = [] + all_magnitudes_true = [] + all_magnitudes_pred = [] + + for fold_idx, (train_df, test_df) in enumerate(splits): + logger.info(f"\n{'='*50}") + logger.info(f"Fold {fold_idx + 1}/{len(splits)}") + logger.info(f"{'='*50}") + + # Create model for this fold + model = VBPModel(self.model_config) + + # Train + train_metrics = model.fit(train_df, verbose=verbose) + + # Predict on test set + predictions = model.predict(test_df) + + # Extract predictions + feature_engineer = VBPFeatureEngineer(self.model_config.feature_config) + test_labels = feature_engineer.label_breakouts(test_df) + + # Get valid indices (where we have labels) + valid_mask = ~np.isnan(test_labels['breakout_label'].values) + valid_indices = np.where(valid_mask)[0] + + if len(valid_indices) == 0: + logger.warning(f"No valid test samples in fold {fold_idx + 1}") + continue + + # Collect predictions for valid samples + for idx in valid_indices: + if idx < len(predictions): + pred = predictions[idx] + all_y_true.append(int(test_labels['breakout_label'].iloc[idx])) + all_y_pred.append(pred.breakout_class) + all_y_proba.append(pred.breakout_probability) + all_magnitudes_true.append(test_labels['breakout_magnitude'].iloc[idx]) + all_magnitudes_pred.append(pred.magnitude) + + # Evaluate fold + if len(all_y_true) > 0: + fold_metrics = self.evaluate_breakout_detection( + np.array(all_y_true[-len(valid_indices):]), + np.array(all_y_pred[-len(valid_indices):]), + np.array(all_magnitudes_true[-len(valid_indices):]), + np.array(all_magnitudes_pred[-len(valid_indices):]) + ) + self.fold_results.append(fold_metrics) + + if verbose: + logger.info(f"Fold {fold_idx + 1} Results:") + logger.info(f" Accuracy: {fold_metrics.accuracy:.4f}") + logger.info(f" Breakout F1: {fold_metrics.breakout_f1:.4f}") + logger.info(f" Breakout Recall: {fold_metrics.breakout_recall:.4f}") + + # Aggregate results + if len(all_y_true) > 0: + aggregated_metrics = self.evaluate_breakout_detection( + np.array(all_y_true), + np.array(all_y_pred), + np.array(all_magnitudes_true), + np.array(all_magnitudes_pred) + ) + + logger.info(f"\n{'='*50}") + logger.info("Walk-Forward Aggregated Results:") + logger.info(f"{'='*50}") + logger.info(f" Total samples: {aggregated_metrics.n_samples}") + logger.info(f" Accuracy: {aggregated_metrics.accuracy:.4f}") + logger.info(f" Breakout Precision: {aggregated_metrics.breakout_precision:.4f}") + logger.info(f" Breakout Recall: {aggregated_metrics.breakout_recall:.4f}") + logger.info(f" Breakout F1: {aggregated_metrics.breakout_f1:.4f}") + logger.info(f" Magnitude MAE: {aggregated_metrics.magnitude_mae:.4f}") + + self.best_metrics = aggregated_metrics + return aggregated_metrics + + return VBPMetrics() + + def evaluate_breakout_detection( + self, + y_true: np.ndarray, + y_pred: np.ndarray, + magnitudes_true: Optional[np.ndarray] = None, + magnitudes_pred: Optional[np.ndarray] = None + ) -> VBPMetrics: + """ + Comprehensive evaluation of breakout detection. + + Computes: + - Standard classification metrics + - Breakout-specific metrics (combining bullish/bearish) + - Direction accuracy + - Magnitude prediction metrics + + Args: + y_true: True labels (0: no breakout, 1: bullish, 2: bearish) + y_pred: Predicted labels + magnitudes_true: True breakout magnitudes + magnitudes_pred: Predicted magnitudes + + Returns: + VBPMetrics object with all metrics + """ + metrics = VBPMetrics() + + # Basic counts + metrics.n_samples = len(y_true) + metrics.n_breakouts = int((y_true > 0).sum()) + metrics.n_predicted_breakouts = int((y_pred > 0).sum()) + + # Standard classification metrics + metrics.accuracy = accuracy_score(y_true, y_pred) + + # Per-class metrics + class_names = ['no_breakout', 'bullish', 'bearish'] + precisions = precision_score(y_true, y_pred, average=None, zero_division=0) + recalls = recall_score(y_true, y_pred, average=None, zero_division=0) + f1s = f1_score(y_true, y_pred, average=None, zero_division=0) + + for i, name in enumerate(class_names[:len(precisions)]): + metrics.precision_per_class[name] = float(precisions[i]) + metrics.recall_per_class[name] = float(recalls[i]) + metrics.f1_per_class[name] = float(f1s[i]) + + # Macro averages + metrics.precision = precision_score(y_true, y_pred, average='macro', zero_division=0) + metrics.recall = recall_score(y_true, y_pred, average='macro', zero_division=0) + metrics.f1 = f1_score(y_true, y_pred, average='macro', zero_division=0) + + # Breakout-specific metrics (treat classes 1 and 2 as "breakout") + y_true_binary = (y_true > 0).astype(int) + y_pred_binary = (y_pred > 0).astype(int) + + metrics.breakout_precision = precision_score(y_true_binary, y_pred_binary, zero_division=0) + metrics.breakout_recall = recall_score(y_true_binary, y_pred_binary, zero_division=0) + metrics.breakout_f1 = f1_score(y_true_binary, y_pred_binary, zero_division=0) + + # Direction accuracy (only when both true and predicted are breakouts) + true_breakout_mask = y_true > 0 + pred_breakout_mask = y_pred > 0 + both_breakout_mask = true_breakout_mask & pred_breakout_mask + + if both_breakout_mask.sum() > 0: + correct_direction = (y_true[both_breakout_mask] == y_pred[both_breakout_mask]) + metrics.direction_accuracy = correct_direction.mean() + else: + metrics.direction_accuracy = 0.0 + + # Magnitude metrics (only for actual breakouts) + if magnitudes_true is not None and magnitudes_pred is not None: + breakout_mask = y_true > 0 + if breakout_mask.sum() > 0: + mag_true = magnitudes_true[breakout_mask] + mag_pred = magnitudes_pred[breakout_mask] + + # Remove NaN + valid_mag = ~(np.isnan(mag_true) | np.isnan(mag_pred)) + if valid_mag.sum() > 0: + metrics.magnitude_mae = np.mean(np.abs(mag_true[valid_mag] - mag_pred[valid_mag])) + metrics.magnitude_rmse = np.sqrt(np.mean((mag_true[valid_mag] - mag_pred[valid_mag]) ** 2)) + + # ROC-AUC (for binary breakout detection) + try: + if len(np.unique(y_true_binary)) > 1: + metrics.roc_auc = roc_auc_score(y_true_binary, y_pred_binary) + except Exception: + metrics.roc_auc = 0.0 + + # Confusion matrix + metrics.confusion_matrix = confusion_matrix(y_true, y_pred) + + return metrics + + def print_evaluation_report( + self, + metrics: VBPMetrics, + y_true: np.ndarray, + y_pred: np.ndarray + ) -> None: + """ + Print detailed evaluation report. + + Args: + metrics: VBPMetrics object + y_true: True labels + y_pred: Predicted labels + """ + logger.info("\n" + "=" * 60) + logger.info("VBP Model Evaluation Report") + logger.info("=" * 60) + + logger.info(f"\nSample Statistics:") + logger.info(f" Total samples: {metrics.n_samples}") + logger.info(f" Actual breakouts: {metrics.n_breakouts} ({metrics.n_breakouts/metrics.n_samples*100:.1f}%)") + logger.info(f" Predicted breakouts: {metrics.n_predicted_breakouts}") + + logger.info(f"\nOverall Metrics:") + logger.info(f" Accuracy: {metrics.accuracy:.4f}") + logger.info(f" Precision (macro): {metrics.precision:.4f}") + logger.info(f" Recall (macro): {metrics.recall:.4f}") + logger.info(f" F1 (macro): {metrics.f1:.4f}") + + logger.info(f"\nBreakout Detection Metrics:") + logger.info(f" Precision: {metrics.breakout_precision:.4f}") + logger.info(f" Recall: {metrics.breakout_recall:.4f}") + logger.info(f" F1: {metrics.breakout_f1:.4f}") + logger.info(f" ROC-AUC: {metrics.roc_auc:.4f}") + + logger.info(f"\nDirection Accuracy (when breakout predicted correctly):") + logger.info(f" {metrics.direction_accuracy:.4f}") + + logger.info(f"\nMagnitude Prediction:") + logger.info(f" MAE: {metrics.magnitude_mae:.4f} ATR units") + logger.info(f" RMSE: {metrics.magnitude_rmse:.4f} ATR units") + + logger.info(f"\nPer-Class Metrics:") + for cls in metrics.f1_per_class: + logger.info(f" {cls}:") + logger.info(f" Precision: {metrics.precision_per_class.get(cls, 0):.4f}") + logger.info(f" Recall: {metrics.recall_per_class.get(cls, 0):.4f}") + logger.info(f" F1: {metrics.f1_per_class.get(cls, 0):.4f}") + + logger.info(f"\nConfusion Matrix:") + logger.info(f" {metrics.confusion_matrix}") + + logger.info("\nClassification Report:") + logger.info(classification_report(y_true, y_pred, target_names=['no_breakout', 'bullish', 'bearish'], zero_division=0)) + + def get_training_summary(self) -> Dict[str, Any]: + """Get training summary.""" + return { + 'best_metrics': self.best_metrics.to_dict() if self.best_metrics else None, + 'n_folds': len(self.fold_results), + 'fold_results': [m.to_dict() for m in self.fold_results], + 'config': { + 'oversample_multiplier': self.config.oversample_multiplier, + 'n_folds': self.config.n_folds, + 'class_weight_method': self.config.class_weight_method + } + } + + +if __name__ == "__main__": + # Test the VBP trainer + print("Testing VBP Trainer") + print("=" * 60) + + # Create sample data + np.random.seed(42) + n = 2000 + + dates = pd.date_range('2025-01-01', periods=n, freq='1H') + + # Simulate price with breakout patterns + volatility = np.where( + (np.arange(n) % 100 > 85), + 0.02, + 0.005 + ) + + returns = np.random.randn(n) * volatility + price = 2000 * (1 + returns).cumprod() + + df = pd.DataFrame({ + 'open': price, + 'high': price * (1 + np.abs(np.random.randn(n)) * volatility), + 'low': price * (1 - np.abs(np.random.randn(n)) * volatility), + 'close': price * (1 + np.random.randn(n) * volatility * 0.5), + 'volume': np.random.randint(100, 1000, n) + }, index=dates) + + # Ensure OHLC consistency + df['high'] = df[['open', 'high', 'close']].max(axis=1) + df['low'] = df[['open', 'low', 'close']].min(axis=1) + + # Initialize trainer + trainer_config = VBPTrainerConfig( + n_folds=3, + oversample_multiplier=2.0 + ) + + model_config = VBPModelConfig( + sequence_length=30, + xgb_n_estimators=50, + use_gpu=False + ) + + trainer = VBPTrainer(trainer_config, model_config) + + print("\n1. Testing class weight computation...") + labels = np.array([0] * 900 + [1] * 50 + [2] * 50) + weights = trainer.compute_class_weights(labels) + print(f" Sample weights range: [{weights.min():.2f}, {weights.max():.2f}]") + + print("\n2. Testing oversampling...") + feature_engineer = VBPFeatureEngineer() + features = feature_engineer.compute_all_features(df) + label_df = feature_engineer.label_breakouts(df) + label_series = pd.Series(label_df['breakout_label'].values, index=features.index) + + # Remove NaN + valid = ~label_series.isna() + features_valid = features[valid] + labels_valid = label_series[valid] + + features_os, labels_os = trainer.oversample_breakouts(features_valid, labels_valid) + print(f" Original: {len(features_valid)}, Oversampled: {len(features_os)}") + + print("\n3. Testing walk-forward splits...") + splits = trainer.create_walk_forward_splits(df, n_folds=3) + print(f" Created {len(splits)} folds") + + print("\n4. Testing simple training...") + metrics = trainer.train('TEST_SYMBOL', df, verbose=True) + print(f" Training completed with accuracy: {metrics.accuracy:.4f}") + + print("\n5. Testing breakout detection evaluation...") + y_true = np.array([0, 0, 1, 2, 0, 1, 0, 2, 0, 0]) + y_pred = np.array([0, 0, 1, 1, 0, 0, 0, 2, 1, 0]) + mag_true = np.array([0, 0, 2.5, 3.0, 0, 2.0, 0, 2.8, 0, 0]) + mag_pred = np.array([0, 0, 2.3, 2.8, 0, 1.5, 0, 2.5, 2.0, 0]) + + eval_metrics = trainer.evaluate_breakout_detection(y_true, y_pred, mag_true, mag_pred) + print(f" Breakout F1: {eval_metrics.breakout_f1:.4f}") + print(f" Direction Accuracy: {eval_metrics.direction_accuracy:.4f}") + print(f" Magnitude MAE: {eval_metrics.magnitude_mae:.4f}") + + print("\n6. Testing evaluation report...") + trainer.print_evaluation_report(eval_metrics, y_true, y_pred) + + print("\n7. Testing training summary...") + summary = trainer.get_training_summary() + print(f" Summary keys: {list(summary.keys())}") + + print("\n" + "=" * 60) + print("All VBP trainer tests passed!") diff --git a/src/training/data_splitter.py b/src/training/data_splitter.py index 83648b6..f5e18b8 100644 --- a/src/training/data_splitter.py +++ b/src/training/data_splitter.py @@ -3,10 +3,15 @@ Temporal Data Splitter for ML-First Strategy ============================================ Implements out-of-sample (OOS) validation by excluding specified time periods. -Key Principle: 2025 data is NEVER seen during training - reserved for OOS validation. +Supports: +- Static OOS: hardcoded train/test date ranges from config +- Dynamic OOS: automatically excludes last N months from max date in data + +Key Principle: OOS data is NEVER seen during training - reserved for validation. Author: ML-Specialist (NEXUS v4.0) Created: 2026-01-04 +Updated: 2026-01-27 (dynamic OOS support) """ import pandas as pd @@ -14,6 +19,7 @@ import numpy as np from typing import Dict, Tuple, Optional, List, Any from dataclasses import dataclass, field from datetime import datetime +from dateutil.relativedelta import relativedelta from loguru import logger import yaml from pathlib import Path @@ -174,6 +180,57 @@ class TemporalDataSplitter: return split + def split_dynamic_oos( + self, + df: pd.DataFrame, + oos_months: Optional[int] = None + ) -> TemporalSplit: + """ + Dynamically calculate OOS period as last N months from max date in data. + + This is the recommended method: it automatically determines the OOS window + based on the actual data range, ensuring the most recent N months are always + held out for validation. + + Args: + df: DataFrame with datetime index + oos_months: Number of months to exclude (defaults to config or 12) + + Returns: + TemporalSplit with dynamically calculated boundaries + """ + if not isinstance(df.index, pd.DatetimeIndex): + raise ValueError("DataFrame must have a DatetimeIndex") + + # Determine OOS months from config or parameter + dynamic_config = self.config.get('validation', {}).get('dynamic', {}) + if oos_months is None: + oos_months = dynamic_config.get('oos_months', 12) + + # Calculate boundaries from data + max_date = df.index.max() + min_date = df.index.min() + oos_start = max_date - relativedelta(months=oos_months) + + # Round to start of month for clean boundary + oos_start = oos_start.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + train_end = oos_start - pd.Timedelta(seconds=1) + + logger.info(f"Dynamic OOS calculation:") + logger.info(f" Data range: {min_date} to {max_date}") + logger.info(f" OOS months: {oos_months}") + logger.info(f" Train: {min_date} to {train_end}") + logger.info(f" OOS: {oos_start} to {max_date}") + + return self.split_temporal( + df, + train_start=min_date.isoformat(), + train_end=train_end.isoformat(), + test_start=oos_start.isoformat(), + test_end=max_date.isoformat() + ) + def split_with_validation( self, df: pd.DataFrame, @@ -182,6 +239,8 @@ class TemporalDataSplitter: """ Create train/validation/test split with validation carved from training period. + Uses dynamic OOS if configured, otherwise falls back to static dates. + Args: df: DataFrame with datetime index val_ratio: Ratio of training data to use for validation @@ -189,8 +248,12 @@ class TemporalDataSplitter: Returns: Tuple of (train_df, val_df, test_df) """ - # First get the main temporal split - split = self.split_temporal(df) + # Use dynamic OOS if configured + dynamic_config = self.config.get('validation', {}).get('dynamic', {}) + if dynamic_config.get('enabled', False): + split = self.split_dynamic_oos(df) + else: + split = self.split_temporal(df) # Then split training data into train/validation train_size = len(split.train_data) @@ -406,26 +469,38 @@ class TemporalDataSplitter: def create_ml_first_splits( df: pd.DataFrame, - config_path: str = "config/validation_oos.yaml" + config_path: str = "config/validation_oos.yaml", + oos_months: Optional[int] = None ) -> Dict[str, pd.DataFrame]: """ Convenience function to create ML-First train/val/test splits. This is the recommended entry point for preparing data for ML training. + Supports dynamic OOS (last N months from max date) when configured. Args: df: DataFrame with datetime index containing all features config_path: Path to validation config + oos_months: Override OOS months (None = use config) Returns: Dictionary with 'train', 'val', 'test_oos' DataFrames """ splitter = TemporalDataSplitter(config_path) + # Override OOS months if provided + if oos_months is not None: + if 'validation' not in splitter.config: + splitter.config['validation'] = {} + if 'dynamic' not in splitter.config['validation']: + splitter.config['validation']['dynamic'] = {} + splitter.config['validation']['dynamic']['enabled'] = True + splitter.config['validation']['dynamic']['oos_months'] = oos_months + # Show data summary splitter.print_data_summary(df) - # Create splits + # Create splits (will use dynamic OOS if configured) train_df, val_df, test_df = splitter.split_with_validation(df) return { diff --git a/tests/test_attention_architecture.py b/tests/test_attention_architecture.py new file mode 100644 index 0000000..b968c6c --- /dev/null +++ b/tests/test_attention_architecture.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +""" +Tests for Attention Architecture Module +======================================== + +Comprehensive unit tests for the attention module components: +- MultiHeadAttention: Core multi-head attention mechanism +- LearnablePositionalEncoding: Time-agnostic position embeddings +- PriceFocusedAttention: Main transformer encoder model +- AttentionExtractor: Utilities for attention analysis + +Uses pytest and torch.testing for assertions. + +Author: ML-Specialist (NEXUS v4.0) +Version: 1.0.0 +Created: 2026-01-25 +""" + +import pytest +import numpy as np +from pathlib import Path + +import torch +import torch.nn as nn + +# Import the modules under test +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from models.attention import ( + MultiHeadAttention, + LearnablePositionalEncoding, + PriceFocusedAttention, + PriceAttentionConfig, + AttentionExtractor, + AttentionScores, + create_causal_mask, + compute_return_features, +) + + +# ============================================================================== +# Test Fixtures +# ============================================================================== + +@pytest.fixture +def device(): + """Return the appropriate device for testing.""" + return torch.device('cuda' if torch.cuda.is_available() else 'cpu') + + +@pytest.fixture +def batch_size(): + """Default batch size for tests.""" + return 4 + + +@pytest.fixture +def seq_len(): + """Default sequence length for tests.""" + return 32 + + +@pytest.fixture +def d_model(): + """Default model dimension for tests.""" + return 64 + + +@pytest.fixture +def n_heads(): + """Default number of attention heads for tests.""" + return 8 + + +@pytest.fixture +def input_features(): + """Default number of input features for tests.""" + return 4 + + +@pytest.fixture +def sample_input(batch_size, seq_len, d_model, device): + """Create sample input tensor for attention tests.""" + torch.manual_seed(42) + return torch.randn(batch_size, seq_len, d_model, device=device) + + +@pytest.fixture +def sample_price_input(batch_size, seq_len, input_features, device): + """Create sample price-based input tensor.""" + torch.manual_seed(42) + return torch.randn(batch_size, seq_len, input_features, device=device) + + +@pytest.fixture +def multi_head_attention(d_model, n_heads, device): + """Create MultiHeadAttention instance for testing.""" + mha = MultiHeadAttention(d_model=d_model, n_heads=n_heads, dropout=0.0) + return mha.to(device) + + +@pytest.fixture +def positional_encoding(d_model, device): + """Create LearnablePositionalEncoding instance for testing.""" + pe = LearnablePositionalEncoding(d_model=d_model, max_seq_len=512, dropout=0.0) + return pe.to(device) + + +@pytest.fixture +def price_attention_config(d_model, n_heads, input_features): + """Create PriceAttentionConfig for testing.""" + return PriceAttentionConfig( + d_model=d_model, + n_heads=n_heads, + d_k=d_model // n_heads, + d_v=d_model // n_heads, + n_layers=2, + d_ff=d_model * 4, + max_seq_len=512, + dropout=0.0, + attention_dropout=0.0, + input_features=input_features, + pre_norm=True, + ) + + +@pytest.fixture +def price_focused_attention(price_attention_config, input_features, device): + """Create PriceFocusedAttention instance for testing.""" + model = PriceFocusedAttention( + config=price_attention_config, + input_features=input_features + ) + return model.to(device) + + +# ============================================================================== +# Tests for MultiHeadAttention +# ============================================================================== + +class TestMultiHeadAttention: + """Tests for MultiHeadAttention module.""" + + def test_output_shape(self, multi_head_attention, sample_input, batch_size, seq_len, d_model): + """Verify that output has correct shape (batch, seq_len, d_model).""" + output, attn_weights = multi_head_attention(sample_input, sample_input, sample_input) + + assert output.shape == (batch_size, seq_len, d_model), \ + f"Expected output shape ({batch_size}, {seq_len}, {d_model}), got {output.shape}" + + def test_attention_weights_shape(self, multi_head_attention, sample_input, batch_size, seq_len, n_heads): + """Verify attention weights have correct shape (batch, n_heads, seq_len, seq_len).""" + output, attn_weights = multi_head_attention(sample_input, sample_input, sample_input) + + assert attn_weights is not None, "Attention weights should not be None" + assert attn_weights.shape == (batch_size, n_heads, seq_len, seq_len), \ + f"Expected attention weights shape ({batch_size}, {n_heads}, {seq_len}, {seq_len}), got {attn_weights.shape}" + + def test_attention_weights_sum_to_one(self, multi_head_attention, sample_input): + """Verify that attention weights sum to 1 along the key dimension (softmax).""" + output, attn_weights = multi_head_attention(sample_input, sample_input, sample_input) + + # Sum along the last dimension (keys) + weight_sums = attn_weights.sum(dim=-1) + + # All sums should be approximately 1.0 + expected_ones = torch.ones_like(weight_sums) + torch.testing.assert_close( + weight_sums, expected_ones, + atol=1e-5, rtol=1e-5, + msg="Attention weights should sum to 1 along key dimension" + ) + + def test_causal_mask(self, multi_head_attention, sample_input, batch_size, seq_len, n_heads, device): + """Verify that causal mask prevents attending to future positions.""" + # Create causal mask + causal_mask = create_causal_mask(seq_len, device=device) + + # Forward with causal mask + output, attn_weights = multi_head_attention( + sample_input, sample_input, sample_input, + mask=causal_mask + ) + + # Check that attention to future positions is zero (masked out) + # Upper triangular part (excluding diagonal) should be ~0 + for b in range(batch_size): + for h in range(n_heads): + attention_matrix = attn_weights[b, h].cpu() + for i in range(seq_len): + for j in range(i + 1, seq_len): + assert attention_matrix[i, j] < 1e-6, \ + f"Position ({i}, {j}) should be masked (near zero), got {attention_matrix[i, j]}" + + def test_no_attention_returned_when_disabled(self, d_model, n_heads, device, sample_input): + """Verify attention weights are None when return_attention is False.""" + mha = MultiHeadAttention(d_model=d_model, n_heads=n_heads).to(device) + output, attn_weights = mha( + sample_input, sample_input, sample_input, + return_attention=False + ) + + assert attn_weights is None, "Attention weights should be None when return_attention=False" + + def test_different_q_k_v_shapes(self, d_model, n_heads, device): + """Verify MHA works with different query and key/value sequence lengths.""" + torch.manual_seed(42) + mha = MultiHeadAttention(d_model=d_model, n_heads=n_heads, dropout=0.0).to(device) + + batch_size = 2 + seq_len_q = 16 + seq_len_kv = 24 + + query = torch.randn(batch_size, seq_len_q, d_model, device=device) + key = torch.randn(batch_size, seq_len_kv, d_model, device=device) + value = torch.randn(batch_size, seq_len_kv, d_model, device=device) + + output, attn_weights = mha(query, key, value) + + assert output.shape == (batch_size, seq_len_q, d_model) + assert attn_weights.shape == (batch_size, n_heads, seq_len_q, seq_len_kv) + + def test_gradients_flow_correctly(self, multi_head_attention, sample_input): + """Verify that gradients flow through the attention mechanism.""" + sample_input.requires_grad_(True) + output, attn_weights = multi_head_attention(sample_input, sample_input, sample_input) + + # Compute loss and backward + loss = output.sum() + loss.backward() + + assert sample_input.grad is not None, "Input should have gradients" + assert not torch.all(sample_input.grad == 0), "Gradients should not be all zeros" + + +# ============================================================================== +# Tests for LearnablePositionalEncoding +# ============================================================================== + +class TestLearnablePositionalEncoding: + """Tests for LearnablePositionalEncoding module.""" + + def test_encoding_shape(self, positional_encoding, sample_input, batch_size, seq_len, d_model): + """Verify that output shape matches input shape.""" + output = positional_encoding(sample_input) + + assert output.shape == sample_input.shape, \ + f"Output shape {output.shape} should match input shape {sample_input.shape}" + + def test_no_temporal_dependency(self, d_model, device): + """Verify encoding does not depend on actual timestamps, only sequence position.""" + pe = LearnablePositionalEncoding(d_model=d_model, max_seq_len=512, dropout=0.0).to(device) + + torch.manual_seed(42) + batch_size = 2 + seq_len = 16 + + # Create two different input tensors + x1 = torch.randn(batch_size, seq_len, d_model, device=device) + x2 = torch.randn(batch_size, seq_len, d_model, device=device) * 2.0 # Different scale + + # Get position embeddings (the added positions should be the same) + output1 = pe(x1) + output2 = pe(x2) + + # The position embeddings added should be the same for both + # (output - input) gives us the position embeddings + pe1 = output1 - x1 + pe2 = output2 - x2 + + torch.testing.assert_close( + pe1, pe2, + atol=1e-5, rtol=1e-5, + msg="Position embeddings should be identical regardless of input values" + ) + + def test_learnable_parameters(self, positional_encoding, d_model): + """Verify that position embeddings are learnable parameters.""" + # Check that position_embeddings is a Parameter + assert hasattr(positional_encoding, 'position_embeddings'), \ + "Should have position_embeddings attribute" + assert isinstance(positional_encoding.position_embeddings, nn.Parameter), \ + "position_embeddings should be nn.Parameter" + + # Check it requires gradients + assert positional_encoding.position_embeddings.requires_grad, \ + "position_embeddings should require gradients" + + # Check shape + max_seq_len = 512 + assert positional_encoding.position_embeddings.shape == (max_seq_len, d_model), \ + f"Expected shape ({max_seq_len}, {d_model}), got {positional_encoding.position_embeddings.shape}" + + def test_offset_parameter_works(self, d_model, device): + """Verify that offset parameter shifts position indices correctly.""" + pe = LearnablePositionalEncoding(d_model=d_model, max_seq_len=512, dropout=0.0).to(device) + + torch.manual_seed(42) + batch_size = 2 + seq_len = 10 + + # Use zeros as input so we can see only the position embeddings + x = torch.zeros(batch_size, seq_len, d_model, device=device) + + output_no_offset = pe(x, offset=0) + output_with_offset = pe(x, offset=5) + + # output_no_offset positions 5:15 should equal output_with_offset positions 0:10 + torch.testing.assert_close( + output_no_offset[:, 5:, :], + output_with_offset[:, :5, :], + atol=1e-5, rtol=1e-5, + msg="Offset should shift position embeddings correctly" + ) + + def test_exceeds_max_seq_len_raises_error(self, positional_encoding, d_model, device): + """Verify that exceeding max_seq_len raises ValueError.""" + x = torch.randn(1, 600, d_model, device=device) # 600 > 512 (max_seq_len) + + with pytest.raises(ValueError, match="exceeds maximum sequence length"): + positional_encoding(x) + + def test_get_position_embedding(self, positional_encoding, d_model): + """Verify get_position_embedding returns correct embedding.""" + position = 10 + embedding = positional_encoding.get_position_embedding(position) + + assert embedding.shape == (d_model,), \ + f"Expected shape ({d_model},), got {embedding.shape}" + + # Should match the parameter at that position + torch.testing.assert_close( + embedding, + positional_encoding.position_embeddings[position], + msg="get_position_embedding should return correct position embedding" + ) + + +# ============================================================================== +# Tests for PriceFocusedAttention +# ============================================================================== + +class TestPriceFocusedAttention: + """Tests for PriceFocusedAttention model.""" + + def test_forward_pass(self, price_focused_attention, sample_price_input): + """Verify forward pass completes without error.""" + output, attentions = price_focused_attention(sample_price_input) + + assert output is not None, "Output should not be None" + assert attentions is not None, "Attentions should not be None" + assert len(attentions) > 0, "Should have at least one attention tensor" + + def test_output_shape(self, price_focused_attention, sample_price_input, batch_size, seq_len, d_model): + """Verify output has correct shape (batch, seq_len, d_model).""" + output, attentions = price_focused_attention(sample_price_input) + + assert output.shape == (batch_size, seq_len, d_model), \ + f"Expected output shape ({batch_size}, {seq_len}, {d_model}), got {output.shape}" + + def test_no_nan_gradients(self, price_focused_attention, sample_price_input): + """Verify gradients are stable (no NaN values).""" + sample_price_input.requires_grad_(True) + + output, attentions = price_focused_attention(sample_price_input) + + # Compute loss and backward + loss = output.sum() + loss.backward() + + # Check for NaN in gradients + assert sample_price_input.grad is not None, "Input should have gradients" + assert not torch.isnan(sample_price_input.grad).any(), \ + "Gradients should not contain NaN values" + assert not torch.isinf(sample_price_input.grad).any(), \ + "Gradients should not contain Inf values" + + # Check model parameters for NaN gradients + for name, param in price_focused_attention.named_parameters(): + if param.grad is not None: + assert not torch.isnan(param.grad).any(), \ + f"Parameter {name} has NaN gradients" + assert not torch.isinf(param.grad).any(), \ + f"Parameter {name} has Inf gradients" + + def test_compute_return_features(self, device): + """Verify compute_return_features produces valid features.""" + torch.manual_seed(42) + batch_size = 4 + seq_len = 50 + + # Create OHLC data (open, high, low, close) + base_price = torch.ones(batch_size, seq_len, 1, device=device) * 100.0 + noise = torch.randn(batch_size, seq_len, 1, device=device) * 2.0 + + open_price = base_price + noise + high = base_price + torch.abs(torch.randn(batch_size, seq_len, 1, device=device)) * 3.0 + low = base_price - torch.abs(torch.randn(batch_size, seq_len, 1, device=device)) * 3.0 + close = base_price + noise * 0.5 + + prices = torch.cat([open_price, high, low, close], dim=-1) + + features = compute_return_features(prices) + + # Check shape + assert features.shape == (batch_size, seq_len, 4), \ + f"Expected features shape ({batch_size}, {seq_len}, 4), got {features.shape}" + + # Check no NaN (except possibly first row due to returns) + assert not torch.isnan(features[:, 1:, :]).any(), \ + "Features should not have NaN values (after first row)" + + # Check returns are reasonable (not extreme values) + returns = features[:, 1:, 0] # First feature is returns + assert torch.abs(returns).max() < 1.0, \ + "Returns should be reasonable (< 100%)" + + def test_return_all_attentions(self, price_focused_attention, sample_price_input, price_attention_config): + """Verify return_all_attentions returns attention from all layers.""" + output, attentions = price_focused_attention( + sample_price_input, + return_all_attentions=True + ) + + expected_n_layers = price_attention_config.n_layers + assert len(attentions) == expected_n_layers, \ + f"Expected {expected_n_layers} attention tensors, got {len(attentions)}" + + def test_encode_sequence_pooling(self, price_focused_attention, sample_price_input, batch_size, d_model): + """Verify encode_sequence with different pooling methods.""" + for pooling in ["last", "first", "mean", "max"]: + encoded = price_focused_attention.encode_sequence(sample_price_input, pooling=pooling) + assert encoded.shape == (batch_size, d_model), \ + f"Pooling '{pooling}' should produce shape ({batch_size}, {d_model}), got {encoded.shape}" + + def test_get_attention_scores(self, price_focused_attention, sample_price_input, batch_size, n_heads, seq_len): + """Verify get_attention_scores returns correct shape.""" + attention_scores = price_focused_attention.get_attention_scores(sample_price_input, layer_idx=-1) + + assert attention_scores.shape == (batch_size, n_heads, seq_len, seq_len), \ + f"Expected shape ({batch_size}, {n_heads}, {seq_len}, {seq_len}), got {attention_scores.shape}" + + def test_deterministic_with_eval_mode(self, price_focused_attention, sample_price_input): + """Verify model produces deterministic outputs in eval mode.""" + price_focused_attention.eval() + + with torch.no_grad(): + output1, _ = price_focused_attention(sample_price_input) + output2, _ = price_focused_attention(sample_price_input) + + torch.testing.assert_close( + output1, output2, + msg="Model should produce identical outputs in eval mode" + ) + + +# ============================================================================== +# Tests for AttentionExtractor +# ============================================================================== + +class TestAttentionExtractor: + """Tests for AttentionExtractor utility class.""" + + @pytest.fixture + def extractor(self): + """Create AttentionExtractor instance for testing.""" + return AttentionExtractor() + + def test_extract_scores(self, extractor, price_focused_attention, sample_price_input): + """Verify attention score extraction works correctly.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + assert isinstance(scores, AttentionScores), \ + "Should return AttentionScores object" + assert scores.scores is not None, \ + "Scores array should not be None" + assert len(scores.scores.shape) == 4, \ + "Scores should have 4 dimensions (batch, heads, seq, seq)" + + def test_extract_scores_specific_layer(self, extractor, price_focused_attention, sample_price_input): + """Verify extraction from specific layer.""" + scores_layer0 = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=0 + ) + scores_layer1 = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=1 + ) + + assert scores_layer0.layer_idx == 0 + assert scores_layer1.layer_idx == 1 + + # Scores from different layers should be different + assert not np.allclose(scores_layer0.scores, scores_layer1.scores), \ + "Different layers should produce different attention patterns" + + def test_extract_scores_specific_head(self, extractor, price_focused_attention, sample_price_input, batch_size, seq_len): + """Verify extraction for specific attention head.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1, + head_idx=0 + ) + + assert scores.head_idx == 0 + # When extracting single head, second dimension should be 1 + assert scores.scores.shape[1] == 1, \ + f"Expected 1 head, got {scores.scores.shape[1]}" + assert scores.scores.shape == (batch_size, 1, seq_len, seq_len) + + def test_compute_statistics(self, extractor, price_focused_attention, sample_price_input): + """Verify attention statistics computation.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + stats = extractor.compute_attention_statistics(scores) + + # Check required keys exist + assert 'global' in stats, "Stats should have 'global' key" + assert 'per_head' in stats, "Stats should have 'per_head' key" + assert 'diagonal_attention_mean' in stats, "Stats should have 'diagonal_attention_mean' key" + assert 'sparsity' in stats, "Stats should have 'sparsity' key" + + # Check global stats + global_stats = stats['global'] + assert 'mean' in global_stats + assert 'std' in global_stats + assert 'max' in global_stats + assert 'min' in global_stats + + # Mean should be reasonable for softmax outputs + assert 0.0 <= global_stats['mean'] <= 1.0, \ + "Mean attention should be between 0 and 1" + + # Min should be >= 0 (softmax outputs) + assert global_stats['min'] >= 0.0, \ + "Min attention should be >= 0" + + # Max should be <= 1 (softmax outputs) + assert global_stats['max'] <= 1.0, \ + "Max attention should be <= 1" + + def test_compute_statistics_per_head(self, extractor, price_focused_attention, sample_price_input, n_heads): + """Verify per-head statistics computation.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + stats = extractor.compute_attention_statistics(scores) + + per_head = stats['per_head'] + assert len(per_head) == n_heads, \ + f"Should have stats for {n_heads} heads, got {len(per_head)}" + + for head_stat in per_head: + assert 'head' in head_stat + assert 'mean' in head_stat + assert 'std' in head_stat + assert 'max' in head_stat + assert 'entropy' in head_stat + + def test_attention_scores_mean_attention(self, extractor, price_focused_attention, sample_price_input, batch_size, seq_len): + """Verify AttentionScores.mean_attention method.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + mean_attn = scores.mean_attention() + + assert mean_attn.shape == (batch_size, seq_len, seq_len), \ + f"Mean attention should have shape ({batch_size}, {seq_len}, {seq_len}), got {mean_attn.shape}" + + def test_attention_scores_head_attention(self, extractor, price_focused_attention, sample_price_input, batch_size, seq_len): + """Verify AttentionScores.head_attention method.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + head_attn = scores.head_attention(0) + + assert head_attn.shape == (batch_size, seq_len, seq_len), \ + f"Head attention should have shape ({batch_size}, {seq_len}, {seq_len}), got {head_attn.shape}" + + def test_attention_scores_to_dict(self, extractor, price_focused_attention, sample_price_input): + """Verify AttentionScores serialization to dict.""" + metadata = {'symbol': 'XAUUSD', 'timeframe': '15m'} + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1, + metadata=metadata + ) + + scores_dict = scores.to_dict() + + assert 'scores' in scores_dict + assert 'layer_idx' in scores_dict + assert 'sequence_len' in scores_dict + assert 'n_heads' in scores_dict + assert 'metadata' in scores_dict + assert scores_dict['metadata'] == metadata + + def test_sparsity_computation(self, extractor, price_focused_attention, sample_price_input): + """Verify sparsity metric is computed correctly.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + stats = extractor.compute_attention_statistics(scores) + + # Sparsity should be between 0 and 1 + assert 0.0 <= stats['sparsity'] <= 1.0, \ + f"Sparsity should be between 0 and 1, got {stats['sparsity']}" + + def test_diagonal_attention(self, extractor, price_focused_attention, sample_price_input): + """Verify diagonal attention mean is computed correctly.""" + scores = extractor.get_attention_scores( + price_focused_attention, + sample_price_input, + layer_idx=-1 + ) + + stats = extractor.compute_attention_statistics(scores) + + # Diagonal attention mean should be between 0 and 1 + assert 0.0 <= stats['diagonal_attention_mean'] <= 1.0, \ + f"Diagonal attention mean should be between 0 and 1, got {stats['diagonal_attention_mean']}" + + +# ============================================================================== +# Integration Tests +# ============================================================================== + +class TestAttentionIntegration: + """Integration tests for the attention module.""" + + def test_full_pipeline(self, device): + """Test complete pipeline from input to attention extraction.""" + torch.manual_seed(42) + + # Create config + config = PriceAttentionConfig( + d_model=64, + n_heads=4, + d_k=16, + d_v=16, + n_layers=2, + d_ff=128, + dropout=0.0, + attention_dropout=0.0, + input_features=4, + ) + + # Create model + model = PriceFocusedAttention(config, input_features=4).to(device) + model.eval() + + # Create input + batch_size = 2 + seq_len = 20 + x = torch.randn(batch_size, seq_len, 4, device=device) + + # Forward pass + output, attentions = model(x, return_all_attentions=True) + + # Verify output + assert output.shape == (batch_size, seq_len, config.d_model) + assert len(attentions) == config.n_layers + + # Extract and analyze attention + extractor = AttentionExtractor() + scores = extractor.get_attention_scores(model, x, layer_idx=-1) + + # Compute statistics + stats = extractor.compute_attention_statistics(scores) + + # Verify stats are reasonable + assert stats['global']['mean'] > 0 + assert stats['global']['max'] <= 1.0 + assert len(stats['per_head']) == config.n_heads + + def test_training_step_simulation(self, price_focused_attention, sample_price_input, d_model): + """Simulate a training step to verify gradients work correctly.""" + price_focused_attention.train() + + # Create a simple output head + output_head = nn.Linear(d_model, 1).to(sample_price_input.device) + + # Forward pass + output, _ = price_focused_attention(sample_price_input) + prediction = output_head(output[:, -1, :]) # Use last position + + # Create fake target + target = torch.randn_like(prediction) + + # Compute loss + loss = nn.MSELoss()(prediction, target) + + # Backward + loss.backward() + + # Verify gradients exist for all parameters + for name, param in price_focused_attention.named_parameters(): + if param.requires_grad: + assert param.grad is not None, f"Parameter {name} should have gradient" + + def test_causal_attention_pattern(self, price_focused_attention, sample_price_input, batch_size, n_heads, seq_len, device): + """Verify causal attention produces lower-triangular attention pattern.""" + price_focused_attention.eval() + + # Create causal mask + causal_mask = create_causal_mask(seq_len, device=device) + + # Forward with causal mask + with torch.no_grad(): + output, attentions = price_focused_attention( + sample_price_input, + mask=causal_mask, + return_all_attentions=True + ) + + # Check last layer attention pattern + last_attention = attentions[-1].cpu().numpy() + + # Upper triangular (excluding diagonal) should be near zero + for b in range(batch_size): + for h in range(n_heads): + for i in range(seq_len): + for j in range(i + 1, seq_len): + assert last_attention[b, h, i, j] < 1e-5, \ + f"Future position ({i}, {j}) should be masked" + + +# ============================================================================== +# Edge Case Tests +# ============================================================================== + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_single_sequence_batch(self, device): + """Test with batch size of 1.""" + config = PriceAttentionConfig(d_model=32, n_heads=4, input_features=4) + model = PriceFocusedAttention(config, input_features=4).to(device) + + x = torch.randn(1, 16, 4, device=device) + output, attentions = model(x) + + assert output.shape == (1, 16, 32) + + def test_short_sequence(self, device): + """Test with very short sequence (length 2).""" + config = PriceAttentionConfig(d_model=32, n_heads=4, input_features=4) + model = PriceFocusedAttention(config, input_features=4).to(device) + + x = torch.randn(2, 2, 4, device=device) # Sequence length 2 + output, attentions = model(x) + + assert output.shape == (2, 2, 32) + + def test_attention_scores_property(self): + """Test AttentionScores dataclass properties.""" + scores_array = np.random.rand(2, 4, 10, 10) + scores = AttentionScores( + scores=scores_array, + layer_idx=1, + head_idx=None, + n_heads=4 + ) + + assert scores.shape == (2, 4, 10, 10) + assert scores.sequence_len == 10 + + def test_multi_head_attention_dimension_validation(self, device): + """Test that invalid dimensions raise appropriate errors.""" + with pytest.raises(ValueError): + # d_k * n_heads != d_model should raise error + MultiHeadAttention(d_model=64, n_heads=8, d_k=10) # 10 * 8 = 80 != 64 + + +if __name__ == '__main__': + pytest.main([__file__, '-v', '--tb=short'])