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,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;