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
package/cli/ot.mjs ADDED
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // `ot` — the command line the SDK docs tell customers to use.
3
+ //
4
+ // ot check . validate, free, no data
5
+ // ot run . --data ./polymarket-data-samples --date … replay locally
6
+ // ot submit . --assets btc,eth --from … --to … send it to the queue
7
+ //
8
+ // The one thing this file must get right is that `ot check` runs the SAME
9
+ // validator the queue runs. The docs promise "if it passes locally it will not
10
+ // be rejected on submit", and that promise only survives if there is exactly
11
+ // one implementation — so check() calls the very modules api/lib/backtest-
12
+ // routes.mjs calls, rather than reimplementing any of it.
13
+ //
14
+ // Written in Node because everything it needs already is: the contract, the
15
+ // validator, both analysers, the engine, the report and the archive writer. A
16
+ // Python submission is still run by the Python harness — the CLI spawns it the
17
+ // same way the worker does.
18
+
19
+ import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
20
+ import path from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+ import process from 'node:process';
23
+
24
+ import { SDK_VERSION, LIMITS, BacktestRejection } from '../api/lib/backtest-contract.mjs';
25
+ import { checkSubmission, MANIFEST_NAME } from '../api/lib/backtest-manifest.mjs';
26
+ import { analyzeSource } from '../runner/analyze/index.mjs';
27
+
28
+ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
29
+
30
+ ot check <dir>
31
+ Validate the manifest, the entry point, the hook signatures and every
32
+ import. Touches no data and costs nothing. This is the exact validator
33
+ the queue runs.
34
+
35
+ ot run <dir> --data <archive> [--date <YYYY-MM-DD>] [--out <file>]
36
+ Replay locally against a cloned sample archive, using the same engine
37
+ the queue uses. Writes a report archive.
38
+
39
+ ot submit <dir> --assets btc,eth --from <day> --to <day> [--venue polymarket]
40
+ Send it to the queue. Needs OT_BACKTEST_KEY.
41
+
42
+ ot status <run_id>
43
+ Where a submitted run got to, and what it cost. Needs OT_BACKTEST_KEY.
44
+
45
+ ot fetch <run_id> [--out <file>]
46
+ Download a finished run's archive. Needs OT_BACKTEST_KEY.
47
+
48
+ Common:
49
+ --json machine-readable output
50
+ --api <url> API base (default https://outcometick.com)
51
+
52
+ Free sample data:
53
+ git clone https://github.com/Ligengxin96/polymarket-data-samples
54
+ `;
55
+
56
+ /** Parse argv into {command, dir, flags}. */
57
+ export function parseArgs(argv) {
58
+ const [command, ...rest] = argv;
59
+ const flags = {};
60
+ const positional = [];
61
+ for (let i = 0; i < rest.length; i += 1) {
62
+ const a = rest[i];
63
+ if (!a.startsWith('--')) { positional.push(a); continue; }
64
+ const key = a.slice(2);
65
+ // Boolean flags take no value; everything else consumes the next token.
66
+ if (key === 'json') { flags.json = true; continue; }
67
+ const value = rest[i + 1];
68
+ if (value == null || value.startsWith('--')) {
69
+ throw new Error(`--${key} needs a value`);
70
+ }
71
+ flags[key] = value;
72
+ i += 1;
73
+ }
74
+ return { command, dir: positional[0] ?? '.', flags };
75
+ }
76
+
77
+ /**
78
+ * Read a submission directory.
79
+ *
80
+ * Only files the manifest could plausibly reference, and only from the top two
81
+ * levels — a strategy directory that happens to contain a virtualenv or a
82
+ * .git should not turn into a 400 MB submission attempt. The limits are
83
+ * enforced properly by the validator; this is about not reading the disk.
84
+ */
85
+ export async function readSubmission(dir) {
86
+ const out = [];
87
+ const walk = async (rel, depth) => {
88
+ const entries = await readdir(path.join(dir, rel || '.'), { withFileTypes: true });
89
+ for (const e of entries) {
90
+ const name = rel ? `${rel}/${e.name}` : e.name;
91
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
92
+ if (e.isDirectory()) {
93
+ if (depth > 0) await walk(name, depth - 1);
94
+ continue;
95
+ }
96
+ if (!/\.(py|mjs|js|json|csv)$/.test(e.name)) continue;
97
+ const full = path.join(dir, name);
98
+ const s = await stat(full);
99
+ if (s.size > LIMITS.maxTotalSourceBytes) {
100
+ throw new Error(`${name} is ${s.size} bytes, over the ${LIMITS.maxTotalSourceBytes} byte submission limit`);
101
+ }
102
+ out.push({ name, content: await readFile(full, 'utf8') });
103
+ }
104
+ };
105
+ await walk('', 1);
106
+ if (!out.some((f) => f.name === MANIFEST_NAME)) {
107
+ throw new Error(`no ${MANIFEST_NAME} in ${path.resolve(dir)}`);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Validate a submission exactly as the queue would.
114
+ *
115
+ * `scope` is optional: coverage can only be checked against a real range, and
116
+ * `ot check` on its own is about the manifest and the source. `ot submit`
117
+ * passes one, and so does the API.
118
+ */
119
+ export async function validate(files, scope = null) {
120
+ const checked = checkSubmission({ files, scope });
121
+ // analyzeSource, not a dispatch of our own: see runner/analyze/index.mjs for
122
+ // why the two sides sharing this exact function is the whole promise.
123
+ const analysis = await analyzeSource(checked);
124
+ return { ...checked, analysis };
125
+ }
126
+
127
+ /** Print a rejection the way the docs describe the codes. */
128
+ function reportRejection(err, json) {
129
+ if (json) {
130
+ process.stdout.write(`${JSON.stringify({ ok: false, ...(err.toJSON?.() ?? { code: 'E_RUNTIME', detail: err.message }) })}\n`);
131
+ return;
132
+ }
133
+ const code = err.code ?? 'E_RUNTIME';
134
+ const detail = err.detail ?? err.message;
135
+ process.stderr.write(`\n ${code}\n ${detail}\n\n`);
136
+ }
137
+
138
+ async function cmdCheck({ dir, flags }) {
139
+ const files = await readSubmission(dir);
140
+ const res = await validate(files);
141
+ if (flags.json) {
142
+ process.stdout.write(`${JSON.stringify({
143
+ ok: true,
144
+ manifest: res.manifest,
145
+ hooks: res.hookNames,
146
+ files: res.files.map((f) => ({ name: f.name, bytes: f.bytes })),
147
+ total_bytes: res.totalBytes,
148
+ imports: res.analysis.imports,
149
+ })}\n`);
150
+ return 0;
151
+ }
152
+ const { manifest } = res;
153
+ process.stdout.write(`\n ok — ${manifest.language}, entry ${manifest.entry.file}:${manifest.entry.className}\n`);
154
+ process.stdout.write(` hooks ${Object.entries(res.hookNames).map(([k, v]) => `${k} → ${v}`).join(', ')}\n`);
155
+ process.stdout.write(` datasets ${manifest.datasets.join(', ')}\n`);
156
+ if (manifest.reference.length) process.stdout.write(` reference ${manifest.reference.join(', ')}\n`);
157
+ process.stdout.write(` files ${res.files.length} / ${LIMITS.maxFiles} · ${(res.totalBytes / 1024).toFixed(1)} / ${LIMITS.maxTotalSourceBytes / 1024} KB\n`);
158
+ if (manifest.mode === 'session') {
159
+ process.stdout.write(' mode session — bills at 3× the market-day rate\n');
160
+ }
161
+ process.stdout.write('\n');
162
+ return 0;
163
+ }
164
+
165
+ async function main(argv) {
166
+ let parsed;
167
+ try {
168
+ parsed = parseArgs(argv);
169
+ } catch (err) {
170
+ process.stderr.write(`${err.message}\n`);
171
+ return 2;
172
+ }
173
+ const { command, flags } = parsed;
174
+
175
+ if (!command || command === 'help' || flags.help) {
176
+ process.stdout.write(USAGE);
177
+ return command ? 0 : 2;
178
+ }
179
+ if (command === 'version') {
180
+ process.stdout.write(`${SDK_VERSION}\n`);
181
+ return 0;
182
+ }
183
+
184
+ try {
185
+ if (command === 'check') return await cmdCheck(parsed);
186
+ if (command === 'run') {
187
+ const { cmdRun } = await import('./commands/run.mjs');
188
+ return await cmdRun(parsed);
189
+ }
190
+ if (command === 'submit') {
191
+ const { cmdSubmit } = await import('./commands/submit.mjs');
192
+ return await cmdSubmit(parsed);
193
+ }
194
+ if (command === 'status') {
195
+ const { cmdStatus } = await import('./commands/status.mjs');
196
+ return await cmdStatus(parsed);
197
+ }
198
+ if (command === 'fetch') {
199
+ const { cmdFetch } = await import('./commands/fetch.mjs');
200
+ return await cmdFetch(parsed);
201
+ }
202
+ process.stderr.write(`unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
203
+ return 2;
204
+ } catch (err) {
205
+ if (err instanceof BacktestRejection) {
206
+ reportRejection(err, flags.json);
207
+ return 1;
208
+ }
209
+ process.stderr.write(`${err.message}\n`);
210
+ return 1;
211
+ }
212
+ }
213
+
214
+ // Exact identity, not a suffix match. The npm package's bin shim is also called
215
+ // ot.mjs, so `endsWith('/ot.mjs')` was true when it merely IMPORTED this file —
216
+ // and the CLI ran twice, once from the shim and once from here.
217
+ const invokedDirectly = process.argv[1]
218
+ && pathToFileURL(process.argv[1]).href === import.meta.url;
219
+ if (invokedDirectly) {
220
+ main(process.argv.slice(2)).then((code) => process.exit(code));
221
+ }
222
+
223
+ export { main, USAGE };
package/index.d.ts ADDED
@@ -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;
package/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export { Strategy, Order, SIDES } from './runner/harness/node/sdk/index.mjs';
2
+ export { default } from './runner/harness/node/sdk/index.mjs';
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "outcometick",
3
+ "version": "1.4.0",
4
+ "description": "Strategy SDK and CLI for outcometick prediction-market backtests",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://outcometick.com/docs/sdk",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/outcometick/outcometick-sdk-ts.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/outcometick/outcometick-sdk-ts/issues"
14
+ },
15
+ "bin": {
16
+ "ot": "./bin/ot.mjs"
17
+ },
18
+ "types": "./index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./index.d.ts",
22
+ "default": "./index.mjs"
23
+ }
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "files": [
29
+ "api",
30
+ "runner",
31
+ "cli",
32
+ "bin",
33
+ "index.mjs",
34
+ "index.d.ts",
35
+ "!**/*.test.mjs",
36
+ "!**/*.test-d.ts"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test runner/analyze/*.test.mjs runner/engine/*.test.mjs runner/harness/node/*.test.mjs cli/*.test.mjs"
40
+ },
41
+ "dependencies": {
42
+ "acorn": "^8.18.0"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.6.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "danfojs-node": {
49
+ "optional": true
50
+ },
51
+ "mathjs": {
52
+ "optional": true
53
+ },
54
+ "decimal.js": {
55
+ "optional": true
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,40 @@
1
+ // Choosing the analyser for a submission's language.
2
+ //
3
+ // This exists as a shared function, in a file both sides can import, because
4
+ // the docs make a promise that only holds if there is ONE of it:
5
+ //
6
+ // ot check runs the exact validator the queue runs. If it passes locally
7
+ // it will not be rejected on submit.
8
+ //
9
+ // api/lib/backtest-routes.mjs and cli/ot.mjs both call this. They used to each
10
+ // dispatch on languageId themselves, and the two dispatches were not the same:
11
+ // the server threw for an unrecognised language while the CLI fell through to
12
+ // the JavaScript analyser. With only nodejs and python that difference was
13
+ // invisible, but it would have surfaced the day a third language was added —
14
+ // as `ot check` cheerfully passing a Go strategy it had analysed as JavaScript,
15
+ // followed by a rejection after queueing. Which is precisely the experience the
16
+ // promise above exists to prevent.
17
+
18
+ import { analyzeJavaScriptSubmission } from './javascript.mjs';
19
+ import { analyzePythonSubmission } from './python.mjs';
20
+
21
+ /**
22
+ * Statically analyse a checked submission.
23
+ *
24
+ * @param checked The result of checkSubmission — already validated, so the
25
+ * language is known to be one the contract lists.
26
+ */
27
+ export async function analyzeSource(checked) {
28
+ const { languageId, deps } = checked.manifest;
29
+ if (languageId === 'nodejs') {
30
+ return analyzeJavaScriptSubmission(checked.files, { deps });
31
+ }
32
+ if (languageId === 'python') {
33
+ return analyzePythonSubmission(checked.files, { deps });
34
+ }
35
+ // Fail closed. A language the contract accepts but no analyser covers is a
36
+ // gap on our side, and running it unanalysed is not the safe reading of it.
37
+ const err = new Error(`no analyser for ${languageId}`);
38
+ err.status = 503;
39
+ throw err;
40
+ }