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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/api/lib/backtest-contract.mjs +318 -0
- package/api/lib/backtest-datasets.mjs +225 -0
- package/api/lib/backtest-manifest.mjs +345 -0
- package/api/lib/coverage-window.mjs +42 -0
- package/api/lib/data-taxonomy.mjs +175 -0
- package/api/lib/venue-path.mjs +16 -0
- package/bin/ot.mjs +4 -0
- package/cli/api-client.mjs +71 -0
- package/cli/commands/fetch.mjs +43 -0
- package/cli/commands/run.mjs +269 -0
- package/cli/commands/status.mjs +102 -0
- package/cli/commands/submit.mjs +77 -0
- package/cli/local-data.mjs +177 -0
- package/cli/ot.mjs +223 -0
- package/index.d.ts +195 -0
- package/index.mjs +2 -0
- package/package.json +58 -0
- package/runner/analyze/index.mjs +40 -0
- package/runner/analyze/javascript.mjs +380 -0
- package/runner/analyze/python.mjs +85 -0
- package/runner/analyze/python_analyze.py +320 -0
- package/runner/archive.mjs +185 -0
- package/runner/engine/book.mjs +226 -0
- package/runner/engine/portfolio.mjs +292 -0
- package/runner/engine/replay.mjs +496 -0
- package/runner/engine/report.mjs +417 -0
- package/runner/events.mjs +190 -0
- package/runner/harness/node/harness.mjs +467 -0
- package/runner/harness/node/sdk/index.d.ts +195 -0
- package/runner/harness/node/sdk/index.mjs +71 -0
- package/runner/harness/node/sdk/package.json +8 -0
- package/runner/harness/protocol.mjs +255 -0
- package/runner/harness/python/harness.py +374 -0
- package/runner/harness/python/otengine.py +523 -0
- package/runner/harness/python/otreplay.py +409 -0
- package/runner/harness/python/outcometick.py +67 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Node.js harness. Runs INSIDE the sandbox, in the same process as the
|
|
3
|
+
// submitted strategy.
|
|
4
|
+
//
|
|
5
|
+
// It is the strategy's whole world: the SDK surface it imports, the loop that
|
|
6
|
+
// calls it, and the only two things it can write. There is no network stack in
|
|
7
|
+
// the image and the filesystem is read-only apart from one scratch directory,
|
|
8
|
+
// so this file does not try to be a security boundary — the container is. What
|
|
9
|
+
// it does enforce is the CONTRACT: hooks are called with the right shapes, the
|
|
10
|
+
// per-event budget is measured, and nothing but trades, fills and logs comes
|
|
11
|
+
// back out.
|
|
12
|
+
//
|
|
13
|
+
// node harness.mjs <job-dir> job on stdin, results on fd 3
|
|
14
|
+
|
|
15
|
+
import { readSync, writeSync } from 'node:fs';
|
|
16
|
+
import { createHmac } from 'node:crypto';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import { replayMarket, BudgetMonitor, RunAbort } from '../../engine/replay.mjs';
|
|
20
|
+
import { Portfolio } from '../../engine/portfolio.mjs';
|
|
21
|
+
import { CHANNEL, RESULT_FD, EXIT } from '../protocol.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The parser, captured at module load — before any strategy is imported.
|
|
25
|
+
*
|
|
26
|
+
* Defence in depth behind the streaming fix below: even reading one event at a
|
|
27
|
+
* time, a strategy that had replaced `JSON.parse` would see each row a moment
|
|
28
|
+
* before its own hook does. Capturing costs nothing.
|
|
29
|
+
*/
|
|
30
|
+
const parseJson = JSON.parse;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Our own JSON writer. `JSON.stringify` is not used for output at all.
|
|
34
|
+
*
|
|
35
|
+
* Capturing `JSON.stringify` at module load is not enough, and neither is
|
|
36
|
+
* projecting rows onto null-prototype objects. `JSON.stringify` looks up
|
|
37
|
+
* `toJSON` through the PROTOTYPE CHAIN — on arrays, and even on primitives via
|
|
38
|
+
* boxing — so a single `Object.prototype.toJSON = …` compromises it no matter
|
|
39
|
+
* what it is handed. (Deleting the property is not a fix either: a strategy can
|
|
40
|
+
* define it non-configurable.)
|
|
41
|
+
*
|
|
42
|
+
* Thirty lines of writer sidesteps the entire question: nothing here consults a
|
|
43
|
+
* prototype, so there is nothing to poison. The shapes are simple — numbers,
|
|
44
|
+
* strings, booleans, nulls, arrays and flat objects — because that is all the
|
|
45
|
+
* protocol allows out.
|
|
46
|
+
*/
|
|
47
|
+
const ESCAPES = {
|
|
48
|
+
'"': '\\"', '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t',
|
|
49
|
+
'\b': '\\b', '\f': '\\f',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function jsonString(str) {
|
|
53
|
+
let out = '"';
|
|
54
|
+
for (const ch of String(str)) {
|
|
55
|
+
const esc = ESCAPES[ch];
|
|
56
|
+
if (esc) out += esc;
|
|
57
|
+
else if (ch < ' ') out += `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
58
|
+
else out += ch;
|
|
59
|
+
}
|
|
60
|
+
return `${out}"`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stringify(value) {
|
|
64
|
+
if (value === null || value === undefined) return 'null';
|
|
65
|
+
const t = typeof value;
|
|
66
|
+
if (t === 'number') return Number.isFinite(value) ? String(value) : 'null';
|
|
67
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
68
|
+
if (t === 'string') return jsonString(value);
|
|
69
|
+
if (Array.isArray(value)) return `[${value.map(stringify).join(',')}]`;
|
|
70
|
+
if (t === 'object') {
|
|
71
|
+
const parts = [];
|
|
72
|
+
for (const k of Object.keys(value)) {
|
|
73
|
+
const v = stringify(value[k]);
|
|
74
|
+
if (v !== undefined) parts.push(`${jsonString(k)}:${v}`);
|
|
75
|
+
}
|
|
76
|
+
return `{${parts.join(',')}}`;
|
|
77
|
+
}
|
|
78
|
+
return 'null';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Copy a row onto a NULL-PROTOTYPE object with primitive-coerced values.
|
|
83
|
+
*
|
|
84
|
+
* A captured stringify still consults `toJSON` on the value it is given, and
|
|
85
|
+
* `Object.prototype.toJSON = …` would reach every ordinary object. A
|
|
86
|
+
* null-prototype object inherits nothing, so there is no hook to install, and
|
|
87
|
+
* coercing each field means a getter cannot be smuggled in either.
|
|
88
|
+
*/
|
|
89
|
+
function projectRow(row, fields) {
|
|
90
|
+
const out = Object.create(null);
|
|
91
|
+
for (const f of fields) {
|
|
92
|
+
const v = row[f];
|
|
93
|
+
if (v == null) out[f] = null;
|
|
94
|
+
else if (typeof v === 'number') out[f] = Number.isFinite(v) ? Number(v) : null;
|
|
95
|
+
else if (typeof v === 'boolean') out[f] = Boolean(v);
|
|
96
|
+
else out[f] = String(v);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Deep-copy the result onto null-prototype objects.
|
|
103
|
+
*
|
|
104
|
+
* Same reasoning as projectRow, applied to the whole document: nothing that
|
|
105
|
+
* inherits from Object.prototype survives, so there is no toJSON hook to
|
|
106
|
+
* install anywhere in the tree.
|
|
107
|
+
*/
|
|
108
|
+
function projectResult(value) {
|
|
109
|
+
if (value == null) return null;
|
|
110
|
+
if (Array.isArray(value)) return value.map(projectResult);
|
|
111
|
+
const t = typeof value;
|
|
112
|
+
if (t === 'number') return Number.isFinite(value) ? Number(value) : null;
|
|
113
|
+
if (t === 'boolean') return Boolean(value);
|
|
114
|
+
if (t === 'string') return String(value);
|
|
115
|
+
if (t !== 'object') return String(value);
|
|
116
|
+
const out = Object.create(null);
|
|
117
|
+
for (const k of Object.keys(value)) out[String(k)] = projectResult(value[k]);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TRADE_FIELDS = [
|
|
122
|
+
'market_id', 'side', 'size', 'entry_px', 'exit_px', 'pnl', 'fees',
|
|
123
|
+
'opened_ms', 'closed_ms', 'how', 'outcome',
|
|
124
|
+
];
|
|
125
|
+
const FILL_FIELDS = [
|
|
126
|
+
'ts_ms', 'market_id', 'side', 'action', 'requested', 'filled', 'unfilled',
|
|
127
|
+
'avg_px', 'worst_px', 'quoted_px', 'levels_walked', 'fee', 'realised', 'tag',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Pull lines off a file descriptor SYNCHRONOUSLY, one at a time.
|
|
132
|
+
*
|
|
133
|
+
* Synchronous, and reading only as far as it is asked to, because that is what
|
|
134
|
+
* makes the product's central claim literally true rather than approximately
|
|
135
|
+
* true. The docs say:
|
|
136
|
+
*
|
|
137
|
+
* Look-ahead is impossible, because future rows are not in the process
|
|
138
|
+
* yet — not filtered out, not present.
|
|
139
|
+
*
|
|
140
|
+
* The previous version buffered a whole market's events into an array before
|
|
141
|
+
* replay started. The future WAS in the process, and the strategy — imported
|
|
142
|
+
* before that buffering — only had to intercept something the harness used to
|
|
143
|
+
* fill it. `JSON.parse = …` and `Array.prototype.push = …` both pass static
|
|
144
|
+
* analysis, and either one hands the strategy every tick of the market before
|
|
145
|
+
* its first hook fires. It could then trade on data it should not have, and the
|
|
146
|
+
* engine would execute those orders for real: not a forged report, a genuinely
|
|
147
|
+
* computed one that means nothing.
|
|
148
|
+
*
|
|
149
|
+
* Pulling one line at a time, driven by the replay loop, removes the thing
|
|
150
|
+
* being stolen. An intercepted parser now sees exactly the event the strategy
|
|
151
|
+
* is about to be handed anyway.
|
|
152
|
+
*
|
|
153
|
+
* Sync rather than async so the replay loop stays a plain `for…of`: an
|
|
154
|
+
* `await` per event across hundreds of millions of events is a real cost, and
|
|
155
|
+
* this runs inside a 400µs-per-event budget.
|
|
156
|
+
*/
|
|
157
|
+
function syncLineReader(fd) {
|
|
158
|
+
const CHUNK = 1 << 16;
|
|
159
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
160
|
+
let pending = '';
|
|
161
|
+
let queue = [];
|
|
162
|
+
let at = 0;
|
|
163
|
+
let eof = false;
|
|
164
|
+
|
|
165
|
+
return function next() {
|
|
166
|
+
for (;;) {
|
|
167
|
+
if (at < queue.length) return queue[at++];
|
|
168
|
+
queue = [];
|
|
169
|
+
at = 0;
|
|
170
|
+
if (eof) return null;
|
|
171
|
+
|
|
172
|
+
let n = 0;
|
|
173
|
+
try {
|
|
174
|
+
n = readSync(fd, buf, 0, CHUNK, null);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// A pipe that Node has put into non-blocking mode has nothing ready
|
|
177
|
+
// yet. Spin rather than fail — the worker is still writing.
|
|
178
|
+
if (err.code === 'EAGAIN') continue;
|
|
179
|
+
if (err.code === 'EOF') n = 0;
|
|
180
|
+
else throw err;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (n === 0) {
|
|
184
|
+
eof = true;
|
|
185
|
+
if (pending) {
|
|
186
|
+
const last = pending;
|
|
187
|
+
pending = '';
|
|
188
|
+
return last;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
pending += buf.toString('utf8', 0, n);
|
|
193
|
+
const parts = pending.split('\n');
|
|
194
|
+
pending = parts.pop();
|
|
195
|
+
queue = parts;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load the strategy class the manifest named.
|
|
202
|
+
*
|
|
203
|
+
* By exact name, with no discovery. A module that exports one class under a
|
|
204
|
+
* different name is a rejection rather than a guess: guessing is how a run
|
|
205
|
+
* silently executes something other than what the submitter meant.
|
|
206
|
+
*/
|
|
207
|
+
async function loadStrategy(dir, entry) {
|
|
208
|
+
const file = path.join(dir, entry.file);
|
|
209
|
+
let mod;
|
|
210
|
+
try {
|
|
211
|
+
mod = await import(pathToFileURL(file).href);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
throw new RunAbort('E_ENTRY', `could not load ${entry.file}: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
const Klass = mod[entry.className] ?? (mod.default?.name === entry.className ? mod.default : undefined);
|
|
216
|
+
if (typeof Klass !== 'function') {
|
|
217
|
+
const exported = Object.keys(mod).filter((k) => k !== 'default');
|
|
218
|
+
throw new RunAbort('E_ENTRY',
|
|
219
|
+
`${entry.file} does not export a class named ${entry.className}`
|
|
220
|
+
+ (exported.length ? `; it exports ${exported.join(', ')}` : ''));
|
|
221
|
+
}
|
|
222
|
+
return Klass;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check the declared hooks exist with the right arity before anything runs.
|
|
227
|
+
*
|
|
228
|
+
* A declared-but-missing hook is a rejection, and finding that out now costs
|
|
229
|
+
* nothing — finding it out after 700 market-days have been decoded costs the
|
|
230
|
+
* customer a run.
|
|
231
|
+
*/
|
|
232
|
+
function checkHooks(Klass, hooks, arities) {
|
|
233
|
+
const proto = Klass.prototype;
|
|
234
|
+
for (const [canonical, name] of Object.entries(hooks)) {
|
|
235
|
+
const fn = proto?.[name];
|
|
236
|
+
if (typeof fn !== 'function') {
|
|
237
|
+
throw new RunAbort('E_HOOK_SIG', `${canonical} was declared but ${name}() is not defined on the class`);
|
|
238
|
+
}
|
|
239
|
+
const want = arities[canonical];
|
|
240
|
+
// Arity is advisory in JS — rest params and defaults both report oddly —
|
|
241
|
+
// so only an obviously wrong signature is refused.
|
|
242
|
+
if (want != null && fn.length > want) {
|
|
243
|
+
throw new RunAbort('E_HOOK_SIG',
|
|
244
|
+
`${name}() takes ${fn.length} parameters; ${canonical} is called with ${want - 1} after ctx`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function main() {
|
|
250
|
+
const jobDir = process.argv[2];
|
|
251
|
+
if (!jobDir) {
|
|
252
|
+
process.stderr.write('usage: harness.mjs <job-dir> (job on stdin, results on fd 3)\n');
|
|
253
|
+
return 2;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// fd 0 directly, never `process.stdin`: touching the stream API puts the
|
|
257
|
+
// descriptor into non-blocking mode and hands the strategy a global to
|
|
258
|
+
// intercept.
|
|
259
|
+
const nextLine = syncLineReader(0);
|
|
260
|
+
const first = nextLine();
|
|
261
|
+
if (first == null) {
|
|
262
|
+
process.stderr.write('no job on stdin\n');
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
const job = parseJson(first);
|
|
266
|
+
const srcDir = path.join(jobDir, 'src');
|
|
267
|
+
|
|
268
|
+
// Every result line goes out over FD 3, authenticated with the per-run key
|
|
269
|
+
// that arrived in the job — before any strategy was imported. See the long
|
|
270
|
+
// note in protocol.mjs: /out used to be a writable mount, and an allowlisted
|
|
271
|
+
// pandas could rewrite trades.jsonl from on_settle.
|
|
272
|
+
const outputKey = String(job.outputKey ?? '');
|
|
273
|
+
if (!outputKey) {
|
|
274
|
+
process.stderr.write('no output key in the job\n');
|
|
275
|
+
return 2;
|
|
276
|
+
}
|
|
277
|
+
const emit = (channel, payload) => {
|
|
278
|
+
const mac = createHmac('sha256', outputKey).update(`${channel} ${payload}`).digest('hex').slice(0, 32);
|
|
279
|
+
writeSync(RESULT_FD, `${mac} ${channel} ${payload}\n`);
|
|
280
|
+
};
|
|
281
|
+
const logsOut = { write: (text) => emit(CHANNEL.log, text.replace(/\n/g, ' ').trimEnd()) };
|
|
282
|
+
|
|
283
|
+
const result = {
|
|
284
|
+
markets_run: 0,
|
|
285
|
+
events_seen: 0,
|
|
286
|
+
fees_paid: 0,
|
|
287
|
+
log_truncated: false,
|
|
288
|
+
budget: null,
|
|
289
|
+
market_summaries: [],
|
|
290
|
+
crosschecks: [],
|
|
291
|
+
rejection: null,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const finish = (code) => {
|
|
295
|
+
result.budget = monitor.summary();
|
|
296
|
+
// Projected like the rows: `result` is an ordinary object built AFTER
|
|
297
|
+
// untrusted code has run, and Object.prototype.toJSON reaches every
|
|
298
|
+
// ordinary object — so a captured stringify alone still let a strategy
|
|
299
|
+
// forge markets_run, fees_paid, crosschecks and the budget summary.
|
|
300
|
+
emit(CHANNEL.result, stringify(projectResult(result)));
|
|
301
|
+
return code;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// One monitor across the whole run: the budget is a p99 over events, and
|
|
305
|
+
// resetting it per market would let a strategy be pathological on every
|
|
306
|
+
// market and never trip.
|
|
307
|
+
const monitor = new BudgetMonitor({ limitMicros: job.limits?.perEventBudgetMicros ?? 400 });
|
|
308
|
+
|
|
309
|
+
let Klass;
|
|
310
|
+
try {
|
|
311
|
+
Klass = await loadStrategy(srcDir, job.entry);
|
|
312
|
+
checkHooks(Klass, job.hooks, job.arities ?? {});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
result.rejection = { code: err.code ?? 'E_ENTRY', detail: err.detail ?? String(err?.message ?? err) };
|
|
315
|
+
return finish(EXIT.rejected);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Session mode shares one portfolio and one instance across every market;
|
|
319
|
+
// market mode gets a fresh instance per market, which is what lets a run be
|
|
320
|
+
// sharded at all.
|
|
321
|
+
const shared = job.mode === 'session' ? new Portfolio({ feeBps: job.feeBps ?? 0 }) : null;
|
|
322
|
+
let sharedInstance = null;
|
|
323
|
+
|
|
324
|
+
// Markets stream in, one at a time, for as long as the worker sends them.
|
|
325
|
+
for (;;) {
|
|
326
|
+
const header = nextLine();
|
|
327
|
+
if (header == null) break;
|
|
328
|
+
let entry;
|
|
329
|
+
try {
|
|
330
|
+
entry = parseJson(header);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
logsOut.write(`[runner] malformed market header: ${err.message}\n`);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The market's events, pulled ONE AT A TIME as the replay loop asks for
|
|
337
|
+
// them. Nothing here holds more than the current row, which is the whole
|
|
338
|
+
// point — see syncLineReader above.
|
|
339
|
+
let seenEvents = 0;
|
|
340
|
+
let lastBook = null;
|
|
341
|
+
const remaining = { n: entry.n ?? 0 };
|
|
342
|
+
function* eventStream() {
|
|
343
|
+
while (remaining.n > 0) {
|
|
344
|
+
remaining.n -= 1;
|
|
345
|
+
const line = nextLine();
|
|
346
|
+
if (line == null) return;
|
|
347
|
+
let ev;
|
|
348
|
+
try {
|
|
349
|
+
ev = parseJson(line);
|
|
350
|
+
} catch {
|
|
351
|
+
// A corrupt row in OUR OWN data is not the strategy's problem. The
|
|
352
|
+
// worker reconciles what it sent against what came back.
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
seenEvents += 1;
|
|
356
|
+
if (ev.kind === 'book' && ev.snapshot) lastBook = ev;
|
|
357
|
+
yield ev;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Drain whatever the replay did not consume, so the stream stays framed. */
|
|
362
|
+
const drainRest = () => {
|
|
363
|
+
while (remaining.n > 0) {
|
|
364
|
+
remaining.n -= 1;
|
|
365
|
+
if (nextLine() == null) return;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const pf = shared ?? new Portfolio({ feeBps: job.feeBps ?? 0 });
|
|
370
|
+
let instance;
|
|
371
|
+
if (shared) {
|
|
372
|
+
sharedInstance ??= new Klass();
|
|
373
|
+
instance = sharedInstance;
|
|
374
|
+
} else {
|
|
375
|
+
instance = new Klass();
|
|
376
|
+
}
|
|
377
|
+
// A fresh copy per instance. Sharing one object across markets let a
|
|
378
|
+
// strategy that wrote to ctx.p in market 1 change its own behaviour in
|
|
379
|
+
// market 2 — which is exactly the cross-market state that per-market
|
|
380
|
+
// reset exists to prevent, and it would break sharding silently.
|
|
381
|
+
instance.p = { ...(job.params ?? {}) };
|
|
382
|
+
|
|
383
|
+
const before = { trades: pf.trades.length, fills: pf.fills.length };
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const out = replayMarket({
|
|
387
|
+
market: entry.market,
|
|
388
|
+
events: eventStream(),
|
|
389
|
+
strategy: instance,
|
|
390
|
+
hooks: job.hooks,
|
|
391
|
+
portfolio: pf,
|
|
392
|
+
fillDelayMs: job.fillDelayMs ?? 0,
|
|
393
|
+
logLimit: job.limits?.logLinesPerMarketDay ?? 10_000,
|
|
394
|
+
budget: monitor,
|
|
395
|
+
seed: job.seed ?? 1,
|
|
396
|
+
feeBps: job.feeBps ?? 0,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
drainRest();
|
|
400
|
+
result.markets_run += 1;
|
|
401
|
+
result.events_seen += seenEvents;
|
|
402
|
+
if (out.logTruncated) result.log_truncated = true;
|
|
403
|
+
for (const line of out.logs) logsOut.write(`${entry.market.market_id} ${line}\n`);
|
|
404
|
+
for (const c of out.crosschecks) result.crosschecks.push(c);
|
|
405
|
+
|
|
406
|
+
// Tracked as the stream went past rather than scanned afterwards: there
|
|
407
|
+
// is no array left to scan, which is the point. The worker prices the
|
|
408
|
+
// report's baselines from its OWN copy anyway; this is informational.
|
|
409
|
+
result.market_summaries.push({
|
|
410
|
+
market_id: entry.market.market_id,
|
|
411
|
+
asset: entry.market.asset ?? null,
|
|
412
|
+
interval: entry.market.interval ?? null,
|
|
413
|
+
outcome: entry.market.outcome ?? null,
|
|
414
|
+
up_px: lastBook?.levels?.UP?.asks?.[0]?.[0] ?? null,
|
|
415
|
+
down_px: lastBook?.levels?.DOWN?.asks?.[0]?.[0] ?? null,
|
|
416
|
+
stream: entry.stream ?? null,
|
|
417
|
+
});
|
|
418
|
+
} catch (err) {
|
|
419
|
+
drainRest();
|
|
420
|
+
if (err instanceof RunAbort) {
|
|
421
|
+
result.rejection = { code: err.code, detail: `${entry.market.market_id}: ${err.detail}` };
|
|
422
|
+
// A budget breach kills the shard, and a strategy that throws is not
|
|
423
|
+
// going to stop throwing on the next market. Either way the run is
|
|
424
|
+
// over and nothing is billed.
|
|
425
|
+
flush(pf, before, emit, entry.market.market_id);
|
|
426
|
+
return finish(err.code === 'E_BUDGET' ? EXIT.budget : EXIT.rejected);
|
|
427
|
+
}
|
|
428
|
+
result.rejection = { code: 'E_RUNTIME', detail: `${entry.market.market_id}: ${err?.message ?? err}` };
|
|
429
|
+
return finish(EXIT.rejected);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!shared) {
|
|
433
|
+
flush(pf, before, emit, entry.market.market_id);
|
|
434
|
+
result.fees_paid += pf.feesPaid;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (shared) {
|
|
439
|
+
flush(shared, { trades: 0, fills: 0 }, emit, null);
|
|
440
|
+
result.fees_paid = shared.feesPaid;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return finish(EXIT.ok);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Write everything a market added to the two logs. */
|
|
447
|
+
function flush(pf, before, emit, marketId) {
|
|
448
|
+
for (let i = before.trades; i < pf.trades.length; i += 1) {
|
|
449
|
+
emit(CHANNEL.trade, stringify(projectRow(pf.trades[i], TRADE_FIELDS)));
|
|
450
|
+
}
|
|
451
|
+
for (let i = before.fills; i < pf.fills.length; i += 1) {
|
|
452
|
+
emit(CHANNEL.fill, stringify(projectRow(pf.fills[i], FILL_FIELDS)));
|
|
453
|
+
}
|
|
454
|
+
// Keep memory flat across hundreds of market-days: once written, the rows
|
|
455
|
+
// are the worker's problem, not ours.
|
|
456
|
+
if (marketId) {
|
|
457
|
+
pf.trades.length = before.trades;
|
|
458
|
+
pf.fills.length = before.fills;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
main()
|
|
463
|
+
.then((code) => process.exit(code))
|
|
464
|
+
.catch((err) => {
|
|
465
|
+
process.stderr.write(`${err?.stack ?? err}\n`);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Type declarations for the `outcometick` strategy SDK.
|
|
2
|
+
//
|
|
3
|
+
// Hand-written against runner/engine/replay.mjs rather than generated, because
|
|
4
|
+
// the runtime is plain ESM. The value here is that a strategy which
|
|
5
|
+
// type-checks is a strategy the validator will accept: the hook names, the
|
|
6
|
+
// hook arities and the shape of `ctx` are all things the queue rejects on, and
|
|
7
|
+
// finding out at compile time is free while finding out after queueing is not.
|
|
8
|
+
//
|
|
9
|
+
// Anything not declared here does not exist at runtime either. `ctx` is frozen
|
|
10
|
+
// and the SDK deliberately exposes no way to reach the network, the clock or
|
|
11
|
+
// the filesystem — see the docs' "Not supported" list.
|
|
12
|
+
|
|
13
|
+
export type Side = 'UP' | 'DOWN';
|
|
14
|
+
|
|
15
|
+
export declare const SIDES: readonly ['UP', 'DOWN'];
|
|
16
|
+
|
|
17
|
+
/** One level of resting depth: [price, size]. */
|
|
18
|
+
export type Level = [number, number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The book as of one millisecond, frozen.
|
|
22
|
+
*
|
|
23
|
+
* A read-only facade over the engine's live book — mutating what you get back
|
|
24
|
+
* reaches nothing, and there is no way to see a later state through it.
|
|
25
|
+
*/
|
|
26
|
+
export interface BookView {
|
|
27
|
+
readonly marketId: string;
|
|
28
|
+
readonly ts: number;
|
|
29
|
+
/** Best ask for `side` — what you pay to open. */
|
|
30
|
+
best(side: Side): number | null;
|
|
31
|
+
bestBid(side: Side): number | null;
|
|
32
|
+
best_bid(side: Side): number | null;
|
|
33
|
+
/** Size available at or better than `bound` (all of it when omitted). */
|
|
34
|
+
depth(side: Side, bound?: number | null): number;
|
|
35
|
+
bidDepth(side: Side, bound?: number | null): number;
|
|
36
|
+
bid_depth(side: Side, bound?: number | null): number;
|
|
37
|
+
levels(side: Side, n?: number): Level[];
|
|
38
|
+
bidLevels(side: Side, n?: number): Level[];
|
|
39
|
+
bid_levels(side: Side, n?: number): Level[];
|
|
40
|
+
mid(side: Side): number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A settlement-stream observation. */
|
|
44
|
+
export interface Tick {
|
|
45
|
+
ts_ms: number;
|
|
46
|
+
value: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A market, as a hook sees it.
|
|
51
|
+
*
|
|
52
|
+
* `outcome` is absent everywhere except `onSettle`. That is not an oversight:
|
|
53
|
+
* before settlement the official label does not exist yet from the strategy's
|
|
54
|
+
* point of view, and handing it over early is look-ahead.
|
|
55
|
+
*/
|
|
56
|
+
export interface Market {
|
|
57
|
+
market_id: string;
|
|
58
|
+
asset: string;
|
|
59
|
+
interval: string;
|
|
60
|
+
strike: number | null;
|
|
61
|
+
open_ts_ms: number;
|
|
62
|
+
close_ts_ms: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The current position in this market, marked against the real book. */
|
|
66
|
+
export interface Position {
|
|
67
|
+
side: Side | null;
|
|
68
|
+
size: number;
|
|
69
|
+
avg_price: number | null;
|
|
70
|
+
unrealized: number | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A declared reference feed or external series, clamped to `ctx.now`. */
|
|
74
|
+
export interface FeedView<T = Record<string, number>> {
|
|
75
|
+
/** The most recent row stamped at or before ctx.now, or null. */
|
|
76
|
+
readonly last: T | null;
|
|
77
|
+
/** The last `n` rows at or before ctx.now, oldest first. */
|
|
78
|
+
window(n: number): T[];
|
|
79
|
+
/** The row in effect at `ts`, which may not be later than ctx.now. */
|
|
80
|
+
at(ts: number): T | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Everything a strategy can do.
|
|
85
|
+
*
|
|
86
|
+
* Constructed by the runner and frozen. Every accessor is clamped to the
|
|
87
|
+
* current event time, so none of it can see the future by construction rather
|
|
88
|
+
* than by convention.
|
|
89
|
+
*/
|
|
90
|
+
export interface Ctx<P = Record<string, unknown>> {
|
|
91
|
+
/** Params from the manifest, injected before the first hook. */
|
|
92
|
+
readonly p: P;
|
|
93
|
+
/** Current event time in epoch ms. Not the wall clock — there isn't one. */
|
|
94
|
+
readonly now: number;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The book as of this millisecond.
|
|
98
|
+
*
|
|
99
|
+
* Passing another market's id throws unless the manifest declared session
|
|
100
|
+
* mode; cross-market reads are what that mode is for.
|
|
101
|
+
*/
|
|
102
|
+
book(id?: string | null): BookView;
|
|
103
|
+
|
|
104
|
+
/** The last `n` ticks already seen, oldest first. Always a copy. */
|
|
105
|
+
history(n?: number): Tick[];
|
|
106
|
+
|
|
107
|
+
position(): Position;
|
|
108
|
+
|
|
109
|
+
/** Appended to logs.txt in the archive. Truncated past the log limit. */
|
|
110
|
+
log(msg: unknown): void;
|
|
111
|
+
|
|
112
|
+
/** The only randomness available, seeded and recorded in the report. */
|
|
113
|
+
random(seed?: number | null): number;
|
|
114
|
+
|
|
115
|
+
/** A reference feed declared in the manifest. Throws if undeclared. */
|
|
116
|
+
ref(name: string): FeedView;
|
|
117
|
+
|
|
118
|
+
/** An external series declared in the manifest. Throws if undeclared. */
|
|
119
|
+
ext(name: string): FeedView;
|
|
120
|
+
|
|
121
|
+
/** Rolling helpers over the tick history. Identical across languages. */
|
|
122
|
+
zscore(value: number, opts?: { window?: number }): number;
|
|
123
|
+
sma(window?: number): number | null;
|
|
124
|
+
stdev(window?: number): number;
|
|
125
|
+
ema(window?: number): number | null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record the strategy's own recompute of the outcome against the official
|
|
129
|
+
* one. Recorded for the cross-check panel, never enforced — a mismatch is
|
|
130
|
+
* information, not a failed run.
|
|
131
|
+
*/
|
|
132
|
+
assert_outcome(market: unknown, outcome: Side): void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OrderInit {
|
|
136
|
+
side: Side;
|
|
137
|
+
/** Contracts. Must be positive. */
|
|
138
|
+
size: number;
|
|
139
|
+
/**
|
|
140
|
+
* A bound in whichever direction protects you: a ceiling when opening, a
|
|
141
|
+
* floor when reducing. Must be within [0, 1] — a binary outcome token
|
|
142
|
+
* trades nowhere else.
|
|
143
|
+
*/
|
|
144
|
+
limit?: number | null;
|
|
145
|
+
holdS?: number | null;
|
|
146
|
+
hold_s?: number | null;
|
|
147
|
+
reduceOnly?: boolean;
|
|
148
|
+
reduce_only?: boolean;
|
|
149
|
+
/** Only 'ioc' is modelled; anything else is rejected at construction. */
|
|
150
|
+
tif?: 'ioc';
|
|
151
|
+
tag?: string | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* An order a hook returns.
|
|
156
|
+
*
|
|
157
|
+
* Never sent — returned, and matched by the runner against the depth that was
|
|
158
|
+
* actually resting at that millisecond.
|
|
159
|
+
*/
|
|
160
|
+
export declare class Order {
|
|
161
|
+
constructor(init: OrderInit);
|
|
162
|
+
readonly side: Side;
|
|
163
|
+
readonly size: number;
|
|
164
|
+
readonly limit: number | null;
|
|
165
|
+
readonly hold_s: number | null;
|
|
166
|
+
readonly reduce_only: boolean;
|
|
167
|
+
readonly tif: 'ioc';
|
|
168
|
+
readonly tag: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Base class for a submitted strategy.
|
|
173
|
+
*
|
|
174
|
+
* The hooks are intentionally not declared as members: implementing one you
|
|
175
|
+
* did not list in the manifest does nothing, and listing one you did not
|
|
176
|
+
* implement is a rejection. Declare them in `outcometick.json` and write them
|
|
177
|
+
* with these signatures.
|
|
178
|
+
*
|
|
179
|
+
* onMarketOpen(ctx: Ctx, market: Market): void
|
|
180
|
+
* onTick(ctx: Ctx, tick: Tick): Order | null
|
|
181
|
+
* onBook(ctx: Ctx, book: BookView): Order | null
|
|
182
|
+
* onTrade(ctx: Ctx, trade: Tick): Order | null
|
|
183
|
+
* onSettle(ctx: Ctx, market: Market, outcome: Side): void
|
|
184
|
+
*/
|
|
185
|
+
export declare class Strategy<P = Record<string, unknown>> {
|
|
186
|
+
/** Params from the manifest, injected by the runner before the first hook. */
|
|
187
|
+
p: P;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
declare const _default: {
|
|
191
|
+
Strategy: typeof Strategy;
|
|
192
|
+
Order: typeof Order;
|
|
193
|
+
SIDES: typeof SIDES;
|
|
194
|
+
};
|
|
195
|
+
export default _default;
|