outcometick 1.6.1 → 1.6.3

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
6
6
  An edit made here survives until the next publish and then disappears.
7
7
 
8
- Generated from monorepo revision 56659396f7f20223103270472ffb16a5b0417bbd.
8
+ Generated from monorepo revision 600941b94e4cf95204fc8daf624a24a05f9c43c0.
9
9
  -->
10
10
 
11
11
  # outcometick
@@ -18,7 +18,7 @@ Predict.fun crypto Up/Down markets.
18
18
  npm i -g outcometick
19
19
 
20
20
  ot check . # validate, free, no data
21
- ot run . --data ./polymarket-data-samples --date … # replay locally
21
+ ot run . --data ./polymarket-data-samples # replay locally
22
22
  ot submit . --assets btc,eth --from … --to … # send it to the queue
23
23
  ot status <run_id> # where it got to
24
24
  ot fetch <run_id> # download the report
@@ -63,7 +63,7 @@ ship in this package rather than being reimplemented client-side.
63
63
  It is the same engine, the same report and the same archive format the queue
64
64
  uses, against a local copy of the archive:
65
65
 
66
- git clone https://github.com/Ligengxin96/polymarket-data-samples
66
+ curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz
67
67
 
68
68
  It is **not** the sandbox. Locally your strategy runs as you, with your
69
69
  privileges, on your machine — which is fine, because it is your code. On our
@@ -15,7 +15,7 @@ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
15
15
  export const SCHEMA_VERSION = 1;
16
16
 
17
17
  /** SDK version reported by the docs page and stamped into every report. */
18
- export const SDK_VERSION = '1.6.1';
18
+ export const SDK_VERSION = '1.6.3';
19
19
 
20
20
  /**
21
21
  * The tag of the sandbox images, and the ONLY place it is written down.
@@ -60,7 +60,7 @@ export const SDK_VERSION = '1.6.1';
60
60
  * forwarded a fourth descriptor, so fd 3 was closed inside the container and no
61
61
  * containerised run had ever returned anything.
62
62
  */
63
- export const SANDBOX_IMAGE_TAG = '1.11.0';
63
+ export const SANDBOX_IMAGE_TAG = '1.14.0';
64
64
 
65
65
  // ---------------------------------------------------------------------------
66
66
  // Languages
