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.
@@ -12,8 +12,24 @@
12
12
 
13
13
  import { matchOrder, isSide } from './book.mjs';
14
14
 
15
- /** Settlement value of one contract, given the official outcome. */
16
- export const contractValue = (side, outcome) => (outcome === side ? 1 : 0);
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
 
@@ -571,14 +571,35 @@ export function replayMarket({
571
571
  control.setNow(ev.ts_ms);
572
572
 
573
573
  if (ev.kind === 'book') {
574
- if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
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
- const hook = HOOK_FOR[ev.kind];
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) {
@@ -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, so
185
- * the edge on one contract is (outcome - p). Only settled trades carry an
186
- * outcome; a trade closed early is edge against the market, not against the
187
- * truth, and is excluded rather than scored as if it had settled.
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
- const won = t.outcome === t.side ? 1 : 0;
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
- const won = t.outcome === t.side ? 1 : 0;
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.outcome === t.side ? 1 : 0)));
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 += ((m.outcome === 'UP' ? 1 : 0) - m.up_px) * size;
302
- out.always_down += ((m.outcome === 'DOWN' ? 1 : 0) - m.down_px) * size;
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 === favSide ? 1 : 0) - favPx) * size;
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, same checksums, same coverage report"
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
- // RESOLVED is the venue's own word for "this is final". An OPEN market has no
150
- // outcome even if both prices are present — they are live quotes then, not a
151
- // settlement, and treating them as one would hand a strategy the answer.
152
- // Same rule, and the same reason: only a RESOLVED market with two readable
153
- // prices has an outcome. A tie is not a guess either the venue settles it
154
- // one way and we do not know which, so the market-day is dropped.
155
- const outcome = String(row.status).toUpperCase() === 'RESOLVED'
156
- && start != null && end != null && end !== start
157
- ? (end > start ? 'UP' : 'DOWN')
158
- : null;
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
- * same checksums, same coverage report". They already shared the decoder and
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 (market.outcome !== 'UP' && market.outcome !== 'DOWN') {
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
- if (ev.snapshot) book.snapshot(ev.ts_ms, ev.levels);
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: Side): void;
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: Side): void
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: raw.outcome === 'UP' || raw.outcome === 'DOWN' ? raw.outcome : undefined,
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: m.outcome === 'UP' || m.outcome === 'DOWN' ? m.outcome : null,
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
- self.levels: list[list[int | float]] = [] # [ticks, size], best first
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
- def contract_value(side: str, outcome: str) -> int:
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
- if ev.get("snapshot"):
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 = HOOK_FOR.get(ev.get("kind"))
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