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
package/runner/engine/replay.mjs
CHANGED
|
@@ -15,25 +15,50 @@
|
|
|
15
15
|
import { Book } from './book.mjs';
|
|
16
16
|
import { Portfolio } from './portfolio.mjs';
|
|
17
17
|
|
|
18
|
+
|
|
19
|
+
// Mirrors LIMITS.logLineChars / LIMITS.logBytesPerRun, and does NOT import
|
|
20
|
+
// them. This file is baked into the sandbox image, which contains runner/ and
|
|
21
|
+
// nothing else — an import of api/ resolves fine on a developer's machine and
|
|
22
|
+
// on the worker, and then fails inside every container with
|
|
23
|
+
// ERR_MODULE_NOT_FOUND. otreplay.py mirrors the same two numbers for the same
|
|
24
|
+
// reason. replay-limits.test.mjs asserts both copies still equal the contract.
|
|
25
|
+
const LOG_LINE_CHARS = 512;
|
|
26
|
+
const LOG_BYTES_PER_RUN = 2 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
// A settlement recompute is a once-per-market claim, so these are generous.
|
|
29
|
+
// They exist because `crosschecks` rides the same result line as everything
|
|
30
|
+
// else: unbounded, it is an output channel with no budget. Mirrored in
|
|
31
|
+
// otreplay.py.
|
|
32
|
+
const MAX_CROSSCHECKS_PER_MARKET = 16;
|
|
33
|
+
const CROSSCHECK_CLAIMED_CHARS = 32;
|
|
34
|
+
|
|
35
|
+
|
|
18
36
|
/** Event kinds the loop understands, in the order they dispatch. */
|
|
19
37
|
export const EVENT_KINDS = Object.freeze(['tick', 'book', 'trade']);
|
|
20
38
|
|
|
21
39
|
/**
|
|
22
40
|
* Per-event budget.
|
|
23
41
|
*
|
|
24
|
-
* Measured over the strategy's own hook, not the loop around it.
|
|
25
|
-
* kills the shard rather than the run: one pathological market must
|
|
26
|
-
* the customer the other 719.
|
|
42
|
+
* Measured over the strategy's own hook, not the loop around it. Sustained cost
|
|
43
|
+
* over budget kills the shard rather than the run: one pathological market must
|
|
44
|
+
* not cost the customer the other 719.
|
|
27
45
|
*/
|
|
28
46
|
export class BudgetMonitor {
|
|
29
|
-
constructor({ limitMicros = 400, sampleFloor =
|
|
47
|
+
constructor({ limitMicros = 400, sampleFloor = 2000 } = {}) {
|
|
30
48
|
this.limitMicros = limitMicros;
|
|
31
49
|
this.sampleFloor = sampleFloor;
|
|
32
|
-
this.tolerance = tolerance;
|
|
33
50
|
this.count = 0;
|
|
34
51
|
this.breaches = 0;
|
|
35
52
|
this.maxMicros = 0;
|
|
36
53
|
this.totalMicros = 0;
|
|
54
|
+
// The most recent `sampleFloor` events, as a ring. One monitor covers the
|
|
55
|
+
// WHOLE run, so a lifetime mean is diluted by however much came before: a
|
|
56
|
+
// strategy that runs 18,000 events at 8us and then 2,000 at 2,000us has a
|
|
57
|
+
// lifetime mean of 207us and passes, while its last 2,000 events are
|
|
58
|
+
// continuously 5x over budget. The window is what makes "sustained" local.
|
|
59
|
+
this.window = new Float64Array(sampleFloor);
|
|
60
|
+
this.windowSum = 0;
|
|
61
|
+
this.windowAt = 0;
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
record(micros) {
|
|
@@ -41,14 +66,64 @@ export class BudgetMonitor {
|
|
|
41
66
|
this.totalMicros += micros;
|
|
42
67
|
if (micros > this.maxMicros) this.maxMicros = micros;
|
|
43
68
|
if (micros > this.limitMicros) this.breaches += 1;
|
|
69
|
+
this.windowSum += micros - this.window[this.windowAt];
|
|
70
|
+
this.window[this.windowAt] = micros;
|
|
71
|
+
this.windowAt = (this.windowAt + 1) % this.window.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mean of the most recent `sampleFloor` events. Zero until the window fills. */
|
|
75
|
+
get windowMicros() {
|
|
76
|
+
return this.count >= this.sampleFloor ? this.windowSum / this.window.length : 0;
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
80
|
+
* SUSTAINED cost, which is what the limit is for and what customers are told
|
|
81
|
+
* it means ("sustained breach kills the shard"): the mean of the most recent
|
|
82
|
+
* `sampleFloor` events, not the tail and not the lifetime.
|
|
83
|
+
*
|
|
84
|
+
* This judged the breach RATE against a 1% tolerance, and it was measuring
|
|
85
|
+
* the wrong machine. The budget brackets each hook with two wall-clock reads,
|
|
86
|
+
* on a 2-core box where the worker is decompressing and feeding stdin the
|
|
87
|
+
* whole time and the sandbox holds one vCPU — so an event that gets
|
|
88
|
+
* descheduled is recorded as an event the strategy spent 4ms in. Measured
|
|
89
|
+
* inside the real image: the same strategy on an idle host averages 7.8us
|
|
90
|
+
* with a 766us worst case, and under contention averages 20.9us with a 4090us
|
|
91
|
+
* worst case. Nothing about the strategy changed.
|
|
92
|
+
*
|
|
93
|
+
* The page's own sample was rejected in production at avg 72us — a fifth of
|
|
94
|
+
* its 400us budget — because 1.1% of its events had been interrupted. The
|
|
95
|
+
* breaches were not even front-loaded, so a longer warm-up floor could never
|
|
96
|
+
* have fixed it: measured in-image they land at events 171, 2368, 3201 and so
|
|
97
|
+
* on, which is the shape of GC and scheduling, not of a slow strategy.
|
|
98
|
+
*
|
|
99
|
+
* The mean is what actually predicts the thing this protects — the 20-minute
|
|
100
|
+
* wall clock is mean times event count — and it is not fooled by a machine
|
|
101
|
+
* that takes the CPU away. A strategy that really is slow raises the mean; an
|
|
102
|
+
* interrupted one does not. A single hook that never returns is still caught,
|
|
103
|
+
* by the run deadline, which is where that belongs.
|
|
104
|
+
*
|
|
105
|
+
* WINDOWED, NOT LIFETIME. One monitor covers the whole run, so a lifetime
|
|
106
|
+
* mean lets a cheap prefix pay for an expensive phase: 18,000 events at 8us
|
|
107
|
+
* followed by 2,000 at 2,000us averages 207us and passes, while the strategy
|
|
108
|
+
* has been 5x over budget for its last two thousand events. The window is
|
|
109
|
+
* what makes "sustained" mean sustained rather than "on average, eventually".
|
|
110
|
+
*
|
|
111
|
+
* `breaches` and `max_micros` stay in the summary. They are good diagnostics.
|
|
112
|
+
* They are not a verdict.
|
|
113
|
+
*
|
|
114
|
+
* KNOWN AND DELIBERATE GAP: a low-frequency, very heavy event slips through.
|
|
115
|
+
* One 500ms hook among 2,000 events at 8us is a window mean of 258us, inside
|
|
116
|
+
* budget; it would take 800ms to trip on its own. A per-hook hard cap would
|
|
117
|
+
* close it, and it is not being added, because this limit has now been wrong
|
|
118
|
+
* twice and BOTH times the same way — a one-off cost judged as a sustained
|
|
119
|
+
* one, killing a strategy that was fine. Building an index on the first tick
|
|
120
|
+
* is a legitimate 300ms hook. The wall clock already bounds total resource
|
|
121
|
+
* use, so the cap would buy protection against a failure nobody has seen at
|
|
122
|
+
* the cost of the exact mistake already made twice. Add it when a real run
|
|
123
|
+
* demonstrates the need, not before.
|
|
49
124
|
*/
|
|
50
125
|
get breached() {
|
|
51
|
-
return this.count >= this.sampleFloor && this.
|
|
126
|
+
return this.count >= this.sampleFloor && this.windowMicros > this.limitMicros;
|
|
52
127
|
}
|
|
53
128
|
|
|
54
129
|
get avgMicros() { return this.count ? this.totalMicros / this.count : 0; }
|
|
@@ -59,6 +134,10 @@ export class BudgetMonitor {
|
|
|
59
134
|
breaches: this.breaches,
|
|
60
135
|
breach_rate: this.count ? this.breaches / this.count : 0,
|
|
61
136
|
avg_micros: this.avgMicros,
|
|
137
|
+
// The number the verdict is actually made on. Without it a rejection
|
|
138
|
+
// shows a lifetime average comfortably inside budget and reads as a lie.
|
|
139
|
+
window_micros: this.windowMicros,
|
|
140
|
+
window_events: this.sampleFloor,
|
|
62
141
|
max_micros: this.maxMicros,
|
|
63
142
|
limit_micros: this.limitMicros,
|
|
64
143
|
};
|
|
@@ -124,7 +203,7 @@ function bookView(book) {
|
|
|
124
203
|
});
|
|
125
204
|
}
|
|
126
205
|
|
|
127
|
-
function createCtx({ params, portfolio, marketId, market,
|
|
206
|
+
function createCtx({ params, portfolio, marketId, market, logBudget, references, series, rng }) {
|
|
128
207
|
const history = [];
|
|
129
208
|
const logs = [];
|
|
130
209
|
const crosschecks = [];
|
|
@@ -171,8 +250,20 @@ function createCtx({ params, portfolio, marketId, market, logLimit, references,
|
|
|
171
250
|
},
|
|
172
251
|
|
|
173
252
|
log(msg) {
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
// BYTES FOR THE WHOLE RUN, not lines per market. The old shape — 10,000
|
|
254
|
+
// lines per market, no length cap — let a run emit the archive it had
|
|
255
|
+
// just paid a market-day for into a file the customer downloads. See
|
|
256
|
+
// LIMITS.logBytesPerRun for the arithmetic that picks these numbers.
|
|
257
|
+
if (logBudget.spent >= logBudget.bytes) { logTruncated = true; return; }
|
|
258
|
+
const line = `${now} ${String(msg)}`.slice(0, logBudget.lineChars);
|
|
259
|
+
// BYTES, not string length. logs.txt is UTF-8, and `.length` counts
|
|
260
|
+
// UTF-16 units — so a run logging Chinese spent a third of what it
|
|
261
|
+
// wrote, and the archive could reach three times the 2 MB this budget
|
|
262
|
+
// advertises. The line cap stays in characters because it is about
|
|
263
|
+
// being readable; the run cap is about how much data leaves with the
|
|
264
|
+
// customer, and that is measured in bytes.
|
|
265
|
+
logBudget.spent += Buffer.byteLength(line, 'utf8') + 1;
|
|
266
|
+
logs.push(line);
|
|
176
267
|
},
|
|
177
268
|
|
|
178
269
|
/** Seeded generator — the only randomness available, and it is recorded. */
|
|
@@ -240,9 +331,19 @@ function createCtx({ params, portfolio, marketId, market, logLimit, references,
|
|
|
240
331
|
// recompute match that never happened. The cross-check panel's whole
|
|
241
332
|
// value is that it is the ARCHIVE's answer, not the strategy's.
|
|
242
333
|
const official = market?.outcome ?? null;
|
|
334
|
+
// BOUNDED, for the same reason ctx.log is. `outcome` is whatever the
|
|
335
|
+
// strategy passed and this can be called on every event, so an unbounded
|
|
336
|
+
// push here is an unmetered output channel wearing a different name: the
|
|
337
|
+
// whole array is serialised onto the authenticated result line, sent,
|
|
338
|
+
// and parsed by the worker before anything downstream gets to ignore it.
|
|
339
|
+
// The panel only ever shows an aggregate, so nothing of value is lost by
|
|
340
|
+
// capping — a settlement recompute is a once-per-market claim.
|
|
341
|
+
if (crosschecks.length >= MAX_CROSSCHECKS_PER_MARKET) return;
|
|
243
342
|
crosschecks.push({
|
|
244
343
|
market_id: marketId,
|
|
245
|
-
claimed: outcome
|
|
344
|
+
claimed: typeof outcome === 'string'
|
|
345
|
+
? outcome.slice(0, CROSSCHECK_CLAIMED_CHARS)
|
|
346
|
+
: String(outcome).slice(0, CROSSCHECK_CLAIMED_CHARS),
|
|
246
347
|
official,
|
|
247
348
|
match: official === outcome,
|
|
248
349
|
});
|
|
@@ -293,11 +394,32 @@ const HOOK_FOR = { tick: 'on_tick', book: 'on_book', trade: 'on_trade' };
|
|
|
293
394
|
* decision. Zero is "as captured"; the latency panel is this same replay at
|
|
294
395
|
* 100ms, 250ms, 500ms, 1s and 2s.
|
|
295
396
|
*/
|
|
397
|
+
/**
|
|
398
|
+
* A log allowance for one run.
|
|
399
|
+
*
|
|
400
|
+
* Bytes, not lines, and shared by every market in the run — see
|
|
401
|
+
* LIMITS.logBytesPerRun for why those two choices are the whole fix.
|
|
402
|
+
*/
|
|
403
|
+
export function makeLogBudget({
|
|
404
|
+
bytes = LOG_BYTES_PER_RUN,
|
|
405
|
+
lineChars = LOG_LINE_CHARS,
|
|
406
|
+
} = {}) {
|
|
407
|
+
return { bytes, lineChars, spent: 0 };
|
|
408
|
+
}
|
|
409
|
+
|
|
296
410
|
export function replayMarket({
|
|
297
411
|
market, events, strategy, hooks,
|
|
298
412
|
portfolio = null,
|
|
299
413
|
fillDelayMs = 0,
|
|
300
|
-
|
|
414
|
+
/**
|
|
415
|
+
* The run's remaining log allowance, SHARED ACROSS MARKETS.
|
|
416
|
+
*
|
|
417
|
+
* Passed in rather than created here, because a per-market allowance is what
|
|
418
|
+
* the old limit was and what made the log channel an export route: 386
|
|
419
|
+
* markets a day each got their own budget. One object for the whole run is
|
|
420
|
+
* the fix — the harness makes it once and hands the same one to every market.
|
|
421
|
+
*/
|
|
422
|
+
logBudget = null,
|
|
301
423
|
budget = null,
|
|
302
424
|
references = null,
|
|
303
425
|
series = null,
|
|
@@ -338,7 +460,7 @@ export function replayMarket({
|
|
|
338
460
|
portfolio: pf,
|
|
339
461
|
marketId,
|
|
340
462
|
market: engineMarket,
|
|
341
|
-
|
|
463
|
+
logBudget: logBudget ?? makeLogBudget(),
|
|
342
464
|
references,
|
|
343
465
|
series,
|
|
344
466
|
rng: makeRng(seed),
|
package/runner/engine/report.mjs
CHANGED
|
@@ -8,16 +8,6 @@
|
|
|
8
8
|
// Nothing in this module can see the strategy. It reads the trade and fill logs
|
|
9
9
|
// the engine produced, so a report cannot be tuned by the thing it is judging.
|
|
10
10
|
|
|
11
|
-
/** Delays the latency panel re-prices every fill at. */
|
|
12
|
-
export const LATENCY_STEPS = Object.freeze([
|
|
13
|
-
{ label: 'as captured (0 ms)', ms: 0 },
|
|
14
|
-
{ label: '+100 ms', ms: 100 },
|
|
15
|
-
{ label: '+250 ms', ms: 250 },
|
|
16
|
-
{ label: '+500 ms', ms: 500 },
|
|
17
|
-
{ label: '+1 s', ms: 1000 },
|
|
18
|
-
{ label: '+2 s', ms: 2000 },
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
11
|
/** Entry-price buckets for the calibration panel. */
|
|
22
12
|
export const CALIBRATION_BUCKETS = Object.freeze([
|
|
23
13
|
[0.0, 0.1], [0.1, 0.2], [0.2, 0.3], [0.3, 0.4], [0.4, 0.5],
|
|
@@ -46,6 +36,64 @@ const r4 = (x) => (Number.isFinite(x) ? Number(x.toFixed(4)) : null);
|
|
|
46
36
|
*/
|
|
47
37
|
const collateralOf = (t) => (t.entry_px ?? 0) * (t.size ?? 0);
|
|
48
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The most money this strategy had at risk AT ONE TIME.
|
|
41
|
+
*
|
|
42
|
+
* THE NUMBER THAT ANSWERS "how much do I need to run this", and the one this
|
|
43
|
+
* report was missing. Summing every entry answers a different question: the
|
|
44
|
+
* sample strategy opened 1,676 positions over fifteen days and never held more
|
|
45
|
+
* than one, so its entries total $72,175 while it never needed more than $80.
|
|
46
|
+
* Dividing a loss by the sum therefore reported −4.35% for a strategy that had
|
|
47
|
+
* burned through its stake thirty-nine times over.
|
|
48
|
+
*
|
|
49
|
+
* Computed by sweeping the open/close events, so overlapping positions add up
|
|
50
|
+
* and sequential ones do not. Trades with no timestamps are skipped rather
|
|
51
|
+
* than assumed concurrent — an unknown that inflates the peak would make the
|
|
52
|
+
* strategy look safer to fund than it is.
|
|
53
|
+
*/
|
|
54
|
+
function peakCapital(trades) {
|
|
55
|
+
const events = [];
|
|
56
|
+
for (const t of trades) {
|
|
57
|
+
if (t.opened_ms == null || t.closed_ms == null) continue;
|
|
58
|
+
const amt = collateralOf(t);
|
|
59
|
+
if (!(amt > 0)) continue;
|
|
60
|
+
events.push([t.opened_ms, amt]);
|
|
61
|
+
events.push([t.closed_ms, -amt]);
|
|
62
|
+
}
|
|
63
|
+
// Closes before opens at the same instant: a position that ends exactly when
|
|
64
|
+
// the next begins did not need both stakes at once.
|
|
65
|
+
events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
66
|
+
let cur = 0;
|
|
67
|
+
let peak = 0;
|
|
68
|
+
for (const [, delta] of events) {
|
|
69
|
+
cur += delta;
|
|
70
|
+
if (cur > peak) peak = cur;
|
|
71
|
+
}
|
|
72
|
+
return peak;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Share of the run's span with a position open. Money idle is money wasted. */
|
|
76
|
+
function holdingRatio(trades) {
|
|
77
|
+
const withTimes = trades.filter((t) => t.opened_ms != null && t.closed_ms != null);
|
|
78
|
+
if (withTimes.length === 0) return null;
|
|
79
|
+
const first = Math.min(...withTimes.map((t) => t.opened_ms));
|
|
80
|
+
const last = Math.max(...withTimes.map((t) => t.closed_ms));
|
|
81
|
+
const span = last - first;
|
|
82
|
+
if (!(span > 0)) return null;
|
|
83
|
+
// Union of the intervals, not their sum: two overlapping positions are one
|
|
84
|
+
// stretch of being in the market, and summing them can exceed the span.
|
|
85
|
+
const spans = withTimes
|
|
86
|
+
.map((t) => [t.opened_ms, t.closed_ms])
|
|
87
|
+
.sort((a, b) => a[0] - b[0]);
|
|
88
|
+
let held = 0;
|
|
89
|
+
let [s, e] = spans[0];
|
|
90
|
+
for (const [a, b] of spans.slice(1)) {
|
|
91
|
+
if (a > e) { held += e - s; [s, e] = [a, b]; } else if (b > e) e = b;
|
|
92
|
+
}
|
|
93
|
+
held += e - s;
|
|
94
|
+
return held / span;
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
/**
|
|
50
98
|
* Headline metrics — the twelve cells at the top of the report.
|
|
51
99
|
*/
|
|
@@ -62,6 +110,8 @@ export function metrics(trades, { feesPaid = 0, days = 1 } = {}) {
|
|
|
62
110
|
const wins = pnls.filter((p) => p > 0);
|
|
63
111
|
const losses = pnls.filter((p) => p < 0);
|
|
64
112
|
const collateral = sum(closed.map(collateralOf));
|
|
113
|
+
const peak = peakCapital(closed);
|
|
114
|
+
const hold = holdingRatio(closed);
|
|
65
115
|
|
|
66
116
|
const equity = equityCurve(closed);
|
|
67
117
|
const dd = maxDrawdown(equity.map((p) => p.equity));
|
|
@@ -96,7 +146,25 @@ export function metrics(trades, { feesPaid = 0, days = 1 } = {}) {
|
|
|
96
146
|
max_drawdown_abs: r2(dd.abs),
|
|
97
147
|
sharpe: sharpe == null ? null : r2(sharpe),
|
|
98
148
|
trades: closed.length,
|
|
149
|
+
// Distinct markets the strategy actually took a position in. The engine
|
|
150
|
+
// does not stop a strategy trading a market twice, so `trades / markets`
|
|
151
|
+
// is only an entry rate for strategies that enter once — this one is an
|
|
152
|
+
// entry rate for all of them, and equals `trades` in the common case.
|
|
153
|
+
markets_traded: new Set(closed.map((t) => t.market_id)).size,
|
|
154
|
+
// Net P&L over the SUM of every entry — "for each dollar traded, how much
|
|
155
|
+
// was made". Renamed on the page to say that, because "return on capital"
|
|
156
|
+
// reads as an account return and is not one: the same stake recycled a
|
|
157
|
+
// thousand times makes this number a thousand times smaller than what
|
|
158
|
+
// happened to the money.
|
|
99
159
|
return_on_collateral: collateral > 0 ? r4(netPnl / collateral) : null,
|
|
160
|
+
// THE ACCOUNT NUMBER. Net P&L over the most that was ever at risk at once,
|
|
161
|
+
// which is what someone funding this strategy actually has to put up.
|
|
162
|
+
peak_capital: r2(peak),
|
|
163
|
+
return_on_peak: peak > 0 ? r4(netPnl / peak) : null,
|
|
164
|
+
// How much of the run had a position open. The sample strategy is in the
|
|
165
|
+
// market 13% of the time, which is the other half of why the two return
|
|
166
|
+
// figures differ by three orders of magnitude.
|
|
167
|
+
holding_ratio: hold == null ? null : r4(hold),
|
|
100
168
|
// Cents of edge per contract: what the outcome was worth minus what was
|
|
101
169
|
// paid, averaged. This is the number that says whether there was an edge
|
|
102
170
|
// at all, as opposed to a lucky run of variance.
|
|
@@ -202,7 +270,7 @@ export function calibration(trades) {
|
|
|
202
270
|
const implied = mean(inBucket.map((t) => t.entry_px));
|
|
203
271
|
const realized = mean(inBucket.map((t) => (t.outcome === t.side ? 1 : 0)));
|
|
204
272
|
return {
|
|
205
|
-
bucket: `${lo.toFixed(2)}
|
|
273
|
+
bucket: `${lo.toFixed(2)}-${hi.toFixed(2)}`,
|
|
206
274
|
lo,
|
|
207
275
|
hi,
|
|
208
276
|
implied: r4(implied),
|
|
@@ -308,26 +376,6 @@ export function splitByMarket(trades, marketMeta = new Map()) {
|
|
|
308
376
|
return rows;
|
|
309
377
|
}
|
|
310
378
|
|
|
311
|
-
/**
|
|
312
|
-
* The latency panel: net PnL if every fill had landed later.
|
|
313
|
-
*
|
|
314
|
-
* The rows come from re-running the replay at each delay, which the caller
|
|
315
|
-
* does — this only shapes the result. We keep event time, upstream server time
|
|
316
|
-
* and our receive time separate on every row, which is what makes re-pricing at
|
|
317
|
-
* an arbitrary delay meaningful rather than a guess.
|
|
318
|
-
*/
|
|
319
|
-
export function latencyPanel(resultsByDelay) {
|
|
320
|
-
const base = resultsByDelay.find((r) => r.delayMs === 0)?.netPnl ?? 0;
|
|
321
|
-
return resultsByDelay.map((r) => ({
|
|
322
|
-
label: LATENCY_STEPS.find((s) => s.ms === r.delayMs)?.label ?? `+${r.delayMs} ms`,
|
|
323
|
-
delay_ms: r.delayMs,
|
|
324
|
-
net_pnl: r2(r.netPnl),
|
|
325
|
-
// Relative to the as-captured run, so the shape of the decay is readable
|
|
326
|
-
// without dividing in your head.
|
|
327
|
-
ratio: base === 0 ? null : r4(r.netPnl / base),
|
|
328
|
-
unprofitable: r.netPnl < 0,
|
|
329
|
-
}));
|
|
330
|
-
}
|
|
331
379
|
|
|
332
380
|
/**
|
|
333
381
|
* The parameter sweep grid.
|
|
@@ -365,9 +413,9 @@ export function sweepPanel(cells, { xParam, yParam, metric = 'sharpe' }) {
|
|
|
365
413
|
* makes the rest of it checkable, so it is never summarised away.
|
|
366
414
|
*/
|
|
367
415
|
export function buildReport({
|
|
368
|
-
runId, submittedAt, manifest, scope,
|
|
416
|
+
runId, submittedAt, manifest, scope, sourceSha256 = null,
|
|
369
417
|
trades, fills, marketSummaries, marketMeta,
|
|
370
|
-
feesPaid = 0,
|
|
418
|
+
feesPaid = 0, fillDelayMs = 0, sweep = null, coverage = null,
|
|
371
419
|
crosschecks = [], budget = null, seed = null, scanned = {},
|
|
372
420
|
}) {
|
|
373
421
|
const closed = trades.filter((t) => Number.isFinite(t.pnl));
|
|
@@ -376,6 +424,10 @@ export function buildReport({
|
|
|
376
424
|
|
|
377
425
|
return {
|
|
378
426
|
run_id: runId,
|
|
427
|
+
// WHICH CODE PRODUCED THIS. The source itself is no longer in the archive
|
|
428
|
+
// — a report is a thing you forward to someone and the strategy is not —
|
|
429
|
+
// so this is what answers "which version of my strategy was this?".
|
|
430
|
+
source_sha256: sourceSha256,
|
|
379
431
|
generated_ms: submittedAt,
|
|
380
432
|
sdk_schema: manifest?.schema ?? null,
|
|
381
433
|
language: manifest?.language ?? null,
|
|
@@ -409,7 +461,17 @@ export function buildReport({
|
|
|
409
461
|
}),
|
|
410
462
|
split: splitByMarket(closed, marketMeta ?? new Map()),
|
|
411
463
|
slippage: slippage(fills ?? []),
|
|
412
|
-
|
|
464
|
+
// THE DELAY THIS RUN WAS PRICED AT, not a comparison table.
|
|
465
|
+
//
|
|
466
|
+
// There used to be five extra replays at 100ms..2s, then one, and the
|
|
467
|
+
// panel that compared them. It is gone: a run now replays ONCE, at
|
|
468
|
+
// whatever delay the submitter asked for, which is both the fastest answer
|
|
469
|
+
// and the only one that is a measurement rather than an extrapolation.
|
|
470
|
+
//
|
|
471
|
+
// It has to be IN the report, because it changes every number in it and
|
|
472
|
+
// nothing else in here would tell a reader whether they are looking at a
|
|
473
|
+
// zero-latency run or a 250ms one.
|
|
474
|
+
fill_delay_ms: fillDelayMs,
|
|
413
475
|
sweep,
|
|
414
476
|
coverage,
|
|
415
477
|
budget,
|