opencode-pyneruntime 6.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
  2. opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
  3. opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
  4. opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
  5. opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
  6. opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
  7. opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
  8. pynecore/__init__.py +6 -0
  9. pynecore/cli/__init__.py +2 -0
  10. pynecore/cli/app.py +238 -0
  11. pynecore/cli/commands/__init__.py +343 -0
  12. pynecore/cli/commands/benchmark.py +186 -0
  13. pynecore/cli/commands/compile.py +198 -0
  14. pynecore/cli/commands/data.py +857 -0
  15. pynecore/cli/commands/debug.py +63 -0
  16. pynecore/cli/commands/optimize.py +956 -0
  17. pynecore/cli/commands/plugin.py +242 -0
  18. pynecore/cli/commands/run.py +2006 -0
  19. pynecore/cli/pluggable.py +132 -0
  20. pynecore/cli/utils/__init__.py +0 -0
  21. pynecore/cli/utils/api_error_handler.py +168 -0
  22. pynecore/cli/utils/broker_picker.py +330 -0
  23. pynecore/cli/utils/error_hook.py +28 -0
  24. pynecore/cli/utils/keyreader.py +178 -0
  25. pynecore/cli/utils/provider_picker.py +19 -0
  26. pynecore/cli/utils/symbol_browser.py +1149 -0
  27. pynecore/core/__init__.py +0 -0
  28. pynecore/core/aggregator.py +257 -0
  29. pynecore/core/bar_magnifier.py +168 -0
  30. pynecore/core/broker/__init__.py +64 -0
  31. pynecore/core/broker/defaults.py +113 -0
  32. pynecore/core/broker/disappearance.py +927 -0
  33. pynecore/core/broker/emulator.py +345 -0
  34. pynecore/core/broker/exceptions.py +346 -0
  35. pynecore/core/broker/idempotency.py +401 -0
  36. pynecore/core/broker/intent_builder.py +334 -0
  37. pynecore/core/broker/journal.py +1785 -0
  38. pynecore/core/broker/models.py +1600 -0
  39. pynecore/core/broker/native_failsafe_manager.py +1436 -0
  40. pynecore/core/broker/one_way_emulator.py +1128 -0
  41. pynecore/core/broker/position.py +787 -0
  42. pynecore/core/broker/run_identity.py +126 -0
  43. pynecore/core/broker/software_entry_stop_engine.py +351 -0
  44. pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
  45. pynecore/core/broker/spot_inventory.py +1327 -0
  46. pynecore/core/broker/storage.py +2655 -0
  47. pynecore/core/broker/store_helpers.py +2161 -0
  48. pynecore/core/broker/sync_engine.py +16070 -0
  49. pynecore/core/broker/validation.py +382 -0
  50. pynecore/core/class_property.py +7 -0
  51. pynecore/core/config.py +392 -0
  52. pynecore/core/csv_file.py +547 -0
  53. pynecore/core/currency.py +262 -0
  54. pynecore/core/data_converter.py +1002 -0
  55. pynecore/core/datetime.py +296 -0
  56. pynecore/core/download_info.py +71 -0
  57. pynecore/core/download_runner.py +274 -0
  58. pynecore/core/htf_aggregator.py +181 -0
  59. pynecore/core/import_hook.py +358 -0
  60. pynecore/core/instance_state.py +494 -0
  61. pynecore/core/live_ltf_collector.py +442 -0
  62. pynecore/core/live_ltf_window.py +189 -0
  63. pynecore/core/live_runner.py +1347 -0
  64. pynecore/core/module_property.py +26 -0
  65. pynecore/core/ohlcv_file.py +1888 -0
  66. pynecore/core/overload.py +371 -0
  67. pynecore/core/pine_cast.py +113 -0
  68. pynecore/core/pine_export.py +95 -0
  69. pynecore/core/pine_method.py +244 -0
  70. pynecore/core/pine_range.py +86 -0
  71. pynecore/core/pine_udt.py +69 -0
  72. pynecore/core/plugin/__init__.py +394 -0
  73. pynecore/core/plugin/broker.py +781 -0
  74. pynecore/core/plugin/cli.py +96 -0
  75. pynecore/core/plugin/live_provider.py +208 -0
  76. pynecore/core/plugin/provider.py +331 -0
  77. pynecore/core/provider_string.py +148 -0
  78. pynecore/core/random.py +40 -0
  79. pynecore/core/resampler.py +686 -0
  80. pynecore/core/safe_convert.py +64 -0
  81. pynecore/core/script.py +1011 -0
  82. pynecore/core/script_runner.py +3202 -0
  83. pynecore/core/security.py +1749 -0
  84. pynecore/core/security_process.py +1253 -0
  85. pynecore/core/security_shm.py +456 -0
  86. pynecore/core/series.py +417 -0
  87. pynecore/core/strategy_stats.py +669 -0
  88. pynecore/core/symbol_map.py +134 -0
  89. pynecore/core/syminfo.py +505 -0
  90. pynecore/core/viz.py +591 -0
  91. pynecore/lib/__init__.py +1771 -0
  92. pynecore/lib/_fixnan.py +32 -0
  93. pynecore/lib/_math_stateful.py +202 -0
  94. pynecore/lib/_timeframe_change.py +101 -0
  95. pynecore/lib/adjustment.py +6 -0
  96. pynecore/lib/alert.py +39 -0
  97. pynecore/lib/alert.pyi +14 -0
  98. pynecore/lib/array.py +1051 -0
  99. pynecore/lib/barmerge.py +60 -0
  100. pynecore/lib/barstate.py +30 -0
  101. pynecore/lib/box.py +415 -0
  102. pynecore/lib/chart.py +128 -0
  103. pynecore/lib/color.py +152 -0
  104. pynecore/lib/color.pyi +50 -0
  105. pynecore/lib/currency.py +62 -0
  106. pynecore/lib/dayofweek.py +36 -0
  107. pynecore/lib/dayofweek.pyi +18 -0
  108. pynecore/lib/display.py +8 -0
  109. pynecore/lib/dividends.py +9 -0
  110. pynecore/lib/earnings.py +11 -0
  111. pynecore/lib/extend.py +6 -0
  112. pynecore/lib/font.py +5 -0
  113. pynecore/lib/footprint.py +79 -0
  114. pynecore/lib/format.py +11 -0
  115. pynecore/lib/hline.py +67 -0
  116. pynecore/lib/hline.pyi +24 -0
  117. pynecore/lib/label.py +409 -0
  118. pynecore/lib/line.py +433 -0
  119. pynecore/lib/linefill.py +93 -0
  120. pynecore/lib/location.py +11 -0
  121. pynecore/lib/log.py +362 -0
  122. pynecore/lib/map.py +150 -0
  123. pynecore/lib/math.py +385 -0
  124. pynecore/lib/matrix.py +708 -0
  125. pynecore/lib/order.py +8 -0
  126. pynecore/lib/pivotpointtype.py +8 -0
  127. pynecore/lib/plot.py +95 -0
  128. pynecore/lib/plot.pyi +33 -0
  129. pynecore/lib/polyline.py +91 -0
  130. pynecore/lib/position.py +15 -0
  131. pynecore/lib/request.py +281 -0
  132. pynecore/lib/runtime.py +5 -0
  133. pynecore/lib/scale.py +9 -0
  134. pynecore/lib/session.py +267 -0
  135. pynecore/lib/session.pyi +12 -0
  136. pynecore/lib/shape.py +18 -0
  137. pynecore/lib/size.py +12 -0
  138. pynecore/lib/splits.py +4 -0
  139. pynecore/lib/strategy/__init__.py +4778 -0
  140. pynecore/lib/strategy/closedtrades.py +347 -0
  141. pynecore/lib/strategy/closedtrades.pyi +53 -0
  142. pynecore/lib/strategy/commission.py +9 -0
  143. pynecore/lib/strategy/direction.py +9 -0
  144. pynecore/lib/strategy/oca.py +13 -0
  145. pynecore/lib/strategy/opentrades.py +281 -0
  146. pynecore/lib/strategy/opentrades.pyi +49 -0
  147. pynecore/lib/strategy/risk.py +109 -0
  148. pynecore/lib/string.py +649 -0
  149. pynecore/lib/syminfo.py +84 -0
  150. pynecore/lib/ta.py +2230 -0
  151. pynecore/lib/table.py +290 -0
  152. pynecore/lib/text.py +17 -0
  153. pynecore/lib/ticker.py +207 -0
  154. pynecore/lib/timeframe.py +293 -0
  155. pynecore/lib/volume_row.py +67 -0
  156. pynecore/lib/xloc.py +4 -0
  157. pynecore/lib/yloc.py +5 -0
  158. pynecore/providers/__init__.py +0 -0
  159. pynecore/providers/ccxt.py +664 -0
  160. pynecore/providers/replay.py +187 -0
  161. pynecore/pynesys/__init__.py +0 -0
  162. pynecore/pynesys/api.py +498 -0
  163. pynecore/pynesys/compiler.py +112 -0
  164. pynecore/standalone.py +99 -0
  165. pynecore/testing/__init__.py +1 -0
  166. pynecore/testing/broker_lab/__init__.py +41 -0
  167. pynecore/testing/broker_lab/__main__.py +5 -0
  168. pynecore/testing/broker_lab/cli.py +87 -0
  169. pynecore/testing/broker_lab/generate.py +47 -0
  170. pynecore/testing/broker_lab/model.py +84 -0
  171. pynecore/testing/broker_lab/reference.py +645 -0
  172. pynecore/testing/broker_lab/runner.py +372 -0
  173. pynecore/testing/broker_lab/scheduler.py +50 -0
  174. pynecore/testing/broker_lab/subprocess.py +73 -0
  175. pynecore/transformers/__init__.py +0 -0
  176. pynecore/transformers/builtin_shadow.py +136 -0
  177. pynecore/transformers/closure_arguments_transformer.py +428 -0
  178. pynecore/transformers/display_rewrite.py +140 -0
  179. pynecore/transformers/dynamic_default.py +147 -0
  180. pynecore/transformers/function_isolation.py +757 -0
  181. pynecore/transformers/import_lifter.py +61 -0
  182. pynecore/transformers/import_normalizer.py +328 -0
  183. pynecore/transformers/inline_series_hoist.py +178 -0
  184. pynecore/transformers/input_transformer.py +175 -0
  185. pynecore/transformers/lib_series.py +201 -0
  186. pynecore/transformers/locations.py +70 -0
  187. pynecore/transformers/module_properties.json +3387 -0
  188. pynecore/transformers/module_property.py +221 -0
  189. pynecore/transformers/ne_guard.py +70 -0
  190. pynecore/transformers/persistent.py +320 -0
  191. pynecore/transformers/persistent_series.py +76 -0
  192. pynecore/transformers/safe_convert_transformer.py +97 -0
  193. pynecore/transformers/safe_division_transformer.py +95 -0
  194. pynecore/transformers/script_requirements.py +308 -0
  195. pynecore/transformers/security.py +752 -0
  196. pynecore/transformers/security_instantiation.py +274 -0
  197. pynecore/transformers/series.py +275 -0
  198. pynecore/transformers/slot_layout.py +381 -0
  199. pynecore/transformers/type_checking_stripper.py +25 -0
  200. pynecore/transformers/unused_series_detector.py +267 -0
  201. pynecore/types/__init__.py +21 -0
  202. pynecore/types/alert.py +5 -0
  203. pynecore/types/barmerge.py +5 -0
  204. pynecore/types/base.py +39 -0
  205. pynecore/types/box.py +37 -0
  206. pynecore/types/chart.py +17 -0
  207. pynecore/types/color.py +107 -0
  208. pynecore/types/currency.py +5 -0
  209. pynecore/types/datetime.py +6 -0
  210. pynecore/types/display.py +5 -0
  211. pynecore/types/dividends.py +5 -0
  212. pynecore/types/earnings.py +5 -0
  213. pynecore/types/extend.py +5 -0
  214. pynecore/types/font.py +5 -0
  215. pynecore/types/footprint.py +41 -0
  216. pynecore/types/format.py +5 -0
  217. pynecore/types/hline.py +24 -0
  218. pynecore/types/ib_persistent.py +8 -0
  219. pynecore/types/ib_persistent.pyi +10 -0
  220. pynecore/types/label.py +35 -0
  221. pynecore/types/line.py +32 -0
  222. pynecore/types/linefill.py +13 -0
  223. pynecore/types/location.py +5 -0
  224. pynecore/types/matrix.py +999 -0
  225. pynecore/types/na.py +237 -0
  226. pynecore/types/na.pyi +83 -0
  227. pynecore/types/ohlcv.py +12 -0
  228. pynecore/types/order.py +5 -0
  229. pynecore/types/persistent.py +8 -0
  230. pynecore/types/persistent.pyi +13 -0
  231. pynecore/types/pine_types.py +11 -0
  232. pynecore/types/pine_types.pyi +15 -0
  233. pynecore/types/pivotpointtype.py +5 -0
  234. pynecore/types/plot.py +12 -0
  235. pynecore/types/plot_meta.py +60 -0
  236. pynecore/types/polyline.py +40 -0
  237. pynecore/types/position.py +5 -0
  238. pynecore/types/scale.py +5 -0
  239. pynecore/types/script_type.py +15 -0
  240. pynecore/types/series.py +23 -0
  241. pynecore/types/series.pyi +19 -0
  242. pynecore/types/session.py +35 -0
  243. pynecore/types/shape.py +5 -0
  244. pynecore/types/size.py +5 -0
  245. pynecore/types/source.py +33 -0
  246. pynecore/types/splits.py +5 -0
  247. pynecore/types/strategy.py +45 -0
  248. pynecore/types/table.py +87 -0
  249. pynecore/types/text.py +13 -0
  250. pynecore/types/type_checker.py +7 -0
  251. pynecore/types/type_checker.pyi +48 -0
  252. pynecore/types/volume_row.py +36 -0
  253. pynecore/types/weekdays.py +11 -0
  254. pynecore/types/xloc.py +5 -0
  255. pynecore/types/yloc.py +5 -0
  256. pynecore/utils/__init__.py +0 -0
  257. pynecore/utils/file_utils.py +50 -0
  258. pynecore/utils/rich/__init__.py +0 -0
  259. pynecore/utils/rich/date_column.py +25 -0
  260. pynecore/utils/sequence_view.py +92 -0
  261. pynecore/utils/stdlib_checker.py +17 -0
