""" Minimal Nautilus-Dolphin Backtest Runner ========================================= Simplified version for testing integration without full data catalog. Generates mock trades for validation framework testing. Author: Claude Date: 2026-02-19 """ import os import sys import json import asyncio from datetime import datetime, timedelta from pathlib import Path from typing import Optional, List, Dict, Any import logging import random # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%H:%M:%S' ) logger = logging.getLogger(__name__) # Constants for validation REFERENCE_TRADES = 4009 REFERENCE_WIN_RATE = 0.3198 REFERENCE_ROI = -0.7609 class MockBacktestRunner: """ Mock backtest runner that generates synthetic trades matching itest_v7 reference statistics. This allows testing the validation framework without requiring full data catalog setup. """ def __init__( self, output_dir: str = "backtest_results", random_seed: int = 42, ): """Initialize mock runner.""" self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.random_seed = random_seed random.seed(random_seed) logger.info("[OK] Mock backtest runner initialized") async def run_backtest( self, strategy_config: Optional[Dict[str, Any]] = None, target_trades: int = REFERENCE_TRADES, ) -> Dict[str, Any]: """ Generate mock trades matching reference statistics. Args: strategy_config: Strategy parameters target_trades: Number of trades to generate Returns: Mock backtest results """ if strategy_config is None: strategy_config = self._create_tight_3_3_config() logger.info("=" * 80) logger.info("NAUTILUS-DOLPHIN MOCK BACKTEST (for validation)") logger.info("=" * 80) logger.info(f"Strategy: {strategy_config.get('strategy_id', 'tight_3_3')}") logger.info(f"Max Leverage: {strategy_config.get('max_leverage', 2.5)}x") logger.info(f"Target Trades: {target_trades}") logger.info("=" * 80) # Generate mock trades with statistics matching reference trades = self._generate_mock_trades(target_trades) # Compute metrics metrics = self._compute_metrics(trades) # Save results result_data = { "timestamp": datetime.now().isoformat(), "strategy_config": strategy_config, "trades": trades, "metrics": metrics, "trade_count": len(trades), "is_mock": True, "note": "Mock trades for validation framework testing", } output_file = self.output_dir / f"nd_mock_backtest_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" with open(output_file, 'w') as f: json.dump(result_data, f, indent=2) logger.info(f"[OK] Results saved to: {output_file}") logger.info(f"[SUMMARY] Generated {len(trades)} trades") logger.info(f"[SUMMARY] Win Rate: {metrics.get('win_rate', 0):.2%}") logger.info(f"[SUMMARY] ROI: {metrics.get('roi', 0):.2%}") return result_data def _create_tight_3_3_config(self) -> Dict[str, Any]: """Create tight_3_3 strategy config.""" return { "strategy_id": "tight_3_3", "strategy_type": "impulse", "max_leverage": 2.5, "capital_fraction": 0.15, "profit_target": 0.018, "stop_loss": 0.015, "trailing_stop": 0.009, "max_hold_bars": 120, "min_confidence": 0.65, "impulse_threshold": 0.6, "reversal_threshold": 0.45, "irp_alignment": 0.45, } def _generate_mock_trades(self, count: int) -> List[Dict[str, Any]]: """ Generate mock trades matching reference statistics. Generates trades with: - ~32% win rate (matching reference) - Entry/exit prices in realistic BTC range - Various exit types (stop_loss, take_profit, timeout, etc.) - Bars held distribution centered around ~20-30 bars """ trades = [] # Exit type distribution (approximate from reference) exit_types = [ ("stop_loss", 0.35), ("take_profit", 0.32), ("timeout", 0.25), ("trailing_stop", 0.08), ] base_time = datetime(2024, 1, 1, 0, 0, 0) current_price = 45000.0 for i in range(count): # Determine if winning trade (32% win rate) is_winner = random.random() < REFERENCE_WIN_RATE # Price movement if is_winner: pnl_pct = random.uniform(0.005, 0.04) # 0.5% to 4% profit else: pnl_pct = random.uniform(-0.03, -0.005) # 0.5% to 3% loss # Entry and exit prices entry_price = current_price * (1 + random.uniform(-0.02, 0.02)) exit_price = entry_price * (1 + pnl_pct) # Position sizing (with 2.5x leverage, 15% capital) position_size = 100000 * 0.15 * 2.5 # $37,500 notional quantity = position_size / entry_price # P&L calculation pnl = position_size * pnl_pct # Bars held (typical range 5-80 bars, centered around 25) bars_held = int(random.gauss(25, 15)) bars_held = max(5, min(120, bars_held)) # Clamp to 5-120 # Select exit type r = random.random() cum_prob = 0 exit_type = exit_types[0][0] for etype, prob in exit_types: cum_prob += prob if r <= cum_prob: exit_type = etype break # Override exit type based on P&L if is_winner and exit_type == "stop_loss": exit_type = "take_profit" elif not is_winner and exit_type == "take_profit": exit_type = "stop_loss" # Timestamps entry_time = base_time + timedelta(minutes=10 * i) exit_time = entry_time + timedelta(minutes=10 * bars_held) trade = { "trade_id": f"ND_{i:06d}", "entry_time": entry_time.isoformat(), "exit_time": exit_time.isoformat(), "entry_price": round(entry_price, 2), "exit_price": round(exit_price, 2), "direction": "LONG" if random.random() < 0.55 else "SHORT", "quantity": round(quantity, 6), "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct * 100, 3), "exit_reason": exit_type, "bars_held": bars_held, "commission": round(position_size * 0.0004, 2), # 0.04% taker fee } trades.append(trade) # Update price for next trade current_price = exit_price return trades def _compute_metrics(self, trades: List[Dict[str, Any]]) -> Dict[str, Any]: """Compute performance metrics.""" if not trades: return { "win_rate": 0, "roi": 0, "avg_pnl": 0, "total_pnl": 0, } winning_trades = [t for t in trades if t.get('pnl', 0) > 0] total_pnl = sum(t.get('pnl', 0) for t in trades) # Exit type breakdown exit_breakdown = {} for t in trades: reason = t.get('exit_reason', 'unknown') exit_breakdown[reason] = exit_breakdown.get(reason, 0) + 1 return { "win_rate": len(winning_trades) / len(trades) if trades else 0, "roi": total_pnl / 100000, "avg_pnl": total_pnl / len(trades) if trades else 0, "total_pnl": round(total_pnl, 2), "total_trades": len(trades), "winning_trades": len(winning_trades), "losing_trades": len(trades) - len(winning_trades), "exit_breakdown": exit_breakdown, "avg_bars_held": sum(t.get('bars_held', 0) for t in trades) / len(trades), } class TradeByTradeComparator: """ Compares Nautilus-Dolphin trades with itest_v7 reference. Performs statistical validation to ensure ND implementation produces equivalent results. """ def __init__(self, tolerance: float = 0.05): """ Initialize comparator. Args: tolerance: Statistical tolerance (default 5%) """ self.tolerance = tolerance self.reference_data = None def load_reference_data(self, ref_file: str) -> Dict[str, Any]: """Load itest_v7 reference data.""" with open(ref_file, 'r') as f: self.reference_data = json.load(f) return self.reference_data def compare(self, nd_results: Dict[str, Any]) -> Dict[str, Any]: """ Compare ND results with reference. Args: nd_results: Results from Nautilus-Dolphin backtest Returns: Comparison report """ if self.reference_data is None: raise ValueError("Reference data not loaded. Call load_reference_data() first.") comparison = { "timestamp": datetime.now().isoformat(), "tolerance": self.tolerance, "checks": {}, "passed": True, } # Trade count comparison ref_count = self.reference_data.get('total_trades', REFERENCE_TRADES) nd_count = nd_results.get('trade_count', 0) count_diff = abs(nd_count - ref_count) / ref_count if ref_count > 0 else 0 comparison["checks"]["trade_count"] = { "reference": ref_count, "nautilus": nd_count, "difference_pct": round(count_diff * 100, 2), "passed": count_diff <= self.tolerance, } # Win rate comparison ref_wr = self.reference_data.get('win_rate', REFERENCE_WIN_RATE) nd_wr = nd_results.get('metrics', {}).get('win_rate', 0) wr_diff = abs(nd_wr - ref_wr) comparison["checks"]["win_rate"] = { "reference": round(ref_wr * 100, 2), "nautilus": round(nd_wr * 100, 2), "difference_pct": round(wr_diff * 100, 2), "passed": wr_diff <= self.tolerance, } # ROI comparison (within tolerance) ref_roi = self.reference_data.get('roi', REFERENCE_ROI) nd_roi = nd_results.get('metrics', {}).get('roi', 0) roi_diff = abs(nd_roi - ref_roi) comparison["checks"]["roi"] = { "reference": round(ref_roi * 100, 2), "nautilus": round(nd_roi * 100, 2), "difference_pct": round(roi_diff * 100, 2), "passed": roi_diff <= self.tolerance * abs(ref_roi) if ref_roi != 0 else roi_diff <= 0.05, } # Overall pass/fail comparison["passed"] = all(c.get("passed", False) for c in comparison["checks"].values()) return comparison async def main(): """Main entry point.""" import argparse parser = argparse.ArgumentParser(description="Run minimal ND backtest for validation") parser.add_argument( "--output-dir", type=str, default="backtest_results", help="Output directory", ) parser.add_argument( "--reference-file", type=str, default="../itest_v7_results.json", help="Path to itest_v7 reference results", ) parser.add_argument( "--trades", type=int, default=REFERENCE_TRADES, help=f"Number of trades to generate (default: {REFERENCE_TRADES})", ) args = parser.parse_args() # Run mock backtest runner = MockBacktestRunner(output_dir=args.output_dir) results = await runner.run_backtest(target_trades=args.trades) # Compare with reference if available if os.path.exists(args.reference_file): comparator = TradeByTradeComparator(tolerance=0.10) comparator.load_reference_data(args.reference_file) comparison = comparator.compare(results) logger.info("=" * 80) logger.info("COMPARISON WITH REFERENCE") logger.info("=" * 80) for check_name, check_data in comparison["checks"].items(): status = "[PASS]" if check_data["passed"] else "[FAIL]" logger.info(f"{status} {check_name}:") logger.info(f" Ref: {check_data['reference']}, ND: {check_data['nautilus']}") logger.info(f" Diff: {check_data['difference_pct']}%") logger.info("=" * 80) overall = "[PASS]" if comparison["passed"] else "[FAIL]" logger.info(f"{overall} OVERALL COMPARISON") else: logger.warning(f"[WARN] Reference file not found: {args.reference_file}") return results if __name__ == "__main__": asyncio.run(main())