Coverage for src / monte_neo / core / evolution.py: 82%
112 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
1"""Evolutionary algorithm module."""
3from __future__ import annotations
5import re
6from collections.abc import Callable
7from typing import TYPE_CHECKING
9import numpy as np
10import pandas as pd
12from monte_neo.indicators.base import BaseIndicator
13from monte_neo.indicators.code_gen import CodeGenerator
14from monte_neo.indicators.dynamic import DynamicIndicator
15from monte_neo.metrics.calculator import MetricsCalculator
16from monte_neo.utils.ast_utils import crossover_trees
18if TYPE_CHECKING:
19 from monte_neo.core.config import GeneratorConfig
20 from monte_neo.utils.parallel import ParallelExecutor
23class EvolutionEngine:
24 """Evolutionary algorithm engine."""
26 def __init__(
27 self,
28 config: GeneratorConfig,
29 metrics_calc: MetricsCalculator | None = None,
30 progress_callback: Callable[[int, int, str], None] | None = None
31 ):
32 """Initialize engine.
34 Args:
35 config: Generator configuration.
36 metrics_calc: Metrics calculator.
37 progress_callback: Progress callback.
38 """
39 self.config = config
40 self.metrics_calc = metrics_calc or MetricsCalculator()
41 self.rng = np.random.default_rng()
42 self.code_gen = CodeGenerator(self.rng)
43 self.progress_callback = progress_callback
45 def run(
46 self,
47 data: pd.DataFrame,
48 initial_population: list[BaseIndicator],
49 executor: ParallelExecutor | None = None
50 ) -> BaseIndicator | None:
51 """Run evolutionary optimization.
53 Args:
54 data: OHLCV data.
55 initial_population: Initial population.
56 executor: Optional parallel executor for signal generation.
58 Returns:
59 Best indicator found.
60 """
61 population: list[BaseIndicator] = list(initial_population)
63 # Pad population if needed
64 while len(population) < self.config.population_size:
65 new_indicator = DynamicIndicator()
66 new_indicator.set_parameter("source_code", self.code_gen.generate_code())
67 population.append(new_indicator)
69 for gen in range(self.config.generations):
70 # Evaluate fitness
71 fitness_scores: list[tuple[BaseIndicator, float]] = []
73 # Use parallel execution for fitness evaluation to reach >2000 ops/s
74 # Note: Evolution handles batches of DynamicIndicators
75 if executor:
76 from monte_neo.monte_carlo.workers import run_indicator_batch
77 n_workers = executor.n_workers
78 chunk_size = max(1, len(population) // n_workers)
79 chunks = [population[i : i + chunk_size] for i in range(0, len(population), chunk_size)]
80 # Using None for data since initializer already set it in SHARED_DATA
81 tasks = [(chunk, None) for chunk in chunks]
82 batch_results = executor.map(run_indicator_batch, tasks)
83 raw_signals = []
84 for batch in batch_results:
85 raw_signals.extend(batch)
86 else:
87 raw_signals = [ind.generate_signals_fast(data) for ind in population]
89 # Prepare for Numba batch calculation
90 signal_matrix = np.zeros((len(population), len(data)), dtype=np.int32)
91 from monte_neo.core.gpu_scenarios import normalize_signal_array
92 for i, sig in enumerate(raw_signals):
93 signal_matrix[i] = normalize_signal_array(sig, len(data)).astype(np.int32)
95 batch_metrics_arr = self.metrics_calc.calculate_batch_fast(
96 data["close"].values,
97 data["high"].values,
98 data["low"].values,
99 signal_matrix,
100 use_sl_tp=self.config.use_sl_tp,
101 sl_pct=self.config.stop_loss_pct,
102 tp_pct=self.config.take_profit_pct,
103 )
105 for i, ind in enumerate(population):
106 # Fitness function: Profit Factor * (1 - Max Drawdown)
107 pf = batch_metrics_arr[i, 2]
108 dd = batch_metrics_arr[i, 1]
109 trades = int(batch_metrics_arr[i, 3])
111 if trades < self.config.min_trades:
112 score = 0.0
113 else:
114 score = pf * (1.0 - dd)
116 fitness_scores.append((ind, score))
118 # Sort
119 fitness_scores.sort(key=lambda x: x[1], reverse=True)
120 best_gen_score = fitness_scores[0][1]
122 if self.progress_callback:
123 self.progress_callback(
124 gen + 1,
125 self.config.generations,
126 f"Evolution Gen {gen + 1}: Best Score {best_gen_score:.2f}",
127 )
129 # Selection (Elite + Tournament)
130 elite_count = max(2, int(self.config.population_size * 0.1))
131 new_pop: list[BaseIndicator] = [x[0] for x in fitness_scores[:elite_count]]
133 while len(new_pop) < self.config.population_size:
134 parent1 = self._tournament_select(fitness_scores)
136 if self.rng.random() < self.config.crossover_rate:
137 parent2 = self._tournament_select(fitness_scores)
138 child = self._crossover_indicators(parent1, parent2)
139 else:
140 child = self._mutate_indicator(parent1)
142 new_pop.append(child)
144 population = new_pop
146 # Return the best found
147 if not population:
148 return None
150 # Final evaluation
151 final_scores: list[tuple[BaseIndicator, float]] = []
152 for ind in population:
153 try:
154 signals = ind.generate_signals(data)
155 metrics = self.metrics_calc.calculate_all(data, signals)
156 pf = float(metrics.get("profit_factor", 0.0))
157 dd = float(metrics.get("max_drawdown", 1.0))
158 trades = int(metrics.get("trade_count", 0))
159 score = pf * (1.0 - dd) if trades >= self.config.min_trades else 0.0
160 final_scores.append((ind, score))
161 except Exception:
162 final_scores.append((ind, 0.0))
164 final_scores.sort(key=lambda x: x[1], reverse=True)
165 return final_scores[0][0] if final_scores[0][1] > 0 else None
167 def _crossover_indicators(self, p1: BaseIndicator, p2: BaseIndicator) -> BaseIndicator:
168 """Perform crossover."""
169 if not isinstance(p1, DynamicIndicator) or not isinstance(p2, DynamicIndicator):
170 return self._mutate_indicator(p1)
172 code1 = p1.get_parameters().get("source_code", "data['close']")
173 code2 = p2.get_parameters().get("source_code", "data['close']")
175 new_code = crossover_trees(code1, code2)
177 new_ind = DynamicIndicator()
178 new_ind.set_parameter("source_code", new_code)
179 return new_ind
181 def _tournament_select(self, fitness: list, k: int = 3) -> BaseIndicator:
182 indices = self.rng.integers(0, len(fitness), size=k)
183 best_idx = max(indices, key=lambda i: fitness[i][1])
184 return fitness[best_idx][0]
186 def _mutate_indicator(self, indicator: BaseIndicator) -> BaseIndicator:
187 """Mutate an indicator."""
188 if not isinstance(indicator, DynamicIndicator):
189 return indicator
191 code = indicator.get_parameters().get("source_code", "")
192 if not code:
193 return indicator
195 new_code = code
197 # Replace numbers
198 def replace_num(match):
199 val = int(match.group())
200 change = self.rng.choice([-1, 1]) * max(1, int(val * 0.2))
201 return str(max(1, val + change))
203 if self.rng.random() < 0.5:
204 new_code = re.sub(r"\b\d+\b", replace_num, code)
205 else:
206 op = self.rng.choice(["+", "-", "*"])
207 operand = self.rng.choice(["data['close']", "data['volume']"])
208 new_code = f"({code} {op} {operand})"
210 new_ind = DynamicIndicator()
211 new_ind.set_parameter("source_code", new_code)
212 return new_ind