@coinrithm/mcp-trading 0.7.2 → 0.7.4

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 (47) hide show
  1. package/CHANGELOG.md +171 -113
  2. package/README.md +277 -238
  3. package/dist/agent/act.d.ts +2 -2
  4. package/dist/agent/act.js +24 -3
  5. package/dist/agent/cli.js +68 -23
  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.d.ts +4 -0
  22. package/dist/agent/observe.js +140 -53
  23. package/dist/agent/prompt.d.ts +3 -1
  24. package/dist/agent/prompt.js +17 -6
  25. package/dist/agent/providers.js +39 -4
  26. package/dist/agent/resolve.js +23 -6
  27. package/dist/agent/resolvePm.js +14 -3
  28. package/dist/agent/runEvidence.js +6 -2
  29. package/dist/agent/runner.d.ts +8 -2
  30. package/dist/agent/runner.js +363 -59
  31. package/dist/agent/scorecard.js +12 -4
  32. package/dist/agent/setups.js +57 -9
  33. package/dist/agent/skill.js +1 -1
  34. package/dist/agent/state.js +9 -4
  35. package/dist/agent/types.d.ts +17 -2
  36. package/dist/agent/types.js +2 -1
  37. package/dist/agent/util.js +11 -4
  38. package/dist/agent/version.d.ts +1 -1
  39. package/dist/agent/version.js +1 -1
  40. package/dist/client.d.ts +51 -0
  41. package/dist/client.js +30 -3
  42. package/dist/executionPolicy.d.ts +2 -0
  43. package/dist/executionPolicy.js +21 -0
  44. package/dist/http.js +13 -3
  45. package/dist/tools.d.ts +23 -0
  46. package/dist/tools.js +796 -39
  47. package/package.json +86 -78
@@ -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
+ }));
@@ -4,4 +4,8 @@ export interface ObserveOutput {
4
4
  observation: Observation;
5
5
  skip?: string;
6
6
  }
7
+ export declare function isCalibrationChurnMarket(market: {
8
+ slug?: string;
9
+ title?: string;
10
+ }): boolean;
7
11
  export declare function observe(client: CoinRithmClient, spec: AgentSpec, state: RunState, trace?: AgentTrace): Promise<ObserveOutput>;
@@ -28,6 +28,16 @@ const PM_COIN_NAMES = {
28
28
  UNI: "Uniswap",
29
29
  SUI: "Sui",
30
30
  };
31
+ // Repeated micro-contracts are useful for execution smoke tests but are a poor
32
+ // calibration universe: outcomes overlap heavily, resolve too quickly to admit
33
+ // meaningful independent research, and drown the public scorecard in Bitcoin
34
+ // coin flips. Non-mechanical calibration agents receive a deeper discovery
35
+ // page with these rows removed. Mechanical baselines intentionally keep the
36
+ // unmodified universe so their reference contract remains reproducible.
37
+ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)|(?:5|15)\s+min(?:ute)?s?|-1h-|hourly|-daily-|\bdaily\b|what-price-will[^\n]*(?:today|tomorrow)|-above-on-|-price-on-|this[ -]week|of[ -]the[ -]week|-weekly-)/i;
38
+ export function isCalibrationChurnMarket(market) {
39
+ return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
40
+ }
31
41
  // Fetch candles for one coin and reduce them to a compact indicator bundle.
32
42
  // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
33
43
  // null so the cycle proceeds with price-only context rather than skipping.
@@ -54,6 +64,73 @@ function freshnessOf(block) {
54
64
  const status = asStr(fr.status);
55
65
  return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
56
66
  }
