outcometick 1.5.2 → 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.
@@ -14,12 +14,19 @@
14
14
  import { createReadStream } from 'node:fs';
15
15
  import { readdir, stat } from 'node:fs/promises';
16
16
  import { createInterface } from 'node:readline';
17
+ import { pipeline } from 'node:stream/promises';
17
18
  import { createGunzip } from 'node:zlib';
18
19
  import path from 'node:path';
19
20
 
20
21
  import { classifyPath } from '../api/lib/data-taxonomy.mjs';
21
- import { archiveDatasetsFor, fileMatchesRun } from '../api/lib/backtest-datasets.mjs';
22
- import { indexMarkets, eventsFromRow, finaliseMarket, parseRow } from '../runner/events.mjs';
22
+ import {
23
+ archiveDatasetsFor, fileMatchesRun, normalizeIntervals, settlementPathsFor, orderedFeed,
24
+ } from '../api/lib/backtest-datasets.mjs';
25
+ import {
26
+ indexMarkets, eventsFromRow, finaliseMarket, parseRow, buildSlugIndex, marketUnusable,
27
+ sortMarketsForReplay,
28
+ makeBookThrottle,
29
+ } from '../runner/events.mjs';
23
30
 
24
31
  /**
25
32
  * Every file under a directory, as archive-relative paths.
@@ -59,7 +66,23 @@ async function walk(root, prefix = '', depth = 0) {
59
66
  async function* readRows(root, rel) {
60
67
  const full = path.join(root, rel);
61
68
  const raw = createReadStream(full);
62
- const stream = rel.endsWith('.gz') ? raw.pipe(createGunzip()) : raw;
69
+ // `pipeline`, not `raw.pipe(...)`, for the same reason the queue's reader
70
+ // uses it: `.pipe()` does not forward errors, so a failure on `raw` — a
71
+ // truncated file, a disk that went away mid-read — emits 'error' on a stream
72
+ // nobody is listening to, which in Node is process death rather than an
73
+ // exception. That exact shape killed the worker in production; the risk is
74
+ // lower on a local file, but the wrong pattern is not worth keeping a second
75
+ // copy of.
76
+ let stream = raw;
77
+ if (rel.endsWith('.gz')) {
78
+ const gunzip = createGunzip();
79
+ stream = gunzip;
80
+ pipeline(raw, gunzip).catch((err) => {
81
+ if (!gunzip.destroyed) gunzip.destroy(err);
82
+ });
83
+ } else {
84
+ raw.on('error', () => {});
85
+ }
63
86
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
64
87
 
65
88
  const isCsv = rel.includes('.csv');
@@ -91,11 +114,15 @@ export function dayOfPath(rel) {
91
114
  * Returns the same shape fetchMarketDays does, so `ot run` and the worker feed
92
115
  * the harness identically.
93
116
  */
