Coverage for src / monte_neo / core / optimizer.py: 42%

126 statements  

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

1"""Parameter optimizer module. 

2 

3Optimizes indicator parameters using various strategies. 

4""" 

5 

6from __future__ import annotations 

7 

8from collections.abc import Callable 

9from dataclasses import dataclass, field 

10from typing import TYPE_CHECKING 

11 

12import numpy as np 

13 

14from monte_neo.utils.cache import load_calibration, save_calibration 

15from monte_neo.utils.logger import get_logger 

16 

17if TYPE_CHECKING: 

18 import pandas as pd 

19 

20 from monte_neo.indicators.base import BaseIndicator 

21 from monte_neo.metrics.calculator import MetricsCalculator 

22 

23logger = get_logger(__name__) 

24 

25 

26@dataclass 

27class OptimizationResult: 

28 """Optimization result.""" 

29 

30 best_params: dict 

31 best_score: float 

32 iterations: int 

33 history: list = field(default_factory=list) 

34 

35 

36class ParameterOptimizer: 

37 """Optimize indicator parameters.""" 

38 

39 def __init__( 

40 self, 

41 method: str = "random", 

42 max_iterations: int = 1000, 

43 random_seed: int | None = None, 

44 ) -> None: 

45 """Initialize optimizer. 

46 

47 Args: 

48 method: Optimization method ('random', 'grid', 'genetic'). 

49 max_iterations: Maximum iterations. 

50 random_seed: Random seed. 

51 """ 

52 self.method = method 

53 self.max_iterations = max_iterations 

54 self.rng = np.random.default_rng(random_seed) 

55 

56 def optimize( 

57 self, 

58 indicator: BaseIndicator, 

59 param_ranges: dict[str, tuple[int, int]], 

60 data: pd.DataFrame, 

61 metrics_calc: MetricsCalculator, 

62 objective: str = "sharpe_ratio", 

63 objective_func: Callable[[dict], float] | None = None, 

64 ) -> OptimizationResult: 

65 """Optimize indicator parameters. 

66 

67 Args: 

68 indicator: Indicator to optimize. 

69 param_ranges: Parameter ranges {name: (min, max)}. 

70 data: OHLCV data. 

71 metrics_calc: Metrics calculator. 

72 objective: Metric to maximize. 

73 objective_func: Custom objective function. 

74 

75 Returns: 

76 OptimizationResult with best parameters. 

77 """ 

78 # Try to load from cache 

79 indicator_name = indicator.__class__.__name__ 

80 if hasattr(indicator, "source_code"): 

81 # For dynamic indicators, use source code as part of the key 

82 indicator_name += f"_{hash(indicator.source_code)}" 

83 

84 cached_params = load_calibration(indicator_name, data) 

85 if cached_params: 

86 logger.info(f"🚀 Using cached calibration for {indicator_name}") 

87 # We don't have the history/iterations, so we return a simplified result 

88 return OptimizationResult( 

89 best_params=cached_params, 

90 best_score=0.0, # Unknown but presumably good 

91 iterations=0, 

92 history=[], 

93 ) 

94 

95 if self.method == "random": 

96 result = self._random_search( 

97 indicator, param_ranges, data, metrics_calc, objective, objective_func 

98 ) 

99 elif self.method == "grid": 

100 result = self._grid_search( 

101 indicator, param_ranges, data, metrics_calc, objective, objective_func 

102 ) 

103 elif self.method == "genetic": 

104 result = self._genetic_search( 

105 indicator, param_ranges, data, metrics_calc, objective, objective_func 

106 ) 

107 else: 

108 raise ValueError(f"Unknown method: {self.method}") 

109 

110 # Save to cache 

111 save_calibration(indicator_name, data, result.best_params) 

112 return result 

113 

114 def _random_search( 

115 self, 

116 indicator: BaseIndicator, 

117 param_ranges: dict, 

118 data: pd.DataFrame, 

119 metrics_calc: MetricsCalculator, 

120 objective: str, 

121 objective_func: Callable | None, 

122 ) -> OptimizationResult: 

123 """Random search optimization.""" 

124 best_params = {} 

125 best_score = float("-inf") 

126 history = [] 

127 

128 for i in range(self.max_iterations): 

129 # Generate random parameters 

130 params = {} 

131 for name, (min_val, max_val) in param_ranges.items(): 

132 params[name] = int(self.rng.integers(min_val, max_val + 1)) 

133 

134 # Evaluate 

135 score = self._evaluate( 

136 indicator, params, data, metrics_calc, objective, objective_func 

137 ) 

138 

139 history.append({"iteration": i, "params": params, "score": score}) 

140 

141 if score > best_score: 

142 best_score = score 

143 best_params = dict(params) 

144 

145 return OptimizationResult( 

146 best_params=best_params, 

147 best_score=best_score, 

148 iterations=self.max_iterations, 

149 history=history, 

150 ) 

151 

