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,374 @@
1
+ #!/usr/bin/env python3
2
+ """The Python harness. Runs INSIDE the sandbox, in the same process as the
3
+ submitted strategy.
4
+
5
+ The mirror of runner/harness/node/harness.mjs: same job file, same output files,
6
+ same exit codes. The worker does not know or care which language produced a
7
+ run's logs, which is what stops the report shape depending on the customer's
8
+ choice of language.
9
+
10
+ python3 harness.py <job-dir> job on stdin, results on fd 3
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import importlib.util
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+
24
+ from otengine import BudgetMonitor, Portfolio, RunAbort # noqa: E402
25
+ from otreplay import replay_market # noqa: E402
26
+
27
+ # Results go out over FD 3, authenticated — see the long note in protocol.mjs.
28
+ # /out used to be a writable bind mount, and a strategy declaring the allowed
29
+ # `pandas` could rewrite trades.jsonl from on_settle, after being told the
30
+ # official outcome.
31
+ RESULT_FD = 3
32
+ CHANNEL_TRADE = "t"
33
+ CHANNEL_FILL = "f"
34
+ CHANNEL_LOG = "l"
35
+ CHANNEL_RESULT = "r"
36
+
37
+ EXIT_OK = 0
38
+ EXIT_REJECTED = 10
39
+ EXIT_BUDGET = 11
40
+
41
+ # Our own JSON writer — the mirror of `stringify` in harness.mjs, and for the
42
+ # same reason: the harness shares a process with the submitted code, so anything
43
+ # reached through a module attribute at call time is reachable by the strategy
44
+ # too. Nothing below looks anything up.
45
+ _ESCAPES = {
46
+ '"': '\\"', "\\": "\\\\", "\n": "\\n", "\r": "\\r",
47
+ "\t": "\\t", "\b": "\\b", "\f": "\\f",
48
+ }
49
+
50
+
51
+ def _json_string(value):
52
+ out = ['"']
53
+ for ch in str(value):
54
+ esc = _ESCAPES.get(ch)
55
+ if esc is not None:
56
+ out.append(esc)
57
+ elif ch < " ":
58
+ out.append("\\u%04x" % ord(ch))
59
+ else:
60
+ out.append(ch)
61
+ out.append('"')
62
+ return "".join(out)
63
+
64
+
65
+ def _DUMPS(value):
66
+ if value is None:
67
+ return "null"
68
+ if value is True:
69
+ return "true"
70
+ if value is False:
71
+ return "false"
72
+ if isinstance(value, (int, float)):
73
+ if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
74
+ return "null"
75
+ return repr(value) if isinstance(value, float) else str(value)
76
+ if isinstance(value, str):
77
+ return _json_string(value)
78
+ if isinstance(value, (list, tuple)):
79
+ return "[" + ",".join(_DUMPS(v) for v in value) + "]"
80
+ if isinstance(value, dict):
81
+ return "{" + ",".join(
82
+ _json_string(k) + ":" + _DUMPS(v) for k, v in value.items()
83
+ ) + "}"
84
+ return _json_string(value)
85
+
86
+ TRADE_FIELDS = (
87
+ "market_id", "side", "size", "entry_px", "exit_px", "pnl", "fees",
88
+ "opened_ms", "closed_ms", "how", "outcome",
89
+ )
90
+ FILL_FIELDS = (
91
+ "ts_ms", "market_id", "side", "action", "requested", "filled", "unfilled",
92
+ "avg_px", "worst_px", "quoted_px", "levels_walked", "fee", "realised", "tag",
93
+ )
94
+
95
+
96
+ def project_row(row, fields):
97
+ """Copy a row to a plain dict of primitives.
98
+
99
+ Coerced field by field so a property, a subclass with a custom __repr__ or
100
+ an object with a rebound method cannot ride along into the output.
101
+ """
102
+ out = {}
103
+ for f in fields:
104
+ v = row.get(f) if isinstance(row, dict) else getattr(row, f, None)
105
+ if v is None:
106
+ out[f] = None
107
+ elif isinstance(v, bool):
108
+ out[f] = bool(v)
109
+ elif isinstance(v, (int, float)):
110
+ out[f] = float(v) if isinstance(v, float) else int(v)
111
+ else:
112
+ out[f] = str(v)
113
+ return out
114
+
115
+
116
+ def load_strategy_class(src_dir: str, entry: dict):
117
+ """Import the submitted module and resolve the class by EXACT name.
118
+
119
+ No discovery. A module that exports one class under a different name is a
120
+ rejection rather than a guess — guessing is how a run silently executes
121
+ something other than what the submitter meant.
122
+
123
+ Note this is the one place the submitted code is executed at import time,
124
+ which is exactly why the static analyser refuses import-time side effects
125
+ and why this runs inside the sandbox rather than in the validator.
126
+ """
127
+ file_name = entry["file"]
128
+ path = os.path.join(src_dir, file_name)
129
+ module_name = os.path.splitext(os.path.basename(file_name))[0]
130
+ spec = importlib.util.spec_from_file_location(module_name, path)
131
+ if spec is None or spec.loader is None:
132
+ raise RunAbort("E_ENTRY", f"could not load {file_name}")
133
+ module = importlib.util.module_from_spec(spec)
134
+ sys.modules[module_name] = module
135
+ try:
136
+ spec.loader.exec_module(module)
137
+ except Exception as err: # noqa: BLE001
138
+ raise RunAbort("E_ENTRY", f"could not load {file_name}: {err}") from err
139
+
140
+ klass = getattr(module, entry["className"], None)
141
+ if not isinstance(klass, type):
142
+ exported = [k for k in vars(module) if not k.startswith("_") and isinstance(vars(module)[k], type)]
143
+ detail = f'{file_name} does not define a class named {entry["className"]}'
144
+ if exported:
145
+ detail += f'; it defines {", ".join(sorted(exported))}'
146
+ raise RunAbort("E_ENTRY", detail)
147
+ return klass
148
+
149
+
150
+ def check_hooks(klass, hooks: dict) -> None:
151
+ """A declared-but-missing hook is a rejection, found before anything runs."""
152
+ for canonical, name in hooks.items():
153
+ fn = getattr(klass, name, None)
154
+ if not callable(fn):
155
+ raise RunAbort(
156
+ "E_HOOK_SIG",
157
+ f"{canonical} was declared but {name}() is not defined on the class",
158
+ )
159
+
160
+
161
+ # The parser, bound at import — BEFORE any strategy is loaded.
162
+ #
163
+ # `import json` is on the strategy allowlist and `json.loads = ...` passes
164
+ # static analysis, so an unbound lookup would let a strategy see every row the
165
+ # harness decodes. Defence in depth behind the streaming below.
166
+ _LOADS = json.loads
167
+
168
+
169
+ def read_line(stream):
170
+ """One line off the job stream, or None at end.
171
+
172
+ The job arrives on stdin rather than as files, and events are pulled ONE AT
173
+ A TIME as the replay loop asks for them — see the long note on
174
+ syncLineReader in harness.mjs. The previous version decoded a whole
175
+ market's events before replay started, which put the future in the process
176
+ and only required the strategy to intercept `json.loads` at import time to
177
+ steal it.
178
+ """
179
+ line = stream.readline()
180
+ if not line:
181
+ return None
182
+ return line.rstrip("\n")
183
+
184
+
185
+ def main() -> int:
186
+ if len(sys.argv) < 2:
187
+ sys.stderr.write("usage: harness.py <job-dir> (job on stdin, results on fd 3)\n")
188
+ return 2
189
+ job_dir = sys.argv[1]
190
+
191
+ stream = sys.stdin
192
+ first = read_line(stream)
193
+ if first is None:
194
+ sys.stderr.write("no job on stdin\n")
195
+ return 2
196
+ job = _LOADS(first)
197
+ src_dir = os.path.join(job_dir, "src")
198
+
199
+ limits = job.get("limits") or {}
200
+ monitor = BudgetMonitor(limit_micros=limits.get("perEventBudgetMicros", 400))
201
+
202
+ result = {
203
+ "markets_run": 0,
204
+ "events_seen": 0,
205
+ "fees_paid": 0,
206
+ "log_truncated": False,
207
+ "budget": None,
208
+ "market_summaries": [],
209
+ "crosschecks": [],
210
+ "rejection": None,
211
+ }
212
+
213
+ output_key = str(job.get("outputKey") or "")
214
+ if not output_key:
215
+ sys.stderr.write("no output key in the job\n")
216
+ return 2
217
+ key_bytes = output_key.encode("utf-8")
218
+
219
+ def emit(channel, payload):
220
+ mac = hmac.new(
221
+ key_bytes, f"{channel} {payload}".encode("utf-8"), hashlib.sha256
222
+ ).hexdigest()[:32]
223
+ os.write(RESULT_FD, f"{mac} {channel} {payload}\n".encode("utf-8"))
224
+
225
+ class _Logs:
226
+ @staticmethod
227
+ def write(text):
228
+ emit(CHANNEL_LOG, text.replace("\n", " ").rstrip())
229
+
230
+ logs_fh = _Logs()
231
+
232
+ def finish(code: int) -> int:
233
+ result["budget"] = monitor.summary()
234
+ emit(CHANNEL_RESULT, _DUMPS(result))
235
+ return code
236
+
237
+ def flush(pf: Portfolio, before: dict, market_id) -> None:
238
+ for row in pf.trades[before["trades"]:]:
239
+ emit(CHANNEL_TRADE, _DUMPS(project_row(row, TRADE_FIELDS)))
240
+ for row in pf.fills[before["fills"]:]:
241
+ emit(CHANNEL_FILL, _DUMPS(project_row(row, FILL_FIELDS)))
242
+ if market_id:
243
+ # Keep memory flat across hundreds of market-days.
244
+ del pf.trades[before["trades"]:]
245
+ del pf.fills[before["fills"]:]
246
+
247
+ try:
248
+ klass = load_strategy_class(src_dir, job["entry"])
249
+ check_hooks(klass, job.get("hooks") or {})
250
+ except RunAbort as err:
251
+ result["rejection"] = {"code": err.code, "detail": err.detail}
252
+ return finish(EXIT_REJECTED)
253
+
254
+ shared = Portfolio(fee_bps=job.get("feeBps", 0)) if job.get("mode") == "session" else None
255
+ shared_instance = None
256
+
257
+ # Markets stream in, one at a time, for as long as the worker sends them.
258
+ while True:
259
+ header = read_line(stream)
260
+ if header is None:
261
+ break
262
+ try:
263
+ entry = _LOADS(header)
264
+ except json.JSONDecodeError as err:
265
+ logs_fh.write(f"[runner] malformed market header: {err}\n")
266
+ break
267
+
268
+ # Pulled one at a time as replay asks. Nothing here holds more than the
269
+ # current row — that is what makes "future rows are not in the process"
270
+ # literally true rather than approximately true.
271
+ counters = {"n": int(entry.get("n") or 0), "seen": 0, "last_book": None}
272
+
273
+ def event_stream():
274
+ while counters["n"] > 0:
275
+ counters["n"] -= 1
276
+ line = read_line(stream)
277
+ if line is None:
278
+ return
279
+ try:
280
+ ev = _LOADS(line)
281
+ except json.JSONDecodeError:
282
+ # Corruption in OUR data, not the strategy's problem.
283
+ continue
284
+ counters["seen"] += 1
285
+ if ev.get("kind") == "book" and ev.get("snapshot"):
286
+ counters["last_book"] = ev
287
+ yield ev
288
+
289
+ def drain_rest():
290
+ """Consume what replay did not, so the stream stays framed."""
291
+ while counters["n"] > 0:
292
+ counters["n"] -= 1
293
+ if read_line(stream) is None:
294
+ return
295
+
296
+ pf = shared if shared is not None else Portfolio(fee_bps=job.get("feeBps", 0))
297
+ if shared is not None:
298
+ if shared_instance is None:
299
+ shared_instance = klass()
300
+ instance = shared_instance
301
+ else:
302
+ instance = klass()
303
+ # A fresh copy per instance — see the matching comment in harness.mjs.
304
+ instance.p = dict(job.get("params") or {})
305
+
306
+ before = {"trades": len(pf.trades), "fills": len(pf.fills)}
307
+
308
+ try:
309
+ out = replay_market(
310
+ market=entry["market"],
311
+ events=event_stream(),
312
+ strategy=instance,
313
+ hooks=job.get("hooks") or {},
314
+ portfolio=pf,
315
+ fill_delay_ms=job.get("fillDelayMs", 0),
316
+ log_limit=limits.get("logLinesPerMarketDay", 10_000),
317
+ budget=monitor,
318
+ seed=job.get("seed", 1),
319
+ fee_bps=job.get("feeBps", 0),
320
+ )
321
+ except RunAbort as err:
322
+ drain_rest()
323
+ result["rejection"] = {
324
+ "code": err.code,
325
+ "detail": f'{entry["market"]["market_id"]}: {err.detail}',
326
+ }
327
+ flush(pf, before, entry["market"]["market_id"])
328
+ return finish(EXIT_BUDGET if err.code == "E_BUDGET" else EXIT_REJECTED)
329
+ except Exception as err: # noqa: BLE001
330
+ result["rejection"] = {
331
+ "code": "E_RUNTIME",
332
+ "detail": f'{entry["market"]["market_id"]}: {err}',
333
+ }
334
+ return finish(EXIT_REJECTED)
335
+
336
+ drain_rest()
337
+ result["markets_run"] += 1
338
+ result["events_seen"] += counters["seen"]
339
+ if out["log_truncated"]:
340
+ result["log_truncated"] = True
341
+ for line in out["logs"]:
342
+ logs_fh.write(f'{entry["market"]["market_id"]} {line}\n')
343
+ result["crosschecks"].extend(out["crosschecks"])
344
+
345
+ # Tracked as the stream went past; there is no array left to scan.
346
+ lb = counters["last_book"] or {}
347
+ levels = lb.get("levels") or {}
348
+ up_asks = (levels.get("UP") or {}).get("asks") or []
349
+ down_asks = (levels.get("DOWN") or {}).get("asks") or []
350
+ up_px = up_asks[0][0] if up_asks else None
351
+ down_px = down_asks[0][0] if down_asks else None
352
+ result["market_summaries"].append({
353
+ "market_id": entry["market"]["market_id"],
354
+ "asset": entry["market"].get("asset"),
355
+ "interval": entry["market"].get("interval"),
356
+ "outcome": entry["market"].get("outcome"),
357
+ "up_px": up_px,
358
+ "down_px": down_px,
359
+ "stream": entry.get("stream"),
360
+ })
361
+
362
+ if shared is None:
363
+ flush(pf, before, entry["market"]["market_id"])
364
+ result["fees_paid"] += pf.fees_paid
365
+
366
+ if shared is not None:
367
+ flush(shared, {"trades": 0, "fills": 0}, None)
368
+ result["fees_paid"] = shared.fees_paid
369
+
370
+ return finish(EXIT_OK)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ sys.exit(main())