@@ -0,0 +1,956 @@
1
+ """
2
+ Parameter optimization command for PyneCore.
3
+ Runs a strategy with all combinations of specified parameter values (grid search)
4
+ and ranks results by a chosen metric. Supports parallel execution via --workers.
5
+ """
6
+
7
+ import csv
8
+ import gc
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import sys
13
+ from concurrent.futures import ProcessPoolExecutor, as_completed
14
+ from dataclasses import dataclass, fields as dataclass_fields
15
+ from datetime import datetime
16
+ from itertools import product
17
+ from multiprocessing import cpu_count
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from typer import Option, Argument, secho, Exit
22
+ from rich.progress import (Progress, SpinnerColumn, TextColumn, BarColumn,
23
+ MofNCompleteColumn, ProgressColumn, Task)
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+ from rich.console import Console
27
+
28
+ from ..app import app, app_state
29
+ from ...core.ohlcv_file import OHLCVReader
30
+ from ...core.syminfo import SymInfo
31
+ from ...core.script_runner import ScriptRunner
32
+ from ...core.strategy_stats import StrategyStatistics
33
+
34
+ __all__ = []
35
+
36
+ console = Console()
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Metric registry
40
+ # ---------------------------------------------------------------------------
41
+
42
+ METRIC_ALIASES: dict[str, str] = {
43
+ "net_profit": "net_profit",
44
+ "net_profit_pct": "net_profit_percent",
45
+ "sharpe": "sharpe_ratio",
46
+ "sortino": "sortino_ratio",
47
+ "profit_factor": "profit_factor",
48
+ "max_drawdown_pct": "max_equity_drawdown_percent",
49
+ "win_rate": "percent_profitable",
50
+ "total_trades": "total_trades",
51
+ "avg_trade_pct": "avg_trade_percent",
52
+ "avg_win_loss": "ratio_avg_win_loss",
53
+ }
54
+
55
+ # Metrics where lower is better
56
+ MINIMIZE_METRICS = {"max_drawdown_pct"}
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Parameter parsing
61
+ # ---------------------------------------------------------------------------
62
+
63
+ @dataclass(frozen=True)
64
+ class ParamSpec:
65
+ """Immutable specification for a single parameter's sweep values."""
66
+ name: str
67
+ values: tuple[Any, ...]
68
+
69
+
70
+ def parse_param_specs(params_dict: dict[str, Any]) -> list[ParamSpec]:
71
+ """Parse parameter JSON into ParamSpec list.
72
+
73
+ Supports:
74
+ - Range dict: {"min": 5, "max": 20, "step": 1}
75
+ - Explicit list: [true, false] or ["ema", "sma"]
76
+ - Keys starting with "_" are skipped (metadata like _meta)
77
+ """
78
+ specs: list[ParamSpec] = []
79
+ for name, spec in params_dict.items():
80
+ if name.startswith("_"):
81
+ continue
82
+ if isinstance(spec, list):
83
+ if len(spec) == 0:
84
+ raise ValueError(f"Parameter '{name}': empty list")
85
+ specs.append(ParamSpec(name=name, values=tuple(spec)))
86
+ elif isinstance(spec, dict):
87
+ required = {"min", "max", "step"}
88
+ missing = required - set(spec.keys())
89
+ if missing:
90
+ raise ValueError(f"Parameter '{name}': missing keys {missing}")
91
+
92
+ min_val, max_val, step = spec["min"], spec["max"], spec["step"]
93
+
94
+ if step <= 0:
95
+ raise ValueError(f"Parameter '{name}': step must be positive")
96
+ if min_val > max_val:
97
+ raise ValueError(f"Parameter '{name}': min ({min_val}) > max ({max_val})")
98
+
99
+ # Integer range — use native range for precision
100
+ if (isinstance(min_val, int) and isinstance(max_val, int)
101
+ and isinstance(step, int)):
102
+ values = tuple(range(min_val, max_val + 1, step))
103
+ else:
104
+ # Float range — use index-based generation to avoid drift
105
+ steps_count = int((max_val - min_val) / step) + 1
106
+ values = []
107
+ for i in range(steps_count):
108
+ val = round(min_val + i * step, 10)
109
+ if val > max_val + 1e-9:
110
+ break
111
+ values.append(val)
112
+ values = tuple(values)
113
+
114
+ if len(values) == 0:
115
+ raise ValueError(f"Parameter '{name}': range produces no values")
116
+ specs.append(ParamSpec(name=name, values=values))
117
+ else:
118
+ raise ValueError(
119
+ f"Parameter '{name}': must be a list or dict with min/max/step, "
120
+ f"got {type(spec).__name__}"
121
+ )
122
+ return specs
123
+
124
+
125
+ def generate_combinations(specs: list[ParamSpec]) -> list[dict[str, Any]]:
126
+ """Generate all parameter combinations (cartesian product)."""
127
+ if not specs:
128
+ return [{}]
129
+ names = [s.name for s in specs]
130
+ value_lists = [s.values for s in specs]
131
+ return [dict(zip(names, combo)) for combo in product(*value_lists)]
132
+
133
+
134
+ def generate_explicit_combinations(specs: list[ParamSpec]) -> list[dict[str, Any]]:
135
+ """Generate combinations by column-wise zip (explicit mode).
136
+
137
+ All multi-value param lists must have the same length N.
138
+ Single-value params (fixed) expand to that value for all N combos.
139
+ Combo i uses index i from each multi-value list.
140
+
141
+ Raises ValueError if multi-value lists have inconsistent lengths.
142
+ """
143
+ if not specs:
144
+ return [{}]
145
+
146
+ # Separate fixed (single-value) from variable (multi-value) params
147
+ fixed_params: list[tuple[str, Any]] = []
148
+ variable_params: list[tuple[str, tuple[Any, ...]]] = []
149
+
150
+ for spec in specs:
151
+ if len(spec.values) == 1:
152
+ fixed_params.append((spec.name, spec.values[0]))
153
+ else:
154
+ variable_params.append((spec.name, spec.values))
155
+
156
+ # If no variable params, return single combo with all fixed values
157
+ if not variable_params:
158
+ return [{name: value for name, value in fixed_params}]
159
+
160
+ # Validate: all variable param lists must have same length
161
+ combo_count = len(variable_params[0][1])
162
+ for name, values in variable_params[1:]:
163
+ if len(values) != combo_count:
164
+ raise ValueError(
165
+ f"Explicit mode: param '{name}' has {len(values)} values, "
166
+ f"but '{variable_params[0][0]}' has {combo_count}. "
167
+ f"All multi-value param lists must have the same length."
168
+ )
169
+
170
+ # Build N combos by zipping columns
171
+ combinations = []
172
+ for i in range(combo_count):
173
+ combo = {name: value for name, value in fixed_params}
174
+ for name, values in variable_params:
175
+ combo[name] = values[i]
176
+ combinations.append(combo)
177
+
178
+ return combinations
179
+
180
+
181
+ def parse_chunk(chunk_str: str) -> tuple[int, int]:
182
+ """Parse 'N/M' -> (chunk_num, total_chunks). Validates 1 <= N <= M."""
183
+ parts = chunk_str.split("/")
184
+ if len(parts) != 2:
185
+ raise ValueError(f"Invalid chunk format '{chunk_str}', expected 'N/M' (e.g. '2/4')")
186
+ try:
187
+ n, m = int(parts[0]), int(parts[1])
188
+ except ValueError:
189
+ raise ValueError(f"Invalid chunk format '{chunk_str}', N and M must be integers")
190
+ if m < 1:
191
+ raise ValueError(f"Total chunks M must be >= 1, got {m}")
192
+ if n < 1 or n > m:
193
+ raise ValueError(f"Chunk number N must be 1..{m}, got {n}")
194
+ return n, m
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Single-run helper (used by both sequential and parallel paths)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def _run_backtest(
202
+ script_path: Path,
203
+ reader: OHLCVReader,
204
+ syminfo: SymInfo,
205
+ start_ts: int,
206
+ end_ts: int,
207
+ size: int,
208
+ params: dict[str, Any],
209
+ ) -> tuple[StrategyStatistics | None, str | None]:
210
+ """Run one backtest with the given parameter overrides.
211
+
212
+ Returns (stats, None) on success or (None, error_message) on failure.
213
+ """
214
+ from pynecore.core import script as script_module
215
+
216
+ # 1. Clean input state for this combo, then hand the values to the runner via
217
+ # the sanctioned inputs= API. Upstream loads inputs= into
218
+ # _programmatic_inputs, which the script import copies into
219
+ # _old_input_values (script.py). This replaces the fork's manual
220
+ # _old_input_values mutation + PYNE_OPTIMIZE_MODE + _var_cache machinery,
221
+ # and is per-run rather than shared mutable module state.
222
+ script_module._old_input_values.clear()
223
+ script_module._programmatic_inputs.clear()
224
+
225
+ # 2. Force fresh module import so each combo runs isolated
226
+ module_name = script_path.stem
227
+ if module_name in sys.modules:
228
+ del sys.modules[module_name]
229
+
230
+ # 3. Clear registered libraries from previous run
231
+ script_module._registered_libraries.clear()
232
+
233
+ # 4. Fresh OHLCV iterator
234
+ ohlcv_iter = reader.read_from(start_ts, end_ts)
235
+
236
+ # 5. Run (no file I/O)
237
+ try:
238
+ runner = ScriptRunner(
239
+ script_path, ohlcv_iter, syminfo,
240
+ last_bar_index=size - 1,
241
+ plot_path=None,
242
+ strat_path=None,
243
+ trade_path=None,
244
+ inputs=params,
245
+ )
246
+ runner.run()
247
+ return (runner.stats, None)
248
+ except Exception as e:
249
+ return (None, f"Run failed for {params}: {e}")
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Parallel worker functions
254
+ # ---------------------------------------------------------------------------
255
+
256
+ # Per-worker globals (set by _worker_init, used by _worker_run)
257
+ _w_script_path: Path | None = None
258
+ _w_reader: OHLCVReader | None = None
259
+ _w_syminfo: SymInfo | None = None
260
+ _w_start_ts: int = 0
261
+ _w_end_ts: int = 0
262
+ _w_size: int = 0
263
+
264
+
265
+ def _worker_init(
266
+ script_path_str: str,
267
+ data_path_str: str,
268
+ syminfo_toml_str: str,
269
+ start_ts: int,
270
+ end_ts: int,
271
+ size: int,
272
+ lib_dir_str: str | None,
273
+ cache_path: str | None = None,
274
+ ) -> None:
275
+ """Initialize per-worker state. Called once per worker process."""
276
+ global _w_script_path, _w_reader, _w_syminfo, _w_start_ts, _w_end_ts, _w_size
277
+
278
+ _w_script_path = Path(script_path_str)
279
+ _w_reader = OHLCVReader(Path(data_path_str))
280
+ _w_reader.__enter__()
281
+ _w_syminfo = SymInfo.load_toml(Path(syminfo_toml_str))
282
+ _w_start_ts = start_ts
283
+ _w_end_ts = end_ts
284
+ _w_size = size
285
+
286
+ os.environ["PYNE_OPTIMIZE_MODE"] = "1"
287
+ os.environ["PYNE_SAVE_SCRIPT_TOML"] = "0"
288
+
289
+ if lib_dir_str:
290
+ sys.path.insert(0, lib_dir_str)
291
+
292
+ if cache_path:
293
+ import pickle
294
+ from pynecore.core import _var_cache
295
+ with open(cache_path, 'rb') as f:
296
+ _var_cache._data = pickle.load(f)
297
+
298
+
299
+ def _worker_run(
300
+ params: dict[str, Any],
301
+ ) -> tuple[dict[str, Any], StrategyStatistics | None, str | None]:
302
+ """Run a single backtest in a worker process.
303
+
304
+ Returns (params, stats, error_message).
305
+ """
306
+ stats, error = _run_backtest(
307
+ _w_script_path, _w_reader, _w_syminfo,
308
+ _w_start_ts, _w_end_ts, _w_size, params,
309
+ )
310
+ return (params, stats, error)
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Output helpers
315
+ # ---------------------------------------------------------------------------
316
+
317
+ def get_metric_value(stats: StrategyStatistics, metric_attr: str) -> float:
318
+ """Extract a metric value from StrategyStatistics."""
319
+ return float(getattr(stats, metric_attr, 0.0))
320
+
321
+
322
+ def write_results_csv(
323
+ results: list[tuple[dict[str, Any], StrategyStatistics]],
324
+ param_names: list[str],
325
+ output_path: Path,
326
+ ) -> None:
327
+ """Write all optimization results to CSV."""
328
+ if not results:
329
+ return
330
+
331
+ stat_fields = [f.name for f in dataclass_fields(StrategyStatistics)]
332
+ header = param_names + stat_fields
333
+
334
+ with open(output_path, "w", newline="") as f:
335
+ writer = csv.writer(f)
336
+ writer.writerow(header)
337
+ for params, stats in results:
338
+ row = [params.get(name, "") for name in param_names]
339
+ row += [getattr(stats, field) for field in stat_fields]
340
+ writer.writerow(row)
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Incremental CSV + Resume helpers
345
+ # ---------------------------------------------------------------------------
346
+
347
+ def _load_completed_keys(
348
+ csv_path: Path,
349
+ param_names: list[str],
350
+ ) -> tuple[set[tuple[str, ...]], list[list[str]], list[str]]:
351
+ """Load completed parameter combos from a partial CSV.
352
+
353
+ Returns (completed_keys, existing_rows, fieldnames).
354
+ completed_keys: set of param-value tuples already done.
355
+ existing_rows: raw CSV rows (list of values) for later merge.
356
+ fieldnames: header from the CSV file.
357
+ """
358
+ completed: set[tuple[str, ...]] = set()
359
+ existing_rows: list[list[str]] = []
360
+ fieldnames: list[str] = []
361
+
362
+ if not csv_path.exists() or csv_path.stat().st_size == 0:
363
+ return completed, existing_rows, fieldnames
364
+
365
+ try:
366
+ with open(csv_path, "r", newline="") as f:
367
+ reader = csv.reader(f)
368
+ fieldnames = next(reader, [])
369
+ if not fieldnames:
370
+ return completed, existing_rows, fieldnames
371
+ # Find param column indices
372
+ param_indices = []
373
+ for name in param_names:
374
+ try:
375
+ param_indices.append(fieldnames.index(name))
376
+ except ValueError:
377
+ # Header doesn't match — can't resume
378
+ return set(), [], []
379
+ for row in reader:
380
+ if len(row) < len(fieldnames):
381
+ continue
382
+ key = tuple(row[i] for i in param_indices)
383
+ completed.add(key)
384
+ existing_rows.append(row)
385
+ except Exception:
386
+ return set(), [], []
387
+
388
+ return completed, existing_rows, fieldnames
389
+
390
+
391
+ def _combo_to_key(
392
+ combo: dict[str, Any],
393
+ param_names: list[str],
394
+ ) -> tuple[str, ...]:
395
+ """Convert a parameter combo to a hashable string tuple for comparison."""
396
+ return tuple(str(combo.get(name, "")) for name in param_names)
397
+
398
+
399
+ def _run_signature(
400
+ script: Path,
401
+ data: Path,
402
+ params_dict: dict[str, Any],
403
+ time_from: datetime | None,
404
+ time_to: datetime | None,
405
+ chunk: str | None,
406
+ ) -> str:
407
+ """Compute a signature that uniquely identifies an optimization invocation.
408
+
409
+ Two invocations share a result CSV only when this signature matches. It
410
+ covers every input that changes the computed numbers — script, data file,
411
+ parameter spec, and date window — so results from an unrelated run are never
412
+ silently reused (see resume logic in ``optimize``). The metric is
413
+ deliberately excluded: it only affects sort order, not the values.
414
+
415
+ :param script: Resolved strategy script path.
416
+ :param data: Resolved OHLCV data file path.
417
+ :param params_dict: Full parameter specification (as parsed from JSON).
418
+ :param time_from: Start of the backtest window, if any.
419
+ :param time_to: End of the backtest window, if any.
420
+ :param chunk: Grid chunk selector (e.g. ``"2/4"``), if any.
421
+ :return: Hex signature string.
422
+ """
423
+ payload = json.dumps(
424
+ {
425
+ "script": str(script.resolve()),
426
+ "data": str(data.resolve()),
427
+ "params": params_dict,
428
+ "from": time_from.isoformat() if time_from else "",
429
+ "to": time_to.isoformat() if time_to else "",
430
+ "chunk": chunk or "",
431
+ },
432
+ sort_keys=True,
433
+ )
434
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
435
+
436
+
437
+ def _sort_and_rewrite_csv(
438
+ csv_path: Path,
439
+ metric_attr: str,
440
+ is_minimize: bool,
441
+ ) -> int:
442
+ """Read CSV, sort by metric column, rewrite in-place. Returns row count."""
443
+ rows: list[list[str]] = []
444
+ header: list[str] = []
445
+
446
+ with open(csv_path, "r", newline="") as f:
447
+ reader = csv.reader(f)
448
+ header = next(reader, [])
449
+ if not header:
450
+ return 0
451
+ for row in reader:
452
+ rows.append(row)
453
+
454
+ if not rows:
455
+ return 0
456
+
457
+ # Find metric column index
458
+ try:
459
+ metric_idx = header.index(metric_attr)
460
+ except ValueError:
461
+ return len(rows) # Can't sort, leave as-is
462
+
463
+ def sort_key(row):
464
+ try:
465
+ return float(row[metric_idx])
466
+ except (ValueError, TypeError, IndexError):
467
+ return 0.0
468
+
469
+ rows.sort(key=sort_key, reverse=not is_minimize)
470
+
471
+ with open(csv_path, "w", newline="") as f:
472
+ writer = csv.writer(f)
473
+ writer.writerow(header)
474
+ writer.writerows(rows)
475
+
476
+ return len(rows)
477
+
478
+
479
+ def write_best_toml(best_params: dict[str, Any], output_path: Path) -> None:
480
+ """Write the best parameter set as a TOML snippet."""
481
+ lines = [
482
+ "# Best optimization parameters",
483
+ "# Generated by: pyne optimize",
484
+ "",
485
+ "[script]",
486
+ "",
487
+ "# Input Settings",
488
+ "",
489
+ ]
490
+ for name, value in best_params.items():
491
+ lines.append(f"[inputs.{name}]")
492
+ if isinstance(value, bool):
493
+ lines.append(f"value = {str(value).lower()}")
494
+ elif isinstance(value, str):
495
+ lines.append(f'value = "{value}"')
496
+ else:
497
+ lines.append(f"value = {value}")
498
+ lines.append("")
499
+
500
+ with open(output_path, "w") as f:
501
+ f.write("\n".join(lines) + "\n")
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # Progress column
506
+ # ---------------------------------------------------------------------------
507
+
508
+ class RateColumn(ProgressColumn):
509
+ """Show runs per second."""
510
+
511
+ def render(self, task: Task) -> Text:
512
+ elapsed = task.elapsed
513
+ if elapsed is None or elapsed == 0 or task.completed == 0:
514
+ return Text("-- runs/s", style="cyan")
515
+ rate = task.completed / elapsed
516
+ return Text(f"{rate:.1f} runs/s", style="cyan")
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Command
521
+ # ---------------------------------------------------------------------------
522
+
523
+ @app.command()
524
+ def optimize(
525
+ script: Path = Argument(
526
+ ..., dir_okay=False, file_okay=True,
527
+ help="Strategy script to optimize (.py)",
528
+ ),
529
+ data: Path = Argument(
530
+ ..., dir_okay=False, file_okay=True,
531
+ help="Data file to use (*.ohlcv)",
532
+ ),
533
+ params: Path = Argument(
534
+ ..., dir_okay=False, file_okay=True,
535
+ help="Parameter specification JSON file",
536
+ ),
537
+ metric: str = Option(
538
+ "net_profit", "--metric", "-m",
539
+ help="Metric to optimize. Options: "
540
+ + ", ".join(METRIC_ALIASES.keys()),
541
+ ),
542
+ top_n: int = Option(10, "--top", "-n", help="Number of top results to display"),
543
+ output: Path | None = Option(
544
+ None, "--output", "-o",
545
+ help="CSV output path for all results",
546
+ ),
547
+ save_best: bool = Option(False, "--save-best", help="Save best params as .toml"),
548
+ workers: int = Option(
549
+ 0, "--workers", "-w",
550
+ help="Parallel workers (0=auto/half cores, 1=sequential)",
551
+ ),
552
+ time_from: datetime | None = Option(
553
+ None, "--from", "-f",
554
+ formats=["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"],
555
+ help="Start date (UTC)",
556
+ ),
557
+ time_to: datetime | None = Option(
558
+ None, "--to", "-t",
559
+ formats=["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"],
560
+ help="End date (UTC)",
561
+ ),
562
+ chunk: str | None = Option(
563
+ None, "--chunk", "-c",
564
+ help="Run chunk N/M of the grid, e.g. '2/4' runs the 2nd quarter",
565
+ ),
566
+ ):
567
+ """
568
+ Optimize strategy parameters via grid search.
569
+
570
+ Runs the strategy with every combination of parameters defined in the JSON
571
+ file and ranks results by the chosen metric.
572
+
573
+ \b
574
+ Example JSON (optimize.json):
575
+ {
576
+ "fast_length": {"min": 5, "max": 20, "step": 1},
577
+ "slow_length": {"min": 20, "max": 50, "step": 5},
578
+ "use_filter": [true, false]
579
+ }
580
+
581
+ \b
582
+ Parallel execution (use all CPU cores):
583
+ pyne optimize script.py data.ohlcv params.json -w 0
584
+ Sequential execution:
585
+ pyne optimize script.py data.ohlcv params.json -w 1
586
+ """
587
+
588
+ # --- Resolve script path ---
589
+ if script.suffix != ".py":
590
+ script = script.with_suffix(".py")
591
+ if len(script.parts) == 1:
592
+ script = app_state.scripts_dir / script
593
+ if not script.exists():
594
+ secho(f"Script file '{script}' not found!", fg="red", err=True)
595
+ raise Exit(1)
596
+
597
+ # --- Resolve data path ---
598
+ if data.suffix == "":
599
+ data = data.with_suffix(".ohlcv")
600
+ if len(data.parts) == 1:
601
+ data = app_state.data_dir / data
602
+ if not data.exists():
603
+ secho(f"Data file '{data}' not found!", fg="red", err=True)
604
+ raise Exit(1)
605
+
606
+ # --- Resolve params path ---
607
+ if len(params.parts) == 1:
608
+ # Try scripts dir first, then workdir
609
+ candidate = app_state.scripts_dir / params
610
+ if candidate.exists():
611
+ params = candidate
612
+ else:
613
+ candidate = app_state.workdir / params
614
+ if candidate.exists():
615
+ params = candidate
616
+ if not params.exists():
617
+ secho(f"Parameter file '{params}' not found!", fg="red", err=True)
618
+ raise Exit(1)
619
+
620
+ # --- Validate metric ---
621
+ if metric not in METRIC_ALIASES:
622
+ secho(
623
+ f"Unknown metric '{metric}'. Available: {', '.join(METRIC_ALIASES.keys())}",
624
+ fg="red", err=True,
625
+ )
626
+ raise Exit(1)
627
+ metric_attr = METRIC_ALIASES[metric]
628
+
629
+ # --- Load parameter specs ---
630
+ try:
631
+ with open(params, "r") as f:
632
+ params_dict = json.load(f)
633
+ except (json.JSONDecodeError, OSError) as e:
634
+ secho(f"Failed to read parameter file: {e}", fg="red", err=True)
635
+ raise Exit(1)
636
+
637
+ try:
638
+ specs = parse_param_specs(params_dict)
639
+ except ValueError as e:
640
+ secho(f"Invalid parameter specification: {e}", fg="red", err=True)
641
+ raise Exit(1)
642
+
643
+ is_explicit_mode = params_dict.get("_mode") == "explicit"
644
+ if is_explicit_mode:
645
+ combinations = generate_explicit_combinations(specs)
646
+ else:
647
+ combinations = generate_combinations(specs)
648
+ chunk_num, total_chunks = 0, 0
649
+ if chunk:
650
+ chunk_num, total_chunks = parse_chunk(chunk)
651
+ combinations = combinations[chunk_num - 1::total_chunks]
652
+ param_names = [s.name for s in specs]
653
+ total_combos = len(combinations)
654
+
655
+ # --- Resolve worker count ---
656
+ num_workers = workers if workers > 0 else max((cpu_count() or 1) // 2, 1)
657
+ num_workers = min(num_workers, total_combos)
658
+
659
+ # --- Load symbol info ---
660
+ syminfo_toml = data.with_suffix(".toml")
661
+ try:
662
+ syminfo = SymInfo.load_toml(syminfo_toml)
663
+ except FileNotFoundError:
664
+ secho(f"Symbol info file '{syminfo_toml}' not found!",
665
+ fg="red", err=True)
666
+ raise Exit(1)
667
+
668
+ # --- Pre-compute data parameters ---
669
+ with OHLCVReader(data) as reader:
670
+ start_ts = (reader.start_timestamp if not time_from
671
+ else int(time_from.replace(tzinfo=None).timestamp()))
672
+ end_ts = (reader.end_timestamp if not time_to
673
+ else int(time_to.replace(tzinfo=None).timestamp()))
674
+ size = reader.get_size(start_ts, end_ts)
675
+
676
+ # --- Print summary ---
677
+ secho(f"\nOptimizing: {script.name}", fg="cyan")
678
+ secho(f"Data: {data.name}", fg="cyan")
679
+ secho(f"Metric: {metric} ({metric_attr})", fg="cyan")
680
+ secho(f"Workers: {num_workers}", fg="cyan")
681
+ secho(f"Parameters: {len(specs)}", fg="cyan")
682
+ for spec in specs:
683
+ secho(f" {spec.name}: {len(spec.values)} values "
684
+ f"({spec.values[0]} .. {spec.values[-1]})", fg="cyan")
685
+ if chunk:
686
+ secho(f"Chunk: {chunk_num}/{total_chunks} ({total_combos} combinations)", fg="cyan")
687
+ secho(f"Total combinations: {total_combos}\n", fg="cyan")
688
+
689
+ # --- Lib directory ---
690
+ lib_dir = app_state.scripts_dir / "lib"
691
+ lib_dir_str = str(lib_dir) if lib_dir.exists() and lib_dir.is_dir() else None
692
+
693
+ # --- Set optimize-mode environment (for sequential path) ---
694
+ saved_env: dict[str, str | None] = {}
695
+ for key in ("PYNE_OPTIMIZE_MODE", "PYNE_SAVE_SCRIPT_TOML"):
696
+ saved_env[key] = os.environ.get(key)
697
+
698
+ # --- Resolve CSV path early (needed for resume check) ---
699
+ if chunk and not output:
700
+ csv_path = app_state.output_dir / f"{script.stem}_optimize_chunk{chunk_num}of{total_chunks}.csv"
701
+ else:
702
+ csv_path = output or (app_state.output_dir / f"{script.stem}_optimize.csv")
703
+
704
+ # --- Resume support: check for partial results ---
705
+ # Only resume from a CSV that belongs to THIS exact invocation. The run
706
+ # signature covers script, data, params and date window, so results from an
707
+ # unrelated run against the same script name are never silently reused.
708
+ stat_fields = [f.name for f in dataclass_fields(StrategyStatistics)]
709
+ run_sig = _run_signature(script, data, params_dict, time_from, time_to, chunk)
710
+ sig_path = csv_path.with_name(csv_path.name + ".runmeta")
711
+ can_resume = sig_path.exists() and sig_path.read_text().strip() == run_sig
712
+
713
+ if can_resume:
714
+ completed_keys, existing_rows, _ = _load_completed_keys(
715
+ csv_path, param_names,
716
+ )
717
+ else:
718
+ if csv_path.exists() and csv_path.stat().st_size > 0:
719
+ console.print(
720
+ "[yellow]Existing results CSV is from a different run "
721
+ "(script/data/params/date window changed) — starting fresh.[/yellow]"
722
+ )
723
+ completed_keys, existing_rows = set(), []
724
+ resumed_count = len(completed_keys)
725
+
726
+ if resumed_count > 0:
727
+ combinations = [
728
+ c for c in combinations
729
+ if _combo_to_key(c, param_names) not in completed_keys
730
+ ]
731
+ console.print(
732
+ f"[green]Resuming: {resumed_count} already completed, "
733
+ f"{len(combinations)} remaining of {total_combos} total[/green]\n"
734
+ )
735
+
736
+ remaining_count = len(combinations)
737
+ if remaining_count == 0:
738
+ console.print("[green]All combinations already completed![/green]")
739
+ # Re-sort and display existing results
740
+ _sort_and_rewrite_csv(csv_path, metric_attr, metric in MINIMIZE_METRICS)
741
+ console.print(f"[green]Results at: {csv_path}[/green]")
742
+ return
743
+
744
+ # Adjust worker count for remaining work
745
+ num_workers = min(num_workers, remaining_count)
746
+
747
+ # --- Open CSV for incremental writing ---
748
+ csv_header = param_names + stat_fields
749
+ write_header = resumed_count == 0
750
+ csv_file = open(
751
+ csv_path,
752
+ "a" if resumed_count > 0 else "w",
753
+ newline="",
754
+ )
755
+ csv_writer = csv.writer(csv_file)
756
+ if write_header:
757
+ csv_writer.writerow(csv_header)
758
+ csv_file.flush()
759
+ # Stamp this fresh CSV with its run signature so a later invocation can
760
+ # tell whether the file belongs to it before resuming from it.
761
+ sig_path.write_text(run_sig)
762
+
763
+ # --- Run optimization ---
764
+ results: list[tuple[dict[str, Any], StrategyStatistics]] = []
765
+ failed_count = 0
766
+
767
+ try:
768
+ with Progress(
769
+ SpinnerColumn(finished_text="[green]OK"),
770
+ TextColumn("{task.description}"),
771
+ BarColumn(),
772
+ MofNCompleteColumn(),
773
+ TextColumn("[cyan]{task.percentage:>3.0f}%"),
774
+ RateColumn(),
775
+ ) as progress:
776
+ desc = (
777
+ f"Optimizing ({resumed_count} resumed)..."
778
+ if resumed_count > 0
779
+ else "Optimizing..."
780
+ )
781
+ task = progress.add_task(desc, total=remaining_count)
782
+
783
+ if num_workers > 1:
784
+ # --- Parallel execution ---
785
+ # Each worker runs each combo independently through
786
+ # _run_backtest's ScriptRunner(inputs=...) path. The fork's
787
+ # cross-combo variable cache (fork-only `_var_cache` module,
788
+ # probed once and pickled to workers) is intentionally dropped:
789
+ # it required fork internals absent upstream, and mutating that
790
+ # shared cache across pooled workers was the source of the
791
+ # recorded `_vc` cross-worker contamination bug. Workers now
792
+ # recompute series per combo — slower, but correct and isolated.
793
+ os.environ["PYNE_OPTIMIZE_MODE"] = "1"
794
+ os.environ["PYNE_SAVE_SCRIPT_TOML"] = "0"
795
+ if lib_dir_str:
796
+ sys.path.insert(0, lib_dir_str)
797
+
798
+ cache_path = None
799
+ worker_combos = combinations
800
+
801
+ with ProcessPoolExecutor(
802
+ max_workers=num_workers,
803
+ initializer=_worker_init,
804
+ initargs=(
805
+ str(script), str(data), str(syminfo_toml),
806
+ start_ts, end_ts, size, lib_dir_str,
807
+ cache_path,
808
+ ),
809
+ ) as pool:
810
+ futures = [
811
+ pool.submit(_worker_run, combo)
812
+ for combo in worker_combos
813
+ ]
814
+ for future in as_completed(futures):
815
+ combo_params, stats, error = future.result()
816
+ if stats is not None:
817
+ results.append((combo_params, stats))
818
+ # Incremental CSV write
819
+ row = [combo_params.get(n, "") for n in param_names]
820
+ row += [getattr(stats, f) for f in stat_fields]
821
+ csv_writer.writerow(row)
822
+ csv_file.flush()
823
+ else:
824
+ failed_count += 1
825
+ if error:
826
+ console.print(
827
+ f"[yellow]Warning: {error}[/yellow]"
828
+ )
829
+ progress.update(task, advance=1)
830
+
831
+ if len(results) % 50 == 0:
832
+ gc.collect()
833
+
834
+ # Clean up cache temp file
835
+ if cache_path:
836
+ try:
837
+ os.unlink(cache_path)
838
+ except OSError:
839
+ pass
840
+
841
+ else:
842
+ # --- Sequential execution ---
843
+ os.environ["PYNE_OPTIMIZE_MODE"] = "1"
844
+ os.environ["PYNE_SAVE_SCRIPT_TOML"] = "0"
845
+
846
+ if lib_dir_str:
847
+ sys.path.insert(0, lib_dir_str)
848
+
849
+ with OHLCVReader(data) as reader:
850
+ for combo in combinations:
851
+ stats, error = _run_backtest(
852
+ script_path=script,
853
+ reader=reader,
854
+ syminfo=syminfo,
855
+ start_ts=start_ts,
856
+ end_ts=end_ts,
857
+ size=size,
858
+ params=combo,
859
+ )
860
+
861
+ if stats is not None:
862
+ results.append((combo, stats))
863
+ # Incremental CSV write
864
+ row = [combo.get(n, "") for n in param_names]
865
+ row += [getattr(stats, f) for f in stat_fields]
866
+ csv_writer.writerow(row)
867
+ csv_file.flush()
868
+ else:
869
+ failed_count += 1
870
+ if error:
871
+ console.print(
872
+ f"[yellow]Warning: {error}[/yellow]"
873
+ )
874
+
875
+ progress.update(task, advance=1)
876
+
877
+ if len(results) % 50 == 0:
878
+ gc.collect()
879
+
880
+ finally:
881
+ csv_file.close()
882
+
883
+ # Restore environment
884
+ for key, val in saved_env.items():
885
+ if val is None:
886
+ os.environ.pop(key, None)
887
+ else:
888
+ os.environ[key] = val
889
+
890
+ if lib_dir_str and lib_dir_str in sys.path:
891
+ sys.path.remove(lib_dir_str)
892
+
893
+ # --- Check results ---
894
+ total_successful = len(results) + resumed_count
895
+ if total_successful == 0:
896
+ secho("\nNo successful runs. Cannot produce results.", fg="red", err=True)
897
+ raise Exit(1)
898
+
899
+ # --- Sort the full CSV (resumed + new results) ---
900
+ is_minimize = metric in MINIMIZE_METRICS
901
+ _sort_and_rewrite_csv(csv_path, metric_attr, is_minimize)
902
+
903
+ # --- Sort in-memory results for display ---
904
+ results.sort(
905
+ key=lambda r: get_metric_value(r[1], metric_attr),
906
+ reverse=not is_minimize,
907
+ )
908
+
909
+ # --- Display top N table ---
910
+ display_count = min(top_n, len(results))
911
+ table = Table(
912
+ title=f"\nTop {display_count} Results (by {metric})",
913
+ show_header=True,
914
+ header_style="bold magenta",
915
+ )
916
+ table.add_column("#", style="dim", width=4)
917
+ for name in param_names:
918
+ table.add_column(name, style="cyan")
919
+ table.add_column(metric, justify="right", style="green bold")
920
+ table.add_column("Net P %", justify="right", style="green")
921
+ table.add_column("Sharpe", justify="right")
922
+ table.add_column("PF", justify="right")
923
+ table.add_column("Win %", justify="right")
924
+ table.add_column("Trades", justify="right")
925
+ table.add_column("Max DD %", justify="right", style="red")
926
+
927
+ for rank, (combo, stats) in enumerate(results[:display_count], 1):
928
+ row = [str(rank)]
929
+ row += [str(combo.get(name, "")) for name in param_names]
930
+ row.append(f"{get_metric_value(stats, metric_attr):.4f}")
931
+ row.append(f"{stats.net_profit_percent:.2f}")
932
+ row.append(f"{stats.sharpe_ratio:.3f}")
933
+ row.append(f"{stats.profit_factor:.3f}")
934
+ row.append(f"{stats.percent_profitable:.1f}")
935
+ row.append(str(stats.total_trades))
936
+ row.append(f"{stats.max_equity_drawdown_percent:.2f}")
937
+ table.add_row(*row)
938
+
939
+ console.print(table)
940
+
941
+ if failed_count > 0:
942
+ console.print(f"\n[yellow]{failed_count} combination(s) failed.[/yellow]")
943
+
944
+ console.print(
945
+ f"\n[dim]Total: {total_successful} successful / {total_combos} combinations"
946
+ f"{f' ({resumed_count} resumed)' if resumed_count > 0 else ''}[/dim]"
947
+ )
948
+
949
+ console.print(f"[green]Results saved to: {csv_path}[/green]")
950
+
951
+ # --- Write best TOML ---
952
+ if save_best and results:
953
+ best_params = results[0][0]
954
+ toml_path = script.with_name(script.stem + ".optimized.toml")
955
+ write_best_toml(best_params, toml_path)
956
+ console.print(f"[green]Best parameters saved to: {toml_path}[/green]")