94
- export async function loadLocalDay({ root, day, venue, assets, datasets }) {
117
+ export async function loadLocalDay({ root, day, venue, assets, datasets, intervals, throttle = null }) {
95
118
  const archiveDatasets = archiveDatasetsFor({ datasets, venue, from: day, to: day });
119
+ // Same normalisation, same default, same two filters as the queue. `ot run`
120
+ // promises the identical files and checksums; an interval narrowing applied
121
+ // on one side only would break that on the very first 15m market.
122
+ const wantIntervals = normalizeIntervals(intervals ?? null);
96
123
  const all = await walk(root);
97
124
  const wanted = all.filter((rel) => dayOfPath(rel) === day
98
- && fileMatchesRun(rel, { venue, assets, archiveDatasets }));
125
+ && fileMatchesRun(rel, { venue, assets, archiveDatasets, intervals: wantIntervals }));
99
126
 
100
127
  if (wanted.length === 0) {
101
128
  return { markets: [], reason: `no files for ${day} under ${root}` };
@@ -107,16 +134,36 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
107
134
  for (const rel of wanted.filter((r) => classifyPath(r).dataset === 'markets')) {
108
135
  for await (const row of readRows(root, rel)) marketRows.push(row);
109
136
  }
110
- const markets = indexMarkets(marketRows);
137
+ // Same normalisation the queue uses. `ot run` promising "the identical files,
138
+ // same checksums" only holds while both sides decode the archive identically,
139
+ // so the venue has to reach the decoder here too.
140
+ const indexed = indexMarkets(marketRows, { venue });
141
+ const inScope = new Set((assets ?? []).map((a) => String(a).toUpperCase()));
142
+ const wantIv = new Set(wantIntervals.map(String));
143
+ const markets = new Map();
144
+ for (const [id, m] of indexed) {
145
+ if (m.asset && inScope.size && !inScope.has(String(m.asset).toUpperCase())) continue;
146
+ if (m.interval && wantIv.size && !wantIv.has(String(m.interval))) continue;
147
+ markets.set(id, m);
148
+ }
111
149
  if (markets.size === 0) {
112
150
  return { markets: [], reason: `no market metadata for ${day}` };
113
151
  }
152
+ const bySlug = buildSlugIndex(markets);
153
+
154
+ // The settlement files these markets need, from the SAME function the queue
155
+ // uses. Selecting them here separately is how "it runs locally but not in the
156
+ // queue" happens — and it had already happened: without this, a strategy
157
+ // asking for `prices` read nothing at all locally while the queue produced a
158
+ // report, off the identical archive.
159
+ const feed = orderedFeed([...wanted, ...settlementPathsFor(markets.values(),
160
+ all.filter((rel) => dayOfPath(rel) === day), { venue, assets, already: wanted })]);
114
161
 
115
162
  const byMarket = new Map();
116
- for (const rel of wanted) {
163
+ for (const rel of feed) {
117
164
  if (classifyPath(rel).dataset === 'markets') continue;
118
165
  for await (const row of readRows(root, rel)) {
119
- for (const [id, ev] of eventsFromRow(rel, row, markets)) {
166
+ for (const [id, ev] of eventsFromRow(rel, row, markets, bySlug, throttle)) {
120
167
  if (!markets.has(id)) continue;
121
168
  let list = byMarket.get(id);
122
169
  if (!list) { list = []; byMarket.set(id, list); }
@@ -126,13 +173,27 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
126
173
  }
127
174
 
128
175
  const out = [];
129
- for (const [marketId, events] of byMarket) {
176
+ const unusable = [];
177
+ // EVERY market in the metadata, exactly as the worker does. Iterating only
178
+ // the ones that produced events skipped the emptiest case — a market that
179
+ // exists in the archive and decodes to nothing — which is precisely the gap
180
+ // this is here to expose, and skipping it locally would put the divergence
181
+ // back after it had just been removed.
182
+ for (const marketId of markets.keys()) {
130
183
  const market = markets.get(marketId);
131
- // Fail-closed, exactly as the worker does: a market whose settlement stream
132
- // we cannot read is dropped, not guessed at.
133
- if (!market || market.stream == null) continue;
134
- const { events: inWindow, up_px, down_px } = finaliseMarket(events, market);
135
- if (inWindow.length === 0) continue;
184
+ const events = byMarket.get(marketId) ?? [];
185
+ const { events: inWindow, up_px, down_px } = market
186
+ ? finaliseMarket(events, market)
187
+ : { events: [], up_px: null, down_px: null };
188
+ // THE SAME predicate the queue applies, not a local copy of it. A rule that
189
+ // lives in one reader and not the other is how `ot run` ends up replaying a
190
+ // market the queue drops — and a market-making strategy, which never reads
191
+ // the settlement price, is precisely the case that would never notice.
192
+ const why = marketUnusable(market, inWindow);
193
+ if (why) {
194
+ unusable.push({ market_id: marketId, asset: market?.asset ?? null, day, why });
195
+ continue;
196
+ }
136
197
  out.push({
137
198
  market: {
138
199
  market_id: market.market_id,
@@ -150,7 +211,17 @@ export async function loadLocalDay({ root, day, venue, assets, datasets }) {
150
211
  down_px,
151
212
  });
152
213
  }
153
- return { markets: out, reason: null };
214
+ return {
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),
220
+ unusable,
221
+ reason: out.length === 0 && unusable.length
222
+ ? `${unusable.length} market(s) unusable: ${unusable[0].why}`
223
+ : null,
224
+ };
154
225
  }
155
226
 
156
227
  /** Days a local archive appears to hold, sorted. */
package/cli/ot.mjs CHANGED
@@ -35,9 +35,16 @@ const USAGE = `ot ${SDK_VERSION} — outcometick strategy tools
35
35
  ot run <dir> --data <archive> [--date <YYYY-MM-DD>] [--out <file>]
36
36
  Replay locally against a cloned sample archive, using the same engine
37
37
  the queue uses. Writes a report archive.
38
+ Refused if the manifest declares a reference feed: those come from an
39
+ archive held on the worker, so a local replay would hand your strategy
40
+ empty ones. Your own CSV series work locally.
38
41
 
39
42
  ot submit <dir> --assets btc,eth --from <day> --to <day> [--venue polymarket]
40
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.
41
48
 
42
49
  ot status <run_id>
43
50
  Where a submitted run got to, and what it cost. Needs OT_BACKTEST_KEY.
@@ -96,8 +103,15 @@ export async function readSubmission(dir) {
96
103
  if (!/\.(py|mjs|js|json|csv)$/.test(e.name)) continue;
97
104
  const full = path.join(dir, name);
98
105
  const s = await stat(full);
99
- if (s.size > LIMITS.maxTotalSourceBytes) {
100
- throw new Error(`${name} is ${s.size} bytes, over the ${LIMITS.maxTotalSourceBytes} byte submission limit`);
106
+ // The ceiling for ANY one file is the series limit, not the source limit:
107
+ // a CSV series is allowed to be much larger than the code, and the shared
108
+ // validator is what enforces which budget a given file falls under.
109
+ // Refusing a 1MB CSV here meant `ot check` rejected a submission the API
110
+ // accepts — and "a local pass is not rejected on submit" is a promise the
111
+ // docs make.
112
+ const ceiling = Math.max(LIMITS.maxTotalSourceBytes, LIMITS.maxSeriesBytes);
113
+ if (s.size > ceiling) {
114
+ throw new Error(`${name} is ${s.size} bytes, over the ${ceiling} byte limit for a single file`);
101
115
  }
102
116
  out.push({ name, content: await readFile(full, 'utf8') });
103
117
  }
@@ -153,10 +167,19 @@ async function cmdCheck({ dir, flags }) {
153
167
  process.stdout.write(`\n ok — ${manifest.language}, entry ${manifest.entry.file}:${manifest.entry.className}\n`);
154
168
  process.stdout.write(` hooks ${Object.entries(res.hookNames).map(([k, v]) => `${k} → ${v}`).join(', ')}\n`);
155
169
  process.stdout.write(` datasets ${manifest.datasets.join(', ')}\n`);
170
+ // Both of these change what comes back, so `ot check` has to show them:
171
+ // this command exists to say what the queue will do with this submission,
172
+ // and a run narrowed to 5m at a 250ms fill delay is a different answer to
173
+ // the same strategy.
174
+ process.stdout.write(` intervals ${manifest.intervals.join(', ')}\n`);
175
+ process.stdout.write(` delay ${manifest.latency ? `${manifest.latency} ms` : 'none'}\n`);
156
176
  if (manifest.reference.length) process.stdout.write(` reference ${manifest.reference.join(', ')}\n`);
157
177
  process.stdout.write(` files ${res.files.length} / ${LIMITS.maxFiles} · ${(res.totalBytes / 1024).toFixed(1)} / ${LIMITS.maxTotalSourceBytes / 1024} KB\n`);
158
178
  if (manifest.mode === 'session') {
159
- process.stdout.write(' mode session bills at 3× the market-day rate\n');
179
+ // NOT "3x the market-day rate". That multiplier was deleted, and this was
180
+ // its third hiding place after both i18n dictionaries — the guard that
181
+ // caught the other two only scans lib/backtest-i18n.ts.
182
+ process.stdout.write(' mode session — same price as market mode\n');
160
183
  }
161
184
  process.stdout.write('\n');
162
185
  return 0;
package/index.d.ts CHANGED
@@ -132,10 +132,37 @@ export interface Ctx<P = Record<string, unknown>> {
132
132
  assert_outcome(market: unknown, outcome: Side): void;
133
133
  }
134
134
 
135
- export interface OrderInit {
135
+ /**
136
+ * Size an order in contracts, or in money.
137
+ *
138
+ * A union rather than two optional fields, so `{ size, notional }` together is
139
+ * a compile error rather than a run-time rejection: they answer the same
140
+ * question two ways and there is no sensible reading of both.
141
+ */
142
+ export type OrderSizing =
143
+ | {
144
+ /** Contracts. Must be positive. */
145
+ size: number;
146
+ notional?: never;
147
+ }
148
+ | {
149
+ size?: never;
150
+ /**
151
+ * Spend at most this much, converted to contracts as
152
+ * `floor(notional / limit)`.
153
+ *
154
+ * REQUIRES `limit`, which is why this arm makes it non-optional: a
155
+ * contract costs whatever it fills at and a marketable order walks the
156
+ * book, so dividing by the current best price overspends the moment there
157
+ * is any slippage. The limit is the price you have already said you will
158
+ * not exceed, which is what makes "at most" true.
159
+ */
160
+ notional: number;
161
+ limit: number;
162
+ };
163
+
164
+ export type OrderInit = OrderSizing & {
136
165
  side: Side;
137
- /** Contracts. Must be positive. */
138
- size: number;
139
166
  /**
140
167
  * A bound in whichever direction protects you: a ceiling when opening, a
141
168
  * floor when reducing. Must be within [0, 1] — a binary outcome token
@@ -149,7 +176,7 @@ export interface OrderInit {
149
176
  /** Only 'ioc' is modelled; anything else is rejected at construction. */
150
177
  tif?: 'ioc';
151
178
  tag?: string | null;
152
- }
179
+ };
153
180
 
154
181
  /**
155
182
  * An order a hook returns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.5.2",
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",
@@ -123,10 +123,26 @@ function csvField(v) {
123
123
  }
124
124
 
125
125
  /** Rows to CSV with a fixed column order, so a diff between runs is meaningful. */
126
+ /**
127
+ * A UTF-8 byte-order mark.
128
+ *
129
+ * Excel opens a .csv as the system ANSI code page unless the file says
130
+ * otherwise, and the only thing it accepts as saying otherwise is this. The
131
+ * symptom was a calibration bucket reading `0.30 釴?0.40` — an en dash, three
132
+ * UTF-8 bytes, read as GBK. That is not a Chinese-locale problem: it is every
133
+ * non-ASCII byte in every one of these files, including the `tag` a strategy
134
+ * puts on its own fills, which we do not control at all.
135
+ *
136
+ * Parsers that do not expect it see one stray character on the first header;
137
+ * `encoding='utf-8-sig'` is the standard remedy. A spreadsheet that mangles
138
+ * the whole file is the worse failure, and it is the one that was happening.
139
+ */
140
+ const BOM = '\uFEFF';
141
+
126
142
  export function toCsv(rows, columns) {
127
143
  const out = [columns.join(',')];
128
144
  for (const row of rows) out.push(columns.map((c) => csvField(row[c])).join(','));
129
- return `${out.join('\n')}\n`;
145
+ return `${BOM}${out.join('\n')}\n`;
130
146
  }
131
147
 
132
148
  const TRADE_COLUMNS = [
@@ -141,14 +157,17 @@ const FILL_COLUMNS = [
141
157
  /**
142
158
  * Assemble the archive a customer downloads.
143
159
  *
144
- * The submitted source goes IN, deliberately: a report that cannot be tied back
145
- * to the exact code that produced it is not reproducible, and "which version of
146
- * my strategy was this?" is the first question anyone asks a week later.
160
+ * THE SOURCE IS NOT IN IT. It used to be, so that a report could be tied back
161
+ * to the exact code that produced it "which version of my strategy was
162
+ * this?" is the first question anyone asks a week later. That question is now
163
+ * answered by `source_sha256` in report.json instead: the same identification,
164
+ * without handing back a copy of the code. Shipping the strategy inside the
165
+ * deliverable made a report something you cannot forward to anyone.
147
166
  *
148
167
  * sha256sums.txt covers every other entry, so the whole thing is verifiable
149
168
  * without trusting the transport.
150
169
  */
151
- export async function buildArchive({ runId, report, trades, fills, logs, source }) {
170
+ export async function buildArchive({ runId, report, trades, fills, logs }) {
152
171
  const entries = [
153
172
  { name: 'report.json', data: `${JSON.stringify(report, null, 2)}\n` },
154
173
  { name: 'trades.csv', data: toCsv(trades, TRADE_COLUMNS) },
@@ -158,18 +177,10 @@ export async function buildArchive({ runId, report, trades, fills, logs, source
158
177
  name: 'calibration.csv',
159
178
  data: toCsv(report.calibration ?? [], ['bucket', 'implied', 'realized', 'edge_cents', 'trades']),
160
179
  },
161
- {
162
- name: 'latency.csv',
163
- data: toCsv(report.latency ?? [], ['label', 'delay_ms', 'net_pnl', 'ratio', 'unprofitable']),
164
- },
165
180
  { name: 'coverage.json', data: `${JSON.stringify(report.coverage ?? {}, null, 2)}\n` },
166
181
  { name: 'logs.txt', data: logs ?? '' },
167
182
  ];
168
183
 
169
- for (const f of source ?? []) {
170
- entries.push({ name: `strategy/${f.name}`, data: f.content });
171
- }
172
-
173
184
  // Checksums last, over everything above.
174
185
  const sums = entries
175
186
  .map(({ name, data }) => {
@@ -209,7 +209,18 @@ export function matchOrder(book, order) {
209
209
  const quotedPx = ladder.best();
210
210
 
211
211
  const { fills, remaining, notional } = ladder.take(size, order.limit ?? null);
212
- const filled = size - remaining;
212
+ // SUMMED FROM WHAT WAS TAKEN, not derived as `size - remaining`.
213
+ //
214
+ // `remaining` is the requested size with each level's depth subtracted from
215
+ // it, and at IEEE-754 precision `1e308 - 1000` is still `1e308`. So a huge
216
+ // but finite order consumed the whole ladder while reporting `filled: 0` —
217
+ // no position, no cash, no trade row, and an empty book for every order
218
+ // after it in that market. The report said nothing happened; the book said
219
+ // otherwise.
220
+ //
221
+ // Adding up the levels actually taken cannot drift from what was removed,
222
+ // because it IS what was removed. otengine.py mirrors this.
223
+ const filled = fills.reduce((a, f) => a + f.size, 0);
213
224
 
214
225
  return {
215
226
  fills,
@@ -0,0 +1,116 @@
1
+ // Point-in-time views over an out-of-band series.
2
+ //
3
+ // `ctx.ref(name)` and `ctx.ext(name)` both call `feed.viewAt(ctx.now)` and hand
4
+ // the result to the strategy. Until now nothing implemented `viewAt`: the
5
+ // manifest validator accepted `reference` and `series`, the run was queued and
6
+ // billed, and the strategy crashed on its first `ctx.ref(...)` with a message
7
+ // claiming the feed had not been declared — which it had.
8
+ //
9
+ // Two properties this file exists to guarantee:
10
+ //
11
+ // 1. NOTHING STAMPED AFTER ctx.now IS REACHABLE. Not filtered on the way out
12
+ // — the cursor never advances past `now`, so a later row is not something
13
+ // the strategy can ask for. That is the same promise the event replay
14
+ // makes, and a reference feed is exactly where it would otherwise leak:
15
+ // the whole point of an outside series is that we hold all of it up front.
16
+ //
17
+ // 2. WHAT THE STRATEGY GETS IS A COPY. `ctx.book()` and `ctx.history()` were
18
+ // both caught handing out live objects a strategy could rewrite, and a
19
+ // rewritten reference row would poison every later window() over it.
20
+ //
21
+ // `lagMs` models publication delay: a row is not visible until ts_ms + lagMs,
22
+ // which is how you backtest a signal you could not have had instantly.
23
+
24
+ /** Frozen, prototype-less copy of one row. */
25
+ function freezeRow(row) {
26
+ const out = Object.create(null);
27
+ for (const k of Object.keys(row)) out[k] = row[k];
28
+ return Object.freeze(out);
29
+ }
30
+
31
+ export class PointInTimeFeed {
32
+ /**
33
+ * @param rows ascending by ts_ms. Not copied — this class owns them and
34
+ * never hands one out directly.
35
+ * @param lagMs publication delay; a row becomes visible at ts_ms + lagMs.
36
+ */
37
+ constructor(rows, { lagMs = 0 } = {}) {
38
+ this.rows = rows;
39
+ this.lagMs = Number(lagMs) || 0;
40
+ // Monotone cursor: the replay only ever moves forward, so the whole feed is
41
+ // walked once across a market-day rather than binary-searched per call.
42
+ // `ctx.now` never goes backwards within a market — but a fresh feed is
43
+ // built per market, so this is not an assumption about the strategy.
44
+ this.cursor = 0;
45
+ }
46
+
47
+ /** Index one past the last row visible at `now`. */
48
+ _visibleCount(now) {
49
+ const limit = now - this.lagMs;
50
+ while (this.cursor < this.rows.length && this.rows[this.cursor].ts_ms <= limit) {
51
+ this.cursor += 1;
52
+ }
53
+ // Defensive: if time moved backwards, do not report rows from the future.
54
+ if (this.cursor > 0 && this.rows[this.cursor - 1].ts_ms > limit) {
55
+ let i = this.cursor;
56
+ while (i > 0 && this.rows[i - 1].ts_ms > limit) i -= 1;
57
+ return i;
58
+ }
59
+ return this.cursor;
60
+ }
61
+
62
+ viewAt(now) {
63
+ const n = this._visibleCount(now);
64
+ const rows = this.rows;
65
+ // Captured, not read off `this`: the object below is what the strategy
66
+ // holds, so `this` inside its methods is that frozen view — `this.lagMs`
67
+ // was undefined there and turned the clamp into NaN, which made at() return
68
+ // null for every timestamp.
69
+ const { lagMs } = this;
70
+ const horizon = now - lagMs;
71
+ return Object.freeze({
72
+ /** The most recent row at or before now, or null. */
73
+ get last() { return n > 0 ? freezeRow(rows[n - 1]) : null; },
74
+
75
+ /** The last `k` visible rows, oldest first. Never more than exist. */
76
+ window(k) {
77
+ const want = Math.max(0, Math.min(Number(k) || 0, n));
78
+ const out = [];
79
+ for (let i = n - want; i < n; i += 1) out.push(freezeRow(rows[i]));
80
+ return out;
81
+ },
82
+
83
+ /**
84
+ * The row in effect at `ts`. Clamped to now: asking for a later
85
+ * timestamp cannot reach a later row.
86
+ */
87
+ at(ts) {
88
+ const asked = Number(ts);
89
+ const t = Number.isFinite(asked) ? Math.min(asked, horizon) : horizon;
90
+ let lo = 0;
91
+ let hi = n - 1;
92
+ let found = -1;
93
+ while (lo <= hi) {
94
+ const mid = (lo + hi) >> 1;
95
+ if (rows[mid].ts_ms <= t) { found = mid; lo = mid + 1; } else { hi = mid - 1; }
96
+ }
97
+ return found >= 0 ? freezeRow(rows[found]) : null;
98
+ },
99
+ });
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build the feeds a run declared, keyed by the name the strategy will ask for.
105
+ *
106
+ * @param declared manifest names, e.g. ['binance:btcusdt:spot:1s']
107
+ * @param rowsByName name -> ascending rows
108
+ * @param lagByName name -> publication lag in ms
109
+ */
110
+ export function buildFeeds(declared, rowsByName, lagByName = {}) {
111
+ const out = new Map();
112
+ for (const name of declared ?? []) {
113
+ out.set(name, new PointInTimeFeed(rowsByName[name] ?? [], { lagMs: lagByName[name] ?? 0 }));
114
+ }
115
+ return out;
116
+ }
@@ -129,8 +129,74 @@ export class Portfolio {
129
129
  if (!isSide(order?.side)) { this.rejected += 1; return null; }
130
130
 
131
131
  const leg = this.#legs(marketId)[order.side];
132
- let size = Number(order.size);
133
- if (!(size > 0)) { this.rejected += 1; return null; }
132
+
133
+ // ONE PLACE THAT DECIDES WHETHER AN ORDER IS USABLE.
134
+ //
135
+ // This was three separate checks bolted on one at a time, and each time a
136
+ // new shape of bad input walked past the ones already there — an infinite
137
+ // notional, then a size and a notional together, then a bad `limit` on a
138
+ // plain size order. Whack-a-mole on a consumption point is how you end up
139
+ // with an engine that rejects what it happens to have been asked about.
140
+ //
141
+ // Every unusable order is COUNTED AND RETURNS NULL. Never thrown: a bad
142
+ // order is one order, and otengine.py raising ValueError/OverflowError on
143
+ // the same input turned "reject one order" into "fail the whole run" —
144
+ // same input, two different outcomes, in a product whose whole promise is
145
+ // that the two engines are the same engine. That mirror is held to this
146
+ // table by runner/conformance.
147
+ // A NUMBER, not something Number() is willing to turn into one.
148
+ //
149
+ // Coercion is not validation: `Number('10')` is 10, `Number([10])` is 10,
150
+ // `Number(true)` is 1. Python's `float()` agrees about the string and the
151
+ // bool and RAISES on the list — so `{ size: [10] }` filled ten contracts
152
+ // in JS and was rejected in Python, from one strategy, on one input.
153
+ //
154
+ // The SDK's Order requires a number and the docs say a number. Anything
155
+ // else is a mistake in the strategy, and a mistake that fills is worse
156
+ // than one that is counted.
157
+ const finite = (v) => (
158
+ typeof v === 'number' && Number.isFinite(v) ? v : null
159
+ );
160
+
161
+ const hasSize = order.size != null;
162
+ const hasNotional = order.notional != null;
163
+ // Contradictory instructions. Choosing one silently would trade a contract
164
+ // count while the author believed they had set a spending cap.
165
+ if (hasSize === hasNotional) { this.rejected += 1; return null; }
166
+
167
+ // A limit is optional, but a limit that is PRESENT must be a price: an
168
+ // outcome token trades in [0, 1], and `limit: 2` or `limit: Infinity`
169
+ // means "pay anything" — which is what it silently did.
170
+ let limit = null;
171
+ if (order.limit != null) {
172
+ limit = finite(order.limit);
173
+ if (limit == null || limit < 0 || limit > 1) { this.rejected += 1; return null; }
174
+ }
175
+
176
+ let size;
177
+ if (hasNotional) {
178
+ // MONEY -> CONTRACTS, here rather than in the SDK's Order constructor:
179
+ // a hook may return a plain object literal — most of the JS examples do
180
+ // — and one that never passed through `new Order()` would carry a
181
+ // `notional` nobody converted.
182
+ //
183
+ // The divisor is the limit, never the current best price: a contract
184
+ // costs whatever it fills at and a marketable order walks the book, so
185
+ // dividing by the touch overspends the moment there is any slippage.
186
+ const budget = finite(order.notional);
187
+ if (budget == null || budget <= 0 || limit == null || limit <= 0) {
188
+ this.rejected += 1; return null;
189
+ }
190
+ // The QUOTIENT can overflow from two finite inputs: 1e308 / 0.01 is
191
+ // Infinity. Checked before the floor, because that is where Python
192
+ // raises.
193
+ const q = budget / limit;
194
+ if (!Number.isFinite(q)) { this.rejected += 1; return null; }
195
+ size = Math.floor(q);
196
+ } else {
197
+ size = finite(order.size);
198
+ }
199
+ if (size == null || !(size > 0)) { this.rejected += 1; return null; }
134
200
 
135
201
  // reduce_only is clamped to what is open. A strategy asking to close more
136
202
  // than it holds must not accidentally open the other way.
@@ -139,9 +205,15 @@ export class Portfolio {
139
205
  if (!(size > EPS)) { this.rejected += 1; return null; }
140
206
  }
141
207
 
142
- const res = matchOrder(book, { ...order, size });
208
+ // ONE effective order from here on: the derived size has to reach the fill
209
+ // row as well as the match. It did not, and the row's `requested` came out
210
+ // as NaN for a notional-only order — the fill was executed (the fee was
211
+ // identical) but the ROW was rejected by the parser and never emitted, so
212
+ // the run silently lost its fill log. otengine.py does the same.
213
+ const eff = { ...order, size, limit };
214
+ const res = matchOrder(book, eff);
143
215
  if (res.filled <= 0) {
144
- this.fills.push(this.#fillRow({ ts, marketId, order, res, tag, realised: 0, fee: 0 }));
216
+ this.fills.push(this.#fillRow({ ts, marketId, order: eff, res, tag, realised: 0, fee: 0 }));
145
217
  return res;
146
218
  }
147
219
 
@@ -177,7 +249,7 @@ export class Portfolio {
177
249
  this.cash -= res.notional + fee;
178
250
  }
179
251
 
180
- this.fills.push(this.#fillRow({ ts, marketId, order, res, tag, realised, fee }));
252
+ this.fills.push(this.#fillRow({ ts, marketId, order: eff, res, tag, realised, fee }));
181
253
  return res;
182
254
  }
183
255