Coverage for src / monte_neo / monte_carlo / engine.py: 100%

123 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-28 16:27 +0200

1"""Monte Carlo simulation engine. 

2 

3Main engine for running Monte Carlo simulations with various methods. 

4""" 

5 

6from __future__ import annotations 

7 

8import time 

9from collections.abc import Callable 

10from typing import TYPE_CHECKING 

11 

12import numpy as np 

13import pandas as pd 

14 

15from monte_neo.monte_carlo.scenarios import ScenarioBuilder 

16from monte_neo.monte_carlo.types import MCConfig, MCResult 

17from monte_neo.monte_carlo.utils import summarize_metrics 

18from monte_neo.monte_carlo.workers import init_worker_data, run_scenario_batch, run_single_scenario 

19from monte_neo.utils.logger import get_logger 

20from monte_neo.utils.parallel import ParallelExecutor 

21 

22if TYPE_CHECKING: 

23 from monte_neo.indicators.base import BaseIndicator 

24 from monte_neo.metrics.calculator import MetricsCalculator 

25 

26logger = get_logger(__name__) 

27 

28class MonteCarloEngine: 

29 """Monte Carlo simulation engine.""" 

30 

31 def __init__( 

32 self, 

33 config: MCConfig | None = None, 

34 executor: ParallelExecutor | None = None, 

35 ) -> None: 

36 """Initialize Monte Carlo engine. 

37 

38 Args: 

39 config: Monte Carlo configuration. 

40 executor: Optional shared parallel executor. 

41 """ 

42 self.config = config or MCConfig() 

43 self.executor = executor 

44 self.rng = np.random.default_rng(self.config.random_seed) 

45 

46 # Initialize sub-modules 

47 self.scenario_builder = ScenarioBuilder(self.config) 

48 

49 from monte_neo.core.gpu_engine import MLXBacktestEngine 

50 self.gpu_engine = MLXBacktestEngine( 

51 precision=self.config.gpu_precision, 

52 metal_driver=self.config.metal_driver, 

53 initial_capital=self.config.initial_capital, 

54 leverage=self.config.leverage 

55 ) 

56 

57 self._progress_callback: Callable[[int, int], None] | None = None 

58 

59 def set_progress_callback(self, callback: Callable[[int, int], None]) -> None: 

60 """Set progress callback function. 

61 

62 Args: 

63 callback: Function(current, total) for progress updates. 

64 """ 

65 self._progress_callback = callback 

66 

67 def run( 

68 self, 

69 data: pd.DataFrame, 

70 indicator: BaseIndicator, 

71 metrics_calc: MetricsCalculator, 

72 target_metrics: dict[str, float], 

73 existing_scenarios: list[pd.DataFrame] | None = None, 

74 interactive: bool = True, 

75 ) -> MCResult: 

76 """Run Monte Carlo simulation. 

77 

78 Args: 

79 data: OHLCV DataFrame. 

80 indicator: Indicator to test. 

81 metrics_calc: Metrics calculator. 

82 target_metrics: Target metrics to achieve. 

83 existing_scenarios: Optional list of pre-generated scenarios. 

84 interactive: Whether to ask for confirmation in sequential mode. 

85 

86 Returns: 

87 MCResult with simulation results. 

88 """ 

89 start_time = time.time() 

90 passed_count = 0 

91 all_results = [] 

92 

93 def _meets_targets(metrics: dict[str, float]) -> bool: 

94 for metric_name, target_value in target_metrics.items(): 

95 if metric_name not in metrics: 

96 continue 

97 actual = metrics[metric_name] 

98 if metric_name in ["max_drawdown", "consecutive_losses"]: 

99 if actual > target_value: 

100 return False 

101 else: 

102 if actual < target_value: 

103 return False 

104 return True 

105 

106 if self.config.use_sequential: 

107 return self.run_sequential(data, indicator, metrics_calc, target_metrics, interactive=interactive) 

108 

109 # Check for Pure GPU Acceleration (End-to-End on GPU) 

110 # Only for Shuffling method currently, and if indicator supports it. 

111 # This bypasses CPU scenario generation and data transfer overhead. 

112 has_mlx = indicator.to_mlx_representation() is not None 

113 has_metal = indicator.get_metal_params() is not None 

114 

115 only_shuffling = ( 

116 (self.config.use_shuffling or not any([ 

117 self.config.use_noise, 

118 self.config.use_sensitivity, 

119 self.config.use_walk_forward, 

120 self.config.use_block_bootstrap 

121 ])) 

122 and not self.config.use_noise 

123 and not self.config.use_sensitivity 

124 and not self.config.use_walk_forward 

125 and not self.config.use_block_bootstrap 

126 ) 

127 

128 if (has_mlx or has_metal) and only_shuffling and existing_scenarios is None and self.config.iterations > 100: 

129 try: 

