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,496 @@
1
+ // The event loop: what actually drives a strategy.
2
+ //
3
+ // Control is inverted. The strategy has no main(), no loop, no clock and no
4
+ // file handles — this module owns all of it and hands over events one at a
5
+ // time, in event-time order. Look-ahead is impossible here not because we
6
+ // filter it out but because future rows are not in the process yet: the caller
7
+ // feeds an iterator and we never read ahead of the cursor.
8
+ //
9
+ // Decode once, replay many. A market-day's events are decoded by the caller and
10
+ // handed here as an array; running the same array again with different params
11
+ // is what makes a 36-cell parameter sweep cost one market-day, and what lets
12
+ // the latency panel re-price every fill at six different delays. Both are our
13
+ // CPU, not the customer's data.
14
+
15
+ import { Book } from './book.mjs';
16
+ import { Portfolio } from './portfolio.mjs';
17
+
18
+ /** Event kinds the loop understands, in the order they dispatch. */
19
+ export const EVENT_KINDS = Object.freeze(['tick', 'book', 'trade']);
20
+
21
+ /**
22
+ * Per-event budget.
23
+ *
24
+ * Measured over the strategy's own hook, not the loop around it. A p99 breach
25
+ * kills the shard rather than the run: one pathological market must not cost
26
+ * the customer the other 719.
27
+ */
28
+ export class BudgetMonitor {
29
+ constructor({ limitMicros = 400, sampleFloor = 200, tolerance = 0.01 } = {}) {
30
+ this.limitMicros = limitMicros;
31
+ this.sampleFloor = sampleFloor;
32
+ this.tolerance = tolerance;
33
+ this.count = 0;
34
+ this.breaches = 0;
35
+ this.maxMicros = 0;
36
+ this.totalMicros = 0;
37
+ }
38
+
39
+ record(micros) {
40
+ this.count += 1;
41
+ this.totalMicros += micros;
42
+ if (micros > this.maxMicros) this.maxMicros = micros;
43
+ if (micros > this.limitMicros) this.breaches += 1;
44
+ }
45
+
46
+ /**
47
+ * A single slow event is not a breach — a JIT warm-up or a GC pause is not
48
+ * the strategy's fault. Sustained breach past the p99 tolerance is.
49
+ */
50
+ get breached() {
51
+ return this.count >= this.sampleFloor && this.breaches / this.count > this.tolerance;
52
+ }
53
+
54
+ get avgMicros() { return this.count ? this.totalMicros / this.count : 0; }
55
+
56
+ summary() {
57
+ return {
58
+ events: this.count,
59
+ breaches: this.breaches,
60
+ breach_rate: this.count ? this.breaches / this.count : 0,
61
+ avg_micros: this.avgMicros,
62
+ max_micros: this.maxMicros,
63
+ limit_micros: this.limitMicros,
64
+ };
65
+ }
66
+ }
67
+
68
+ /** Raised when a run must stop for a reason the submitter can act on. */
69
+ export class RunAbort extends Error {
70
+ constructor(code, detail) {
71
+ super(detail);
72
+ this.name = 'RunAbort';
73
+ this.code = code;
74
+ this.detail = detail;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Build the object a strategy sees, plus a private handle for the loop.
80
+ *
81
+ * A CLOSURE, not a class with underscore-prefixed fields. The earlier version
82
+ * held `_pf`, `_book`, `_history` and `_logs` as ordinary properties, which
83
+ * meant a strategy could reach `ctx._pf.trades` and push a fabricated settled
84
+ * trade straight into the report — verified: a submitted strategy could invent
85
+ * a $990,000 profit, or delete its real losses, and the worker would archive it
86
+ * as fact. That defeats the entire claim that the report is computed outside
87
+ * the strategy from engine-owned facts.
88
+ *
89
+ * Nothing below is reachable from the returned `ctx`: the internals exist only
90
+ * as locals captured by the methods, and `control` is kept by the loop.
91
+ *
92
+ * `now` is a getter for the same reason. It looks like a harmless field, but
93
+ * `ctx.ref()` and `ctx.ext()` use it as the point-in-time cursor — a strategy
94
+ * that could assign it would read reference rows from the future.
95
+ */
96
+ /**
97
+ * A read-only view of a book.
98
+ *
99
+ * `ctx.book()` used to hand back the LIVE Book, whose ladders are plain arrays.
100
+ * A strategy could `unshift` a level that never existed and then fill against
101
+ * it — verified: an order filled at $0.01 in a market whose real best ask was
102
+ * $0.90, and the fabricated fill went into the report as fact.
103
+ *
104
+ * Rebuilt per call rather than cached: the underlying book changes on every
105
+ * book event, and a stale view would be a different bug in the same place.
106
+ */
107
+ function bookView(book) {
108
+ if (!book) return null;
109
+ return Object.freeze({
110
+ marketId: book.marketId,
111
+ get ts() { return book.ts; },
112
+ best: (side) => book.best(side),
113
+ bestBid: (side) => book.bestBid(side),
114
+ best_bid: (side) => book.bestBid(side),
115
+ depth: (side, bound = null) => book.depth(side, bound),
116
+ bidDepth: (side, bound = null) => book.bidDepth(side, bound),
117
+ bid_depth: (side, bound = null) => book.bidDepth(side, bound),
118
+ // .levels() already returns freshly-built pairs, so mutating the result
119
+ // reaches nothing.
120
+ levels: (side, n = 10) => book.levels(side, n),
121
+ bidLevels: (side, n = 10) => book.bidLevels(side, n),
122
+ bid_levels: (side, n = 10) => book.bidLevels(side, n),
123
+ mid: (side) => book.mid(side),
124
+ });
125
+ }
126
+
127
+ function createCtx({ params, portfolio, marketId, market, logLimit, references, series, rng }) {
128
+ const history = [];
129
+ const logs = [];
130
+ const crosschecks = [];
131
+ let book = null;
132
+ let now = 0;
133
+ let logTruncated = false;
134
+
135
+ const refs = references ?? new Map();
136
+ const ext = series ?? new Map();
137
+
138
+ const tail = (window) => {
139
+ const n = Math.max(1, Math.min(Number(window) || 1, history.length));
140
+ return history.slice(history.length - n).map((t) => t.value);
141
+ };
142
+
143
+ const ctx = {
144
+ p: params,
145
+
146
+ get now() { return now; },
147
+
148
+ /** The book as of this millisecond. Never a future state. */
149
+ book(id = null) {
150
+ if (id && id !== marketId) {
151
+ // Cross-market reads are what session mode is for. Returning another
152
+ // market's book would silently break the sharding guarantee.
153
+ throw new RunAbort('E_STATE',
154
+ `ctx.book(${id}) from market ${marketId}: cross-market state needs mode "session"`);
155
+ }
156
+ return bookView(book);
157
+ },
158
+
159
+ /** The last n ticks already seen. Never more, by construction. */
160
+ history(n = 1) {
161
+ const k = Math.max(0, Math.min(Number(n) || 0, history.length));
162
+ // A COPY: handing back the live array would let a strategy rewrite the
163
+ // series its own indicators are computed from.
164
+ return history.slice(history.length - k).map((t) => ({ ...t }));
165
+ },
166
+
167
+ position() {
168
+ // The REAL book here: this is the engine marking the position, not the
169
+ // strategy reading it.
170
+ return portfolio.position(marketId, book);
171
+ },
172
+
173
+ log(msg) {
174
+ if (logs.length >= logLimit) { logTruncated = true; return; }
175
+ logs.push(`${now} ${String(msg)}`);
176
+ },
177
+
178
+ /** Seeded generator — the only randomness available, and it is recorded. */
179
+ random(seed = null) { return rng(seed); },
180
+
181
+ /**
182
+ * A declared reference feed as of now.
183
+ *
184
+ * `.last`, `.window(n)` and `.at(ts)` can never see a row stamped after
185
+ * ctx.now, so a carelessly built signal cannot leak the future into a
186
+ * backtest.
187
+ */
188
+ ref(name) {
189
+ const feed = refs.get(name);
190
+ if (!feed) throw new RunAbort('E_MANIFEST', `reference feed ${name} was not declared in the manifest`);
191
+ return feed.viewAt(now);
192
+ },
193
+
194
+ ext(name) {
195
+ const s = ext.get(name);
196
+ if (!s) throw new RunAbort('E_MANIFEST', `series ${name} was not declared in the manifest`);
197
+ return s.viewAt(now);
198
+ },
199
+
200
+ /** Rolling helpers over the tick history. Identical across languages. */
201
+ zscore(value, { window = 60 } = {}) {
202
+ const xs = tail(window);
203
+ if (xs.length < 2) return 0;
204
+ const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
205
+ const variance = xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length;
206
+ const sd = Math.sqrt(variance);
207
+ return sd === 0 ? 0 : (value - mean) / sd;
208
+ },
209
+
210
+ sma(window = 60) {
211
+ const xs = tail(window);
212
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
213
+ },
214
+
215
+ stdev(window = 60) {
216
+ const xs = tail(window);
217
+ if (xs.length < 2) return 0;
218
+ const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
219
+ return Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length);
220
+ },
221
+
222
+ ema(window = 60) {
223
+ const xs = tail(window);
224
+ if (!xs.length) return null;
225
+ const k = 2 / (xs.length + 1);
226
+ return xs.reduce((acc, x, i) => (i === 0 ? x : x * k + acc * (1 - k)), 0);
227
+ },
228
+
229
+ /**
230
+ * Compare the strategy's own recompute against the official settlement.
231
+ *
232
+ * Recorded rather than enforced: a mismatch is information for the
233
+ * cross-check panel, not grounds to fail someone's run.
234
+ */
235
+ assert_outcome(_market, outcome) {
236
+ // The first argument is IGNORED for everything that matters. It used to
237
+ // supply both `official` and `market_id`, so a strategy could call
238
+ // ctx.assert_outcome({ market_id: realId, outcome: 'UP' }, 'UP') — or
239
+ // simply mutate the market object it was handed — and book itself a
240
+ // recompute match that never happened. The cross-check panel's whole
241
+ // value is that it is the ARCHIVE's answer, not the strategy's.
242
+ const official = market?.outcome ?? null;
243
+ crosschecks.push({
244
+ market_id: marketId,
245
+ claimed: outcome,
246
+ official,
247
+ match: official === outcome,
248
+ });
249
+ },
250
+ };
251
+
252
+ const control = {
253
+ setNow(v) { now = v; },
254
+ setBook(b) { book = b; },
255
+ // A COPY. The same object is handed to the hook, and a strategy that
256
+ // writes to `tick.value` would otherwise be rewriting the series its own
257
+ // zscore/sma/ema are computed from — and the report's prices with it.
258
+ pushTick(ev) { history.push({ ...ev }); },
259
+ logs,
260
+ crosschecks,
261
+ get logTruncated() { return logTruncated; },
262
+ };
263
+
264
+ return { ctx, control };
265
+ }
266
+
267
+ /** Deterministic PRNG. The seed is recorded in the report. */
268
+ export function makeRng(runSeed) {
269
+ return (seed = null) => {
270
+ // splitmix32 — small, fast, and identical to the Python harness's copy.
271
+ let s = ((seed == null ? runSeed : Number(seed)) >>> 0);
272
+ return () => {
273
+ s = (s + 0x9e3779b9) >>> 0;
274
+ let z = s;
275
+ z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0;
276
+ z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
277
+ return ((z ^ (z >>> 15)) >>> 0) / 4294967296;
278
+ };
279
+ };
280
+ }
281
+
282
+ /** Which hook an event dispatches to. */
283
+ const HOOK_FOR = { tick: 'on_tick', book: 'on_book', trade: 'on_trade' };
284
+
285
+ /**
286
+ * Replay one market.
287
+ *
288
+ * @param {object} market metadata: market_id, asset, strike, outcome, close_ts_ms
289
+ * @param {Array} events already merged into event-time order by the caller
290
+ * @param {object} strategy the instance, with hooks under their canonical names
291
+ * @param {object} opts
292
+ * @param {number} opts.fillDelayMs match every order this much later than the
293
+ * decision. Zero is "as captured"; the latency panel is this same replay at
294
+ * 100ms, 250ms, 500ms, 1s and 2s.
295
+ */
296
+ export function replayMarket({
297
+ market, events, strategy, hooks,
298
+ portfolio = null,
299
+ fillDelayMs = 0,
300
+ logLimit = 10_000,
301
+ budget = null,
302
+ references = null,
303
+ series = null,
304
+ seed = 1,
305
+ feeBps = 0,
306
+ }) {
307
+ const marketId = market.market_id;
308
+ const pf = portfolio ?? new Portfolio({ feeBps });
309
+ const book = new Book(marketId);
310
+ const monitor = budget ?? new BudgetMonitor();
311
+ // The engine keeps its OWN copy and hands the strategy a different one.
312
+ // Both halves matter: settlement reads `outcome` from here, so a strategy
313
+ // that mutated the object it was handed would have forged not just the
314
+ // cross-check panel but its own PnL — every position settling the way it
315
+ // said rather than the way the venue did.
316
+ const engineMarket = { ...market };
317
+
318
+ /**
319
+ * What a hook is allowed to see about the market, BEFORE it settles.
320
+ *
321
+ * `outcome` is a future fact and it is stripped. It arrived here because the
322
+ * worker's market metadata carries the settled result — so a strategy could
323
+ * read `market.outcome` in on_market_open, buy the winning side at whatever
324
+ * it was quoted at, and every number in the report became meaningless.
325
+ * Verified: a 900% return on a market the strategy was simply told the answer
326
+ * to. This is not an output-forgery hole; it is the documented SDK input
327
+ * handing over the answer.
328
+ *
329
+ * Only on_settle sees it, which is exactly what the docs say: "on_settle …
330
+ * carrying the official outcome and strike".
331
+ */
332
+ const preSettleMarket = () => {
333
+ const { outcome, ...rest } = engineMarket;
334
+ return rest;
335
+ };
336
+ const { ctx, control } = createCtx({
337
+ params: strategy.p ?? {},
338
+ portfolio: pf,
339
+ marketId,
340
+ market: engineMarket,
341
+ logLimit,
342
+ references,
343
+ series,
344
+ rng: makeRng(seed),
345
+ });
346
+ control.setBook(book);
347
+
348
+ // Orders decided at T but matched at T + fillDelayMs, and hold_s expiries.
349
+ /** @type {Array<{at:number, kind:'order'|'flatten', payload:any}>} */
350
+ const pending = [];
351
+ const schedule = (at, kind, payload) => {
352
+ let i = pending.length;
353
+ while (i > 0 && pending[i - 1].at > at) i -= 1;
354
+ pending.splice(i, 0, { at, kind, payload });
355
+ };
356
+
357
+ const drainUntil = (ts) => {
358
+ while (pending.length && pending[0].at <= ts) {
359
+ const job = pending.shift();
360
+ if (job.kind === 'order') {
361
+ const res = pf.execute({ book, order: job.payload, ts: job.at, marketId, how: 'exit' });
362
+ // hold_s is measured from the FILL, not the decision: a fill that
363
+ // landed late has not been held as long.
364
+ if (res?.filled > 0 && job.payload.hold_s > 0 && !job.payload.reduce_only) {
365
+ schedule(job.at + job.payload.hold_s * 1000, 'flatten', { side: job.payload.side });
366
+ }
367
+ } else {
368
+ pf.flatten(marketId, book, job.at, 'hold_expired');
369
+ }
370
+ }
371
+ };
372
+
373
+ const call = (name, ...args) => {
374
+ const fn = hooks[name] && strategy[hooks[name]];
375
+ if (typeof fn !== 'function') return undefined;
376
+ const t0 = process.hrtime.bigint();
377
+ let out;
378
+ try {
379
+ out = fn.call(strategy, ctx, ...args);
380
+ } catch (err) {
381
+ throw new RunAbort('E_RUNTIME', `${name} threw: ${err?.message ?? err}`);
382
+ }
383
+ monitor.record(Number(process.hrtime.bigint() - t0) / 1000);
384
+ return out;
385
+ };
386
+
387
+ const emit = (out, ts) => {
388
+ if (out == null) return;
389
+ const list = Array.isArray(out) ? out : [out];
390
+ for (const order of list) {
391
+ if (order == null) continue;
392
+ // `gtc` was advertised in the SDK reference and silently executed as a
393
+ // single IOC attempt: if the book did not fill at that instant the order
394
+ // vanished, even though the documented semantics say it rests until the
395
+ // market closes. That is a wrong fill, slippage and PnL number for an
396
+ // order type we told customers we supported.
397
+ //
398
+ // Refused rather than approximated, which is the same call the docs
399
+ // already make about resting orders: "we would rather ship it late than
400
+ // ship it flattering."
401
+ const tif = order.tif ?? 'ioc';
402
+ if (tif !== 'ioc') {
403
+ throw new RunAbort('E_MANIFEST',
404
+ `tif ${JSON.stringify(tif)} is not supported — only "ioc". Resting orders need a`
405
+ + ' queue-position model, and guessing at one inflates returns by multiples.');
406
+ }
407
+ schedule(ts + fillDelayMs, 'order', order);
408
+ }
409
+ };
410
+
411
+ // A fresh copy per call: whatever the strategy does to it reaches nothing.
412
+ call('on_market_open', preSettleMarket());
413
+ if (monitor.breached) {
414
+ throw new RunAbort('E_BUDGET', `per-event budget exceeded: ${JSON.stringify(monitor.summary())}`);
415
+ }
416
+
417
+ // `events` is any ITERABLE, not necessarily an array.
418
+ //
419
+ // The harness passes a generator that pulls one line off stdin per step, so
420
+ // the future is not in the process at all — which is what the docs claim and
421
+ // what was previously only approximately true. Nothing below may index it,
422
+ // take its length, or look ahead in it.
423
+
424
+ // The market's close, resolved BEFORE the loop because the loop has to stop
425
+ // there.
426
+ //
427
+ // The settlement feed is a per-DAY stream, and fetch-data hands every row of
428
+ // it to every market that settles on that stream — so a market closing at
429
+ // 10:00 was still being shown ticks from 14:00. A strategy could watch the
430
+ // price that decides its own settlement, hours after its market had closed,
431
+ // and log it. That is look-ahead in its purest form, and it survived the
432
+ // pending-order cutoff because that fixed the ORDERS and not the EVENTS.
433
+ // A market with no close time in the archive has no cutoff to apply; the
434
+ // last event seen becomes the close, tracked as we go rather than peeked.
435
+ const declaredClose = engineMarket.close_ts_ms ?? null;
436
+ const closeTs = declaredClose ?? Number.POSITIVE_INFINITY;
437
+ let seen = 0;
438
+ let lastTs = 0;
439
+
440
+ for (const ev of events) {
441
+ seen += 1;
442
+ lastTs = ev.ts_ms;
443
+ // Nothing past the close reaches a hook, the book, or the history.
444
+ if (ev.ts_ms > closeTs) break;
445
+ // Everything scheduled strictly BEFORE this event resolves against the book
446
+ // as it stood then — draining after applying the event would fill a delayed
447
+ // order against depth that arrived after it.
448
+ drainUntil(ev.ts_ms - 1);
449
+ control.setNow(ev.ts_ms);
450
+
451
+ if (ev.kind === 'book') {
452
+ if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
453
+ else book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
454
+ }
455
+ drainUntil(ev.ts_ms);
456
+
457
+ if (ev.kind === 'tick') control.pushTick(ev);
458
+
459
+ const hook = HOOK_FOR[ev.kind];
460
+ if (hook && hooks[hook]) emit(call(hook, ev), ev.ts_ms);
461
+
462
+ if (monitor.breached) {
463
+ throw new RunAbort('E_BUDGET', `per-event budget exceeded: ${JSON.stringify(monitor.summary())}`);
464
+ }
465
+ }
466
+
467
+ // Anything still queued lands AT THE CLOSE — not at its own future
468
+ // timestamp. Draining to Infinity executed a delayed order stamped after the
469
+ // market had already closed, producing trade rows with opened_ms later than
470
+ // closed_ms and letting late fills trade against a book that no longer
471
+ // existed. An order that had not landed by the close did not land.
472
+ // With no declared close, the last event seen IS the close — tracked as the
473
+ // stream went past, because there is no array to look back into.
474
+ const settleTs = declaredClose ?? lastTs;
475
+ control.setNow(settleTs);
476
+ drainUntil(settleTs);
477
+ // Whatever is still pending never filled. Dropped, not back-dated.
478
+ pending.length = 0;
479
+
480
+ call('on_settle', { ...engineMarket }, engineMarket.outcome);
481
+
482
+ const settled = engineMarket.outcome ? pf.settle(marketId, engineMarket.outcome, settleTs) : [];
483
+
484
+ return {
485
+ marketId,
486
+ asset: engineMarket.asset ?? null,
487
+ // What the engine PULLED, not what the caller had. With a stream the
488
+ // latter is unknowable without draining, and not draining is the point.
489
+ events: seen,
490
+ settled,
491
+ logs: control.logs,
492
+ logTruncated: control.logTruncated,
493
+ crosschecks: control.crosschecks,
494
+ budget: monitor.summary(),
495
+ };
496
+ }