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,71 @@
1
+ // The SDK surface a submitted Node.js strategy imports.
2
+ //
3
+ // The mirror of runner/harness/python/outcometick.py, and it has to exist as a
4
+ // resolvable PACKAGE rather than a file beside the harness: the docs, the
5
+ // editor sample and the analyser all say
6
+ //
7
+ // import { Strategy, Order } from "outcometick";
8
+ //
9
+ // and Node resolves that by walking node_modules upward from the strategy's own
10
+ // directory. Without a package the documented form fails at load time inside
11
+ // the sandbox — accepted by the validator, then rejected after queueing, which
12
+ // is the worst place to find out.
13
+ //
14
+ // Deliberately tiny. Everything a strategy can actually DO arrives through
15
+ // `ctx`, which the runner constructs; there is nothing here to reach out with.
16
+
17
+ export const SIDES = Object.freeze(['UP', 'DOWN']);
18
+
19
+ /**
20
+ * Base class for a submitted strategy.
21
+ *
22
+ * The hooks are not defined here on purpose. A default no-op `onTick` would
23
+ * turn "you declared a hook you did not implement" — a rejection fixable in
24
+ * seconds — into a run that quietly never trades and bills for an empty equity
25
+ * curve.
26
+ */
27
+ export class Strategy {
28
+ /** Params from the manifest, injected by the runner before the first hook. */
29
+ p = {};
30
+ }
31
+
32
+ /**
33
+ * An order a hook returns. Never sent — returned, and matched by the runner
34
+ * against the depth that was actually resting at that millisecond.
35
+ *
36
+ * `limit` is a bound in whichever direction protects you: a ceiling when
37
+ * opening, a floor when reducing.
38
+ */
39
+ export class Order {
40
+ constructor({ side, size, limit = null, holdS = null, hold_s = null,
41
+ reduceOnly = false, reduce_only = false, tif = 'ioc', tag = null } = {}) {
42
+ if (!SIDES.includes(side)) {
43
+ throw new Error(`side must be "UP" or "DOWN", got ${JSON.stringify(side)}`);
44
+ }
45
+ if (!(typeof size === 'number' && Number.isFinite(size) && size > 0)) {
46
+ throw new Error(`size must be a positive number, got ${JSON.stringify(size)}`);
47
+ }
48
+ if (limit != null && !(Number(limit) >= 0 && Number(limit) <= 1)) {
49
+ // A binary outcome token trades between 0 and 1. A limit outside that is
50
+ // not a price, and silently clamping it would fill an order the strategy
51
+ // never asked for.
52
+ throw new Error(`limit must be between 0 and 1, got ${JSON.stringify(limit)}`);
53
+ }
54
+ if (tif !== 'ioc') {
55
+ // Not modelled, so not accepted. See "Not supported yet" in the docs.
56
+ throw new Error(`tif must be "ioc"; ${JSON.stringify(tif)} is not supported yet`);
57
+ }
58
+ this.side = side;
59
+ this.size = Number(size);
60
+ this.limit = limit == null ? null : Number(limit);
61
+ // Both spellings accepted: the docs use holdS in the Node examples and
62
+ // hold_s is the wire field. Neither should be a trap.
63
+ const hold = holdS ?? hold_s;
64
+ this.hold_s = hold == null ? null : Number(hold);
65
+ this.reduce_only = Boolean(reduceOnly || reduce_only);
66
+ this.tif = tif;
67
+ this.tag = tag;
68
+ }
69
+ }
70
+
71
+ export default { Strategy, Order, SIDES };
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "outcometick",
3
+ "version": "1.4.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": { ".": "./index.mjs" }
8
+ }
@@ -0,0 +1,255 @@
1
+ // The contract between the worker (outside the sandbox) and a harness (inside).
2
+ //
3
+ // The worker never trusts anything a harness writes: it validates the shape,
4
+ // bounds every array, and recomputes the report itself from the trade and fill
5
+ // logs. A harness runs in the same process as untrusted code, so its output is
6
+ // untrusted output — the container is the boundary, not the harness.
7
+ //
8
+ import { createHmac, timingSafeEqual } from 'node:crypto';
9
+
10
+ // This is also the reason the REPORT is not computed inside. Metrics,
11
+ // calibration, latency and slippage are all derived outside, in one shared
12
+ // implementation, from the two logs below. A Python run and a Node run
13
+ // therefore cannot produce differently-shaped reports even though they run
14
+ // different engines.
15
+
16
+ /**
17
+ * The job reaches the harness on STDIN, not as a file.
18
+ *
19
+ * It used to be written to /job/job.json and the events to a mounted /events.
20
+ * The static analysers forbid `open`, `os` and `pathlib`, but the allowlisted
21
+ * dependencies read files perfectly well — `pandas.read_json('/job/job.json')`
22
+ * needs none of them. That handed a strategy every market's settled outcome and
23
+ * the path to every event file, i.e. the whole future. Look-ahead being
24
+ * impossible is the one claim this product cannot lose.
25
+ *
26
+ * stdin is reachable in neither language without `process` (JS) or `sys`
27
+ * (Python), both of which the analysers refuse. Nothing with a future fact in it
28
+ * touches a path the strategy can open.
29
+ *
30
+ * The stream is newline-framed:
31
+ *
32
+ * <job json>
33
+ * {"market": {...}, "n": <count>}
34
+ * <event json> x count
35
+ * {"market": {...}, "n": <count>}
36
+ * ...
37
+ *
38
+ * Markets arrive one at a time so the harness's memory stays flat across a
39
+ * seven-hundred-market-day run, exactly as it did when reading files.
40
+ */
41
+ export const JOB_FILE = 'job.json';
42
+
43
+ /**
44
+ * Results come back over FD 3, authenticated. There is no output directory.
45
+ *
46
+ * `/out` used to be a writable bind mount holding trades.jsonl and fills.jsonl.
47
+ * A Python strategy declaring the allowlisted `pandas` could call
48
+ * `DataFrame.to_json('/out/trades.jsonl')` from `on_settle` — that is, AFTER
49
+ * being told the official outcome — and write profitable trades with valid
50
+ * market ids and in-window timestamps. It needed no analyser bypass at all, and
51
+ * reconcileRows cannot tell a forged row from an engine one: it checks that the
52
+ * market existed, not that the fill happened.
53
+ *
54
+ * Two changes, and both are needed:
55
+ *
56
+ * 1. Output is a PIPE, not a file. Rows already sent cannot be unsent, so a
57
+ * strategy cannot delete its losses either — which an HMAC alone would not
58
+ * have stopped.
59
+ * 2. Every line is MACed with a per-run key that arrives on stdin and is
60
+ * consumed before user code is imported. It never touches a filesystem, so
61
+ * writing to `/proc/self/fd/3` — which IS addressable, a pipe is not by
62
+ * itself enough — produces lines the worker drops.
63
+ *
64
+ * This does not defeat a strategy that achieves full code execution and digs
65
+ * the key out of a closure. It does defeat "declare pandas and write a file",
66
+ * which was the actual, verified hole.
67
+ *
68
+ * Line format: <mac> <channel> <payload>
69
+ */
70
+ export const CHANNEL = Object.freeze({
71
+ trade: 't',
72
+ fill: 'f',
73
+ log: 'l',
74
+ result: 'r',
75
+ });
76
+
77
+ /** The fd the harness writes results to. 0 is the job, 1 and 2 are discarded. */
78
+ export const RESULT_FD = 3;
79
+
80
+ /** Exit codes a harness may use. Anything else is treated as a crash. */
81
+ export const EXIT = Object.freeze({
82
+ ok: 0,
83
+ /** The strategy or the manifest is wrong — a rejection, and free. */
84
+ rejected: 10,
85
+ /** The strategy exceeded a limit. Also free: no report was produced. */
86
+ budget: 11,
87
+ });
88
+
89
+ /** Fields a trade row must carry. A row missing any of them is dropped. */
90
+ export const TRADE_FIELDS = Object.freeze([
91
+ 'market_id', 'side', 'size', 'entry_px', 'exit_px', 'pnl', 'fees',
92
+ 'opened_ms', 'closed_ms', 'how',
93
+ ]);
94
+
95
+ export const FILL_FIELDS = Object.freeze([
96
+ 'ts_ms', 'market_id', 'side', 'action', 'requested', 'filled', 'unfilled',
97
+ 'avg_px', 'worst_px', 'quoted_px', 'levels_walked', 'fee', 'realised',
98
+ ]);
99
+
100
+ const finite = (x) => typeof x === 'number' && Number.isFinite(x);
101
+
102
+ /**
103
+ * Validate one trade row from a harness.
104
+ *
105
+ * Returns the row narrowed to known fields, or null. Dropping rather than
106
+ * throwing: one malformed row out of a hundred thousand should cost that row,
107
+ * not the customer's whole run — and the count of dropped rows is reported so
108
+ * it cannot be silent.
109
+ */
110
+ export function parseTrade(raw) {
111
+ if (!raw || typeof raw !== 'object') return null;
112
+ if (typeof raw.market_id !== 'string' || !raw.market_id) return null;
113
+ if (raw.side !== 'UP' && raw.side !== 'DOWN') return null;
114
+ if (!finite(raw.size) || raw.size < 0) return null;
115
+ if (!finite(raw.pnl)) return null;
116
+ const px = (v) => (v == null ? null : (finite(v) && v >= 0 && v <= 1 ? v : undefined));
117
+ const entry = px(raw.entry_px);
118
+ const exit = px(raw.exit_px);
119
+ // `undefined` means a value was present but impossible — a price outside
120
+ // 0..1 is not a price on a binary market, and accepting it would put a
121
+ // fabricated number into the calibration panel.
122
+ if (entry === undefined || exit === undefined) return null;
123
+ return {
124
+ market_id: raw.market_id,
125
+ side: raw.side,
126
+ size: raw.size,
127
+ entry_px: entry,
128
+ exit_px: exit,
129
+ pnl: raw.pnl,
130
+ fees: finite(raw.fees) ? raw.fees : 0,
131
+ opened_ms: finite(raw.opened_ms) ? raw.opened_ms : null,
132
+ closed_ms: finite(raw.closed_ms) ? raw.closed_ms : null,
133
+ how: typeof raw.how === 'string' ? raw.how : 'exit',
134
+ outcome: raw.outcome === 'UP' || raw.outcome === 'DOWN' ? raw.outcome : undefined,
135
+ };
136
+ }
137
+
138
+ export function parseFill(raw) {
139
+ if (!raw || typeof raw !== 'object') return null;
140
+ if (typeof raw.market_id !== 'string' || !raw.market_id) return null;
141
+ if (raw.side !== 'UP' && raw.side !== 'DOWN') return null;
142
+ if (!finite(raw.requested) || !finite(raw.filled)) return null;
143
+ const px = (v) => (v == null ? null : (finite(v) ? v : null));
144
+ return {
145
+ ts_ms: finite(raw.ts_ms) ? raw.ts_ms : null,
146
+ market_id: raw.market_id,
147
+ side: raw.side,
148
+ action: raw.action === 'reduce' ? 'reduce' : 'open',
149
+ requested: raw.requested,
150
+ filled: raw.filled,
151
+ unfilled: finite(raw.unfilled) ? raw.unfilled : Math.max(0, raw.requested - raw.filled),
152
+ avg_px: px(raw.avg_px),
153
+ worst_px: px(raw.worst_px),
154
+ quoted_px: px(raw.quoted_px),
155
+ levels_walked: finite(raw.levels_walked) ? raw.levels_walked : 0,
156
+ fee: finite(raw.fee) ? raw.fee : 0,
157
+ realised: finite(raw.realised) ? raw.realised : 0,
158
+ tag: typeof raw.tag === 'string' ? raw.tag.slice(0, 64) : null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Parse a JSONL log from a harness, bounded.
164
+ *
165
+ * `maxRows` is a hard stop, not a suggestion: a harness that emits rows in a
166
+ * loop must not be able to exhaust the worker's memory from inside the
167
+ * sandbox. What was dropped is returned, never swallowed.
168
+ */
169
+ export function parseJsonl(text, parseRow, { maxRows = 2_000_000 } = {}) {
170
+ const rows = [];
171
+ let malformed = 0;
172
+ let truncated = false;
173
+ const lines = String(text ?? '').split('\n');
174
+ for (const line of lines) {
175
+ const s = line.trim();
176
+ if (!s) continue;
177
+ if (rows.length >= maxRows) { truncated = true; break; }
178
+ let raw;
179
+ try {
180
+ raw = JSON.parse(s);
181
+ } catch {
182
+ malformed += 1;
183
+ continue;
184
+ }
185
+ const parsed = parseRow(raw);
186
+ if (parsed) rows.push(parsed);
187
+ else malformed += 1;
188
+ }
189
+ return { rows, malformed, truncated };
190
+ }
191
+
192
+ /** Validate the result.json a harness writes. */
193
+ export function parseResult(raw) {
194
+ const r = raw && typeof raw === 'object' ? raw : {};
195
+ const marketSummaries = Array.isArray(r.market_summaries) ? r.market_summaries.slice(0, 100_000) : [];
196
+ const crosschecks = Array.isArray(r.crosschecks) ? r.crosschecks.slice(0, 100_000) : [];
197
+ return {
198
+ marketsRun: finite(r.markets_run) ? r.markets_run : 0,
199
+ eventsSeen: finite(r.events_seen) ? r.events_seen : 0,
200
+ feesPaid: finite(r.fees_paid) ? r.fees_paid : 0,
201
+ logTruncated: Boolean(r.log_truncated),
202
+ budget: r.budget && typeof r.budget === 'object' ? r.budget : null,
203
+ marketSummaries: marketSummaries.filter((m) => m && typeof m === 'object').map((m) => ({
204
+ market_id: typeof m.market_id === 'string' ? m.market_id : null,
205
+ asset: typeof m.asset === 'string' ? m.asset : null,
206
+ interval: typeof m.interval === 'string' ? m.interval : null,
207
+ outcome: m.outcome === 'UP' || m.outcome === 'DOWN' ? m.outcome : null,
208
+ up_px: finite(m.up_px) ? m.up_px : null,
209
+ down_px: finite(m.down_px) ? m.down_px : null,
210
+ stream: typeof m.stream === 'string' ? m.stream : null,
211
+ })),
212
+ crosschecks: crosschecks.filter((c) => c && typeof c === 'object').map((c) => ({
213
+ market_id: typeof c.market_id === 'string' ? c.market_id : null,
214
+ claimed: c.claimed ?? null,
215
+ official: c.official ?? null,
216
+ match: Boolean(c.match),
217
+ })),
218
+ rejection: r.rejection && typeof r.rejection === 'object'
219
+ ? { code: String(r.rejection.code ?? 'E_RUNTIME'), detail: String(r.rejection.detail ?? '').slice(0, 4000) }
220
+ : null,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Authenticate one output line.
226
+ *
227
+ * Truncated to 128 bits: this is a forgery check against a process that cannot
228
+ * read the key, not a long-term signature, and a shorter tag keeps the per-row
229
+ * overhead down across millions of rows.
230
+ */
231
+ export function lineMac(key, channel, payload) {
232
+ return createHmac('sha256', key).update(`${channel} ${payload}`).digest('hex').slice(0, 32);
233
+ }
234
+
235
+ /**
236
+ * Split and verify one output line.
237
+ *
238
+ * Returns null for anything that does not authenticate — which is what a
239
+ * strategy writing to /proc/self/fd/3 produces. Constant-time compare because
240
+ * the tag is a secret-derived value and the loop runs per row.
241
+ */
242
+ export function parseOutputLine(key, line) {
243
+ const first = line.indexOf(' ');
244
+ if (first !== 32) return null;
245
+ const second = line.indexOf(' ', first + 1);
246
+ if (second !== first + 2) return null;
247
+ const mac = line.slice(0, first);
248
+ const channel = line[first + 1];
249
+ const payload = line.slice(second + 1);
250
+
251
+ const want = Buffer.from(lineMac(key, channel, payload), 'utf8');
252
+ const got = Buffer.from(mac, 'utf8');
253
+ if (want.length !== got.length || !timingSafeEqual(want, got)) return null;
254
+ return { channel, payload };
255
+ }