desic-okx-agent 0.2.1 → 0.3.0

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 (236) hide show
  1. package/README.en.md +90 -10
  2. package/README.md +76 -10
  3. package/dist/account/private-websocket.js +4 -4
  4. package/dist/account/private-websocket.js.map +1 -1
  5. package/dist/account/service.d.ts +12 -1
  6. package/dist/account/service.js +18 -0
  7. package/dist/account/service.js.map +1 -1
  8. package/dist/bars/rate-limiter.d.ts +18 -0
  9. package/dist/bars/rate-limiter.js +84 -0
  10. package/dist/bars/rate-limiter.js.map +1 -0
  11. package/dist/bars/schema.d.ts +36 -0
  12. package/dist/bars/schema.js +134 -0
  13. package/dist/bars/schema.js.map +1 -0
  14. package/dist/bars/service.d.ts +60 -0
  15. package/dist/bars/service.js +120 -0
  16. package/dist/bars/service.js.map +1 -0
  17. package/dist/bars/store.d.ts +105 -0
  18. package/dist/bars/store.js +415 -0
  19. package/dist/bars/store.js.map +1 -0
  20. package/dist/bars/timeframe.d.ts +40 -0
  21. package/dist/bars/timeframe.js +146 -0
  22. package/dist/bars/timeframe.js.map +1 -0
  23. package/dist/bars/types.d.ts +68 -0
  24. package/dist/bars/types.js +13 -0
  25. package/dist/bars/types.js.map +1 -0
  26. package/dist/cli/data-render.d.ts +37 -0
  27. package/dist/cli/data-render.js +143 -0
  28. package/dist/cli/data-render.js.map +1 -0
  29. package/dist/cli/doctor.js +7 -3
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/index.js +757 -26
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/live-render.d.ts +24 -0
  34. package/dist/cli/live-render.js +85 -0
  35. package/dist/cli/live-render.js.map +1 -0
  36. package/dist/cli/range.d.ts +28 -0
  37. package/dist/cli/range.js +63 -0
  38. package/dist/cli/range.js.map +1 -0
  39. package/dist/cli/render.js +3 -0
  40. package/dist/cli/render.js.map +1 -1
  41. package/dist/cli/strategy-render.d.ts +36 -0
  42. package/dist/cli/strategy-render.js +391 -0
  43. package/dist/cli/strategy-render.js.map +1 -0
  44. package/dist/cli/width.d.ts +18 -0
  45. package/dist/cli/width.js +71 -0
  46. package/dist/cli/width.js.map +1 -0
  47. package/dist/config/loader.js +1 -1
  48. package/dist/config/schema.d.ts +7 -0
  49. package/dist/config/schema.js +24 -0
  50. package/dist/config/schema.js.map +1 -1
  51. package/dist/core/okx-client.d.ts +9 -1
  52. package/dist/core/okx-client.js +14 -5
  53. package/dist/core/okx-client.js.map +1 -1
  54. package/dist/i18n/locale.d.ts +24 -0
  55. package/dist/i18n/locale.js +65 -0
  56. package/dist/i18n/locale.js.map +1 -0
  57. package/dist/i18n/messages.d.ts +333 -0
  58. package/dist/i18n/messages.js +660 -0
  59. package/dist/i18n/messages.js.map +1 -0
  60. package/dist/live/account-snapshot.d.ts +30 -0
  61. package/dist/live/account-snapshot.js +130 -0
  62. package/dist/live/account-snapshot.js.map +1 -0
  63. package/dist/live/cutoff-queue.d.ts +42 -0
  64. package/dist/live/cutoff-queue.js +69 -0
  65. package/dist/live/cutoff-queue.js.map +1 -0
  66. package/dist/live/execution-key.d.ts +23 -0
  67. package/dist/live/execution-key.js +31 -0
  68. package/dist/live/execution-key.js.map +1 -0
  69. package/dist/live/failures.d.ts +37 -0
  70. package/dist/live/failures.js +57 -0
  71. package/dist/live/failures.js.map +1 -0
  72. package/dist/live/gates.d.ts +65 -0
  73. package/dist/live/gates.js +136 -0
  74. package/dist/live/gates.js.map +1 -0
  75. package/dist/live/loop.d.ts +56 -0
  76. package/dist/live/loop.js +197 -0
  77. package/dist/live/loop.js.map +1 -0
  78. package/dist/live/preconditions.d.ts +48 -0
  79. package/dist/live/preconditions.js +69 -0
  80. package/dist/live/preconditions.js.map +1 -0
  81. package/dist/live/reconcile.d.ts +46 -0
  82. package/dist/live/reconcile.js +104 -0
  83. package/dist/live/reconcile.js.map +1 -0
  84. package/dist/live/runner.d.ts +57 -0
  85. package/dist/live/runner.js +160 -0
  86. package/dist/live/runner.js.map +1 -0
  87. package/dist/live/schema.d.ts +18 -0
  88. package/dist/live/schema.js +91 -0
  89. package/dist/live/schema.js.map +1 -0
  90. package/dist/live/service.d.ts +144 -0
  91. package/dist/live/service.js +303 -0
  92. package/dist/live/service.js.map +1 -0
  93. package/dist/live/session.d.ts +85 -0
  94. package/dist/live/session.js +234 -0
  95. package/dist/live/session.js.map +1 -0
  96. package/dist/live/sizing.d.ts +62 -0
  97. package/dist/live/sizing.js +79 -0
  98. package/dist/live/sizing.js.map +1 -0
  99. package/dist/live/store.d.ts +123 -0
  100. package/dist/live/store.js +350 -0
  101. package/dist/live/store.js.map +1 -0
  102. package/dist/live/types.d.ts +82 -0
  103. package/dist/live/types.js +2 -0
  104. package/dist/live/types.js.map +1 -0
  105. package/dist/market/websocket.d.ts +16 -1
  106. package/dist/market/websocket.js +60 -5
  107. package/dist/market/websocket.js.map +1 -1
  108. package/dist/mcp/server.d.ts +1 -0
  109. package/dist/mcp/server.js +15 -1
  110. package/dist/mcp/server.js.map +1 -1
  111. package/dist/network/connectivity.d.ts +9 -1
  112. package/dist/network/connectivity.js +28 -1
  113. package/dist/network/connectivity.js.map +1 -1
  114. package/dist/report/chart-script.d.ts +12 -0
  115. package/dist/report/chart-script.js +146 -0
  116. package/dist/report/chart-script.js.map +1 -0
  117. package/dist/report/compare-html.d.ts +8 -0
  118. package/dist/report/compare-html.js +254 -0
  119. package/dist/report/compare-html.js.map +1 -0
  120. package/dist/report/compare-script.d.ts +12 -0
  121. package/dist/report/compare-script.js +109 -0
  122. package/dist/report/compare-script.js.map +1 -0
  123. package/dist/report/compare.d.ts +61 -0
  124. package/dist/report/compare.js +205 -0
  125. package/dist/report/compare.js.map +1 -0
  126. package/dist/report/fetch.d.ts +20 -0
  127. package/dist/report/fetch.js +56 -0
  128. package/dist/report/fetch.js.map +1 -0
  129. package/dist/report/html.d.ts +54 -0
  130. package/dist/report/html.js +641 -0
  131. package/dist/report/html.js.map +1 -0
  132. package/dist/report/open.d.ts +42 -0
  133. package/dist/report/open.js +114 -0
  134. package/dist/report/open.js.map +1 -0
  135. package/dist/runtime/server.d.ts +16 -1
  136. package/dist/runtime/server.js +112 -9
  137. package/dist/runtime/server.js.map +1 -1
  138. package/dist/setup/installer.d.ts +1 -0
  139. package/dist/setup/installer.js +8 -0
  140. package/dist/setup/installer.js.map +1 -1
  141. package/dist/setup/wizard.js +19 -22
  142. package/dist/setup/wizard.js.map +1 -1
  143. package/dist/strategy/constants.d.ts +23 -0
  144. package/dist/strategy/constants.js +24 -0
  145. package/dist/strategy/constants.js.map +1 -0
  146. package/dist/strategy/environment.d.ts +52 -0
  147. package/dist/strategy/environment.js +187 -0
  148. package/dist/strategy/environment.js.map +1 -0
  149. package/dist/strategy/instrument.d.ts +29 -0
  150. package/dist/strategy/instrument.js +39 -0
  151. package/dist/strategy/instrument.js.map +1 -0
  152. package/dist/strategy/optimize.d.ts +73 -0
  153. package/dist/strategy/optimize.js +113 -0
  154. package/dist/strategy/optimize.js.map +1 -0
  155. package/dist/strategy/parameter-space.d.ts +59 -0
  156. package/dist/strategy/parameter-space.js +221 -0
  157. package/dist/strategy/parameter-space.js.map +1 -0
  158. package/dist/strategy/python-bridge.d.ts +24 -0
  159. package/dist/strategy/python-bridge.js +114 -0
  160. package/dist/strategy/python-bridge.js.map +1 -0
  161. package/dist/strategy/schema.d.ts +9 -0
  162. package/dist/strategy/schema.js +91 -0
  163. package/dist/strategy/schema.js.map +1 -0
  164. package/dist/strategy/service.d.ts +138 -0
  165. package/dist/strategy/service.js +745 -0
  166. package/dist/strategy/service.js.map +1 -0
  167. package/dist/strategy/settings.d.ts +162 -0
  168. package/dist/strategy/settings.js +243 -0
  169. package/dist/strategy/settings.js.map +1 -0
  170. package/dist/strategy/store.d.ts +96 -0
  171. package/dist/strategy/store.js +367 -0
  172. package/dist/strategy/store.js.map +1 -0
  173. package/dist/strategy/templates.d.ts +11 -0
  174. package/dist/strategy/templates.js +134 -0
  175. package/dist/strategy/templates.js.map +1 -0
  176. package/dist/strategy/types.d.ts +111 -0
  177. package/dist/strategy/types.js +2 -0
  178. package/dist/strategy/types.js.map +1 -0
  179. package/dist/tools/catalog.d.ts +16 -0
  180. package/dist/tools/catalog.js +118 -17
  181. package/dist/tools/catalog.js.map +1 -1
  182. package/dist/trade/service.d.ts +12 -0
  183. package/dist/trade/service.js +24 -7
  184. package/dist/trade/service.js.map +1 -1
  185. package/dist/tui/app.d.ts +23 -0
  186. package/dist/tui/app.js +322 -0
  187. package/dist/tui/app.js.map +1 -0
  188. package/dist/tui/commands.d.ts +70 -0
  189. package/dist/tui/commands.js +313 -0
  190. package/dist/tui/commands.js.map +1 -0
  191. package/dist/tui/entries.d.ts +17 -0
  192. package/dist/tui/entries.js +24 -0
  193. package/dist/tui/entries.js.map +1 -0
  194. package/dist/tui/execute.d.ts +26 -0
  195. package/dist/tui/execute.js +664 -0
  196. package/dist/tui/execute.js.map +1 -0
  197. package/dist/tui/history.d.ts +17 -0
  198. package/dist/tui/history.js +48 -0
  199. package/dist/tui/history.js.map +1 -0
  200. package/dist/tui/index.d.ts +8 -0
  201. package/dist/tui/index.js +48 -0
  202. package/dist/tui/index.js.map +1 -0
  203. package/dist/tui/line-editor.d.ts +44 -0
  204. package/dist/tui/line-editor.js +98 -0
  205. package/dist/tui/line-editor.js.map +1 -0
  206. package/dist/tui/progress.d.ts +23 -0
  207. package/dist/tui/progress.js +46 -0
  208. package/dist/tui/progress.js.map +1 -0
  209. package/dist/tui/settings-editor.d.ts +18 -0
  210. package/dist/tui/settings-editor.js +115 -0
  211. package/dist/tui/settings-editor.js.map +1 -0
  212. package/docs/live-trading.md +455 -0
  213. package/docs/strategy-research.md +597 -0
  214. package/package.json +10 -1
  215. package/python/desic_strategy/__init__.py +34 -0
  216. package/python/desic_strategy/actions.py +158 -0
  217. package/python/desic_strategy/context.py +164 -0
  218. package/python/desic_strategy/engine.py +614 -0
  219. package/python/desic_strategy/indicators.py +159 -0
  220. package/python/desic_strategy/live.py +253 -0
  221. package/python/desic_strategy/policy.py +193 -0
  222. package/python/desic_strategy/portfolio.py +152 -0
  223. package/python/desic_strategy/report.py +319 -0
  224. package/python/desic_strategy/runner.py +574 -0
  225. package/python/desic_strategy/timeframe.py +150 -0
  226. package/python/main.py +18 -0
  227. package/skills/okx-live-trading/SKILL.md +117 -0
  228. package/skills/okx-live-trading/agents/openai.yaml +9 -0
  229. package/skills/okx-live-trading/references/lifecycle.md +128 -0
  230. package/skills/okx-strategy-research/SKILL.md +113 -0
  231. package/skills/okx-strategy-research/agents/openai.yaml +9 -0
  232. package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
  233. package/skills/okx-strategy-research/references/field-traps.md +142 -0
  234. package/skills/okx-strategy-research/references/python-api.md +121 -0
  235. package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
  236. package/skills/okx-trading/SKILL.md +11 -10