@@ -469,7 +469,17 @@ export const LIMITS = Object.freeze({
469
469
  // strategy being slow, and charging their execution budget for our network
470
470
  // is backwards — a 30-day run spent all twenty minutes fetching and was
471
471
  // killed without replaying an event.
472
- wallClockMs: 20 * 60 * 1000,
472
+ //
473
+ // SIZED PER RUN, not a constant — see wallClockMsFor below. A flat twenty
474
+ // minutes was a limit on the SMALLEST run that could not finish: replay costs
475
+ // ~24s per market-day (measured, warm cache, 289 markets and ~1.04M events in
476
+ // a polymarket BTC day), so twenty minutes covers about fifty of them while
477
+ // the page was selling a ninety-day chip. The customer paid, watched it work
478
+ // for twenty minutes, and got a refund and no report.
479
+ //
480
+ // These two are the inputs to that function and the only numbers to tune.
481
+ replayBaseMs: 3 * 60 * 1000,
482
+ replayPerMarketDayMs: 35 * 1000,
473
483
  // The FETCH budget, separate and bounded. Not unbounded, because there is one
474
484
  // worker and one slot: a stalled R2 read used to sit inside the fetch while
475
485
  // the heartbeat kept renewing the lease, so nobody could reclaim the run and
@@ -491,6 +501,59 @@ export const LIMITS = Object.freeze({
491
501
  archiveRetentionDays: 7,
492
502
  });
493
503
 
504
+ /**
505
+ * The longest range one run may cover, in CALENDAR DAYS.
506
+ *
507
+ * The product limit. Checked against the days that actually exist in the
508
+ * archive — the range AFTER it is intersected — not against what was asked
509
+ * for: requesting more days than exist has always been fine and is billed for
510
+ * what was there, and moving the check earlier would hard-fail a page whose
511
+ * capacity figure is a few minutes stale.
512
+ *
513
+ * Enforced in the API and mirrored in the editor, so nobody can build a
514
+ * submission the queue will refuse.
515
+ *
516
+ * Raising it is a hardware decision, not a config one: there is one worker
517
+ * slot and a run holds it for its whole life.
518
+ */
519
+ export const MAX_BACKTEST_DAYS = 90;
520
+
521
+ /**
522
+ * The clamp on the REPLAY BUDGET's input — not a limit on what may be run.
523
+ *
524
+ * The budget below grows with market-days because that is what the machine
525
+ * spends time on, and market-days are days × assets × intervals: ninety days
526
+ * of one asset is 90, ninety days of seven assets over two intervals is 1,260.
527
+ * Without a clamp the second would be handed an eight-hour budget and would
528
+ * hold the only worker slot for a working day.
529
+ *
530
+ * So a run larger than this still RUNS — it simply is not given proportionally
531
+ * more time, and if it cannot finish it is refunded in full like any other
532
+ * overrun. That is the honest failure: bounded queue damage, money back.
533
+ */
534
+ export const BUDGET_CLAMP_MARKET_DAYS = 180;
535
+
536
+ /**
537
+ * How long a run's REPLAY may take, given its size.
538
+ *
539
+ * Derived rather than declared so the limit and the thing it limits cannot
540
+ * drift: `MAX_BACKTEST_DAYS` decides how long a range can be, this decides how long
541
+ * that size is allowed to take, and both come from the two constants in LIMITS.
542
+ *
543
+ * Sized on the WARM path (~24s/market-day measured) plus margin, because the
544
+ * fetch has its own budget — `fetchClockMs` — and a slow archive read is our
545
+ * pipe being slow, not the strategy. A run whose days are cold spends that
546
+ * time under the fetch clock and arrives here with the same work to do.
547
+ */
548
+ export function wallClockMsFor(marketDays) {
549
+ const n = Number.isFinite(marketDays) && marketDays > 0 ? Math.ceil(marketDays) : 1;
550
+ return LIMITS.replayBaseMs
551
+ + LIMITS.replayPerMarketDayMs * Math.min(n, BUDGET_CLAMP_MARKET_DAYS);
552
+ }
553
+
554
+ /** The ceiling that follows from the numbers above. For copy and for docs. */
555
+ export const MAX_WALL_CLOCK_MS = wallClockMsFor(BUDGET_CLAMP_MARKET_DAYS);
556
+
494
557
  // ---------------------------------------------------------------------------
495
558
  // Rejection codes
496
559
  // ---------------------------------------------------------------------------
@@ -512,6 +575,12 @@ export const REJECTION_CODES = Object.freeze({
512
575
  E_COVERAGE: 'A captured stream was requested outside the window it was captured in.',
513
576
  E_LIMIT: 'A submission limit was exceeded — file count, total source size or series size.',
514
577
  E_SCOPE: 'The requested venue, asset or date range is not something we can serve.',
578
+ // The only one `ot check` cannot produce: it means the run started and did not
579
+ // finish. Used in eleven places across the API, the CLI and the worker long before
580
+ // it was declared here — so the docs table, which renders these keys, never listed
581
+ // the one code a customer was most likely to be holding when they came to look it up.
582
+ E_RUNTIME: 'The run started but could not finish — the sandbox crashed, the feed to it was'
583
+ + ' cut short, or the replay ended early. Nothing was billed.',
515
584
  });
516
585
 
517
586
  export const KNOWN_REJECTION_CODES = Object.freeze(Object.keys(REJECTION_CODES));
@@ -563,6 +632,12 @@ export function contractDocument() {
563
632
  referenceSymbols: [...REFERENCE_SYMBOLS],
564
633
  modes: MODES,
565
634
  limits: LIMITS,
635
+ // THE CEILINGS A CLIENT HAS TO KNOW BEFORE IT BUILDS A REQUEST. They are
636
+ // not in LIMITS because LIMITS describes the sandbox — what one strategy
637
+ // gets — and these describe what one RUN may ask for. A client that cannot
638
+ // read them discovers them as a 422 on the paid path.
639
+ maxBacktestDays: MAX_BACKTEST_DAYS,
640
+ maxMarketDays: BUDGET_CLAMP_MARKET_DAYS,
566
641
  rejectionCodes: REJECTION_CODES,
567
642
  };
568
643
  }
@@ -11,7 +11,7 @@
11
11
  import { venueOfPath } from './venue-path.mjs';
12
12
 
13
13
  /** Asset symbols we collect, longest-first so BNBUSDT matches before BNB. */
14
- const ASSETS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE', 'BNB', 'HYPE', 'ZEC'];
14
+ export const ASSETS = ['BTC', 'ETH', 'SOL', 'XRP', 'DOGE', 'BNB', 'HYPE', 'ZEC'];
15
15
 
16
16
  /** Datasets, as a customer would name them. */
17
17
  export const DATASETS = {
@@ -22,10 +22,12 @@ import { LANGUAGES, HOOK_NAMES, LIMITS } from '../../api/lib/backtest-contract.m
22
22
  import { CHANNEL, EXIT, parseTrade, parseFill, parseResult, parseOutputLine } from '../../runner/harness/protocol.mjs';
23
23
  import {
24
24
  countMarketDays, countStreams, buildCoverage, mergeReferenceRows, makeBookThrottle,
25
+ sortMarketsForReplay,
25
26
  } from '../../runner/events.mjs';
26
27
  import { loadSeries } from '../../runner/series-data.mjs';
27
28
  import { buildReport } from '../../runner/engine/report.mjs';
28
29
  import { buildArchive } from '../../runner/archive.mjs';
30
+ import { createLineWriter } from '../../runner/stdin-writer.mjs';
29
31
  import { loadLocalDay, localDays, looksLikeArchive } from '../local-data.mjs';
30
32
  import { readSubmission, validate } from '../ot.mjs';
31
33
 
@@ -71,29 +73,50 @@ function runHarness({
71
73
  }
72
74
  });
73
75
  child.on('error', reject);
74
- child.on('close', (code) => resolve({ code, stderr, lines, forged }));
76
+ child.on('close', (code) => {
77
+ // A short feed that still exited 0 is the dangerous case: the harness
78
+ // replayed whatever reached it, reported cleanly, and the report looks
79
+ // complete. It must not be resolved as a successful run. When the harness
80
+ // died first the pipe breaks as a CONSEQUENCE, and its own exit code and
81
+ // stderr say more than the EPIPE does — so let that path through
82
+ // unchanged and let the caller report the real failure.
83
+ if (streamError && code === EXIT.ok) { reject(streamError); return; }
84
+ resolve({ code, stderr, lines, forged });
85
+ });
75
86
 
76
- child.stdin.on('error', () => {});
77
- child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
78
- for (const m of markets) {
79
- // Series rows are INTERLEAVED into the same stream in event time, exactly
80
- // as the worker sends them and `lags` travels with them, or a signal
81
- // that declared a publication delay would be visible the instant its row
82
- // was stamped rather than when it could have existed.
83
- const lines = m.events.map((ev) => JSON.stringify(ev));
84
- const merged = seriesNames.length
85
- ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
86
- : lines;
87
- child.stdin.write(`${JSON.stringify({
88
- market: m.market,
89
- stream: m.stream,
90
- n: merged.length,
91
- ...(seriesNames.length ? { series: seriesNames } : {}),
92
- ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
93
- })}\n`);
94
- for (const line of merged) child.stdin.write(`${line}\n`);
95
- }
96
- child.stdin.end();
87
+ // THE SAME writer the queue uses (runner/stdin-writer.mjs). This loop used
88
+ // to ignore what write() returned and swallow every stdin error, so once
89
+ // the pipe's buffer filled the rows simply stopped arriving: a 289-market
90
+ // day came back as a 2-market report, exit 0, no warning. `ot run` and the
91
+ // worker have drifted eight times; sharing the writer is how this one stops
92
+ // being a ninth.
93
+ let streamError = null;
94
+ const write = createLineWriter(child.stdin);
95
+ (async () => {
96
+ await write(JSON.stringify({ ...job, outputKey }));
97
+ for (const m of markets) {
98
+ // Series rows are INTERLEAVED into the same stream in event time,
99
+ // exactly as the worker sends them — and `lags` travels with them, or a
100
+ // signal that declared a publication delay would be visible the instant
101
+ // its row was stamped rather than when it could have existed.
102
+ const lines = m.events.map((ev) => JSON.stringify(ev));
103
+ const merged = seriesNames.length
104
+ ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
105
+ : lines;
106
+ await write(JSON.stringify({
107
+ market: m.market,
108
+ stream: m.stream,
109
+ n: merged.length,
110
+ ...(seriesNames.length ? { series: seriesNames } : {}),
111
+ ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
112
+ }));
113
+ for (const line of merged) await write(line);
114
+ }
115
+ child.stdin.end();
116
+ })().catch((err) => {
117
+ streamError = err;
118
+ child.stdin.destroy();
119
+ });
97
120
  });
