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 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 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.1';
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.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
  // ---------------------------------------------------------------------------
@@ -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 = {
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
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