152 def _grid_search( 

153 self, 

154 indicator: BaseIndicator, 

155 param_ranges: dict, 

156 data: pd.DataFrame, 

157 metrics_calc: MetricsCalculator, 

158 objective: str, 

159 objective_func: Callable | None, 

160 ) -> OptimizationResult: 

161 """Grid search optimization.""" 

162 from itertools import product 

163 

164 # Create grid 

165 grid_points = {} 

166 for name, (min_val, max_val) in param_ranges.items(): 

167 step = max(1, (max_val - min_val) // 10) 

168 grid_points[name] = list(range(min_val, max_val + 1, step)) 

169 

170 # Search 

171 best_params = {} 

172 best_score = float("-inf") 

173 history = [] 

174 iterations = 0 

175 

176 for values in product(*grid_points.values()): 

177 params = dict(zip(grid_points.keys(), values)) 

178 

179 score = self._evaluate( 

180 indicator, params, data, metrics_calc, objective, objective_func 

181 ) 

182 

183 history.append({"iteration": iterations, "params": params, "score": score}) 

184 iterations += 1 

185 

186 if score > best_score: 

187 best_score = score 

188 best_params = dict(params) 

189 

190 if iterations >= self.max_iterations: 

191 break 

192 

193 return OptimizationResult( 

194 best_params=best_params, 

195 best_score=best_score, 

196 iterations=iterations, 

197 history=history, 

198 ) 

199 

200 def _genetic_search( 

201 self, 

202 indicator: BaseIndicator, 

203 param_ranges: dict, 

204 data: pd.DataFrame, 

205 metrics_calc: MetricsCalculator, 

206 objective: str, 

207 objective_func: Callable | None, 

208 ) -> OptimizationResult: 

209 """Genetic algorithm optimization.""" 

210 population_size = 50 

211 mutation_rate = 0.1 

212 elite_ratio = 0.2 

213 

214 # Initialize population 

215 population = [] 

216 for _ in range(population_size): 

217 params = {} 

218 for name, (min_val, max_val) in param_ranges.items(): 

219 params[name] = int(self.rng.integers(min_val, max_val + 1)) 

220 population.append(params) 

221 

222 best_params = {} 

223 best_score = float("-inf") 

224 history = [] 

225 generations = self.max_iterations // population_size 

226 

227 for gen in range(generations): 

228 # Evaluate fitness 

229 fitness = [] 

230 for params in population: 

231 score = self._evaluate( 

232 indicator, params, data, metrics_calc, objective, objective_func 

233 ) 

234 fitness.append((params, score)) 

235 

236 # Sort by fitness 

237 fitness.sort(key=lambda x: x[1], reverse=True) 

238 

239 # Update best 

240 if fitness[0][1] > best_score: 

241 best_score = fitness[0][1] 

242 best_params = dict(fitness[0][0]) 

243 

244 history.append({"generation": gen, "best_score": best_score}) 

245 

246 # Selection and reproduction 

247 elite_count = int(population_size * elite_ratio) 

248 new_population = [f[0] for f in fitness[:elite_count]] 

249 

250 while len(new_population) < population_size: 

251 # Tournament selection 

252 parent1 = self._tournament_select(fitness) 

253 parent2 = self._tournament_select(fitness) 

254 

255 # Crossover and mutation 

256 child = self._crossover(parent1, parent2, param_ranges) 

257 child = self._mutate(child, param_ranges, mutation_rate) 

258 new_population.append(child) 

259 

260 population = new_population 

261 

262 return OptimizationResult( 

263 best_params=best_params, 

264 best_score=best_score, 

265 iterations=generations * population_size, 

266 history=history, 

267 ) 

268 

269 def _evaluate( 

270 self, 

271 indicator: BaseIndicator, 

272 params: dict, 

273 data: pd.DataFrame, 

274 metrics_calc: MetricsCalculator, 

275 objective: str, 

276 objective_func: Callable | None, 

277 ) -> float: 

278 """Evaluate parameters.""" 

279 indicator.set_parameters(params) 

280 signals = indicator.generate_signals(data) 

281 metrics = metrics_calc.calculate_all(data, signals) 

282 

283 if objective_func: 

284 return objective_func(metrics) 

285 

286 return metrics.get(objective, 0) 

287 

288 def _tournament_select(self, fitness: list, k: int = 3) -> dict: 

289 """Tournament selection.""" 

290 selected = self.rng.choice( 

291 len(fitness), size=min(k, len(fitness)), replace=False 

292 ) 

293 best = max(selected, key=lambda i: fitness[i][1]) 

294 return fitness[best][0] 

295 

296 def _crossover(self, p1: dict, p2: dict, ranges: dict) -> dict: 

297 """Single-point crossover.""" 

298 child = {} 

299 for name in ranges: 

300 child[name] = p1[name] if self.rng.random() < 0.5 else p2[name] 

301 return child 

302 

303 def _mutate(self, params: dict, ranges: dict, rate: float) -> dict: 

304 """Mutation with given rate.""" 

305 for name, (min_val, max_val) in ranges.items(): 

306 if self.rng.random() < rate: 

307 params[name] = int(self.rng.integers(min_val, max_val + 1)) 

308 return params