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,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
|
+
}
|