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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/api/lib/backtest-contract.mjs +318 -0
- package/api/lib/backtest-datasets.mjs +225 -0
- package/api/lib/backtest-manifest.mjs +345 -0
- package/api/lib/coverage-window.mjs +42 -0
- package/api/lib/data-taxonomy.mjs +175 -0
- package/api/lib/venue-path.mjs +16 -0
- package/bin/ot.mjs +4 -0
- package/cli/api-client.mjs +71 -0
- package/cli/commands/fetch.mjs +43 -0
- package/cli/commands/run.mjs +269 -0
- package/cli/commands/status.mjs +102 -0
- package/cli/commands/submit.mjs +77 -0
- package/cli/local-data.mjs +177 -0
- package/cli/ot.mjs +223 -0
- package/index.d.ts +195 -0
- package/index.mjs +2 -0
- package/package.json +58 -0
- package/runner/analyze/index.mjs +40 -0
- package/runner/analyze/javascript.mjs +380 -0
- package/runner/analyze/python.mjs +85 -0
- package/runner/analyze/python_analyze.py +320 -0
- package/runner/archive.mjs +185 -0
- package/runner/engine/book.mjs +226 -0
- package/runner/engine/portfolio.mjs +292 -0
- package/runner/engine/replay.mjs +496 -0
- package/runner/engine/report.mjs +417 -0
- package/runner/events.mjs +190 -0
- package/runner/harness/node/harness.mjs +467 -0
- package/runner/harness/node/sdk/index.d.ts +195 -0
- package/runner/harness/node/sdk/index.mjs +71 -0
- package/runner/harness/node/sdk/package.json +8 -0
- package/runner/harness/protocol.mjs +255 -0
- package/runner/harness/python/harness.py +374 -0
- package/runner/harness/python/otengine.py +523 -0
- package/runner/harness/python/otreplay.py +409 -0
- 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
|