outcometick 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/api/lib/backtest-contract.mjs +318 -0
  4. package/api/lib/backtest-datasets.mjs +225 -0
  5. package/api/lib/backtest-manifest.mjs +345 -0
  6. package/api/lib/coverage-window.mjs +42 -0
  7. package/api/lib/data-taxonomy.mjs +175 -0
  8. package/api/lib/venue-path.mjs +16 -0
  9. package/bin/ot.mjs +4 -0
  10. package/cli/api-client.mjs +71 -0
  11. package/cli/commands/fetch.mjs +43 -0
  12. package/cli/commands/run.mjs +269 -0
  13. package/cli/commands/status.mjs +102 -0
  14. package/cli/commands/submit.mjs +77 -0
  15. package/cli/local-data.mjs +177 -0
  16. package/cli/ot.mjs +223 -0
  17. package/index.d.ts +195 -0
  18. package/index.mjs +2 -0
  19. package/package.json +58 -0
  20. package/runner/analyze/index.mjs +40 -0
  21. package/runner/analyze/javascript.mjs +380 -0
  22. package/runner/analyze/python.mjs +85 -0
  23. package/runner/analyze/python_analyze.py +320 -0
  24. package/runner/archive.mjs +185 -0
  25. package/runner/engine/book.mjs +226 -0
  26. package/runner/engine/portfolio.mjs +292 -0
  27. package/runner/engine/replay.mjs +496 -0
  28. package/runner/engine/report.mjs +417 -0
  29. package/runner/events.mjs +190 -0
  30. package/runner/harness/node/harness.mjs +467 -0
  31. package/runner/harness/node/sdk/index.d.ts +195 -0
  32. package/runner/harness/node/sdk/index.mjs +71 -0
  33. package/runner/harness/node/sdk/package.json +8 -0
  34. package/runner/harness/protocol.mjs +255 -0
  35. package/runner/harness/python/harness.py +374 -0
  36. package/runner/harness/python/otengine.py +523 -0
  37. package/runner/harness/python/otreplay.py +409 -0
  38. package/runner/harness/python/outcometick.py +67 -0
