outcometick 1.6.4 → 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 CHANGED
@@ -5,7 +5,7 @@
5
5
  scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
6
6
  An edit made here survives until the next publish and then disappears.
7
7
 
8
- Generated from monorepo revision c44df8d3662f109757247e01752e966858db6a05.
8
+ Generated from monorepo revision 13e95ca5b2d1f465cb53799e610a08476f1db274.
9
9
  -->
10
10
 
11
11
  # outcometick
@@ -15,7 +15,7 @@ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
15
15
  export const SCHEMA_VERSION = 1;
16
16
 
17
17
  /** SDK version reported by the docs page and stamped into every report. */
18
- export const SDK_VERSION = '1.6.4';
18
+ export const SDK_VERSION = '1.6.5';
19
19
 
20
20
  /**
21
21
  * The tag of the sandbox images, and the ONLY place it is written down.
@@ -60,7 +60,7 @@ export const SDK_VERSION = '1.6.4';
60
60
  * forwarded a fourth descriptor, so fd 3 was closed inside the container and no
61
61
  * containerised run had ever returned anything.
62
62
  */
63
- export const SANDBOX_IMAGE_TAG = '1.15.0';
63
+ export const SANDBOX_IMAGE_TAG = '1.16.0';
64
64
 
65
65
  // ---------------------------------------------------------------------------
66
66
  // Languages
@@ -294,6 +294,24 @@ const BOOK_CAPTURE = Object.freeze({
294
294
  ]),
295
295
  });
296
296
 
297
+ /**
298
+ * The days the book cadence changed, oldest first.
299
+ *
300
+ * Exported for the prewarm, which has to warm one shape PER CADENCE TIER: the
301
+ * cadence is chosen from a run's date RANGE, so a range that stops short of a
302
+ * change and one that crosses it are two different cache entries for the same
303
+ * day. Warming a single range therefore covers exactly one tier and silently
304
+ * misses the others — which is what happened between 2026-08-25 and
305
+ * 2026-09-07, when the prewarm warmed the whole sellable span (500ms) while
306
+ * every run over recent days wanted 20ms.
307
+ *
308
+ * Derived from the table above rather than restated, so a new cadence entry
309
+ * grows the warm set without anyone remembering to come here.
310
+ */
311
+ export function bookCadenceChangeDays(venue) {
312
+ return (BOOK_CAPTURE[venue] ?? []).map((e) => e.from);
313
+ }
314
+
297
315
  /**
298
316
  * The cadence ONE ASSET replays at within a run: the COARSEST its date range
299
317
  * contains. Ask per asset; there is no run-wide answer by design.
@@ -21,14 +21,44 @@ export const DATASETS = {
21
21
  book: 'Full-depth order-book snapshots',
22
22
  best_bid_ask: 'Top of book, unthrottled — the same best bid/ask price_change carries, at every update rather than the capture cadence; prices only, no sizes, so depth still needs book or price_change',
23
23
  price_change: 'Order-book deltas with best bid/ask',
24
- last_trade_price: 'Every trade print',
24
+ last_trade_price: 'Trade prints, unthrottled',
25
25
  markets: 'Per-market metadata, strike and settlement outcome',
26
26
  tick_size_change: 'Tick-size changes',
27
27
  orderbook: 'Order-book snapshots (Predict.fun)',
28
- klines: 'OHLCV candles derived from the settlement feed',
28
+ klines: 'OHLC candles derived from the settlement feed, with a tick count — no trade volume; the settlement feed is a price feed',
29
29
  other: 'Uncategorised',
30
30
  };
31
31
 
32
+ /**
33
+ * The same descriptions in Chinese.
34
+ *
35
+ * HERE, not in the page that renders them. Two of these lines state the
36
+ * SETTLEMENT BASIS — which TWAP stream settles which market — and CLAUDE.md
37
+ * pins that fact to four places that must move together. A Chinese copy living
38
+ * in a component would be a fifth, and it would drift the way every other
39
+ * hand-copy in this repo has: the basis already moved once (5-minute markets
40
+ * settled on the 30s stream until 2026-08-07, on the 60s stream after), and an
41
+ * AI assistant quoted a stale copy of our coverage back to a customer.
42
+ *
43
+ * Sitting beside DATASETS means changing one entry puts the other on screen in
44
+ * the same diff. data-taxonomy.test.mjs asserts the key sets match, so a new
45
+ * dataset cannot land with only one language.
46
+ */
47
+ export const DATASETS_ZH = {
48
+ prices: '结算价流,逐 tick(Chainlink 瞬时流)',
49
+ twap30s: 'TWAP 30 秒结算流——5 分钟市场改用 60 秒回看之前的结算线,至今仍逐日归档',
50
+ twap60s: 'TWAP 60 秒结算流——5 分钟与 15 分钟市场都判定在它上面',
51
+ book: '全深度盘口快照',
52
+ best_bid_ask: '盘口顶部,未抽稀——与 price_change 携带的是同一个最优买卖价,但每次更新都出一行,而不是按采集节奏;只有价格没有挂单量,深度仍需 book 或 price_change',
53
+ price_change: '盘口增量,带最优买卖价',
54
+ last_trade_price: '成交流,未抽稀',
55
+ markets: '每个市场的元数据、strike 与结算结果',
56
+ tick_size_change: '最小变动价位的变更',
57
+ orderbook: '盘口快照(Predict.fun)',
58
+ klines: '由结算价流推导的 OHLC K 线,带 tick 计数——没有成交量,结算流是价格流不是成交流',
59
+ other: '未归类',
60
+ };
61
+
32
62
  const num = (s) => (s == null ? null : s);