@@ -0,0 +1,745 @@
1
+ import crypto from "node:crypto";
2
+ import os from "node:os";
3
+ import { RuntimeError } from "../core/errors.js";
4
+ import { resultMeta } from "../core/types.js";
5
+ import { ONE_MINUTE_MS, alignMinuteOpen, expectedBarCount } from "../bars/types.js";
6
+ import { isSupportedInterval } from "../bars/timeframe.js";
7
+ import { engineRoot, pythonStatus, setupPythonEnvironment } from "./environment.js";
8
+ import { loadContractSpec } from "./instrument.js";
9
+ import { loadSettings, resetSettings, saveSettings, withSetting } from "./settings.js";
10
+ import { invokePython } from "./python-bridge.js";
11
+ import { requireRun } from "./store.js";
12
+ import { DEFAULT_BUDGET, MAX_BUDGET, countCombinations, describeSpace, parseParameterSpace, sampleCandidates } from "./parameter-space.js";
13
+ import { rankCandidate, splitWindow, verdictFor } from "./optimize.js";
14
+ import { buildComparison } from "../report/compare.js";
15
+ import { MAX_PAGE_ROWS } from "./constants.js";
16
+ /**
17
+ * A backtest may not end inside the last hour.
18
+ *
19
+ * The most recent minutes are the ones an exchange is most likely to revise, so
20
+ * excluding them keeps a run reproducible.
21
+ */
22
+ const MINIMUM_END_LAG_MS = 60 * 60_000;
23
+ const MAX_EVALUATION_DAYS = 365;
24
+ const MAX_BACKTEST_BARS = 600_000;
25
+ const DEFAULT_EVALUATION_DAYS = 30;
26
+ const MINIMUM_PRELOAD_BARS = 2;
27
+ export class StrategyService {
28
+ store;
29
+ bars;
30
+ market;
31
+ maxWorkers;
32
+ now;
33
+ injectedSettings;
34
+ active = new Map();
35
+ queue = [];
36
+ running = 0;
37
+ constructor(store, bars,
38
+ /**
39
+ * Source of contract specifications. Optional so a test can construct the
40
+ * service without a market runtime; a run then fails with a clear message
41
+ * rather than falling back to a guessed contract size.
42
+ */
43
+ market, maxWorkers = defaultWorkerCount(), now = Date.now,
44
+ /** Test seam: bypasses the settings file so a run is reproducible. */
45
+ injectedSettings) {
46
+ this.store = store;
47
+ this.bars = bars;
48
+ this.market = market;
49
+ this.maxWorkers = maxWorkers;
50
+ this.now = now;
51
+ this.injectedSettings = injectedSettings;
52
+ }
53
+ /** Reports interpreter availability without changing anything. */
54
+ async environment() {
55
+ return { data: await pythonStatus(), meta: resultMeta({ source: "derived" }) };
56
+ }
57
+ async setupEnvironment(onLog) {
58
+ const result = await setupPythonEnvironment(onLog);
59
+ const status = await pythonStatus();
60
+ return {
61
+ data: { ...result, status },
62
+ meta: resultMeta({ source: "derived" })
63
+ };
64
+ }
65
+ /** Saved settings, or the injected ones when a caller supplied them. */
66
+ settings() {
67
+ return this.injectedSettings ?? loadSettings();
68
+ }
69
+ readSettings() {
70
+ return { data: this.settings(), meta: resultMeta({ source: "derived" }) };
71
+ }
72
+ /**
73
+ * Applies saved settings. Unknown keys and out-of-range values are rejected
74
+ * rather than ignored: a silently dropped change means the next run uses
75
+ * assumptions the caller believes it changed.
76
+ */
77
+ updateSettings(values, reset = false) {
78
+ let current = reset ? resetSettings() : this.settings();
79
+ for (const [key, value] of Object.entries(values)) {
80
+ current = withSetting(current, key, value).settings;
81
+ }
82
+ saveSettings(current);
83
+ return { data: current, meta: resultMeta({ source: "derived" }) };
84
+ }
85
+ /** Static policy check. Fast enough to run on every edit. */
86
+ async validate(source) {
87
+ const status = await this.requireInterpreter();
88
+ const response = await invokePython({
89
+ interpreter: status.interpreter,
90
+ engineRoot: status.engineRoot,
91
+ request: { command: "validate", source },
92
+ timeoutMs: 30_000
93
+ });
94
+ if (!response.ok)
95
+ throw pythonError(response);
96
+ return {
97
+ data: {
98
+ valid: Boolean(response.valid),
99
+ violations: response.violations ?? []
100
+ },
101
+ meta: resultMeta({ source: "derived" })
102
+ };
103
+ }
104
+ /**
105
+ * Queues a backtest and returns immediately.
106
+ *
107
+ * A run can take minutes, so the caller polls `getRun` rather than holding a
108
+ * request open and risking a client-side timeout.
109
+ */
110
+ async startBacktest(request) {
111
+ if (typeof request.source !== "string" || !request.source.trim()) {
112
+ throw new RuntimeError("VALIDATION", "A backtest requires strategy source");
113
+ }
114
+ const status = await this.requireInterpreter();
115
+ const validation = await this.validate(request.source);
116
+ if (!validation.data.valid) {
117
+ throw new RuntimeError("VALIDATION", "Strategy source failed the static policy check", false, {
118
+ violations: validation.data.violations
119
+ });
120
+ }
121
+ const id = this.store.createRun({
122
+ kind: "backtest",
123
+ strategyName: request.strategyName?.trim() || "strategy",
124
+ source: request.source,
125
+ instId: request.instId,
126
+ request
127
+ });
128
+ const controller = new AbortController();
129
+ this.active.set(id, { id, controller });
130
+ this.enqueue(async () => {
131
+ try {
132
+ await this.execute(id, request, status, controller.signal);
133
+ }
134
+ finally {
135
+ this.active.delete(id);
136
+ }
137
+ });
138
+ return { data: { runId: id, status: "queued" }, meta: resultMeta({ source: "derived" }) };
139
+ }
140
+ /**
141
+ * Queues a parameter search and returns immediately.
142
+ *
143
+ * The space is validated against the strategy's own declared parameters before
144
+ * anything is queued, so a range naming a parameter the strategy never reads
145
+ * fails at the prompt rather than after every candidate has run identically.
146
+ */
147
+ async startOptimize(request) {
148
+ if (typeof request.source !== "string" || !request.source.trim()) {
149
+ throw new RuntimeError("VALIDATION", "An optimization requires strategy source");
150
+ }
151
+ // Host-side checks first. They need no interpreter, so a malformed space, an
152
+ // impossible budget, or too short a window is rejected in microseconds instead
153
+ // of after a process spawn. Everything here is also resolved before queuing:
154
+ // discovering any of it later would leave a failed run where a rejected
155
+ // request belonged.
156
+ const space = parseParameterSpace(request.space, request.params ?? {});
157
+ const budget = resolveBudget(request.budget);
158
+ const candidates = sampleCandidates(space, budget);
159
+ const window = this.resolveWindow(request);
160
+ const split = splitWindow(window.evaluationStart, window.evaluationEnd);
161
+ const status = await this.requireInterpreter();
162
+ const validation = await this.validate(request.source);
163
+ if (!validation.data.valid) {
164
+ throw new RuntimeError("VALIDATION", "Strategy source failed the static policy check", false, {
165
+ violations: validation.data.violations
166
+ });
167
+ }
168
+ const id = this.store.createRun({
169
+ kind: "optimize",
170
+ strategyName: request.strategyName?.trim() || "strategy",
171
+ source: request.source,
172
+ instId: request.instId,
173
+ request: { ...request, space, budget, resolvedCandidates: candidates.length }
174
+ });
175
+ this.store.seedCandidates(id, candidates);
176
+ const controller = new AbortController();
177
+ this.active.set(id, { id, controller });
178
+ this.enqueue(async () => {
179
+ try {
180
+ await this.executeOptimize(id, request, space, candidates, split, status, controller.signal);
181
+ }
182
+ finally {
183
+ this.active.delete(id);
184
+ }
185
+ });
186
+ return { data: { runId: id, status: "queued" }, meta: resultMeta({ source: "derived" }) };
187
+ }
188
+ /**
189
+ * Ranked candidates for one optimize run.
190
+ *
191
+ * Ranking is by the held-back segment, never the searched one. The winner's
192
+ * ranking basis is reported alongside, because Calmar is unavailable on short
193
+ * windows and a column that silently changes meaning is worse than a labelled
194
+ * one.
195
+ */
196
+ optimization(id, top = 20) {
197
+ const run = requireRun(this.store, id);
198
+ if (run.kind !== "optimize") {
199
+ throw new RuntimeError("VALIDATION", `${id} is a ${run.kind} run, not an optimization`);
200
+ }
201
+ const stored = (this.store.runRequest(id) ?? {});
202
+ const space = stored.space ?? {};
203
+ const candidates = this.store.candidates(id, top);
204
+ const counts = this.store.candidateCounts(id);
205
+ const leader = candidates.find((candidate) => candidate.status === "completed");
206
+ return {
207
+ data: {
208
+ run,
209
+ space,
210
+ budget: stored.budget ?? counts.total,
211
+ combinations: Object.keys(space).length ? countCombinations(space) : counts.total,
212
+ split: splitFromAssumptions(run.assumptions),
213
+ candidates,
214
+ completed: counts.completed,
215
+ failed: counts.failed,
216
+ rankBasis: leader?.rankBasis ?? null
217
+ },
218
+ meta: resultMeta({ source: "sqlite" })
219
+ };
220
+ }
221
+ /**
222
+ * Compares completed runs.
223
+ *
224
+ * The equity curves are read whole here rather than paged: a comparison is
225
+ * summary-level, and the curves are downsampled before they leave. The response
226
+ * carries metrics, the assumptions that differ, and the warnings — not the
227
+ * curves, which would bury an agent's context for no benefit.
228
+ */
229
+ compareRuns(runIds) {
230
+ const runs = runIds.map((runId) => ({
231
+ run: requireRun(this.store, runId),
232
+ // A run that never completed has no curve. It stays in the comparison so the
233
+ // report can say why, rather than silently dropping it.
234
+ equity: this.store.equity(runId, 0, MAX_PAGE_ROWS).rows
235
+ }));
236
+ const { series: _series, ...rest } = buildComparison({ runs });
237
+ return { data: rest, meta: resultMeta({ source: "sqlite" }) };
238
+ }
239
+ getRun(id) {
240
+ return { data: requireRun(this.store, id), meta: resultMeta({ source: "sqlite" }) };
241
+ }
242
+ listRuns(options = {}) {
243
+ return { data: this.store.listRuns(options), meta: resultMeta({ source: "sqlite" }) };
244
+ }
245
+ equity(id, offset, limit) {
246
+ requireRun(this.store, id);
247
+ return { data: this.store.equity(id, offset, limit), meta: resultMeta({ source: "sqlite" }) };
248
+ }
249
+ trades(id, offset, limit) {
250
+ requireRun(this.store, id);
251
+ return { data: this.store.trades(id, offset, limit), meta: resultMeta({ source: "sqlite" }) };
252
+ }
253
+ actions(id, offset, limit) {
254
+ requireRun(this.store, id);
255
+ return { data: this.store.actions(id, offset, limit), meta: resultMeta({ source: "sqlite" }) };
256
+ }
257
+ cancel(id) {
258
+ requireRun(this.store, id);
259
+ const active = this.active.get(id);
260
+ active?.controller.abort();
261
+ return { data: { cancelled: this.store.cancelRun(id) }, meta: resultMeta({ source: "derived" }) };
262
+ }
263
+ deleteRun(id) {
264
+ requireRun(this.store, id);
265
+ this.active.get(id)?.controller.abort();
266
+ return { data: { deleted: this.store.deleteRun(id) }, meta: resultMeta({ source: "derived" }) };
267
+ }
268
+ /**
269
+ * Resolves the exact bar window a request describes.
270
+ *
271
+ * Preloaded history is context only: it warms up indicators and supplies the
272
+ * first decision point, and is excluded from every reported statistic.
273
+ */
274
+ resolveWindow(request) {
275
+ const latestAllowed = alignMinuteOpen(this.now() - MINIMUM_END_LAG_MS);
276
+ const evaluationEnd = request.toMs === undefined ? latestAllowed : Math.min(alignMinuteOpen(request.toMs), latestAllowed);
277
+ const evaluationStart = request.fromMs === undefined
278
+ ? evaluationEnd - (request.days ?? DEFAULT_EVALUATION_DAYS) * 1_440 * ONE_MINUTE_MS
279
+ : alignMinuteOpen(request.fromMs);
280
+ if (evaluationEnd <= evaluationStart) {
281
+ throw new RuntimeError("VALIDATION", "The backtest range must end after it starts", false, {
282
+ evaluationStart,
283
+ evaluationEnd,
284
+ latestAllowed
285
+ });
286
+ }
287
+ const evaluationDays = (evaluationEnd - evaluationStart) / 86_400_000;
288
+ if (evaluationDays > MAX_EVALUATION_DAYS) {
289
+ throw new RuntimeError("VALIDATION", `The backtest range covers ${evaluationDays.toFixed(1)} days, above the ${MAX_EVALUATION_DAYS}-day limit`);
290
+ }
291
+ const preloadBars = Math.max(MINIMUM_PRELOAD_BARS, Math.floor(request.preloadBars ?? 120));
292
+ const preloadStart = evaluationStart - preloadBars * ONE_MINUTE_MS;
293
+ const totalBars = expectedBarCount(preloadStart, evaluationEnd);
294
+ if (totalBars > MAX_BACKTEST_BARS) {
295
+ throw new RuntimeError("VALIDATION", `The window covers ${totalBars} bars including preload, above the ${MAX_BACKTEST_BARS} limit`);
296
+ }
297
+ return { evaluationStart, evaluationEnd, preloadStart, preloadBars, totalBars };
298
+ }
299
+ async execute(id, request, status, signal) {
300
+ try {
301
+ if (signal.aborted)
302
+ return;
303
+ const window = this.resolveWindow(request);
304
+ // Fails closed on any absent minute: a run over a window with a hole
305
+ // would report performance for a timeline that never happened.
306
+ const bars = this.bars.store.loadWindow(request.instId, window.preloadStart, window.evaluationEnd);
307
+ const first = bars[window.preloadBars];
308
+ if (!first || first.openTimeMs !== window.evaluationStart) {
309
+ throw new RuntimeError("STALE_DATA", "Local history does not cover the requested preloaded range before the evaluation start", true);
310
+ }
311
+ // Position sizing depends entirely on the contract specification, so it is
312
+ // read from the exchange rather than assumed. Guessing it would produce a
313
+ // run whose sizes are wrong by the factor of the guess while every metric
314
+ // still looked plausible.
315
+ const contract = await this.contractSpec(request.instId);
316
+ const dataSnapshotId = snapshotId(bars, this.bars.store.environment);
317
+ const engineRequest = buildEngineRequest(request, bars, window.preloadBars, contract, this.settings());
318
+ // Record the assumptions this run actually used, not the ones saved now:
319
+ // settings can change between runs, and a report that cannot say what it
320
+ // assumed is not comparable to any other report.
321
+ this.store.markRunning(id, bars.length, window.preloadBars, dataSnapshotId, resolvedAssumptions(engineRequest, contract, window));
322
+ const startedAt = this.now();
323
+ const response = await invokePython({
324
+ interpreter: status.interpreter,
325
+ engineRoot: status.engineRoot,
326
+ request: engineRequest,
327
+ signal,
328
+ onProgress: (progress) => {
329
+ const elapsed = Math.max(1, this.now() - startedAt);
330
+ const remaining = Math.max(0, progress.total - progress.done);
331
+ const rate = progress.done / elapsed;
332
+ this.store.updateProgress(id, progress.pct, rate > 0 ? Math.round(remaining / rate) : null);
333
+ }
334
+ });
335
+ if (signal.aborted)
336
+ return;
337
+ if (!response.ok)
338
+ throw pythonError(response);
339
+ const report = response.report;
340
+ this.store.completeRun(id, report.metrics, report.equity, report.trades, report.actions);
341
+ }
342
+ catch (error) {
343
+ if (signal.aborted) {
344
+ this.store.cancelRun(id);
345
+ return;
346
+ }
347
+ const message = error instanceof Error ? error.message : "The backtest failed";
348
+ this.store.failRun(id, message);
349
+ }
350
+ }
351
+ /**
352
+ * Runs one parameter search.
353
+ *
354
+ * The bars are loaded once and every candidate is measured against that single
355
+ * array. Batches go out one per worker rather than one per candidate: the
356
+ * payload is a small share of a candidate's cost, so nothing is gained by
357
+ * splitting further, while a process per candidate would hold the same
358
+ * multi-megabyte array once per candidate.
359
+ */
360
+ async executeOptimize(id, request, space, candidates, split, status, signal) {
361
+ try {
362
+ if (signal.aborted)
363
+ return;
364
+ const window = this.resolveWindow(request);
365
+ const bars = this.bars.store.loadWindow(request.instId, window.preloadStart, window.evaluationEnd);
366
+ const first = bars[window.preloadBars];
367
+ if (!first || first.openTimeMs !== window.evaluationStart) {
368
+ throw new RuntimeError("STALE_DATA", "Local history does not cover the requested preloaded range before the evaluation start", true);
369
+ }
370
+ const contract = await this.contractSpec(request.instId);
371
+ const settings = this.settings();
372
+ const engineRequest = buildEngineRequest(request, bars, window.preloadBars, contract, settings);
373
+ // The validation segment gets its own warm-up prefix taken from the bars
374
+ // immediately before it — which are training bars. That is legitimate:
375
+ // warm-up supplies indicator state a live strategy would also have, and it
376
+ // produces no equity point, trade, or statistic.
377
+ const segments = resolveSegments(bars, window.preloadBars, split);
378
+ const assumptions = {
379
+ ...resolvedAssumptions(engineRequest, contract, window),
380
+ space: describeSpace(space),
381
+ budget: candidates.length,
382
+ combinations: countCombinations(space),
383
+ trainStartMs: split.trainStart,
384
+ trainEndMs: split.trainEnd,
385
+ validationStartMs: split.validationStart,
386
+ validationEndMs: split.validationEnd,
387
+ trainBars: split.trainBars,
388
+ validationBars: split.validationBars
389
+ };
390
+ this.store.markRunning(id, bars.length, window.preloadBars, snapshotId(bars, this.bars.store.environment), assumptions);
391
+ const batches = splitIntoBatches(candidates, this.maxWorkers);
392
+ const startedAt = this.now();
393
+ const progressByBatch = new Map();
394
+ await Promise.all(batches.map(async (batch, batchIndex) => {
395
+ if (signal.aborted)
396
+ return;
397
+ const payload = {
398
+ ...engineRequest,
399
+ command: "optimize",
400
+ segments,
401
+ // The candidate's values are layered over the strategy's own defaults,
402
+ // so a parameter the space does not vary keeps its declared value
403
+ // rather than disappearing.
404
+ candidates: batch.map((item) => ({ index: item.index, params: { ...request.params, ...item.params } }))
405
+ };
406
+ // Marked before the batch starts so a stalled worker shows its
407
+ // candidates as running rather than leaving them queued.
408
+ for (const item of batch)
409
+ this.store.markCandidateRunning(id, item.index);
410
+ try {
411
+ const response = await invokePython({
412
+ interpreter: status.interpreter,
413
+ engineRoot: status.engineRoot,
414
+ request: payload,
415
+ signal,
416
+ onProgress: (progress) => {
417
+ progressByBatch.set(batchIndex, { done: progress.done, total: progress.total });
418
+ let done = 0;
419
+ let total = 0;
420
+ for (const entry of progressByBatch.values()) {
421
+ done += entry.done;
422
+ total += entry.total;
423
+ }
424
+ // Scaled against every candidate, not this batch: a per-batch
425
+ // percentage would jump backwards each time a batch reported.
426
+ const overall = candidates.length * segments.length;
427
+ const pct = overall === 0 ? 0 : Math.min(100, (done / Math.max(total, overall)) * 100);
428
+ const elapsed = Math.max(1, this.now() - startedAt);
429
+ const rate = done / elapsed;
430
+ this.store.updateProgress(id, pct, rate > 0 ? Math.round((overall - done) / rate) : null);
431
+ }
432
+ });
433
+ if (signal.aborted)
434
+ return;
435
+ if (!response.ok)
436
+ throw pythonError(response);
437
+ this.recordBatch(id, batch, response.results);
438
+ }
439
+ catch (error) {
440
+ if (signal.aborted)
441
+ return;
442
+ // A failed batch marks only its own candidates; the other workers keep
443
+ // their results, so a partial search still reports what it measured.
444
+ const message = error instanceof Error ? error.message : "The candidate batch failed";
445
+ for (const item of batch)
446
+ this.store.failCandidate(id, item.index, message);
447
+ }
448
+ }));
449
+ if (signal.aborted)
450
+ return;
451
+ const counts = this.store.candidateCounts(id);
452
+ if (counts.completed === 0) {
453
+ this.store.failRun(id, `Every one of the ${counts.total} candidates failed`);
454
+ return;
455
+ }
456
+ const ranked = this.store.candidates(id, 1);
457
+ const best = ranked[0];
458
+ // The run's own metrics are the winner's validation metrics: the figure a
459
+ // reader should quote is the one measured out of sample.
460
+ this.store.completeRun(id, {
461
+ ...(best?.validation ?? {}),
462
+ candidatesEvaluated: counts.completed,
463
+ candidatesFailed: counts.failed,
464
+ bestParams: best?.params ?? null,
465
+ bestRankScore: best?.rankScore ?? null,
466
+ bestRankBasis: best?.rankBasis ?? null,
467
+ bestVerdict: best?.verdict ?? null,
468
+ bestTrainReturnPct: best?.train?.returnPct ?? null
469
+ }, [], [], []);
470
+ }
471
+ catch (error) {
472
+ if (signal.aborted) {
473
+ this.store.cancelRun(id);
474
+ return;
475
+ }
476
+ const message = error instanceof Error ? error.message : "The optimization failed";
477
+ this.store.failRun(id, message);
478
+ }
479
+ }
480
+ /** Stores one batch's results, keyed by the candidate index the host assigned. */
481
+ recordBatch(id, batch, results) {
482
+ const rows = Array.isArray(results) ? results : [];
483
+ const byIndex = new Map();
484
+ for (const row of rows) {
485
+ if (row && typeof row === "object") {
486
+ const record = row;
487
+ byIndex.set(Number(record.index), record);
488
+ }
489
+ }
490
+ for (const item of batch) {
491
+ const row = byIndex.get(item.index);
492
+ if (!row) {
493
+ // A silent omission would leave the candidate queued forever, which reads
494
+ // as a stalled run rather than a missing result.
495
+ this.store.failCandidate(id, item.index, "The engine returned no result for this candidate");
496
+ continue;
497
+ }
498
+ if (row.ok !== true) {
499
+ this.store.failCandidate(id, item.index, String(row.error ?? "The candidate failed"));
500
+ continue;
501
+ }
502
+ const metrics = (row.metrics ?? {});
503
+ const train = metrics.train;
504
+ const validation = metrics.validation;
505
+ if (!train || !validation) {
506
+ this.store.failCandidate(id, item.index, "The engine did not measure both segments");
507
+ continue;
508
+ }
509
+ const rank = rankCandidate(validation);
510
+ this.store.completeCandidate(id, item.index, {
511
+ train,
512
+ validation,
513
+ rankScore: rank.score,
514
+ rankBasis: rank.basis,
515
+ verdict: verdictFor(train, validation)
516
+ });
517
+ }
518
+ }
519
+ /**
520
+ * Reads the instrument's contract specification.
521
+ *
522
+ * Refuses rather than defaulting: a backtest sized off a guessed contract value
523
+ * reports confident numbers for positions that could never have been taken.
524
+ */
525
+ async contractSpec(instId) {
526
+ if (!this.market) {
527
+ throw new RuntimeError("CAPABILITY_UNAVAILABLE", `No market runtime is available to read the contract specification for ${instId}`);
528
+ }
529
+ return loadContractSpec(this.market, instId);
530
+ }
531
+ async requireInterpreter() {
532
+ const status = await pythonStatus();
533
+ if (!status.interpreter) {
534
+ throw new RuntimeError("CAPABILITY_UNAVAILABLE", status.problems.join("; ") || "Python is unavailable");
535
+ }
536
+ return { interpreter: status.interpreter, engineRoot: status.engineRoot || engineRoot() };
537
+ }
538
+ enqueue(task) {
539
+ this.queue.push(task);
540
+ this.drain();
541
+ }
542
+ drain() {
543
+ while (this.running < this.maxWorkers && this.queue.length > 0) {
544
+ const task = this.queue.shift();
545
+ this.running += 1;
546
+ void task().finally(() => {
547
+ this.running -= 1;
548
+ this.drain();
549
+ });
550
+ }
551
+ }
552
+ }
553
+ function buildEngineRequest(request, bars, preloadBars, contract, settings) {
554
+ const budgetUsdt = request.entryBudgetUsdt ?? settings.entryBudgetUsdt;
555
+ const intervals = ["1m"];
556
+ for (const interval of request.intervals ?? []) {
557
+ if (!isSupportedInterval(interval)) {
558
+ throw new RuntimeError("VALIDATION", `Unsupported interval '${interval}'`);
559
+ }
560
+ if (!intervals.includes(interval))
561
+ intervals.push(interval);
562
+ }
563
+ return {
564
+ command: "backtest",
565
+ source: request.source,
566
+ params: request.params ?? {},
567
+ intervals,
568
+ preloadBars,
569
+ initialEquityUsdt: request.initialEquityUsdt ?? settings.initialEquityUsdt,
570
+ closeAtEnd: request.closeAtEnd ?? settings.closeAtEnd,
571
+ instrument: {
572
+ instId: request.instId,
573
+ contractValue: contract.contractValue,
574
+ contractMultiplier: contract.contractMultiplier,
575
+ lotSize: contract.lotSize,
576
+ minSize: contract.minSize,
577
+ tickSize: contract.tickSize
578
+ },
579
+ sizing: {
580
+ leverage: request.leverage ?? settings.leverage,
581
+ marginSafetyMultiplier: request.marginSafetyMultiplier ?? settings.marginSafetyMultiplier,
582
+ // A fixed budget of zero means "use the percentage"; a saved zero must not
583
+ // become a literal zero-size budget.
584
+ ...(budgetUsdt > 0 ? { entryBudgetUsdt: budgetUsdt } : {}),
585
+ entryBudgetPct: request.entryBudgetPct ?? settings.entryBudgetPct
586
+ },
587
+ costs: {
588
+ takerFeeRate: request.takerFeeRate ?? settings.takerFeeRate,
589
+ makerFeeRate: request.makerFeeRate ?? settings.makerFeeRate,
590
+ entrySlippageBps: request.entrySlippageBps ?? settings.entrySlippageBps,
591
+ exitSlippageBps: request.exitSlippageBps ?? settings.exitSlippageBps
592
+ },
593
+ bars: bars.map((bar) => ({
594
+ openTimeMs: bar.openTimeMs,
595
+ closeTimeMs: bar.closeTimeMs,
596
+ open: Number(bar.open),
597
+ high: Number(bar.high),
598
+ low: Number(bar.low),
599
+ close: Number(bar.close),
600
+ volume: Number(bar.volume)
601
+ }))
602
+ };
603
+ }
604
+ /**
605
+ * The assumptions a run used, taken from the request the engine received.
606
+ *
607
+ * Derived from that object rather than from the settings file, so a record can
608
+ * never describe a different run than the one that happened.
609
+ */
610
+ function resolvedAssumptions(engineRequest, contract, window) {
611
+ const payload = engineRequest;
612
+ return {
613
+ initialEquityUsdt: payload.initialEquityUsdt,
614
+ closeAtEnd: payload.closeAtEnd,
615
+ leverage: payload.sizing.leverage,
616
+ marginSafetyMultiplier: payload.sizing.marginSafetyMultiplier,
617
+ entryBudgetUsdt: payload.sizing.entryBudgetUsdt ?? null,
618
+ entryBudgetPct: payload.sizing.entryBudgetPct,
619
+ takerFeeRate: payload.costs.takerFeeRate,
620
+ makerFeeRate: payload.costs.makerFeeRate,
621
+ entrySlippageBps: payload.costs.entrySlippageBps,
622
+ exitSlippageBps: payload.costs.exitSlippageBps,
623
+ preloadBars: window.preloadBars,
624
+ evaluationStartMs: window.evaluationStart,
625
+ evaluationEndMs: window.evaluationEnd,
626
+ intervals: payload.intervals,
627
+ params: payload.params,
628
+ contract: {
629
+ contractValue: contract.contractValue,
630
+ contractMultiplier: contract.contractMultiplier,
631
+ lotSize: contract.lotSize,
632
+ minSize: contract.minSize,
633
+ tickSize: contract.tickSize,
634
+ contractValueCurrency: contract.contractValueCurrency
635
+ }
636
+ };
637
+ }
638
+ function resolveBudget(requested) {
639
+ if (requested === undefined)
640
+ return DEFAULT_BUDGET;
641
+ if (!Number.isInteger(requested) || requested < 2) {
642
+ throw new RuntimeError("VALIDATION", "The candidate budget must be a whole number of at least 2");
643
+ }
644
+ if (requested > MAX_BUDGET) {
645
+ throw new RuntimeError("VALIDATION", `A budget of ${requested} is above the ${MAX_BUDGET} limit. Narrow the space instead of searching longer.`);
646
+ }
647
+ return requested;
648
+ }
649
+ /**
650
+ * Converts the two window segments into index ranges over the loaded bars.
651
+ *
652
+ * Indices rather than timestamps, because the engine receives one array and each
653
+ * segment is a slice of it. Each slice carries its own warm-up prefix, so the
654
+ * strategy enters the validation segment with the indicator state it would have
655
+ * had, and the two measurements start from the same footing.
656
+ */
657
+ function resolveSegments(bars, preloadBars, split) {
658
+ const indexOf = (openTimeMs) => {
659
+ const index = Math.round((openTimeMs - bars[0].openTimeMs) / ONE_MINUTE_MS);
660
+ if (index < 0 || index >= bars.length || bars[index].openTimeMs !== openTimeMs) {
661
+ throw new RuntimeError("STALE_DATA", `The loaded window does not contain the minute ${openTimeMs}`, true);
662
+ }
663
+ return index;
664
+ };
665
+ const trainStart = indexOf(split.trainStart);
666
+ const trainEnd = indexOf(split.trainEnd);
667
+ const validationStart = indexOf(split.validationStart);
668
+ const validationEnd = indexOf(split.validationEnd);
669
+ // The validation prefix cannot reach before the array's start, so it shrinks
670
+ // rather than silently sliding the segment's first evaluated minute later.
671
+ const validationPreload = Math.min(preloadBars, validationStart);
672
+ if (validationPreload < 2) {
673
+ throw new RuntimeError("VALIDATION", "There are too few bars before the validation segment to warm up indicators");
674
+ }
675
+ return [
676
+ { name: "train", fromIndex: trainStart - preloadBars, toIndex: trainEnd, preloadBars },
677
+ {
678
+ name: "validation",
679
+ fromIndex: validationStart - validationPreload,
680
+ toIndex: validationEnd,
681
+ preloadBars: validationPreload
682
+ }
683
+ ];
684
+ }
685
+ /**
686
+ * Deals candidates across the worker pool.
687
+ *
688
+ * Round-robin rather than contiguous chunks: candidates near each other in the
689
+ * sample often share a slow parameter value, and contiguous chunks would leave
690
+ * one worker holding all of them.
691
+ */
692
+ function splitIntoBatches(candidates, workers) {
693
+ const count = Math.max(1, Math.min(workers, candidates.length));
694
+ const batches = Array.from({ length: count }, () => []);
695
+ candidates.forEach((params, index) => {
696
+ batches[index % count].push({ index, params });
697
+ });
698
+ return batches.filter((batch) => batch.length > 0);
699
+ }
700
+ /** Reads the split back out of a run's recorded assumptions. */
701
+ function splitFromAssumptions(assumptions) {
702
+ if (!assumptions || assumptions.trainStartMs === undefined)
703
+ return null;
704
+ return {
705
+ trainStartMs: Number(assumptions.trainStartMs),
706
+ trainEndMs: Number(assumptions.trainEndMs),
707
+ validationStartMs: Number(assumptions.validationStartMs),
708
+ validationEndMs: Number(assumptions.validationEndMs),
709
+ trainBars: Number(assumptions.trainBars),
710
+ validationBars: Number(assumptions.validationBars)
711
+ };
712
+ }
713
+ /**
714
+ * Identifies the exact data a run consumed.
715
+ *
716
+ * The hash is embedded in the id, so any drift in the underlying bars produces
717
+ * a different snapshot id instead of passing silently.
718
+ */
719
+ function snapshotId(bars, environment) {
720
+ const hash = crypto.createHash("sha256");
721
+ // The environment is hashed too: demo and live are separate venues, so the same
722
+ // instrument and minute can carry different prices in each. Two runs that happened
723
+ // to agree on every price would otherwise share an id while describing different
724
+ // data.
725
+ hash.update(`${environment}\n`);
726
+ for (const bar of bars) {
727
+ // Price text is hashed as stored, so the digest does not depend on any
728
+ // float formatting decision.
729
+ hash.update(`${bar.openTimeMs}|${bar.open}|${bar.high}|${bar.low}|${bar.close}|${bar.volume}\n`);
730
+ }
731
+ return `bars-${hash.digest("hex").slice(0, 24)}`;
732
+ }
733
+ function pythonError(response) {
734
+ const error = (response.error ?? {});
735
+ return new RuntimeError(error.code === "policy_violation" ? "VALIDATION" : "INTERNAL", error.message ?? "The strategy engine reported an error", false, error.violations ? { violations: error.violations } : undefined);
736
+ }
737
+ /**
738
+ * Leaves one core for the runtime's own websocket and HTTP work, and caps the
739
+ * pool so a batch of runs cannot saturate the machine.
740
+ */
741
+ function defaultWorkerCount() {
742
+ const cpus = os.cpus().length || 2;
743
+ return Math.max(1, Math.min(4, cpus - 1));
744
+ }
745
+ //# sourceMappingURL=service.js.map