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.
@@ -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
- filled = size - remaining
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
- size = float(order.get("size") or 0)
330
- if not size > 0:
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
- res = match_order(book, {**order, "size": size})
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
- def __init__(self, limit_micros: float = 400, sample_floor: int = 200,
474
- tolerance: float = 0.01) -> None:
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 (self.count >= self.sample_floor
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, log_limit, references,
159
+ def __init__(self, params, portfolio, market_id, log_budget, references,
106
160
  series, rng):
107
- object.__setattr__(self, "p", params)
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
- "log_limit": log_limit,
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
- if len(s["logs"]) >= s["log_limit"]:
233
+ budget = s["log_budget"]
234
+ if budget["spent"] >= budget["bytes"]:
169
235
  s["log_truncated"] = True
170
236
  return
171
- s["logs"].append(f'{s["now"]} {msg}')
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": outcome,
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
- log_limit: int = 10_000, budget: BudgetMonitor | None = None,
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, log_limit,
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
- if not (isinstance(size, (int, float)) and size > 0):
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
- if limit is not None and not (0 <= float(limit) <= 1):
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.