outcometick 1.5.2 → 1.6.2
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 +8 -1
- package/api/lib/backtest-contract.mjs +335 -16
- package/api/lib/backtest-datasets.mjs +125 -1
- package/api/lib/backtest-manifest.mjs +52 -4
- package/api/lib/coverage-window.mjs +63 -1
- package/api/lib/data-taxonomy.mjs +15 -1
- package/cli/commands/run.mjs +206 -37
- package/cli/commands/submit.mjs +40 -3
- package/cli/local-data.mjs +86 -15
- package/cli/ot.mjs +26 -3
- package/index.d.ts +31 -4
- package/package.json +1 -1
- package/runner/archive.mjs +24 -13
- package/runner/engine/book.mjs +12 -1
- package/runner/engine/feed.mjs +116 -0
- package/runner/engine/portfolio.mjs +77 -5
- package/runner/engine/replay.mjs +136 -14
- package/runner/engine/report.mjs +96 -34
- package/runner/events.mjs +788 -55
- package/runner/harness/node/harness.mjs +166 -13
- package/runner/harness/node/sdk/index.d.ts +31 -4
- package/runner/harness/node/sdk/index.mjs +46 -2
- package/runner/harness/protocol.mjs +31 -3
- package/runner/harness/python/harness.py +137 -19
- package/runner/harness/python/otengine.py +116 -9
- package/runner/harness/python/otfeed.py +109 -0
- package/runner/harness/python/otreplay.py +90 -8
- package/runner/harness/python/outcometick.py +48 -4
- package/runner/series-data.mjs +220 -0
|
@@ -21,6 +21,7 @@ is the only rounding this module uses.
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import math
|
|
24
|
+
|
|
24
25
|
from typing import Any, Callable, Iterable
|
|
25
26
|
|
|
26
27
|
SIDES = ("UP", "DOWN")
|
|
@@ -229,7 +230,13 @@ def match_order(book: Book, order: dict) -> dict:
|
|
|
229
230
|
ladder = book.ladders[side]["bids" if reducing else "asks"]
|
|
230
231
|
quoted = ladder.best()
|
|
231
232
|
fills, remaining, notional = ladder.take(size, order.get("limit"))
|
|
232
|
-
|
|
233
|
+
# SUMMED FROM WHAT WAS TAKEN, not derived as `size - remaining`. Mirrors
|
|
234
|
+
# book.mjs: `remaining` is the requested size with each level subtracted
|
|
235
|
+
# from it, and at float precision 1e308 - 1000 is still 1e308 -- so a huge
|
|
236
|
+
# but finite order consumed the whole ladder while reporting filled 0. No
|
|
237
|
+
# position, no cash, no trade row, and an empty book for everything after
|
|
238
|
+
# it. Adding up the levels taken cannot drift from what was removed.
|
|
239
|
+
filled = sum(f["size"] for f in fills)
|
|
233
240
|
return {
|
|
234
241
|
"fills": fills,
|
|
235
242
|
"filled": filled,
|
|
@@ -326,18 +333,74 @@ class Portfolio:
|
|
|
326
333
|
return None
|
|
327
334
|
|
|
328
335
|
leg = self._legs(market_id)[order["side"]]
|
|
329
|
-
|
|
330
|
-
|
|
336
|
+
|
|
337
|
+
# ONE PLACE THAT DECIDES WHETHER AN ORDER IS USABLE.
|
|
338
|
+
# Mirrors portfolio.mjs line for line -- see the reasoning there. Every
|
|
339
|
+
# unusable order is COUNTED AND RETURNS None, never raised: ValueError
|
|
340
|
+
# or OverflowError here would turn "reject one order" into "fail the
|
|
341
|
+
# whole run", which is the same input producing two different outcomes
|
|
342
|
+
# in two engines that are supposed to be one engine.
|
|
343
|
+
def _finite(v):
|
|
344
|
+
# A NUMBER, not something float() is willing to turn into one.
|
|
345
|
+
# Mirrors portfolio.mjs: coercion is not validation, and the two
|
|
346
|
+
# languages coerce differently -- float("10") and Number("10") both
|
|
347
|
+
# give 10, but float([10]) raises where Number([10]) gives 10. One
|
|
348
|
+
# strategy, one input, two outcomes.
|
|
349
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
350
|
+
return None
|
|
351
|
+
f = float(v)
|
|
352
|
+
return f if math.isfinite(f) else None
|
|
353
|
+
|
|
354
|
+
has_size = order.get("size") is not None
|
|
355
|
+
has_notional = order.get("notional") is not None
|
|
356
|
+
if has_size == has_notional:
|
|
331
357
|
self.rejected += 1
|
|
332
358
|
return None
|
|
333
359
|
|
|
360
|
+
limit = None
|
|
361
|
+
if order.get("limit") is not None:
|
|
362
|
+
limit = _finite(order.get("limit"))
|
|
363
|
+
if limit is None or limit < 0 or limit > 1:
|
|
364
|
+
self.rejected += 1
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
if has_notional:
|
|
368
|
+
budget = _finite(order.get("notional"))
|
|
369
|
+
if budget is None or budget <= 0 or limit is None or limit <= 0:
|
|
370
|
+
self.rejected += 1
|
|
371
|
+
return None
|
|
372
|
+
# The QUOTIENT can overflow from two finite inputs: 1e308 / 0.01 is
|
|
373
|
+
# inf, and math.floor(inf) raises. Checked before the floor.
|
|
374
|
+
#
|
|
375
|
+
# floor(a / b), NOT a // b: Python's float floor-division is not
|
|
376
|
+
# the same function -- 80 // 0.64 is 124 where Math.floor(80 / 0.64)
|
|
377
|
+
# is 125, and 1 // 0.1 is 9.
|
|
378
|
+
q = budget / limit
|
|
379
|
+
if not math.isfinite(q):
|
|
380
|
+
self.rejected += 1
|
|
381
|
+
return None
|
|
382
|
+
size = float(math.floor(q))
|
|
383
|
+
else:
|
|
384
|
+
size = _finite(order.get("size"))
|
|
385
|
+
if size is None or not size > 0:
|
|
386
|
+
self.rejected += 1
|
|
387
|
+
return None
|
|
334
388
|
if order.get("reduce_only"):
|
|
335
389
|
size = min(size, leg.size)
|
|
336
390
|
if not size > EPS:
|
|
337
391
|
self.rejected += 1
|
|
338
392
|
return None
|
|
339
393
|
|
|
340
|
-
|
|
394
|
+
# One effective order from here on -- AFTER the reduce_only clamp, so
|
|
395
|
+
# the row reports what was actually requested of the book rather than
|
|
396
|
+
# what the strategy asked for before clamping. The derived size has to
|
|
397
|
+
# reach the fill row as well as the match: it did not, and a
|
|
398
|
+
# notional-only order produced a row whose `requested` was NaN, which
|
|
399
|
+
# the parser dropped. The fill happened; only its record vanished.
|
|
400
|
+
# Mirrors portfolio.mjs.
|
|
401
|
+
order = {**order, "size": size, "limit": limit}
|
|
402
|
+
|
|
403
|
+
res = match_order(book, order)
|
|
341
404
|
if res["filled"] <= 0:
|
|
342
405
|
self.fills.append(self._fill_row(ts, market_id, order, res, tag, 0.0, 0.0))
|
|
343
406
|
return res
|
|
@@ -470,15 +533,46 @@ class RunAbort(Exception):
|
|
|
470
533
|
|
|
471
534
|
|
|
472
535
|
class BudgetMonitor:
|
|
473
|
-
|
|
474
|
-
|
|
536
|
+
"""Per-event budget. The mirror of BudgetMonitor in engine/replay.mjs.
|
|
537
|
+
|
|
538
|
+
SUSTAINED cost, which is what the limit is for and what customers are told
|
|
539
|
+
it means. The mean, not the tail.
|
|
540
|
+
|
|
541
|
+
This judged the breach rate against a 1% tolerance, and it was measuring the
|
|
542
|
+
wrong machine: the budget brackets each hook with two wall-clock reads, on a
|
|
543
|
+
2-core box where the worker decompresses and feeds stdin the whole time, so
|
|
544
|
+
an event that gets descheduled is recorded as an event the strategy spent
|
|
545
|
+
4ms in. Measured inside the real image, the same strategy averages 7.8us on
|
|
546
|
+
an idle host and 20.9us under contention, with worst cases of 766us and
|
|
547
|
+
4090us. Nothing about the strategy changed.
|
|
548
|
+
|
|
549
|
+
The page's own sample was rejected in production at avg 72us, a fifth of its
|
|
550
|
+
400us budget, because 1.1% of its events had been interrupted -- and those
|
|
551
|
+
breaches were scattered (events 171, 2368, 3201), not front-loaded, so no
|
|
552
|
+
warm-up floor could have fixed it.
|
|
553
|
+
|
|
554
|
+
The mean predicts the thing this protects: the 20-minute wall clock is mean
|
|
555
|
+
times event count. A hook that never returns is caught by the run deadline,
|
|
556
|
+
which is where that belongs.
|
|
557
|
+
|
|
558
|
+
Windowed, not lifetime: one monitor covers the whole run, so a lifetime mean
|
|
559
|
+
lets a cheap prefix pay for an expensive phase.
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
def __init__(self, limit_micros: float = 400, sample_floor: int = 2000) -> None:
|
|
475
563
|
self.limit_micros = limit_micros
|
|
476
564
|
self.sample_floor = sample_floor
|
|
477
|
-
self.tolerance = tolerance
|
|
478
565
|
self.count = 0
|
|
479
566
|
self.breaches = 0
|
|
480
567
|
self.max_micros = 0.0
|
|
481
568
|
self.total_micros = 0.0
|
|
569
|
+
# The most recent sample_floor events, as a ring. One monitor covers the
|
|
570
|
+
# whole run, so a lifetime mean is diluted by everything that came
|
|
571
|
+
# before: 18,000 events at 8us then 2,000 at 2,000us averages 207us and
|
|
572
|
+
# passes, while the last two thousand are continuously 5x over budget.
|
|
573
|
+
self._window = [0.0] * sample_floor
|
|
574
|
+
self._window_sum = 0.0
|
|
575
|
+
self._window_at = 0
|
|
482
576
|
|
|
483
577
|
def record(self, micros: float) -> None:
|
|
484
578
|
self.count += 1
|
|
@@ -487,11 +581,20 @@ class BudgetMonitor:
|
|
|
487
581
|
self.max_micros = micros
|
|
488
582
|
if micros > self.limit_micros:
|
|
489
583
|
self.breaches += 1
|
|
584
|
+
self._window_sum += micros - self._window[self._window_at]
|
|
585
|
+
self._window[self._window_at] = micros
|
|
586
|
+
self._window_at = (self._window_at + 1) % len(self._window)
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def window_micros(self) -> float:
|
|
590
|
+
"""Mean of the most recent sample_floor events. Zero until it fills."""
|
|
591
|
+
if self.count < self.sample_floor:
|
|
592
|
+
return 0.0
|
|
593
|
+
return self._window_sum / len(self._window)
|
|
490
594
|
|
|
491
595
|
@property
|
|
492
596
|
def breached(self) -> bool:
|
|
493
|
-
return
|
|
494
|
-
and self.breaches / self.count > self.tolerance)
|
|
597
|
+
return self.count >= self.sample_floor and self.window_micros > self.limit_micros
|
|
495
598
|
|
|
496
599
|
def summary(self) -> dict:
|
|
497
600
|
return {
|
|
@@ -499,6 +602,10 @@ class BudgetMonitor:
|
|
|
499
602
|
"breaches": self.breaches,
|
|
500
603
|
"breach_rate": (self.breaches / self.count) if self.count else 0,
|
|
501
604
|
"avg_micros": (self.total_micros / self.count) if self.count else 0,
|
|
605
|
+
# The number the verdict is made on. Without it a rejection shows a
|
|
606
|
+
# lifetime average inside budget and reads as a lie.
|
|
607
|
+
"window_micros": self.window_micros,
|
|
608
|
+
"window_events": self.sample_floor,
|
|
502
609
|
"max_micros": self.max_micros,
|
|
503
610
|
"limit_micros": self.limit_micros,
|
|
504
611
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Point-in-time views over an out-of-band series — the Python half.
|
|
2
|
+
|
|
3
|
+
The mirror of runner/engine/feed.mjs. ``ctx.ref(name)`` and ``ctx.ext(name)``
|
|
4
|
+
call ``feed.view_at(ctx.now)`` here and ``feed.viewAt(ctx.now)`` there; neither
|
|
5
|
+
existed until now, so a manifest declaring ``reference`` or ``series`` was
|
|
6
|
+
accepted, queued, billed, and then crashed on the strategy's first
|
|
7
|
+
``ctx.ref(...)`` claiming the feed had not been declared -- which it had.
|
|
8
|
+
|
|
9
|
+
Two properties, the same two the JavaScript guarantees:
|
|
10
|
+
|
|
11
|
+
1. NOTHING STAMPED AFTER ctx.now IS REACHABLE. The cursor never advances past
|
|
12
|
+
``now``, so a later row is not something a strategy can ask for. It is not
|
|
13
|
+
filtered on the way out; it is not reachable. A reference feed is exactly
|
|
14
|
+
where look-ahead would otherwise leak, because we hold the whole series up
|
|
15
|
+
front.
|
|
16
|
+
|
|
17
|
+
2. WHAT THE STRATEGY GETS IS A COPY. ``ctx.book()`` and ``ctx.history()`` were
|
|
18
|
+
both caught handing out live objects a strategy could rewrite; a rewritten
|
|
19
|
+
reference row would poison every later ``window()`` over it.
|
|
20
|
+
|
|
21
|
+
``lag_ms`` models publication delay: a row is invisible until ts_ms + lag_ms.
|
|
22
|
+
|
|
23
|
+
Any change here needs the same change in feed.mjs. runner/conformance compares
|
|
24
|
+
the two engines row by row and will go red if they drift.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from bisect import bisect_right
|
|
30
|
+
|
|
31
|
+
from otengine import Rec
|
|
32
|
+
|
|
33
|
+
__all__ = ("PointInTimeFeed", "build_feeds")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _View:
|
|
37
|
+
"""What a strategy holds. Read-only, and clamped to the moment it was made."""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_rows", "_n", "_horizon")
|
|
40
|
+
|
|
41
|
+
def __init__(self, rows, n, horizon):
|
|
42
|
+
self._rows = rows
|
|
43
|
+
self._n = n
|
|
44
|
+
self._horizon = horizon
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def last(self):
|
|
48
|
+
"""The most recent row at or before now, or None."""
|
|
49
|
+
return Rec(self._rows[self._n - 1]) if self._n > 0 else None
|
|
50
|
+
|
|
51
|
+
def window(self, k):
|
|
52
|
+
"""The last ``k`` visible rows, oldest first. Never more than exist."""
|
|
53
|
+
try:
|
|
54
|
+
want = int(k)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
want = 0
|
|
57
|
+
want = max(0, min(want, self._n))
|
|
58
|
+
return [Rec(r) for r in self._rows[self._n - want:self._n]]
|
|
59
|
+
|
|
60
|
+
def at(self, ts):
|
|
61
|
+
"""The row in effect at ``ts``, clamped to now.
|
|
62
|
+
|
|
63
|
+
Asking for a later timestamp cannot reach a later row.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
asked = float(ts)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
asked = self._horizon
|
|
69
|
+
t = min(asked, self._horizon)
|
|
70
|
+
# bisect over the visible prefix only.
|
|
71
|
+
i = bisect_right([r["ts_ms"] for r in self._rows[:self._n]], t)
|
|
72
|
+
return Rec(self._rows[i - 1]) if i > 0 else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PointInTimeFeed:
|
|
76
|
+
def __init__(self, rows, lag_ms=0):
|
|
77
|
+
self.rows = rows
|
|
78
|
+
try:
|
|
79
|
+
self.lag_ms = float(lag_ms) or 0.0
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
self.lag_ms = 0.0
|
|
82
|
+
# Monotone cursor: replay only moves forward, so the feed is walked once
|
|
83
|
+
# across a market-day. A fresh feed is built per market, so this is not
|
|
84
|
+
# an assumption about what the strategy does.
|
|
85
|
+
self.cursor = 0
|
|
86
|
+
|
|
87
|
+
def _visible_count(self, now):
|
|
88
|
+
limit = now - self.lag_ms
|
|
89
|
+
rows = self.rows
|
|
90
|
+
while self.cursor < len(rows) and rows[self.cursor]["ts_ms"] <= limit:
|
|
91
|
+
self.cursor += 1
|
|
92
|
+
if self.cursor > 0 and rows[self.cursor - 1]["ts_ms"] > limit:
|
|
93
|
+
i = self.cursor
|
|
94
|
+
while i > 0 and rows[i - 1]["ts_ms"] > limit:
|
|
95
|
+
i -= 1
|
|
96
|
+
return i
|
|
97
|
+
return self.cursor
|
|
98
|
+
|
|
99
|
+
def view_at(self, now):
|
|
100
|
+
return _View(self.rows, self._visible_count(now), now - self.lag_ms)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_feeds(declared, rows_by_name, lag_by_name=None):
|
|
104
|
+
"""Feeds a run declared, keyed by the name the strategy will ask for."""
|
|
105
|
+
lag_by_name = lag_by_name or {}
|
|
106
|
+
return {
|
|
107
|
+
name: PointInTimeFeed(rows_by_name.get(name, []), lag_by_name.get(name, 0))
|
|
108
|
+
for name in (declared or [])
|
|
109
|
+
}
|
|
@@ -14,11 +14,45 @@ the conformance vectors compare them row for row. In particular:
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import os
|
|
18
|
+
import select
|
|
17
19
|
import time
|
|
18
20
|
from typing import Any, Callable
|
|
19
21
|
|
|
20
22
|
from otengine import Book, BudgetMonitor, Portfolio, Rec, RunAbort, make_rng
|
|
21
23
|
|
|
24
|
+
|
|
25
|
+
def write_all(fd, data, write=os.write):
|
|
26
|
+
"""Write every byte, or raise.
|
|
27
|
+
|
|
28
|
+
A bare `os.write` is a SHORT write waiting to happen, and the loss is
|
|
29
|
+
silent: it returns how many bytes it took and the caller that ignores the
|
|
30
|
+
number simply drops the rest. Under gVisor -- which is what runs in
|
|
31
|
+
production -- a write past the 64KB pipe buffer returns exactly 65536 and
|
|
32
|
+
the container exits 0, so nothing anywhere reports a problem.
|
|
33
|
+
|
|
34
|
+
That is not hypothetical. The result line carries one summary per market,
|
|
35
|
+
so it passes 64KB at roughly 500 markets; a market-day of polymarket is
|
|
36
|
+
~386. Every Python run large enough to matter lost the tail of its result
|
|
37
|
+
line, the worker never saw a terminating newline, and `collect()` fell back
|
|
38
|
+
to a default whose markets_run is 0 -- reported to the customer as
|
|
39
|
+
"no market-days were replayed". Seventeen runs, no report, every one
|
|
40
|
+
refunded. Node was unaffected only because fs.writeSync loops internally.
|
|
41
|
+
"""
|
|
42
|
+
view = memoryview(data)
|
|
43
|
+
while view:
|
|
44
|
+
try:
|
|
45
|
+
n = write(fd, view)
|
|
46
|
+
except BlockingIOError:
|
|
47
|
+
select.select([], [fd], [])
|
|
48
|
+
continue
|
|
49
|
+
if n <= 0:
|
|
50
|
+
# Not survivable and not silent: a result channel that accepts
|
|
51
|
+
# nothing means this run has no way to report anything at all.
|
|
52
|
+
raise OSError("short write to the result channel")
|
|
53
|
+
view = view[n:]
|
|
54
|
+
|
|
55
|
+
|
|
22
56
|
HOOK_FOR = {"tick": "on_tick", "book": "on_book", "trade": "on_trade"}
|
|
23
57
|
|
|
24
58
|
|
|
@@ -93,6 +127,26 @@ class BookView:
|
|
|
93
127
|
_INTERNALS: dict[int, dict] = {}
|
|
94
128
|
|
|
95
129
|
|
|
130
|
+
# Mirrors LIMITS.logLineChars / LIMITS.logBytesPerRun in
|
|
131
|
+
# api/lib/backtest-contract.mjs. Two engines disagreeing about how much a
|
|
132
|
+
# strategy may log is the same strategy behaving differently in two languages,
|
|
133
|
+
# which is the thing the conformance suite exists to prevent.
|
|
134
|
+
LOG_LINE_CHARS = 512
|
|
135
|
+
LOG_BYTES_PER_RUN = 2 * 1024 * 1024
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# A settlement recompute is a once-per-market claim, so these are generous.
|
|
139
|
+
# `crosschecks` rides the same result line as everything else: unbounded, it is
|
|
140
|
+
# an output channel with no budget. Mirrors replay.mjs.
|
|
141
|
+
MAX_CROSSCHECKS_PER_MARKET = 16
|
|
142
|
+
CROSSCHECK_CLAIMED_CHARS = 32
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def make_log_budget(bytes_: int = LOG_BYTES_PER_RUN, line_chars: int = LOG_LINE_CHARS) -> dict:
|
|
146
|
+
"""A log allowance for one run, shared by every market in it."""
|
|
147
|
+
return {"bytes": bytes_, "line_chars": line_chars, "spent": 0}
|
|
148
|
+
|
|
149
|
+
|
|
96
150
|
class Ctx:
|
|
97
151
|
"""The strategy's whole world.
|
|
98
152
|
|
|
@@ -102,9 +156,15 @@ class Ctx:
|
|
|
102
156
|
|
|
103
157
|
__slots__ = ("p", "market_id", "__weakref__")
|
|
104
158
|
|
|
105
|
-
def __init__(self, params, portfolio, market_id,
|
|
159
|
+
def __init__(self, params, portfolio, market_id, log_budget, references,
|
|
106
160
|
series, rng):
|
|
107
|
-
|
|
161
|
+
# Rec, not the bare dict the job JSON carries: the SDK documents
|
|
162
|
+
# `ctx.p.entry_z` and every Python example in the docs is written
|
|
163
|
+
# that way, so a plain dict makes all of them fail on the first
|
|
164
|
+
# hook with "'dict' object has no attribute ...", while the
|
|
165
|
+
# identical JavaScript runs. Same parity break Rec exists to stop;
|
|
166
|
+
# `p` was simply missed. Subscript access keeps working.
|
|
167
|
+
object.__setattr__(self, "p", Rec(params or {}))
|
|
108
168
|
object.__setattr__(self, "market_id", market_id)
|
|
109
169
|
_INTERNALS[id(self)] = {
|
|
110
170
|
"now": 0,
|
|
@@ -112,7 +172,7 @@ class Ctx:
|
|
|
112
172
|
"book": None,
|
|
113
173
|
"history": [],
|
|
114
174
|
"logs": [],
|
|
115
|
-
"
|
|
175
|
+
"log_budget": log_budget,
|
|
116
176
|
"refs": references or {},
|
|
117
177
|
"series": series or {},
|
|
118
178
|
"rng": rng,
|
|
@@ -164,11 +224,23 @@ class Ctx:
|
|
|
164
224
|
return s["pf"].position(self.market_id, s["book"])
|
|
165
225
|
|
|
166
226
|
def log(self, msg: Any) -> None:
|
|
227
|
+
# Bytes for the WHOLE RUN, not lines per market -- the mirror of
|
|
228
|
+
# ctx.log in engine/replay.mjs. The old shape gave every market its own
|
|
229
|
+
# allowance of 10,000 unbounded lines, and polymarket has ~386 markets
|
|
230
|
+
# a day, so a run could pour the archive it had just paid for into a
|
|
231
|
+
# file the customer downloads.
|
|
167
232
|
s = _INTERNALS[id(self)]
|
|
168
|
-
|
|
233
|
+
budget = s["log_budget"]
|
|
234
|
+
if budget["spent"] >= budget["bytes"]:
|
|
169
235
|
s["log_truncated"] = True
|
|
170
236
|
return
|
|
171
|
-
|
|
237
|
+
line = f'{s["now"]} {msg}'[: budget["line_chars"]]
|
|
238
|
+
# BYTES, not characters -- logs.txt is UTF-8. The JS side counts the
|
|
239
|
+
# same way; a run logging Chinese would otherwise spend a third of what
|
|
240
|
+
# it actually writes. The line cap stays in characters (readability);
|
|
241
|
+
# the run cap is about how much data leaves with the customer.
|
|
242
|
+
budget["spent"] += len(line.encode("utf-8")) + 1
|
|
243
|
+
s["logs"].append(line)
|
|
172
244
|
|
|
173
245
|
def random(self, seed: int | None = None):
|
|
174
246
|
return _INTERNALS[id(self)]["rng"](seed)
|
|
@@ -232,9 +304,18 @@ class Ctx:
|
|
|
232
304
|
"""
|
|
233
305
|
s = _INTERNALS[id(self)]
|
|
234
306
|
official = (s["market"] or {}).get("outcome")
|
|
307
|
+
# BOUNDED, for the same reason ctx.log is, and mirrored in replay.mjs.
|
|
308
|
+
# `outcome` is whatever the strategy passed and this can be called on
|
|
309
|
+
# every event; the whole list is serialised onto the result line, sent
|
|
310
|
+
# and parsed before anything downstream can ignore it. The panel only
|
|
311
|
+
# shows an aggregate, and a settlement recompute is a once-per-market
|
|
312
|
+
# claim, so a cap costs nothing real.
|
|
313
|
+
if len(s["crosschecks"]) >= MAX_CROSSCHECKS_PER_MARKET:
|
|
314
|
+
return
|
|
315
|
+
claimed = outcome if isinstance(outcome, str) else str(outcome)
|
|
235
316
|
s["crosschecks"].append({
|
|
236
317
|
"market_id": self.market_id,
|
|
237
|
-
"claimed":
|
|
318
|
+
"claimed": claimed[:CROSSCHECK_CLAIMED_CHARS],
|
|
238
319
|
"official": official,
|
|
239
320
|
"match": official == outcome,
|
|
240
321
|
})
|
|
@@ -242,7 +323,7 @@ class Ctx:
|
|
|
242
323
|
|
|
243
324
|
def replay_market(*, market: dict, events: list, strategy, hooks: dict,
|
|
244
325
|
portfolio: Portfolio | None = None, fill_delay_ms: int = 0,
|
|
245
|
-
|
|
326
|
+
log_budget: dict | None = None, budget: BudgetMonitor | None = None,
|
|
246
327
|
references=None, series=None, seed: int = 1,
|
|
247
328
|
fee_bps: float = 0) -> dict:
|
|
248
329
|
market_id = market["market_id"]
|
|
@@ -257,7 +338,8 @@ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
|
|
|
257
338
|
pf = portfolio if portfolio is not None else Portfolio(fee_bps=fee_bps)
|
|
258
339
|
book = Book(market_id)
|
|
259
340
|
monitor = budget if budget is not None else BudgetMonitor()
|
|
260
|
-
ctx = Ctx(getattr(strategy, "p", {}) or {}, pf, market_id,
|
|
341
|
+
ctx = Ctx(getattr(strategy, "p", {}) or {}, pf, market_id,
|
|
342
|
+
log_budget if log_budget is not None else make_log_budget(),
|
|
261
343
|
references, series, make_rng(seed))
|
|
262
344
|
state = _INTERNALS[id(ctx)]
|
|
263
345
|
state["book"] = book
|
|
@@ -9,6 +9,8 @@ with.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import math
|
|
13
|
+
|
|
12
14
|
# Named explicitly so `from __future__ import annotations` does not leak
|
|
13
15
|
# `annotations` into the package's public surface — this module is also
|
|
14
16
|
# published to PyPI, where dir(outcometick) is what a user reads as the API.
|
|
@@ -40,13 +42,55 @@ class Order:
|
|
|
40
42
|
|
|
41
43
|
__slots__ = ("side", "size", "limit", "hold_s", "reduce_only", "tif", "tag")
|
|
42
44
|
|
|
43
|
-
def __init__(self, side, size, limit=None, hold_s=None, reduce_only=False,
|
|
44
|
-
tif="ioc", tag=None):
|
|
45
|
+
def __init__(self, side, size=None, limit=None, hold_s=None, reduce_only=False,
|
|
46
|
+
tif="ioc", tag=None, notional=None):
|
|
45
47
|
if side not in SIDES:
|
|
46
48
|
raise ValueError(f'side must be "UP" or "DOWN", got {side!r}')
|
|
47
|
-
|
|
49
|
+
# SIZING IN MONEY -- this is about the `notional` argument below.
|
|
50
|
+
# `size` is CONTRACTS (see OrderSizing in index.d.ts); it is not
|
|
51
|
+
# money, and reading this heading as if it were is the one wrong
|
|
52
|
+
# turn this comment can cause. Mirrors index.mjs exactly -- see
|
|
53
|
+
# the reasoning there.
|
|
54
|
+
# Position sizing is nearly always a budget, and the only honest
|
|
55
|
+
# divisor is your own limit: a contract costs whatever it fills at, so
|
|
56
|
+
# dividing by the current best price overspends the moment there is any
|
|
57
|
+
# slippage. `notional` therefore REQUIRES `limit`.
|
|
58
|
+
if notional is not None:
|
|
59
|
+
if size is not None:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"give size or notional, not both -- they answer the same question two ways")
|
|
62
|
+
if not (isinstance(notional, (int, float)) and not isinstance(notional, bool)
|
|
63
|
+
and math.isfinite(notional) and notional > 0):
|
|
64
|
+
raise ValueError(f"notional must be a positive number, got {notional!r}")
|
|
65
|
+
if limit is None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"notional needs a limit: without a price ceiling there is no way"
|
|
68
|
+
" to turn a budget into a size")
|
|
69
|
+
px = float(limit)
|
|
70
|
+
if not (math.isfinite(px) and px > 0):
|
|
71
|
+
raise ValueError(f"notional needs a limit above 0, got {limit!r}")
|
|
72
|
+
# FLOOR, so the spend is at most the budget rather than around it.
|
|
73
|
+
# floor(a / b), NOT a // b — see otengine.py. Python's float
|
|
74
|
+
# floor-division disagrees with Math.floor(a / b) on decimal
|
|
75
|
+
# boundaries: 80 // 0.64 is 124, Math.floor(80 / 0.64) is 125.
|
|
76
|
+
derived = math.floor(notional / px)
|
|
77
|
+
if derived < 1:
|
|
78
|
+
raise ValueError(f"notional {notional} buys no contracts at limit {px}")
|
|
79
|
+
size = derived
|
|
80
|
+
# FINITE, and not a bool. Node's Order uses Number.isFinite here, so
|
|
81
|
+
# without this `Order(size=float("inf"))` constructs in Python and
|
|
82
|
+
# throws in JS -- the published SDK behaving differently in the two
|
|
83
|
+
# languages it ships for.
|
|
84
|
+
if not (isinstance(size, (int, float)) and not isinstance(size, bool)
|
|
85
|
+
and math.isfinite(size) and size > 0):
|
|
48
86
|
raise ValueError(f"size must be a positive number, got {size!r}")
|
|
49
|
-
|
|
87
|
+
# A NUMBER, matching the engine and index.mjs. float('0.5') and
|
|
88
|
+
# Number('0.5') both give 0.5, but float([0.5]) raises where
|
|
89
|
+
# Number([0.5]) gives 0.5 -- the same published SDK behaving
|
|
90
|
+
# differently in the two languages it ships for.
|
|
91
|
+
if limit is not None and not (
|
|
92
|
+
isinstance(limit, (int, float)) and not isinstance(limit, bool)
|
|
93
|
+
and math.isfinite(limit) and 0 <= float(limit) <= 1):
|
|
50
94
|
# A binary outcome token trades between 0 and 1. A limit outside
|
|
51
95
|
# that is not a price, and silently clamping it would fill an order
|
|
52
96
|
# the strategy never asked for.
|