130 engine_type = "Native Metal" if has_metal else "MLX" 

131 logger.info(f"🚀 Using High-Performance {engine_type} Engine for {self.config.iterations} iterations") 

132 results, timing_stats = self.gpu_engine.run_full_simulation( 

133 data=data, 

134 indicator=indicator, 

135 n_scenarios=self.config.iterations, 

136 method="shuffling", 

137 seed=self.config.random_seed or 42, 

138 use_sl_tp=self.config.use_sl_tp, 

139 sl_pct=self.config.sl_pct, 

140 tp_pct=self.config.tp_pct, 

141 ) 

142 

143 # Transform results to match MCResult format 

144 passed_count = sum(1 for r in results if _meets_targets(r.get("metrics", {}))) 

145 total = len(results) 

146 

147 all_results = [] 

148 for i, res in enumerate(results): 

149 metrics = res.get("metrics", {}) 

150 passed = _meets_targets(metrics) 

151 all_results.append({ 

152 "scenario_idx": i, 

153 "passed": passed, 

154 "metrics": metrics 

155 }) 

156 

157 if self._progress_callback: 

158 self._progress_callback(total, total) 

159 

160 finalize_res = self._finalize_results(passed_count, total, all_results, start_time) 

161 finalize_res.timing_stats = timing_stats 

162 return finalize_res 

163 

164 except Exception as e: 

165 logger.warning(f"Pure GPU execution failed, falling back: {e}") 

166 # Fall through to standard methods 

167 

168 # Generate test scenarios 

169 other_methods_enabled = ( 

170 self.config.use_shuffling 

171 or self.config.use_noise 

172 or self.config.use_sensitivity 

173 or self.config.use_walk_forward 

174 ) 

175 if ( 

176 self.config.use_block_bootstrap 

177 and not other_methods_enabled 

178 and existing_scenarios is None 

179 ): 

180 # Lazy generation for Block Bootstrap to avoid memory overhead 

181 logger.debug(f"Running lazy Block Bootstrap with {self.config.iterations} iterations") 

182 

183 # Ensure we have an executor with initialized data 

184 executor = self.executor 

185 should_shutdown = False 

186 

187 if executor is None: 

188 # Create a local executor with data initialization 

189 executor = ParallelExecutor( 

190 n_workers=self.config.n_workers, 

191 initializer=init_worker_data, 

192 initargs=(data,) 

193 ) 

194 should_shutdown = True 

195 executor.__enter__() 

196 

197 try: 

198 # Use GPU engine's lazy method 

199 results = self.gpu_engine.backtest_lazy_scenarios( 

200 indicator, 

201 self.config.iterations, 

202 executor=executor, 

203 block_size=None, # Auto-calculated 

204 base_seed=self.config.random_seed or 42, 

205 use_sl_tp=self.config.use_sl_tp, 

206 sl_pct=self.config.sl_pct, 

207 tp_pct=self.config.tp_pct, 

208 ) 

209 

210 # Transform results 

211 passed_count = sum(1 for r in results if _meets_targets(r.get("metrics", {}))) 

212 total = len(results) 

213 

214 all_results = [] 

215 for i, res in enumerate(results): 

216 metrics = res.get("metrics", {}) 

217 passed = _meets_targets(metrics) 

218 all_results.append({ 

219 "scenario_idx": i, 

220 "passed": passed, 

221 "metrics": metrics 

222 }) 

223 

224 if self._progress_callback: 

225 self._progress_callback(total, total) 

226 

227 return self._finalize_results(passed_count, total, all_results, start_time) 

228 

229 finally: 

230 if should_shutdown and executor: 

231 executor.__exit__(None, None, None) 

232 

233 if existing_scenarios is not None: 

234 scenarios = existing_scenarios 

235 else: 

236 scenarios = self.scenario_builder.generate(data) 

237 total = len(scenarios) 

238 

239 logger.debug(f"Running {total} Monte Carlo scenarios in parallel") 

240 

241 # Prepare arguments for parallel execution 

242 # We use a helper function to avoid pickling issues with 'self' if possible, 

243 # but ProcessPoolExecutor usually handles methods if they are defined at module level. 

244 # Alternatively, we can use a standalone function. 

245 

246 # Try GPU acceleration if many scenarios 

247 if len(scenarios) > 10: 

248 try: 

249 logger.debug(f"Offloading {total} scenarios to GPU (MLX)...") 

250 gpu_results = self.gpu_engine.backtest_scenarios( 

251 indicator, 

252 scenarios, 

253 executor=self.executor, 

254 use_sl_tp=self.config.use_sl_tp, 

255 sl_pct=self.config.sl_pct, 

256 tp_pct=self.config.tp_pct, 

257 ) 

258 

259 for i, res in enumerate(gpu_results): 

260 # The GPU engine returns a dict with 'passed' and 'metrics' 

