@coinrithm/mcp-trading 0.7.2 → 0.7.3

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 (46) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +9 -7
  3. package/dist/agent/act.d.ts +2 -2
  4. package/dist/agent/act.js +24 -3
  5. package/dist/agent/cli.js +59 -18
  6. package/dist/agent/client.d.ts +33 -0
  7. package/dist/agent/client.js +34 -7
  8. package/dist/agent/decision.d.ts +3 -0
  9. package/dist/agent/decision.js +26 -3
  10. package/dist/agent/decisionValidator.js +2 -1
  11. package/dist/agent/deploymentOverlay.js +25 -5
  12. package/dist/agent/engine.d.ts +2 -1
  13. package/dist/agent/engine.js +4 -1
  14. package/dist/agent/extract.js +3 -1
  15. package/dist/agent/gate.js +25 -5
  16. package/dist/agent/index.js +0 -1
  17. package/dist/agent/indicators.js +4 -2
  18. package/dist/agent/manifest.js +1 -1
  19. package/dist/agent/mechanical.d.ts +36 -0
  20. package/dist/agent/mechanical.js +286 -0
  21. package/dist/agent/observe.js +120 -52
  22. package/dist/agent/prompt.d.ts +3 -1
  23. package/dist/agent/prompt.js +17 -6
  24. package/dist/agent/providers.js +39 -4
  25. package/dist/agent/resolve.js +23 -6
  26. package/dist/agent/resolvePm.js +14 -3
  27. package/dist/agent/runEvidence.js +6 -2
  28. package/dist/agent/runner.d.ts +8 -2
  29. package/dist/agent/runner.js +363 -59
  30. package/dist/agent/scorecard.js +12 -4
  31. package/dist/agent/setups.js +57 -9
  32. package/dist/agent/skill.js +1 -1
  33. package/dist/agent/state.js +9 -4
  34. package/dist/agent/types.d.ts +17 -2
  35. package/dist/agent/types.js +2 -1
  36. package/dist/agent/util.js +11 -4
  37. package/dist/agent/version.d.ts +1 -1
  38. package/dist/agent/version.js +1 -1
  39. package/dist/client.d.ts +33 -0
  40. package/dist/client.js +12 -3
  41. package/dist/executionPolicy.d.ts +2 -0
  42. package/dist/executionPolicy.js +21 -0
  43. package/dist/http.js +10 -2
  44. package/dist/tools.d.ts +1 -0
  45. package/dist/tools.js +214 -29
  46. package/package.json +9 -1
@@ -64,9 +64,17 @@ export function evaluateGate(observation, state, policy, nowMs) {
64
64
  if (pmAvailable &&
65
65
  policy.pmEvalCooldownMinutes > 0 &&
66
66
  sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
67
- return { fire: true, codes: ["PM_PERIODIC"], reason: "PM periodic eval (quiet price tape)" };
67
+ return {
68
+ fire: true,
69
+ codes: ["PM_PERIODIC"],
70
+ reason: "PM periodic eval (quiet price tape)",
71
+ };
68
72
  }
69
- return { fire: false, codes: [], reason: "no trigger (flat tape, no open position)" };
73
+ return {
74
+ fire: false,
75
+ codes: [],
76
+ reason: "no trigger (flat tape, no open position)",
77
+ };
70
78
  }
71
79
  // A real trigger exists. Open positions are NEVER starved by budget/debounce
72
80
  // (managing a live position is always allowed); the caps below only throttle
@@ -75,7 +83,11 @@ export function evaluateGate(observation, state, policy, nowMs) {
75
83
  if (policy.maxLlmCallsPerHour > 0) {
76
84
  const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
77
85
  if (recent.length >= policy.maxLlmCallsPerHour) {
78
- return { fire: false, codes: codeList, reason: `hourly LLM budget ${policy.maxLlmCallsPerHour} reached` };
86
+ return {
87
+ fire: false,
88
+ codes: codeList,
89
+ reason: `hourly LLM budget ${policy.maxLlmCallsPerHour} reached`,
90
+ };
79
91
  }
80
92
  }
81
93
  if (policy.debounceMinutes > 0) {
@@ -83,11 +95,19 @@ export function evaluateGate(observation, state, policy, nowMs) {
83
95
  if (state.lastTriggerFingerprint === fp &&
84
96
  state.lastLlmCallAt != null &&
85
97
  nowMs - state.lastLlmCallAt < policy.debounceMinutes * 60_000) {
86
- return { fire: false, codes: codeList, reason: `debounced (same triggers within ${policy.debounceMinutes}m)` };
98
+ return {
99
+ fire: false,
100
+ codes: codeList,
101
+ reason: `debounced (same triggers within ${policy.debounceMinutes}m)`,
102
+ };
87
103
  }
88
104
  }
89
105
  }
90
- return { fire: true, codes: codeList, reason: `triggers: ${codeList.join(",")}` };
106
+ return {
107
+ fire: true,
108
+ codes: codeList,
109
+ reason: `triggers: ${codeList.join(",")}`,
110
+ };
91
111
  }