67
+ // Does a market title reference the given watchlist coin? Matches on the PM coin
68
+ // NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
69
+ // is a phrase match so a q=Bitcoin result reliably carries "Bitcoin"/"BTC" in the
70
+ // title. Used both to decide whether the primary board already covers the coin the
71
+ // agent analysed and to keep the secondary (crypto-targeted) fetch on-topic.
72
+ function titleMentionsCoin(title, symbol) {
73
+ const t = (title ?? "").toLowerCase();
74
+ if (!t)
75
+ return false;
76
+ const name = (PM_COIN_NAMES[symbol] ?? symbol).toLowerCase();
77
+ const sym = symbol.toLowerCase();
78
+ return t.includes(name) || t.includes(sym);
79
+ }
80
+ // Expand one raw /api/agent/pm/discover payload into per-outcome PmMarket rows
81
+ // (WITHOUT a ref — refs are stamped once over the final merged+sliced list so they
82
+ // stay contiguous pm1..pmN). One row per quoteable outcome; drops outcomes the
83
+ // backend flagged not-openable (eligible === false) and markets the agent already
84
+ // holds (heldPmKeys). Shared by the primary board fetch and the crypto-targeted
85
+ // secondary fetch so both go through the exact same filters.
86
+ function expandPmMarkets(discData, heldPmKeys) {
87
+ const dd = asObj(discData);
88
+ return (asArr(dd.data ?? dd.markets ?? dd.results)
89
+ .map(asObj)
90
+ .flatMap((ev) => {
91
+ const source = (asStr(ev.source) ?? "").toLowerCase();
92
+ const slug = (asStr(ev.slug) ?? "").toLowerCase();
93
+ // Keep titles SHORT: the model only needs to recognise the market.
94
+ // Untrimmed titles, one per outcome across many events, ballooned the
95
+ // prompt to ~69k tokens (413s on small-context free models).
96
+ const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
97
+ const freshness = freshnessOf(ev); // freshness is event-level
98
+ // Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
99
+ // the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
100
+ // Same for every outcome of the event; undefined on an older backend.
101
+ const volumeUsd = asNum(ev.volume24h) ?? undefined;
102
+ // At most a few outcomes per event so a wide multi-outcome market
103
+ // (e.g. dozens of price buckets) can't explode the prompt. Drop
104
+ // outcomes the backend flagged NOT openable (eligible === false) so the
105
+ // model never bets a market that would fail the binary entry gate at
106
+ // quote. Back-compat: an older backend omits `eligible` (undefined) ->
107
+ // the outcome is kept (current behaviour).
108
+ const outcomes = asArr(ev.outcomes)
109
+ .map(asObj)
110
+ .filter((o) => o.eligible !== false)
111
+ .slice(0, 3);
112
+ // A market with no outcomes array still round-trips a flat fallback row.
113
+ const rows = outcomes.length > 0 ? outcomes : [ev];
114
+ return rows.map((o) => ({
115
+ source,
116
+ slug,
117
+ outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
118
+ // Carry the odds through: the model needs the outcome label + current
119
+ // probability to spot a mispriced market and bet it.
120
+ outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
121
+ // Backend returns probability as 0..100 (percent) — normalise to 0..1
122
+ // to match the prompt's "0..1" framing (probed 2026-06-24).
123
+ probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
124
+ title,
125
+ freshness,
126
+ volumeUsd,
127
+ }));
128
+ })
129
+ .filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
130
+ // Drop already-held markets so the model only sees markets it can actually
131
+ // open — done BEFORE any slice so held positions don't consume candidate slots.
132
+ .filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
133
+ }
57
134
  function emptyObservation(state, scopes = []) {
58
135
  return {
59
136
  asOf: state.cursor ?? new Date().toISOString(),
@@ -222,6 +299,9 @@ export async function observe(client, spec, state, trace) {
222
299
  let pmResolutions = [];
223
300
  let pmMarkets = [];
224
301
  if (wantPm) {
302
+ const curatedCalibrationBoard = spec.objective?.primary === "calibration" &&
303
+ spec.model?.provider !== "mechanical";
304
+ const primaryDiscoveryLimit = curatedCalibrationBoard ? 30 : 12;
225
305
  // Bias PM discovery toward CRYPTO markets the agent has a price view on — the
226
306
  // only PM edge a price agent reliably has (probed 2026-06-24: the default board
227
307
  // is World Cup / elections / F1, which an agent has no edge on). The discover
@@ -232,7 +312,7 @@ export async function observe(client, spec, state, trace) {
232
312
  const pmQuery = PM_COIN_NAMES[topCoin] ?? spec.risk.watchlist[0] ?? "Bitcoin";
233
313
  const [pmPosR, pmDiscFirst] = await Promise.all([
234
314
  client.pmPositions(undefined, trace),
235
- client.discoverPmMarkets({ q: pmQuery, limit: 12 }, trace),
315
+ client.discoverPmMarkets({ q: pmQuery, limit: primaryDiscoveryLimit }, trace),
236
316
  ]);
237
317
  let pmDiscR = pmDiscFirst;
238
318
  const firstCount = pmDiscR.ok
@@ -296,12 +376,11 @@ export async function observe(client, spec, state, trace) {
296
376
  .slice(0, 25);
297
377
  }
298
378
  if (pmDiscR.ok) {
299
- const dd = asObj(pmDiscR.data);
300
379
  // Anti-churn: exclude markets the agent ALREADY holds an open position in
301
380
  // from the candidate list BEFORE it reaches the prompt — so the model never
302
381
  // sees (and re-picks) a held market only to have the runner/server reject it
303
382
  // as a duplicate, burning a whole cycle. Keyed source|slug|outcomeExternalMarketId
304
- // (lower-cased to match the discover rows below). The runner preflight guard
383
+ // (lower-cased to match the discover rows). The runner preflight guard
305
384
  // (duplicate_intent) + server dedup (duplicate_open) remain the backstops.
306
385
  // Side-agnostic = no re-bet/hedge on a held outcome, matching the runner policy.
307
386
  const heldPmKeys = new Set(pmPositions
@@ -309,57 +388,65 @@ export async function observe(client, spec, state, trace) {
309
388
  .map((p) => `${(p.source ?? "").toLowerCase()}|${(p.slug ?? "").toLowerCase()}|${p.outcomeExternalMarketId ?? ""}`));
310
389
  // Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
311
390
  // 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.
391
+ // quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
392
+ // that into one row per quoteable outcome (eligible + not-held filtered).
393
+ let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
394
+ if (curatedCalibrationBoard) {
395
+ mergedRows = mergedRows.filter((market) => !isCalibrationChurnMarket(market));
396
+ }
397
+ // ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
398
+ // The prompt tells the model its SHARPEST PM edge is the crypto price view it
399
+ // JUST formed but that is only actionable if the board actually LISTS a
400
+ // market for the coin it analysed. The primary board is keyed to ONE query
401
+ // (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
402
+ // so an agent whose top coin got displaced by the Bitcoin fallback sees NO
403
+ // market for the coin it has a view on and an 8B model invents a pmN ref
404
+ // (→ pm_ref_unknown, wasted cycle). When the top ANALYSED coin (its sharpest
405
+ // edge) has no market in the primary board, fire ONE extra discover for that
406
+ // coin and MERGE it in — giving the model a real ref to bet instead of a
407
+ // hallucinated one. Budget: at most a single additional CoinRithm data-API
408
+ // read, and only on cycles where the top coin is actually missing; the shared
409
+ // free-tier model-call RateBudget (scheduler) is untouched this is a read,
410
+ // not an LLM call, and the client already backs off on 429.
411
+ const analyzedCoins = watch
412
+ .filter((w) => w.coinId)
413
+ .map((w) => w.symbol.toUpperCase());
414
+ const topAnalyzed = analyzedCoins[0];
415
+ const primaryCoversTop = !topAnalyzed ||
416
+ mergedRows.some((m) => titleMentionsCoin(m.title, topAnalyzed));
417
+ if (topAnalyzed && !primaryCoversTop) {
418
+ const targetName = PM_COIN_NAMES[topAnalyzed] ?? topAnalyzed;
419
+ // limit 6 (not ~5): the eligible/held/dedupe filters shave the list, and we
420
+ // then cap the merged contribution to 4 targeted rows below.
421
+ const secR = await client.discoverPmMarkets({ q: targetName, limit: 6 }, trace);
422
+ if (secR.ok) {
423
+ // Dedupe the secondary rows against the primary list by source+slug (event
424
+ // key) so a market already on the board is never shown twice, and keep only
425
+ // rows that actually reference the targeted coin (a fuzzy backend match
426
+ // can't dilute the board with off-topic events).
427
+ const primaryEventKeys = new Set(mergedRows.map((m) => `${m.source}|${m.slug}`));
428
+ let secRows = expandPmMarkets(secR.data, heldPmKeys)
429
+ .filter((m) => titleMentionsCoin(m.title, topAnalyzed))
430
+ .filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`));
431
+ if (curatedCalibrationBoard) {
432
+ secRows = secRows.filter((market) => !isCalibrationChurnMarket(market));
433
+ }
434
+ secRows = secRows.slice(0, 4);
435
+ // Reserve slots for the targeted rows so the 12-cap can't slice off the
436
+ // very markets the secondary fetch exists to surface. Primary rows keep
437
+ // priority; the targeted rows are appended.
438
+ if (secRows.length > 0) {
439
+ const primaryBudget = Math.max(0, 12 - secRows.length);
440
+ mergedRows = [...mergedRows.slice(0, primaryBudget), ...secRows];
441
+ }
442
+ }
443
+ }
444
+ // Hard cap the PM block (a handful of fresh markets is plenty) and stamp a
445
+ // short, stable per-cycle ref (pm1…pmN) the model copies instead of the long
446
+ // outcomeExternalMarketId. Refs are assigned AFTER the merge + slice so they
447
+ // are a contiguous 1..N matching exactly what the prompt shows.
448
+ pmMarkets = mergedRows
359
449
  .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
450
  .map((m, i) => ({ ...m, ref: `pm${i + 1}` }));
364
451
  }
365
452
  }
@@ -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;
@@ -33,18 +33,24 @@ export function formatPmResolutions(resolutions) {
33
33
  `Resolved since last cycle: ${items.join("; ")}.`,
34
34
  ];
35
35
  }
36
- export function buildSystemPrompt(spec, mergedProse) {
36
+ export function buildSystemPrompt(spec, mergedProse,
37
+ // includeForecast (default OFF here; the runner passes the house-agent flag):
38
+ // when true, pm_open asks the model for its OWN independent forecastProbability
39
+ // (1..99) — its probability the backed side wins, judged from the question, NOT
40
+ // echoed from the market price. This feeds the agent's public calibration record.
41
+ opts = {}) {
37
42
  const r = spec.risk;
38
43
  const v = spec.venues;
44
+ const includeForecast = opts.includeForecast === true;
39
45
  const actions = [];
40
46
  if (v.includes("futures")) {
41
- actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', 'FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG\'s takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.');
47
+ actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', "FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG's takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.");
42
48
  }
43
49
  if (v.includes("spot")) {
44
50
  actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
45
51
  }
46
52
  if (v.includes("pm")) {
47
- actions.push('{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1} (set "ref" to the `ref` of the ONE observation.pmMarkets entry you are betting e.g. "pm3" copied EXACTLY; stakeMusd >= 10)');
53
+ actions.push(`{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1${includeForecast ? ',"forecastProbability":1..99' : ""}} (set "ref" to one of the refs listed THIS cycle (pm1..pmN) — the \`ref\` of the ONE observation.pmMarkets entry you are betting, e.g. "pm3", copied EXACTLY; a ref NOT in this cycle's list is rejected as pm_ref_unknown and wastes the cycle; stakeMusd >= 10${includeForecast ? '; set "forecastProbability" to YOUR OWN probability 1-99 that this outcome wins — see the forecast rule below' : ""})`);
48
54
  }
49
55
  return [
50
56
  "You operate a CoinRithm PAPER-TRADING agent (simulated 50,000 mUSD; not real money, not financial advice).",
@@ -65,7 +71,12 @@ export function buildSystemPrompt(spec, mergedProse) {
65
71
  : []),
66
72
  "- prediction markets are a FIRST-CLASS venue for you — a pm_open is as real a trade as a futures/spot open, not an afterthought. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, …), an `outcome` label, and `prob` (0..1, the market's CURRENT odds). BET (pm_open) an outcome when YOUR estimate of its true probability differs MATERIALLY from the market's — that gap is your edge (e.g. prob 0.35 but you think it's really ~0.55 -> buy). Skip only markets pinned near 0 or 1 (no edge left). Every entry in observation.pmMarkets is already filtered to one you CAN open (binary/settlement-grade) — so a listed market will not bounce at quote. Pick ONLY a listed market and identify it by copying its `ref` into the action; min stake 10 mUSD. Do NOT re-bet a market+outcome you ALREADY hold (check observation.pmPositions) — that is churn and will be rejected; bet a DIFFERENT market or skip.",
67
73
  "- PM stake is a SEPARATE budget from your futures margin: the futures margin cap (maxOpenMarginMusd) does NOT limit pm_open. So when your futures are at the margin/position cap — you hold the max, or a futures_open keeps getting REJECTED with open_margin_exceeds_cap — prediction markets are STILL fully open to you. PIVOT to pm_open on a mispriced market instead of re-proposing a futures_open that will just be rejected: a rejected open wastes the entire cycle, an eligible PM bet does not.",
68
- "- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. Leaving a clearly mispriced crypto market untraded is the same mistake as ignoring a flagged setup. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
74
+ "- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a LISTED crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open by its `ref` — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. ESCAPE HATCH — only the markets actually listed in observation.pmMarkets THIS cycle (pm1..pmN) are bettable: if NONE of them matches the coin or view you formed, that is a legitimate SKIP for PM (say so in one clause and move on) — do NOT invent, guess, or increment a ref for a market you wish existed, because a made-up ref is rejected (pm_ref_unknown) and wastes the whole cycle exactly like a rejected open. The mistake to avoid is leaving a LISTED, clearly mispriced crypto market untraded — a mispricing that is NOT on this cycle's board is simply not actionable now, not a miss. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
75
+ ...(includeForecast
76
+ ? [
77
+ "- FORECAST RULE (pm_open forecastProbability): before you look at what the market is pricing, decide YOUR OWN probability the outcome you are backing actually WINS — reason ONLY from the question, its resolution criteria, and the deadline. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must be YOUR judgement, NOT the market's: do NOT copy, round, or anchor it to the observation.pmMarkets `prob`. It is FINE if your honest forecast happens to land on the market's number — but reaching that by echoing the price defeats the point. If you genuinely cannot form an independent view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet).",
78
+ ]
79
+ : []),
69
80
  `- abstention.minConfidence ${spec.abstention.minConfidence}: opens below this are rejected, so act with genuine conviction — but routine caution is no reason to sit out a clear setup`,
70
81
  ...(spec.capabilities.includes("indicators")
71
82
  ? [
@@ -95,14 +106,14 @@ export function buildSystemPrompt(spec, mergedProse) {
95
106
  "",
96
107
  "## How to act — a decisive trader in character, not a bystander",
97
108
  "You ARE the character in the strategy above; trade like it. When you have a clear read — even a moderate-confidence one — TAKE THE POSITION, sized within your caps and protected with a stop. You wake every cycle and people watch you live: an agent that watches forever and never commits is useless to them and to itself.",
98
- 'Skip ONLY when the read is genuinely contradictory (signals fight each other), the data is stale, or you truly have no edge this cycle. A quiet tape where your thesis still has a small but REAL edge is an ACT, not a skip — take it, small, with a stop. Do not confuse caution with paralysis.',
109
+ "Skip ONLY when the read is genuinely contradictory (signals fight each other), the data is stale, or you truly have no edge this cycle. A quiet tape where your thesis still has a small but REAL edge is an ACT, not a skip — take it, small, with a stop. Do not confuse caution with paralysis.",
99
110
  'In "rationale" (shown LIVE in your public terminal) speak in YOUR voice and commit to a view in 1-2 vivid, specific sentences — what you see and what you are DOING about it, like a trader posting their move, not a risk report. Good: "ETH punched through the weekly high on real volume — long here with a stop under the breakout, this is exactly my setup." Weak: "conditions are mixed, waiting for clarity." Keep "reason" a short label.',
100
111
  "",
101
112
  "## Flagged setups this cycle — your wake-up list (observation.setups)",
102
113
  "A deterministic scan already checked every watchlist coin and put the ones with real, tradeable structure RIGHT NOW into observation.setups — each has symbol, kind, bias, strength, and a factual note (trend / RSI / breakout / ATR reads). This is your shortlist; you do NOT need to re-derive whether a setup exists.",
103
114
  '- If observation.setups is NON-EMPTY: act on the strongest one that fits YOUR strategy. The `bias` is the trend-following read; if you are a contrarian / mean-reversion trader, FADE it with the same facts (e.g. a downtrend that is also "RSI oversold" is YOUR long). Skipping a flagged setup needs a SPECIFIC reason tied to your thesis — "no clear setup" is NOT a valid skip when setups are listed.',
104
115
  "- If observation.setups is EMPTY: no coin has a flagged structure right now — but BEFORE you skip, check observation.pmMarkets for a crypto market your current read prices wrong (a PM mispricing is a valid ACT even with zero coin setups). Only then, if nothing is mispriced, skip new entries and just manage any open positions.",
105
- '- A setup tagged `held` (held: long|short) is a position you ALREADY hold. Do NOT propose a new open on it — that only hits the margin cap and wastes the cycle. MANAGE it instead: trail the stop toward your target, ADD only if you have margin room AND fresh conviction, or cut if the thesis broke.',
116
+ "- A setup tagged `held` (held: long|short) is a position you ALREADY hold. Do NOT propose a new open on it — that only hits the margin cap and wastes the cycle. MANAGE it instead: trail the stop toward your target, ADD only if you have margin room AND fresh conviction, or cut if the thesis broke.",
106
117
  "",
107
118
  "## After you act — hold with conviction, do not churn",
108
119
  "A position is a thesis that needs TIME to work. Once you are in WITH a stop, let the stop or your target close it: do NOT bail on the next cycle over a small adverse tick, and do NOT manually close a fresh position unless the thesis is structurally invalidated (the level broke, the trend flipped) — not merely because price wiggled against you. A trade opened and closed minutes later just donates the round-trip fee + spread to noise.",