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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- 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]")
|