outcometick 1.4.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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/api/lib/backtest-contract.mjs +318 -0
  4. package/api/lib/backtest-datasets.mjs +225 -0
  5. package/api/lib/backtest-manifest.mjs +345 -0
  6. package/api/lib/coverage-window.mjs +42 -0
  7. package/api/lib/data-taxonomy.mjs +175 -0
  8. package/api/lib/venue-path.mjs +16 -0
  9. package/bin/ot.mjs +4 -0
  10. package/cli/api-client.mjs +71 -0
  11. package/cli/commands/fetch.mjs +43 -0
  12. package/cli/commands/run.mjs +269 -0
  13. package/cli/commands/status.mjs +102 -0
  14. package/cli/commands/submit.mjs +77 -0
  15. package/cli/local-data.mjs +177 -0
  16. package/cli/ot.mjs +223 -0
  17. package/index.d.ts +195 -0
  18. package/index.mjs +2 -0
  19. package/package.json +58 -0
  20. package/runner/analyze/index.mjs +40 -0
  21. package/runner/analyze/javascript.mjs +380 -0
  22. package/runner/analyze/python.mjs +85 -0
  23. package/runner/analyze/python_analyze.py +320 -0
  24. package/runner/archive.mjs +185 -0
  25. package/runner/engine/book.mjs +226 -0
  26. package/runner/engine/portfolio.mjs +292 -0
  27. package/runner/engine/replay.mjs +496 -0
  28. package/runner/engine/report.mjs +417 -0
  29. package/runner/events.mjs +190 -0
  30. package/runner/harness/node/harness.mjs +467 -0
  31. package/runner/harness/node/sdk/index.d.ts +195 -0
  32. package/runner/harness/node/sdk/index.mjs +71 -0
  33. package/runner/harness/node/sdk/package.json +8 -0
  34. package/runner/harness/protocol.mjs +255 -0
  35. package/runner/harness/python/harness.py +374 -0
  36. package/runner/harness/python/otengine.py +523 -0
  37. package/runner/harness/python/otreplay.py +409 -0
  38. package/runner/harness/python/outcometick.py +67 -0
