@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
package/CHANGELOG.md CHANGED
@@ -5,11 +5,130 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
5
5
  self-host agent runner) — versioned together. The CoinRithm **API contract** is
6
6
  versioned separately (see `openapi.yaml` `info.version`, currently `1.7.0`).
7
7
 
8
- ## 0.7.15 (current source, not yet published)
9
-
10
- These notes include source changes through 1 October 2026. The frozen
11
- September 28 draft archives omit later changes and must be replaced with a
12
- new reviewed artifact set before this complete release is uploaded.
8
+ ## 0.7.16 (unpublished)
9
+
10
+ - Preserve independently timed funding-by-venue context in watchlist and
11
+ discovered entries, including regular and compact prompts. Keep raw rates,
12
+ interval normalization and unknown source/next-funding times distinct from
13
+ settlement funding.
14
+ - Bench reports retain up to two safe route attempts with response source,
15
+ failure class, actions-string diagnostic and admission categories. Legacy
16
+ missing evidence remains unknown; error text, latency, prompts and raw model
17
+ output are excluded. Record explicit truncation for nonstandard providers.
18
+ - Carry open interest, positioning, sampled liquidations, macro proxies,
19
+ chain TVL, stablecoin supply and sampled order-book depth through the runner
20
+ with their separate freshness, coverage and provenance limits.
21
+ - Keep news source URLs/categories and every related PM coin in context; add
22
+ the opt-in per-coin-horizon PM cap. Objective guidance stays within the
23
+ authored strategy and hard caps. Use the supported MCP SDK 1.31 dependency.
24
+
25
+ - Bench: the PM baselines now preserve the recorded agent's PM board policy
26
+ (for house agents: depth 30, churn removed), the original opportunity set.
27
+ Before, they asked for the uncurated 12-row page a live mechanical agent
28
+ reads, and a cassette recorded without it (house Mia) reported the market
29
+ as missing. Originally mechanical recordings keep their uncurated board;
30
+ live mechanical agents are unchanged. New recordings need only the original
31
+ pass and mark PM baseline readiness after successful PM reads, rather than
32
+ assuming that an enabled PM venue guarantees recorded evidence.
33
+ - Bench: two more PM baselines, `baseline:base-rate` (uninformative 50%) and
34
+ `baseline:random` (seeded 20-80%), next to `baseline:skip` and
35
+ `baseline:market`. They replay the same recorded market pass, so existing
36
+ corpora need no re-recording; futures/spot-only corpora still get skip only.
37
+ - Add `coinrithm-agent label --corpus <dir> [--horizon-hours 24] [--overwrite]`:
38
+ writes `labels/<cassetteId>.json` price bars from candles published after
39
+ each cassette's asOf (reads only). It waits for the horizon to elapse, uses
40
+ the finest bar size the cassette's age still allows, keeps existing PM and
41
+ funding labels, and writes PM settlement only from the public event
42
+ verdict (settlement-eligible + "settle" + a provider won/lost outcome
43
+ result; anything else stays unlabelled). Only
44
+ bars that close by the horizon are stored, and an existing file for
45
+ another horizon is reported (`horizon_mismatch`), never kept silently.
46
+ - Bench futures scoring walks only bars that open after asOf and close by
47
+ the horizon end; a horizon off the bar grid stops at the last full bar
48
+ before it (`horizonFlooredToBar`), so no price after the horizon is used.
49
+ - Add optional per-agent `signals` thresholds (`rsiOversold`, `rsiOverbought`,
50
+ `strongMovePct`, `leanMovePct`, `minStrength`) and a `data.indicatorRange`
51
+ data diet (`1D` 5m bars, `1W` 15m, `1M` 1h, `3M` 4h). They change the setup
52
+ shortlist AND the gate that decides when the model is called, so personality
53
+ changes what an agent notices. Absent blocks keep the previous constants and
54
+ the 1D range exactly. Both are validated fail-closed, strict-linted and
55
+ `$ref`-able; the prompt names the agent's bar size and RSI levels.
56
+
57
+ - Every watch entry now carries perpetual open interest when the API serves it
58
+ (`openInterest`: single-side USD over Bybit and OKX, 1h and 24h change,
59
+ `asOf`, `stale`), read from the existing `/api/agent/market` call. The prompt
60
+ explains how to read it with price; it is context, never a trade rule. Older
61
+ APIs without the block leave entries unchanged.
62
+
63
+ - Add an agent test bench: `coinrithm-agent record` saves the read inputs of
64
+ dry-run cycles as cassettes (writes refused, no model call), and
65
+ `coinrithm-agent bench` replays them through the production runner for
66
+ several agent variants and repeats, next to skip and market-implied
67
+ baselines. The deterministic report gives reject codes, action overlap,
68
+ repeat consistency, seeded paired bootstrap CIs, a calibrated A/A null
69
+ rate, a chronological holdout and optional label scoring. Quotes are
70
+ synthesized and counted; see "Bench" in docs/agent-runner.md for the limits.
71
+
72
+ - Add an optional `universe` block (market boundaries): a market-cap rank
73
+ band, a minimum 24h volume, stablecoins in or out, curated sectors and chain
74
+ ecosystems in or out, and the sort that defines an opportunity. Each cycle
75
+ the runner asks `GET /api/agent/universe` for rows inside those boundaries,
76
+ resolves the top `resolveTop` (max 10) into full `discovered` watch entries
77
+ and passes the rest as context. Rows are re-checked client-side, so a server
78
+ can narrow the set but never widen it; the blocklist still wins and every
79
+ cap applies. A failed screener degrades to the watchlist only. The block
80
+ can live in its own file via `$ref`. Without it, `universe_scan` keeps the
81
+ previous top-gainers scan exactly. Needs backend-v2 with the screener.
82
+
83
+ - Add three optional, runner-enforced prediction-market policy fields, so
84
+ numbers that strategies stated only in prose become executable. Absent keeps
85
+ the previous behaviour exactly; malformed values fail closed.
86
+ - `risk.pmMinEdgeGapPct` (0..100): the forecast must beat the fee-inclusive
87
+ cost by that share of the room left to 100 (16 means cost 50 needs 58). It
88
+ never lowers the global minimum edge, and with it set a `pm_open` without
89
+ `forecastProbability` is rejected (`pm_forecast_required`,
90
+ `pm_edge_below_gap_rule`). Mechanical benchmarks are exempt.
91
+ - `risk.pmMaxEdgePoints` (0..100): overconfidence guard; the forecast may
92
+ beat the fee-inclusive cost by at most that many points, and a forecast
93
+ becomes mandatory (`pm_edge_overconfident`). This is an optional action
94
+ constraint; its effect on future performance requires separate evaluation.
95
+ - `risk.pmMaxOpenPerEvent` (1..50): open bets per event (source + slug),
96
+ counting held positions and opens accepted this cycle (`pm_event_cap`).
97
+ - `risk.pmMaxOpenPerCoinHorizon` (1..50): open bets on the same directly
98
+ linked coin whose events end within 24 h of each other, across different
99
+ events, counting held positions and opens accepted this cycle
100
+ (`pm_coin_horizon_cap`; a value outside 1..50 fails closed). Every linked
101
+ coin is kept. Reads the additive discover `relatedCoins` and
102
+ `/positions/pm` `relatedCoins` / `eventEndDate`; a missing coin, or an end
103
+ that is missing, past or over 5 years out, never blocks. A count cap, not
104
+ a correlation model, over the open positions the feed returns (at most
105
+ 200, newest first).
106
+ - `risk.pmMinMinutesToClose` (0..10080): reject a market whose known close is
107
+ nearer than that to the later of `observation.asOf` and the validation
108
+ clock after the model call (`pm_closes_too_soon`). An unknown or
109
+ unparseable close never blocks.
110
+
111
+ - Keep repeated provider 404/410 availability failures out of the generic model
112
+ failure kill-switch. BYO and self-hosted agents retain their configured model
113
+ and provider holds without entering a disable/revive loop. Transient failures
114
+ and invalid decisions still count toward the existing kill-switch.
115
+ - Keep rejected model text out of retained parser errors: JSON syntax errors
116
+ omit input excerpts, and schema errors omit rejected enum values and unknown
117
+ field names. Useful field/type diagnostics remain; validation is unchanged.
118
+ - Reject OpenAI-compatible and Anthropic responses that explicitly end at the
119
+ output token limit, and Anthropic responses stopped at the context-window
120
+ limit, even if their partial decision text happens to be valid JSON. No action
121
+ from that incomplete response reaches execution. Preserve reported token usage
122
+ and the hosted router's existing bounded malformed-response fallback. A
123
+ truncated direct-provider retry also retains its malformed classification.
124
+ - Classify rejected actions strings with bounded, fixed diagnostic categories.
125
+ JSON5 is used only for diagnostics; string-valued actions remain rejected.
126
+ No raw model output is retained by this diagnostic path.
127
+
128
+ ## 0.7.15 (published 2026-10-02)
129
+
130
+ The verified release includes source changes through 1 October 2026. Its public
131
+ archive and MCP Registry delivery are recorded in the repository publishing log.
13
132
 