261 metrics = res.get("metrics", {}) 

262 passed = _meets_targets(metrics) 

263 if passed: 

264 passed_count += 1 

265 all_results.append( 

266 { 

267 "scenario_idx": i, 

268 "passed": passed, 

269 "metrics": metrics, 

270 } 

271 ) 

272 

273 if self._progress_callback: 

274 self._progress_callback(total, total) 

275 

276 return self._finalize_results( 

277 passed_count, total, all_results, start_time 

278 ) 

279 

280 except Exception as e: 

281 logger.warning(f"GPU acceleration failed, falling back to CPU: {e}") 

282 

283 # Fallback to CPU parallel execution 

284 cpu_results = self._run_cpu_parallel(scenarios, indicator, metrics_calc, target_metrics) 

285 passed_count += cpu_results["passed_count"] 

286 all_results.extend(cpu_results["all_results"]) 

287 

288 if self._progress_callback: 

289 self._progress_callback(total, total) 

290 

291 return self._finalize_results(passed_count, total, all_results, start_time) 

292 

293 def _run_cpu_parallel( 

294 self, 

295 scenarios: list[pd.DataFrame], 

296 indicator: BaseIndicator, 

297 metrics_calc: MetricsCalculator, 

298 target_metrics: dict[str, float], 

299 ) -> dict: 

300 """Run scenarios on CPU in parallel.""" 

301 from functools import partial 

302 passed_count = 0 

303 all_results = [] 

304 total = len(scenarios) 

305 

306 executor = self.executor 

307 if executor is None: 

308 executor = ParallelExecutor(n_workers=self.config.n_workers) 

309 

310 n_workers = executor.n_workers 

311 

312 if total < 50 or n_workers == 1: 

313 worker_func = partial( 

314 run_single_scenario, 

315 indicator=indicator, 

316 metrics_calc=metrics_calc, 

317 target_metrics=target_metrics, 

318 ) 

319 results = executor.map(worker_func, scenarios) 

320 

321 for i, result in enumerate(results): 

322 if result is None: continue 

323 meets_targets, metrics = result 

324 if meets_targets: passed_count += 1 

325 all_results.append({"scenario_idx": i, "passed": meets_targets, "metrics": metrics}) 

326 else: 

327 batch_size = max(10, total // (n_workers * 4)) 

328 scenario_batches = [scenarios[i : i + batch_size] for i in range(0, total, batch_size)] 

329 batch_worker = partial(run_scenario_batch, indicator=indicator, metrics_calc=metrics_calc, target_metrics=target_metrics) 

330 batch_results_list = executor.map(batch_worker, scenario_batches) 

331 

332 current_idx = 0 

333 for batch_res in batch_results_list: 

334 if not batch_res: continue 

335 for meets_targets, metrics in batch_res: 

336 if meets_targets: passed_count += 1 

337 all_results.append({"scenario_idx": current_idx, "passed": meets_targets, "metrics": metrics}) 

338 current_idx += 1 

339 

340 return {"passed_count": passed_count, "all_results": all_results} 

341 

342 def _finalize_results( 

343 self, passed_count: int, total: int, all_results: list, start_time: float 

344 ) -> MCResult: 

345 """Finalize and summarize results.""" 

346 elapsed = time.time() - start_time 

347 pass_rate = passed_count / total if total > 0 else 0 

348 passed = pass_rate >= self.config.pass_threshold 

349 metrics_summary = summarize_metrics(all_results) 

350 

351 logger.info(f"✨ Monte Carlo simulation completed in {elapsed:.2f}s ({total} iterations)") 

352 logger.info(f"📊 Pass Rate: {pass_rate:.1%} ({'PASSED' if passed else 'FAILED'})") 

353 

354 return MCResult( 

355 passed=passed, 

356 pass_rate=pass_rate, 

357 iterations_run=total, 

358 elapsed_time=elapsed, 

359 metrics_summary=metrics_summary, 

360 detailed_results=all_results, 

361 ) 

362 

363 def run_sequential( 

364 self, 

365 data: pd.DataFrame, 

366 indicator: BaseIndicator, 

367 metrics_calc: MetricsCalculator, 

368 target_metrics: dict[str, float], 

369 interactive: bool = True, 

370 ) -> MCResult: 

371 """Run Monte Carlo simulation sequentially. 

372 

373 Args: 

374 data: OHLCV DataFrame. 

375 indicator: Indicator to test. 

376 metrics_calc: Metrics calculator. 

377 target_metrics: Target metrics. 

378 interactive: Whether to ask for confirmation before each step. 

379 

380 Returns: 

381 MCResult with sequential simulation results. 

382 """ 

383 from monte_neo.monte_carlo.sequential import SequentialMCRunner 

384 runner = SequentialMCRunner(self) 

385 return runner.run(data, indicator, metrics_calc, target_metrics, interactive=interactive)