@coinrithm/mcp-trading 0.7.15 → 0.7.16

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +127 -8
  2. package/README.md +18 -3
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/cli.d.ts +25 -0
  20. package/dist/agent/cli.js +300 -2
  21. package/dist/agent/client.d.ts +1 -0
  22. package/dist/agent/client.js +6 -0
  23. package/dist/agent/configurationWarnings.js +11 -0
  24. package/dist/agent/decision.d.ts +3 -0
  25. package/dist/agent/decision.js +85 -3
  26. package/dist/agent/decisionValidator.d.ts +30 -0
  27. package/dist/agent/decisionValidator.js +147 -2
  28. package/dist/agent/engine.d.ts +3 -3
  29. package/dist/agent/engine.js +3 -3
  30. package/dist/agent/observe.d.ts +27 -2
  31. package/dist/agent/observe.js +529 -89
  32. package/dist/agent/prompt.d.ts +4 -1
  33. package/dist/agent/prompt.js +98 -7
  34. package/dist/agent/promptTables.d.ts +1 -0
  35. package/dist/agent/promptTables.js +33 -0
  36. package/dist/agent/providerCapabilities.d.ts +6 -0
  37. package/dist/agent/providerCapabilities.js +23 -0
  38. package/dist/agent/providers.d.ts +25 -7
  39. package/dist/agent/providers.js +188 -48
  40. package/dist/agent/resolve.js +3 -0
  41. package/dist/agent/runner.d.ts +8 -0
  42. package/dist/agent/runner.js +59 -15
  43. package/dist/agent/setups.d.ts +2 -1
  44. package/dist/agent/setups.js +17 -16
  45. package/dist/agent/signals.d.ts +33 -0
  46. package/dist/agent/signals.js +89 -0
  47. package/dist/agent/skill.js +37 -0
  48. package/dist/agent/skillValidator.js +33 -0
  49. package/dist/agent/strictLint.js +29 -0
  50. package/dist/agent/types.d.ts +149 -12
  51. package/dist/agent/universe.d.ts +78 -0
  52. package/dist/agent/universe.js +215 -0
  53. package/package.json +3 -2
