outcometick 1.6.1 → 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 +1 -1
- package/api/lib/backtest-contract.mjs +72 -3
- package/api/lib/data-taxonomy.mjs +1 -1
- package/cli/commands/run.mjs +10 -0
- package/cli/commands/submit.mjs +10 -1
- package/cli/local-data.mjs +6 -1
- package/cli/ot.mjs +4 -0
- package/package.json +1 -1
- package/runner/engine/report.mjs +73 -0
- package/runner/events.mjs +75 -0
- package/runner/harness/node/harness.mjs +28 -1
- package/runner/harness/node/sdk/index.mjs +4 -1
- package/runner/harness/python/harness.py +14 -1
- package/runner/harness/python/outcometick.py +5 -1
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 5c26d7ed75e2172fff108af183089aa1460652ba.
|
|
9
9
|
-->
|
|
10
10
|
|
|
11
11
|
# outcometick
|
|
@@ -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.2';
|
|
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.
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -563,6 +626,12 @@ export function contractDocument() {
|
|
|
563
626
|
referenceSymbols: [...REFERENCE_SYMBOLS],
|
|
564
627
|
modes: MODES,
|
|
565
628
|
limits: LIMITS,
|
|
629
|
+
// THE CEILINGS A CLIENT HAS TO KNOW BEFORE IT BUILDS A REQUEST. They are
|
|
630
|
+
// not in LIMITS because LIMITS describes the sandbox — what one strategy
|
|
631
|
+
// gets — and these describe what one RUN may ask for. A client that cannot
|
|
632
|
+
// read them discovers them as a 422 on the paid path.
|
|
633
|
+
maxBacktestDays: MAX_BACKTEST_DAYS,
|
|
634
|
+
maxMarketDays: BUDGET_CLAMP_MARKET_DAYS,
|
|
566
635
|
rejectionCodes: REJECTION_CODES,
|
|
567
636
|
};
|
|
568
637
|
}
|
|
@@ -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 = {
|
package/cli/commands/run.mjs
CHANGED
|
@@ -22,6 +22,7 @@ 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';
|
|
@@ -242,6 +243,15 @@ export async function cmdRun({ dir, flags }) {
|
|
|
242
243
|
markets.push(...loaded.markets);
|
|
243
244
|
}
|
|
244
245
|
if (markets.length === 0) throw new Error('no market-days could be read from that archive');
|
|
246
|
+
// SESSION IS ONE STREAM ACROSS THE RANGE, so it is ordered once over every
|
|
247
|
+
// day — the same thing fetchMarketDays does for the queue. Ordering it a day
|
|
248
|
+
// at a time leaves the stream day-major, which is chronological only by
|
|
249
|
+
// accident and stops being so as soon as two assets are in scope. Session
|
|
250
|
+
// shares one Portfolio across every market, so this is part of the ANSWER,
|
|
251
|
+
// not of the log.
|
|
252
|
+
if ((manifest.mode ?? 'market') === 'session') {
|
|
253
|
+
sortMarketsForReplay(markets, { mode: 'session' });
|
|
254
|
+
}
|
|
245
255
|
// ONE ASSET ON ONE UTC DAY — the unit the queue bills in. `markets` is one
|
|
246
256
|
// entry per market, and a day of BTC 15-minute markets is ninety-six of them,
|
|
247
257
|
// so counting entries reported a run as being a hundred times bigger than the
|
package/cli/commands/submit.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/cli/local-data.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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.
|
package/package.json
CHANGED
package/runner/engine/report.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|