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