@coinrithm/mcp-trading 0.7.14 → 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 (72) hide show
  1. package/CHANGELOG.md +811 -613
  2. package/README.md +415 -366
  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 +14 -0
  22. package/dist/agent/client.js +113 -4
  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/decisionReceipt.d.ts +5 -1
  27. package/dist/agent/decisionReceipt.js +52 -3
  28. package/dist/agent/decisionValidator.d.ts +30 -0
  29. package/dist/agent/decisionValidator.js +147 -2
  30. package/dist/agent/engine.d.ts +3 -3
  31. package/dist/agent/engine.js +3 -3
  32. package/dist/agent/futuresEligibility.d.ts +36 -0
  33. package/dist/agent/futuresEligibility.js +90 -0
  34. package/dist/agent/observe.d.ts +35 -2
  35. package/dist/agent/observe.js +969 -128
  36. package/dist/agent/pmContext.d.ts +10 -1
  37. package/dist/agent/pmContext.js +163 -1
  38. package/dist/agent/prompt.d.ts +18 -1
  39. package/dist/agent/prompt.js +229 -33
  40. package/dist/agent/promptTables.d.ts +1 -0
  41. package/dist/agent/promptTables.js +33 -0
  42. package/dist/agent/providerCapabilities.d.ts +9 -0
  43. package/dist/agent/providerCapabilities.js +57 -4
  44. package/dist/agent/providers.d.ts +28 -7
  45. package/dist/agent/providers.js +200 -50
  46. package/dist/agent/resolve.js +3 -0
  47. package/dist/agent/runner.d.ts +8 -0
  48. package/dist/agent/runner.js +159 -26
  49. package/dist/agent/setups.d.ts +2 -1
  50. package/dist/agent/setups.js +17 -16
  51. package/dist/agent/signals.d.ts +33 -0
  52. package/dist/agent/signals.js +89 -0
  53. package/dist/agent/skill.js +37 -0
  54. package/dist/agent/skillValidator.js +33 -0
  55. package/dist/agent/strictLint.js +29 -0
  56. package/dist/agent/templates.js +30 -30
  57. package/dist/agent/types.d.ts +266 -13
  58. package/dist/agent/types.js +4 -0
  59. package/dist/agent/universe.d.ts +78 -0
  60. package/dist/agent/universe.js +215 -0
  61. package/dist/client.d.ts +9 -2
  62. package/dist/client.js +22 -10
  63. package/dist/http.d.ts +1 -0
  64. package/dist/http.js +17 -2
  65. package/dist/httpCompletion.d.ts +1 -1
  66. package/dist/httpCompletion.js +1 -0
  67. package/dist/index.js +2 -1
  68. package/dist/instructions.d.ts +1 -0
  69. package/dist/instructions.js +16 -0
  70. package/dist/tools.d.ts +1 -0
  71. package/dist/tools.js +150 -66
  72. package/package.json +98 -97