92
112
  // Record that this cycle spent an LLM call — feeds the budget + debounce next
93
113
  // cycle. Mutates state; the caller persists it.
@@ -4,7 +4,6 @@ import { main } from "./cli.js";
4
4
  main(process.argv.slice(2))
5
5
  .then((code) => process.exit(code))
6
6
  .catch((err) => {
7
- // eslint-disable-next-line no-console
8
7
  console.error(err instanceof Error ? err.message : String(err));
9
8
  process.exit(1);
10
9
  });
@@ -133,14 +133,16 @@ export function computeIndicators(candles, opts = {}) {
133
133
  const f = Math.pow(10, figs - Math.ceil(Math.log10(Math.abs(n))));
134
134
  return Math.round(n * f) / f;
135
135
  };
136
- const sigN = (n, figs = 6) => (n == null ? null : sig(n, figs));
136
+ const sigN = (n, figs = 6) => n == null ? null : sig(n, figs);
137
137
  return {
138
138
  asOfClose: sig(close),
139
139
  rsi14: rsi14 == null ? null : Math.round(rsi14 * 10) / 10,
140
140
  ema20: sigN(ema20),
141
141
  ema50: sigN(ema50),
142
142
  atr14: sigN(atr14),
143
- bollinger: bb == null ? null : { upper: sig(bb.upper), mid: sig(bb.mid), lower: sig(bb.lower) },
143
+ bollinger: bb == null
144
+ ? null
145
+ : { upper: sig(bb.upper), mid: sig(bb.mid), lower: sig(bb.lower) },
144
146
  recent20: r20 == null ? null : { high: sig(r20.high), low: sig(r20.low) },
145
147
  aboveEma20: ema20 == null ? null : close > ema20,
146
148
  ema20AboveEma50: ema20 == null || ema50 == null ? null : ema20 > ema50,
@@ -5,7 +5,7 @@
5
5
  import { writeFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { sha256, stableStringify, toPosix } from "./util.js";
8
- import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA } from "./version.js";
8
+ import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA, } from "./version.js";
9
9
  export function buildManifest(resolved, spec) {
10
10
  // configHash binds the RESOLVED spec to the resolver + schema version, so a
11
11
  // run reproduces only against the same compile (not just the same files).
@@ -0,0 +1,36 @@
1
+ import { AgentSpec, Decision, Observation, PmMarket } from "./types.js";
2
+ export declare const BENCHMARK_STRATEGIES: readonly ["market-implied", "base-rate", "random"];
3
+ export type BenchmarkStrategy = (typeof BENCHMARK_STRATEGIES)[number];
4
+ export declare function isBenchmarkStrategy(s: string): s is BenchmarkStrategy;
5
+ export declare const BASE_RATE_UNINFORMATIVE = 50;
6
+ export declare const BENCHMARK_STAKE_MUSD = 10;
7
+ export declare const RANDOM_FORECAST_MIN = 20;
8
+ export declare const RANDOM_FORECAST_MAX = 80;
9
+ export declare function marketKey(m: {
10
+ source: string;
11
+ slug: string;
12
+ outcomeExternalMarketId: string;
13
+ }): string;
14
+ export declare function seededRandomForecast(seed: string): number;
15
+ export declare function pickBenchmarkMarket(markets: PmMarket[], heldKeys?: Set<string>): PmMarket | undefined;
16
+ export declare function benchmarkForecast(strategy: BenchmarkStrategy, market: PmMarket, dateKey: string): number | undefined;
17
+ export interface MechanicalDecideInput {
18
+ strategy: string;
19
+ observation: Observation;
20
+ dateKey?: string;
21
+ stakeMusd?: number;
22
+ }
23
+ export interface MechanicalDecideResult {
24
+ decision: Decision;
25
+ log: string[];
26
+ }
27
+ export declare function decideMechanical(input: MechanicalDecideInput): MechanicalDecideResult;
28
+ export interface BenchmarkAgentDefinition {
29
+ handle: string;
30
+ displayName: string;
31
+ strategy: BenchmarkStrategy;
32
+ cadenceSeconds: number;
33
+ spec: AgentSpec;
34
+ prose: string;
35
+ }
36
+ export declare const BENCHMARK_AGENTS: BenchmarkAgentDefinition[];
@@ -0,0 +1,286 @@
1
+ // Mechanical BENCHMARK baseline agents — the living reference line the Arena
2
+ // measures skill against (sol-audit #7 baselines).
3
+ //
4
+ // These are NOT LLM agents. There is no model, no prompt, and no inference cost.
5
+ // Each cycle the runner short-circuits the provider and computes a decision
6
+ // deterministically from the observation, so a benchmark's forecast is fully
7
+ // reproducible from (market, date) alone. Three strategies, ALL mechanical:
8
+ //
9
+ // • market-implied — submits a forecast EXACTLY equal to the market's own
10
+ // probability at decision time. This is the definition of the baseline the
11
+ // forecast-skill scorecard measures against, and the ONLY agent for which an
12
+ // echo of the market price is correct BY DESIGN. Its description says
13
+ // BENCHMARK so the runner's anti-echo log is never read as a defect.
14
+ // • base-rate — submits an uninformative 50 for every market. We do NOT
15
+ // invent per-category historical base rates: the observation carries no
16
+ // calibrated category prior, so the honest baseline is the uninformative
17
+ // prior. If a cheap calibrated base rate is ever surfaced in the
18
+ // observation, swap it in here (documented, never fabricated).
19
+ // • random — submits a deterministic pseudo-random forecast in [20,80]
20
+ // seeded from (market key, UTC date), so a run is reproducible and a "null"
21
+ // forecaster's noise floor is a fair, stable comparison.
22
+ //
23
+ // The market PICK rule is identical across all three (highest-volume eligible
24
+ // market that carries a usable probability and is not already held), so the
25
+ // three benchmarks bet the SAME markets and differ ONLY in the forecast — which
26
+ // is exactly what a clean baseline comparison needs.
27
+ import { SPEC_VERSION, } from "./types.js";
28
+ import { dayKey } from "./util.js";
29
+ // ───────────────────────── Strategy vocabulary ──────────────────────────────
30
+ export const BENCHMARK_STRATEGIES = [
31
+ "market-implied",
32
+ "base-rate",
33
+ "random",
34
+ ];
35
+ export function isBenchmarkStrategy(s) {
36
+ return BENCHMARK_STRATEGIES.includes(s);
37
+ }
38
+ // The uninformative prior the base-rate benchmark submits. Documented, NOT an
39
+ // invented per-category historical rate: the observation carries no calibrated
40
+ // category prior to read, so 50 (maximum entropy for a binary) is the honest,
41
+ // non-fabricated baseline. See the module header.
42
+ export const BASE_RATE_UNINFORMATIVE = 50;
43
+ // Tiny fixed stake (mUSD). Equal to the server's PM minimum so a benchmark bets
44
+ // the smallest honest ticket — it exists to record forecasts, not to size risk.
45
+ export const BENCHMARK_STAKE_MUSD = 10;
46
+ // The random/null benchmark's forecast range (inclusive), kept away from the
47
+ // [1,99] extremes so a "no-information" forecaster never masquerades as confident.
48
+ export const RANDOM_FORECAST_MIN = 20;
49
+ export const RANDOM_FORECAST_MAX = 80;
50
+ // ───────────────────────── Deterministic helpers ────────────────────────────
51
+ // A market's stable identity: the canonical triple, lower-cased. Doubles as the
52
+ // dedupe key against held positions and the seed component for the random RNG.
53
+ export function marketKey(m) {
54
+ return `${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`;
55
+ }
56
+ // FNV-1a 32-bit string hash — small, dependency-free, and deterministic across
57
+ // platforms. Used only to derive the reproducible random-benchmark forecast.
58
+ function fnv1a32(s) {
59
+ let h = 0x811c9dc5;
60
+ for (let i = 0; i < s.length; i++) {
61
+ h ^= s.charCodeAt(i);
62
+ // h *= 16777619, kept in 32-bit unsigned space via Math.imul.
63
+ h = Math.imul(h, 0x01000193);
64
+ }
65
+ return h >>> 0;
66
+ }
67
+ // Deterministic pseudo-random forecast in [RANDOM_FORECAST_MIN,
68
+ // RANDOM_FORECAST_MAX], one-decimal, seeded from (marketKey, dateKey). Same seed
69
+ // ⇒ same value, so a re-run of the same cycle reproduces exactly.
70
+ export function seededRandomForecast(seed) {
71
+ const span = RANDOM_FORECAST_MAX - RANDOM_FORECAST_MIN;
72
+ // 0..1 from the hash, then map into the span at one-decimal precision.
73
+ const unit = fnv1a32(seed) / 0xffffffff;
74
+ const raw = RANDOM_FORECAST_MIN + unit * span;
75
+ return Math.round(raw * 10) / 10;
76
+ }
77
+ // Clamp any probability-percentage to the backend's exclusive (0,100) rail as a
78
+ // one-decimal value in [1,99] — the same rail the runner enforces on model
79
+ // forecasts. Non-finite input returns undefined.
80
+ function clampForecast(pct) {
81
+ if (!Number.isFinite(pct))
82
+ return undefined;
83
+ const clamped = Math.min(99, Math.max(1, pct));
84
+ return Math.round(clamped * 10) / 10;
85
+ }
86
+ // A market is a usable benchmark candidate iff it carries a real probability
87
+ // (needed for the market-implied echo) AND a canonical triple. Keeping the SAME
88
+ // gate for all three strategies is what makes them bet identical markets.
89
+ function hasUsableProbability(m) {
90
+ return (typeof m.probability === "number" &&
91
+ Number.isFinite(m.probability) &&
92
+ !!m.source &&
93
+ !!m.slug &&
94
+ !!m.outcomeExternalMarketId);
95
+ }
96
+ // Deterministic pick: among eligible (usable-probability), not-already-held
97
+ // candidates, the highest-volume market wins; ties break on the market key
98
+ // ascending so the choice is fully reproducible. `volumeUsd` absent ⇒ treated as
99
+ // 0, so an older backend that omits volume falls back to a pure key-ordered pick
100
+ // (still deterministic). observe() already excludes held markets and eligibility
101
+ // -false outcomes, so this is a belt-and-suspenders re-filter.
102
+ export function pickBenchmarkMarket(markets, heldKeys = new Set()) {
103
+ const candidates = markets
104
+ .filter(hasUsableProbability)
105
+ .filter((m) => !heldKeys.has(marketKey(m)));
106
+ if (candidates.length === 0)
107
+ return undefined;
108
+ return candidates.reduce((best, m) => {
109
+ const bv = best.volumeUsd ?? 0;
110
+ const mv = m.volumeUsd ?? 0;
111
+ if (mv !== bv)
112
+ return mv > bv ? m : best;
113
+ return marketKey(m) < marketKey(best) ? m : best;
114
+ });
115
+ }
116
+ // The per-strategy forecast (1..99) for a chosen market on a given UTC date.
117
+ // Returns undefined only if a market-implied echo can't be sized (non-finite
118
+ // probability) — the caller then skips rather than fabricating a value.
119
+ export function benchmarkForecast(strategy, market, dateKey) {
120
+ switch (strategy) {
121
+ case "market-implied":
122
+ // Echo the market's own probability (0..1 ⇒ percentage). BY DESIGN — this
123
+ // agent IS the market-implied baseline definition.
124
+ return clampForecast(Math.round((market.probability ?? NaN) * 100));
125
+ case "base-rate":
126
+ return BASE_RATE_UNINFORMATIVE;
127
+ case "random":
128
+ return clampForecast(seededRandomForecast(`${marketKey(market)}|${dateKey}`));
129
+ }
130
+ }
131
+ const heldKeysOf = (positions) => new Set(positions
132
+ .filter((p) => (p.status ?? "open") === "open")
133
+ .map((p) => marketKey({
134
+ source: p.source ?? "",
135
+ slug: p.slug ?? "",
136
+ outcomeExternalMarketId: p.outcomeExternalMarketId ?? "",
137
+ })));
138
+ // Compute one benchmark cycle's decision directly from the observation — no
139
+ // model call. Skips (never throws) when the strategy is unknown or no eligible
140
+ // market is available; otherwise emits a single pm_open carrying the strategy's
141
+ // forecast. Confidence is fixed at 1 (a benchmark never abstains on confidence).
142
+ export function decideMechanical(input) {
143
+ const log = [];
144
+ const strategy = input.strategy;
145
+ if (!isBenchmarkStrategy(strategy)) {
146
+ log.push(`mechanical: unknown benchmark strategy "${strategy}" — skipping`);
147
+ return {
148
+ decision: { decision: "skip", reason: "unknown_strategy", actions: [] },
149
+ log,
150
+ };
151
+ }
152
+ const dateKey = input.dateKey ?? dayKey();
153
+ const stakeMusd = input.stakeMusd ?? BENCHMARK_STAKE_MUSD;
154
+ const held = heldKeysOf(input.observation.pmPositions);
155
+ const market = pickBenchmarkMarket(input.observation.pmMarkets, held);
156
+ if (!market) {
157
+ log.push(`mechanical(${strategy}): no eligible PM market to benchmark this cycle — skipping`);
158
+ return {
159
+ decision: { decision: "skip", reason: "no_eligible_market", actions: [] },
160
+ log,
161
+ };
162
+ }
163
+ const forecast = benchmarkForecast(strategy, market, dateKey);
164
+ if (forecast == null) {
165
+ log.push(`mechanical(${strategy}): could not size a forecast for ${market.slug} — skipping`);
166
+ return {
167
+ decision: { decision: "skip", reason: "unsizable_forecast", actions: [] },
168
+ log,
169
+ };
170
+ }
171
+ const marketPct = typeof market.probability === "number"
172
+ ? Math.round(market.probability * 100)
173
+ : undefined;
174
+ const rationale = strategy === "market-implied"
175
+ ? `BENCHMARK market-implied: forecast ${forecast}% = market probability${marketPct != null ? ` ${marketPct}%` : ""} on ${market.source}/${market.slug} (echo is the baseline definition, not a defect).`
176
+ : strategy === "base-rate"
177
+ ? `BENCHMARK base-rate: uninformative ${forecast}% prior on ${market.source}/${market.slug} (no invented per-category base rate).`
178
+ : `BENCHMARK random: seeded pseudo-random ${forecast}% on ${market.source}/${market.slug} (reproducible from market+date).`;
179
+ const action = {
180
+ type: "pm_open",
181
+ ref: market.ref,
182
+ source: market.source,
183
+ slug: market.slug,
184
+ outcomeExternalMarketId: market.outcomeExternalMarketId,
185
+ stakeMusd,
186
+ confidence: 1,
187
+ forecastProbability: forecast,
188
+ rationaleSummary: rationale,
189
+ };
190
+ log.push(`mechanical(${strategy}): bet ${stakeMusd}mUSD on ${market.source}/${market.slug} @ forecast ${forecast}% (market ${marketPct ?? "?"}%, vol ${market.volumeUsd ?? 0})`);
191
+ return {
192
+ decision: {
193
+ decision: "act",
194
+ confidence: 1,
195
+ rationale,
196
+ actions: [action],
197
+ },
198
+ log,
199
+ };
200
+ }
201
+ // A complete, valid AgentSpec for a mechanical benchmark. The strategy travels
202
+ // in model.name (provider "mechanical"); the runner reads it there. Caps are set
203
+ // so a benchmark NEVER self-disables (all kill-switches off — it is a permanent
204
+ // reference line) and never abstains on confidence (minConfidence 0). PM-only,
205
+ // tiny fixed stake, always-fire trigger policy (mechanical is free, so there is
206
+ // no reason to gate a cycle).
207
+ function benchmarkSpec(strategy, cadenceSeconds) {
208
+ const label = LABELS[strategy];
209
+ return {
210
+ name: `bench-${strategy}`,
211
+ description: `BENCHMARK (${label}) — a mechanical, non-LLM baseline reference agent. ${DESCRIPTIONS[strategy]} It is NOT a skill agent; it exists so the Arena can show real agents beating (or not) a fixed, deterministic baseline. Paper-only, zero inference cost.`,
212
+ spec: SPEC_VERSION,
213
+ trigger: { cadence: `${Math.round(cadenceSeconds / 60)}m` },
214
+ model: { provider: "mechanical", name: strategy },
215
+ venues: ["pm"],
216
+ risk: {
217
+ maxLeverage: 1,
218
+ perTradeMarginMusd: BENCHMARK_STAKE_MUSD, // per-trade stake cap = the tiny fixed stake
219
+ maxConcurrentPositions: 1000, // PM opens aren't capped by this; kept generous
220
+ requireStopLoss: false,
221
+ // Discovery needs a query coin; PM-only agents still seed discover from the
222
+ // watchlist. Majors give the broadest, most-liquid crypto board to benchmark.
223
+ watchlist: ["BTC", "ETH", "SOL"],
224
+ },
225
+ limits: {
226
+ maxTradesPerDay: 0, // 0 = unlimited: a benchmark records as many markets as it sees
227
+ maxWritesPerCycle: 1, // one benchmarked market per cycle
228
+ maxDailyLossMusd: 0, // disabled — a reference line never risk-stops
229
+ maxOpenMarginMusd: 100000,
230
+ },
231
+ abstention: {
232
+ onStaleData: false,
233
+ onWeakSignal: false,
234
+ onMissingQuote: false,
235
+ onInsufficientBalance: false,
236
+ minConfidence: 0, // a benchmark never abstains on confidence
237
+ },
238
+ sync: { requirePollBeforeWrite: false },
239
+ killSwitch: {
240
+ maxDrawdownMusd: 0,
241
+ maxConsecutiveRejects: 0,
242
+ maxConsecutiveModelFailures: 0,
243
+ onRateLimitPressure: false,
244
+ },
245
+ objective: {
246
+ primary: "calibration",
247
+ secondary: ["benchmark", strategy],
248
+ horizon: "all",
249
+ },
250
+ capabilities: [],
251
+ triggerPolicy: {
252
+ mode: "always", // mechanical = free; always evaluate, never gate a cycle
253
+ skipLlmWhenNoTrigger: false,
254
+ alwaysManageOpenPositions: true,
255
+ maxLlmCallsPerHour: 0,
256
+ debounceMinutes: 0,
257
+ pmEvalCooldownMinutes: 0,
258
+ },
259
+ };
260
+ }
261
+ const LABELS = {
262
+ "market-implied": "market-implied",
263
+ "base-rate": "base-rate",
264
+ random: "random/null",
265
+ };
266
+ const DESCRIPTIONS = {
267
+ "market-implied": "Each cycle it picks the highest-volume eligible market and submits a forecast EXACTLY equal to the market's own probability — the market-implied baseline every skill claim is measured against.",
268
+ "base-rate": "It submits an uninformative 50% prior on every market (no fabricated per-category base rate).",
269
+ random: "It submits a deterministic pseudo-random forecast in [20,80] seeded from the market and date, giving a reproducible no-information noise floor.",
270
+ };
271
+ // Human-readable prose stored on the row. NEVER fed to a model (mechanical agents
272
+ // don't reason) — it exists so the Arena/terminal can describe the agent honestly.
273
+ function benchmarkProse(strategy) {
274
+ return `# Benchmark: ${LABELS[strategy]}\n\nThis is a MECHANICAL BENCHMARK baseline, not a skill agent. ${DESCRIPTIONS[strategy]}\n\nIt calls no language model, has zero inference cost, and is fully deterministic and reproducible. It exists purely as a public reference line: the Arena compares real agents' calibration against these baselines. Paper-only.`;
275
+ }
276
+ // Default cadence: hourly. Frequent enough to accumulate a steady benchmark
277
+ // record, slow enough that the three benchmarks don't churn the discovered board.
278
+ const DEFAULT_BENCHMARK_CADENCE_SECONDS = 3600;
279
+ export const BENCHMARK_AGENTS = BENCHMARK_STRATEGIES.map((strategy) => ({
280
+ handle: `bench-${strategy}`,
281
+ displayName: `Benchmark: ${LABELS[strategy]}`,
282
+ strategy,
283
+ cadenceSeconds: DEFAULT_BENCHMARK_CADENCE_SECONDS,
284
+ spec: benchmarkSpec(strategy, DEFAULT_BENCHMARK_CADENCE_SECONDS),
285
+ prose: benchmarkProse(strategy),
286
+ }));
@@ -54,6 +54,73 @@ function freshnessOf(block) {
54
54
  const status = asStr(fr.status);
55
55
  return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
56
56
  }
57
+ // Does a market title reference the given watchlist coin? Matches on the PM coin
58
+ // NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
59
+ // is a phrase match so a q=Bitcoin result reliably carries "Bitcoin"/"BTC" in the
60
+ // title. Used both to decide whether the primary board already covers the coin the
61
+ // agent analysed and to keep the secondary (crypto-targeted) fetch on-topic.
62
+ function titleMentionsCoin(title, symbol) {
63
+ const t = (title ?? "").toLowerCase();
64
+ if (!t)
65
+ return false;
66
+ const name = (PM_COIN_NAMES[symbol] ?? symbol).toLowerCase();
67
+ const sym = symbol.toLowerCase();
68
+ return t.includes(name) || t.includes(sym);
69
+ }
70
+ // Expand one raw /api/agent/pm/discover payload into per-outcome PmMarket rows
71
+ // (WITHOUT a ref — refs are stamped once over the final merged+sliced list so they
72
+ // stay contiguous pm1..pmN). One row per quoteable outcome; drops outcomes the
73
+ // backend flagged not-openable (eligible === false) and markets the agent already
74
+ // holds (heldPmKeys). Shared by the primary board fetch and the crypto-targeted
75
+ // secondary fetch so both go through the exact same filters.
76
+ function expandPmMarkets(discData, heldPmKeys) {
77
+ const dd = asObj(discData);
78
+ return (asArr(dd.data ?? dd.markets ?? dd.results)
79
+ .map(asObj)
80
+ .flatMap((ev) => {
81
+ const source = (asStr(ev.source) ?? "").toLowerCase();
82
+ const slug = (asStr(ev.slug) ?? "").toLowerCase();
83
+ // Keep titles SHORT: the model only needs to recognise the market.
84
+ // Untrimmed titles, one per outcome across many events, ballooned the
85
+ // prompt to ~69k tokens (413s on small-context free models).
86
+ const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
87
+ const freshness = freshnessOf(ev); // freshness is event-level
88
+ // Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
89
+ // the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
90
+ // Same for every outcome of the event; undefined on an older backend.
91
+ const volumeUsd = asNum(ev.volume24h) ?? undefined;
92
+ // At most a few outcomes per event so a wide multi-outcome market
93
+ // (e.g. dozens of price buckets) can't explode the prompt. Drop
94
+ // outcomes the backend flagged NOT openable (eligible === false) so the
95
+ // model never bets a market that would fail the binary entry gate at
96
+ // quote. Back-compat: an older backend omits `eligible` (undefined) ->
97
+ // the outcome is kept (current behaviour).
98
+ const outcomes = asArr(ev.outcomes)
99
+ .map(asObj)
100
+ .filter((o) => o.eligible !== false)
101
+ .slice(0, 3);
102
+ // A market with no outcomes array still round-trips a flat fallback row.
103
+ const rows = outcomes.length > 0 ? outcomes : [ev];
104
+ return rows.map((o) => ({
105
+ source,
106
+ slug,
107
+ outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
108
+ // Carry the odds through: the model needs the outcome label + current
109
+ // probability to spot a mispriced market and bet it.
110
+ outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
111
+ // Backend returns probability as 0..100 (percent) — normalise to 0..1
112
+ // to match the prompt's "0..1" framing (probed 2026-06-24).
113
+ probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
114
+ title,
115
+ freshness,
116
+ volumeUsd,
117
+ }));
118
+ })
119
+ .filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
120
+ // Drop already-held markets so the model only sees markets it can actually
121
+ // open — done BEFORE any slice so held positions don't consume candidate slots.
122
+ .filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
123
+ }
57
124
  function emptyObservation(state, scopes = []) {
58
125
  return {
59
126
  asOf: state.cursor ?? new Date().toISOString(),
@@ -296,12 +363,11 @@ export async function observe(client, spec, state, trace) {
296
363
  .slice(0, 25);
297
364
  }
298
365
  if (pmDiscR.ok) {
299
- const dd = asObj(pmDiscR.data);
300
366
  // Anti-churn: exclude markets the agent ALREADY holds an open position in
301
367
  // from the candidate list BEFORE it reaches the prompt — so the model never
302
368
  // sees (and re-picks) a held market only to have the runner/server reject it
303
369
  // as a duplicate, burning a whole cycle. Keyed source|slug|outcomeExternalMarketId
304
- // (lower-cased to match the discover rows below). The runner preflight guard
370
+ // (lower-cased to match the discover rows). The runner preflight guard
305
371
  // (duplicate_intent) + server dedup (duplicate_open) remain the backstops.
306
372
  // Side-agnostic = no re-bet/hedge on a held outcome, matching the runner policy.
307
373
  const heldPmKeys = new Set(pmPositions
@@ -309,57 +375,59 @@ export async function observe(client, spec, state, trace) {
309
375
  .map((p) => `${(p.source ?? "").toLowerCase()}|${(p.slug ?? "").toLowerCase()}|${p.outcomeExternalMarketId ?? ""}`));
310
376
  // Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
311
377
  // Each EVENT carries source/slug/title/freshness at the top level and the
312
- // quoteable id NESTED at outcomes[].externalMarketId — so expand one
313
- // PmMarket per quoteable outcome. (Tolerant `markets`/`results` and flat
314
- // `outcomeExternalMarketId` fallbacks kept for older/mocked shapes.)
315
- pmMarkets = asArr(dd.data ?? dd.markets ?? dd.results)
316
- .map(asObj)
317
- .flatMap((ev) => {
318
- const source = (asStr(ev.source) ?? "").toLowerCase();
319
- const slug = (asStr(ev.slug) ?? "").toLowerCase();
320
- // Keep titles SHORT: the model only needs to recognise the market.
321
- // Untrimmed titles, one per outcome across many events, ballooned the
322
- // prompt to ~69k tokens (413s on small-context free models).
323
- const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
324
- const freshness = freshnessOf(ev); // freshness is event-level
325
- // At most a few outcomes per event so a wide multi-outcome market
326
- // (e.g. dozens of price buckets) can't explode the prompt. Drop
327
- // outcomes the backend flagged NOT openable (eligible === false) so the
328
- // model never bets a market that would fail the binary entry gate at
329
- // quote. Back-compat: an older backend omits `eligible` (undefined) →
330
- // the outcome is kept (current behaviour).
331
- const outcomes = asArr(ev.outcomes)
332
- .map(asObj)
333
- .filter((o) => o.eligible !== false)
334
- .slice(0, 3);
335
- // A market with no outcomes array still round-trips a flat fallback row.
336
- const rows = outcomes.length > 0 ? outcomes : [ev];
337
- return rows.map((o) => ({
338
- source,
339
- slug,
340
- outcomeExternalMarketId: asStr(o.externalMarketId) ??
341
- asStr(o.outcomeExternalMarketId) ??
342
- "",
343
- // Carry the odds through: the model needs the outcome label + current
344
- // probability to spot a mispriced market and bet it (was stripped).
345
- outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
346
- // Backend returns probability as 0..100 (percent) — normalise to 0..1
347
- // to match the prompt's "0..1" framing (probed 2026-06-24).
348
- probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
349
- title,
350
- freshness,
351
- }));
352
- })
353
- .filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
354
- // Drop already-held markets (see heldPmKeys above) so the model only sees
355
- // markets it can actually open — done BEFORE the slice so held positions
356
- // don't consume the limited candidate slots.
357
- .filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`))
358
- // Hard cap the PM block: a handful of fresh markets is plenty to pick from.
378
+ // quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
379
+ // that into one row per quoteable outcome (eligible + not-held filtered).
380
+ let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
381
+ // ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
382
+ // The prompt tells the model its SHARPEST PM edge is the crypto price view it
383
+ // JUST formed — but that is only actionable if the board actually LISTS a
384
+ // market for the coin it analysed. The primary board is keyed to ONE query
385
+ // (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
386
+ // so an agent whose top coin got displaced by the Bitcoin fallback sees NO
387
+ // market for the coin it has a view on and an 8B model invents a pmN ref
388
+ // (→ pm_ref_unknown, wasted cycle). When the top ANALYSED coin (its sharpest
389
+ // edge) has no market in the primary board, fire ONE extra discover for that
390
+ // coin and MERGE it in giving the model a real ref to bet instead of a
391
+ // hallucinated one. Budget: at most a single additional CoinRithm data-API
392
+ // read, and only on cycles where the top coin is actually missing; the shared
393
+ // free-tier model-call RateBudget (scheduler) is untouched this is a read,
394
+ // not an LLM call, and the client already backs off on 429.
395
+ const analyzedCoins = watch
396
+ .filter((w) => w.coinId)
397
+ .map((w) => w.symbol.toUpperCase());
398
+ const topAnalyzed = analyzedCoins[0];
399
+ const primaryCoversTop = !topAnalyzed ||
400
+ mergedRows.some((m) => titleMentionsCoin(m.title, topAnalyzed));
401
+ if (topAnalyzed && !primaryCoversTop) {
402
+ const targetName = PM_COIN_NAMES[topAnalyzed] ?? topAnalyzed;
403
+ // limit 6 (not ~5): the eligible/held/dedupe filters shave the list, and we
404
+ // then cap the merged contribution to 4 targeted rows below.
405
+ const secR = await client.discoverPmMarkets({ q: targetName, limit: 6 }, trace);
406
+ if (secR.ok) {
407
+ // Dedupe the secondary rows against the primary list by source+slug (event
408
+ // key) so a market already on the board is never shown twice, and keep only
409
+ // rows that actually reference the targeted coin (a fuzzy backend match
410
+ // can't dilute the board with off-topic events).
411
+ const primaryEventKeys = new Set(mergedRows.map((m) => `${m.source}|${m.slug}`));
412
+ const secRows = expandPmMarkets(secR.data, heldPmKeys)
413
+ .filter((m) => titleMentionsCoin(m.title, topAnalyzed))
414
+ .filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`))
415
+ .slice(0, 4);
416
+ // Reserve slots for the targeted rows so the 12-cap can't slice off the
417
+ // very markets the secondary fetch exists to surface. Primary rows keep
418
+ // priority; the targeted rows are appended.
419
+ if (secRows.length > 0) {
420
+ const primaryBudget = Math.max(0, 12 - secRows.length);
421
+ mergedRows = [...mergedRows.slice(0, primaryBudget), ...secRows];
422
+ }
423
+ }
424
+ }
425
+ // Hard cap the PM block (a handful of fresh markets is plenty) and stamp a
426
+ // short, stable per-cycle ref (pm1…pmN) the model copies instead of the long
427
+ // outcomeExternalMarketId. Refs are assigned AFTER the merge + slice so they
428
+ // are a contiguous 1..N matching exactly what the prompt shows.
429
+ pmMarkets = mergedRows
359
430
  .slice(0, 12)
360
- // Stamp a short, stable per-cycle ref (pm1…pmN) the model copies instead of
361
- // the long outcomeExternalMarketId. Assigned AFTER the slice so refs are a
362
- // contiguous 1..N matching exactly what the prompt shows.
363
431
  .map((m, i) => ({ ...m, ref: `pm${i + 1}` }));
364
432
  }
365
433
  }
@@ -1,6 +1,8 @@
1
1
  import { AgentSpec, Observation, PmResolution } from "./types.js";
2
2
  export declare function formatPmResolutions(resolutions: PmResolution[]): string[];
3
- export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string): string;
3
+ export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string, opts?: {
4
+ includeForecast?: boolean;
5
+ }): string;
4
6
  export declare function buildUserPrompt(obs: Observation, journal?: Array<{
5
7
  at: string;
6
8
  did: string;