14
133
  - PM board rows carry event settlement context from discovery's optional
15
134
  `resolution`. Where `scope` is `per_outcome`, each row also carries its own
@@ -203,9 +322,9 @@ Source changes following the published 0.7.9 release. The TypeScript SDK remains
203
322
  - Pin workflow actions and verify release-tool checksums. Add installed-package
204
323
  compatibility and restart smoke checks across operating systems and runtimes.
205
324
 
206
- 0.7.10 was published and deployed on 2026-09-15. Registry and GitHub downloads
207
- matched the CI-tested archive; hosted MCP and scheduler deployments finished.
208
- See the [release record](https://github.com/CoinRithm/coinrithm-agent-trading/releases/tag/mcp-trading-v0.7.10).
325
+ 0.7.10 was published and deployed on 2026-09-15. Registry and GitHub downloads
326
+ matched the CI-tested archive; hosted MCP and scheduler deployments finished.
327
+ See the [release record](https://github.com/CoinRithm/coinrithm-agent-trading/releases/tag/mcp-trading-v0.7.10).
209
328
 
210
329
  ## 0.7.9 - 2026-09-15
211
330
 
package/README.md CHANGED
@@ -44,9 +44,24 @@ This package ships two binaries:
44
44
 
45
45
  > **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
46
46
 
47
- ## Version 0.7.15
47
+ ## Version 0.7.16 (unpublished)
48
48
 
49
- This release preserves observed-price evidence in public market summaries
49
+ The runner rejects explicitly incomplete provider responses before execution,
50
+ including valid-looking prefixes, and preserves their token usage. Bounded
51
+ shape diagnostics still reject string-valued actions. These guards do not raise
52
+ completion limits, repair model proposals or change customer strategies.
53
+
54
+ This release also includes configurable universe and signal boundaries, PM
55
+ policies, objective guidance, separately timed market context and the
56
+ `record`/`bench`/`label` evaluation tools. Funding-by-venue context does not
57
+ change settlement; optional data retains source, coverage and timing limits.
58
+ Benchmarks preserve missing outcomes and bounded route diagnostics. They do not
59
+ establish a profitable strategy or a superior model. See the changelog and
60
+ [runner documentation](https://github.com/CoinRithm/coinrithm-agent-trading/blob/main/docs/agent-runner.md).
61
+
62
+ The preceding published version, 0.7.15, includes the following capabilities.
63
+
64
+ It preserves observed-price evidence in public market summaries
50
65
  (`hasObservedPrice` and bounded `sourceObservation`, including both sides of
51
66
  venue comparisons), keeps full public `walletAddress` values in whale summaries
52
67
  for `pm_data_whale_wallet` follow-up, adds the opt-in `whale_context` runner
@@ -106,7 +121,7 @@ summaries to the model observation for non-mechanical providers. The read is bes
106
121
  availability status; it never expands PM candidates or bypasses risk and write
107
122
  gates. Owners can enable it in Agent Studio under Risk & caps; existing agents
108
123
  remain opted out unless their owners choose it. It ships in package version
109
- 0.7.15; published MCP `0.7.14` does not include it.
124
+ 0.7.15; earlier MCP `0.7.14` does not include it.
110
125
  The CoinRithm hosted scheduler runs this same engine for you — see the
111
126
  [scheduler README](https://github.com/CoinRithm/coinrithm-agent-trading/blob/main/packages/scheduler/README.md) for the built,
112
127
  DB-driven runtime.
@@ -0,0 +1,59 @@
1
+ import { type ObserveOptions } from "../observe.js";
2
+ import { Provider } from "../providers.js";
3
+ import { AgentSpec, ProposedAction } from "../types.js";
4
+ import { Cassette } from "./cassette.js";
5
+ import type { BenchmarkStrategy } from "../mechanical.js";
6
+ import { LabelFile } from "./labels.js";
7
+ import { BenchReport, CycleRow } from "./report.js";
8
+ export declare const BASELINE_SKIP = "baseline:skip";
9
+ export declare const BASELINE_MARKET = "baseline:market";
10
+ export declare const BASELINE_BASE_RATE = "baseline:base-rate";
11
+ export declare const BASELINE_RANDOM = "baseline:random";
12
+ /**
13
+ * Mechanical PM baselines, one deterministic run per cassette each: the
14
+ * market's own probability, an uninformative 50, and a seeded random
15
+ * forecast (mechanical.ts). They need the recorded PM observation, so a
16
+ * futures/spot-only corpus has only baseline:skip.
17
+ */
18
+ export declare const MECHANICAL_BASELINES: ReadonlyArray<readonly [name: string, strategy: BenchmarkStrategy]>;
19
+ export declare const DEFAULT_REPEATS = 3;
20
+ export declare const MAX_REPEATS = 50;
21
+ export declare const DEFAULT_SEED = 20261007;
22
+ export declare const DEFAULT_BOOTSTRAP_RESAMPLES = 2000;
23
+ export interface BenchVariant {
24
+ name: string;
25
+ spec: AgentSpec;
26
+ mergedProse: string;
27
+ provider: Provider;
28
+ }
29
+ export interface RunBenchOptions {
30
+ cassettes: Cassette[];
31
+ variants: BenchVariant[];
32
+ repeats?: number;
33
+ labels?: Record<string, LabelFile>;
34
+ seed?: number;
35
+ bootstrapResamples?: number;
36
+ /** Include the skip and market-implied baselines (default true). */
37
+ baselines?: boolean;
38
+ log?: (line: string) => void;
39
+ }
40
+ /**
41
+ * Run `fn` with Date.now() reading `clockMs` plus the real time elapsed since
42
+ * the call. Restored afterwards even when `fn` throws. `new Date()` with no
43
+ * argument is NOT shifted (it does not call Date.now); in the runner it only
44
+ * stamps the whale-context fetchedAt and fallback asOf values.
45
+ */
46
+ export declare function withReplayClock<T>(clockMs: number, fn: () => Promise<T>): Promise<T>;
47
+ /**
48
+ * The identity of a move WITHOUT its size, for action-set overlap and repeat
49
+ * consistency: did the variant do the same thing, not the same amount.
50
+ */
51
+ export declare function actionKey(action: ProposedAction): string;
52
+ /** Replay one cassette for one variant once, through the real runCycle. */
53
+ export declare function replayCycle(cassette: Cassette, variant: string, repeat: number, spec: AgentSpec, mergedProse: string, provider: Provider, labels?: LabelFile, observeOptions?: ObserveOptions): Promise<CycleRow>;
54
+ /**
55
+ * Every modelling assumption behind the numbers, written into each report so
56
+ * a reader never has to guess what a "stop" or a "cost" means here.
57
+ */
58
+ export declare function benchAssumptions(): Record<string, unknown>;
59
+ export declare function runBench(opts: RunBenchOptions): Promise<BenchReport>;
@@ -0,0 +1,319 @@
1
+ // The bench: run variants of an agent on the SAME recorded inputs, K times
2
+ // each, next to built-in baselines, and report honest paired statistics.
3
+ //
4
+ // Every replayed cycle goes through the production runCycle in DRY-RUN with a
5
+ // ReplayClient, so prompt building, parsing, ref resolution, capital sizing,
6
+ // validation and every reject code are exactly the shipped code paths. There
7
+ // is no bench-only decision logic to drift from production.
8
+ //
9
+ // Each cycle starts from a fresh run state (the same state recording used),
10
+ // so daily counters, debounce and journal can never leak between cassettes or
11
+ // repeats. The consequence: replay shows how a variant decides on a first
12
+ // look at a recorded market, not how it behaves deep into a running streak.
13
+ //
14
+ // Clock: Date.now() reads the recording's clock plus the real time elapsed in
15
+ // this cycle, so freshness and entry-predicate age checks see the age the data
16
+ // had when recorded (plus the variant's own model latency, as in production).
17
+ import { buildAgentDefinitionSnapshot } from "../definitionSnapshot.js";
18
+ import { clearPmCalibrationCache } from "../observe.js";
19
+ import { runCycle } from "../runner.js";
20
+ import { newState } from "../state.js";
21
+ import { mechanicalBaselineSpec } from "./cassette.js";
22
+ import { FUTURES_SYNTHETIC_FEE_BPS, PM_SYNTHETIC_FEE_RATE_AT_MID, SPOT_SYNTHETIC_FEE_BPS, } from "./costs.js";
23
+ import { scoreAction } from "./labels.js";
24
+ import { BENCH_RUN_ID, SKIP_PROVIDER } from "./recordingClient.js";
25
+ import { ReplayClient } from "./replayClient.js";
26
+ import { buildReport, chronological, } from "./report.js";
27
+ export const BASELINE_SKIP = "baseline:skip";
28
+ export const BASELINE_MARKET = "baseline:market";
29
+ export const BASELINE_BASE_RATE = "baseline:base-rate";
30
+ export const BASELINE_RANDOM = "baseline:random";
31
+ /**
32
+ * Mechanical PM baselines, one deterministic run per cassette each: the
33
+ * market's own probability, an uninformative 50, and a seeded random
34
+ * forecast (mechanical.ts). They need the recorded PM observation, so a
35
+ * futures/spot-only corpus has only baseline:skip.
36
+ */
37
+ export const MECHANICAL_BASELINES = [
38
+ [BASELINE_MARKET, "market-implied"],
39
+ [BASELINE_BASE_RATE, "base-rate"],
40
+ [BASELINE_RANDOM, "random"],
41
+ ];
42
+ export const DEFAULT_REPEATS = 3;
43
+ export const MAX_REPEATS = 50;
44
+ export const DEFAULT_SEED = 20261007;
45
+ export const DEFAULT_BOOTSTRAP_RESAMPLES = 2000;
46
+ const VARIANT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
47
+ /**
48
+ * Run `fn` with Date.now() reading `clockMs` plus the real time elapsed since
49
+ * the call. Restored afterwards even when `fn` throws. `new Date()` with no
50
+ * argument is NOT shifted (it does not call Date.now); in the runner it only
51
+ * stamps the whale-context fetchedAt and fallback asOf values.
52
+ */
53
+ export async function withReplayClock(clockMs, fn) {
54
+ const realNow = Date.now;
55
+ const offset = clockMs - realNow();
56
+ Date.now = () => realNow() + offset;
57
+ try {
58
+ return await fn();
59
+ }
60
+ finally {
61
+ Date.now = realNow;
62
+ }
63
+ }
64
+ /**
65
+ * The identity of a move WITHOUT its size, for action-set overlap and repeat
66
+ * consistency: did the variant do the same thing, not the same amount.
67
+ */
68
+ export function actionKey(action) {
69
+ const lower = (s) => (s ?? "").toLowerCase();
70
+ const upper = (s) => (s ?? "").toUpperCase();
71
+ switch (action.type) {
72
+ case "pm_open":
73
+ return `pm_open:${lower(action.source)}/${lower(action.slug)}/${action.outcomeExternalMarketId ?? action.ref ?? ""}`;
74
+ case "futures_open":
75
+ return `futures_open:${upper(action.symbol)}:${action.side}`;
76
+ case "futures_close":
77
+ return `futures_close:${action.positionId}`;
78
+ case "futures_set_sltp":
79
+ return `futures_set_sltp:${action.positionId}`;
80
+ case "spot_order":
81
+ return `spot_order:${upper(action.symbol)}:${action.side}:${action.orderType}`;
82
+ case "spot_cancel":
83
+ return `spot_cancel:${action.orderId}`;
84
+ }
85
+ }
86
+ function sizeOf(action) {
87
+ if (action.type === "pm_open")
88
+ return action.stakeMusd;
89
+ if (action.type === "futures_open")
90
+ return action.marginMusd;
91
+ return undefined;
92
+ }
93
+ function toRow(cassette, variant, repeat, result, runtimeError, client, labels) {
94
+ const actions = (result?.planned ?? []).map((p) => {
95
+ const forecast = p.action
96
+ .forecastProbability;
97
+ return {
98
+ key: actionKey(p.action),
99
+ type: p.action.type,
100
+ accepted: p.accepted,
101
+ ...(p.accepted ? {} : { code: p.code ?? "unknown" }),
102
+ ...(p.quote ? { quote: "synthesized" } : {}),
103
+ ...(typeof forecast === "number"
104
+ ? { forecastProbability: forecast }
105
+ : {}),
106
+ ...(p.quote?.entryProbability !== undefined
107
+ ? { entryProbability: p.quote.entryProbability }
108
+ : {}),
109
+ ...(sizeOf(p.action) !== undefined ? { sizeMusd: sizeOf(p.action) } : {}),
110
+ ...(p.accepted
111
+ ? {
112
+ score: scoreAction({
113
+ action: p.action,
114
+ quote: p.quote,
115
+ asOf: cassette.asOf,
116
+ labels,
117
+ }),
118
+ }
119
+ : {}),
120
+ };
121
+ });
122
+ const decisionType = runtimeError
123
+ ? "runtime_error"
124
+ : (result?.decisionType ?? (result?.decision === "act" ? "act" : "skip"));
125
+ return {
126
+ cassetteId: cassette.id,
127
+ asOf: cassette.asOf,
128
+ variant,
129
+ repeat,
130
+ decision: result?.decision ?? "skip",
131
+ decisionType,
132
+ modelFailed: result?.modelFailed === true,
133
+ llmCallMade: result?.llmCallMade === true,
134
+ ...(result?.routeAttempts
135
+ ? {
136
+ routeAttempts: result.routeAttempts.slice(0, 2).map((attempt) => ({
137
+ provider: attempt.provider,
138
+ model: attempt.model,
139
+ outcome: attempt.outcome,
140
+ ...(attempt.failureClass
141
+ ? { failureClass: attempt.failureClass }
142
+ : {}),
143
+ ...(attempt.status !== undefined ? { status: attempt.status } : {}),
144
+ ...(attempt.responseSource
145
+ ? { responseSource: attempt.responseSource }
146
+ : {}),
147
+ ...(attempt.actionsStringDiagnostic
148
+ ? { actionsStringDiagnostic: attempt.actionsStringDiagnostic }
149
+ : {}),
150
+ ...(attempt.admissionReasons
151
+ ? { admissionReasons: [...attempt.admissionReasons] }
152
+ : {}),
153
+ })),
154
+ ...(result.routeAttempts.length > 2
155
+ ? { routeAttemptsTruncated: true }
156
+ : {}),
157
+ }
158
+ : {}),
159
+ ...(result?.triggerCodes ? { triggerCodes: [...result.triggerCodes] } : {}),
160
+ ...(result?.skipReason
161
+ ? { skipReason: result.skipReason.slice(0, 200) }
162
+ : {}),
163
+ ...(runtimeError ? { runtimeError: runtimeError.slice(0, 200) } : {}),
164
+ actions,
165
+ missingInputs: [...new Set(client.stats.missing)].sort(),
166
+ synthesizedQuotes: client.stats.synthesizedQuotes.length,
167
+ refusedWrites: [...client.stats.refusedWrites],
168
+ };
169
+ }
170
+ /** Replay one cassette for one variant once, through the real runCycle. */
171
+ export async function replayCycle(cassette, variant, repeat, spec, mergedProse, provider, labels, observeOptions) {
172
+ clearPmCalibrationCache();
173
+ const client = new ReplayClient(cassette);
174
+ let result;
175
+ let runtimeError;
176
+ try {
177
+ result = await withReplayClock(cassette.clockMs, () => runCycle({
178
+ client,
179
+ provider,
180
+ spec,
181
+ mergedProse,
182
+ state: newState(BENCH_RUN_ID),
183
+ live: false,
184
+ ...(observeOptions ? { observeOptions } : {}),
185
+ }));
186
+ }
187
+ catch (error) {
188
+ // A crash is a result for this variant, never a reason to drop the cycle.
189
+ runtimeError = error instanceof Error ? error.message : String(error);
190
+ }
191
+ return toRow(cassette, variant, repeat, result, runtimeError, client, labels);
192
+ }
193
+ /** The always-skip baseline: no reads, no model, no action, by definition. */
194
+ function skipBaselineRow(cassette) {
195
+ return {
196
+ cassetteId: cassette.id,
197
+ asOf: cassette.asOf,
198
+ variant: BASELINE_SKIP,
199
+ repeat: 0,
200
+ decision: "skip",
201
+ decisionType: "skip",
202
+ modelFailed: false,
203
+ llmCallMade: false,
204
+ skipReason: "baseline: always skip",
205
+ actions: [],
206
+ missingInputs: [],
207
+ synthesizedQuotes: 0,
208
+ refusedWrites: [],
209
+ };
210
+ }
211
+ /**
212
+ * Every modelling assumption behind the numbers, written into each report so
213
+ * a reader never has to guess what a "stop" or a "cost" means here.
214
+ */
215
+ export function benchAssumptions() {
216
+ return {
217
+ quotes: "every quote is synthesized from recorded evidence (PM: the discover probability of the chosen outcome; futures/spot: the recorded market price); none comes from the paper engine",
218
+ entry: "entry at the recorded (synthesized) quote with the paper fee model below; entry latency is not modelled",
219
+ pmFee: {
220
+ constant: "PM_SYNTHETIC_FEE_RATE_AT_MID",
221
+ value: PM_SYNTHETIC_FEE_RATE_AT_MID,
222
+ model: "fee = rate x 4p(1-p) of the price paid (openapi Paper Execution Realism v1 shape: ~1.8% near 50%, ~0 at the extremes); ask spread and size slippage are not modelled, so PM cost is optimistic by an unmeasured amount",
223
+ },
224
+ futuresFee: {
225
+ constant: "FUTURES_SYNTHETIC_FEE_BPS",
226
+ value: FUTURES_SYNTHETIC_FEE_BPS,
227
+ model: "flat taker fee on notional at entry and at exit (the runner's conservative pre-quote estimate); spread and slippage not modelled",
228
+ },
229
+ spotFee: {
230
+ constant: "SPOT_SYNTHETIC_FEE_BPS",
231
+ value: SPOT_SYNTHETIC_FEE_BPS,
232
+ },
233
+ pmEntryFloor: "the synthesized quote does not apply the server floor; the runner validator rejects the same action as pm_entry_below_floor (production: quote_ineligible)",
234
+ pmOutcome: "settled 0/1 from the label file; cost = stake / net shares (fee-inclusive); Brier uses the forecast the agent sent",
235
+ futuresOutcome: "an OHLC walk MODEL over label bars that open after asOf; it is not exact fill or stop behaviour",
236
+ sameBarStopAndTarget: "a bar touching both stop and take-profit is resolved as the STOP (conservative) and counted in labelled.futuresSameBarStopFirst",
237
+ missingBars: "a missing bar before the exit (or before the horizon end) gives outcome unlabelled_gap: counted, never filled",
238
+ funding: "included only when the label file carries funding events for the symbol (labelled.futuresFundingIncluded); otherwise excluded",
239
+ liquidation: "approximated at entry x (1 -/+ 1/leverage); forfeits the margin with no extra fill cost",
240
+ state: "fresh run state per cycle: no journal, theses, debounce or daily counters carried between cassettes or repeats",
241
+ clock: "Date.now = recording clock + real elapsed time, so data ages as recorded plus model latency",
242
+ outcomeCutoff: "asOf is the end of all recording reads; it is not the earlier trades sync cursor",
243
+ pnlComparisons: "zero means a complete no-action cycle; cycles with missing inputs, runtime errors, or any unscored accepted action are excluded from paired PnL, with exclusion counts shown; all repeats of a cassette must qualify",
244
+ inference: "per-cassette bootstrap assumes independent observations; overlapping market windows can violate that assumption; repeated A/A calibration does not establish independence or future performance",
245
+ };
246
+ }
247
+ export async function runBench(opts) {
248
+ const repeats = opts.repeats ?? DEFAULT_REPEATS;
249
+ if (!Number.isInteger(repeats) || repeats < 1 || repeats > MAX_REPEATS)
250
+ throw new Error(`repeats must be an integer from 1 to ${MAX_REPEATS}`);
251
+ if (opts.variants.length === 0)
252
+ throw new Error("at least one variant is required");
253
+ const names = new Set();
254
+ for (const v of opts.variants) {
255
+ if (!VARIANT_NAME_RE.test(v.name))
256
+ throw new Error(`variant name "${v.name}" must be 1-40 letters, digits, "_", "." or "-"`);
257
+ if (names.has(v.name))
258
+ throw new Error(`duplicate variant "${v.name}"`);
259
+ names.add(v.name);
260
+ }
261
+ if (opts.cassettes.length === 0)
262
+ throw new Error("the corpus has no cassettes");
263
+ const seed = (opts.seed ?? DEFAULT_SEED) >>> 0;
264
+ const resamples = opts.bootstrapResamples ?? DEFAULT_BOOTSTRAP_RESAMPLES;
265
+ const baselines = opts.baselines !== false;
266
+ const log = opts.log ?? (() => { });
267
+ const cassettes = chronological(opts.cassettes);
268
+ const rows = [];
269
+ for (const cassette of cassettes) {
270
+ const labels = opts.labels?.[cassette.id];
271
+ for (const v of opts.variants) {
272
+ for (let r = 0; r < repeats; r++) {
273
+ const row = await replayCycle(cassette, v.name, r, v.spec, v.mergedProse, v.provider, labels);
274
+ rows.push(row);
275
+ log(`${cassette.id} ${v.name}#${r + 1}: ${row.decisionType}, ${row.actions.filter((a) => a.accepted).length}/${row.actions.length} accepted${row.missingInputs.length ? `, ${row.missingInputs.length} missing input(s)` : ""}`);
276
+ }
277
+ }
278
+ if (baselines) {
279
+ rows.push(skipBaselineRow(cassette));
280
+ // Deterministic, so one run per cassette each. Use the recorded agent's
281
+ // board policy, not the replacement mechanical provider's policy.
282
+ // Originally mechanical recordings keep their uncurated board.
283
+ if (cassette.marketBaselineRecorded)
284
+ for (const [name, strategy] of MECHANICAL_BASELINES)
285
+ rows.push(await replayCycle(cassette, name, 0, mechanicalBaselineSpec(cassette.spec, strategy), "", SKIP_PROVIDER, labels, {
286
+ curatedPmBoard: cassette.spec.model?.provider !== "mechanical",
287
+ }));
288
+ }
289
+ }
290
+ const variants = opts.variants.map((v) => ({
291
+ name: v.name,
292
+ kind: "variant",
293
+ definitionHash: buildAgentDefinitionSnapshot(v.spec, v.mergedProse)
294
+ .definitionHash,
295
+ }));
296
+ if (baselines) {
297
+ variants.push({ name: BASELINE_SKIP, kind: "baseline" });
298
+ if (cassettes.some((c) => c.marketBaselineRecorded))
299
+ for (const [name] of MECHANICAL_BASELINES)
300
+ variants.push({ name, kind: "baseline" });
301
+ }
302
+ const specHashes = [...new Set(cassettes.map((c) => c.agentSpecHash))].sort();
303
+ return buildReport({
304
+ cassettes,
305
+ rows,
306
+ variants,
307
+ repeats,
308
+ seed,
309
+ bootstrapResamples: resamples,
310
+ assumptions: benchAssumptions(),
311
+ fidelity: {
312
+ recordedAgentDefinitions: specHashes,
313
+ synthesizedQuotes: rows.reduce((s, r) => s + r.synthesizedQuotes, 0),
314
+ cyclesWithMissingInputs: rows.filter((r) => r.missingInputs.length > 0)
315
+ .length,
316
+ refusedWrites: rows.reduce((s, r) => s + r.refusedWrites.length, 0),
317
+ },
318
+ });
319
+ }
@@ -0,0 +1,97 @@
1
+ import { AgentSpec } from "../types.js";
2
+ import type { BenchmarkStrategy } from "../mechanical.js";
3
+ import { LabelFile } from "./labels.js";
4
+ export declare const CASSETTE_SCHEMA = "coinrithm.bench.cassette.v1";
5
+ export interface RecordedResponse {
6
+ /** Canonical request key: METHOD + path + sorted query. */
7
+ key: string;
8
+ method: string;
9
+ path: string;
10
+ query: Record<string, string>;
11
+ status: number;
12
+ ok: boolean;
13
+ /** The body as the client parses it: JSON when it parses, else the text. */
14
+ data: unknown;
15
+ /** The recorded request failed in transport (no HTTP response at all). */
16
+ transportError?: boolean;
17
+ }
18
+ export interface Cassette {
19
+ schema: typeof CASSETTE_SCHEMA;
20
+ id: string;
21
+ recordedAt: string;
22
+ /** Epoch ms the replay clock starts from (Date.now during replay). */
23
+ clockMs: number;
24
+ /** Cutoff after ALL recording reads, including baseline-only inputs. */
25
+ asOf: string;
26
+ /** definitionHash of the recorded spec + prose (see definitionSnapshot). */
27
+ agentSpecHash: string;
28
+ spec: AgentSpec;
29
+ /** PM observation available for baselines. Legacy recordings used this
30
+ * only to mark an attempted extra mechanical pass; replay still reports
31
+ * missing inputs instead of treating the marker as a coverage guarantee. */
32
+ marketBaselineRecorded: boolean;
33
+ recordCycle: {
34
+ decision: string;
35
+ decisionType?: string;
36
+ skipReason?: string;
37
+ };
38
+ /** Non-GET requests the recorder refused to forward (never sent). */
39
+ refusedRequests: string[];
40
+ responses: RecordedResponse[];
41
+ }
42
+ export interface CanonicalRequest {
43
+ key: string;
44
+ method: string;
45
+ path: string;
46
+ query: Record<string, string>;
47
+ }
48
+ /**
49
+ * Canonical request identity used for both recording and replay: upper-cased
50
+ * method, the path relative to the API base path, and the query parameters
51
+ * sorted by name. The host is deliberately not part of the key, so a cassette
52
+ * recorded against one base URL replays against the bench's fake one.
53
+ */
54
+ export declare function canonicalRequest(method: string, url: string, basePath?: string): CanonicalRequest;
55
+ /** The path prefix of an API base URL ("" for a host-root base). */
56
+ export declare function basePathOf(baseUrl: string): string;
57
+ export declare function sha256Hex(text: string): string;
58
+ /** Sorted-key JSON, the same canonicalization the scorecard hash relies on. */
59
+ export declare function canonicalJson(value: unknown): string;
60
+ /**
61
+ * The spec a recording pass runs with: the agent's own spec with its gate
62
+ * forced to fire, so the post-gate reads (whale context) are always recorded
63
+ * and a variant whose gate fires on replay finds them in the cassette.
64
+ */
65
+ export declare function recordingSpec(spec: AgentSpec): AgentSpec;
66
+ /**
67
+ * The market-implied baseline: the recorded agent's spec and caps with the
68
+ * deterministic mechanical "market-implied" strategy as its brain (mechanical
69
+ * .ts). runBench separately preserves the recorded agent's observation
70
+ * policy, so replacing the provider never changes the PM opportunity set.
71
+ */
72
+ export declare function marketBaselineSpec(spec: AgentSpec): AgentSpec;
73
+ /**
74
+ * The same recorded spec with one of the mechanical benchmark strategies as
75
+ * its brain. base-rate and random pick the SAME market from the SAME
76
+ * observation as market-implied (mechanical.ts); all use the original
77
+ * recording's PM board and never fetch a separate baseline universe.
78
+ */
79
+ export declare function mechanicalBaselineSpec(spec: AgentSpec, strategy: BenchmarkStrategy): AgentSpec;
80
+ /** Filesystem-safe, content-addressed cassette id: asOf stamp + body hash. */
81
+ export declare function cassetteId(asOf: string, contentHash: string): string;
82
+ export declare function writeCassette(dir: string, cassette: Cassette): string;
83
+ /** Validate a parsed cassette. Throws on a malformed one (fail closed). */
84
+ export declare function parseCassette(value: unknown, source: string): Cassette;
85
+ export interface Corpus {
86
+ cassettes: Cassette[];
87
+ labels: Record<string, LabelFile>;
88
+ /** Top-level .json files that are not cassettes (e.g. a saved report). */
89
+ ignored: string[];
90
+ }
91
+ /**
92
+ * Read every cassette at the top level of `dir` plus the optional
93
+ * `labels/<cassetteId>.json` files. A JSON file with the cassette schema that
94
+ * fails validation, or a malformed label file, stops the bench: a silently
95
+ * skipped input would bias the comparison.
96
+ */
97
+ export declare function readCorpus(dir: string): Corpus;