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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ligengxin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
GENERATED — do not edit this repository directly.
|
|
3
|
+
|
|
4
|
+
Every file here is built from the outcometick monorepo by
|
|
5
|
+
scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
|
|
6
|
+
An edit made here survives until the next publish and then disappears.
|
|
7
|
+
|
|
8
|
+
Generated from monorepo revision 532aa834e8f0f07b21958b0d3c30e7332fbbc34e.
|
|
9
|
+
-->
|
|
10
|
+
|
|
11
|
+
# outcometick
|
|
12
|
+
|
|
13
|
+
The TypeScript / JavaScript strategy SDK and the `ot` command line for
|
|
14
|
+
[outcometick.com](https://outcometick.com) — tick-level data for Polymarket and
|
|
15
|
+
Predict.fun crypto Up/Down markets.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npm i -g outcometick
|
|
19
|
+
|
|
20
|
+
ot check . # validate, free, no data
|
|
21
|
+
ot run . --data ./polymarket-data-samples --date … # replay locally
|
|
22
|
+
ot submit . --assets btc,eth --from … --to … # send it to the queue
|
|
23
|
+
ot status <run_id> # where it got to
|
|
24
|
+
ot fetch <run_id> # download the report
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Writing a strategy
|
|
28
|
+
|
|
29
|
+
Fully typed — `ot` ships `.d.ts` declarations, so a strategy that type-checks
|
|
30
|
+
is one the queue will accept.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Strategy, Order, type Ctx, type Tick } from "outcometick";
|
|
34
|
+
|
|
35
|
+
export default class MeanReversion extends Strategy<{ entry_z: number; size: number }> {
|
|
36
|
+
private entered = false;
|
|
37
|
+
|
|
38
|
+
onMarketOpen() { this.entered = false; }
|
|
39
|
+
|
|
40
|
+
onTick(ctx: Ctx<{ entry_z: number; size: number }>, tick: Tick): Order | null {
|
|
41
|
+
const z = ctx.zscore(tick.value, { window: 180 });
|
|
42
|
+
if (this.entered || Math.abs(z) < ctx.p.entry_z) return null;
|
|
43
|
+
const side = z > 0 ? "DOWN" : "UP";
|
|
44
|
+
const limit = ctx.book().best(side);
|
|
45
|
+
if (limit === null) return null;
|
|
46
|
+
this.entered = true;
|
|
47
|
+
return new Order({ side, size: ctx.p.size, limit });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Plain JavaScript works too — the runtime is ESM with no build step.
|
|
53
|
+
|
|
54
|
+
## The point of `ot check`
|
|
55
|
+
|
|
56
|
+
It runs the **same validator the queue runs** — the same module, not a copy. If
|
|
57
|
+
it passes locally it will not be rejected on submit. That is only true because
|
|
58
|
+
there is one implementation, which is why the validator and the static analysers
|
|
59
|
+
ship in this package rather than being reimplemented client-side.
|
|
60
|
+
|
|
61
|
+
## What `ot run` is and is not
|
|
62
|
+
|
|
63
|
+
It is the same engine, the same report and the same archive format the queue
|
|
64
|
+
uses, against a local copy of the archive:
|
|
65
|
+
|
|
66
|
+
git clone https://github.com/Ligengxin96/polymarket-data-samples
|
|
67
|
+
|
|
68
|
+
It is **not** the sandbox. Locally your strategy runs as you, with your
|
|
69
|
+
privileges, on your machine — which is fine, because it is your code. On our
|
|
70
|
+
machines it runs in a container with no network, no writable filesystem and a
|
|
71
|
+
hard CPU and wall-clock budget.
|
|
72
|
+
|
|
73
|
+
## Testing
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
npm install && npm test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires `python3` on PATH: one of the two static analysers is written in
|
|
80
|
+
Python, and the CLI drives it the same way the API does.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
Writing your strategy in Python instead? The SDK for it is
|
|
85
|
+
[`pip install outcometick`](https://pypi.org/project/outcometick/). `ot` runs
|
|
86
|
+
those too — it is the one CLI for both languages.
|
|
87
|
+
|
|
88
|
+
Full reference: https://outcometick.com/docs/sdk
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// The backtest contract: what a submission may declare, and what the runner
|
|
2
|
+
// promises to honour. This module is the single source of truth for both ends
|
|
3
|
+
// — the API validates against it, the runner builds its sandbox from it, and
|
|
4
|
+
// /v1/backtest/contract serves it to the SDK and the docs page.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is a closed set on purpose. An unknown language, dataset,
|
|
7
|
+
// hook or reference feed is a rejection, never a pass-through: the whole
|
|
8
|
+
// premise is a sealed, deterministic replay, and "we did not recognise it so we
|
|
9
|
+
// ignored it" is how a strategy silently gets fed something other than what it
|
|
10
|
+
// asked for.
|
|
11
|
+
|
|
12
|
+
import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
|
|
13
|
+
|
|
14
|
+
/** Manifest schema version. Field meanings never change within a version. */
|
|
15
|
+
export const SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/** SDK version reported by the docs page and stamped into every report. */
|
|
18
|
+
export const SDK_VERSION = '1.4.0';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Languages
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runtimes we execute, pinned exactly. A range is not accepted: two runs of the
|
|
26
|
+
* same source on different patch releases can differ in float formatting or
|
|
27
|
+
* dict ordering, and the product claims byte-identical reports.
|
|
28
|
+
*
|
|
29
|
+
* `compiled` languages are deliberately absent for now. Compiling untrusted
|
|
30
|
+
* source IS untrusted execution (build.rs, proc macros, go generate), needs a
|
|
31
|
+
* vendored offline module cache, and its own resource envelope — a different
|
|
32
|
+
* security problem from importing a module, not a bigger version of the same
|
|
33
|
+
* one. Adding one means adding an entry here plus a runner plugin; nothing in
|
|
34
|
+
* the API or the schema has to move.
|
|
35
|
+
*/
|
|
36
|
+
export const LANGUAGES = Object.freeze({
|
|
37
|
+
'python@3.14': Object.freeze({
|
|
38
|
+
id: 'python',
|
|
39
|
+
label: 'python',
|
|
40
|
+
runtime: 'python 3.14 · numpy, pandas, polars, scipy',
|
|
41
|
+
entrySignature: 'on_tick(ctx, tick) -> Order | None',
|
|
42
|
+
// Names only. Versions are ours and there is no install step inside the
|
|
43
|
+
// sandbox — the image already holds them.
|
|
44
|
+
deps: Object.freeze(['numpy', 'pandas', 'polars', 'scipy']),
|
|
45
|
+
sourceExtensions: Object.freeze(['.py', '.json']),
|
|
46
|
+
}),
|
|
47
|
+
'nodejs@24': Object.freeze({
|
|
48
|
+
id: 'nodejs',
|
|
49
|
+
label: 'node.js',
|
|
50
|
+
runtime: 'node 24 · danfo, mathjs, decimal.js',
|
|
51
|
+
entrySignature: 'onTick(ctx, tick) => Order | null',
|
|
52
|
+
deps: Object.freeze(['danfojs-node', 'mathjs', 'decimal.js']),
|
|
53
|
+
sourceExtensions: Object.freeze(['.mjs', '.js', '.json']),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const KNOWN_LANGUAGES = Object.freeze(Object.keys(LANGUAGES));
|
|
58
|
+
|
|
59
|
+
/** Hook names differ per language; the semantics do not. */
|
|
60
|
+
export const HOOK_NAMES = Object.freeze({
|
|
61
|
+
python: Object.freeze({
|
|
62
|
+
on_market_open: 'on_market_open',
|
|
63
|
+
on_tick: 'on_tick',
|
|
64
|
+
on_book: 'on_book',
|
|
65
|
+
on_trade: 'on_trade',
|
|
66
|
+
on_settle: 'on_settle',
|
|
67
|
+
}),
|
|
68
|
+
nodejs: Object.freeze({
|
|
69
|
+
on_market_open: 'onMarketOpen',
|
|
70
|
+
on_tick: 'onTick',
|
|
71
|
+
on_book: 'onBook',
|
|
72
|
+
on_trade: 'onTrade',
|
|
73
|
+
on_settle: 'onSettle',
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Hooks
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The five events the runner drives. `emits` says whether returning an Order
|
|
83
|
+
* from that hook is meaningful — returning one from a lifecycle hook is a
|
|
84
|
+
* signature error, not a silently dropped order.
|
|
85
|
+
*/
|
|
86
|
+
export const HOOKS = Object.freeze({
|
|
87
|
+
on_market_open: Object.freeze({ arity: 3, emitsOrders: false, requiresDataset: null }),
|
|
88
|
+
on_tick: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'settlement' }),
|
|
89
|
+
on_book: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'book' }),
|
|
90
|
+
on_trade: Object.freeze({ arity: 3, emitsOrders: true, requiresDataset: 'trades' }),
|
|
91
|
+
on_settle: Object.freeze({ arity: 4, emitsOrders: false, requiresDataset: null }),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const KNOWN_HOOKS = Object.freeze(Object.keys(HOOKS));
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Datasets
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Dataset names as the SDK sees them. These are NOT the archive's internal
|
|
102
|
+
* dataset names — `book` covers two different venue-specific trees, and
|
|
103
|
+
* `settlement` is not a stored stream at all but a per-market resolution. The
|
|
104
|
+
* mapping lives in backtest-datasets.mjs so this file stays a contract.
|
|
105
|
+
*/
|
|
106
|
+
export const DATASETS = Object.freeze({
|
|
107
|
+
settlement: 'Resolves per market to the stream that market actually settled on.',
|
|
108
|
+
prices: 'The 1 Hz Chainlink report stream.',
|
|
109
|
+
twap30s: 'TWAP over a 30-second lookback.',
|
|
110
|
+
twap60s: 'TWAP over a 60-second lookback.',
|
|
111
|
+
book: 'Order-book snapshots and deltas.',
|
|
112
|
+
trades: 'Every trade print on the venue.',
|
|
113
|
+
markets: 'Per-market metadata, strike and settlement outcome.',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const KNOWN_DATASETS = Object.freeze(Object.keys(DATASETS));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A derived stream is computed from one we hold rather than captured. It is
|
|
120
|
+
* always flagged as derived on every row, and may never be presented as the
|
|
121
|
+
* captured stream — the honesty of the archive is the product.
|
|
122
|
+
*/
|
|
123
|
+
export const DERIVED_DATASETS = Object.freeze({
|
|
124
|
+
'twap60s:derived': Object.freeze({
|
|
125
|
+
from: 'prices',
|
|
126
|
+
produces: 'twap60s',
|
|
127
|
+
lookbackSeconds: 60,
|
|
128
|
+
}),
|
|
129
|
+
'twap30s:derived': Object.freeze({
|
|
130
|
+
from: 'prices',
|
|
131
|
+
produces: 'twap30s',
|
|
132
|
+
lookbackSeconds: 30,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* When each captured stream actually starts, per venue.
|
|
138
|
+
*
|
|
139
|
+
* Requesting a captured stream outside its window is E_COVERAGE — never a
|
|
140
|
+
* silent substitution, and never an approximation. A customer who asked for
|
|
141
|
+
* twap60s and got 1 Hz reports back would draw a conclusion about a settlement
|
|
142
|
+
* rule that did not exist yet.
|
|
143
|
+
*
|
|
144
|
+
* `null` end means "still being captured".
|
|
145
|
+
*/
|
|
146
|
+
export const CAPTURE_WINDOWS = Object.freeze({
|
|
147
|
+
polymarket: Object.freeze({
|
|
148
|
+
prices: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
|
|
149
|
+
twap30s: Object.freeze({ from: '2026-08-07', to: null }),
|
|
150
|
+
twap60s: Object.freeze({ from: '2026-08-07', to: null }),
|
|
151
|
+
book: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
|
|
152
|
+
trades: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
|
|
153
|
+
markets: Object.freeze({ from: FIRST_COMPLETE_DAY.polymarket, to: null }),
|
|
154
|
+
}),
|
|
155
|
+
predict: Object.freeze({
|
|
156
|
+
prices: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
|
|
157
|
+
twap30s: Object.freeze({ from: '2026-08-07', to: null }),
|
|
158
|
+
twap60s: Object.freeze({ from: '2026-08-07', to: null }),
|
|
159
|
+
book: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
|
|
160
|
+
trades: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
|
|
161
|
+
markets: Object.freeze({ from: FIRST_COMPLETE_DAY.predict, to: null }),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Reference feeds (external data, resolved before the run)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Outside data never arrives as a call from strategy code — the sandbox has no
|
|
171
|
+
* network, and a live fetch would make the same source produce different
|
|
172
|
+
* reports on different days. Feeds are resolved into a dataset ahead of the run
|
|
173
|
+
* and replayed on the same clock as everything else.
|
|
174
|
+
*
|
|
175
|
+
* Binance klines are pre-downloaded from data.binance.vision, which publishes
|
|
176
|
+
* one zip per symbol per day; see scripts/fetch-binance-reference.mjs.
|
|
177
|
+
*/
|
|
178
|
+
export const REFERENCE_FEEDS = Object.freeze({
|
|
179
|
+
'binance:{symbol}:spot:1s': Object.freeze({ kind: 'klines', market: 'spot', interval: '1s' }),
|
|
180
|
+
'binance:{symbol}:spot:100ms': Object.freeze({ kind: 'klines', market: 'spot', interval: '100ms', assets: Object.freeze(['BTC', 'ETH']) }),
|
|
181
|
+
'binance:{symbol}:perp:1s': Object.freeze({ kind: 'klines', market: 'perp', interval: '1s' }),
|
|
182
|
+
'binance:{symbol}:funding': Object.freeze({ kind: 'funding', market: 'perp', interval: null }),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** Symbols we carry a reference feed for. */
|
|
186
|
+
export const REFERENCE_SYMBOLS = Object.freeze(['btcusdt', 'ethusdt', 'solusdt', 'xrpusdt']);
|
|
187
|
+
|
|
188
|
+
/** `binance:btcusdt:spot:1s` -> {feed, symbol} or null if it is not a feed we carry. */
|
|
189
|
+
export function parseReferenceFeed(name) {
|
|
190
|
+
const s = String(name ?? '').trim().toLowerCase();
|
|
191
|
+
const parts = s.split(':');
|
|
192
|
+
if (parts[0] !== 'binance' || parts.length < 3) return null;
|
|
193
|
+
const symbol = parts[1];
|
|
194
|
+
if (!REFERENCE_SYMBOLS.includes(symbol)) return null;
|
|
195
|
+
const pattern = ['binance', '{symbol}', ...parts.slice(2)].join(':');
|
|
196
|
+
const feed = REFERENCE_FEEDS[pattern];
|
|
197
|
+
if (!feed) return null;
|
|
198
|
+
if (feed.assets && !feed.assets.includes(symbol.replace(/usdt$/, '').toUpperCase())) return null;
|
|
199
|
+
return { canonical: s, pattern, symbol, ...feed };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Run modes
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `market` shards by market-day across workers, which is what makes a
|
|
208
|
+
* market-day cheap. `session` feeds one instance every market in the range as a
|
|
209
|
+
* single ordered stream — it cannot be sharded, so it runs slower and bills at
|
|
210
|
+
* a multiple.
|
|
211
|
+
*/
|
|
212
|
+
export const MODES = Object.freeze({
|
|
213
|
+
market: Object.freeze({ shardable: true, rateMultiplier: 1 }),
|
|
214
|
+
session: Object.freeze({ shardable: false, rateMultiplier: 3 }),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
export const KNOWN_MODES = Object.freeze(Object.keys(MODES));
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Limits
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Hard limits, enforced by the API on submission and by the sandbox at run
|
|
225
|
+
* time. The API copy of a limit is a fast rejection, not the security boundary
|
|
226
|
+
* — the sandbox enforces every one of these again.
|
|
227
|
+
*/
|
|
228
|
+
export const LIMITS = Object.freeze({
|
|
229
|
+
maxFiles: 6,
|
|
230
|
+
maxTotalSourceBytes: 256 * 1024,
|
|
231
|
+
maxFileNameLength: 96,
|
|
232
|
+
maxSeriesBytes: 32 * 1024 * 1024,
|
|
233
|
+
maxSeriesCount: 4,
|
|
234
|
+
perEventBudgetMicros: 400,
|
|
235
|
+
memoryBytes: 8 * 1024 * 1024 * 1024,
|
|
236
|
+
vcpu: 4,
|
|
237
|
+
wallClockMs: 20 * 60 * 1000,
|
|
238
|
+
logLinesPerMarketDay: 10_000,
|
|
239
|
+
maxParams: 64,
|
|
240
|
+
maxSweepCells: 256,
|
|
241
|
+
archiveRetentionDays: 90,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Rejection codes
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every rejection a submission can earn before anything is billed. `ot check`
|
|
250
|
+
* runs the same validator and returns the same codes — the docs promise that a
|
|
251
|
+
* local pass is not rejected on submit, so these must stay in one place.
|
|
252
|
+
*/
|
|
253
|
+
export const REJECTION_CODES = Object.freeze({
|
|
254
|
+
E_MANIFEST: 'Missing or malformed outcometick.json, or a schema version we do not know.',
|
|
255
|
+
E_ENTRY: 'entry does not resolve to a class in the named file, or the class does not implement the SDK base.',
|
|
256
|
+
E_HOOK_SIG: 'A declared hook has the wrong arity or returns a type that is not Order or nothing.',
|
|
257
|
+
E_IMPORT: 'An import outside the allowlist, transitive ones included. The offending chain is printed.',
|
|
258
|
+
E_FORBIDDEN: 'Threads, subprocess, eval, dynamic import, reflection or a native extension found at import time.',
|
|
259
|
+
E_NONDETERMINISM: 'Unseeded randomness or a wall-clock read. Use ctx.random and ctx.now.',
|
|
260
|
+
E_STATE: 'Instance state is not serialisable, so the market-day cannot be moved between workers.',
|
|
261
|
+
E_BUDGET: 'Per-event budget exceeded on the smoke run. Nothing was billed.',
|
|
262
|
+
E_COVERAGE: 'A captured stream was requested outside the window it was captured in.',
|
|
263
|
+
E_LIMIT: 'A submission limit was exceeded — file count, total source size or series size.',
|
|
264
|
+
E_SCOPE: 'The requested venue, asset or date range is not something we can serve.',
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
export const KNOWN_REJECTION_CODES = Object.freeze(Object.keys(REJECTION_CODES));
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A rejection carries its code so the CLI, the API and the page all speak the
|
|
271
|
+
* same language. Thrown rather than returned wherever validation is deep enough
|
|
272
|
+
* that threading a result out would obscure the check.
|
|
273
|
+
*/
|
|
274
|
+
export class BacktestRejection extends Error {
|
|
275
|
+
constructor(code, detail, extra = {}) {
|
|
276
|
+
if (!REJECTION_CODES[code]) throw new Error(`unknown rejection code ${code}`);
|
|
277
|
+
super(detail || REJECTION_CODES[code]);
|
|
278
|
+
this.name = 'BacktestRejection';
|
|
279
|
+
this.code = code;
|
|
280
|
+
this.detail = detail || REJECTION_CODES[code];
|
|
281
|
+
Object.assign(this, extra);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
toJSON() {
|
|
285
|
+
const { code, detail, ...rest } = this;
|
|
286
|
+
return { code: this.code, detail: this.detail, ...stripNoise(rest) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stripNoise(o) {
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const [k, v] of Object.entries(o)) {
|
|
293
|
+
if (k === 'name' || k === 'message' || k === 'stack') continue;
|
|
294
|
+
out[k] = v;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The whole contract, in the shape /v1/backtest/contract serves it. */
|
|
300
|
+
export function contractDocument() {
|
|
301
|
+
return {
|
|
302
|
+
schema: SCHEMA_VERSION,
|
|
303
|
+
sdkVersion: SDK_VERSION,
|
|
304
|
+
languages: Object.fromEntries(Object.entries(LANGUAGES).map(([k, v]) => [k, {
|
|
305
|
+
label: v.label, runtime: v.runtime, entrySignature: v.entrySignature, deps: [...v.deps],
|
|
306
|
+
}])),
|
|
307
|
+
hooks: HOOKS,
|
|
308
|
+
hookNames: HOOK_NAMES,
|
|
309
|
+
datasets: DATASETS,
|
|
310
|
+
derivedDatasets: DERIVED_DATASETS,
|
|
311
|
+
captureWindows: CAPTURE_WINDOWS,
|
|
312
|
+
referenceFeeds: REFERENCE_FEEDS,
|
|
313
|
+
referenceSymbols: [...REFERENCE_SYMBOLS],
|
|
314
|
+
modes: MODES,
|
|
315
|
+
limits: LIMITS,
|
|
316
|
+
rejectionCodes: REJECTION_CODES,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Translating between what a strategy asks for and what the archive holds.
|
|
2
|
+
//
|
|
3
|
+
// The SDK's dataset names are a product surface: `book`, `trades`, `settlement`.
|
|
4
|
+
// The archive's are a storage detail: `price_change`, `orderbook`,
|
|
5
|
+
// `last_trade_price`, `chainlink-twap-60s`. Keeping the two apart is what lets
|
|
6
|
+
// the archive be reorganised without breaking a manifest a customer wrote three
|
|
7
|
+
// months ago.
|
|
8
|
+
//
|
|
9
|
+
// The interesting one is `settlement`, which is not a stored stream at all.
|
|
10
|
+
|
|
11
|
+
import { classifyPath } from './data-taxonomy.mjs';
|
|
12
|
+
import {
|
|
13
|
+
CAPTURE_WINDOWS, DERIVED_DATASETS, KNOWN_DATASETS, BacktestRejection,
|
|
14
|
+
} from './backtest-contract.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SDK dataset name -> the archive dataset names that satisfy it, per venue.
|
|
18
|
+
*
|
|
19
|
+
* `book` and `trades` are one concept each to a strategy but two different
|
|
20
|
+
* trees per venue: Polymarket publishes full snapshots plus deltas, Predict.fun
|
|
21
|
+
* publishes a single `orderbook` tree. A strategy that declares `book` gets
|
|
22
|
+
* whatever that venue actually has, which is the point of naming it `book`.
|
|
23
|
+
*/
|
|
24
|
+
const ARCHIVE_DATASETS = Object.freeze({
|
|
25
|
+
polymarket: Object.freeze({
|
|
26
|
+
prices: Object.freeze(['prices']),
|
|
27
|
+
twap30s: Object.freeze(['twap30s']),
|
|
28
|
+
twap60s: Object.freeze(['twap60s']),
|
|
29
|
+
book: Object.freeze(['book', 'price_change']),
|
|
30
|
+
trades: Object.freeze(['last_trade_price']),
|
|
31
|
+
markets: Object.freeze(['markets']),
|
|
32
|
+
}),
|
|
33
|
+
predict: Object.freeze({
|
|
34
|
+
prices: Object.freeze(['prices']),
|
|
35
|
+
twap30s: Object.freeze(['twap30s']),
|
|
36
|
+
twap60s: Object.freeze(['twap60s']),
|
|
37
|
+
book: Object.freeze(['orderbook']),
|
|
38
|
+
trades: Object.freeze(['last_trade_price']),
|
|
39
|
+
markets: Object.freeze(['markets']),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `markets` is always fed, whether or not a strategy declares it.
|
|
45
|
+
*
|
|
46
|
+
* Not a convenience: on_market_open carries the strike and on_settle carries
|
|
47
|
+
* the official outcome, both of which live in the markets tree, and the
|
|
48
|
+
* settlement-stream resolution below reads twapLookbackSeconds from the same
|
|
49
|
+
* place. A run without it could not identify which stream a market settled on,
|
|
50
|
+
* which is the one thing this product exists to get right.
|
|
51
|
+
*/
|
|
52
|
+
export const ALWAYS_FED = Object.freeze(['markets']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Which captured stream a single market settled on.
|
|
56
|
+
*
|
|
57
|
+
* Read from the market's OWN config, never inferred from its date. The venues
|
|
58
|
+
* moved 5-minute markets onto a 30-second TWAP and then onto 60, while
|
|
59
|
+
* 15-minute markets went straight to 60 — so any date-based rule is wrong for
|
|
60
|
+
* whole classes of market inside the transition, and wrong quietly. The market
|
|
61
|
+
* record states its own lookback; that is the answer.
|
|
62
|
+
*
|
|
63
|
+
* Fail-closed on anything unrecognised. A market whose config we cannot read is
|
|
64
|
+
* not guessed at: the caller gets null and must reject the market-day rather
|
|
65
|
+
* than feed a strategy a stream the market did not settle on.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} market a row from the markets dataset
|
|
68
|
+
* @returns {'prices'|'twap30s'|'twap60s'|null}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveSettlementStream(market) {
|
|
71
|
+
const cfg = market?.raw?.cryptoMarketConfig ?? market?.cryptoMarketConfig ?? null;
|
|
72
|
+
// No config object at all is a record we could not read, NOT evidence of the
|
|
73
|
+
// pre-TWAP regime. An earlier version returned 'prices' here and that was
|
|
74
|
+
// fail-OPEN in the worst possible place: every market whose metadata we
|
|
75
|
+
// failed to parse would have been fed the 1 Hz stream and silently reported
|
|
76
|
+
// as if that were what it settled on. A dropped market-day is visible in
|
|
77
|
+
// coverage and costs the customer nothing; a wrong settlement stream is
|
|
78
|
+
// invisible and makes the whole report a lie.
|
|
79
|
+
if (!cfg || typeof cfg !== 'object') return null;
|
|
80
|
+
if (!Object.prototype.hasOwnProperty.call(cfg, 'twapLookbackSeconds')) return null;
|
|
81
|
+
|
|
82
|
+
const lookback = cfg.twapLookbackSeconds;
|
|
83
|
+
// An explicit null or 0 IS a positive statement: this market settled on the
|
|
84
|
+
// instantaneous stream. That is different from the field being absent.
|
|
85
|
+
if (lookback === null || lookback === 0) return 'prices';
|
|
86
|
+
if (lookback === 30) return 'twap30s';
|
|
87
|
+
if (lookback === 60) return 'twap60s';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Is a captured stream available for this venue on this day?
|
|
93
|
+
*
|
|
94
|
+
* Both ends are inclusive and `to: null` means "still capturing".
|
|
95
|
+
*/
|
|
96
|
+
export function isCaptured(venue, dataset, day) {
|
|
97
|
+
const w = CAPTURE_WINDOWS[venue]?.[dataset];
|
|
98
|
+
if (!w) return false;
|
|
99
|
+
if (day < w.from) return false;
|
|
100
|
+
if (w.to && day > w.to) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every day in [from, to] on which a captured stream is unavailable.
|
|
106
|
+
*
|
|
107
|
+
* Returned rather than counted so E_COVERAGE can name the range that failed —
|
|
108
|
+
* "twap60s is not captured before 2026-08-07" is actionable, "coverage error"
|
|
109
|
+
* is not.
|
|
110
|
+
*/
|
|
111
|
+
export function uncapturedRange(venue, dataset, from, to) {
|
|
112
|
+
const w = CAPTURE_WINDOWS[venue]?.[dataset];
|
|
113
|
+
if (!w) return { from, to };
|
|
114
|
+
const badFrom = from < w.from ? from : null;
|
|
115
|
+
const badTo = badFrom ? (to < w.from ? to : prevDay(w.from)) : null;
|
|
116
|
+
if (badFrom) return { from: badFrom, to: badTo };
|
|
117
|
+
if (w.to && to > w.to) return { from: nextDay(w.to), to };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shiftDay(day, delta) {
|
|
122
|
+
const d = new Date(`${day}T00:00:00Z`);
|
|
123
|
+
d.setUTCDate(d.getUTCDate() + delta);
|
|
124
|
+
return d.toISOString().slice(0, 10);
|
|
125
|
+
}
|
|
126
|
+
const prevDay = (d) => shiftDay(d, -1);
|
|
127
|
+
const nextDay = (d) => shiftDay(d, 1);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check a manifest's declared datasets against a venue and date range.
|
|
131
|
+
*
|
|
132
|
+
* `settlement` is exempt: it resolves per market to whatever that market
|
|
133
|
+
* settled on, so it is available wherever the archive is — that is the entire
|
|
134
|
+
* reason to prefer it, and why the docs recommend it.
|
|
135
|
+
*
|
|
136
|
+
* Throws BacktestRejection(E_COVERAGE) on the first stream that is not
|
|
137
|
+
* available for the whole range, naming the gap.
|
|
138
|
+
*/
|
|
139
|
+
export function assertCoverage({ datasets, venue, from, to }) {
|
|
140
|
+
for (const ds of datasets ?? []) {
|
|
141
|
+
if (ds === 'settlement') continue;
|
|
142
|
+
if (DERIVED_DATASETS[ds]) {
|
|
143
|
+
// A derived stream is a function of one we hold, so its availability is
|
|
144
|
+
// the SOURCE stream's availability, not its own.
|
|
145
|
+
const src = DERIVED_DATASETS[ds].from;
|
|
146
|
+
const gap = uncapturedRange(venue, src, from, to);
|
|
147
|
+
if (gap) {
|
|
148
|
+
throw new BacktestRejection('E_COVERAGE',
|
|
149
|
+
`${ds} is derived from ${src}, which is not captured for ${gap.from}..${gap.to ?? gap.from} on ${venue}`,
|
|
150
|
+
{ dataset: ds, derivedFrom: src, venue, gap });
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const gap = uncapturedRange(venue, ds, from, to);
|
|
155
|
+
if (gap) {
|
|
156
|
+
const w = CAPTURE_WINDOWS[venue]?.[ds];
|
|
157
|
+
throw new BacktestRejection('E_COVERAGE',
|
|
158
|
+
`${ds} is not captured for ${gap.from}..${gap.to ?? gap.from} on ${venue}`
|
|
159
|
+
+ (w ? ` (captured from ${w.from}${w.to ? ` to ${w.to}` : ''})` : '')
|
|
160
|
+
+ '. Use settlement, or a :derived stream if you specifically want today\'s rules on older dates.',
|
|
161
|
+
{ dataset: ds, venue, gap, capturedFrom: w?.from ?? null, capturedTo: w?.to ?? null });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The archive dataset names a run must fetch for a given manifest.
|
|
168
|
+
*
|
|
169
|
+
* `settlement` expands to every stream that could be authoritative for some
|
|
170
|
+
* market in the range: the resolution is per market and is not known until the
|
|
171
|
+
* markets metadata has been read, so the worker fetches the union and the
|
|
172
|
+
* engine picks per market. Streams outside their capture window are dropped
|
|
173
|
+
* rather than requested — no market in that window can have settled on them.
|
|
174
|
+
*/
|
|
175
|
+
export function archiveDatasetsFor({ datasets, venue, from, to }) {
|
|
176
|
+
const wanted = new Set();
|
|
177
|
+
for (const ds of [...(datasets ?? []), ...ALWAYS_FED]) {
|
|
178
|
+
if (ds === 'settlement') {
|
|
179
|
+
for (const s of ['prices', 'twap30s', 'twap60s']) {
|
|
180
|
+
if (!uncapturedRange(venue, s, from, to) || isCaptured(venue, s, to)) wanted.add(s);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const base = DERIVED_DATASETS[ds]?.from ?? ds;
|
|
185
|
+
wanted.add(base);
|
|
186
|
+
}
|
|
187
|
+
const out = new Set();
|
|
188
|
+
const map = ARCHIVE_DATASETS[venue] ?? {};
|
|
189
|
+
for (const w of wanted) for (const a of map[w] ?? []) out.add(a);
|
|
190
|
+
return [...out].sort();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Does an archived file belong to this run's scope?
|
|
195
|
+
*
|
|
196
|
+
* Runs on top of the entitlement gate, never instead of it: this decides what a
|
|
197
|
+
* paid-for run is FED, while scope.mjs decides what a customer may DOWNLOAD.
|
|
198
|
+
* A backtest reads files the submitter has not bought, which is the product —
|
|
199
|
+
* so this filter must never be mistaken for an authorisation check.
|
|
200
|
+
*/
|
|
201
|
+
export function fileMatchesRun(filePath, { venue, assets, archiveDatasets }) {
|
|
202
|
+
const meta = classifyPath(filePath);
|
|
203
|
+
if (meta.venue !== venue) return false;
|
|
204
|
+
if (!archiveDatasets.includes(meta.dataset)) return false;
|
|
205
|
+
// Venue-wide datasets (markets) carry no asset and are always in scope.
|
|
206
|
+
if (meta.asset && assets?.length && !assets.includes(meta.asset)) return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Validate a declared dataset list, returning it normalised. */
|
|
211
|
+
export function normalizeDatasets(list) {
|
|
212
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
213
|
+
throw new BacktestRejection('E_MANIFEST', 'datasets must be a non-empty array');
|
|
214
|
+
}
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const raw of list) {
|
|
217
|
+
const ds = String(raw ?? '').trim();
|
|
218
|
+
if (!KNOWN_DATASETS.includes(ds) && !DERIVED_DATASETS[ds]) {
|
|
219
|
+
throw new BacktestRejection('E_MANIFEST',
|
|
220
|
+
`unknown dataset ${JSON.stringify(ds)}; known: ${[...KNOWN_DATASETS, ...Object.keys(DERIVED_DATASETS)].join(', ')}`);
|
|
221
|
+
}
|
|
222
|
+
if (!out.includes(ds)) out.push(ds);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|