@@ -0,0 +1,380 @@
1
+ // Static analysis of a submitted JavaScript strategy.
2
+ //
3
+ // This is the half of the determinism guarantee that runs BEFORE anything
4
+ // executes. The other half is structural — the sandbox image has no network
5
+ // stack and no writable filesystem, so those APIs are absent rather than
6
+ // blocked. What is left over is the code that would still be non-deterministic
7
+ // inside a perfect jail: a wall-clock read, an unseeded random, a thread, an
8
+ // eval. Those are what this catches.
9
+ //
10
+ // A real parse, not a regex sweep. `Math.random` inside a string literal is not
11
+ // a call, and `// eval(` is a comment — a regex cannot tell, and a validator
12
+ // that rejects working code is worse than one that is slightly slower. The docs
13
+ // promise that a local `ot check` pass is not rejected on submit, so a false
14
+ // positive here is a broken promise.
15
+
16
+ import { parse } from 'acorn';
17
+ import { BacktestRejection } from '../../api/lib/backtest-contract.mjs';
18
+
19
+ /**
20
+ * Globals a strategy may reference.
21
+ *
22
+ * Deliberately tiny. Everything a strategy legitimately needs arrives through
23
+ * `ctx`; this list is the arithmetic and data-structure surface it needs to do
24
+ * anything with what it is given.
25
+ */
26
+ export const ALLOWED_GLOBALS = new Set([
27
+ 'Array', 'ArrayBuffer', 'BigInt', 'Boolean', 'DataView', 'Error', 'Float32Array',
28
+ 'Float64Array', 'Infinity', 'Int8Array', 'Int16Array', 'Int32Array', 'JSON',
29
+ 'Map', 'Math', 'NaN', 'Number', 'Object', 'Promise', 'RangeError', 'ReferenceError',
30
+ 'RegExp', 'Set', 'String', 'Symbol', 'SyntaxError', 'TypeError', 'Uint8Array',
31
+ 'Uint16Array', 'Uint32Array', 'WeakMap', 'WeakSet', 'console',
32
+ 'isFinite', 'isNaN', 'parseFloat', 'parseInt', 'structuredClone', 'undefined',
33
+ // The SDK's own surface.
34
+ 'Strategy', 'Order',
35
+ ]);
36
+ // Deliberately NOT here, and each for a reason rather than by omission:
37
+ // globalThis the gateway to everything else on this list's other side
38
+ // Proxy trap handlers make what a later line does unanalysable
39
+ // Reflect the reflection API this whole file exists to keep out
40
+ // Intl locale-dependent formatting, so not reproducible
41
+ // Date the wall clock; event time is ctx.now (see NONDETERMINISTIC)
42
+
43
+ /**
44
+ * Identifiers that are non-deterministic no matter how they are reached.
45
+ *
46
+ * `Date` is here and it is the one people are surprised by: `new Date()` reads
47
+ * the wall clock, and two runs of the same code then differ. Event time is on
48
+ * `ctx.now`, which is the only clock in the process.
49
+ */
50
+ const NONDETERMINISTIC = new Map([
51
+ ['Date', 'the wall clock is not readable; event time is ctx.now'],
52
+ ['performance', 'the wall clock is not readable; event time is ctx.now'],
53
+ ['process', 'process state (pid, env, hrtime, platform) differs between workers'],
54
+ ]);
55
+
56
+ /**
57
+ * Constructs that are refused outright.
58
+ *
59
+ * Each one either escapes the analysis (eval, dynamic import, Function) or
60
+ * breaks the sharding guarantee (threads, subprocesses).
61
+ */
62
+ const FORBIDDEN_IDENTIFIERS = new Map([
63
+ ['eval', 'eval escapes static analysis'],
64
+ ['Function', 'the Function constructor escapes static analysis'],
65
+ ['Worker', 'threads are not available; parallelism is across markets'],
66
+ ['SharedArrayBuffer', 'shared memory implies threads'],
67
+ ['Atomics', 'shared memory implies threads'],
68
+ ['require', 'CommonJS require is not available; declare deps in the manifest'],
69
+ ['fetch', 'there is no network in the sandbox'],
70
+ ['XMLHttpRequest', 'there is no network in the sandbox'],
71
+ ['WebSocket', 'there is no network in the sandbox'],
72
+ ['setTimeout', 'the runner owns the clock and the loop'],
73
+ ['setInterval', 'the runner owns the clock and the loop'],
74
+ ['setImmediate', 'the runner owns the clock and the loop'],
75
+ ['queueMicrotask', 'the runner owns the clock and the loop'],
76
+ ]);
77
+
78
+ /**
79
+ * Property names that hand back the machinery of the language itself.
80
+ *
81
+ * `.constructor` is the important one: `globalThis.constructor.constructor` is
82
+ * the Function constructor, and `Function("return process")()` reaches the
83
+ * whole runtime — the filesystem, the environment, everything the allowlist was
84
+ * supposed to exclude. Blocking the bare `Function` identifier alone was not
85
+ * enough, because nobody spells it that way when they are trying to escape.
86
+ */
87
+ const FORBIDDEN_PROPERTIES = new Set([
88
+ 'constructor',
89
+ '__proto__',
90
+ '__defineGetter__',
91
+ '__defineSetter__',
92
+ '__lookupGetter__',
93
+ '__lookupSetter__',
94
+ ]);
95
+
96
+ /**
97
+ * The only `Object` methods a strategy may reach.
98
+ *
99
+ * An allowlist, because the denylist lost. Blocking `.constructor` as a member
100
+ * access does nothing about
101
+ *
102
+ * Object.getOwnPropertyDescriptor(Object.getPrototypeOf(function(){}), 'constructor').value
103
+ *
104
+ * which IS the Function constructor — the forbidden property arrives as a
105
+ * string argument, not as a property access, so nothing in the member rules
106
+ * sees it. Verified: that payload passed analysis and returned `process`.
107
+ *
108
+ * Everything below is data-shaping and cannot hand back a callable from the
109
+ * prototype chain. Anything reflective — getPrototypeOf, defineProperty,
110
+ * getOwnPropertyDescriptor(s), getOwnPropertyNames — is absent on purpose.
111
+ */
112
+ const OBJECT_METHODS = new Set([
113
+ 'keys', 'values', 'entries', 'fromEntries', 'assign',
114
+ 'freeze', 'isFrozen', 'hasOwn', 'is',
115
+ ]);
116
+
117
+ /** `Math.random` is the one member expression that is a rejection on its own. */
118
+ const FORBIDDEN_MEMBERS = new Map([
119
+ ['Math.random', 'unseeded randomness; use ctx.random(seed)'],
120
+ ['crypto.randomUUID', 'unseeded randomness; use ctx.random(seed)'],
121
+ ['crypto.getRandomValues', 'unseeded randomness; use ctx.random(seed)'],
122
+ ]);
123
+
124
+ /**
125
+ * Walk every node of an ESTree AST.
126
+ *
127
+ * Written out rather than pulled in: the visitor is twenty lines and a
128
+ * dependency here would itself need to be on an allowlist.
129
+ */
130
+ function walk(node, visit, parent = null) {
131
+ if (!node || typeof node.type !== 'string') return;
132
+ visit(node, parent);
133
+ for (const key of Object.keys(node)) {
134
+ if (key === 'type' || key === 'loc' || key === 'range') continue;
135
+ const child = node[key];
136
+ if (Array.isArray(child)) {
137
+ for (const c of child) if (c && typeof c.type === 'string') walk(c, visit, node);
138
+ } else if (child && typeof child.type === 'string') {
139
+ walk(child, visit, node);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Fold a constant string expression to its value, or null if it is not one.
146
+ *
147
+ * Exists because `[]["con" + "structor"]["con" + "structor"]` is the Function
148
+ * constructor, and checking only literal keys let it straight through. Anything
149
+ * built from string literals and `+` is decided here; a genuinely dynamic key
150
+ * (`obj[name]`) cannot be, and is handled by the caller.
151
+ */
152
+ function foldString(node) {
153
+ if (!node) return null;
154
+ if (node.type === 'Literal') return typeof node.value === 'string' ? node.value : null;
155
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
156
+ return node.quasis.map((q) => q.value.cooked).join('');
157
+ }
158
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
159
+ const left = foldString(node.left);
160
+ const right = foldString(node.right);
161
+ return left != null && right != null ? left + right : null;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /** Flatten `a.b.c` to "a.b.c", or null when it is computed (`a[x]`). */
167
+ function memberPath(node) {
168
+ const parts = [];
169
+ let cur = node;
170
+ while (cur && cur.type === 'MemberExpression') {
171
+ if (cur.computed) return null;
172
+ if (cur.property.type !== 'Identifier') return null;
173
+ parts.unshift(cur.property.name);
174
+ cur = cur.object;
175
+ }
176
+ if (!cur || cur.type !== 'Identifier') return null;
177
+ parts.unshift(cur.name);
178
+ return parts.join('.');
179
+ }
180
+
181
+ /**
182
+ * Collect every name bound anywhere in the file.
183
+ *
184
+ * Used ONLY to decide whether a name that is not on the allowlist is really a
185
+ * local. It is deliberately NOT used to excuse a FORBIDDEN name any more:
186
+ * `onTick(ctx, t, fetch = fetch)` binds `fetch` while the default value
187
+ * captures the real global, and the shadowing exemption then waved it through.
188
+ * An exemption that is itself the attack is not an exemption.
189
+ */
190
+ function collectBindings(ast) {
191
+ const bound = new Set();
192
+ const addPattern = (p) => {
193
+ if (!p) return;
194
+ switch (p.type) {
195
+ case 'Identifier': bound.add(p.name); break;
196
+ case 'ObjectPattern': for (const prop of p.properties) addPattern(prop.value ?? prop.argument); break;
197
+ case 'ArrayPattern': for (const el of p.elements) addPattern(el); break;
198
+ case 'AssignmentPattern': addPattern(p.left); break;
199
+ case 'RestElement': addPattern(p.argument); break;
200
+ default: break;
201
+ }
202
+ };
203
+ walk(ast, (n) => {
204
+ if (n.type === 'VariableDeclarator') addPattern(n.id);
205
+ else if (n.type === 'ClassDeclaration') { if (n.id) bound.add(n.id.name); }
206
+ // A function declaration binds its own name AND its parameters. Missing the
207
+ // parameters made `function f(Date) { return Date + 1 }` read as a wall-clock
208
+ // access — a false positive, which is the expensive kind here.
209
+ else if (n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression'
210
+ || n.type === 'ArrowFunctionExpression') {
211
+ if (n.id) bound.add(n.id.name);
212
+ for (const p of n.params) addPattern(p);
213
+ } else if (n.type === 'CatchClause') addPattern(n.param);
214
+ else if (n.type === 'ImportDefaultSpecifier' || n.type === 'ImportSpecifier' || n.type === 'ImportNamespaceSpecifier') {
215
+ bound.add(n.local.name);
216
+ } else if (n.type === 'ClassMethod' || n.type === 'MethodDefinition') {
217
+ const fn = n.value;
218
+ if (fn?.params) for (const p of fn.params) addPattern(p);
219
+ } else if (n.type === 'PropertyDefinition' && n.value?.params) {
220
+ for (const p of n.value.params) addPattern(p);
221
+ }
222
+ });
223
+ return bound;
224
+ }
225
+
226
+ /**
227
+ * Analyse one JavaScript source file.
228
+ *
229
+ * @param {string} source
230
+ * @param {string} name file name, for the error message
231
+ * @param {string[]} allowedDeps package names the manifest declared
232
+ * @returns {{imports: string[]}}
233
+ * @throws {BacktestRejection}
234
+ */
235
+ export function analyzeJavaScript(source, name, allowedDeps = []) {
236
+ let ast;
237
+ try {
238
+ ast = parse(source, { ecmaVersion: 2023, sourceType: 'module', locations: true });
239
+ } catch (err) {
240
+ throw new BacktestRejection('E_ENTRY', `${name}: ${err.message}`);
241
+ }
242
+
243
+ const bound = collectBindings(ast);
244
+ const imports = [];
245
+ const at = (n) => `${name}:${n.loc?.start?.line ?? '?'}`;
246
+
247
+ walk(ast, (node, parent) => {
248
+ // ---- imports ----
249
+ if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration'
250
+ || (node.type === 'ExportNamedDeclaration' && node.source)) {
251
+ const spec = node.source.value;
252
+ imports.push(spec);
253
+ // Relative imports are the submitter's own files, checked separately
254
+ // against the submitted file list.
255
+ if (String(spec).startsWith('.')) return;
256
+ if (spec === 'outcometick') return;
257
+ if (!allowedDeps.includes(spec)) {
258
+ throw new BacktestRejection('E_IMPORT',
259
+ `${at(node)}: import of ${JSON.stringify(spec)} is not on the allowlist`
260
+ + (allowedDeps.length ? `; declared deps are ${allowedDeps.join(', ')}` : ' and no deps were declared'),
261
+ { file: name, line: node.loc?.start?.line ?? null, specifier: spec });
262
+ }
263
+ return;
264
+ }
265
+
266
+ // A dynamic import takes a runtime expression, so no static analysis can
267
+ // say what it loads. Refused rather than approximated.
268
+ if (node.type === 'ImportExpression') {
269
+ throw new BacktestRejection('E_FORBIDDEN',
270
+ `${at(node)}: dynamic import() escapes static analysis`,
271
+ { file: name, line: node.loc?.start?.line ?? null });
272
+ }
273
+
274
+ if (node.type === 'MetaProperty') {
275
+ throw new BacktestRejection('E_FORBIDDEN',
276
+ `${at(node)}: import.meta exposes the filesystem and the module loader`,
277
+ { file: name, line: node.loc?.start?.line ?? null });
278
+ }
279
+
280
+ // ---- member expressions ----
281
+ if (node.type === 'MemberExpression') {
282
+ // Reached by name (`x.constructor`) or by computed string
283
+ // (`x["constructor"]`) — both are the same door.
284
+ const prop = node.computed
285
+ ? foldString(node.property)
286
+ : (node.property.type === 'Identifier' ? node.property.name : null);
287
+ if (prop && FORBIDDEN_PROPERTIES.has(prop)) {
288
+ throw new BacktestRejection('E_FORBIDDEN',
289
+ `${at(node)}: .${prop} — reaches the runtime through the language's own machinery`,
290
+ { file: name, line: node.loc?.start?.line ?? null });
291
+ }
292
+ // Reflective escape hatches on Object, by allowlist.
293
+ if (!node.computed && node.object.type === 'Identifier'
294
+ && node.object.name === 'Object' && !bound.has('Object')
295
+ && prop && !OBJECT_METHODS.has(prop)) {
296
+ throw new BacktestRejection('E_FORBIDDEN',
297
+ `${at(node)}: Object.${prop} — reflection reaches the runtime through the`
298
+ + ` prototype chain. Available: ${[...OBJECT_METHODS].join(', ')}.`,
299
+ { file: name, line: node.loc?.start?.line ?? null });
300
+ }
301
+
302
+ const path = memberPath(node);
303
+ if (path && FORBIDDEN_MEMBERS.has(path) && !bound.has(path.split('.')[0])) {
304
+ throw new BacktestRejection('E_NONDETERMINISM',
305
+ `${at(node)}: ${path} — ${FORBIDDEN_MEMBERS.get(path)}`,
306
+ { file: name, line: node.loc?.start?.line ?? null });
307
+ }
308
+ return;
309
+ }
310
+
311
+ // ---- bare identifiers ----
312
+ if (node.type !== 'Identifier') return;
313
+ // Property names, labels, keys and declarations are not references.
314
+ if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) return;
315
+ if (parent?.type === 'Property' && parent.key === node && !parent.computed) return;
316
+ if (parent?.type === 'PropertyDefinition' && parent.key === node && !parent.computed) return;
317
+ if (parent?.type === 'MethodDefinition' && parent.key === node && !parent.computed) return;
318
+ if (parent?.type === 'LabeledStatement' || parent?.type === 'BreakStatement' || parent?.type === 'ContinueStatement') return;
319
+
320
+ // Checked BEFORE the `bound` exemption, and regardless of it — a parameter
321
+ // or local named after a forbidden global is refused wherever it appears.
322
+ // The cost is a false positive on `function f(Date) {…}`, which renames in
323
+ // one keystroke; the alternative is a documented way out of the allowlist.
324
+ if (FORBIDDEN_IDENTIFIERS.has(node.name)) {
325
+ throw new BacktestRejection('E_FORBIDDEN',
326
+ `${at(node)}: ${node.name} — ${FORBIDDEN_IDENTIFIERS.get(node.name)}.`
327
+ + ' It cannot be used as a name either; rename the variable or parameter.',
328
+ { file: name, line: node.loc?.start?.line ?? null });
329
+ }
330
+ if (NONDETERMINISTIC.has(node.name)) {
331
+ throw new BacktestRejection('E_NONDETERMINISM',
332
+ `${at(node)}: ${node.name} — ${NONDETERMINISTIC.get(node.name)}.`
333
+ + ' It cannot be used as a name either; rename the variable or parameter.',
334
+ { file: name, line: node.loc?.start?.line ?? null });
335
+ }
336
+
337
+ if (bound.has(node.name)) return;
338
+ // An ALLOWLIST, not a denylist. ALLOWED_GLOBALS was previously declared and
339
+ // never consulted, which meant every global nobody had thought to name —
340
+ // Reflect, Proxy, WebAssembly, Intl, Atomics via another spelling — passed
341
+ // straight through. Enumerating what is permitted is the only version of
342
+ // this that does not lose to the next name someone thinks of.
343
+ if (!ALLOWED_GLOBALS.has(node.name)) {
344
+ throw new BacktestRejection('E_FORBIDDEN',
345
+ `${at(node)}: ${node.name} is not available; a strategy reaches the outside only through ctx`,
346
+ { file: name, line: node.loc?.start?.line ?? null });
347
+ }
348
+ });
349
+
350
+ return { imports };
351
+ }
352
+
353
+ /**
354
+ * Analyse a whole submission, and check that relative imports resolve to files
355
+ * that were actually submitted.
356
+ *
357
+ * A relative import of a file that is not there fails at run time, after the
358
+ * credits are held. Catching it here keeps the "a rejection costs nothing"
359
+ * promise true.
360
+ */
361
+ export function analyzeJavaScriptSubmission(files, { deps = [] } = {}) {
362
+ const names = new Set(files.map((f) => f.name));
363
+ const all = [];
364
+ for (const f of files) {
365
+ if (!/\.(mjs|js)$/.test(f.name)) continue;
366
+ const { imports } = analyzeJavaScript(f.content, f.name, deps);
367
+ for (const spec of imports) {
368
+ if (!String(spec).startsWith('.')) continue;
369
+ const resolved = spec.replace(/^\.\//, '');
370
+ const candidates = [resolved, `${resolved}.mjs`, `${resolved}.js`];
371
+ if (!candidates.some((c) => names.has(c))) {
372
+ throw new BacktestRejection('E_ENTRY',
373
+ `${f.name} imports ${JSON.stringify(spec)}, which was not submitted`,
374
+ { file: f.name, specifier: spec });
375
+ }
376
+ }
377
+ all.push(...imports);
378
+ }
379
+ return { imports: [...new Set(all)] };
380
+ }
@@ -0,0 +1,85 @@
1
+ // Node-side wrapper around the Python static analyser.
2
+ //
3
+ // The analysis itself has to run in Python — a Python AST is the only thing
4
+ // that can tell a call from a docstring, and reimplementing one in JavaScript
5
+ // would drift from the language it is meant to describe. This module only
6
+ // shuttles a job in and a verdict out, so both languages reject through the
7
+ // same BacktestRejection type and the same codes.
8
+ //
9
+ // The analyser runs as a SEPARATE PROCESS with no arguments and a JSON job on
10
+ // stdin. It never imports the submitted code — it parses it. Importing to
11
+ // inspect is how a validator becomes the first thing an attacker executes.
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { BacktestRejection } from '../../api/lib/backtest-contract.mjs';
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const SCRIPT = path.join(HERE, 'python_analyze.py');
20
+
21
+ /** Interpreter to parse with. The sandbox image pins 3.14. */
22
+ export const PYTHON = process.env.OT_PYTHON || 'python3';
23
+
24
+ /**
25
+ * Analyse a Python submission.
26
+ *
27
+ * @param {{name:string, content:string}[]} files
28
+ * @param {{deps?:string[], timeoutMs?:number}} opts
29
+ * @returns {Promise<{imports:string[]}>}
30
+ */
31
+ export function analyzePythonSubmission(files, { deps = [], timeoutMs = 10_000 } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn(PYTHON, [SCRIPT], {
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ // No inherited environment: the analyser needs nothing from it, and a
36
+ // PYTHONPATH or PYTHONSTARTUP pointing somewhere unexpected is a way to
37
+ // change what parses.
38
+ env: { PATH: process.env.PATH ?? '', PYTHONDONTWRITEBYTECODE: '1', PYTHONHASHSEED: '0' },
39
+ });
40
+
41
+ let out = '';
42
+ let err = '';
43
+ let settled = false;
44
+
45
+ const timer = setTimeout(() => {
46
+ if (settled) return;
47
+ settled = true;
48
+ child.kill('SIGKILL');
49
+ // A parse that will not terminate is not a strategy we can validate, and
50
+ // it must not hold a worker open.
51
+ reject(new BacktestRejection('E_BUDGET', `static analysis did not finish within ${timeoutMs}ms`));
52
+ }, timeoutMs);
53
+
54
+ child.stdout.on('data', (d) => { out += d; });
55
+ child.stderr.on('data', (d) => { err += d; });
56
+
57
+ child.on('error', (e) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ reject(new Error(`could not run the python analyser (${PYTHON}): ${e.message}`));
62
+ });
63
+
64
+ child.on('close', () => {
65
+ if (settled) return;
66
+ settled = true;
67
+ clearTimeout(timer);
68
+ let verdict;
69
+ try {
70
+ verdict = JSON.parse(out);
71
+ } catch {
72
+ reject(new Error(`python analyser produced no verdict: ${err.trim() || out.trim() || 'no output'}`));
73
+ return;
74
+ }
75
+ if (verdict.ok) {
76
+ resolve({ imports: verdict.imports ?? [] });
77
+ return;
78
+ }
79
+ const { code, detail, ...extra } = verdict;
80
+ reject(new BacktestRejection(code, detail, extra));
81
+ });
82
+
83
+ child.stdin.end(JSON.stringify({ files, deps }));
84
+ });
85
+ }