98
121
  }
99
122
 
@@ -126,11 +149,13 @@ function demux(lines) {
126
149
  export async function cmdRun({ dir, flags }) {
127
150
  const dataRoot = flags.data;
128
151
  if (!dataRoot) {
129
- throw new Error('--data is required: point it at a cloned sample archive\n'
130
- + ' git clone https://github.com/Ligengxin96/polymarket-data-samples');
152
+ throw new Error('--data is required: point it at an unpacked sample archive\n'
153
+ + ' curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz');
131
154
  }
132
155
  if (!await looksLikeArchive(dataRoot)) {
133
- throw new Error(`${path.resolve(dataRoot)} does not look like an archive — no recognisable data files under it`);
156
+ throw new Error(`${path.resolve(dataRoot)} does not look like an archive — no recognisable data files under it\n`
157
+ + ' the sample archive is a release download, not the git repository:\n'
158
+ + ' curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz');
134
159
  }
135
160
 
136
161
  const files = await readSubmission(dir);
@@ -242,6 +267,15 @@ export async function cmdRun({ dir, flags }) {
242
267
  markets.push(...loaded.markets);
243
268
  }
244
269
  if (markets.length === 0) throw new Error('no market-days could be read from that archive');
270
+ // SESSION IS ONE STREAM ACROSS THE RANGE, so it is ordered once over every
271
+ // day — the same thing fetchMarketDays does for the queue. Ordering it a day
272
+ // at a time leaves the stream day-major, which is chronological only by
273
+ // accident and stops being so as soon as two assets are in scope. Session
274
+ // shares one Portfolio across every market, so this is part of the ANSWER,
275
+ // not of the log.
276
+ if ((manifest.mode ?? 'market') === 'session') {
277
+ sortMarketsForReplay(markets, { mode: 'session' });
278
+ }
245
279
  // ONE ASSET ON ONE UTC DAY — the unit the queue bills in. `markets` is one
246
280
  // entry per market, and a day of BTC 15-minute markets is ninety-six of them,
247
281
  // so counting entries reported a run as being a hundred times bigger than the
@@ -331,6 +365,27 @@ export async function cmdRun({ dir, flags }) {
331
365
  }
332
366
 
333
367
  const base = passes[0];
368
+ // A SHORT REPLAY MUST FAIL EVEN WHEN NOTHING REPORTED AN ERROR.
369
+ //
370
+ // The backpressure bug produced exactly that shape: every write "succeeded",
371
+ // the harness exited 0, and 2 of 289 markets came back as a clean, complete
372
+ // looking report. Fixing the writer closes the cause we found; counting what
373
+ // came back is what catches the next one, whatever it turns out to be.
374
+ //
375
+ // `markets_run` is incremented by the harness only after a market is fully
376
+ // replayed, so on a clean exit it equals what was fed. A rejected or
377
+ // over-budget run never reaches here — those exit non-zero and are raised
378
+ // above with the sandbox's own reason, which says more than this count.
379
+ if (base.result.marketsRun < markets.length) {
380
+ const err = new Error(
381
+ `only ${base.result.marketsRun} of ${markets.length} market(s) were replayed —`
382
+ + ' the report would be incomplete, so none was written.'
383
+ + ' This usually means the feed to the runner was cut short.',
384
+ );
385
+ err.code = 'E_RUNTIME';
386
+ err.detail = err.message;
387
+ throw err;
388
+ }
334
389
  const marketMeta = new Map(markets.map((m) => [m.market.market_id, {
335
390
  market_id: m.market.market_id,
336
391
  asset: m.market.asset,
@@ -79,7 +79,16 @@ export async function cmdSubmit({ dir, flags }) {
79
79
  }
80
80
  process.stdout.write(` cost ${json.credits_held} cr\n`);
81
81
  process.stdout.write(` source sha256 ${String(json.source_sha256).slice(0, 16)}…\n\n`);
82
- process.stdout.write(` ot status ${json.run_id} (or wait for the email)\n\n`);
82
+ // DO NOT PROMISE THE EMAIL WHEN NONE WAS ASKED FOR. `--email` is what
83
+ // fills deliver_to, and without it the delivery poller correctly skips the
84
+ // run — so the old unconditional "(or wait for the email)" told every CLI
85
+ // submitter to wait for something that was never going to arrive. The flag
86
+ // was implemented and undocumented, which is the same failure from the
87
+ // other side: nobody could use the thing this line advertised.
88
+ process.stdout.write(flags.email
89
+ ? ` ot status ${json.run_id} (or wait for the email)\n\n`
90
+ : ` ot status ${json.run_id}\n`
91
+ + ' (no --email, so nothing will be sent — note that id down)\n\n');
83
92
  return 0;
84
93
  }
85
94
 
@@ -24,6 +24,7 @@ import {
24
24
  } from '../api/lib/backtest-datasets.mjs';
25
25
  import {
26
26
  indexMarkets, eventsFromRow, finaliseMarket, parseRow, buildSlugIndex, marketUnusable,
27
+ sortMarketsForReplay,
27
28
  makeBookThrottle,
28
29
  } from '../runner/events.mjs';
29
30
 
@@ -211,7 +212,11 @@ export async function loadLocalDay({ root, day, venue, assets, datasets, interva
211
212
  });
212
213
  }
213
214
  return {
214
- markets: out,
215
+ // THE SAME ORDER THE QUEUE FEEDS — see sortMarketsForReplay. A local replay
216
+ // that emitted its logs in a different order than the queue would break the
217
+ // one promise `ot run` makes: the identical files from the identical
218
+ // archive.
219
+ markets: sortMarketsForReplay(out),
215
220
  unusable,
216
221
  reason: out.length === 0 && unusable.length
217
222
  ? `${unusable.length} market(s) unusable: ${unusable[0].why}`
package/cli/ot.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // `ot` — the command line the SDK docs tell customers to use.
3
3
  //
4
4
  // ot check . validate, free, no data
5
- // ot run . --data ./polymarket-data-samples --date … replay locally
5
+ // ot run . --data ./polymarket-data-samples replay locally
6
6
  // ot submit . --assets btc,eth --from … --to … send it to the queue
7
7
  //
8
8
  // The one thing this file must get right is that `ot check` runs the SAME
@@ -41,6 +41,10 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
41
41
 
42
42
  ot submit <dir> --assets btc,eth --from <day> --to <day> [--venue polymarket]
43
43
  Send it to the queue. Needs OT_BACKTEST_KEY.
44
+ --email <address> have the finished report emailed to you. Without it
45
+ the run is only reachable from 'ot status', which
46
+ means remembering the id — and a queued run outlives
47
+ the terminal you started it from.
44
48
 
45
49
  ot status <run_id>
46
50
  Where a submitted run got to, and what it cost. Needs OT_BACKTEST_KEY.
@@ -53,7 +57,7 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
53
57
  --api <url> API base (default https://outcometick.com)
54
58
 
55
59
  Free sample data:
56
- git clone https://github.com/Ligengxin96/polymarket-data-samples
60
+ curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz
57
61
  `;
58
62
 
59
63
  /** Parse argv into {command, dir, flags}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "Strategy SDK and CLI for outcometick prediction-market backtests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,64 @@ const r4 = (x) => (Number.isFinite(x) ? Number(x.toFixed(4)) : null);
36
36
  */
37
37
  const collateralOf = (t) => (t.entry_px ?? 0) * (t.size ?? 0);
38
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
+
39
97
  /**
40
98
  * Headline metrics — the twelve cells at the top of the report.
41
99
  */
@@ -52,6 +110,8 @@ export function metrics(trades, { feesPaid = 0, days = 1 } = {}) {
52
110
  const wins = pnls.filter((p) => p > 0);
53
111
  const losses = pnls.filter((p) => p < 0);
54
112
  const collateral = sum(closed.map(collateralOf));
113
+ const peak = peakCapital(closed);
114
+ const hold = holdingRatio(closed);
55
115
 
56
116
  const equity = equityCurve(closed);
57
117
  const dd = maxDrawdown(equity.map((p) => p.equity));
@@ -91,7 +151,20 @@ export function metrics(trades, { feesPaid = 0, days = 1 } = {}) {
91
151
  // is only an entry rate for strategies that enter once — this one is an
92
152
  // entry rate for all of them, and equals `trades` in the common case.
93
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.
94
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),
95
168
  // Cents of edge per contract: what the outcome was worth minus what was
96
169
  // paid, averaged. This is the number that says whether there was an edge
97
170
  // at all, as opposed to a lucky run of variance.
package/runner/events.mjs CHANGED
@@ -619,8 +619,83 @@ export function countStreams(items) {
619
619
  * stream, or settled off an outcome we could not read, is invisible and makes
620
620
  * the report wrong.
621
621
  */
622
+ /**
623
+ * Put one day's markets in the order they will be replayed.
624
+ *
625
+ * ASSET, THEN OPENING TIME, THEN ID — and shared, because `ot run` and the
626
+ * queue both feed markets to a strategy and a difference here is a difference
627
+ * in what a reader sees from the same archive.
628
+ *
629
+ * The decoder produces market_id order, which is a hash and therefore
630
+ * unrelated to time. Nothing about a RESULT depends on it — state is reset at
631
+ * every on_market_open, which is what lets these be sharded — but `ctx.log`
632
+ * from every market lands in one stream, and a human reads that stream as a
633
+ * timeline. In hash order its timestamps jump hours in both directions for no
634
+ * visible reason.
635
+ *
636
+ * Asset before time so each market-day stays a CONTIGUOUS block:
637
+ * `marketDayPrefix` counts a market-day done when its last market is done, and
638
+ * interleaving two assets pushes both of their last markets to the end of the
639
+ * run — a progress bar that sits still and then jumps, which is the "looks
640
+ * stuck" this channel exists to remove.
641
+ *
642
+ * The id breaks ties because many strikes open at the same instant, and an
643
+ * unstable order would make two runs of the same strategy over the same days
644
+ * emit their logs differently.
645
+ */
646
+ export function sortMarketsForReplay(markets, { mode = 'market' } = {}) {
647
+ const byId = (a, b) => {
648
+ const ai = String(a.market?.market_id ?? '');
649
+ const bi = String(b.market?.market_id ?? '');
650
+ return ai < bi ? -1 : (ai > bi ? 1 : 0);
651
+ };
652
+ const byTime = (a, b) => (a.market?.open_ts_ms ?? 0) - (b.market?.open_ts_ms ?? 0);
653
+
654
+ // SESSION MODE IS ONE STREAM, so it is ordered by time and by nothing else.
655
+ //
656
+ // Session shares one instance and one Portfolio across every market in the
657
+ // range — the docs call it "one ordered stream across the range" — which
658
+ // makes the feed order part of the RESULT, not just of the log. Its equity
659
+ // curve, its position and its P&L accumulate in whatever order markets
660
+ // arrive. Feeding it asset-major would build that curve by walking all of
661
+ // BTC and then going back in time to walk all of ETH: not a sequence that
662
+ // ever happened, and not a number anyone can act on.
663
+ //
664
+ // Before this function existed, session got market_id order — a hash. The
665
+ // "ordered stream" in the docs was ordered by nothing at all.
666
+ if (mode === 'session') return markets.sort((a, b) => byTime(a, b) || byId(a, b));
667
+
668
+ // Market mode: each market is independent (state resets at every
669
+ // on_market_open), so the order changes no result — only what a human reads.
670
+ // Asset-major keeps each market-day a CONTIGUOUS block, which is what
671
+ // marketDayPrefix needs: it counts a market-day done when its LAST market is
672
+ // done, and interleaving assets pushes every asset's last market to the end
673
+ // of the run — a progress bar that sits still and then jumps.
674
+ return markets.sort((a, b) => {
675
+ const aa = a.market?.asset ?? '';
676
+ const ba = b.market?.asset ?? '';
677
+ if (aa !== ba) return aa < ba ? -1 : 1;
678
+ return byTime(a, b) || byId(a, b);
679
+ });
680
+ }
681
+
622
682
  export function marketUnusable(market, inWindow) {
623
683
  if (!market) return 'no market metadata';
684
+ // WHICH ASSET IS THIS? Every layer above needs the answer and none of them
685
+ // can work it out later: the billing key is `asset|day|interval`, the book
686
+ // cadence is chosen per asset, and — since the decoded day became per-asset —
687
+ // a market with no asset belongs to no cache entry in particular. Predict
688
+ // publishes ONE venue-wide markets file, and the per-asset row filter is
689
+ // `if (m.asset && …)`, so a row missing its category lands in every asset's
690
+ // read at once, each carrying a different slice of its events under the same
691
+ // name. Keeping any one of those replays a market with half its data and
692
+ // nothing to say so.
693
+ //
694
+ // Decided HERE because this is the one function both the queue and `ot run`
695
+ // ask. The first version of this rule lived in the queue's merge step, which
696
+ // meant the local runner kept the market, and a single-asset run — which has
697
+ // nothing to merge — replayed it out of the cache anyway.
698
+ if (!market.asset) return 'market has no asset';
624
699
  if (market.stream == null) return 'settlement stream could not be resolved';
625
700
  if (market.outcome !== 'UP' && market.outcome !== 'DOWN') {
626
701
  return 'outcome could not be read';
@@ -526,7 +526,34 @@ async function main() {
526
526
  + ' ctx.log output was dropped. ctx.log is for reading, not for'
527
527
  + ' exporting; see the SDK docs for the limit.\n');
528
528
  }
529
- for (const line of out.logs) logsOut.write(`${entry.market.market_id} ${line}\n`);
529
+ // OPENING TIME FIRST, then a short id.
530
+ //
531
+ // The prefix used to be the full 64-character condition hash, which
532
+ // identifies a market to the venue and to nobody reading a log: there is
533
+ // no way to tell from it which market this was, or when. The opening
534
+ // time is the thing a person actually navigates by — it is what the
535
+ // venue puts in the slug — and eight characters of the hash still
536
+ // separate the several strikes that open at the same instant.
537
+ //
538
+ // MUST MATCH THE PYTHON HARNESS. Two log formats from one archive is the
539
+ // kind of divergence the conformance suite exists to catch.
540
+ // A market with no opening time keeps the id alone rather than gaining a
541
+ // leading space — every consumer of this file splits on whitespace, and
542
+ // a blank first field shifts all of them by one.
543
+ const shortId = String(entry.market.market_id ?? '').slice(0, 10);
544
+ // READABLE, because ctx.log already puts the EVENT time on every line as
545
+ // epoch millis. Two bare 13-digit numbers side by side are two numbers
546
+ // nobody can tell apart — and the one this prefix exists for is the one
547
+ // that would be mistaken for the other.
548
+ //
549
+ // UTC, to the minute: the market schedule is published in UTC and a
550
+ // market-day is a UTC day, so a local rendering would file a row under a
551
+ // different date than the archive does.
552
+ const openedAt = entry.market.open_ts_ms == null
553
+ ? null
554
+ : new Date(entry.market.open_ts_ms).toISOString().slice(0, 16).replace('T', ' ');
555
+ const prefix = openedAt == null ? shortId : `${openedAt} ${shortId}`;
556
+ for (const line of out.logs) logsOut.write(`${prefix} ${line}\n`);
530
557
  for (const c of out.crosschecks) result.crosschecks.push(c);
531
558
 
532
559
  // Tracked as the stream went past rather than scanned afterwards: there
@@ -42,7 +42,10 @@ export class Order {
42
42
  if (!SIDES.includes(side)) {
43
43
  throw new Error(`side must be "UP" or "DOWN", got ${JSON.stringify(side)}`);
44
44
  }
45
- // SIZE IN MONEY, converted here rather than in the engine.
45
+ // SIZING IN MONEY this is about the `notional` field, converted here
46
+ // rather than in the engine. `size` is CONTRACTS (see OrderSizing in
47
+ // index.d.ts); it is not money, and reading this heading as if it were
48
+ // is the one wrong turn this comment can cause.
46
49
  //
47
50
  // Position sizing is nearly always a budget, not a contract count, and the
48
51
  // conversion has exactly one honest divisor: your own limit. A contract
@@ -18,6 +18,7 @@ import hmac
18
18
  import importlib.util
19
19
  import json
20
20
  import os
21
+ import datetime as _datetime
21
22
  import sys
22
23
 
23
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -447,7 +448,19 @@ def main() -> int:
447
448
  " run's ctx.log output was dropped. ctx.log is for reading,"
448
449
  " not for exporting; see the SDK docs for the limit.")
449
450
  for line in out["logs"]:
450
- logs_fh.write(f'{entry["market"]["market_id"]} {line}\n')
451
+ # 开盘时间在前,短 id 在后 —— 见 node harness 里的同一处注释。
452
+ # 两个 harness 的日志格式必须一致。
453
+ _open = entry["market"].get("open_ts_ms")
454
+ _sid = str(entry["market"].get("market_id") or "")[:10]
455
+ # 格式化成可读的 UTC —— ctx.log 已经在每行放了事件时间(毫秒数),
456
+ # 两个 13 位裸数字挨在一起没人分得清。见 node harness 的注释。
457
+ # 没有开盘时间时只留 id,不要留一个前导空格。
458
+ if _open is None:
459
+ _prefix = _sid
460
+ else:
461
+ _dt = _datetime.datetime.fromtimestamp(_open / 1000, _datetime.timezone.utc)
462
+ _prefix = f"{_dt.strftime('%Y-%m-%d %H:%M')} {_sid}"
463
+ logs_fh.write(f'{_prefix} {line}\n')
451
464
  result["crosschecks"].extend(out["crosschecks"])
452
465
 
453
466
  # Tracked as the stream went past; there is no array left to scan.
@@ -46,7 +46,11 @@ class Order:
46
46
  tif="ioc", tag=None, notional=None):
47
47
  if side not in SIDES:
48
48
  raise ValueError(f'side must be "UP" or "DOWN", got {side!r}')
49
- # SIZE IN MONEY. Mirrors index.mjs exactly -- see the reasoning there.
49
+ # SIZING IN MONEY -- this is about the `notional` argument below.
50
+ # `size` is CONTRACTS (see OrderSizing in index.d.ts); it is not
51
+ # money, and reading this heading as if it were is the one wrong
52
+ # turn this comment can cause. Mirrors index.mjs exactly -- see
53
+ # the reasoning there.
50
54
  # Position sizing is nearly always a budget, and the only honest
51
55
  # divisor is your own limit: a contract costs whatever it fills at, so
52
56
  # dividing by the current best price overspends the moment there is any
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Backpressure-aware line writer for a child process's stdin.
3
+ *
4
+ * Both the queued worker and `ot run` push an entire archive down one pipe —
5
+ * tens of millions of newline-framed rows for a large run. Ignoring what
6
+ * `write()` returns does not merely buffer: past the pipe's high-water mark the
7
+ * rows stop reaching the harness, and because a strategy that exits early
8
+ * closes the pipe under us, the failure arrives as an error on a stream nobody
9
+ * is listening to. The result is a run that replays the first fraction of its
10
+ * markets, exits 0, and produces a report that looks complete.
11
+ *
12
+ * That is not hypothetical: `ot run` did exactly this. It wrote 6.04M rows in a
13
+ * synchronous loop with `stdin.on('error', () => {})`, and a 289-market day
14
+ * came back as a 2-market report with no warning at all.
15
+ *
16
+ * Two details here are paid for in incidents and must not be simplified away:
17
+ *
18
+ * 1. ONE error listener for the whole stream, not one per line. A `once`
19
+ * added per write and never removed is millions of live listeners, and the
20
+ * writer dies of its own bookkeeping partway through a run.
21
+ * 2. An error has to settle a PENDING DRAIN. If the pipe breaks while we are
22
+ * parked waiting for one, the drain never arrives, the promise never
23
+ * settles, and the consumer waits for input that is not coming — burning
24
+ * the whole wall clock instead of failing in the second it broke.
25
+ */
26
+
27
+ /**
28
+ * @param {import('node:stream').Writable} stream
29
+ * @returns {(line: string) => Promise<void>} resolves once the line is accepted
30
+ */
31
+ export function createLineWriter(stream) {
32
+ let writeError = null;
33
+ let wakeDrain = null;
34
+
35
+ stream.on('error', (err) => {
36
+ writeError = err;
37
+ const wake = wakeDrain;
38
+ wakeDrain = null;
39
+ if (wake) wake();
40
+ });
41
+
42
+ return (line) => new Promise((resolve, reject) => {
43
+ if (writeError) { reject(writeError); return; }
44
+ // Sequential by contract, and it says so rather than corrupting quietly.
45
+ // `wakeDrain` is a single slot: a second concurrent call would overwrite
46
+ // the first one's continuation, so that write would never settle and its
47
+ // line could interleave into the middle of another. Both callers await
48
+ // every line, and the framing (a market header, then that market's rows)
49
+ // only means anything in order — so this is a programming error, not a
50
+ // case to support.
51
+ if (wakeDrain) {
52
+ reject(new Error('createLineWriter: concurrent write; lines must be awaited one at a time'));
53
+ return;
54
+ }
55
+ // Respect backpressure: ignoring the return of write() is the whole bug.
56
+ if (stream.write(`${line}\n`)) { resolve(); return; }
57
+ wakeDrain = () => {
58
+ stream.removeListener('drain', onDrain);
59
+ if (writeError) reject(writeError);
60
+ else resolve();
61
+ };
62
+ function onDrain() {
63
+ const wake = wakeDrain;
64
+ wakeDrain = null;
65
+ if (wake) wake();
66
+ }
67
+ stream.once('drain', onDrain);
68
+ });
69
+ }