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,409 @@
1
+ """The event loop and the ctx object, ported from runner/engine/replay.mjs.
2
+
3
+ Same rules as the JavaScript version and the same order of operations, because
4
+ the conformance vectors compare them row for row. In particular:
5
+
6
+ - pending orders are drained against the book as it stood BEFORE the current
7
+ event is applied, then again after, so a delayed order cannot fill against
8
+ depth that arrived after it;
9
+ - hold_s is measured from the FILL, not from the decision, because a fill that
10
+ landed late has not been held as long;
11
+ - instance state resets per market unless the run is in session mode, which is
12
+ the property that lets a run be sharded at all.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any, Callable
19
+
20
+ from otengine import Book, BudgetMonitor, Portfolio, Rec, RunAbort, make_rng
21
+
22
+ HOOK_FOR = {"tick": "on_tick", "book": "on_book", "trade": "on_trade"}
23
+
24
+
25
+ # Live books, keyed by the view that fronts them.
26
+ #
27
+ # The first version stored the Book on the view as `_b`. Python has no privacy
28
+ # and the analyser cannot blanket-refuse single-underscore attributes (a
29
+ # strategy's own `self._entered` is normal), so `ctx.book()._b.ladders[...]`
30
+ # reached the engine-owned ladder — verified: a strategy inserted a level that
31
+ # never existed and filled 1000 contracts at $0.01 in a market whose real book
32
+ # held 10 at $0.90.
33
+ #
34
+ # With the reference in a side table there is no attribute to find.
35
+ _BOOKS: dict[int, Book] = {}
36
+
37
+
38
+ class BookView:
39
+ """A read-only view of a book — the mirror of bookView() in replay.mjs."""
40
+
41
+ __slots__ = ("__weakref__",)
42
+
43
+ def __init__(self, book):
44
+ _BOOKS[id(self)] = book
45
+
46
+ def __setattr__(self, name, value):
47
+ raise AttributeError("the book is read-only")
48
+
49
+ def __getattr__(self, name):
50
+ raise AttributeError(f"{name!r} does not exist on a book view")
51
+
52
+ @property
53
+ def market_id(self):
54
+ return _BOOKS[id(self)].market_id
55
+
56
+ @property
57
+ def ts(self):
58
+ return _BOOKS[id(self)].ts
59
+
60
+ def best(self, side):
61
+ return _BOOKS[id(self)].best(side)
62
+
63
+ def best_bid(self, side):
64
+ return _BOOKS[id(self)].best_bid(side)
65
+
66
+ def depth(self, side, bound=None):
67
+ return _BOOKS[id(self)].depth(side, bound)
68
+
69
+ def bid_depth(self, side, bound=None):
70
+ return _BOOKS[id(self)].bid_depth(side, bound)
71
+
72
+ def levels(self, side, n=10):
73
+ return _BOOKS[id(self)].levels(side, n)
74
+
75
+ def bid_levels(self, side, n=10):
76
+ return _BOOKS[id(self)].bid_levels(side, n)
77
+
78
+ def mid(self, side):
79
+ return _BOOKS[id(self)].mid(side)
80
+
81
+
82
+ # Engine internals, keyed by the Ctx that fronts them.
83
+ #
84
+ # Deliberately NOT attributes on Ctx. The earlier version held `_pf`, `_book`
85
+ # and `_history` as ordinary underscore-prefixed fields, so a strategy could
86
+ # reach `ctx._pf.trades` and push a fabricated settled trade into the report —
87
+ # invent a profit, or delete a real loss, and the worker archived it as fact.
88
+ #
89
+ # Python has no true privacy, so this is defence in depth rather than a wall:
90
+ # the side table means there is no attribute to find, `__getattr__` below
91
+ # refuses the old names outright, and the static analyser already refuses
92
+ # `getattr`, `vars` and dunder attribute access, which are the ways back in.
93
+ _INTERNALS: dict[int, dict] = {}
94
+
95
+
96
+ class Ctx:
97
+ """The strategy's whole world.
98
+
99
+ Everything the runner will let a strategy touch is on this object; anything
100
+ not here does not exist in the process.
101
+ """
102
+
103
+ __slots__ = ("p", "market_id", "__weakref__")
104
+
105
+ def __init__(self, params, portfolio, market_id, log_limit, references,
106
+ series, rng):
107
+ object.__setattr__(self, "p", params)
108
+ object.__setattr__(self, "market_id", market_id)
109
+ _INTERNALS[id(self)] = {
110
+ "now": 0,
111
+ "pf": portfolio,
112
+ "book": None,
113
+ "history": [],
114
+ "logs": [],
115
+ "log_limit": log_limit,
116
+ "refs": references or {},
117
+ "series": series or {},
118
+ "rng": rng,
119
+ "crosschecks": [],
120
+ "log_truncated": False,
121
+ "market": None,
122
+ }
123
+
124
+ @property
125
+ def now(self):
126
+ """Read-only: ctx.ref()/ctx.ext() use it as the point-in-time cursor,
127
+ so a strategy that could assign it would read future rows."""
128
+ return _INTERNALS[id(self)]["now"]
129
+
130
+ def __getattr__(self, name):
131
+ # Every name, including `_s` — which used to be a convenience property
132
+ # and was therefore a documented route to the live Portfolio and Book.
133
+ # The internals live in a module-level table keyed by id(self); there is
134
+ # no attribute on this object that leads to them.
135
+ raise AttributeError(
136
+ f"{name!r} does not exist on ctx; a strategy reaches the engine "
137
+ "only through the documented methods"
138
+ )
139
+
140
+ def __setattr__(self, name, value):
141
+ raise AttributeError("ctx is read-only")
142
+
143
+
144
+ def book(self, market_id: str | None = None) -> Book | None:
145
+ if market_id and market_id != self.market_id:
146
+ # Cross-market reads are what session mode is for. Answering here
147
+ # would silently break the sharding guarantee.
148
+ raise RunAbort(
149
+ "E_STATE",
150
+ f'ctx.book({market_id}) from market {self.market_id}: '
151
+ 'cross-market state needs mode "session"',
152
+ )
153
+ return _INTERNALS[id(self)]["view"]
154
+
155
+ def history(self, n: int = 1) -> list[dict]:
156
+ hist = _INTERNALS[id(self)]["history"]
157
+ k = max(0, min(int(n or 0), len(hist)))
158
+ # COPIES: handing back the live rows would let a strategy rewrite the
159
+ # series its own indicators are computed from.
160
+ return [Rec(row) for row in hist[len(hist) - k:]]
161
+
162
+ def position(self) -> dict:
163
+ s = _INTERNALS[id(self)]
164
+ return s["pf"].position(self.market_id, s["book"])
165
+
166
+ def log(self, msg: Any) -> None:
167
+ s = _INTERNALS[id(self)]
168
+ if len(s["logs"]) >= s["log_limit"]:
169
+ s["log_truncated"] = True
170
+ return
171
+ s["logs"].append(f'{s["now"]} {msg}')
172
+
173
+ def random(self, seed: int | None = None):
174
+ return _INTERNALS[id(self)]["rng"](seed)
175
+
176
+ def ref(self, name: str):
177
+ feed = _INTERNALS[id(self)]["refs"].get(name)
178
+ if feed is None:
179
+ raise RunAbort("E_MANIFEST", f"reference feed {name} was not declared in the manifest")
180
+ return feed.view_at(self.now)
181
+
182
+ def ext(self, name: str):
183
+ entry = _INTERNALS[id(self)]["series"].get(name)
184
+ if entry is None:
185
+ raise RunAbort("E_MANIFEST", f"series {name} was not declared in the manifest")
186
+ return entry.view_at(self.now)
187
+
188
+ # ---- rolling helpers. Numerically identical to the JavaScript versions. ----
189
+
190
+ def _tail(self, window: int) -> list[float]:
191
+ hist = _INTERNALS[id(self)]["history"]
192
+ n = max(1, min(int(window or 1), len(hist)))
193
+ return [t.get("value") for t in hist[len(hist) - n:]]
194
+
195
+ def zscore(self, value: float, window: int = 60) -> float:
196
+ xs = self._tail(window)
197
+ if len(xs) < 2:
198
+ return 0.0
199
+ mean = sum(xs) / len(xs)
200
+ variance = sum((x - mean) ** 2 for x in xs) / len(xs)
201
+ sd = variance ** 0.5
202
+ return 0.0 if sd == 0 else (value - mean) / sd
203
+
204
+ def sma(self, window: int = 60):
205
+ xs = self._tail(window)
206
+ return (sum(xs) / len(xs)) if xs else None
207
+
208
+ def stdev(self, window: int = 60) -> float:
209
+ xs = self._tail(window)
210
+ if len(xs) < 2:
211
+ return 0.0
212
+ mean = sum(xs) / len(xs)
213
+ return (sum((x - mean) ** 2 for x in xs) / len(xs)) ** 0.5
214
+
215
+ def ema(self, window: int = 60):
216
+ xs = self._tail(window)
217
+ if not xs:
218
+ return None
219
+ k = 2 / (len(xs) + 1)
220
+ acc = 0.0
221
+ for i, x in enumerate(xs):
222
+ acc = x if i == 0 else x * k + acc * (1 - k)
223
+ return acc
224
+
225
+ def assert_outcome(self, _market: Any, outcome: Any) -> None:
226
+ """Record a cross-check. Never fails the run — it is information.
227
+
228
+ The first argument is IGNORED for everything that matters: it used to
229
+ supply both `official` and `market_id`, so a strategy could book itself
230
+ a recompute match that never happened. The panel's whole value is that
231
+ it is the ARCHIVE's answer. Mirrors replay.mjs.
232
+ """
233
+ s = _INTERNALS[id(self)]
234
+ official = (s["market"] or {}).get("outcome")
235
+ s["crosschecks"].append({
236
+ "market_id": self.market_id,
237
+ "claimed": outcome,
238
+ "official": official,
239
+ "match": official == outcome,
240
+ })
241
+
242
+
243
+ def replay_market(*, market: dict, events: list, strategy, hooks: dict,
244
+ portfolio: Portfolio | None = None, fill_delay_ms: int = 0,
245
+ log_limit: int = 10_000, budget: BudgetMonitor | None = None,
246
+ references=None, series=None, seed: int = 1,
247
+ fee_bps: float = 0) -> dict:
248
+ market_id = market["market_id"]
249
+ # Attribute access for everything a hook is handed: the docs say
250
+ # `market.strike` and `tick.value`, and they have to be true here.
251
+ # Pre-settle view: `outcome` is a future fact and is stripped. See the
252
+ # matching comment in replay.mjs — a strategy that read it in
253
+ # on_market_open could buy the winning side and the report became
254
+ # meaningless. Only on_settle sees it.
255
+ market_rec = Rec({k: v for k, v in market.items() if k != "outcome"})
256
+ settle_rec = Rec(market)
257
+ pf = portfolio if portfolio is not None else Portfolio(fee_bps=fee_bps)
258
+ book = Book(market_id)
259
+ monitor = budget if budget is not None else BudgetMonitor()
260
+ ctx = Ctx(getattr(strategy, "p", {}) or {}, pf, market_id, log_limit,
261
+ references, series, make_rng(seed))
262
+ state = _INTERNALS[id(ctx)]
263
+ state["book"] = book
264
+ # The engine's own copy of the market, for assert_outcome.
265
+ state["market"] = dict(market)
266
+ state["view"] = BookView(book)
267
+
268
+ pending: list[dict] = []
269
+
270
+ def schedule(at: int, kind: str, payload) -> None:
271
+ i = len(pending)
272
+ while i > 0 and pending[i - 1]["at"] > at:
273
+ i -= 1
274
+ pending.insert(i, {"at": at, "kind": kind, "payload": payload})
275
+
276
+ def drain_until(ts) -> None:
277
+ while pending and pending[0]["at"] <= ts:
278
+ job = pending.pop(0)
279
+ if job["kind"] == "order":
280
+ order = job["payload"]
281
+ res = pf.execute(book, order, job["at"], market_id, how="exit")
282
+ hold = order.get("hold_s") if isinstance(order, dict) else None
283
+ if res and res["filled"] > 0 and hold and not order.get("reduce_only"):
284
+ schedule(job["at"] + int(hold) * 1000, "flatten", {"side": order.get("side")})
285
+ else:
286
+ pf.flatten(market_id, book, job["at"], "hold_expired")
287
+
288
+ def call(canonical: str, *args):
289
+ name = hooks.get(canonical)
290
+ fn = getattr(strategy, name, None) if name else None
291
+ if not callable(fn):
292
+ return None
293
+ t0 = time.perf_counter_ns()
294
+ try:
295
+ out = fn(ctx, *args)
296
+ except RunAbort:
297
+ raise
298
+ except Exception as err: # noqa: BLE001 - a strategy may raise anything
299
+ raise RunAbort("E_RUNTIME", f"{canonical} threw: {err}") from err
300
+ monitor.record((time.perf_counter_ns() - t0) / 1000)
301
+ return out
302
+
303
+ def emit(out, ts: int) -> None:
304
+ if out is None:
305
+ return
306
+ orders = out if isinstance(out, list) else [out]
307
+ for order in orders:
308
+ if order is None:
309
+ continue
310
+ row = _as_order(order)
311
+ # Mirrors replay.mjs: refused rather than silently executed as a
312
+ # one-shot IOC.
313
+ tif = row.get("tif") or "ioc"
314
+ if tif != "ioc":
315
+ raise RunAbort(
316
+ "E_MANIFEST",
317
+ f'tif {tif!r} is not supported — only "ioc". Resting orders need a '
318
+ "queue-position model, and guessing at one inflates returns by multiples.",
319
+ )
320
+ schedule(ts + fill_delay_ms, "order", row)
321
+
322
+ call("on_market_open", market_rec)
323
+ if monitor.breached:
324
+ raise RunAbort("E_BUDGET", f"per-event budget exceeded: {monitor.summary()}")
325
+
326
+ # `events` is any ITERABLE, not necessarily a list — the harness passes a
327
+ # generator that pulls one line off stdin per step, so the future is not in
328
+ # the process at all. Nothing below may index it or take its length.
329
+ #
330
+ # A market with no declared close has no cutoff; the last event seen becomes
331
+ # the close, tracked as we go rather than peeked.
332
+ declared_close = market.get("close_ts_ms")
333
+ close_ts = declared_close if declared_close else float("inf")
334
+ seen = 0
335
+ last_ts = 0
336
+
337
+ for ev in events:
338
+ seen += 1
339
+ ts = ev["ts_ms"]
340
+ last_ts = ts
341
+ # Nothing past the close reaches a hook, the book, or the history.
342
+ if ts > close_ts:
343
+ break
344
+ # Everything scheduled strictly before this event resolves against the
345
+ # book as it stood then.
346
+ drain_until(ts - 1)
347
+ state["now"] = ts
348
+
349
+ if ev.get("kind") == "book":
350
+ if ev.get("snapshot"):
351
+ book.snapshot(ts, ev.get("levels") or {})
352
+ else:
353
+ book.delta(ts, ev.get("side"), ev.get("ladder"), ev.get("px"), ev.get("size"))
354
+ drain_until(ts)
355
+
356
+ ev_rec = Rec(ev)
357
+ if ev.get("kind") == "tick":
358
+ # A separate copy from the one the hook is handed — see the matching
359
+ # comment in replay.mjs.
360
+ state["history"].append(Rec(ev))
361
+
362
+ hook = HOOK_FOR.get(ev.get("kind"))
363
+ if hook and hooks.get(hook):
364
+ emit(call(hook, ev_rec), ts)
365
+
366
+ if monitor.breached:
367
+ raise RunAbort("E_BUDGET", f"per-event budget exceeded: {monitor.summary()}")
368
+
369
+ # Queued work lands AT THE CLOSE, never at its own future timestamp — see
370
+ # the matching comment in replay.mjs. Both engines or neither.
371
+ settle_ts = declared_close if declared_close else last_ts
372
+ state["now"] = settle_ts
373
+ drain_until(settle_ts)
374
+ pending.clear()
375
+
376
+ call("on_settle", settle_rec, market.get("outcome"))
377
+ settled = pf.settle(market_id, market["outcome"], settle_ts) if market.get("outcome") else []
378
+
379
+ view = state.pop("view", None)
380
+ if view is not None:
381
+ _BOOKS.pop(id(view), None)
382
+ _INTERNALS.pop(id(ctx), None)
383
+
384
+ return {
385
+ "market_id": market_id,
386
+ "asset": market.get("asset"),
387
+ # What the engine PULLED, not what the caller had — see replay.mjs.
388
+ "events": seen,
389
+ "settled": settled,
390
+ "logs": state["logs"],
391
+ "log_truncated": state["log_truncated"],
392
+ "crosschecks": state["crosschecks"],
393
+ "budget": monitor.summary(),
394
+ }
395
+
396
+
397
+ def _as_order(order) -> dict:
398
+ """Accept either an Order object or a plain dict from a strategy."""
399
+ if isinstance(order, dict):
400
+ return order
401
+ return {
402
+ "side": getattr(order, "side", None),
403
+ "size": getattr(order, "size", 0),
404
+ "limit": getattr(order, "limit", None),
405
+ "hold_s": getattr(order, "hold_s", None),
406
+ "reduce_only": getattr(order, "reduce_only", False),
407
+ "tif": getattr(order, "tif", "ioc"),
408
+ "tag": getattr(order, "tag", None),
409
+ }
@@ -0,0 +1,67 @@
1
+ """The SDK surface a submitted Python strategy imports.
2
+
3
+ Deliberately tiny. `Strategy` is a base class that exists so `entry` can be
4
+ checked against something, and `Order` is a value object. Everything a strategy
5
+ can actually DO arrives through `ctx`, which the runner constructs — there is no
6
+ way to reach the outside from here, because there is nothing here to reach it
7
+ with.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ # Named explicitly so `from __future__ import annotations` does not leak
13
+ # `annotations` into the package's public surface — this module is also
14
+ # published to PyPI, where dir(outcometick) is what a user reads as the API.
15
+ __all__ = ("Strategy", "Order", "SIDES")
16
+
17
+ SIDES = ("UP", "DOWN")
18
+
19
+
20
+ class Strategy:
21
+ """Base class for a submitted strategy.
22
+
23
+ The hooks are not defined here on purpose. A default no-op `on_tick` would
24
+ turn "you declared a hook you did not implement" — a rejection the submitter
25
+ can fix in seconds — into a run that quietly never trades and bills for an
26
+ empty equity curve.
27
+ """
28
+
29
+ #: Params from the manifest, injected by the runner before the first hook.
30
+ p: dict = {}
31
+
32
+
33
+ class Order:
34
+ """An order a hook returns. Never sent — returned, and matched by the runner
35
+ against the depth that was actually resting at that millisecond.
36
+
37
+ `limit` is a bound in whichever direction protects you: a ceiling when
38
+ opening, a floor when reducing.
39
+ """
40
+
41
+ __slots__ = ("side", "size", "limit", "hold_s", "reduce_only", "tif", "tag")
42
+
43
+ def __init__(self, side, size, limit=None, hold_s=None, reduce_only=False,
44
+ tif="ioc", tag=None):
45
+ if side not in SIDES:
46
+ raise ValueError(f'side must be "UP" or "DOWN", got {side!r}')
47
+ if not (isinstance(size, (int, float)) and size > 0):
48
+ raise ValueError(f"size must be a positive number, got {size!r}")
49
+ if limit is not None and not (0 <= float(limit) <= 1):
50
+ # A binary outcome token trades between 0 and 1. A limit outside
51
+ # that is not a price, and silently clamping it would fill an order
52
+ # the strategy never asked for.
53
+ raise ValueError(f"limit must be between 0 and 1, got {limit!r}")
54
+ if tif != "ioc":
55
+ # Not modelled, so not accepted. See "Not supported yet" in the docs.
56
+ raise ValueError(f'tif must be "ioc"; {tif!r} is not supported yet')
57
+ self.side = side
58
+ self.size = float(size)
59
+ self.limit = None if limit is None else float(limit)
60
+ self.hold_s = None if hold_s is None else int(hold_s)
61
+ self.reduce_only = bool(reduce_only)
62
+ self.tif = tif
63
+ self.tag = tag
64
+
65
+ def __repr__(self) -> str:
66
+ return (f"Order(side={self.side!r}, size={self.size}, limit={self.limit}, "
67
+ f"reduce_only={self.reduce_only})")