33
63
 
34
64
  /**
@@ -4,7 +4,7 @@
4
4
  // R2. Both go through runner/events.mjs for the row -> event mapping, because
5
5
  // the docs make a promise about exactly this pair:
6
6
  //
7
- // The identical files, same checksums, same coverage report. A backtest
7
+ // The identical files, byte for byte, with the same checksums. A backtest
8
8
  // here and a backtest on your own machine after subscribing read the same
9
9
  // bytes — that is the point of offering it.
10
10
  //
package/index.d.ts CHANGED
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.6.4",
3
+ "version": "1.6.5",
4
4
  "description": "Strategy SDK and CLI for outcometick prediction-market backtests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
 
@@ -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,7 +6,7 @@
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
 
@@ -14,6 +14,7 @@ import { classifyPath } from '../api/lib/data-taxonomy.mjs';
14
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,
@@ -582,7 +600,7 @@ export function buildSlugIndex(markets) {
582
600
  * The coverage block, in ONE shape.
583
601
  *
584
602
  * The docs promise a local run and a queued run produce "the identical files,
585
- * 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
586
604
  * the feed list; the coverage object was still built twice, so `ot run` emitted
587
605
  * five keys where the queue emitted ten, and anyone diffing the two saw a
588
606
  * schema difference rather than an answer. A field a local run genuinely cannot
@@ -834,7 +852,7 @@ export function marketUnusable(market, inWindow) {
834
852
  // nothing to merge — replayed it out of the cache anyway.
835
853
  if (!market.asset) return 'market has no asset';
836
854
  if (market.stream == null) return 'settlement stream could not be resolved';
837
- if (market.outcome !== 'UP' && market.outcome !== 'DOWN') {
855
+ if (!OUTCOMES.includes(market.outcome)) {
838
856
  return 'outcome could not be read';
839
857
  }
840
858
  if (!inWindow || inWindow.length === 0) return 'no events inside the market window';
@@ -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,
@@ -321,7 +321,14 @@ def match_order(book: Book, order: dict) -> dict:
321
321
  }
322
322
 
323
323
 
324
- 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
325
332
  return 1 if outcome == side else 0
326
333
 
327
334