@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.
- package/CHANGELOG.md +811 -613
- package/README.md +415 -366
- package/dist/agent/bench/bench.d.ts +59 -0
- package/dist/agent/bench/bench.js +319 -0
- package/dist/agent/bench/cassette.d.ts +97 -0
- package/dist/agent/bench/cassette.js +181 -0
- package/dist/agent/bench/costs.d.ts +28 -0
- package/dist/agent/bench/costs.js +60 -0
- package/dist/agent/bench/labelBuilder.d.ts +123 -0
- package/dist/agent/bench/labelBuilder.js +282 -0
- package/dist/agent/bench/labels.d.ts +75 -0
- package/dist/agent/bench/labels.js +259 -0
- package/dist/agent/bench/recordingClient.d.ts +51 -0
- package/dist/agent/bench/recordingClient.js +193 -0
- package/dist/agent/bench/replayClient.d.ts +41 -0
- package/dist/agent/bench/replayClient.js +262 -0
- package/dist/agent/bench/report.d.ts +115 -0
- package/dist/agent/bench/report.js +435 -0
- package/dist/agent/cli.d.ts +25 -0
- package/dist/agent/cli.js +300 -2
- package/dist/agent/client.d.ts +14 -0
- package/dist/agent/client.js +113 -4
- package/dist/agent/configurationWarnings.js +11 -0
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +85 -3
- package/dist/agent/decisionReceipt.d.ts +5 -1
- package/dist/agent/decisionReceipt.js +52 -3
- package/dist/agent/decisionValidator.d.ts +30 -0
- package/dist/agent/decisionValidator.js +147 -2
- package/dist/agent/engine.d.ts +3 -3
- package/dist/agent/engine.js +3 -3
- package/dist/agent/futuresEligibility.d.ts +36 -0
- package/dist/agent/futuresEligibility.js +90 -0
- package/dist/agent/observe.d.ts +35 -2
- package/dist/agent/observe.js +969 -128
- package/dist/agent/pmContext.d.ts +10 -1
- package/dist/agent/pmContext.js +163 -1
- package/dist/agent/prompt.d.ts +18 -1
- package/dist/agent/prompt.js +229 -33
- package/dist/agent/promptTables.d.ts +1 -0
- package/dist/agent/promptTables.js +33 -0
- package/dist/agent/providerCapabilities.d.ts +9 -0
- package/dist/agent/providerCapabilities.js +57 -4
- package/dist/agent/providers.d.ts +28 -7
- package/dist/agent/providers.js +200 -50
- package/dist/agent/resolve.js +3 -0
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.js +159 -26
- package/dist/agent/setups.d.ts +2 -1
- package/dist/agent/setups.js +17 -16
- package/dist/agent/signals.d.ts +33 -0
- package/dist/agent/signals.js +89 -0
- package/dist/agent/skill.js +37 -0
- package/dist/agent/skillValidator.js +33 -0
- package/dist/agent/strictLint.js +29 -0
- package/dist/agent/templates.js +30 -30
- package/dist/agent/types.d.ts +266 -13
- package/dist/agent/types.js +4 -0
- package/dist/agent/universe.d.ts +78 -0
- package/dist/agent/universe.js +215 -0
- package/dist/client.d.ts +9 -2
- package/dist/client.js +22 -10
- package/dist/http.d.ts +1 -0
- package/dist/http.js +17 -2
- package/dist/httpCompletion.d.ts +1 -1
- package/dist/httpCompletion.js +1 -0
- package/dist/index.js +2 -1
- package/dist/instructions.d.ts +1 -0
- package/dist/instructions.js +16 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +150 -66
- 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 {};
|