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,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static analysis of a submitted Python strategy.
|
|
3
|
+
|
|
4
|
+
The mirror of runner/analyze/javascript.mjs, and it must stay a mirror: the two
|
|
5
|
+
languages are advertised as having identical semantics, so a construct rejected
|
|
6
|
+
in one and accepted in the other is a broken promise, not a quirk.
|
|
7
|
+
|
|
8
|
+
A real parse via the stdlib `ast`, never a regex. `Math.random` in a docstring
|
|
9
|
+
is not a call and `# import os` is a comment; a regex cannot tell the
|
|
10
|
+
difference, and the docs promise that a local `ot check` pass is not rejected on
|
|
11
|
+
submit. A false positive here breaks that promise.
|
|
12
|
+
|
|
13
|
+
Reads a JSON job on stdin and writes a JSON verdict on stdout:
|
|
14
|
+
|
|
15
|
+
{"files": [{"name": "strategy.py", "content": "..."}], "deps": ["numpy"]}
|
|
16
|
+
-> {"ok": true, "imports": ["numpy"]}
|
|
17
|
+
-> {"ok": false, "code": "E_IMPORT", "detail": "...", "file": "...", "line": 3}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# Modules a strategy may import beyond its declared deps. `outcometick` is the
|
|
25
|
+
# SDK itself; the rest are pure-computation stdlib with no clock, no I/O and no
|
|
26
|
+
# entropy. `random` is NOT here — seeded randomness is ctx.random.
|
|
27
|
+
ALWAYS_ALLOWED = {
|
|
28
|
+
"outcometick",
|
|
29
|
+
"math",
|
|
30
|
+
"statistics",
|
|
31
|
+
"itertools",
|
|
32
|
+
"functools",
|
|
33
|
+
"collections",
|
|
34
|
+
"dataclasses",
|
|
35
|
+
"enum",
|
|
36
|
+
"typing",
|
|
37
|
+
"decimal",
|
|
38
|
+
"fractions",
|
|
39
|
+
"heapq",
|
|
40
|
+
"bisect",
|
|
41
|
+
"array",
|
|
42
|
+
"json",
|
|
43
|
+
"re",
|
|
44
|
+
"abc",
|
|
45
|
+
"operator",
|
|
46
|
+
"copy",
|
|
47
|
+
"string",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Imports that are refused with the reason a submitter can act on.
|
|
51
|
+
FORBIDDEN_IMPORTS = {
|
|
52
|
+
"os": "the filesystem and the environment are not reachable",
|
|
53
|
+
"sys": "interpreter state differs between workers",
|
|
54
|
+
"io": "the filesystem is not reachable",
|
|
55
|
+
"pathlib": "the filesystem is not reachable",
|
|
56
|
+
"shutil": "the filesystem is not reachable",
|
|
57
|
+
"tempfile": "the scratch tmpfs is managed by the runner",
|
|
58
|
+
"subprocess": "subprocesses are not available",
|
|
59
|
+
"multiprocessing": "parallelism is across markets, not inside a strategy",
|
|
60
|
+
"threading": "threads are not available; parallelism is across markets",
|
|
61
|
+
"concurrent": "threads are not available; parallelism is across markets",
|
|
62
|
+
"asyncio": "the runner owns the loop",
|
|
63
|
+
"socket": "there is no network in the sandbox",
|
|
64
|
+
"ssl": "there is no network in the sandbox",
|
|
65
|
+
"http": "there is no network in the sandbox",
|
|
66
|
+
"urllib": "there is no network in the sandbox",
|
|
67
|
+
"requests": "there is no network in the sandbox",
|
|
68
|
+
"ctypes": "native extensions are not available",
|
|
69
|
+
"importlib": "dynamic import escapes static analysis",
|
|
70
|
+
"builtins": "reaches every builtin the allowlist refuses",
|
|
71
|
+
"pickle": "pickle executes arbitrary code on load",
|
|
72
|
+
"marshal": "marshal executes arbitrary code on load",
|
|
73
|
+
"inspect": "reflection escapes static analysis",
|
|
74
|
+
"gc": "interpreter state differs between workers",
|
|
75
|
+
"resource": "interpreter state differs between workers",
|
|
76
|
+
"platform": "host state differs between workers",
|
|
77
|
+
"getpass": "host state differs between workers",
|
|
78
|
+
"uuid": "unseeded randomness; use ctx.random(seed)",
|
|
79
|
+
"secrets": "unseeded randomness; use ctx.random(seed)",
|
|
80
|
+
"random": "unseeded randomness; use ctx.random(seed)",
|
|
81
|
+
"time": "the wall clock is not readable; event time is ctx.now",
|
|
82
|
+
"datetime": "the wall clock is not readable; event time is ctx.now",
|
|
83
|
+
"calendar": "the wall clock is not readable; event time is ctx.now",
|
|
84
|
+
"locale": "locale changes number formatting, which breaks byte-identical reports",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Names that are a door around every other check in this file.
|
|
88
|
+
#
|
|
89
|
+
# `__builtins__.open(...)` and `__builtins__.eval(...)` reach exactly the
|
|
90
|
+
# builtins FORBIDDEN_CALLS refuses, and neither is a bare call nor a dunder
|
|
91
|
+
# ATTRIBUTE, so both slipped through. The name itself has to go.
|
|
92
|
+
FORBIDDEN_NAMES = {
|
|
93
|
+
"__builtins__": "reaches every builtin the allowlist refuses",
|
|
94
|
+
"builtins": "reaches every builtin the allowlist refuses",
|
|
95
|
+
"__loader__": "the module loader reaches the filesystem",
|
|
96
|
+
"__spec__": "the module loader reaches the filesystem",
|
|
97
|
+
"globals": "reflection escapes static analysis",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Names that escape analysis or read the wall clock, called bare.
|
|
101
|
+
FORBIDDEN_CALLS = {
|
|
102
|
+
"eval": ("E_FORBIDDEN", "eval escapes static analysis"),
|
|
103
|
+
"exec": ("E_FORBIDDEN", "exec escapes static analysis"),
|
|
104
|
+
"compile": ("E_FORBIDDEN", "compile escapes static analysis"),
|
|
105
|
+
"__import__": ("E_FORBIDDEN", "dynamic import escapes static analysis"),
|
|
106
|
+
"open": ("E_FORBIDDEN", "the filesystem is not reachable"),
|
|
107
|
+
"input": ("E_FORBIDDEN", "there is no stdin"),
|
|
108
|
+
"globals": ("E_FORBIDDEN", "reflection escapes static analysis"),
|
|
109
|
+
"locals": ("E_FORBIDDEN", "reflection escapes static analysis"),
|
|
110
|
+
"vars": ("E_FORBIDDEN", "reflection escapes static analysis"),
|
|
111
|
+
"getattr": ("E_FORBIDDEN", "dynamic attribute access escapes static analysis"),
|
|
112
|
+
"setattr": ("E_FORBIDDEN", "dynamic attribute access escapes static analysis"),
|
|
113
|
+
"delattr": ("E_FORBIDDEN", "dynamic attribute access escapes static analysis"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Reject(Exception):
|
|
118
|
+
def __init__(self, code, detail, file=None, line=None, **extra):
|
|
119
|
+
super().__init__(detail)
|
|
120
|
+
self.payload = {"ok": False, "code": code, "detail": detail}
|
|
121
|
+
if file:
|
|
122
|
+
self.payload["file"] = file
|
|
123
|
+
if line:
|
|
124
|
+
self.payload["line"] = line
|
|
125
|
+
self.payload.update(extra)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def root_module(name):
|
|
129
|
+
"""`numpy.linalg` is allowed by declaring `numpy`; the root is what counts."""
|
|
130
|
+
return (name or "").split(".")[0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_import(module, name, line, deps, relative_ok):
|
|
134
|
+
root = root_module(module)
|
|
135
|
+
if not root:
|
|
136
|
+
# A bare relative import (`from . import x`) has no module name. The
|
|
137
|
+
# submitted-file check upstream is what validates those.
|
|
138
|
+
if relative_ok:
|
|
139
|
+
return
|
|
140
|
+
raise Reject("E_IMPORT", "relative import outside the submission", name, line)
|
|
141
|
+
if root in FORBIDDEN_IMPORTS:
|
|
142
|
+
code = (
|
|
143
|
+
"E_NONDETERMINISM"
|
|
144
|
+
if root in {"random", "secrets", "uuid", "time", "datetime", "calendar", "locale", "gc", "resource", "platform", "getpass"}
|
|
145
|
+
else "E_FORBIDDEN"
|
|
146
|
+
)
|
|
147
|
+
raise Reject(code, f"{name}:{line}: import {root} — {FORBIDDEN_IMPORTS[root]}", name, line)
|
|
148
|
+
if root in ALWAYS_ALLOWED or root in deps:
|
|
149
|
+
return
|
|
150
|
+
raise Reject(
|
|
151
|
+
"E_IMPORT",
|
|
152
|
+
f"{name}:{line}: import of {root!r} is not on the allowlist"
|
|
153
|
+
+ (f"; declared deps are {', '.join(deps)}" if deps else " and no deps were declared"),
|
|
154
|
+
name,
|
|
155
|
+
line,
|
|
156
|
+
specifier=root,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def analyze_source(source, name, deps):
|
|
161
|
+
try:
|
|
162
|
+
tree = ast.parse(source, filename=name)
|
|
163
|
+
except SyntaxError as err:
|
|
164
|
+
raise Reject("E_ENTRY", f"{name}:{err.lineno}: {err.msg}", name, err.lineno) from err
|
|
165
|
+
|
|
166
|
+
imports = []
|
|
167
|
+
# Names bound anywhere in the file.
|
|
168
|
+
#
|
|
169
|
+
# Used ONLY to decide that a module-level global reference is really a
|
|
170
|
+
# local. It is deliberately NOT used to excuse a forbidden builtin any
|
|
171
|
+
# more: `def on_tick(self, ctx, tick, getattr=getattr)` binds the name
|
|
172
|
+
# `getattr` while the DEFAULT VALUE captures the real builtin, and the
|
|
173
|
+
# shadowing exemption then waved the whole thing through. A rule whose
|
|
174
|
+
# exemption is itself the attack is not a rule.
|
|
175
|
+
bound = set()
|
|
176
|
+
for node in ast.walk(tree):
|
|
177
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
178
|
+
bound.add(node.id)
|
|
179
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
180
|
+
bound.add(node.name)
|
|
181
|
+
args = getattr(node, "args", None)
|
|
182
|
+
if args:
|
|
183
|
+
for a in list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs):
|
|
184
|
+
bound.add(a.arg)
|
|
185
|
+
if args.vararg:
|
|
186
|
+
bound.add(args.vararg.arg)
|
|
187
|
+
if args.kwarg:
|
|
188
|
+
bound.add(args.kwarg.arg)
|
|
189
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
190
|
+
for alias in node.names:
|
|
191
|
+
bound.add(alias.asname or root_module(alias.name))
|
|
192
|
+
|
|
193
|
+
for node in ast.walk(tree):
|
|
194
|
+
if isinstance(node, ast.arg) and node.arg in FORBIDDEN_CALLS:
|
|
195
|
+
# `def f(open=open)` — the parameter NAME is an ast.arg and the
|
|
196
|
+
# default is an ast.Name, so both halves need refusing.
|
|
197
|
+
code, why = FORBIDDEN_CALLS[node.arg]
|
|
198
|
+
raise Reject(
|
|
199
|
+
code,
|
|
200
|
+
f"{name}:{node.lineno}: a parameter named {node.arg!r} — {why}",
|
|
201
|
+
name,
|
|
202
|
+
node.lineno,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if isinstance(node, ast.Import):
|
|
206
|
+
for alias in node.names:
|
|
207
|
+
imports.append(alias.name)
|
|
208
|
+
check_import(alias.name, name, node.lineno, deps, relative_ok=False)
|
|
209
|
+
elif isinstance(node, ast.ImportFrom):
|
|
210
|
+
if node.level and node.level > 0:
|
|
211
|
+
# Relative: the submitter's own module. Recorded so the caller
|
|
212
|
+
# can check it was actually submitted.
|
|
213
|
+
imports.append("." * node.level + (node.module or ""))
|
|
214
|
+
continue
|
|
215
|
+
imports.append(node.module or "")
|
|
216
|
+
check_import(node.module, name, node.lineno, deps, relative_ok=False)
|
|
217
|
+
|
|
218
|
+
elif isinstance(node, ast.Name):
|
|
219
|
+
# Regardless of binding: see the note on `bound` above.
|
|
220
|
+
if node.id in FORBIDDEN_CALLS:
|
|
221
|
+
code, why = FORBIDDEN_CALLS[node.id]
|
|
222
|
+
raise Reject(
|
|
223
|
+
code,
|
|
224
|
+
f"{name}:{node.lineno}: {node.id} — {why}. It cannot be used "
|
|
225
|
+
"as a name either; rename the variable or parameter.",
|
|
226
|
+
name,
|
|
227
|
+
node.lineno,
|
|
228
|
+
)
|
|
229
|
+
if node.id in FORBIDDEN_NAMES:
|
|
230
|
+
raise Reject(
|
|
231
|
+
"E_FORBIDDEN",
|
|
232
|
+
f"{name}:{node.lineno}: {node.id} — {FORBIDDEN_NAMES[node.id]}",
|
|
233
|
+
name,
|
|
234
|
+
node.lineno,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
elif isinstance(node, ast.Attribute):
|
|
238
|
+
# An attribute whose NAME is a forbidden builtin, however it was
|
|
239
|
+
# reached: obj.open(...), obj.eval(...). The receiver does not
|
|
240
|
+
# matter — if we cannot see what it is, we cannot allow the call.
|
|
241
|
+
if node.attr in FORBIDDEN_CALLS:
|
|
242
|
+
code, why = FORBIDDEN_CALLS[node.attr]
|
|
243
|
+
raise Reject(
|
|
244
|
+
code,
|
|
245
|
+
f"{name}:{node.lineno}: .{node.attr} — {why}",
|
|
246
|
+
name,
|
|
247
|
+
node.lineno,
|
|
248
|
+
)
|
|
249
|
+
# A private attribute on anything that is not `self`.
|
|
250
|
+
#
|
|
251
|
+
# Not a blanket rule: `self._entered` is ordinary Python and
|
|
252
|
+
# refusing it would reject working strategies. But `ctx._pf`,
|
|
253
|
+
# `ctx.book()._b` and `obj._anything` are reaching into something
|
|
254
|
+
# that belongs to the engine.
|
|
255
|
+
if (
|
|
256
|
+
node.attr.startswith("_")
|
|
257
|
+
and not node.attr.startswith("__")
|
|
258
|
+
and not (isinstance(node.value, ast.Name) and node.value.id == "self")
|
|
259
|
+
):
|
|
260
|
+
raise Reject(
|
|
261
|
+
"E_FORBIDDEN",
|
|
262
|
+
f"{name}:{node.lineno}: .{node.attr} — private attributes of engine "
|
|
263
|
+
"objects are not reachable; use the documented methods",
|
|
264
|
+
name,
|
|
265
|
+
node.lineno,
|
|
266
|
+
)
|
|
267
|
+
# Dunder attribute access is the standard escape hatch out of any
|
|
268
|
+
# allowlist: __class__, __globals__, __subclasses__.
|
|
269
|
+
if node.attr.startswith("__") and node.attr.endswith("__") and node.attr not in {"__init__", "__name__", "__doc__"}:
|
|
270
|
+
raise Reject(
|
|
271
|
+
"E_FORBIDDEN",
|
|
272
|
+
f"{name}:{node.lineno}: {node.attr} — reflection escapes static analysis",
|
|
273
|
+
name,
|
|
274
|
+
node.lineno,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return imports
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main():
|
|
281
|
+
try:
|
|
282
|
+
job = json.load(sys.stdin)
|
|
283
|
+
except json.JSONDecodeError as err:
|
|
284
|
+
print(json.dumps({"ok": False, "code": "E_MANIFEST", "detail": f"bad job: {err}"}))
|
|
285
|
+
return 2
|
|
286
|
+
|
|
287
|
+
files = job.get("files") or []
|
|
288
|
+
deps = set(job.get("deps") or [])
|
|
289
|
+
names = {f.get("name") for f in files}
|
|
290
|
+
all_imports = []
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
for f in files:
|
|
294
|
+
name = f.get("name") or ""
|
|
295
|
+
if not name.endswith(".py"):
|
|
296
|
+
continue
|
|
297
|
+
imports = analyze_source(f.get("content") or "", name, deps)
|
|
298
|
+
for spec in imports:
|
|
299
|
+
if not spec.startswith("."):
|
|
300
|
+
continue
|
|
301
|
+
# A relative import must resolve to a file that was submitted,
|
|
302
|
+
# or the run fails after the credits are held.
|
|
303
|
+
target = spec.lstrip(".")
|
|
304
|
+
if target and f"{target}.py" not in names:
|
|
305
|
+
raise Reject(
|
|
306
|
+
"E_ENTRY",
|
|
307
|
+
f"{name} imports {spec!r}, which was not submitted",
|
|
308
|
+
name,
|
|
309
|
+
)
|
|
310
|
+
all_imports.extend(imports)
|
|
311
|
+
except Reject as r:
|
|
312
|
+
print(json.dumps(r.payload))
|
|
313
|
+
return 1
|
|
314
|
+
|
|
315
|
+
print(json.dumps({"ok": True, "imports": sorted(set(all_imports))}))
|
|
316
|
+
return 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The report archive: one zip, built by hand.
|
|
2
|
+
//
|
|
3
|
+
// One zip and not a folder of links was an explicit product decision — a
|
|
4
|
+
// customer should be able to drop the whole thing into a notebook and have
|
|
5
|
+
// every number reproducible from what is inside it.
|
|
6
|
+
//
|
|
7
|
+
// Written without a zip dependency because the format's stored (uncompressed)
|
|
8
|
+
// variant is about eighty lines, and this runs on the machine that executes
|
|
9
|
+
// untrusted code: every package on that host is attack surface, and a zip
|
|
10
|
+
// library is one that parses attacker-adjacent data. Deflate is skipped
|
|
11
|
+
// deliberately — CSVs compress well, but "the archive is 3x bigger" is a much
|
|
12
|
+
// cheaper problem than "the archive is subtly corrupt".
|
|
13
|
+
|
|
14
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
15
|
+
import { deflateRawSync } from 'node:zlib';
|
|
16
|
+
|
|
17
|
+
/** CRC-32, the checksum the zip format uses. Table built once. */
|
|
18
|
+
const CRC_TABLE = (() => {
|
|
19
|
+
const table = new Int32Array(256);
|
|
20
|
+
for (let n = 0; n < 256; n += 1) {
|
|
21
|
+
let c = n;
|
|
22
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
23
|
+
table[n] = c;
|
|
24
|
+
}
|
|
25
|
+
return table;
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
function crc32(buf) {
|
|
29
|
+
let c = -1;
|
|
30
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
31
|
+
return (c ^ -1) >>> 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a zip from a list of {name, data}.
|
|
36
|
+
*
|
|
37
|
+
* Uses DEFLATE where it helps and STORE where it does not, decided per entry by
|
|
38
|
+
* measuring rather than guessing — a compressed entry that came out larger is
|
|
39
|
+
* stored instead.
|
|
40
|
+
*
|
|
41
|
+
* Timestamps are fixed rather than taken from the clock. Two runs of the same
|
|
42
|
+
* strategy over the same range must produce byte-identical output, and a zip
|
|
43
|
+
* carrying "now" in every local header would break that for no benefit.
|
|
44
|
+
*/
|
|
45
|
+
export function zip(entries) {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
const central = [];
|
|
48
|
+
let offset = 0;
|
|
49
|
+
|
|
50
|
+
// MS-DOS epoch: 1980-01-01 00:00:00. A constant, for reproducibility.
|
|
51
|
+
const DOS_TIME = 0;
|
|
52
|
+
const DOS_DATE = 0x0021;
|
|
53
|
+
|
|
54
|
+
for (const { name, data } of entries) {
|
|
55
|
+
const nameBuf = Buffer.from(name, 'utf8');
|
|
56
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
57
|
+
const deflated = raw.length > 256 ? deflateRawSync(raw, { level: 9 }) : null;
|
|
58
|
+
const useDeflate = deflated != null && deflated.length < raw.length;
|
|
59
|
+
const body = useDeflate ? deflated : raw;
|
|
60
|
+
const method = useDeflate ? 8 : 0;
|
|
61
|
+
const crc = crc32(raw);
|
|
62
|
+
|
|
63
|
+
const local = Buffer.alloc(30);
|
|
64
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
65
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
66
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
67
|
+
local.writeUInt16LE(method, 8);
|
|
68
|
+
local.writeUInt16LE(DOS_TIME, 10);
|
|
69
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
70
|
+
local.writeUInt32LE(crc, 14);
|
|
71
|
+
local.writeUInt32LE(body.length, 18);
|
|
72
|
+
local.writeUInt32LE(raw.length, 22);
|
|
73
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
74
|
+
local.writeUInt16LE(0, 28);
|
|
75
|
+
|
|
76
|
+
chunks.push(local, nameBuf, body);
|
|
77
|
+
|
|
78
|
+
const dir = Buffer.alloc(46);
|
|
79
|
+
dir.writeUInt32LE(0x02014b50, 0);
|
|
80
|
+
dir.writeUInt16LE(20, 4); // version made by
|
|
81
|
+
dir.writeUInt16LE(20, 6); // version needed
|
|
82
|
+
dir.writeUInt16LE(0x0800, 8);
|
|
83
|
+
dir.writeUInt16LE(method, 10);
|
|
84
|
+
dir.writeUInt16LE(DOS_TIME, 12);
|
|
85
|
+
dir.writeUInt16LE(DOS_DATE, 14);
|
|
86
|
+
dir.writeUInt32LE(crc, 16);
|
|
87
|
+
dir.writeUInt32LE(body.length, 20);
|
|
88
|
+
dir.writeUInt32LE(raw.length, 24);
|
|
89
|
+
dir.writeUInt16LE(nameBuf.length, 28);
|
|
90
|
+
dir.writeUInt16LE(0, 30); // extra
|
|
91
|
+
dir.writeUInt16LE(0, 32); // comment
|
|
92
|
+
dir.writeUInt16LE(0, 34); // disk
|
|
93
|
+
dir.writeUInt16LE(0, 36); // internal attrs
|
|
94
|
+
// Multiplication, not `<< 16`: JavaScript's bitwise operators are signed
|
|
95
|
+
// 32-bit, and 0o100644 << 16 overflows to a negative that writeUInt32LE
|
|
96
|
+
// rejects outright.
|
|
97
|
+
dir.writeUInt32LE(0o100644 * 0x10000, 38); // external attrs (0644, regular file)
|
|
98
|
+
dir.writeUInt32LE(offset, 42);
|
|
99
|
+
central.push(dir, nameBuf);
|
|
100
|
+
|
|
101
|
+
offset += local.length + nameBuf.length + body.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const centralBuf = Buffer.concat(central);
|
|
105
|
+
const end = Buffer.alloc(22);
|
|
106
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
107
|
+
end.writeUInt16LE(0, 4);
|
|
108
|
+
end.writeUInt16LE(0, 6);
|
|
109
|
+
end.writeUInt16LE(entries.length, 8);
|
|
110
|
+
end.writeUInt16LE(entries.length, 10);
|
|
111
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
112
|
+
end.writeUInt32LE(offset, 16);
|
|
113
|
+
end.writeUInt16LE(0, 20);
|
|
114
|
+
|
|
115
|
+
return Buffer.concat([...chunks, centralBuf, end]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Escape one CSV field. */
|
|
119
|
+
function csvField(v) {
|
|
120
|
+
if (v == null) return '';
|
|
121
|
+
const s = String(v);
|
|
122
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Rows to CSV with a fixed column order, so a diff between runs is meaningful. */
|
|
126
|
+
export function toCsv(rows, columns) {
|
|
127
|
+
const out = [columns.join(',')];
|
|
128
|
+
for (const row of rows) out.push(columns.map((c) => csvField(row[c])).join(','));
|
|
129
|
+
return `${out.join('\n')}\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const TRADE_COLUMNS = [
|
|
133
|
+
'market_id', 'side', 'size', 'entry_px', 'exit_px', 'pnl', 'fees',
|
|
134
|
+
'opened_ms', 'closed_ms', 'how', 'outcome',
|
|
135
|
+
];
|
|
136
|
+
const FILL_COLUMNS = [
|
|
137
|
+
'ts_ms', 'market_id', 'side', 'action', 'requested', 'filled', 'unfilled',
|
|
138
|
+
'avg_px', 'worst_px', 'quoted_px', 'levels_walked', 'fee', 'realised', 'tag',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Assemble the archive a customer downloads.
|
|
143
|
+
*
|
|
144
|
+
* The submitted source goes IN, deliberately: a report that cannot be tied back
|
|
145
|
+
* to the exact code that produced it is not reproducible, and "which version of
|
|
146
|
+
* my strategy was this?" is the first question anyone asks a week later.
|
|
147
|
+
*
|
|
148
|
+
* sha256sums.txt covers every other entry, so the whole thing is verifiable
|
|
149
|
+
* without trusting the transport.
|
|
150
|
+
*/
|
|
151
|
+
export async function buildArchive({ runId, report, trades, fills, logs, source }) {
|
|
152
|
+
const entries = [
|
|
153
|
+
{ name: 'report.json', data: `${JSON.stringify(report, null, 2)}\n` },
|
|
154
|
+
{ name: 'trades.csv', data: toCsv(trades, TRADE_COLUMNS) },
|
|
155
|
+
{ name: 'fills.csv', data: toCsv(fills, FILL_COLUMNS) },
|
|
156
|
+
{ name: 'equity.csv', data: toCsv(report.equity ?? [], ['ts_ms', 'equity']) },
|
|
157
|
+
{
|
|
158
|
+
name: 'calibration.csv',
|
|
159
|
+
data: toCsv(report.calibration ?? [], ['bucket', 'implied', 'realized', 'edge_cents', 'trades']),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'latency.csv',
|
|
163
|
+
data: toCsv(report.latency ?? [], ['label', 'delay_ms', 'net_pnl', 'ratio', 'unprofitable']),
|
|
164
|
+
},
|
|
165
|
+
{ name: 'coverage.json', data: `${JSON.stringify(report.coverage ?? {}, null, 2)}\n` },
|
|
166
|
+
{ name: 'logs.txt', data: logs ?? '' },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
for (const f of source ?? []) {
|
|
170
|
+
entries.push({ name: `strategy/${f.name}`, data: f.content });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Checksums last, over everything above.
|
|
174
|
+
const sums = entries
|
|
175
|
+
.map(({ name, data }) => {
|
|
176
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
177
|
+
return `${createHash('sha256').update(buf).digest('hex')} ${name}`;
|
|
178
|
+
})
|
|
179
|
+
.join('\n');
|
|
180
|
+
entries.push({ name: 'sha256sums.txt', data: `${sums}\n` });
|
|
181
|
+
|
|
182
|
+
return zip(entries);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { randomUUID };
|