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 +3 -3
- package/api/lib/backtest-contract.mjs +7 -1
- package/cli/commands/run.mjs +70 -25
- package/cli/ot.mjs +2 -2
- package/package.json +1 -1
- package/runner/stdin-writer.mjs +69 -0
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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));
|
package/cli/commands/run.mjs
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
131
|
-
+ '
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|