outcometick 1.5.2 → 1.6.2
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/README.md +8 -1
- package/api/lib/backtest-contract.mjs +335 -16
- package/api/lib/backtest-datasets.mjs +125 -1
- package/api/lib/backtest-manifest.mjs +52 -4
- package/api/lib/coverage-window.mjs +63 -1
- package/api/lib/data-taxonomy.mjs +15 -1
- package/cli/commands/run.mjs +206 -37
- package/cli/commands/submit.mjs +40 -3
- package/cli/local-data.mjs +86 -15
- package/cli/ot.mjs +26 -3
- package/index.d.ts +31 -4
- package/package.json +1 -1
- package/runner/archive.mjs +24 -13
- package/runner/engine/book.mjs +12 -1
- package/runner/engine/feed.mjs +116 -0
- package/runner/engine/portfolio.mjs +77 -5
- package/runner/engine/replay.mjs +136 -14
- package/runner/engine/report.mjs +96 -34
- package/runner/events.mjs +788 -55
- package/runner/harness/node/harness.mjs +166 -13
- package/runner/harness/node/sdk/index.d.ts +31 -4
- package/runner/harness/node/sdk/index.mjs +46 -2
- package/runner/harness/protocol.mjs +31 -3
- package/runner/harness/python/harness.py +137 -19
- package/runner/harness/python/otengine.py +116 -9
- package/runner/harness/python/otfeed.py +109 -0
- package/runner/harness/python/otreplay.py +90 -8
- package/runner/harness/python/outcometick.py +48 -4
- package/runner/series-data.mjs +220 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The submitter's own CSV, turned into rows a strategy can read.
|
|
2
|
+
//
|
|
3
|
+
// `ctx.ext('my_signal')` hands back a point-in-time view over these. They ride
|
|
4
|
+
// the same stream as market events and reference rows, so the same guarantee
|
|
5
|
+
// holds structurally: a row the replay has not reached is not in the process.
|
|
6
|
+
//
|
|
7
|
+
// This is the one data path where the CONTENT is written by the submitter. It
|
|
8
|
+
// is theirs and only they see it, so the risk is not what it says — it is what
|
|
9
|
+
// a malformed file does quietly. A column read as the wrong thing produces a
|
|
10
|
+
// series that is empty, or shifted in time, and neither errors.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which column holds the timestamp, and what unit it is in.
|
|
14
|
+
*
|
|
15
|
+
* Guessed from the data rather than demanded in the manifest, because a
|
|
16
|
+
* required `ts_column` is a thing to get wrong on the first try — but the guess
|
|
17
|
+
* is CHECKED, and a file we cannot read confidently is refused rather than
|
|
18
|
+
* half-parsed. Silently picking the wrong column gives a series stamped in 1970
|
|
19
|
+
* that is simply never visible, which looks like "my signal did nothing".
|
|
20
|
+
*/
|
|
21
|
+
export const TIMESTAMP_NAMES = /^(ts_ms|ts|time|timestamp|date|datetime|open_time)$/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Column names the wire format owns.
|
|
25
|
+
*
|
|
26
|
+
* These are not stylistic. `kind` and `name` are how the worker routes a row:
|
|
27
|
+
* it emits `{ kind: 'ext', name, ...row }`, so a column called `kind` makes the
|
|
28
|
+
* spread overwrite it and the harness stops treating the row as a series at all
|
|
29
|
+
* — `kind: 'tick'` would feed it to on_tick as a market event. A column called
|
|
30
|
+
* `name` sends the row to a feed nobody declared, leaving the declared one
|
|
31
|
+
* empty. And a second `ts_ms` beside a `timestamp` column silently replaces the
|
|
32
|
+
* timestamp everything is ordered by.
|
|
33
|
+
*
|
|
34
|
+
* Refused rather than renamed: a column quietly renamed is a column the
|
|
35
|
+
* strategy cannot find.
|
|
36
|
+
*/
|
|
37
|
+
export const RESERVED_COLUMNS = ['kind', 'name'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `ts_ms` is ours as well — but only when it is NOT the timestamp column.
|
|
41
|
+
*
|
|
42
|
+
* A file with `timestamp,ts_ms` used to have its ts_ms column silently
|
|
43
|
+
* overwritten by the parsed time, so the strategy read a different number than
|
|
44
|
+
* the one submitted. Refused rather than quietly rewritten: their data changing
|
|
45
|
+
* under them is worse than being told to rename a column.
|
|
46
|
+
*/
|
|
47
|
+
export const TS_COLUMN = 'ts_ms';
|
|
48
|
+
|
|
49
|
+
export function detectTimestamp(header, firstRow) {
|
|
50
|
+
const named = header.findIndex((h) => TIMESTAMP_NAMES.test(h.trim()));
|
|
51
|
+
const idx = named >= 0 ? named : 0;
|
|
52
|
+
const sample = String(firstRow?.[idx] ?? '').trim();
|
|
53
|
+
if (!sample) return null;
|
|
54
|
+
|
|
55
|
+
// An UNNAMED first column has to look like a time, not merely like a number.
|
|
56
|
+
// An `id` or a row counter parses happily as an epoch near 1970, every row
|
|
57
|
+
// then falls outside the replay window, and the strategy gets a series that
|
|
58
|
+
// is declared and permanently empty — the exact "accepted then silently
|
|
59
|
+
// empty" this whole path exists to avoid.
|
|
60
|
+
if (named < 0 && /^\d+$/.test(sample)) {
|
|
61
|
+
const asMs = sample.length >= 16 ? Number(sample) / 1000
|
|
62
|
+
: sample.length >= 13 ? Number(sample)
|
|
63
|
+
: Number(sample) * 1000;
|
|
64
|
+
// 2000-01-01 .. 2100-01-01. Anything outside that is not a timestamp
|
|
65
|
+
// someone meant to give us.
|
|
66
|
+
if (!(asMs > 946_684_800_000 && asMs < 4_102_444_800_000)) return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Epoch, in whatever unit. Digit count is the only signal, and it is a good
|
|
70
|
+
// one: seconds are 10 digits until 2286, milliseconds 13, microseconds 16.
|
|
71
|
+
if (/^\d+$/.test(sample)) {
|
|
72
|
+
const scale = sample.length >= 16 ? 1e-3 : sample.length >= 13 ? 1 : 1000;
|
|
73
|
+
return { idx, kind: 'epoch', scale, column: header[idx] };
|
|
74
|
+
}
|
|
75
|
+
// ISO. Date.parse handles the shapes people actually write; anything it
|
|
76
|
+
// cannot read is refused below rather than becoming NaN.
|
|
77
|
+
if (Number.isFinite(Date.parse(sample))) return { idx, kind: 'iso', scale: 1, column: header[idx] };
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const numOrNull = (v) => {
|
|
82
|
+
const s = String(v ?? '').trim();
|
|
83
|
+
if (s === '') return null;
|
|
84
|
+
const n = Number(s);
|
|
85
|
+
return Number.isFinite(n) ? n : null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Split a CSV line. Quoted fields are supported; embedded newlines are not. */
|
|
89
|
+
function splitLine(line) {
|
|
90
|
+
const out = [];
|
|
91
|
+
let cur = '';
|
|
92
|
+
let quoted = false;
|
|
93
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
94
|
+
const c = line[i];
|
|
95
|
+
if (quoted) {
|
|
96
|
+
if (c === '"') {
|
|
97
|
+
if (line[i + 1] === '"') { cur += '"'; i += 1; } else quoted = false;
|
|
98
|
+
} else cur += c;
|
|
99
|
+
} else if (c === '"') quoted = true;
|
|
100
|
+
else if (c === ',') { out.push(cur); cur = ''; }
|
|
101
|
+
else cur += c;
|
|
102
|
+
}
|
|
103
|
+
out.push(cur);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse one submitted series.
|
|
109
|
+
*
|
|
110
|
+
* @returns {{rows: object[], problem: string|null, tsColumn: string|null}}
|
|
111
|
+
*/
|
|
112
|
+
export function parseSeries(csv, { maxRows = 2_000_000 } = {}) {
|
|
113
|
+
const lines = String(csv ?? '').split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
114
|
+
if (lines.length < 2) return { rows: [], problem: 'needs a header row and at least one row of data', tsColumn: null };
|
|
115
|
+
|
|
116
|
+
const header = splitLine(lines[0]).map((h) => h.trim());
|
|
117
|
+
if (header.some((h) => h === '')) return { rows: [], problem: 'a column has no name', tsColumn: null };
|
|
118
|
+
if (new Set(header).size !== header.length) {
|
|
119
|
+
return { rows: [], problem: 'two columns share a name', tsColumn: null };
|
|
120
|
+
}
|
|
121
|
+
const clash = header.find((h) => RESERVED_COLUMNS.includes(h.toLowerCase()));
|
|
122
|
+
if (clash) {
|
|
123
|
+
return {
|
|
124
|
+
rows: [],
|
|
125
|
+
problem: `column ${JSON.stringify(clash)} is reserved — rename it`
|
|
126
|
+
+ ' (kind and name are how a row is routed to your feed)',
|
|
127
|
+
tsColumn: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const first = splitLine(lines[1]);
|
|
132
|
+
const ts = detectTimestamp(header, first);
|
|
133
|
+
if (ts && header.some((h, i) => i !== ts.idx && h.toLowerCase() === TS_COLUMN)) {
|
|
134
|
+
return {
|
|
135
|
+
rows: [],
|
|
136
|
+
problem: `column "${TS_COLUMN}" is the timestamp we key off — it cannot also be a data column`
|
|
137
|
+
+ ` (this file uses ${JSON.stringify(header[ts.idx])} for the timestamp)`,
|
|
138
|
+
tsColumn: null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (!ts) {
|
|
142
|
+
return {
|
|
143
|
+
rows: [],
|
|
144
|
+
problem: `could not read a timestamp from the first column (${JSON.stringify(header[0])})`
|
|
145
|
+
+ ' — name it ts_ms, ts, time, timestamp, date or datetime, or put epoch/ISO values in column one',
|
|
146
|
+
tsColumn: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rows = [];
|
|
151
|
+
let dropped = 0;
|
|
152
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
153
|
+
if (rows.length >= maxRows) return { rows: [], problem: `over ${maxRows} rows`, tsColumn: ts.column };
|
|
154
|
+
const cells = splitLine(lines[i]);
|
|
155
|
+
const raw = String(cells[ts.idx] ?? '').trim();
|
|
156
|
+
const at = ts.kind === 'epoch' ? Number(raw) * ts.scale : Date.parse(raw);
|
|
157
|
+
if (!Number.isFinite(at)) { dropped += 1; continue; }
|
|
158
|
+
|
|
159
|
+
// Prototype-less: these keys come from a submitted file, and `__proto__` as
|
|
160
|
+
// a column name on an ordinary object is a real thing to hand a strategy.
|
|
161
|
+
const row = Object.create(null);
|
|
162
|
+
for (let c = 0; c < header.length; c += 1) {
|
|
163
|
+
if (c === ts.idx) continue;
|
|
164
|
+
const n = numOrNull(cells[c]);
|
|
165
|
+
row[header[c]] = n === null ? String(cells[c] ?? '') : n;
|
|
166
|
+
}
|
|
167
|
+
// LAST, so a data column that also happens to be called ts_ms cannot
|
|
168
|
+
// replace the timestamp every ordering and window check depends on. It is
|
|
169
|
+
// still readable by the strategy — under whatever it called the timestamp
|
|
170
|
+
// column — it just is not the one we sort by.
|
|
171
|
+
row.ts_ms = Math.floor(at);
|
|
172
|
+
rows.push(row);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (rows.length === 0) {
|
|
176
|
+
return { rows: [], problem: 'no row had a readable timestamp', tsColumn: ts.column };
|
|
177
|
+
}
|
|
178
|
+
// Sorted, because PointInTimeFeed walks a monotone cursor: one row out of
|
|
179
|
+
// order hides every row behind it for the rest of the run.
|
|
180
|
+
rows.sort((a, b) => a.ts_ms - b.ts_ms);
|
|
181
|
+
return {
|
|
182
|
+
rows,
|
|
183
|
+
problem: dropped > 0 && dropped >= rows.length
|
|
184
|
+
? `${dropped} of ${dropped + rows.length} rows had no readable timestamp`
|
|
185
|
+
: null,
|
|
186
|
+
tsColumn: ts.column,
|
|
187
|
+
dropped,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Every series a run declared, from the files that came with the submission.
|
|
193
|
+
*
|
|
194
|
+
* A series that cannot be parsed is REFUSED rather than delivered empty: an
|
|
195
|
+
* empty ctx.ext() reads as "my signal never fired", and the submitter would go
|
|
196
|
+
* looking at their strategy instead of their file.
|
|
197
|
+
*/
|
|
198
|
+
export function loadSeries(declared, files) {
|
|
199
|
+
const byName = new Map(files.map((f) => [f.name, f.content]));
|
|
200
|
+
const rowsByName = {};
|
|
201
|
+
const problems = [];
|
|
202
|
+
|
|
203
|
+
for (const s of declared ?? []) {
|
|
204
|
+
const csv = byName.get(s.file);
|
|
205
|
+
if (csv == null) {
|
|
206
|
+
problems.push({ name: s.name, file: s.file, problem: 'file was not submitted' });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const out = parseSeries(csv);
|
|
210
|
+
if (out.problem && out.rows.length === 0) {
|
|
211
|
+
problems.push({ name: s.name, file: s.file, problem: out.problem });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
rowsByName[s.name] = out.rows;
|
|
215
|
+
if (out.dropped > 0) {
|
|
216
|
+
problems.push({ name: s.name, file: s.file, problem: `${out.dropped} row(s) had no readable timestamp`, fatal: false });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { rowsByName, problems };
|
|
220
|
+
}
|