outcometick 1.6.2 → 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 5c26d7ed75e2172fff108af183089aa1460652ba.
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.2';
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.
@@ -575,6 +575,12 @@ export const REJECTION_CODES = Object.freeze({
575
575
  E_COVERAGE: 'A captured stream was requested outside the window it was captured in.',
576
576
  E_LIMIT: 'A submission limit was exceeded — file count, total source size or series size.',
577
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.',
578
584
  });
579
585
 
580
586
  export const KNOWN_REJECTION_CODES = Object.freeze(Object.keys(REJECTION_CODES));
@@ -27,6 +27,7 @@ import {
27
27
  import { loadSeries } from '../../runner/series-data.mjs';
28
28
  import { buildReport } from '../../runner/engine/report.mjs';
29
29
  import { buildArchive } from '../../runner/archive.mjs';
30
+ import { createLineWriter } from '../../runner/stdin-writer.mjs';
30
31
  import { loadLocalDay, localDays, looksLikeArchive } from '../local-data.mjs';
31
32
  import { readSubmission, validate } from '../ot.mjs';
32
33
 
@@ -72,29 +73,50 @@ function runHarness({
72
73
  }
73
74
  });
74
75
  child.on('error', reject);
75
- 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
+ });
76
86
 
77
- child.stdin.on('error', () => {});
78
- child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
79
- for (const m of markets) {
80
- // Series rows are INTERLEAVED into the same stream in event time, exactly
81
- // as the worker sends them and `lags` travels with them, or a signal
82
- // that declared a publication delay would be visible the instant its row
83
- // was stamped rather than when it could have existed.
84
- const lines = m.events.map((ev) => JSON.stringify(ev));
85
- const merged = seriesNames.length
86
- ? mergeReferenceRows(lines, seriesRows, m.market, 'ext', seriesLags)
87
- : lines;
88
- child.stdin.write(`${JSON.stringify({
89
- market: m.market,
90
- stream: m.stream,
91
- n: merged.length,
92
- ...(seriesNames.length ? { series: seriesNames } : {}),
93
- ...(Object.keys(seriesLags).length ? { lags: seriesLags } : {}),
94
- })}\n`);
95
- for (const line of merged) child.stdin.write(`${line}\n`);
96
- }
97
- 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
+ });
98
120
  });
99
121
  }
100
122
 
@@ -127,11 +149,13 @@ function demux(lines) {
127
149
  export async function cmdRun({ dir, flags }) {
128
150
  const dataRoot = flags.data;
129
151
  if (!dataRoot) {
130
- throw new Error('--data is required: point it at a cloned sample archive\n'
131
- + ' 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');
132
154
  }
133
155
  if (!await looksLikeArchive(dataRoot)) {
134
- 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');
135
159
  }
136
160
 
137
161
  const files = await readSubmission(dir);
@@ -341,6 +365,27 @@ export async function cmdRun({ dir, flags }) {
341
365
  }
342
366
 
343
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
+ }
344
389
  const marketMeta = new Map(markets.map((m) => [m.market.market_id, {
345
390
  market_id: m.market.market_id,
346
391
  asset: m.market.asset,
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
@@ -57,7 +57,7 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
57
57
  --api <url> API base (default https://outcometick.com)
58
58
 
59
59
  Free sample data:
60
- 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
61
61
  `;
62
62
 
63
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.2",
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",
@@ -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
+ }