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,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())
|