outcometick 1.6.3 → 1.6.5
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/README.md +1 -1
- package/api/lib/backtest-contract.mjs +67 -2
- package/api/lib/backtest-datasets.mjs +135 -2
- package/api/lib/data-taxonomy.mjs +58 -5
- package/cli/commands/run.mjs +10 -1
- package/cli/local-data.mjs +18 -3
- package/index.d.ts +9 -2
- package/package.json +1 -1
- package/runner/engine/book.mjs +90 -9
- package/runner/engine/portfolio.mjs +18 -2
- package/runner/engine/replay.mjs +23 -2
- package/runner/engine/report.mjs +13 -12
- package/runner/events.mjs +178 -15
- package/runner/harness/node/sdk/index.d.ts +9 -2
- package/runner/harness/protocol.mjs +3 -2
- package/runner/harness/python/otengine.py +90 -11
- package/runner/harness/python/otreplay.py +12 -2
|
@@ -12,8 +12,24 @@
|
|
|
12
12
|
|
|
13
13
|
import { matchOrder, isSide } from './book.mjs';
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A market that settled 50:50. Predict.fun resolves end_price == start_price
|
|
17
|
+
* this way: both outcome tokens pay half a dollar.
|
|
18
|
+
*/
|
|
19
|
+
export const OUTCOME_TIE = 'TIE';
|
|
20
|
+
|
|
21
|
+
/** Every official outcome a market can settle on. */
|
|
22
|
+
export const OUTCOMES = Object.freeze(['UP', 'DOWN', OUTCOME_TIE]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Settlement value of one contract, given the official outcome.
|
|
26
|
+
*
|
|
27
|
+
* MUST MATCH otengine.py `contract_value`, and `scripts/audit-report.py`.
|
|
28
|
+
*/
|
|
29
|
+
export const contractValue = (side, outcome) => {
|
|
30
|
+
if (outcome === OUTCOME_TIE) return 0.5;
|
|
31
|
+
return outcome === side ? 1 : 0;
|
|
32
|
+
};
|
|
17
33
|
|
|
18
34
|
const EPS = 1e-9;
|
|
19
35
|
|
package/runner/engine/replay.mjs
CHANGED
|
@@ -571,14 +571,35 @@ export function replayMarket({
|
|
|
571
571
|
control.setNow(ev.ts_ms);
|
|
572
572
|
|
|
573
573
|
if (ev.kind === 'book') {
|
|
574
|
-
|
|
574
|
+
// BEFORE the snapshot test, because a bound carries snapshot:false and
|
|
575
|
+
// would otherwise be applied as a delta with no ladder, no price and no
|
|
576
|
+
// size — which Book.delta rejects by throwing, taking the whole run with
|
|
577
|
+
// it.
|
|
578
|
+
if (ev.bbo) book.bbo(ev.ts_ms, ev.side, ev.bid, ev.ask);
|
|
579
|
+
else if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
|
|
575
580
|
else book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
|
|
576
581
|
}
|
|
577
582
|
drainUntil(ev.ts_ms);
|
|
578
583
|
|
|
579
584
|
if (ev.kind === 'tick') control.pushTick(ev);
|
|
580
585
|
|
|
581
|
-
|
|
586
|
+
// A BOUND REFINES THE BOOK SILENTLY. Three reasons it must not reach a hook,
|
|
587
|
+
// and the first one alone is enough:
|
|
588
|
+
//
|
|
589
|
+
// - The event has no `levels`, `ladder`, `px` or `size`. Handing it to
|
|
590
|
+
// on_book gives a documented SDK input a shape no documentation
|
|
591
|
+
// describes, and a strategy reading ev.levels gets undefined.
|
|
592
|
+
// - This stream is UNTHROTTLED — 1.3M rows in a day of BTC-5m against a
|
|
593
|
+
// price_change stream thinned to 20-500ms. Firing a hook on each would
|
|
594
|
+
// multiply hook invocations several-fold inside a 20-minute wall clock,
|
|
595
|
+
// and a run that times out is refunded in full at our cost.
|
|
596
|
+
// - Nothing is lost by staying quiet: ctx.book() is live, so the next real
|
|
597
|
+
// event already sees the refined ladder. Pruning only ever REMOVES
|
|
598
|
+
// liquidity, so not waking a strategy cannot cost it an opportunity that
|
|
599
|
+
// existed — which is the direction this engine resolves ambiguity in.
|
|
600
|
+
//
|
|
601
|
+
// MUST MATCH otreplay.py. Both engines or neither.
|
|
602
|
+
const hook = ev.bbo ? null : HOOK_FOR[ev.kind];
|
|
582
603
|
if (hook && hooks[hook]) emit(call(hook, ev), ev.ts_ms);
|
|
583
604
|
|
|
584
605
|
if (monitor.breached) {
|
package/runner/engine/report.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// Nothing in this module can see the strategy. It reads the trade and fill logs
|
|
9
9
|
// the engine produced, so a report cannot be tuned by the thing it is judging.
|
|
10
10
|
|
|
11
|
+
import { contractValue } from './portfolio.mjs';
|
|
12
|
+
|
|
11
13
|
/** Entry-price buckets for the calibration panel. */
|
|
12
14
|
export const CALIBRATION_BUCKETS = Object.freeze([
|
|
13
15
|
[0.0, 0.1], [0.1, 0.2], [0.2, 0.3], [0.3, 0.4], [0.4, 0.5],
|
|
@@ -181,10 +183,11 @@ export function metrics(trades, { feesPaid = 0, days = 1 } = {}) {
|
|
|
181
183
|
/**
|
|
182
184
|
* Mean realised edge per contract, in dollars.
|
|
183
185
|
*
|
|
184
|
-
* A binary token bought at p is worth 1 if its side settles and 0 otherwise
|
|
185
|
-
*
|
|
186
|
-
* outcome
|
|
187
|
-
* truth, and is excluded rather than
|
|
186
|
+
* A binary token bought at p is worth 1 if its side settles and 0 otherwise
|
|
187
|
+
* (0.5 on a TIE — `contractValue`), so the edge on one contract is
|
|
188
|
+
* (outcome - p). Only settled trades carry an outcome; a trade closed early is
|
|
189
|
+
* edge against the market, not against the truth, and is excluded rather than
|
|
190
|
+
* scored as if it had settled.
|
|
188
191
|
*/
|
|
189
192
|
export function edgePerContract(trades) {
|
|
190
193
|
const settled = trades.filter((t) => t.how === 'settled' && t.entry_px != null && t.outcome);
|
|
@@ -192,8 +195,7 @@ export function edgePerContract(trades) {
|
|
|
192
195
|
let contracts = 0;
|
|
193
196
|
let edge = 0;
|
|
194
197
|
for (const t of settled) {
|
|
195
|
-
|
|
196
|
-
edge += (won - t.entry_px) * t.size;
|
|
198
|
+
edge += (contractValue(t.side, t.outcome) - t.entry_px) * t.size;
|
|
197
199
|
contracts += t.size;
|
|
198
200
|
}
|
|
199
201
|
return contracts > 0 ? edge / contracts : 0;
|
|
@@ -207,8 +209,7 @@ export function brier(trades) {
|
|
|
207
209
|
const settled = trades.filter((t) => t.how === 'settled' && t.entry_px != null && t.outcome);
|
|
208
210
|
if (!settled.length) return null;
|
|
209
211
|
return mean(settled.map((t) => {
|
|
210
|
-
|
|
211
|
-
return (t.entry_px - won) ** 2;
|
|
212
|
+
return (t.entry_px - contractValue(t.side, t.outcome)) ** 2;
|
|
212
213
|
}));
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -268,7 +269,7 @@ export function calibration(trades) {
|
|
|
268
269
|
const inBucket = settled.filter((t) => t.entry_px >= lo && t.entry_px < hi);
|
|
269
270
|
if (!inBucket.length) return null;
|
|
270
271
|
const implied = mean(inBucket.map((t) => t.entry_px));
|
|
271
|
-
const realized = mean(inBucket.map((t) => (t.
|
|
272
|
+
const realized = mean(inBucket.map((t) => contractValue(t.side, t.outcome)));
|
|
272
273
|
return {
|
|
273
274
|
bucket: `${lo.toFixed(2)}-${hi.toFixed(2)}`,
|
|
274
275
|
lo,
|
|
@@ -298,8 +299,8 @@ export function baselines(marketSummaries, { size = 1 } = {}) {
|
|
|
298
299
|
const out = { always_up: 0, always_down: 0, always_favourite: 0 };
|
|
299
300
|
for (const m of marketSummaries) {
|
|
300
301
|
if (!m.outcome || m.up_px == null || m.down_px == null) continue;
|
|
301
|
-
out.always_up += ((
|
|
302
|
-
out.always_down += ((
|
|
302
|
+
out.always_up += (contractValue('UP', m.outcome) - m.up_px) * size;
|
|
303
|
+
out.always_down += (contractValue('DOWN', m.outcome) - m.down_px) * size;
|
|
303
304
|
// The favourite is the side the market thinks is MORE likely, and on a
|
|
304
305
|
// binary market the price IS the implied probability — so it is the DEARER
|
|
305
306
|
// side, not the cheaper one. This was inverted: the panel labelled "always
|
|
@@ -307,7 +308,7 @@ export function baselines(marketSummaries, { size = 1 } = {}) {
|
|
|
307
308
|
// handed customers a backwards comparison to judge their strategy against.
|
|
308
309
|
const favSide = m.up_px >= m.down_px ? 'UP' : 'DOWN';
|
|
309
310
|
const favPx = Math.max(m.up_px, m.down_px);
|
|
310
|
-
out.always_favourite += ((m.outcome
|
|
311
|
+
out.always_favourite += (contractValue(favSide, m.outcome) - favPx) * size;
|
|
311
312
|
}
|
|
312
313
|
return {
|
|
313
314
|
always_up: r2(out.always_up),
|
package/runner/events.mjs
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The lesson this is applying is the one runner/conformance already enforces
|
|
8
8
|
// for the two engines: two implementations of the same rules drift, and the
|
|
9
|
-
// drift is silent. "The identical files,
|
|
9
|
+
// drift is silent. "The identical files, byte for byte, with the same checksums"
|
|
10
10
|
// is a published promise about `ot run` versus a queued run — it cannot be true
|
|
11
11
|
// if local and remote decode the archive differently.
|
|
12
12
|
|
|
13
13
|
import { classifyPath } from '../api/lib/data-taxonomy.mjs';
|
|
14
|
-
import { resolveSettlementStream } from '../api/lib/backtest-datasets.mjs';
|
|
14
|
+
import { resolveSettlementStream, degradingCoverage, inputKeys } from '../api/lib/backtest-datasets.mjs';
|
|
15
15
|
import { bookThrottleMs } from '../api/lib/backtest-contract.mjs';
|
|
16
16
|
import { Book } from './engine/book.mjs';
|
|
17
|
+
import { OUTCOME_TIE, OUTCOMES } from './engine/portfolio.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Coerce a field to a number, or null.
|
|
@@ -146,16 +147,33 @@ function predictRecord(row) {
|
|
|
146
147
|
const closeMs = num(row.end_sec) == null ? null : num(row.end_sec) * 1000;
|
|
147
148
|
const start = num(row.start_price);
|
|
148
149
|
const end = num(row.end_price);
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
// `end_price` is the settlement marker, NOT `status`.
|
|
151
|
+
//
|
|
152
|
+
// This used to require status === 'RESOLVED', on the theory that an OPEN
|
|
153
|
+
// market's prices are live quotes rather than a settlement. Both halves were
|
|
154
|
+
// wrong. `end_price` is written only by the settlement backfill, which is
|
|
155
|
+
// driven by `end_price IS NULL` and drops a market from its re-read queue the
|
|
156
|
+
// moment it lands — so `status` is whatever the venue last said while we were
|
|
157
|
+
// still polling, and it freezes there. There is no row in the whole table
|
|
158
|
+
// with status RESOLVED and no end_price, so the implication runs one way
|
|
159
|
+
// only: end_price present ⇒ settled.
|
|
160
|
+
//
|
|
161
|
+
// MEASURED 2026-09-08, all assets: 1245 markets, every one of them with an
|
|
162
|
+
// end_price, but only 1104 said RESOLVED. The check discarded 39/873 of the
|
|
163
|
+
// 5-minute markets (4.5%) and 21/291 of the 15-minute ones (7.2%) — reported
|
|
164
|
+
// to the customer as "outcome could not be read" while the outcome was right
|
|
165
|
+
// there, and billed for, because a market-day charges whole.
|
|
166
|
+
//
|
|
167
|
+
// A TIE IS ITS OWN OUTCOME. Predict.fun settles end_price == start_price
|
|
168
|
+
// 50:50 — every UP and every DOWN contract pays $0.50 (`contractValue`).
|
|
169
|
+
// It used to be dropped because the engines only knew UP/DOWN, which cost the
|
|
170
|
+
// customer 0.53–1.20% of the 5-minute markets they paid for, reported as
|
|
171
|
+
// "outcome could not be read". Never "fix" a tie by picking a side: half of
|
|
172
|
+
// them would be scored backwards.
|
|
173
|
+
let outcome = null;
|
|
174
|
+
if (start != null && end != null) {
|
|
175
|
+
outcome = end > start ? 'UP' : end < start ? 'DOWN' : OUTCOME_TIE;
|
|
176
|
+
}
|
|
159
177
|
return {
|
|
160
178
|
market_id: String(row.market_id ?? row.condition_id ?? ''),
|
|
161
179
|
slug: row.category_slug ?? null,
|
|
@@ -465,6 +483,58 @@ export function eventsFromRow(filePath, row, markets, bySlug = null, throttle =
|
|
|
465
483
|
}]];
|
|
466
484
|
}
|
|
467
485
|
|
|
486
|
+
if (meta.dataset === 'best_bid_ask') {
|
|
487
|
+
// TOP OF BOOK AS A BOUND, not as a quote.
|
|
488
|
+
//
|
|
489
|
+
// This stream carries prices and no sizes, so it can never say what IS on
|
|
490
|
+
// the ladder — only what is NOT: "nothing better than this exists right
|
|
491
|
+
// now". The engine uses it to delete levels the venue has moved past and
|
|
492
|
+
// never to add one, because a level invented without a size is exactly the
|
|
493
|
+
// fabricated liquidity this product has had to fix five times.
|
|
494
|
+
//
|
|
495
|
+
// Emitted as kind:'book' deliberately: on_book already fires for the book,
|
|
496
|
+
// a strategy reads the refined ladder rather than this row, and no new hook
|
|
497
|
+
// or SDK surface appears. `bbo` marks it as a bound so the engine does not
|
|
498
|
+
// mistake it for a delta with a missing size.
|
|
499
|
+
const side = sideOfToken(market, row.asset_id ?? payload.asset_id);
|
|
500
|
+
if (!side) return [];
|
|
501
|
+
const bid = num(payload.best_bid);
|
|
502
|
+
const ask = num(payload.best_ask);
|
|
503
|
+
// MEASURED, over 900,000 rows across BTC-5m, SOL-15m and DOGE-5m on
|
|
504
|
+
// 2026-09-04: zero crossed, zero equal, zero outside [0,1], zero
|
|
505
|
+
// unparseable. So none of these guards fire on today's archive — they are
|
|
506
|
+
// here because the failure they prevent is silent and total. A garbage
|
|
507
|
+
// bound is a MAXIMAL deletion instruction: one unreadable row would empty a
|
|
508
|
+
// ladder, the market would stop filling, and the run would come back with
|
|
509
|
+
// an honest-looking report of a strategy that could not trade.
|
|
510
|
+
//
|
|
511
|
+
// Fail-safe rather than fail-closed, and that is the one place this differs
|
|
512
|
+
// from the rest of this file: dropping a bbo row costs nothing but the
|
|
513
|
+
// refinement, leaving the book exactly as it was before 2026-09-02. There
|
|
514
|
+
// is no wrong answer to propagate, so the market is not dropped.
|
|
515
|
+
if (bid == null || ask == null) return [];
|
|
516
|
+
if (bid < 0 || bid > 1 || ask < 0 || ask > 1) return [];
|
|
517
|
+
if (bid > ask) return [];
|
|
518
|
+
// 0 and 1 need NO special case, which is why the rule is phrased as a
|
|
519
|
+
// bound. `best_bid = "0"` is how the venue writes "no bid", and deleting
|
|
520
|
+
// every bid strictly better than 0 deletes all of them — correct. Its
|
|
521
|
+
// complement is `best_ask = "1"` on the other token of the same market,
|
|
522
|
+
// because UP + DOWN = 1: measured as an exact pairing, 3228 `bid=0 ask=0.01`
|
|
523
|
+
// against 3228 `bid=0.99 ask=1`, every count matching. An earlier version of
|
|
524
|
+
// this rule treated [0.001, 0.999] as the valid domain and would have
|
|
525
|
+
// thrown away precisely those rows — the ones carrying the MOST definite
|
|
526
|
+
// information about the book.
|
|
527
|
+
return [[id, {
|
|
528
|
+
kind: 'book',
|
|
529
|
+
ts_ms: ts,
|
|
530
|
+
snapshot: false,
|
|
531
|
+
bbo: true,
|
|
532
|
+
side,
|
|
533
|
+
bid,
|
|
534
|
+
ask,
|
|
535
|
+
}]];
|
|
536
|
+
}
|
|
537
|
+
|
|
468
538
|
if (meta.dataset === 'price_change') {
|
|
469
539
|
// A delta carries a batch, each entry naming its own token and ladder side.
|
|
470
540
|
const changes = Array.isArray(payload.price_changes) ? payload.price_changes : [];
|
|
@@ -530,7 +600,7 @@ export function buildSlugIndex(markets) {
|
|
|
530
600
|
* The coverage block, in ONE shape.
|
|
531
601
|
*
|
|
532
602
|
* The docs promise a local run and a queued run produce "the identical files,
|
|
533
|
-
*
|
|
603
|
+
* byte for byte, with the same checksums". They already shared the decoder and
|
|
534
604
|
* the feed list; the coverage object was still built twice, so `ot run` emitted
|
|
535
605
|
* five keys where the queue emitted ten, and anyone diffing the two saw a
|
|
536
606
|
* schema difference rather than an answer. A field a local run genuinely cannot
|
|
@@ -546,6 +616,21 @@ export function buildCoverage({
|
|
|
546
616
|
streams = {},
|
|
547
617
|
droppedRows = 0,
|
|
548
618
|
unreconciledRows = 0,
|
|
619
|
+
/**
|
|
620
|
+
* Did the manifest declare `bbo`, and on which of the run's days did the
|
|
621
|
+
* archive actually have it?
|
|
622
|
+
*
|
|
623
|
+
* Always emitted, even when nothing declared it, because these three keys are
|
|
624
|
+
* the only place a reader can tell which book a report was computed against.
|
|
625
|
+
* `bbo` refines the ladder by deleting levels the venue has moved past, and a
|
|
626
|
+
* run over 2026-06-10 and a run over 2026-09-04 both succeed while using two
|
|
627
|
+
* different books. Same reason `fill_delay_ms` is written out: without it,
|
|
628
|
+
* whoever holds the archive cannot tell which of the two they have.
|
|
629
|
+
*/
|
|
630
|
+
bboDeclared = false,
|
|
631
|
+
bboDays = [],
|
|
632
|
+
bboMissingDays = [],
|
|
633
|
+
bboPartialDays = [],
|
|
549
634
|
local = false,
|
|
550
635
|
source = null,
|
|
551
636
|
}) {
|
|
@@ -568,11 +653,81 @@ export function buildCoverage({
|
|
|
568
653
|
// Rows the harness produced that do not describe a market the caller
|
|
569
654
|
// supplied. Published rather than swallowed.
|
|
570
655
|
unreconciled_rows: unreconciledRows,
|
|
656
|
+
// Which book this report was computed against. `bbo_missing_days` is not an
|
|
657
|
+
// error and is not a gap in the archive for the days before 2026-09-02 —
|
|
658
|
+
// nothing was ever captured then. It is the list of days that ran exactly
|
|
659
|
+
// as this product ran for its whole life before that stream existed.
|
|
660
|
+
bbo_declared: bboDeclared,
|
|
661
|
+
bbo_days: bboDays,
|
|
662
|
+
bbo_missing_days: bboMissingDays,
|
|
663
|
+
// Dates where SOME market-days got the refinement and some did not, each
|
|
664
|
+
// naming the `asset|interval` that did not. A date is never in more than
|
|
665
|
+
// one of these three, and the three together are every date scanned.
|
|
666
|
+
bbo_partial_days: bboPartialDays,
|
|
571
667
|
// Local-only, and last: a queued run has no source directory to name.
|
|
572
668
|
...(local ? { local: true, source } : {}),
|
|
573
669
|
};
|
|
574
670
|
}
|
|
575
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Which of a run's days the unthrottled top-of-book stream actually covered.
|
|
674
|
+
*
|
|
675
|
+
* SHARED, for the reason every predicate in this file is shared: `ot run` and
|
|
676
|
+
* the queue have drifted apart nine times, always by each computing a rule the
|
|
677
|
+
* other also computes. This one decides what a report SAYS about itself, so a
|
|
678
|
+
* second copy would let the local run and the queue describe the same archive
|
|
679
|
+
* differently.
|
|
680
|
+
*
|
|
681
|
+
* Days come from the markets actually scanned rather than from the requested
|
|
682
|
+
* range, so the answer describes the run that happened.
|
|
683
|
+
*
|
|
684
|
+
* `bbo_missing_days` is not an error and not a gap in our archive: before
|
|
685
|
+
* 2026-09-02 nothing was captured, so those days ran exactly as this product
|
|
686
|
+
* ran for its whole life before the stream existed. Reported anyway, because
|
|
687
|
+
* two runs that used different books are otherwise indistinguishable — the same
|
|
688
|
+
* reason `fill_delay_ms` is written out.
|
|
689
|
+
*/
|
|
690
|
+
export { inputKeys };
|
|
691
|
+
|
|
692
|
+
export function bboCoverage({ venue, datasets, markets, applied = null }) {
|
|
693
|
+
if (!(datasets ?? []).includes('bbo')) {
|
|
694
|
+
return { bboDeclared: false, bboDays: [], bboMissingDays: [], bboPartialDays: [] };
|
|
695
|
+
}
|
|
696
|
+
// MEASURED OR NOTHING. There used to be a fallback that inferred coverage
|
|
697
|
+
// from CAPTURE_WINDOWS when no measurement was passed, and that is fail-OPEN
|
|
698
|
+
// for precisely the bug this function exists to prevent: a caller added later
|
|
699
|
+
// — or a refactor that drops an argument — would silently go back to claiming
|
|
700
|
+
// a refinement the replay never had. The window says what SHOULD have been
|
|
701
|
+
// possible; only the decode knows what happened.
|
|
702
|
+
if (!applied) {
|
|
703
|
+
throw new Error('bboCoverage: `applied` is required when the manifest declares bbo '
|
|
704
|
+
+ '— coverage must be measured from what was decoded, never inferred from the capture window');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// THE UNIT IS THE MARKET-DAY, the same `asset|day|interval` the run is billed
|
|
708
|
+
// on. Collapsing to the date makes one asset speak for every asset.
|
|
709
|
+
const byDay = new Map();
|
|
710
|
+
for (const m of markets ?? []) {
|
|
711
|
+
const day = m?.day;
|
|
712
|
+
if (!day) continue;
|
|
713
|
+
if (!byDay.has(day)) byDay.set(day, new Set());
|
|
714
|
+
byDay.get(day).add(`${m.market?.asset ?? 'unknown'}|${m.market?.interval ?? 'none'}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const full = [], none = [], partial = [];
|
|
718
|
+
for (const day of [...byDay.keys()].sort()) {
|
|
719
|
+
const scanned = [...byDay.get(day)];
|
|
720
|
+
const got = applied.get(day) ?? new Set();
|
|
721
|
+
const without = scanned.filter((k) => !got.has(k)).sort();
|
|
722
|
+
if (without.length === 0) full.push(day);
|
|
723
|
+
else if (without.length === scanned.length) none.push(day);
|
|
724
|
+
// NAMED, not counted. "Some of 2026-09-04 ran on the old book" is not
|
|
725
|
+
// actionable; "ETH|5m did" is. Same reason uncapturedRange returns the gap.
|
|
726
|
+
else partial.push({ day, without });
|
|
727
|
+
}
|
|
728
|
+
return { bboDeclared: true, bboDays: full, bboMissingDays: none, bboPartialDays: partial };
|
|
729
|
+
}
|
|
730
|
+
|
|
576
731
|
/**
|
|
577
732
|
* The billing unit: one asset on one UTC day.
|
|
578
733
|
*
|
|
@@ -697,7 +852,7 @@ export function marketUnusable(market, inWindow) {
|
|
|
697
852
|
// nothing to merge — replayed it out of the cache anyway.
|
|
698
853
|
if (!market.asset) return 'market has no asset';
|
|
699
854
|
if (market.stream == null) return 'settlement stream could not be resolved';
|
|
700
|
-
if (
|
|
855
|
+
if (!OUTCOMES.includes(market.outcome)) {
|
|
701
856
|
return 'outcome could not be read';
|
|
702
857
|
}
|
|
703
858
|
if (!inWindow || inWindow.length === 0) return 'no events inside the market window';
|
|
@@ -781,7 +936,15 @@ export function finaliseMarket(events, market) {
|
|
|
781
936
|
let downPx = null;
|
|
782
937
|
for (const ev of inWindow) {
|
|
783
938
|
if (ev.kind !== 'book') continue;
|
|
784
|
-
|
|
939
|
+
// THE SAME THREE BRANCHES AS replay.mjs, IN THE SAME ORDER. A bound was
|
|
940
|
+
// missing here while the engine applied it, so the strategy traded against
|
|
941
|
+
// a pruned ladder and the report compared it to an unpruned one: a bound
|
|
942
|
+
// landing between the UP and DOWN snapshots left up_px at the stale 0.44
|
|
943
|
+
// while ctx.book() already said 0.45. The baseline is what every headline
|
|
944
|
+
// number is measured against, so a book it never traded on is worse than a
|
|
945
|
+
// missing baseline.
|
|
946
|
+
if (ev.bbo) book.bbo(ev.ts_ms, ev.side, ev.bid, ev.ask);
|
|
947
|
+
else if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
|
|
785
948
|
else if (ev.side && ev.ladder) book.delta(ev.ts_ms, ev.side, ev.ladder, ev.px, ev.size);
|
|
786
949
|
// BOTH SIDES FROM ONE BOOK STATE, captured together.
|
|
787
950
|
//
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
export type Side = 'UP' | 'DOWN';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How a market settled. `'TIE'` is a 50:50 settlement — Predict.fun resolves
|
|
17
|
+
* an end price equal to the start price this way, and every UP and DOWN
|
|
18
|
+
* contract pays $0.50.
|
|
19
|
+
*/
|
|
20
|
+
export type Outcome = Side | 'TIE';
|
|
21
|
+
|
|
15
22
|
export declare const SIDES: readonly ['UP', 'DOWN'];
|
|
16
23
|
|
|
17
24
|
/** One level of resting depth: [price, size]. */
|
|
@@ -129,7 +136,7 @@ export interface Ctx<P = Record<string, unknown>> {
|
|
|
129
136
|
* one. Recorded for the cross-check panel, never enforced — a mismatch is
|
|
130
137
|
* information, not a failed run.
|
|
131
138
|
*/
|
|
132
|
-
assert_outcome(market: unknown, outcome:
|
|
139
|
+
assert_outcome(market: unknown, outcome: Outcome): void;
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
/**
|
|
@@ -207,7 +214,7 @@ export declare class Order {
|
|
|
207
214
|
* onTick(ctx: Ctx, tick: Tick): Order | null
|
|
208
215
|
* onBook(ctx: Ctx, book: BookView): Order | null
|
|
209
216
|
* onTrade(ctx: Ctx, trade: Tick): Order | null
|
|
210
|
-
* onSettle(ctx: Ctx, market: Market, outcome:
|
|
217
|
+
* onSettle(ctx: Ctx, market: Market, outcome: Outcome): void
|
|
211
218
|
*/
|
|
212
219
|
export declare class Strategy<P = Record<string, unknown>> {
|
|
213
220
|
/** Params from the manifest, injected by the runner before the first hook. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// untrusted output — the container is the boundary, not the harness.
|
|
7
7
|
//
|
|
8
8
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { OUTCOMES } from '../engine/portfolio.mjs';
|
|
9
10
|
|
|
10
11
|
// This is also the reason the REPORT is not computed inside. Metrics,
|
|
11
12
|
// calibration, latency and slippage are all derived outside, in one shared
|
|
@@ -159,7 +160,7 @@ export function parseTrade(raw) {
|
|
|
159
160
|
opened_ms: finite(raw.opened_ms) ? raw.opened_ms : null,
|
|
160
161
|
closed_ms: finite(raw.closed_ms) ? raw.closed_ms : null,
|
|
161
162
|
how: typeof raw.how === 'string' ? raw.how : 'exit',
|
|
162
|
-
outcome:
|
|
163
|
+
outcome: OUTCOMES.includes(raw.outcome) ? raw.outcome : undefined,
|
|
163
164
|
};
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -232,7 +233,7 @@ export function parseResult(raw) {
|
|
|
232
233
|
market_id: typeof m.market_id === 'string' ? m.market_id : null,
|
|
233
234
|
asset: typeof m.asset === 'string' ? m.asset : null,
|
|
234
235
|
interval: typeof m.interval === 'string' ? m.interval : null,
|
|
235
|
-
outcome:
|
|
236
|
+
outcome: OUTCOMES.includes(m.outcome) ? m.outcome : null,
|
|
236
237
|
up_px: finite(m.up_px) ? m.up_px : null,
|
|
237
238
|
down_px: finite(m.down_px) ? m.down_px : null,
|
|
238
239
|
stream: typeof m.stream === 'string' ? m.stream : null,
|
|
@@ -85,27 +85,33 @@ class Ladder:
|
|
|
85
85
|
|
|
86
86
|
def __init__(self, direction: int) -> None:
|
|
87
87
|
self.direction = direction
|
|
88
|
-
|
|
88
|
+
# [ticks, size, ts], best first. `ts` is when this level was last
|
|
89
|
+
# stated by the archive; `prune` needs it to tell a level the venue has
|
|
90
|
+
# moved past from one stamped the same millisecond as the bound that
|
|
91
|
+
# would delete it. Kept as a third slot rather than a parallel map so
|
|
92
|
+
# both engines carry it the same way.
|
|
93
|
+
self.levels: list[list[int | float]] = []
|
|
89
94
|
|
|
90
95
|
def _worse(self, a: int, b: int) -> int:
|
|
91
96
|
return (a - b) if self.direction > 0 else (b - a)
|
|
92
97
|
|
|
93
|
-
def reset(self, levels: Iterable[Any]) -> None:
|
|
98
|
+
def reset(self, levels: Iterable[Any], ts: int = 0) -> None:
|
|
94
99
|
rows = []
|
|
95
100
|
for entry in levels or ():
|
|
96
101
|
px, size = entry[0], float(entry[1])
|
|
97
102
|
if size > 0:
|
|
98
|
-
rows.append([to_ticks(float(px)), size])
|
|
103
|
+
rows.append([to_ticks(float(px)), size, ts])
|
|
99
104
|
rows.sort(key=lambda r: r[0] * (1 if self.direction > 0 else -1))
|
|
100
105
|
self.levels = rows
|
|
101
106
|
|
|
102
|
-
def apply(self, px: float, size: float) -> None:
|
|
107
|
+
def apply(self, px: float, size: float, ts: int = 0) -> None:
|
|
103
108
|
ticks = to_ticks(float(px))
|
|
104
109
|
n = float(size)
|
|
105
110
|
for i, level in enumerate(self.levels):
|
|
106
111
|
if level[0] == ticks:
|
|
107
112
|
if n > 0:
|
|
108
113
|
level[1] = n
|
|
114
|
+
level[2] = ts
|
|
109
115
|
else:
|
|
110
116
|
self.levels.pop(i)
|
|
111
117
|
return
|
|
@@ -114,7 +120,54 @@ class Ladder:
|
|
|
114
120
|
j = len(self.levels)
|
|
115
121
|
while j > 0 and self._worse(self.levels[j - 1][0], ticks) > 0:
|
|
116
122
|
j -= 1
|
|
117
|
-
self.levels.insert(j, [ticks, n])
|
|
123
|
+
self.levels.insert(j, [ticks, n, ts])
|
|
124
|
+
|
|
125
|
+
def prune(self, bound: float | None, ts: int) -> int:
|
|
126
|
+
"""Delete every level STRICTLY BETTER than `bound` that is older than `ts`.
|
|
127
|
+
|
|
128
|
+
The whole of what the unthrottled top-of-book stream may do. It carries
|
|
129
|
+
prices and no sizes, so it can state what is NOT on the ladder and never
|
|
130
|
+
what is; adding a level from it would be liquidity invented without a
|
|
131
|
+
size.
|
|
132
|
+
|
|
133
|
+
Why it matters: `take()` eats from the best end while the delta stream
|
|
134
|
+
maintaining this ladder is thinned to the venue's capture cadence, so the
|
|
135
|
+
stalest levels are exactly the ones an order hits first — and stale in
|
|
136
|
+
one direction only, since a price already taken still looks available.
|
|
137
|
+
|
|
138
|
+
OLDER THAN, not "at or older than": a delta and a bound stamped the same
|
|
139
|
+
millisecond contradict each other and the archive does not say which came
|
|
140
|
+
first, so requiring the level to be strictly older makes the result the
|
|
141
|
+
same whichever way a tie was sorted.
|
|
142
|
+
|
|
143
|
+
MUST MATCH Ladder.prune in runner/engine/book.mjs exactly.
|
|
144
|
+
"""
|
|
145
|
+
# A NUMBER, not something that parses as one. float('0.45') is 0.45
|
|
146
|
+
# while JavaScript's Number.isFinite('0.45') is false, so accepting
|
|
147
|
+
# strings here would make the same event prune in Python and not in
|
|
148
|
+
# Node. bool is excluded because it is an int in Python and True would
|
|
149
|
+
# otherwise read as the bound 1.
|
|
150
|
+
if not isinstance(bound, (int, float)) or isinstance(bound, bool):
|
|
151
|
+
return 0
|
|
152
|
+
b = float(bound)
|
|
153
|
+
if b != b or b in (float("inf"), float("-inf")):
|
|
154
|
+
return 0
|
|
155
|
+
# Inside [0, 1], for the reason spelled out in Ladder.prune in
|
|
156
|
+
# runner/engine/book.mjs: a bound is a maximal deletion instruction, and
|
|
157
|
+
# an outcome token cannot quote outside that range.
|
|
158
|
+
if b < 0.0 or b > 1.0:
|
|
159
|
+
return 0
|
|
160
|
+
cap = to_ticks(b)
|
|
161
|
+
kept = []
|
|
162
|
+
removed = 0
|
|
163
|
+
for level in self.levels:
|
|
164
|
+
if self._worse(level[0], cap) < 0 and level[2] < ts:
|
|
165
|
+
removed += 1
|
|
166
|
+
continue
|
|
167
|
+
kept.append(level)
|
|
168
|
+
if removed:
|
|
169
|
+
self.levels = kept
|
|
170
|
+
return removed
|
|
118
171
|
|
|
119
172
|
def best(self) -> float | None:
|
|
120
173
|
return from_ticks(self.levels[0][0]) if self.levels else None
|
|
@@ -122,14 +175,14 @@ class Ladder:
|
|
|
122
175
|
def depth(self, bound: float | None = None) -> float:
|
|
123
176
|
cap = None if bound is None else to_ticks(float(bound))
|
|
124
177
|
total = 0.0
|
|
125
|
-
for ticks, size in self.levels:
|
|
178
|
+
for ticks, size, _ts in self.levels:
|
|
126
179
|
if cap is not None and self._worse(ticks, cap) > 0:
|
|
127
180
|
break
|
|
128
181
|
total += size
|
|
129
182
|
return total
|
|
130
183
|
|
|
131
184
|
def view(self, n: int = 10) -> list[list[float]]:
|
|
132
|
-
return [[from_ticks(t), s] for t, s in self.levels[:n]]
|
|
185
|
+
return [[from_ticks(t), s] for t, s, _ts in self.levels[:n]]
|
|
133
186
|
|
|
134
187
|
def take(self, size: float, bound: float | None):
|
|
135
188
|
cap = None if bound is None else to_ticks(float(bound))
|
|
@@ -169,8 +222,8 @@ class Book:
|
|
|
169
222
|
spec = (levels or {}).get(side)
|
|
170
223
|
if not spec:
|
|
171
224
|
continue
|
|
172
|
-
self.ladders[side]["asks"].reset(spec.get("asks"))
|
|
173
|
-
self.ladders[side]["bids"].reset(spec.get("bids"))
|
|
225
|
+
self.ladders[side]["asks"].reset(spec.get("asks"), ts)
|
|
226
|
+
self.ladders[side]["bids"].reset(spec.get("bids"), ts)
|
|
174
227
|
|
|
175
228
|
def delta(self, ts: int, side: str, kind: str, px: float, size: float) -> None:
|
|
176
229
|
self.ts = ts
|
|
@@ -178,7 +231,26 @@ class Book:
|
|
|
178
231
|
raise ValueError(f"unknown side {side}")
|
|
179
232
|
if kind not in ("asks", "bids"):
|
|
180
233
|
raise ValueError(f"unknown ladder {kind}")
|
|
181
|
-
self.ladders[side][kind].apply(px, size)
|
|
234
|
+
self.ladders[side][kind].apply(px, size, ts)
|
|
235
|
+
|
|
236
|
+
def bbo(self, ts: int, side: str, bid: float | None, ask: float | None) -> int:
|
|
237
|
+
"""Apply an unthrottled top-of-book bound. DELETES ONLY.
|
|
238
|
+
|
|
239
|
+
`bid` and `ask` are prices with no size behind them, so they can shrink
|
|
240
|
+
the book and never grow it.
|
|
241
|
+
|
|
242
|
+
0 and 1 need no special case. `bid = 0` is how the venue writes "no bid",
|
|
243
|
+
and deleting every bid strictly better than 0 deletes all of them. Its
|
|
244
|
+
complement is `ask = 1` on the other token of the same market, since
|
|
245
|
+
UP + DOWN = 1 — measured as an exact pairing in the archive.
|
|
246
|
+
|
|
247
|
+
MUST MATCH Book.bbo in runner/engine/book.mjs exactly.
|
|
248
|
+
"""
|
|
249
|
+
self.ts = ts
|
|
250
|
+
if side not in SIDES:
|
|
251
|
+
raise ValueError(f"unknown side {side}")
|
|
252
|
+
lad = self.ladders[side]
|
|
253
|
+
return lad["asks"].prune(ask, ts) + lad["bids"].prune(bid, ts)
|
|
182
254
|
|
|
183
255
|
def best(self, side: str) -> float | None:
|
|
184
256
|
"""The price to BUY that outcome at — the best ask."""
|
|
@@ -249,7 +321,14 @@ def match_order(book: Book, order: dict) -> dict:
|
|
|
249
321
|
}
|
|
250
322
|
|
|
251
323
|
|
|
252
|
-
|
|
324
|
+
OUTCOME_TIE = "TIE"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def contract_value(side: str, outcome: str) -> float:
|
|
328
|
+
# A tie settles 50:50: both outcome tokens pay half a dollar. MUST MATCH
|
|
329
|
+
# portfolio.mjs `contractValue`.
|
|
330
|
+
if outcome == OUTCOME_TIE:
|
|
331
|
+
return 0.5
|
|
253
332
|
return 1 if outcome == side else 0
|
|
254
333
|
|
|
255
334
|
|
|
@@ -429,7 +429,13 @@ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
|
|
|
429
429
|
state["now"] = ts
|
|
430
430
|
|
|
431
431
|
if ev.get("kind") == "book":
|
|
432
|
-
|
|
432
|
+
# BEFORE the snapshot test, because a bound carries snapshot:false
|
|
433
|
+
# and would otherwise be applied as a delta with no ladder, no price
|
|
434
|
+
# and no size — which Book.delta rejects by raising, taking the whole
|
|
435
|
+
# run with it. MUST MATCH the same ordering in runner/engine/replay.mjs.
|
|
436
|
+
if ev.get("bbo"):
|
|
437
|
+
book.bbo(ts, ev.get("side"), ev.get("bid"), ev.get("ask"))
|
|
438
|
+
elif ev.get("snapshot"):
|
|
433
439
|
book.snapshot(ts, ev.get("levels") or {})
|
|
434
440
|
else:
|
|
435
441
|
book.delta(ts, ev.get("side"), ev.get("ladder"), ev.get("px"), ev.get("size"))
|
|
@@ -441,7 +447,11 @@ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
|
|
|
441
447
|
# comment in replay.mjs.
|
|
442
448
|
state["history"].append(Rec(ev))
|
|
443
449
|
|
|
444
|
-
hook
|
|
450
|
+
# A bound refines the book silently and never reaches a hook — the event
|
|
451
|
+
# has no levels/ladder/px/size, the stream is unthrottled, and ctx.book()
|
|
452
|
+
# is live so the next real event already sees the refined ladder. Full
|
|
453
|
+
# reasoning in replay.mjs; both engines or neither.
|
|
454
|
+
hook = None if ev.get("bbo") else HOOK_FOR.get(ev.get("kind"))
|
|
445
455
|
if hook and hooks.get(hook):
|
|
446
456
|
emit(call(hook, ev_rec), ts)
|
|
447
457
|
|