@@ -0,0 +1,523 @@
1
+ """The matching engine, ported from runner/engine/*.mjs.
2
+
3
+ Why a port and not a call: the per-event budget is 400 microseconds, and an IPC
4
+ round trip per event over hundreds of millions of events is not close to
5
+ affordable. So Python strategies get a Python engine.
6
+
7
+ Two implementations of the same rules is a drift risk, and the mitigation is
8
+ runner/conformance: golden vectors generated from the JavaScript engine that
9
+ BOTH harnesses must reproduce exactly. If you change anything in here, change
10
+ the JavaScript too and regenerate the vectors — a report that depends on which
11
+ language the customer wrote in is worthless.
12
+
13
+ The one subtle thing is rounding. JavaScript's Math.round breaks ties upward
14
+ (Math.round(0.5) == 1, Math.round(-0.5) == -0) while Python's round() uses
15
+ banker's rounding (round(0.5) == 0). Prices are quantised through this, so
16
+ using the native round here would put fills on a different tick than the
17
+ JavaScript engine for every exact half. js_round below is the compatible one and
18
+ is the only rounding this module uses.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from typing import Any, Callable, Iterable
25
+
26
+ SIDES = ("UP", "DOWN")
27
+ PRICE_SCALE = 10_000
28
+ EPS = 1e-9
29
+
30
+
31
+ class Rec(dict):
32
+ """A record a strategy reads with attribute access.
33
+
34
+ The SDK documents `tick.value`, `market.strike` and `pos.size` — the same
35
+ spelling in both languages. Events arrive from JSON as dicts, so without
36
+ this a Python strategy written straight from the docs fails with
37
+ "'dict' object has no attribute 'value'" on its first tick, while the
38
+ identical JavaScript works. That is an API-parity break, not a papercut.
39
+
40
+ Subscript access still works, so a strategy written either way is fine.
41
+ """
42
+
43
+ __slots__ = ()
44
+
45
+ def __getattr__(self, name):
46
+ try:
47
+ return self[name]
48
+ except KeyError:
49
+ raise AttributeError(
50
+ f"{name!r} is not on this record; it has {', '.join(sorted(self))}"
51
+ ) from None
52
+
53
+ def __setattr__(self, name, value):
54
+ self[name] = value
55
+
56
+
57
+ def js_round(x: float) -> int:
58
+ """Math.round from JavaScript: ties go toward +Infinity, not to even.
59
+
60
+ Not a stylistic choice. round(0.5) is 0 in Python and 1 in JavaScript, so
61
+ the native function would quantise a price landing exactly on a half-tick to
62
+ a different level than the JS engine — a silent, data-dependent divergence
63
+ in what fills.
64
+ """
65
+ return math.floor(x + 0.5)
66
+
67
+
68
+ def to_ticks(px: float) -> int:
69
+ return js_round(px * PRICE_SCALE)
70
+
71
+
72
+ def from_ticks(t: int) -> float:
73
+ return t / PRICE_SCALE
74
+
75
+
76
+ class Ladder:
77
+ """One side of one outcome token's book.
78
+
79
+ `direction` is which way "better" runs: +1 for asks (cheapest first), -1 for
80
+ bids (dearest first).
81
+ """
82
+
83
+ __slots__ = ("direction", "levels")
84
+
85
+ def __init__(self, direction: int) -> None:
86
+ self.direction = direction
87
+ self.levels: list[list[int | float]] = [] # [ticks, size], best first
88
+
89
+ def _worse(self, a: int, b: int) -> int:
90
+ return (a - b) if self.direction > 0 else (b - a)
91
+
92
+ def reset(self, levels: Iterable[Any]) -> None:
93
+ rows = []
94
+ for entry in levels or ():
95
+ px, size = entry[0], float(entry[1])
96
+ if size > 0:
97
+ rows.append([to_ticks(float(px)), size])
98
+ rows.sort(key=lambda r: r[0] * (1 if self.direction > 0 else -1))
99
+ self.levels = rows
100
+
101
+ def apply(self, px: float, size: float) -> None:
102
+ ticks = to_ticks(float(px))
103
+ n = float(size)
104
+ for i, level in enumerate(self.levels):
105
+ if level[0] == ticks:
106
+ if n > 0:
107
+ level[1] = n
108
+ else:
109
+ self.levels.pop(i)
110
+ return
111
+ if not n > 0:
112
+ return
113
+ j = len(self.levels)
114
+ while j > 0 and self._worse(self.levels[j - 1][0], ticks) > 0:
115
+ j -= 1
116
+ self.levels.insert(j, [ticks, n])
117
+
118
+ def best(self) -> float | None:
119
+ return from_ticks(self.levels[0][0]) if self.levels else None
120
+
121
+ def depth(self, bound: float | None = None) -> float:
122
+ cap = None if bound is None else to_ticks(float(bound))
123
+ total = 0.0
124
+ for ticks, size in self.levels:
125
+ if cap is not None and self._worse(ticks, cap) > 0:
126
+ break
127
+ total += size
128
+ return total
129
+
130
+ def view(self, n: int = 10) -> list[list[float]]:
131
+ return [[from_ticks(t), s] for t, s in self.levels[:n]]
132
+
133
+ def take(self, size: float, bound: float | None):
134
+ cap = None if bound is None else to_ticks(float(bound))
135
+ fills: list[dict[str, float]] = []
136
+ remaining = float(size)
137
+ notional = 0.0
138
+ while remaining > 0 and self.levels:
139
+ level = self.levels[0]
140
+ if cap is not None and self._worse(level[0], cap) > 0:
141
+ break
142
+ take = min(remaining, level[1])
143
+ px = from_ticks(level[0])
144
+ fills.append({"px": px, "size": take})
145
+ notional += px * take
146
+ remaining -= take
147
+ level[1] -= take
148
+ if level[1] <= 0:
149
+ self.levels.pop(0)
150
+ return fills, remaining, notional
151
+
152
+
153
+ class Book:
154
+ """A binary market: two outcome tokens, each with bids and asks."""
155
+
156
+ __slots__ = ("market_id", "ts", "ladders")
157
+
158
+ def __init__(self, market_id: str) -> None:
159
+ self.market_id = market_id
160
+ self.ts = 0
161
+ self.ladders = {
162
+ side: {"asks": Ladder(1), "bids": Ladder(-1)} for side in SIDES
163
+ }
164
+
165
+ def snapshot(self, ts: int, levels: dict[str, Any]) -> None:
166
+ self.ts = ts
167
+ for side in SIDES:
168
+ spec = (levels or {}).get(side)
169
+ if not spec:
170
+ continue
171
+ self.ladders[side]["asks"].reset(spec.get("asks"))
172
+ self.ladders[side]["bids"].reset(spec.get("bids"))
173
+
174
+ def delta(self, ts: int, side: str, kind: str, px: float, size: float) -> None:
175
+ self.ts = ts
176
+ if side not in SIDES:
177
+ raise ValueError(f"unknown side {side}")
178
+ if kind not in ("asks", "bids"):
179
+ raise ValueError(f"unknown ladder {kind}")
180
+ self.ladders[side][kind].apply(px, size)
181
+
182
+ def best(self, side: str) -> float | None:
183
+ """The price to BUY that outcome at — the best ask."""
184
+ lad = self.ladders.get(side)
185
+ return lad["asks"].best() if lad else None
186
+
187
+ def best_bid(self, side: str) -> float | None:
188
+ lad = self.ladders.get(side)
189
+ return lad["bids"].best() if lad else None
190
+
191
+ def depth(self, side: str, bound: float | None = None) -> float:
192
+ lad = self.ladders.get(side)
193
+ return lad["asks"].depth(bound) if lad else 0.0
194
+
195
+ def bid_depth(self, side: str, bound: float | None = None) -> float:
196
+ lad = self.ladders.get(side)
197
+ return lad["bids"].depth(bound) if lad else 0.0
198
+
199
+ def levels(self, side: str, n: int = 10):
200
+ lad = self.ladders.get(side)
201
+ return lad["asks"].view(n) if lad else []
202
+
203
+ def bid_levels(self, side: str, n: int = 10):
204
+ lad = self.ladders.get(side)
205
+ return lad["bids"].view(n) if lad else []
206
+
207
+ def mid(self, side: str) -> float | None:
208
+ a, b = self.best(side), self.best_bid(side)
209
+ return None if a is None or b is None else (a + b) / 2
210
+
211
+
212
+ def match_order(book: Book, order: dict) -> dict:
213
+ """Match a taker order, consuming what it takes.
214
+
215
+ `limit` is a ceiling when opening and a floor when reducing — a bound in
216
+ whichever direction protects the trader.
217
+ """
218
+ size = float(order.get("size") or 0)
219
+ reducing = bool(order.get("reduce_only"))
220
+ side = order.get("side")
221
+ blank = {
222
+ "fills": [], "filled": 0.0, "unfilled": max(0.0, size),
223
+ "notional": 0.0, "avg_px": None, "worst_px": None,
224
+ "quoted_px": None, "reduce_only": reducing,
225
+ }
226
+ if side not in SIDES or not size > 0:
227
+ return blank
228
+
229
+ ladder = book.ladders[side]["bids" if reducing else "asks"]
230
+ quoted = ladder.best()
231
+ fills, remaining, notional = ladder.take(size, order.get("limit"))
232
+ filled = size - remaining
233
+ return {
234
+ "fills": fills,
235
+ "filled": filled,
236
+ "unfilled": remaining,
237
+ "notional": notional,
238
+ "avg_px": (notional / filled) if filled > 0 else None,
239
+ "worst_px": fills[-1]["px"] if fills else None,
240
+ "quoted_px": quoted,
241
+ "reduce_only": reducing,
242
+ }
243
+
244
+
245
+ def contract_value(side: str, outcome: str) -> int:
246
+ return 1 if outcome == side else 0
247
+
248
+
249
+ class Leg:
250
+ """One side of one market, plus the round trip in progress."""
251
+
252
+ __slots__ = ("side", "size", "cost", "entry_size", "entry_notional",
253
+ "exit_size", "exit_notional", "realised", "fees", "entry_ts")
254
+
255
+ def __init__(self, side: str) -> None:
256
+ self.side = side
257
+ self.size = 0.0
258
+ self.cost = 0.0
259
+ self.reset()
260
+
261
+ def reset(self) -> None:
262
+ self.entry_size = 0.0
263
+ self.entry_notional = 0.0
264
+ self.exit_size = 0.0
265
+ self.exit_notional = 0.0
266
+ self.realised = 0.0
267
+ self.fees = 0.0
268
+ self.entry_ts = None
269
+
270
+ @property
271
+ def avg_entry(self):
272
+ return (self.cost / self.size) if self.size > EPS else None
273
+
274
+ @property
275
+ def trade_entry_px(self):
276
+ return (self.entry_notional / self.entry_size) if self.entry_size > EPS else None
277
+
278
+ @property
279
+ def trade_exit_px(self):
280
+ return (self.exit_notional / self.exit_size) if self.exit_size > EPS else None
281
+
282
+
283
+ class Portfolio:
284
+ def __init__(self, fee_bps: float = 0) -> None:
285
+ self.fee_bps = float(fee_bps or 0)
286
+ self.legs: dict[str, dict[str, Leg]] = {}
287
+ self.trades: list[dict] = []
288
+ self.fills: list[dict] = []
289
+ self.cash = 0.0
290
+ self.fees_paid = 0.0
291
+ self.rejected = 0
292
+
293
+ def _legs(self, market_id: str) -> dict[str, Leg]:
294
+ legs = self.legs.get(market_id)
295
+ if legs is None:
296
+ legs = {"UP": Leg("UP"), "DOWN": Leg("DOWN")}
297
+ self.legs[market_id] = legs
298
+ return legs
299
+
300
+ def size_of(self, market_id: str, side: str) -> float:
301
+ return self._legs(market_id)[side].size
302
+
303
+ def position(self, market_id: str, book: Book | None = None) -> dict:
304
+ legs = self._legs(market_id)
305
+ realised = legs["UP"].realised + legs["DOWN"].realised
306
+ open_legs = [leg for leg in (legs["UP"], legs["DOWN"]) if leg.size > EPS]
307
+ if not open_legs:
308
+ return Rec(side=None, size=0.0, avg_entry=None,
309
+ unrealised=0.0, realised=realised, both=False)
310
+ if len(open_legs) == 1:
311
+ lead = open_legs[0]
312
+ else:
313
+ lead = legs["UP"] if legs["UP"].size >= legs["DOWN"].size else legs["DOWN"]
314
+ # Marked against the BID: the bid is where the position could actually
315
+ # be closed. Marking at the ask reports a profit that cannot be taken.
316
+ mark = book.best_bid(lead.side) if book else None
317
+ unrealised = 0.0 if mark is None else (mark - lead.avg_entry) * lead.size
318
+ return Rec(side=lead.side, size=lead.size, avg_entry=lead.avg_entry,
319
+ unrealised=unrealised, realised=realised,
320
+ both=len(open_legs) == 2)
321
+
322
+ def execute(self, book: Book, order: dict, ts: int, market_id: str,
323
+ tag: str | None = None, how: str = "exit"):
324
+ if not isinstance(order, dict) or order.get("side") not in SIDES:
325
+ self.rejected += 1
326
+ return None
327
+
328
+ leg = self._legs(market_id)[order["side"]]
329
+ size = float(order.get("size") or 0)
330
+ if not size > 0:
331
+ self.rejected += 1
332
+ return None
333
+
334
+ if order.get("reduce_only"):
335
+ size = min(size, leg.size)
336
+ if not size > EPS:
337
+ self.rejected += 1
338
+ return None
339
+
340
+ res = match_order(book, {**order, "size": size})
341
+ if res["filled"] <= 0:
342
+ self.fills.append(self._fill_row(ts, market_id, order, res, tag, 0.0, 0.0))
343
+ return res
344
+
345
+ fee = (res["notional"] * self.fee_bps) / 10_000
346
+ self.fees_paid += fee
347
+ realised = 0.0
348
+
349
+ if order.get("reduce_only"):
350
+ basis = leg.avg_entry or 0.0
351
+ realised = res["notional"] - basis * res["filled"] - fee
352
+ leg.size -= res["filled"]
353
+ leg.cost -= basis * res["filled"]
354
+ if leg.size <= EPS:
355
+ leg.size = 0.0
356
+ leg.cost = 0.0
357
+ leg.realised += realised
358
+ leg.fees += fee
359
+ leg.exit_size += res["filled"]
360
+ leg.exit_notional += res["notional"]
361
+ self.cash += res["notional"] - fee
362
+ if leg.size == 0.0:
363
+ self._close_trade(market_id, leg, ts, how)
364
+ else:
365
+ if leg.size <= EPS and leg.entry_ts is None:
366
+ leg.entry_ts = ts
367
+ leg.size += res["filled"]
368
+ leg.cost += res["notional"]
369
+ leg.entry_size += res["filled"]
370
+ leg.entry_notional += res["notional"]
371
+ leg.fees += fee
372
+ # The ENTRY fee belongs in the round trip's realised PnL — see the
373
+ # matching comment in portfolio.mjs. Both engines or neither.
374
+ leg.realised -= fee
375
+ self.cash -= res["notional"] + fee
376
+
377
+ self.fills.append(self._fill_row(ts, market_id, order, res, tag, realised, fee))
378
+ return res
379
+
380
+ def _fill_row(self, ts, market_id, order, res, tag, realised, fee) -> dict:
381
+ return {
382
+ "ts_ms": ts,
383
+ "market_id": market_id,
384
+ "side": order.get("side"),
385
+ "action": "reduce" if order.get("reduce_only") else "open",
386
+ "requested": float(order.get("size") or 0),
387
+ "filled": res["filled"],
388
+ "unfilled": res["unfilled"],
389
+ "avg_px": res["avg_px"],
390
+ "worst_px": res["worst_px"],
391
+ "quoted_px": res["quoted_px"],
392
+ "levels_walked": len(res["fills"]),
393
+ "fee": fee,
394
+ "realised": realised,
395
+ "tag": tag if tag is not None else order.get("tag"),
396
+ }
397
+
398
+ def _close_trade(self, market_id, leg: Leg, ts, how, **extra) -> None:
399
+ row = {
400
+ "market_id": market_id,
401
+ "side": leg.side,
402
+ "size": leg.exit_size,
403
+ "entry_px": leg.trade_entry_px,
404
+ "exit_px": leg.trade_exit_px,
405
+ "pnl": leg.realised,
406
+ "fees": leg.fees,
407
+ "opened_ms": leg.entry_ts,
408
+ "closed_ms": ts,
409
+ "how": how,
410
+ }
411
+ row.update(extra)
412
+ self.trades.append(row)
413
+ leg.reset()
414
+
415
+ def settle(self, market_id: str, outcome: str, ts: int) -> list[dict]:
416
+ legs = self.legs.get(market_id)
417
+ if not legs:
418
+ return []
419
+ closed = []
420
+ for side in SIDES:
421
+ leg = legs[side]
422
+ if leg.size <= EPS:
423
+ continue
424
+ # Priced at $1/$0: a binary market's terminal value is a fact, not
425
+ # a quote. No fee — nothing is traded, the market pays out.
426
+ value = contract_value(side, outcome) * leg.size
427
+ leg.realised += value - leg.cost
428
+ leg.exit_size += leg.size
429
+ leg.exit_notional += value
430
+ self.cash += value
431
+ before = len(self.trades)
432
+ self._close_trade(market_id, leg, ts, "settled", outcome=outcome)
433
+ closed.append(self.trades[before])
434
+ leg.size = 0.0
435
+ leg.cost = 0.0
436
+ return closed
437
+
438
+ def flatten(self, market_id: str, book: Book, ts: int, how: str = "hold_expired") -> None:
439
+ legs = self.legs.get(market_id)
440
+ if not legs:
441
+ return
442
+ for side in SIDES:
443
+ leg = legs[side]
444
+ if leg.size <= EPS:
445
+ continue
446
+ self.execute(
447
+ book,
448
+ {"side": side, "size": leg.size, "limit": None, "reduce_only": True},
449
+ ts, market_id, tag=how, how=how,
450
+ )
451
+
452
+ def equity(self, books: dict[str, Book] | None = None) -> float:
453
+ open_value = 0.0
454
+ for market_id, legs in self.legs.items():
455
+ book = (books or {}).get(market_id)
456
+ for side in SIDES:
457
+ leg = legs[side]
458
+ if leg.size <= EPS:
459
+ continue
460
+ mark = book.best_bid(side) if book else None
461
+ open_value += leg.cost if mark is None else mark * leg.size
462
+ return self.cash + open_value
463
+
464
+
465
+ class RunAbort(Exception):
466
+ def __init__(self, code: str, detail: str) -> None:
467
+ super().__init__(detail)
468
+ self.code = code
469
+ self.detail = detail
470
+
471
+
472
+ class BudgetMonitor:
473
+ def __init__(self, limit_micros: float = 400, sample_floor: int = 200,
474
+ tolerance: float = 0.01) -> None:
475
+ self.limit_micros = limit_micros
476
+ self.sample_floor = sample_floor
477
+ self.tolerance = tolerance
478
+ self.count = 0
479
+ self.breaches = 0
480
+ self.max_micros = 0.0
481
+ self.total_micros = 0.0
482
+
483
+ def record(self, micros: float) -> None:
484
+ self.count += 1
485
+ self.total_micros += micros
486
+ if micros > self.max_micros:
487
+ self.max_micros = micros
488
+ if micros > self.limit_micros:
489
+ self.breaches += 1
490
+
491
+ @property
492
+ def breached(self) -> bool:
493
+ return (self.count >= self.sample_floor
494
+ and self.breaches / self.count > self.tolerance)
495
+
496
+ def summary(self) -> dict:
497
+ return {
498
+ "events": self.count,
499
+ "breaches": self.breaches,
500
+ "breach_rate": (self.breaches / self.count) if self.count else 0,
501
+ "avg_micros": (self.total_micros / self.count) if self.count else 0,
502
+ "max_micros": self.max_micros,
503
+ "limit_micros": self.limit_micros,
504
+ }
505
+
506
+
507
+ def make_rng(run_seed: int) -> Callable[[int | None], Callable[[], float]]:
508
+ """splitmix32, identical to the JavaScript implementation."""
509
+
510
+ def factory(seed: int | None = None):
511
+ state = (run_seed if seed is None else int(seed)) & 0xFFFFFFFF
512
+
513
+ def nxt() -> float:
514
+ nonlocal state
515
+ state = (state + 0x9E3779B9) & 0xFFFFFFFF
516
+ z = state
517
+ z = ((z ^ (z >> 16)) * 0x21F0AAAD) & 0xFFFFFFFF
518
+ z = ((z ^ (z >> 15)) * 0x735A2D97) & 0xFFFFFFFF
519
+ return ((z ^ (z >> 15)) & 0xFFFFFFFF) / 4294967296
520
+
521
+ return nxt
522
+
523
+ return factory