@@ -0,0 +1,259 @@
1
+ // Outcome labels for bench actions. Pure functions, no network.
2
+ //
3
+ // Labels are OPTIONAL in this slice. When `<corpus>/labels/<cassetteId>.json`
4
+ // exists the bench scores accepted opens against it; otherwise every accepted
5
+ // open is reported as "unlabelled" (counted, never dropped). This slice never
6
+ // fetches labels: whoever builds the label file must take its data from AFTER
7
+ // the cassette's asOf, and the scorer enforces that for price bars by ignoring
8
+ // every bar at or before asOf (no look-ahead from the decision's own inputs).
9
+ //
10
+ // A futures outcome is a MODEL, not a fill record. It walks OHLC bars after
11
+ // asOf with these assumptions (also listed in every report's `assumptions`):
12
+ // - entry at the recorded (synthesized) quote price with the paper fee model;
13
+ // entry latency is not modelled;
14
+ // - inside one bar the price path is unknown, so a bar that touches both the
15
+ // stop and the take-profit is resolved as the STOP (conservative) and is
16
+ // counted (sameBarStopAndTarget) so its share is visible;
17
+ // - a missing bar before the exit (or before the horizon end) makes the
18
+ // outcome "unlabelled_gap": counted, never filled or interpolated;
19
+ // - only bars that open after asOf AND close by the horizon end are
20
+ // walked, so no price after the horizon is ever seen; with a horizon
21
+ // that does not end on a bar boundary the walk stops at the last full
22
+ // bar before it (horizonFlooredToBar), a conservative cutoff;
23
+ // - funding is included only when the label file carries funding events for
24
+ // the symbol; otherwise the score says fundingIncluded: false.
25
+ //
26
+ // Label file shape:
27
+ // {
28
+ // "pm": { "<source>/<slug>/<outcomeExternalMarketId>": { "settled": 0 | 1 } },
29
+ // "prices": { "<SYMBOL>": [{ "t": <bar open, unix seconds>, "h": n, "l": n, "c": n }] },
30
+ // "barSeconds": <optional bar interval, default 300>,
31
+ // "funding": { "<SYMBOL>": [{ "t": <unix seconds>, "rate": <fraction of notional per event> }] },
32
+ // "horizonHours": <optional number: stop the walk at asOf + horizon>
33
+ // }
34
+ // `settled: 1` means the outcome the agent BOUGHT won. A positive funding rate
35
+ // is paid by longs and received by shorts.
36
+ import { baseSymbol } from "../setups.js";
37
+ import { FUTURES_SYNTHETIC_FEE_BPS } from "./costs.js";
38
+ export const DEFAULT_BAR_SECONDS = 300;
39
+ const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
40
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
41
+ /** Parse and validate a label file. Throws on a malformed file (fail closed). */
42
+ export function parseLabelFile(value, source) {
43
+ const bad = (why) => {
44
+ throw new Error(`label file ${source} is invalid: ${why}`);
45
+ };
46
+ if (!isObject(value))
47
+ return bad("not a JSON object");
48
+ const root = value;
49
+ const out = {};
50
+ if (root.pm !== undefined) {
51
+ if (!isObject(root.pm))
52
+ return bad("pm must be an object");
53
+ out.pm = {};
54
+ for (const [key, label] of Object.entries(root.pm)) {
55
+ const settled = isObject(label) ? label.settled : undefined;
56
+ if (settled !== 0 && settled !== 1)
57
+ return bad(`pm["${key}"].settled must be 0 or 1`);
58
+ if (key.split("/").length < 3)
59
+ return bad(`pm key "${key}" must be source/slug/outcomeExternalMarketId`);
60
+ out.pm[key] = { settled: settled };
61
+ }
62
+ }
63
+ const series = (field, keys) => {
64
+ const raw = root[field];
65
+ if (raw === undefined)
66
+ return undefined;
67
+ if (!isObject(raw))
68
+ return bad(`${field} must be an object`);
69
+ const result = {};
70
+ for (const [symbol, rows] of Object.entries(raw)) {
71
+ if (!Array.isArray(rows))
72
+ return bad(`${field}["${symbol}"] must be an array`);
73
+ result[symbol.toUpperCase()] = rows.map((row, i) => {
74
+ if (!isObject(row) || !keys.every((k) => isFiniteNumber(row[k])))
75
+ return bad(`${field}["${symbol}"][${i}] needs numeric ${keys.join(", ")}`);
76
+ return Object.fromEntries(keys.map((k) => [k, row[k]]));
77
+ });
78
+ }
79
+ return result;
80
+ };
81
+ const prices = series("prices", ["t", "h", "l", "c"]);
82
+ if (prices)
83
+ out.prices = prices;
84
+ const funding = series("funding", ["t", "rate"]);
85
+ if (funding)
86
+ out.funding = funding;
87
+ for (const field of ["horizonHours", "barSeconds"]) {
88
+ const v = root[field];
89
+ if (v === undefined)
90
+ continue;
91
+ if (!isFiniteNumber(v) || v <= 0)
92
+ return bad(`${field} must be a positive number`);
93
+ out[field] = v;
94
+ }
95
+ return out;
96
+ }
97
+ function pmLabel(labels, action) {
98
+ const want = `${action.source.toLowerCase()}/${action.slug.toLowerCase()}/${action.outcomeExternalMarketId}`;
99
+ for (const [key, label] of Object.entries(labels.pm ?? {})) {
100
+ const [source, slug, ...rest] = key.split("/");
101
+ if (`${source.toLowerCase()}/${slug.toLowerCase()}/${rest.join("/")}` === want)
102
+ return label.settled;
103
+ }
104
+ return undefined;
105
+ }
106
+ function scorePm(action, quote, labels) {
107
+ const settled = pmLabel(labels, action);
108
+ if (settled === undefined)
109
+ return { status: "unlabelled", reason: "no_pm_settlement_label" };
110
+ const stake = quote?.stakeMusd;
111
+ const shares = quote?.sharesEstimate;
112
+ const market = quote?.entryProbability;
113
+ // Fee-inclusive cost first, the raw entry probability only as a fallback.
114
+ const cost = isFiniteNumber(stake) && isFiniteNumber(shares) && stake > 0 && shares > 0
115
+ ? stake / shares
116
+ : isFiniteNumber(market)
117
+ ? market / 100
118
+ : undefined;
119
+ if (cost === undefined || cost <= 0)
120
+ return { status: "unlabelled", reason: "no_entry_cost" };
121
+ const forecast = action.forecastProbability;
122
+ const returnOnStake = (settled - cost) / cost;
123
+ return {
124
+ status: "labelled",
125
+ venue: "pm",
126
+ settled,
127
+ cost,
128
+ pnlPerShare: settled - cost,
129
+ returnOnStake,
130
+ pnlMusd: returnOnStake * action.stakeMusd,
131
+ ...(isFiniteNumber(forecast)
132
+ ? { brier: (forecast / 100 - settled) ** 2 }
133
+ : {}),
134
+ ...(isFiniteNumber(market)
135
+ ? { marketBrier: (market / 100 - settled) ** 2 }
136
+ : {}),
137
+ };
138
+ }
139
+ const seconds = (t) => (t > 1e12 ? t / 1000 : t);
140
+ function seriesFor(map, symbol) {
141
+ return map?.[symbol.toUpperCase()] ?? map?.[baseSymbol(symbol)];
142
+ }
143
+ function scoreFutures(action, quote, asOf, labels) {
144
+ const entry = quote?.entryPrice;
145
+ if (!isFiniteNumber(entry) || entry <= 0)
146
+ return { status: "unlabelled", reason: "no_entry_price" };
147
+ const asOfSec = Date.parse(asOf) / 1000;
148
+ if (!Number.isFinite(asOfSec))
149
+ return { status: "unlabelled", reason: "asof_unparseable" };
150
+ const series = seriesFor(labels.prices, action.symbol);
151
+ if (!series)
152
+ return { status: "unlabelled", reason: "no_price_series" };
153
+ const barSeconds = labels.barSeconds ?? DEFAULT_BAR_SECONDS;
154
+ const horizonEnd = labels.horizonHours !== undefined
155
+ ? asOfSec + labels.horizonHours * 3600
156
+ : Infinity;
157
+ // Only bars that open strictly AFTER the decision's asOf (no look-ahead)
158
+ // and CLOSE by the horizon end: a bar straddling the horizon could carry
159
+ // a stop or target after it, and a still-open bar is not final.
160
+ const bars = series
161
+ .map((b) => ({ ...b, t: seconds(b.t) }))
162
+ .filter((b) => b.t > asOfSec && b.t + barSeconds <= horizonEnd)
163
+ .sort((a, b) => a.t - b.t);
164
+ const gap = (why) => ({
165
+ status: "unlabelled_gap",
166
+ reason: why,
167
+ });
168
+ if (bars.length === 0)
169
+ return gap("no_bars_after_asof");
170
+ const long = action.side === "long";
171
+ const lev = action.leverage;
172
+ const liquidation = long ? entry * (1 - 1 / lev) : entry * (1 + 1 / lev);
173
+ const stop = isFiniteNumber(action.stopLossPrice)
174
+ ? action.stopLossPrice
175
+ : undefined;
176
+ const target = isFiniteNumber(action.takeProfitPrice)
177
+ ? action.takeProfitPrice
178
+ : undefined;
179
+ // The adverse trigger nearest to entry fires first.
180
+ const adverse = long
181
+ ? Math.max(stop ?? -Infinity, liquidation)
182
+ : Math.min(stop ?? Infinity, liquidation);
183
+ const adverseExit = adverse === liquidation ? "liquidation" : "stop";
184
+ let exit;
185
+ let sameBar = false;
186
+ let previousT = asOfSec;
187
+ for (const bar of bars) {
188
+ // A bar that opens more than one interval after the previous one (or,
189
+ // for the first bar, after asOf) leaves an unobserved stretch where the
190
+ // stop could have fired. Never fill it.
191
+ if (bar.t - previousT > barSeconds)
192
+ return gap("missing_bar_before_exit");
193
+ previousT = bar.t;
194
+ const hitAdverse = long ? bar.l <= adverse : bar.h >= adverse;
195
+ const hitTarget = target !== undefined && (long ? bar.h >= target : bar.l <= target);
196
+ if (hitAdverse) {
197
+ sameBar = hitTarget;
198
+ exit = { kind: adverseExit, price: adverse, t: bar.t };
199
+ break;
200
+ }
201
+ if (hitTarget && target !== undefined) {
202
+ exit = { kind: "take_profit", price: target, t: bar.t };
203
+ break;
204
+ }
205
+ }
206
+ if (!exit) {
207
+ // No trigger: mark at the last close, but only when the bars reach the
208
+ // last full bar before the horizon end (when one is set); a short series
209
+ // is a gap, not an exit.
210
+ if (Number.isFinite(horizonEnd) &&
211
+ horizonEnd - (previousT + barSeconds) >= barSeconds)
212
+ return gap("bars_end_before_horizon");
213
+ const last = bars[bars.length - 1];
214
+ // Priced at the final full bar's CLOSE, so funding settled inside that
215
+ // bar counts. Stop/target exits keep the bar OPEN as their time: inside
216
+ // one bar the trigger time is unknown (the stated same-bar ambiguity).
217
+ exit = { kind: "horizon", price: last.c, t: last.t + barSeconds };
218
+ }
219
+ const exitT = exit.t;
220
+ const fundingEvents = seriesFor(labels.funding, action.symbol);
221
+ const fundingCost = (fundingEvents ?? [])
222
+ .map((f) => ({ ...f, t: seconds(f.t) }))
223
+ .filter((f) => f.t > asOfSec && f.t <= exitT)
224
+ .reduce((s, f) => s + (long ? f.rate : -f.rate) * lev, 0);
225
+ const fees = (2 * FUTURES_SYNTHETIC_FEE_BPS * lev) / 10_000;
226
+ const gross = ((long ? 1 : -1) * (exit.price - entry) * lev) / entry;
227
+ // Liquidation forfeits the margin (openapi: no adverse fill cost on top).
228
+ const returnOnMargin = exit.kind === "liquidation" ? -1 : Math.max(-1, gross - fees - fundingCost);
229
+ return {
230
+ status: "labelled",
231
+ venue: "futures",
232
+ basis: "ohlc_walk_model",
233
+ exit: exit.kind,
234
+ exitPrice: exit.price,
235
+ sameBarStopAndTarget: sameBar,
236
+ // Candle times are epoch multiples of the bar size: a horizon end off
237
+ // that grid was cut back to the last full bar before it.
238
+ horizonFlooredToBar: Number.isFinite(horizonEnd) && horizonEnd % barSeconds !== 0,
239
+ fundingIncluded: fundingEvents !== undefined,
240
+ returnOnMargin,
241
+ pnlMusd: returnOnMargin * action.marginMusd,
242
+ };
243
+ }
244
+ /**
245
+ * Score one ACCEPTED action. Only risk-increasing PM and futures opens are
246
+ * scored in this slice; closes, protection updates, cancels and spot orders
247
+ * are "not_scored". An open with no matching label is "unlabelled"; a futures
248
+ * open whose bar path has holes is "unlabelled_gap".
249
+ */
250
+ export function scoreAction(input) {
251
+ const { action, quote, asOf, labels } = input;
252
+ if (action.type !== "pm_open" && action.type !== "futures_open")
253
+ return { status: "not_scored", reason: `${action.type}_not_scored` };
254
+ if (!labels)
255
+ return { status: "unlabelled", reason: "no_label_file" };
256
+ return action.type === "pm_open"
257
+ ? scorePm(action, quote, labels)
258
+ : scoreFutures(action, quote, asOf, labels);
259
+ }
@@ -0,0 +1,51 @@
1
+ import { ClientConfig, CoinRithmClient } from "../client.js";
2
+ import { Provider } from "../providers.js";
3
+ import { AgentSpec } from "../types.js";
4
+ import { Cassette, RecordedResponse } from "./cassette.js";
5
+ export declare const BENCH_RUN_ID = "bench";
6
+ export declare const BENCH_WRITE_REFUSED = "bench_write_refused";
7
+ export type FetchInput = Parameters<typeof fetch>[0];
8
+ export type FetchInit = Parameters<typeof fetch>[1];
9
+ export declare function urlOf(input: FetchInput): string;
10
+ export declare function jsonResponse(status: number, body: unknown): Response;
11
+ export declare function writeRefusedResponse(): Response;
12
+ /** A brain that never calls a model: every cycle is a skip. */
13
+ export declare const SKIP_PROVIDER: Provider;
14
+ export declare class ResponseRecorder {
15
+ private readonly inner;
16
+ private readonly basePath;
17
+ private readonly entries;
18
+ private readonly frozen;
19
+ readonly refused: string[];
20
+ readonly fetch: typeof fetch;
21
+ constructor(inner: typeof fetch, basePath: string);
22
+ /**
23
+ * Keep every key recorded so far. Later reads can add new keys, but cannot
24
+ * replace these stored responses. Transport reads still run normally.
25
+ */
26
+ freeze(): void;
27
+ responses(): RecordedResponse[];
28
+ private keep;
29
+ private handle;
30
+ }
31
+ /** A production CoinRithmClient whose transport is a ResponseRecorder. */
32
+ export declare class RecordingClient extends CoinRithmClient {
33
+ readonly recorder: ResponseRecorder;
34
+ constructor(cfg: ClientConfig);
35
+ }
36
+ export interface RecordCassetteOptions {
37
+ spec: AgentSpec;
38
+ mergedProse: string;
39
+ apiKey: string;
40
+ baseUrl?: string;
41
+ fetchFn?: typeof fetch;
42
+ log?: (line: string) => void;
43
+ }
44
+ /**
45
+ * Record one cassette: run the real runCycle in DRY-RUN through a
46
+ * RecordingClient with a brain that never calls a model, including the
47
+ * original PM board when the agent enables that venue.
48
+ * Baselines override the decision policy, not the recorded opportunity set,
49
+ * so no second pass or later market snapshot is needed.
50
+ */
51
+ export declare function recordCassette(opts: RecordCassetteOptions): Promise<Cassette>;
@@ -0,0 +1,193 @@
1
+ // Recording side of the bench: a real CoinRithmClient whose transport records
2
+ // every READ response into a cassette, and refuses every write.
3
+ //
4
+ // The recorder sits at the fetch layer (ClientConfig.fetchFn) rather than
5
+ // wrapping client methods. That keeps the production client code in the path
6
+ // (URL building, auth header, 429 handling, JSON parsing), records the public
7
+ // whale reads too, and needs no knowledge of which client method made a call:
8
+ // a new read added to observe() is recorded automatically.
9
+ //
10
+ // Write refusal is structural, not a convention: recording runs the cycle in
11
+ // DRY-RUN (live=false) with a stub brain that always skips, so no write should
12
+ // ever be attempted. If one is (a mechanical baseline asking for a quote, or a
13
+ // future runner change), the recorder records nothing, never forwards it, and
14
+ // answers 599 bench_write_refused. Only GET ever reaches the network.
15
+ import { CoinRithmClient, DEFAULT_BASE_URL } from "../client.js";
16
+ import { buildAgentDefinitionSnapshot } from "../definitionSnapshot.js";
17
+ import { clearPmCalibrationCache } from "../observe.js";
18
+ import { runCycle } from "../runner.js";
19
+ import { newState } from "../state.js";
20
+ import { asObj } from "../extract.js";
21
+ import { basePathOf, canonicalJson, canonicalRequest, CASSETTE_SCHEMA, cassetteId, recordingSpec, sha256Hex, } from "./cassette.js";
22
+ export const BENCH_RUN_ID = "bench";
23
+ export const BENCH_WRITE_REFUSED = "bench_write_refused";
24
+ // Statuses whose Response must carry a null body (the constructor throws
25
+ // otherwise).
26
+ const NULL_BODY_STATUS = new Set([204, 205, 304]);
27
+ export function urlOf(input) {
28
+ if (typeof input === "string")
29
+ return input;
30
+ if (input instanceof URL)
31
+ return input.href;
32
+ return input.url;
33
+ }
34
+ export function jsonResponse(status, body) {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { "content-type": "application/json" },
38
+ });
39
+ }
40
+ export function writeRefusedResponse() {
41
+ return jsonResponse(599, {
42
+ error: BENCH_WRITE_REFUSED,
43
+ message: "the bench never forwards writes; recording and replay are dry-run",
44
+ });
45
+ }
46
+ /** A brain that never calls a model: every cycle is a skip. */
47
+ export const SKIP_PROVIDER = {
48
+ label: "bench-skip",
49
+ decide: async () => ({
50
+ ok: true,
51
+ text: JSON.stringify({ decision: "skip", reason: "bench_no_model" }),
52
+ }),
53
+ };
54
+ export class ResponseRecorder {
55
+ inner;
56
+ basePath;
57
+ entries = new Map();
58
+ frozen = new Set();
59
+ refused = [];
60
+ fetch;
61
+ constructor(inner, basePath) {
62
+ this.inner = inner;
63
+ this.basePath = basePath;
64
+ this.fetch = ((input, init) => this.handle(input, init));
65
+ }
66
+ /**
67
+ * Keep every key recorded so far. Later reads can add new keys, but cannot
68
+ * replace these stored responses. Transport reads still run normally.
69
+ */
70
+ freeze() {
71
+ for (const key of this.entries.keys())
72
+ this.frozen.add(key);
73
+ }
74
+ responses() {
75
+ return [...this.entries.values()].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
76
+ }
77
+ keep(entry) {
78
+ // Last wins within a pass: after 429 retries the final answer is the one
79
+ // the client actually used.
80
+ if (!this.frozen.has(entry.key))
81
+ this.entries.set(entry.key, entry);
82
+ }
83
+ async handle(input, init) {
84
+ const req = canonicalRequest(init?.method ?? (input instanceof Request ? input.method : "GET"), urlOf(input), this.basePath);
85
+ if (req.method !== "GET") {
86
+ this.refused.push(req.key);
87
+ return writeRefusedResponse();
88
+ }
89
+ let res;
90
+ try {
91
+ res = await this.inner(input, init);
92
+ }
93
+ catch (error) {
94
+ this.keep({
95
+ ...req,
96
+ status: 0,
97
+ ok: false,
98
+ data: { error: "network_error" },
99
+ transportError: true,
100
+ });
101
+ throw error;
102
+ }
103
+ const text = await res.text();
104
+ let data = text;
105
+ if (text) {
106
+ try {
107
+ data = JSON.parse(text);
108
+ }
109
+ catch {
110
+ /* leave as text, exactly as the client does */
111
+ }
112
+ }
113
+ this.keep({ ...req, status: res.status, ok: res.ok, data });
114
+ return new Response(NULL_BODY_STATUS.has(res.status) ? null : text, {
115
+ status: res.status,
116
+ statusText: res.statusText,
117
+ headers: res.headers,
118
+ });
119
+ }
120
+ }
121
+ /** A production CoinRithmClient whose transport is a ResponseRecorder. */
122
+ export class RecordingClient extends CoinRithmClient {
123
+ recorder;
124
+ constructor(cfg) {
125
+ const recorder = new ResponseRecorder(cfg.fetchFn ?? fetch, basePathOf(cfg.baseUrl ?? DEFAULT_BASE_URL));
126
+ super({ ...cfg, fetchFn: recorder.fetch });
127
+ this.recorder = recorder;
128
+ }
129
+ }
130
+ /**
131
+ * Record one cassette: run the real runCycle in DRY-RUN through a
132
+ * RecordingClient with a brain that never calls a model, including the
133
+ * original PM board when the agent enables that venue.
134
+ * Baselines override the decision policy, not the recorded opportunity set,
135
+ * so no second pass or later market snapshot is needed.
136
+ */
137
+ export async function recordCassette(opts) {
138
+ const client = new RecordingClient({
139
+ apiKey: opts.apiKey,
140
+ baseUrl: opts.baseUrl,
141
+ fetchFn: opts.fetchFn,
142
+ });
143
+ // The own-calibration read is cached per credential for 30 minutes. Clear
144
+ // it so every cassette records the read instead of inheriting a cache hit.
145
+ clearPmCalibrationCache();
146
+ const result = await runCycle({
147
+ client,
148
+ provider: SKIP_PROVIDER,
149
+ spec: recordingSpec(opts.spec),
150
+ mergedProse: opts.mergedProse,
151
+ state: newState(BENCH_RUN_ID),
152
+ live: false,
153
+ log: opts.log,
154
+ });
155
+ const responses = client.recorder.responses();
156
+ const pmDiscovery = responses.filter((r) => r.path === "/api/agent/pm/discover");
157
+ // Keep the legacy field name, but never confuse "pm enabled" with a
158
+ // recorded PM observation. An empty successful board is valid; missing or
159
+ // failed reads and pre-observation exits are not baseline-ready.
160
+ const marketBaselineRecorded = opts.spec.venues.includes("pm") &&
161
+ result.decisionInputRecord?.phase === "decision_input" &&
162
+ responses.some((r) => r.path === "/api/agent/positions/pm" &&
163
+ r.ok &&
164
+ Array.isArray(asObj(r.data).positions)) &&
165
+ pmDiscovery.length > 0 &&
166
+ pmDiscovery.every((r) => {
167
+ const body = asObj(r.data);
168
+ return r.ok && Array.isArray(body.data ?? body.markets ?? body.results);
169
+ });
170
+ // A trades cursor predates subsequent reads. Scoring from it would allow
171
+ // prices inside the input-collection window to count as future outcomes.
172
+ const clockMs = Date.now();
173
+ const recordedAt = new Date(clockMs).toISOString();
174
+ const asOf = recordedAt;
175
+ return {
176
+ schema: CASSETTE_SCHEMA,
177
+ id: cassetteId(asOf, sha256Hex(canonicalJson(responses))),
178
+ recordedAt,
179
+ clockMs,
180
+ asOf,
181
+ agentSpecHash: buildAgentDefinitionSnapshot(opts.spec, opts.mergedProse)
182
+ .definitionHash,
183
+ spec: opts.spec,
184
+ marketBaselineRecorded,
185
+ recordCycle: {
186
+ decision: result.decision,
187
+ ...(result.decisionType ? { decisionType: result.decisionType } : {}),
188
+ ...(result.skipReason ? { skipReason: result.skipReason } : {}),
189
+ },
190
+ refusedRequests: [...client.recorder.refused],
191
+ responses,
192
+ };
193
+ }
@@ -0,0 +1,41 @@
1
+ import { CoinRithmClient } from "../client.js";
2
+ import { Cassette } from "./cassette.js";
3
+ export declare const REPLAY_BASE_URL = "https://bench-replay.invalid";
4
+ export declare const NOT_RECORDED_STATUS = 599;
5
+ export interface SynthesizedQuote {
6
+ venue: "pm" | "futures" | "spot";
7
+ /** source/slug/outcome for PM, coinId for futures and spot. */
8
+ subject: string;
9
+ eligible: boolean;
10
+ /** What the price came from, or why the quote was ineligible. */
11
+ basis: string;
12
+ }
13
+ export interface ReplayStats {
14
+ served: number;
15
+ /** Canonical keys of reads the cassette did not contain (in call order). */
16
+ missing: string[];
17
+ synthesizedQuotes: SynthesizedQuote[];
18
+ refusedWrites: string[];
19
+ }
20
+ interface RecordedPmOutcome {
21
+ probability?: number;
22
+ freshness: string;
23
+ eligible: boolean;
24
+ }
25
+ /**
26
+ * The chosen PM outcome as the recording's discover reads reported it. Every
27
+ * recorded discover page is searched in key order (deterministic); the first
28
+ * match wins. Mirrors observe()'s expandPmMarkets field fallbacks.
29
+ */
30
+ export declare function findRecordedPmOutcome(cassette: Cassette, source: string, slug: string, outcomeExternalMarketId: string): RecordedPmOutcome | undefined;
31
+ /** The recorded /market price and freshness for a coin, if it was read. */
32
+ export declare function findRecordedPrice(cassette: Cassette, coinId: string): {
33
+ priceUsd?: number;
34
+ freshness: string;
35
+ } | undefined;
36
+ /** A production CoinRithmClient that answers from one cassette. */
37
+ export declare class ReplayClient extends CoinRithmClient {
38
+ readonly stats: ReplayStats;
39
+ constructor(cassette: Cassette);
40
+ }
41
+ export {};