@@ -0,0 +1,181 @@
1
+ // Bench cassettes: the recorded READ inputs of one agent cycle.
2
+ //
3
+ // A cassette is the source of truth the bench replays. It holds every read
4
+ // response the real production runner made in one dry-run cycle (status, body,
5
+ // keyed by a canonical request key), the clock it was recorded at, and the
6
+ // recorded agent's spec (needed to replay the built-in baselines on the same
7
+ // inputs). It never holds request headers, so no API key reaches a cassette.
8
+ // It DOES hold the recording account's paper reads (wallet, positions), so
9
+ // keep a corpus as private as the account it was recorded from.
10
+ //
11
+ // Why not replay the stored `decision_input_record`: that record declares
12
+ // itself partial (prompts, news and journal excluded, byte budget) and carries
13
+ // no replay guarantee. Inputs are therefore recorded going forward, in full.
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { DEFAULT_TRIGGER_POLICY } from "../types.js";
18
+ import { sortDeep } from "../util.js";
19
+ import { parseLabelFile } from "./labels.js";
20
+ export const CASSETTE_SCHEMA = "coinrithm.bench.cassette.v1";
21
+ /**
22
+ * Canonical request identity used for both recording and replay: upper-cased
23
+ * method, the path relative to the API base path, and the query parameters
24
+ * sorted by name. The host is deliberately not part of the key, so a cassette
25
+ * recorded against one base URL replays against the bench's fake one.
26
+ */
27
+ export function canonicalRequest(method, url, basePath = "") {
28
+ const u = new URL(url);
29
+ const base = basePath.replace(/\/+$/, "");
30
+ let path = u.pathname;
31
+ if (base && (path === base || path.startsWith(`${base}/`)))
32
+ path = path.slice(base.length) || "/";
33
+ const params = new URLSearchParams(u.search);
34
+ params.sort();
35
+ const query = {};
36
+ params.forEach((value, name) => {
37
+ query[name] = value;
38
+ });
39
+ const qs = params.toString();
40
+ const m = method.toUpperCase();
41
+ return { key: `${m} ${path}${qs ? `?${qs}` : ""}`, method: m, path, query };
42
+ }
43
+ /** The path prefix of an API base URL ("" for a host-root base). */
44
+ export function basePathOf(baseUrl) {
45
+ return new URL(baseUrl).pathname.replace(/\/+$/, "");
46
+ }
47
+ export function sha256Hex(text) {
48
+ return createHash("sha256").update(text).digest("hex");
49
+ }
50
+ /** Sorted-key JSON, the same canonicalization the scorecard hash relies on. */
51
+ export function canonicalJson(value) {
52
+ return JSON.stringify(sortDeep(value));
53
+ }
54
+ /**
55
+ * The spec a recording pass runs with: the agent's own spec with its gate
56
+ * forced to fire, so the post-gate reads (whale context) are always recorded
57
+ * and a variant whose gate fires on replay finds them in the cassette.
58
+ */
59
+ export function recordingSpec(spec) {
60
+ return {
61
+ ...spec,
62
+ triggerPolicy: {
63
+ ...(spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY),
64
+ mode: "always",
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * The market-implied baseline: the recorded agent's spec and caps with the
70
+ * deterministic mechanical "market-implied" strategy as its brain (mechanical
71
+ * .ts). runBench separately preserves the recorded agent's observation
72
+ * policy, so replacing the provider never changes the PM opportunity set.
73
+ */
74
+ export function marketBaselineSpec(spec) {
75
+ return mechanicalBaselineSpec(spec, "market-implied");
76
+ }
77
+ /**
78
+ * The same recorded spec with one of the mechanical benchmark strategies as
79
+ * its brain. base-rate and random pick the SAME market from the SAME
80
+ * observation as market-implied (mechanical.ts); all use the original
81
+ * recording's PM board and never fetch a separate baseline universe.
82
+ */
83
+ export function mechanicalBaselineSpec(spec, strategy) {
84
+ return {
85
+ ...recordingSpec(spec),
86
+ model: { provider: "mechanical", name: strategy },
87
+ };
88
+ }
89
+ function stampOf(asOf) {
90
+ const stamp = asOf
91
+ .replace(/[^0-9A-Za-z]+/g, "-")
92
+ .slice(0, 40)
93
+ .replace(/^-+|-+$/g, "");
94
+ return stamp || "cassette";
95
+ }
96
+ /** Filesystem-safe, content-addressed cassette id: asOf stamp + body hash. */
97
+ export function cassetteId(asOf, contentHash) {
98
+ return `${stampOf(asOf)}-${contentHash.slice(0, 12)}`;
99
+ }
100
+ export function writeCassette(dir, cassette) {
101
+ mkdirSync(dir, { recursive: true });
102
+ const file = join(dir, `${cassette.id}.json`);
103
+ writeFileSync(file, `${JSON.stringify(cassette, null, 2)}\n`, "utf8");
104
+ return file;
105
+ }
106
+ /** Validate a parsed cassette. Throws on a malformed one (fail closed). */
107
+ export function parseCassette(value, source) {
108
+ const c = (value ?? {});
109
+ const problems = [];
110
+ if (c.schema !== CASSETTE_SCHEMA)
111
+ problems.push("schema");
112
+ if (typeof c.id !== "string" || !/^[0-9A-Za-z-]{1,80}$/.test(c.id))
113
+ problems.push("id");
114
+ if (typeof c.asOf !== "string" || !Number.isFinite(Date.parse(c.asOf)))
115
+ problems.push("asOf");
116
+ if (typeof c.clockMs !== "number" || !Number.isFinite(c.clockMs))
117
+ problems.push("clockMs");
118
+ if (!c.spec || typeof c.spec !== "object" || !Array.isArray(c.spec.venues))
119
+ problems.push("spec");
120
+ if (!Array.isArray(c.responses) ||
121
+ c.responses.some((r) => !r ||
122
+ typeof r.key !== "string" ||
123
+ typeof r.path !== "string" ||
124
+ typeof r.status !== "number"))
125
+ problems.push("responses");
126
+ if (problems.length > 0)
127
+ throw new Error(`cassette ${source} is invalid (${problems.join(", ")}); re-record it`);
128
+ return {
129
+ ...c,
130
+ marketBaselineRecorded: c.marketBaselineRecorded === true,
131
+ refusedRequests: Array.isArray(c.refusedRequests) ? c.refusedRequests : [],
132
+ };
133
+ }
134
+ /**
135
+ * Read every cassette at the top level of `dir` plus the optional
136
+ * `labels/<cassetteId>.json` files. A JSON file with the cassette schema that
137
+ * fails validation, or a malformed label file, stops the bench: a silently
138
+ * skipped input would bias the comparison.
139
+ */
140
+ export function readCorpus(dir) {
141
+ const cassettes = [];
142
+ const ignored = [];
143
+ for (const name of readdirSync(dir).sort()) {
144
+ if (!name.endsWith(".json"))
145
+ continue;
146
+ const file = join(dir, name);
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(readFileSync(file, "utf8"));
150
+ }
151
+ catch {
152
+ throw new Error(`corpus file ${name} is not valid JSON; repair or explicitly remove it before comparing variants`);
153
+ }
154
+ if (parsed?.schema !== CASSETTE_SCHEMA) {
155
+ ignored.push(name);
156
+ continue;
157
+ }
158
+ cassettes.push(parseCassette(parsed, name));
159
+ }
160
+ const ids = new Set();
161
+ for (const c of cassettes) {
162
+ if (ids.has(c.id))
163
+ throw new Error(`duplicate cassette id ${c.id}`);
164
+ ids.add(c.id);
165
+ }
166
+ const labels = {};
167
+ for (const c of cassettes) {
168
+ const file = join(dir, "labels", `${c.id}.json`);
169
+ if (!existsSync(file))
170
+ continue;
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(readFileSync(file, "utf8"));
174
+ }
175
+ catch (e) {
176
+ throw new Error(`label file labels/${c.id}.json is not valid JSON: ${e.message}`);
177
+ }
178
+ labels[c.id] = parseLabelFile(parsed, `labels/${c.id}.json`);
179
+ }
180
+ return { cassettes, labels, ignored };
181
+ }
@@ -0,0 +1,28 @@
1
+ /** Fee fraction of the price paid at a 50% PM price (openapi.yaml: ~1.8%). */
2
+ export declare const PM_SYNTHETIC_FEE_RATE_AT_MID = 0.018;
3
+ /** Flat futures taker fee (bps of notional) used for synthesized quotes. */
4
+ export declare const FUTURES_SYNTHETIC_FEE_BPS = 10;
5
+ /** Flat spot taker fee (bps of notional) used for synthesized quotes. */
6
+ export declare const SPOT_SYNTHETIC_FEE_BPS = 10;
7
+ /**
8
+ * Synthetic PM fee in probability POINTS for an outcome priced at
9
+ * `probabilityPoints` (0..100). Zero at the extremes, largest at 50.
10
+ */
11
+ export declare function pmSyntheticFeePoints(probabilityPoints: number): number;
12
+ export interface SyntheticPmQuote {
13
+ /** Raw chosen-outcome probability in points: the value the floor checks. */
14
+ entryProbability: number;
15
+ feePoints: number;
16
+ /** Fee-inclusive break-even cost in points (entry + fee). */
17
+ costPoints: number;
18
+ stakeMusd: number;
19
+ /** Net shares: stake / (cost / 100). One share pays 1 mUSD on a win. */
20
+ sharesEstimate: number;
21
+ }
22
+ /**
23
+ * The PM quote the bench serves for a recorded outcome probability. Returns
24
+ * undefined for a probability the paper engine could not fill (0, 100, or not
25
+ * a finite number in range) or a non-positive stake, so the caller reports an
26
+ * ineligible quote instead of dividing by zero.
27
+ */
28
+ export declare function synthesizePmQuote(probabilityPoints: number, stakeMusd: number): SyntheticPmQuote | undefined;
@@ -0,0 +1,60 @@
1
+ // Synthetic execution costs for the bench's replayed quotes.
2
+ //
3
+ // A quote depends on the action the agent proposes (which outcome, which stake,
4
+ // which leverage), so it can never be recorded ahead of time: a cassette only
5
+ // holds the READ inputs of a cycle. On replay the bench therefore synthesizes
6
+ // every quote from recorded evidence (the discover probability of the chosen PM
7
+ // outcome, the recorded market price of a coin) plus the cost model below. Every
8
+ // synthesized quote is counted in the report; it is a fidelity limit, not a
9
+ // silent substitute for the paper engine.
10
+ //
11
+ // What the cost model covers and what it leaves out, stated plainly:
12
+ // - PM: the published fee SHAPE only. openapi.yaml ("Paper Execution Realism
13
+ // v1") documents a Polymarket-shaped taker fee of about 1.8% near a 50%
14
+ // price and about 0 at the extremes. That is modelled as
15
+ // PM_SYNTHETIC_FEE_RATE_AT_MID * 4p(1-p) of the price paid. The ask spread
16
+ // and the size-scaled slippage the paper engine adds are NOT modelled, so a
17
+ // synthesized PM cost is optimistic by an unmeasured amount.
18
+ // - Futures and spot: a flat taker fee on notional. The paper engine discloses
19
+ // its own feeBps per quote, which a cassette cannot contain, so the bench
20
+ // uses the runner's own conservative pre-quote estimate
21
+ // (CAPITAL_FEE_BUFFER_BPS). Funding, spread and slippage are not modelled.
22
+ import { CAPITAL_FEE_BUFFER_BPS } from "../capitalSizing.js";
23
+ /** Fee fraction of the price paid at a 50% PM price (openapi.yaml: ~1.8%). */
24
+ export const PM_SYNTHETIC_FEE_RATE_AT_MID = 0.018;
25
+ /** Flat futures taker fee (bps of notional) used for synthesized quotes. */
26
+ export const FUTURES_SYNTHETIC_FEE_BPS = CAPITAL_FEE_BUFFER_BPS;
27
+ /** Flat spot taker fee (bps of notional) used for synthesized quotes. */
28
+ export const SPOT_SYNTHETIC_FEE_BPS = CAPITAL_FEE_BUFFER_BPS;
29
+ /**
30
+ * Synthetic PM fee in probability POINTS for an outcome priced at
31
+ * `probabilityPoints` (0..100). Zero at the extremes, largest at 50.
32
+ */
33
+ export function pmSyntheticFeePoints(probabilityPoints) {
34
+ const p = probabilityPoints / 100;
35
+ return probabilityPoints * PM_SYNTHETIC_FEE_RATE_AT_MID * 4 * p * (1 - p);
36
+ }
37
+ /**
38
+ * The PM quote the bench serves for a recorded outcome probability. Returns
39
+ * undefined for a probability the paper engine could not fill (0, 100, or not
40
+ * a finite number in range) or a non-positive stake, so the caller reports an
41
+ * ineligible quote instead of dividing by zero.
42
+ */
43
+ export function synthesizePmQuote(probabilityPoints, stakeMusd) {
44
+ if (!Number.isFinite(probabilityPoints) ||
45
+ probabilityPoints <= 0 ||
46
+ probabilityPoints >= 100 ||
47
+ !Number.isFinite(stakeMusd) ||
48
+ stakeMusd <= 0) {
49
+ return undefined;
50
+ }
51
+ const feePoints = pmSyntheticFeePoints(probabilityPoints);
52
+ const costPoints = probabilityPoints + feePoints;
53
+ return {
54
+ entryProbability: probabilityPoints,
55
+ feePoints,
56
+ costPoints,
57
+ stakeMusd,
58
+ sharesEstimate: stakeMusd / (costPoints / 100),
59
+ };
60
+ }
@@ -0,0 +1,123 @@
1
+ import { ApiResult } from "../types.js";
2
+ import { Cassette } from "./cassette.js";
3
+ import { LabelFile, PriceBar } from "./labels.js";
4
+ export declare const LABEL_RANGES: readonly [{
5
+ readonly range: "1D";
6
+ readonly spanSeconds: 86400;
7
+ readonly barSeconds: 300;
8
+ }, {
9
+ readonly range: "1W";
10
+ readonly spanSeconds: number;
11
+ readonly barSeconds: 900;
12
+ }, {
13
+ readonly range: "1M";
14
+ readonly spanSeconds: number;
15
+ readonly barSeconds: 3600;
16
+ }, {
17
+ readonly range: "3M";
18
+ readonly spanSeconds: number;
19
+ readonly barSeconds: 14400;
20
+ }];
21
+ export declare const DEFAULT_LABEL_HORIZON_HOURS = 24;
22
+ export type PriceLabelPlan = {
23
+ status: "ready";
24
+ range: string;
25
+ barSeconds: number;
26
+ fromSec: number;
27
+ toSec: number;
28
+ } | {
29
+ status: "not_yet";
30
+ labelableAfter: string;
31
+ } | {
32
+ status: "too_old";
33
+ };
34
+ /**
35
+ * Which candle range can label [asOf, asOf + horizon] right now. The horizon
36
+ * must have fully elapsed, and the range window (ending now) must still hold
37
+ * the bar that contains asOf.
38
+ */
39
+ export declare function planPriceLabels(asOf: string, horizonHours: number, nowMs: number): PriceLabelPlan;
40
+ /** The coins a cassette observed: its recorded GET /api/agent/market/:id reads. */
41
+ export declare function cassetteCoins(cassette: Pick<Cassette, "responses">): Array<{
42
+ coinId: string;
43
+ symbol: string;
44
+ }>;
45
+ /**
46
+ * Bars from a candles response that can matter for [fromSec, toSec]: the bar
47
+ * containing asOf onward (the scorer itself ignores every bar at or before
48
+ * asOf) through the last bar that CLOSES by toSec, finite OHLC only, oldest
49
+ * first. A bar straddling toSec (it may hold prices after the horizon) or
50
+ * still open when labelling runs is never stored.
51
+ */
52
+ export declare function priceBarsFor(data: unknown, fromSec: number, toSec: number, barSeconds: number): PriceBar[];
53
+ export type CassetteLabelResult = {
54
+ id: string;
55
+ status: "not_yet";
56
+ labelableAfter: string;
57
+ } | {
58
+ id: string;
59
+ status: "too_old";
60
+ } | {
61
+ id: string;
62
+ status: "kept";
63
+ } | {
64
+ id: string;
65
+ status: "horizon_mismatch";
66
+ existingHorizonHours: number | null;
67
+ } | {
68
+ id: string;
69
+ status: "no_coins";
70
+ } | {
71
+ id: string;
72
+ status: "built";
73
+ file: LabelFile;
74
+ range: string;
75
+ symbols: string[];
76
+ missing: string[];
77
+ };
78
+ /**
79
+ * Price labels for each cassette. `fetchCandles` is the agent API candles
80
+ * read; one fetch per coin and range serves every cassette. An existing label
81
+ * file keeps its PM and funding labels; its prices are replaced only with
82
+ * `overwrite`. A symbol whose read fails or returns no bar in the window is
83
+ * reported missing and stays unlabelled.
84
+ */
85
+ export declare function buildPriceLabels(cassettes: ReadonlyArray<Pick<Cassette, "id" | "asOf" | "responses">>, opts: {
86
+ fetchCandles: (coinId: string, range: string) => Promise<ApiResult>;
87
+ horizonHours?: number;
88
+ nowMs: number;
89
+ existing?: Record<string, LabelFile>;
90
+ overwrite?: boolean;
91
+ }): Promise<CassetteLabelResult[]>;
92
+ /** The PM events a cassette discovered (its recorded /pm/discover reads). */
93
+ export declare function cassettePmEvents(cassette: Pick<Cassette, "responses">): Array<{
94
+ source: string;
95
+ slug: string;
96
+ }>;
97
+ /**
98
+ * Pure: settled labels from one public event-detail body, keyed
99
+ * `source/slug/outcomeExternalMarketId`. Empty unless the platform's own
100
+ * verdict says the event settles and the outcome has a provider result.
101
+ */
102
+ export declare function pmVerdictLabels(source: string, slug: string, body: unknown): Record<string, {
103
+ settled: 0 | 1;
104
+ }>;
105
+ export type CassettePmResult = {
106
+ id: string;
107
+ events: number;
108
+ labelled: number;
109
+ failed: number;
110
+ pm: Record<string, {
111
+ settled: 0 | 1;
112
+ }>;
113
+ };
114
+ /**
115
+ * Settled PM labels per cassette from the public event verdict. One read per
116
+ * event serves every cassette; a failed or unusable read leaves that event
117
+ * unlabelled and is counted. Existing PM labels are kept unless `overwrite`.
118
+ */
119
+ export declare function buildPmLabels(cassettes: ReadonlyArray<Pick<Cassette, "id" | "responses">>, opts: {
120
+ fetchEvent: (source: string, slug: string) => Promise<ApiResult>;
121
+ existing?: Record<string, LabelFile>;
122
+ overwrite?: boolean;
123
+ }): Promise<CassettePmResult[]>;
@@ -0,0 +1,282 @@
1
+ // Outcome labels for bench cassettes, built from data published AFTER each
2
+ // cassette's asOf (C2 of the item-3 bench plan). Reads only; never writes to
3
+ // the API.
4
+ //
5
+ // Prices: OHLC bars from GET /api/agent/market/:coinId/candles, the same
6
+ // loader and shape as the public /api/coins/:slug/candles (probed live
7
+ // 2026-10-07: range=1D -> 288 five-minute bars {t, o, h, l, c, v}, t = bar
8
+ // open in unix seconds). Candle storage keeps fine bars only briefly (about
9
+ // 24 h of 5-minute bars, 7 days of 15-minute, 30 days of hourly), so the bar
10
+ // size follows the cassette's age: the finest range whose window still covers
11
+ // asOf. A label file has one bar size (barSeconds).
12
+ //
13
+ // PM settlement comes from the platform's own public event verdict (see the
14
+ // PM section below), never a rule rebuilt here; anything short of a settled
15
+ // provider result stays "unlabelled".
16
+ export const LABEL_RANGES = [
17
+ { range: "1D", spanSeconds: 86_400, barSeconds: 300 },
18
+ { range: "1W", spanSeconds: 7 * 86_400, barSeconds: 900 },
19
+ { range: "1M", spanSeconds: 30 * 86_400, barSeconds: 3_600 },
20
+ { range: "3M", spanSeconds: 90 * 86_400, barSeconds: 14_400 },
21
+ ];
22
+ export const DEFAULT_LABEL_HORIZON_HOURS = 24;
23
+ /**
24
+ * Which candle range can label [asOf, asOf + horizon] right now. The horizon
25
+ * must have fully elapsed, and the range window (ending now) must still hold
26
+ * the bar that contains asOf.
27
+ */
28
+ export function planPriceLabels(asOf, horizonHours, nowMs) {
29
+ const asOfMs = Date.parse(asOf);
30
+ if (!Number.isFinite(asOfMs))
31
+ throw new Error(`invalid asOf "${asOf}"`);
32
+ if (!Number.isFinite(horizonHours) || horizonHours <= 0)
33
+ throw new Error("horizon hours must be a positive number");
34
+ const fromSec = Math.floor(asOfMs / 1000);
35
+ const toSec = fromSec + Math.round(horizonHours * 3600);
36
+ const nowSec = Math.floor(nowMs / 1000);
37
+ if (toSec > nowSec)
38
+ return {
39
+ status: "not_yet",
40
+ labelableAfter: new Date(toSec * 1000).toISOString(),
41
+ };
42
+ const age = nowSec - fromSec;
43
+ const pick = LABEL_RANGES.find((r) => age + r.barSeconds <= r.spanSeconds);
44
+ return pick
45
+ ? {
46
+ status: "ready",
47
+ range: pick.range,
48
+ barSeconds: pick.barSeconds,
49
+ fromSec,
50
+ toSec,
51
+ }
52
+ : { status: "too_old" };
53
+ }
54
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
55
+ /** The coins a cassette observed: its recorded GET /api/agent/market/:id reads. */
56
+ export function cassetteCoins(cassette) {
57
+ const out = new Map();
58
+ for (const r of cassette.responses) {
59
+ const m = /^\/api\/agent\/market\/([^/]+)$/.exec(r.path);
60
+ if (!m || r.method !== "GET" || !r.ok)
61
+ continue;
62
+ const coin = isObject(r.data) ? r.data.coin : undefined;
63
+ const symbol = isObject(coin) ? coin.symbol : undefined;
64
+ if (typeof symbol === "string" && symbol.trim())
65
+ out.set(decodeURIComponent(m[1]), symbol.trim().toUpperCase());
66
+ }
67
+ return [...out].map(([coinId, symbol]) => ({ coinId, symbol }));
68
+ }
69
+ /**
70
+ * Bars from a candles response that can matter for [fromSec, toSec]: the bar
71
+ * containing asOf onward (the scorer itself ignores every bar at or before
72
+ * asOf) through the last bar that CLOSES by toSec, finite OHLC only, oldest
73
+ * first. A bar straddling toSec (it may hold prices after the horizon) or
74
+ * still open when labelling runs is never stored.
75
+ */
76
+ export function priceBarsFor(data, fromSec, toSec, barSeconds) {
77
+ const rows = isObject(data) && Array.isArray(data.candles) ? data.candles : [];
78
+ const bars = [];
79
+ for (const row of rows) {
80
+ if (!isObject(row))
81
+ continue;
82
+ const { t, h, l, c } = row;
83
+ if ([t, h, l, c].every((v) => typeof v === "number" && Number.isFinite(v)) &&
84
+ t > fromSec - barSeconds &&
85
+ t + barSeconds <= toSec)
86
+ bars.push({
87
+ t: t,
88
+ h: h,
89
+ l: l,
90
+ c: c,
91
+ });
92
+ }
93
+ return bars.sort((a, b) => a.t - b.t);
94
+ }
95
+ /**
96
+ * Price labels for each cassette. `fetchCandles` is the agent API candles
97
+ * read; one fetch per coin and range serves every cassette. An existing label
98
+ * file keeps its PM and funding labels; its prices are replaced only with
99
+ * `overwrite`. A symbol whose read fails or returns no bar in the window is
100
+ * reported missing and stays unlabelled.
101
+ */
102
+ export async function buildPriceLabels(cassettes, opts) {
103
+ const horizonHours = opts.horizonHours ?? DEFAULT_LABEL_HORIZON_HOURS;
104
+ const cache = new Map();
105
+ const read = (coinId, range) => {
106
+ const key = `${coinId}|${range}`;
107
+ let hit = cache.get(key);
108
+ if (!hit) {
109
+ hit = opts
110
+ .fetchCandles(coinId, range)
111
+ .catch(() => ({ ok: false, status: 0, data: null }));
112
+ cache.set(key, hit);
113
+ }
114
+ return hit;
115
+ };
116
+ const results = [];
117
+ for (const cassette of cassettes) {
118
+ const existing = opts.existing?.[cassette.id];
119
+ if (existing?.prices && !opts.overwrite) {
120
+ // An existing file labelled for another horizon is never reported as
121
+ // done for this one: rebuilding it needs --overwrite.
122
+ results.push(existing.horizonHours === horizonHours
123
+ ? { id: cassette.id, status: "kept" }
124
+ : {
125
+ id: cassette.id,
126
+ status: "horizon_mismatch",
127
+ existingHorizonHours: existing.horizonHours ?? null,
128
+ });
129
+ continue;
130
+ }
131
+ const plan = planPriceLabels(cassette.asOf, horizonHours, opts.nowMs);
132
+ if (plan.status !== "ready") {
133
+ results.push({ id: cassette.id, ...plan });
134
+ continue;
135
+ }
136
+ const coins = cassetteCoins(cassette);
137
+ if (coins.length === 0) {
138
+ results.push({ id: cassette.id, status: "no_coins" });
139
+ continue;
140
+ }
141
+ const prices = {};
142
+ const missing = [];
143
+ for (const { coinId, symbol } of coins) {
144
+ const res = await read(coinId, plan.range);
145
+ const bars = res.ok
146
+ ? priceBarsFor(res.data, plan.fromSec, plan.toSec, plan.barSeconds)
147
+ : [];
148
+ if (bars.length > 0)
149
+ prices[symbol] = bars;
150
+ else
151
+ missing.push(symbol);
152
+ }
153
+ const { prices: _old, barSeconds: _bar, horizonHours: _h, ...kept } = existing ?? {};
154
+ results.push({
155
+ id: cassette.id,
156
+ status: "built",
157
+ range: plan.range,
158
+ symbols: Object.keys(prices).sort(),
159
+ missing: missing.sort(),
160
+ file: {
161
+ ...kept,
162
+ prices,
163
+ barSeconds: plan.barSeconds,
164
+ horizonHours,
165
+ },
166
+ });
167
+ }
168
+ return results;
169
+ }
170
+ // ── PM settlement labels (root 57074) ──────────────────────────────────────
171
+ // From the EXISTING public event read GET /api/prediction-markets/events/
172
+ // :source/:slug, never an invented verdict: an outcome is labelled only when
173
+ // the event's settlementTrust says settlementEligible AND limboVerdict
174
+ // "settle", and the outcome's own lifecycle is a provider result (isResult,
175
+ // basis "provider", result won|lost). Probed live 2026-10-07: a Kalshi ladder
176
+ // (1 won / 74 lost, eligible, settle) and a ForecastEx event whose winner is
177
+ // known but whose time is unverified (eligible false, limbo "void"), which
178
+ // stays unlabelled. A pm_open buys its named outcome, so settled = won.
179
+ /** The PM events a cassette discovered (its recorded /pm/discover reads). */
180
+ export function cassettePmEvents(cassette) {
181
+ const out = new Map();
182
+ for (const r of cassette.responses) {
183
+ if (r.path !== "/api/agent/pm/discover" || r.method !== "GET" || !r.ok)
184
+ continue;
185
+ const rows = isObject(r.data) && Array.isArray(r.data.data) ? r.data.data : [];
186
+ for (const ev of rows) {
187
+ if (!isObject(ev))
188
+ continue;
189
+ const { source, slug } = ev;
190
+ if (typeof source !== "string" || typeof slug !== "string")
191
+ continue;
192
+ const s = source.trim().toLowerCase();
193
+ const g = slug.trim().toLowerCase();
194
+ if (s && g)
195
+ out.set(`${s}/${g}`, { source: s, slug: g });
196
+ }
197
+ }
198
+ return [...out.values()];
199
+ }
200
+ /**
201
+ * Pure: settled labels from one public event-detail body, keyed
202
+ * `source/slug/outcomeExternalMarketId`. Empty unless the platform's own
203
+ * verdict says the event settles and the outcome has a provider result.
204
+ */
205
+ export function pmVerdictLabels(source, slug, body) {
206
+ const out = {};
207
+ if (!isObject(body))
208
+ return out;
209
+ const resolution = isObject(body.resolution) ? body.resolution : undefined;
210
+ const trust = resolution && isObject(resolution.settlementTrust)
211
+ ? resolution.settlementTrust
212
+ : undefined;
213
+ if (!trust ||
214
+ trust.settlementEligible !== true ||
215
+ trust.limboVerdict !== "settle")
216
+ return out;
217
+ const event = isObject(body.event) ? body.event : undefined;
218
+ const outcomes = event && Array.isArray(event.outcomes) ? event.outcomes : [];
219
+ for (const o of outcomes) {
220
+ if (!isObject(o) || typeof o.externalMarketId !== "string")
221
+ continue;
222
+ const lc = isObject(o.lifecycle) ? o.lifecycle : undefined;
223
+ if (!lc ||
224
+ lc.isResult !== true ||
225
+ lc.basis !== "provider" ||
226
+ (lc.result !== "won" && lc.result !== "lost"))
227
+ continue;
228
+ out[`${source}/${slug}/${o.externalMarketId}`] = {
229
+ settled: lc.result === "won" ? 1 : 0,
230
+ };
231
+ }
232
+ return out;
233
+ }
234
+ /**
235
+ * Settled PM labels per cassette from the public event verdict. One read per
236
+ * event serves every cassette; a failed or unusable read leaves that event
237
+ * unlabelled and is counted. Existing PM labels are kept unless `overwrite`.
238
+ */
239
+ export async function buildPmLabels(cassettes, opts) {
240
+ const cache = new Map();
241
+ const read = (source, slug) => {
242
+ const key = `${source}/${slug}`;
243
+ let hit = cache.get(key);
244
+ if (!hit) {
245
+ hit = opts
246
+ .fetchEvent(source, slug)
247
+ .catch(() => ({ ok: false, status: 0, data: null }));
248
+ cache.set(key, hit);
249
+ }
250
+ return hit;
251
+ };
252
+ const results = [];
253
+ for (const cassette of cassettes) {
254
+ const events = cassettePmEvents(cassette);
255
+ const pm = opts.overwrite
256
+ ? {}
257
+ : { ...(opts.existing?.[cassette.id]?.pm ?? {}) };
258
+ let failed = 0;
259
+ let labelled = 0;
260
+ for (const { source, slug } of events) {
261
+ const res = await read(source, slug);
262
+ if (!res.ok) {
263
+ failed += 1;
264
+ continue;
265
+ }
266
+ for (const [key, label] of Object.entries(pmVerdictLabels(source, slug, res.data))) {
267
+ if (!opts.overwrite && pm[key] !== undefined)
268
+ continue;
269
+ pm[key] = label;
270
+ labelled += 1;
271
+ }
272
+ }
273
+ results.push({
274
+ id: cassette.id,
275
+ events: events.length,
276
+ labelled,
277
+ failed,
278
+ pm,
279
+ });
280
+ }
281
+ return results;
282
+ }