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.
- package/README.en.md +90 -10
- package/README.md +76 -10
- package/dist/account/private-websocket.js +4 -4
- package/dist/account/private-websocket.js.map +1 -1
- package/dist/account/service.d.ts +12 -1
- package/dist/account/service.js +18 -0
- package/dist/account/service.js.map +1 -1
- package/dist/bars/rate-limiter.d.ts +18 -0
- package/dist/bars/rate-limiter.js +84 -0
- package/dist/bars/rate-limiter.js.map +1 -0
- package/dist/bars/schema.d.ts +36 -0
- package/dist/bars/schema.js +134 -0
- package/dist/bars/schema.js.map +1 -0
- package/dist/bars/service.d.ts +60 -0
- package/dist/bars/service.js +120 -0
- package/dist/bars/service.js.map +1 -0
- package/dist/bars/store.d.ts +105 -0
- package/dist/bars/store.js +415 -0
- package/dist/bars/store.js.map +1 -0
- package/dist/bars/timeframe.d.ts +40 -0
- package/dist/bars/timeframe.js +146 -0
- package/dist/bars/timeframe.js.map +1 -0
- package/dist/bars/types.d.ts +68 -0
- package/dist/bars/types.js +13 -0
- package/dist/bars/types.js.map +1 -0
- package/dist/cli/data-render.d.ts +37 -0
- package/dist/cli/data-render.js +143 -0
- package/dist/cli/data-render.js.map +1 -0
- package/dist/cli/doctor.js +7 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +757 -26
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/live-render.d.ts +24 -0
- package/dist/cli/live-render.js +85 -0
- package/dist/cli/live-render.js.map +1 -0
- package/dist/cli/range.d.ts +28 -0
- package/dist/cli/range.js +63 -0
- package/dist/cli/range.js.map +1 -0
- package/dist/cli/render.js +3 -0
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/strategy-render.d.ts +36 -0
- package/dist/cli/strategy-render.js +391 -0
- package/dist/cli/strategy-render.js.map +1 -0
- package/dist/cli/width.d.ts +18 -0
- package/dist/cli/width.js +71 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/config/loader.js +1 -1
- package/dist/config/schema.d.ts +7 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/core/okx-client.d.ts +9 -1
- package/dist/core/okx-client.js +14 -5
- package/dist/core/okx-client.js.map +1 -1
- package/dist/i18n/locale.d.ts +24 -0
- package/dist/i18n/locale.js +65 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/messages.d.ts +333 -0
- package/dist/i18n/messages.js +660 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/live/account-snapshot.d.ts +30 -0
- package/dist/live/account-snapshot.js +130 -0
- package/dist/live/account-snapshot.js.map +1 -0
- package/dist/live/cutoff-queue.d.ts +42 -0
- package/dist/live/cutoff-queue.js +69 -0
- package/dist/live/cutoff-queue.js.map +1 -0
- package/dist/live/execution-key.d.ts +23 -0
- package/dist/live/execution-key.js +31 -0
- package/dist/live/execution-key.js.map +1 -0
- package/dist/live/failures.d.ts +37 -0
- package/dist/live/failures.js +57 -0
- package/dist/live/failures.js.map +1 -0
- package/dist/live/gates.d.ts +65 -0
- package/dist/live/gates.js +136 -0
- package/dist/live/gates.js.map +1 -0
- package/dist/live/loop.d.ts +56 -0
- package/dist/live/loop.js +197 -0
- package/dist/live/loop.js.map +1 -0
- package/dist/live/preconditions.d.ts +48 -0
- package/dist/live/preconditions.js +69 -0
- package/dist/live/preconditions.js.map +1 -0
- package/dist/live/reconcile.d.ts +46 -0
- package/dist/live/reconcile.js +104 -0
- package/dist/live/reconcile.js.map +1 -0
- package/dist/live/runner.d.ts +57 -0
- package/dist/live/runner.js +160 -0
- package/dist/live/runner.js.map +1 -0
- package/dist/live/schema.d.ts +18 -0
- package/dist/live/schema.js +91 -0
- package/dist/live/schema.js.map +1 -0
- package/dist/live/service.d.ts +144 -0
- package/dist/live/service.js +303 -0
- package/dist/live/service.js.map +1 -0
- package/dist/live/session.d.ts +85 -0
- package/dist/live/session.js +234 -0
- package/dist/live/session.js.map +1 -0
- package/dist/live/sizing.d.ts +62 -0
- package/dist/live/sizing.js +79 -0
- package/dist/live/sizing.js.map +1 -0
- package/dist/live/store.d.ts +123 -0
- package/dist/live/store.js +350 -0
- package/dist/live/store.js.map +1 -0
- package/dist/live/types.d.ts +82 -0
- package/dist/live/types.js +2 -0
- package/dist/live/types.js.map +1 -0
- package/dist/market/websocket.d.ts +16 -1
- package/dist/market/websocket.js +60 -5
- package/dist/market/websocket.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +15 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/network/connectivity.d.ts +9 -1
- package/dist/network/connectivity.js +28 -1
- package/dist/network/connectivity.js.map +1 -1
- package/dist/report/chart-script.d.ts +12 -0
- package/dist/report/chart-script.js +146 -0
- package/dist/report/chart-script.js.map +1 -0
- package/dist/report/compare-html.d.ts +8 -0
- package/dist/report/compare-html.js +254 -0
- package/dist/report/compare-html.js.map +1 -0
- package/dist/report/compare-script.d.ts +12 -0
- package/dist/report/compare-script.js +109 -0
- package/dist/report/compare-script.js.map +1 -0
- package/dist/report/compare.d.ts +61 -0
- package/dist/report/compare.js +205 -0
- package/dist/report/compare.js.map +1 -0
- package/dist/report/fetch.d.ts +20 -0
- package/dist/report/fetch.js +56 -0
- package/dist/report/fetch.js.map +1 -0
- package/dist/report/html.d.ts +54 -0
- package/dist/report/html.js +641 -0
- package/dist/report/html.js.map +1 -0
- package/dist/report/open.d.ts +42 -0
- package/dist/report/open.js +114 -0
- package/dist/report/open.js.map +1 -0
- package/dist/runtime/server.d.ts +16 -1
- package/dist/runtime/server.js +112 -9
- package/dist/runtime/server.js.map +1 -1
- package/dist/setup/installer.d.ts +1 -0
- package/dist/setup/installer.js +8 -0
- package/dist/setup/installer.js.map +1 -1
- package/dist/setup/wizard.js +19 -22
- package/dist/setup/wizard.js.map +1 -1
- package/dist/strategy/constants.d.ts +23 -0
- package/dist/strategy/constants.js +24 -0
- package/dist/strategy/constants.js.map +1 -0
- package/dist/strategy/environment.d.ts +52 -0
- package/dist/strategy/environment.js +187 -0
- package/dist/strategy/environment.js.map +1 -0
- package/dist/strategy/instrument.d.ts +29 -0
- package/dist/strategy/instrument.js +39 -0
- package/dist/strategy/instrument.js.map +1 -0
- package/dist/strategy/optimize.d.ts +73 -0
- package/dist/strategy/optimize.js +113 -0
- package/dist/strategy/optimize.js.map +1 -0
- package/dist/strategy/parameter-space.d.ts +59 -0
- package/dist/strategy/parameter-space.js +221 -0
- package/dist/strategy/parameter-space.js.map +1 -0
- package/dist/strategy/python-bridge.d.ts +24 -0
- package/dist/strategy/python-bridge.js +114 -0
- package/dist/strategy/python-bridge.js.map +1 -0
- package/dist/strategy/schema.d.ts +9 -0
- package/dist/strategy/schema.js +91 -0
- package/dist/strategy/schema.js.map +1 -0
- package/dist/strategy/service.d.ts +138 -0
- package/dist/strategy/service.js +745 -0
- package/dist/strategy/service.js.map +1 -0
- package/dist/strategy/settings.d.ts +162 -0
- package/dist/strategy/settings.js +243 -0
- package/dist/strategy/settings.js.map +1 -0
- package/dist/strategy/store.d.ts +96 -0
- package/dist/strategy/store.js +367 -0
- package/dist/strategy/store.js.map +1 -0
- package/dist/strategy/templates.d.ts +11 -0
- package/dist/strategy/templates.js +134 -0
- package/dist/strategy/templates.js.map +1 -0
- package/dist/strategy/types.d.ts +111 -0
- package/dist/strategy/types.js +2 -0
- package/dist/strategy/types.js.map +1 -0
- package/dist/tools/catalog.d.ts +16 -0
- package/dist/tools/catalog.js +118 -17
- package/dist/tools/catalog.js.map +1 -1
- package/dist/trade/service.d.ts +12 -0
- package/dist/trade/service.js +24 -7
- package/dist/trade/service.js.map +1 -1
- package/dist/tui/app.d.ts +23 -0
- package/dist/tui/app.js +322 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/commands.d.ts +70 -0
- package/dist/tui/commands.js +313 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/entries.d.ts +17 -0
- package/dist/tui/entries.js +24 -0
- package/dist/tui/entries.js.map +1 -0
- package/dist/tui/execute.d.ts +26 -0
- package/dist/tui/execute.js +664 -0
- package/dist/tui/execute.js.map +1 -0
- package/dist/tui/history.d.ts +17 -0
- package/dist/tui/history.js +48 -0
- package/dist/tui/history.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.js +48 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/line-editor.d.ts +44 -0
- package/dist/tui/line-editor.js +98 -0
- package/dist/tui/line-editor.js.map +1 -0
- package/dist/tui/progress.d.ts +23 -0
- package/dist/tui/progress.js +46 -0
- package/dist/tui/progress.js.map +1 -0
- package/dist/tui/settings-editor.d.ts +18 -0
- package/dist/tui/settings-editor.js +115 -0
- package/dist/tui/settings-editor.js.map +1 -0
- package/docs/live-trading.md +455 -0
- package/docs/strategy-research.md +597 -0
- package/package.json +10 -1
- package/python/desic_strategy/__init__.py +34 -0
- package/python/desic_strategy/actions.py +158 -0
- package/python/desic_strategy/context.py +164 -0
- package/python/desic_strategy/engine.py +614 -0
- package/python/desic_strategy/indicators.py +159 -0
- package/python/desic_strategy/live.py +253 -0
- package/python/desic_strategy/policy.py +193 -0
- package/python/desic_strategy/portfolio.py +152 -0
- package/python/desic_strategy/report.py +319 -0
- package/python/desic_strategy/runner.py +574 -0
- package/python/desic_strategy/timeframe.py +150 -0
- package/python/main.py +18 -0
- package/skills/okx-live-trading/SKILL.md +117 -0
- package/skills/okx-live-trading/agents/openai.yaml +9 -0
- package/skills/okx-live-trading/references/lifecycle.md +128 -0
- package/skills/okx-strategy-research/SKILL.md +113 -0
- package/skills/okx-strategy-research/agents/openai.yaml +9 -0
- package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
- package/skills/okx-strategy-research/references/field-traps.md +142 -0
- package/skills/okx-strategy-research/references/python-api.md +121 -0
- package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
- 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
|