@coinrithm/mcp-trading 0.7.13 → 0.7.15

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 (49) hide show
  1. package/CHANGELOG.md +692 -573
  2. package/README.md +400 -364
  3. package/dist/agent/act.d.ts +7 -2
  4. package/dist/agent/act.js +10 -3
  5. package/dist/agent/cli.d.ts +1 -0
  6. package/dist/agent/cli.js +25 -4
  7. package/dist/agent/client.d.ts +13 -0
  8. package/dist/agent/client.js +107 -4
  9. package/dist/agent/decisionReceipt.d.ts +5 -1
  10. package/dist/agent/decisionReceipt.js +95 -3
  11. package/dist/agent/decisionValidator.js +74 -0
  12. package/dist/agent/definitionSnapshot.d.ts +22 -0
  13. package/dist/agent/definitionSnapshot.js +28 -0
  14. package/dist/agent/engine.d.ts +1 -0
  15. package/dist/agent/engine.js +1 -0
  16. package/dist/agent/futuresEligibility.d.ts +36 -0
  17. package/dist/agent/futuresEligibility.js +90 -0
  18. package/dist/agent/mergeRules.d.ts +1 -1
  19. package/dist/agent/mergeRules.js +17 -2
  20. package/dist/agent/observe.d.ts +9 -1
  21. package/dist/agent/observe.js +507 -45
  22. package/dist/agent/pmContext.d.ts +10 -1
  23. package/dist/agent/pmContext.js +163 -1
  24. package/dist/agent/prompt.d.ts +14 -0
  25. package/dist/agent/prompt.js +138 -27
  26. package/dist/agent/providerCapabilities.d.ts +3 -0
  27. package/dist/agent/providerCapabilities.js +34 -4
  28. package/dist/agent/providers.d.ts +3 -0
  29. package/dist/agent/providers.js +12 -2
  30. package/dist/agent/resolve.js +8 -1
  31. package/dist/agent/runner.js +140 -18
  32. package/dist/agent/skill.js +9 -0
  33. package/dist/agent/skillValidator.js +11 -2
  34. package/dist/agent/strictLint.js +1 -0
  35. package/dist/agent/templates.js +30 -30
  36. package/dist/agent/types.d.ts +138 -1
  37. package/dist/agent/types.js +4 -0
  38. package/dist/client.d.ts +11 -2
  39. package/dist/client.js +22 -10
  40. package/dist/http.d.ts +1 -0
  41. package/dist/http.js +17 -2
  42. package/dist/httpCompletion.d.ts +1 -1
  43. package/dist/httpCompletion.js +1 -0
  44. package/dist/index.js +2 -1
  45. package/dist/instructions.d.ts +1 -0
  46. package/dist/instructions.js +16 -0
  47. package/dist/tools.d.ts +1 -0
  48. package/dist/tools.js +150 -66
  49. package/package.json +97 -97
@@ -1,4 +1,9 @@
1
1
  import { CoinRithmClient, ProvenanceReport } from "./client.js";
2
2
  import { ProposedAction, AgentTrace, ApiResult, Observation, QuoteEvidence } from "./types.js";
3
- export declare function fetchQuote(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace?: AgentTrace): Promise<QuoteEvidence | undefined>;
4
- export declare function executeAction(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace: AgentTrace, idempotencyKey: string, provenance?: ProvenanceReport): Promise<ApiResult>;
3
+ /** Configured policy values the server enforces independently of the model's
4
+ * action fields (2026-09-24: the PM entry floor). */
5
+ export interface ExecutionPolicy {
6
+ pmMinEntryProbabilityPct?: number;
7
+ }
8
+ export declare function fetchQuote(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace?: AgentTrace, policy?: ExecutionPolicy): Promise<QuoteEvidence | undefined>;
9
+ export declare function executeAction(client: CoinRithmClient, action: ProposedAction, observation: Observation, trace: AgentTrace, idempotencyKey: string, provenance?: ProvenanceReport, policy?: ExecutionPolicy): Promise<ApiResult>;
package/dist/agent/act.js CHANGED
@@ -35,8 +35,11 @@ function fundingQuoteEvidence(value) {
35
35
  function coinIdFor(observation, symbol) {
36
36
  return (observation.watch.find((w) => w.symbol.toUpperCase() === symbol.toUpperCase())?.coinId ?? undefined);
37
37
  }
38
- // Read-only quote BEFORE any open. Returns ineligible (never throws) on error.
39
- export async function fetchQuote(client, action, observation, trace) {
38
+ const pmFloorBody = (policy) => typeof policy?.pmMinEntryProbabilityPct === "number" &&
39
+ Number.isFinite(policy.pmMinEntryProbabilityPct)
40
+ ? { minEntryProbabilityPct: policy.pmMinEntryProbabilityPct }
41
+ : {};
42
+ export async function fetchQuote(client, action, observation, trace, policy) {
40
43
  let r;
41
44
  if (action.type === "futures_open") {
42
45
  const coinId = coinIdFor(observation, action.symbol);
@@ -64,6 +67,7 @@ export async function fetchQuote(client, action, observation, trace) {
64
67
  ...(action.forecastProbability != null
65
68
  ? { forecastProbability: action.forecastProbability }
66
69
  : {}),
70
+ ...pmFloorBody(policy),
67
71
  }, trace);
68
72
  }
69
73
  else {
@@ -111,7 +115,7 @@ export async function executeAction(client, action, observation, trace, idempote
111
115
  // Optional SELF-REPORTED runner provenance, attached to a pm_open only (the one
112
116
  // durable-artifact write path here). Absent => the request is byte-identical to
113
117
  // pre-provenance behavior.
114
- provenance) {
118
+ provenance, policy) {
115
119
  if (action.type === "futures_open") {
116
120
  const coinId = coinIdFor(observation, action.symbol);
117
121
  if (!coinId)
@@ -182,6 +186,9 @@ provenance) {
182
186
  ? { forecastProbability: action.forecastProbability }
183
187
  : {}),
184
188
  ...(action.thesis?.summary ? { thesis: action.thesis.summary } : {}),
189
+ // The CONFIGURED entry floor rides along so the server re-checks it inside
190
+ // its locked open transaction (absent => byte-identical request).
191
+ ...pmFloorBody(policy),
185
192
  // Attach runner provenance only when present (byte-identical to before when absent).
186
193
  ...(provenance ? { provenance } : {}),
187
194
  agentTrace: trace,
@@ -18,5 +18,6 @@ export declare function cmdRun(path: string, opts?: {
18
18
  once?: boolean;
19
19
  live?: boolean;
20
20
  stateFile?: string;
21
+ expectDefinition?: string;
21
22
  }): Promise<CmdResult>;
22
23
  export declare function main(argv: string[]): Promise<number>;
package/dist/agent/cli.js CHANGED
@@ -10,6 +10,7 @@ import { resolve as resolvePath, dirname, join, basename } from "node:path";
10
10
  import { parse as parseYaml } from "yaml";
11
11
  import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, hostedProseBudget, HOSTED_PROSE_MAX_CHARS, } from "./resolve.js";
12
12
  import { buildSpec, loadAgent } from "./skill.js";
13
+ import { buildAgentDefinitionSnapshot } from "./definitionSnapshot.js";
13
14
  import { validateSkill } from "./skillValidator.js";
14
15
  import { strictLint } from "./strictLint.js";
15
16
  import { checkCapabilityDrift } from "./capabilityGuard.js";
@@ -227,6 +228,9 @@ export function cmdInspect(path, json = false) {
227
228
  resolvedConfig: resolved.rawFrontmatter,
228
229
  provenance: resolved.provenance,
229
230
  contentHashes: resolved.contentHashes,
231
+ // The local runtime consumes these exact compiled inputs. In particular,
232
+ // skill ablation changes prose, not the source manifest or hard caps.
233
+ compiledDefinition: buildAgentDefinitionSnapshot(spec, runtimeProse(resolved)),
230
234
  validation: { valid: v.valid, issues: v.issues, lint },
231
235
  };
232
236
  if (json) {
@@ -244,11 +248,17 @@ export function cmdInspect(path, json = false) {
244
248
  `model: ${spec.model ? `${spec.model.provider}/${spec.model.name}` : "(host free-tier)"}`,
245
249
  `risk: maxLeverage=${spec.risk.maxLeverage} perTradeMargin=${spec.risk.perTradeMarginMusd} requireStopLoss=${spec.risk.requireStopLoss}`,
246
250
  `sources: ${Object.keys(resolved.contentHashes).length} file(s)`,
251
+ `definition: ${output.compiledDefinition.definitionHash}`,
247
252
  `validation: ${v.valid ? "valid" : "INVALID"}${lint.length ? ` (+${lint.length} lint note(s))` : ""}`,
248
253
  ...output.warnings.map((warning) => `⚠ ${warning}`),
249
254
  ];
250
255
  return { ok: v.valid, code: 0, lines, data: output };
251
256
  }
257
+ function runtimeProse(resolved) {
258
+ return envFlag(process.env.COINRITHM_AGENT_DISABLE_SKILLS)
259
+ ? mergeProseParts(resolved.proseParts.filter((part) => !isSkillProseSource(part.source)))
260
+ : resolved.mergedProse;
261
+ }
252
262
  // Is a process still alive? signal 0 probes without sending — ESRCH means it's
253
263
  // gone, EPERM means it exists but we can't signal it (still alive). Unknown PIDs
254
264
  // (NaN / non-positive) are treated as alive so we never reclaim a malformed lock.
@@ -333,6 +343,16 @@ export async function cmdRun(path, opts = {}) {
333
343
  return issuesResult(e.issues, "resolve failed");
334
344
  throw e;
335
345
  }
346
+ const mergedProse = runtimeProse(loaded.resolved);
347
+ const definition = buildAgentDefinitionSnapshot(loaded.spec, mergedProse);
348
+ if (opts.expectDefinition !== undefined &&
349
+ opts.expectDefinition !== definition.definitionHash) {
350
+ return fail([
351
+ "compiled agent definition does not match --expect-definition; no model or account call was made",
352
+ `current definition: ${definition.definitionHash}`,
353
+ "inspect the change before choosing a new baseline",
354
+ ]);
355
+ }
336
356
  const apiKey = process.env.COINRITHM_API_KEY;
337
357
  if (!apiKey)
338
358
  return fail([
@@ -386,14 +406,12 @@ export async function cmdRun(path, opts = {}) {
386
406
  const live = !!opts.live;
387
407
  const lines = [
388
408
  `run ${live ? "LIVE (paper trades WILL be placed)" : "DRY-RUN (no writes; set --live or LIVE=1)"} — ${loaded.spec.name}`,
409
+ `definition: ${definition.definitionHash}`,
389
410
  ];
390
411
  // Skills ablation kill-switch: drop tactic-skill prose from the prompt for
391
412
  // token-cost control or A/B testing. Affects ONLY the run-time prompt — the
392
413
  // resolver, manifest, and caps are untouched (the spec is still enforced).
393
414
  const disableSkills = envFlag(process.env.COINRITHM_AGENT_DISABLE_SKILLS);
394
- const mergedProse = disableSkills
395
- ? mergeProseParts(loaded.resolved.proseParts.filter((p) => !isSkillProseSource(p.source)))
396
- : loaded.body;
397
415
  if (disableSkills) {
398
416
  const dropped = loaded.resolved.proseParts.filter((p) => isSkillProseSource(p.source)).length;
399
417
  lines.push(`skills DISABLED via COINRITHM_AGENT_DISABLE_SKILLS — ${dropped} tactic skill(s) dropped from the prompt (caps unchanged)`);
@@ -442,6 +460,8 @@ function parseFlags(args) {
442
460
  out.preset = args[++i];
443
461
  else if (a === "--state")
444
462
  out.state = args[++i];
463
+ else if (a === "--expect-definition")
464
+ out.expectDefinition = args[++i] ?? "";
445
465
  else
446
466
  out._.push(a);
447
467
  }
@@ -455,7 +475,7 @@ function usageLines() {
455
475
  " inspect <path> [--json]",
456
476
  " eject <agent.md | dir>",
457
477
  " lock <path>",
458
- " run <path> [--once] [--live] [--dry-run] [--state <file>] (dry-run by default)",
478
+ " run <path> [--once] [--live] [--dry-run] [--state <file>] [--expect-definition sha256:...] (dry-run by default)",
459
479
  ];
460
480
  }
461
481
  export async function main(argv) {
@@ -489,6 +509,7 @@ export async function main(argv) {
489
509
  once: flags.once,
490
510
  live,
491
511
  stateFile: flags.state,
512
+ expectDefinition: flags.expectDefinition,
492
513
  });
493
514
  break;
494
515
  }
@@ -40,13 +40,26 @@ export declare class CoinRithmClient {
40
40
  rateLimitHits: number;
41
41
  constructor(cfg: ClientConfig);
42
42
  private request;
43
+ private publicRequest;
44
+ credentialFingerprint(): string;
43
45
  me(trace?: AgentTrace): Promise<ApiResult>;
46
+ performance(trace?: AgentTrace, options?: {
47
+ timeoutMs?: number;
48
+ maxRetries?: number;
49
+ }): Promise<ApiResult>;
44
50
  portfolio(trace?: AgentTrace): Promise<ApiResult>;
45
51
  wallet(query?: {
46
52
  coinId?: string;
47
53
  }, trace?: AgentTrace): Promise<ApiResult>;
48
54
  resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
49
55
  cryptoMovers(direction: "gainers" | "losers", limit: number, trace?: AgentTrace): Promise<ApiResult>;
56
+ getPublicPmWhales(options?: {
57
+ timeoutMs?: number;
58
+ limit?: number;
59
+ }): Promise<ApiResult>;
60
+ getPublicPmWhaleWallet(source: string, wallet: string, options?: {
61
+ timeoutMs?: number;
62
+ }): Promise<ApiResult>;
50
63
  market(coinId: string, trace?: AgentTrace): Promise<ApiResult>;
51
64
  candles(coinId: string, range: string, trace?: AgentTrace): Promise<ApiResult>;
52
65
  trades(query?: {
@@ -4,6 +4,7 @@
4
4
  // from an agent file). 429 backs off on Retry-After; 401/403/409/422 are
5
5
  // FAIL-CLOSED cycle outcomes (returned, not retried). fetch + sleep are
6
6
  // injectable so tests run with no network and no real waits.
7
+ import { createHash } from "node:crypto";
7
8
  import { setTimeout as sleep } from "node:timers/promises";
8
9
  import { retryAfterSeconds } from "../retryAfter.js";
9
10
  export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
@@ -54,6 +55,16 @@ export class CoinRithmClient {
54
55
  this.extraHeaders = cfg.extraHeaders;
55
56
  }
56
57
  async request(method, path, opts = {}) {
58
+ const requestTimeoutMs = opts.timeoutMs !== undefined &&
59
+ Number.isSafeInteger(opts.timeoutMs) &&
60
+ opts.timeoutMs >= 1
61
+ ? Math.min(opts.timeoutMs, this.requestTimeoutMs)
62
+ : this.requestTimeoutMs;
63
+ const maxRetries = opts.maxRetries !== undefined &&
64
+ Number.isSafeInteger(opts.maxRetries) &&
65
+ opts.maxRetries >= 0
66
+ ? Math.min(opts.maxRetries, this.maxRetries)
67
+ : this.maxRetries;
57
68
  const url = new URL(this.baseUrl + path);
58
69
  if (opts.query) {
59
70
  for (const [k, v] of Object.entries(opts.query)) {
@@ -76,7 +87,7 @@ export class CoinRithmClient {
76
87
  const timer = setTimeout(() => {
77
88
  timedOut = true;
78
89
  controller.abort(new Error("API request deadline exceeded"));
79
- }, this.requestTimeoutMs);
90
+ }, requestTimeoutMs);
80
91
  this.signal?.addEventListener("abort", cancel, { once: true });
81
92
  if (this.signal?.aborted)
82
93
  cancel();
@@ -102,12 +113,12 @@ export class CoinRithmClient {
102
113
  const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
103
114
  if (res.status === 429)
104
115
  this.rateLimitHits += 1;
105
- if (res.status === 429 && attempt < this.maxRetries) {
116
+ if (res.status === 429 && attempt < maxRetries) {
106
117
  // Release this response before waiting so retries do not retain sockets.
107
118
  void res.body?.cancel().catch(() => { });
108
119
  const delayMs = (retryAfter ?? 5) * 1000;
109
120
  // Never shorten a provider's Retry-After or overflow a Node timer.
110
- if (delayMs >= this.requestTimeoutMs)
121
+ if (delayMs >= requestTimeoutMs)
111
122
  await aborted;
112
123
  else if (this.sleepFn)
113
124
  await this.sleepFn(delayMs);
@@ -151,7 +162,7 @@ export class CoinRithmClient {
151
162
  ? "request_aborted"
152
163
  : "network_error",
153
164
  message: timedOut
154
- ? `API request exceeded ${this.requestTimeoutMs}ms deadline`
165
+ ? `API request exceeded ${requestTimeoutMs}ms deadline`
155
166
  : controller.signal.aborted
156
167
  ? "API request cancelled"
157
168
  : err instanceof Error
@@ -166,10 +177,96 @@ export class CoinRithmClient {
166
177
  controller.signal.removeEventListener("abort", rejectAborted);
167
178
  }
168
179
  }
180
+ // Keyless, bounded reads against the public PM surface. These deliberately
181
+ // bypass the authenticated request path so a runner key cannot leak into a
182
+ // public context lookup. No retries are added here; the enrichment is
183
+ // optional and its deadline includes response-body consumption.
184
+ async publicRequest(path, query, timeoutMs = 5_000) {
185
+ const url = new URL(this.baseUrl + path);
186
+ for (const [key, value] of Object.entries(query ?? {})) {
187
+ if (value !== undefined && value !== null && value !== "")
188
+ url.searchParams.set(key, String(value));
189
+ }
190
+ const controller = new AbortController();
191
+ if (this.signal?.aborted) {
192
+ return { ok: false, status: 0, data: { error: "network_error" } };
193
+ }
194
+ const cancel = () => controller.abort();
195
+ this.signal?.addEventListener("abort", cancel, { once: true });
196
+ const deadlineMs = Math.min(timeoutMs, this.requestTimeoutMs);
197
+ let timedOut = false;
198
+ const timer = setTimeout(() => {
199
+ timedOut = true;
200
+ controller.abort();
201
+ }, deadlineMs);
202
+ let rejectAborted;
203
+ const aborted = new Promise((_, reject) => {
204
+ rejectAborted = () => reject(new Error("public request aborted"));
205
+ controller.signal.addEventListener("abort", rejectAborted, {
206
+ once: true,
207
+ });
208
+ });
209
+ if (this.signal?.aborted)
210
+ cancel();
211
+ try {
212
+ const perform = async () => {
213
+ const res = await this.fetchFn(url.toString(), {
214
+ method: "GET",
215
+ headers: { Accept: "application/json" },
216
+ signal: controller.signal,
217
+ });
218
+ const text = await res.text();
219
+ let data = text;
220
+ if (text) {
221
+ try {
222
+ data = JSON.parse(text);
223
+ }
224
+ catch {
225
+ /* leave as text */
226
+ }
227
+ }
228
+ return { ok: res.ok, status: res.status, data };
229
+ };
230
+ return await Promise.race([perform(), aborted]);
231
+ }
232
+ catch {
233
+ return {
234
+ ok: false,
235
+ status: 0,
236
+ data: {
237
+ error: timedOut ? "timeout" : "network_error",
238
+ },
239
+ };
240
+ }
241
+ finally {
242
+ clearTimeout(timer);
243
+ controller.signal.removeEventListener("abort", rejectAborted);
244
+ this.signal?.removeEventListener("abort", cancel);
245
+ }
246
+ }
247
+ // A stable, non-reversible identity for this credential and API base, for
248
+ // per-agent in-memory caches that must outlive one client instance (the
249
+ // hosted scheduler builds a new client every cycle). Never the key itself.
250
+ credentialFingerprint() {
251
+ return createHash("sha256")
252
+ .update(`${this.baseUrl}\n${this.apiKey}`, "utf8")
253
+ .digest("hex")
254
+ .slice(0, 32);
255
+ }
169
256
  // ── reads ──────────────────────────────────────────────────────────────────
170
257
  me(trace) {
171
258
  return this.request("GET", "/api/agent/me", { trace });
172
259
  }
260
+ // GET /api/agent/performance: the calling key's own realized performance.
261
+ // Newer backends add an optional `pmCalibration` block (the key's settled PM
262
+ // forecast record), parsed defensively in observe.
263
+ performance(trace, options) {
264
+ return this.request("GET", "/api/agent/performance", {
265
+ trace,
266
+ timeoutMs: options?.timeoutMs,
267
+ maxRetries: options?.maxRetries,
268
+ });
269
+ }
173
270
  portfolio(trace) {
174
271
  return this.request("GET", "/api/agent/portfolio", { trace });
175
272
  }
@@ -186,6 +283,12 @@ export class CoinRithmClient {
186
283
  ? "/api/coins/top-losers"
187
284
  : "/api/coins/top-gainers", { query: { limit }, trace });
188
285
  }
286
+ getPublicPmWhales(options) {
287
+ return this.publicRequest("/api/prediction-markets/whales", { limit: options?.limit }, options?.timeoutMs);
288
+ }
289
+ getPublicPmWhaleWallet(source, wallet, options) {
290
+ return this.publicRequest(`/api/prediction-markets/whales/wallets/${encodeURIComponent(source)}/${encodeURIComponent(wallet)}`, undefined, options?.timeoutMs);
291
+ }
189
292
  market(coinId, trace) {
190
293
  return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}`, { trace });
191
294
  }
@@ -8,7 +8,7 @@ interface Row {
8
8
  }
9
9
  export interface DecisionInputRecord {
10
10
  version: "coinrithm.decision-input.v1";
11
- projectionVersion?: "coinrithm.decision-input-projection.v2";
11
+ projectionVersion?: "coinrithm.decision-input-projection.v2" | "coinrithm.decision-input-projection.v3";
12
12
  visibility: "private";
13
13
  completeness: "partial";
14
14
  phase: DecisionInputPhase;
@@ -21,6 +21,10 @@ export interface DecisionInputRecord {
21
21
  dailyRiskBudget: DailyRiskBudget | null;
22
22
  guardState: Record<string, number | boolean | null>;
23
23
  account: Record<string, number | boolean | string | null> | null;
24
+ marketMood?: {
25
+ fearGreed: number | null;
26
+ fetchedAt: string | null;
27
+ } | null;
24
28
  lists: Record<string, Row[]>;
25
29
  counts: Record<string, {
26
30
  source: number;
@@ -22,6 +22,20 @@ const fingerprint = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64
22
22
  ? value
23
23
  : null;
24
24
  const numeric = (raw, keys) => Object.fromEntries(keys.map((key) => [key, num(raw[key])]));
25
+ const count = (value) => Number.isSafeInteger(value) && value >= 0
26
+ ? value
27
+ : null;
28
+ const fearGreed = (value) => {
29
+ const v = num(value);
30
+ return v !== null && v >= 0 && v <= 100 ? v : null;
31
+ };
32
+ const PROJECTION_V3 = "coinrithm.decision-input-projection.v3";
33
+ // Watch-row fields that exist only from projection v3 on.
34
+ const SENTIMENT_V3_KEYS = [
35
+ "sentimentTotalVotes",
36
+ "sentimentDayUtc",
37
+ "sentimentUpdatedAt",
38
+ ];
25
39
  const freshness = (value) => {
26
40
  const raw = obj(parseFreshness({ freshness: value }));
27
41
  return {
@@ -49,7 +63,7 @@ export function buildDecisionInputRecord(input) {
49
63
  const budget = buildDailyRiskBudget(input.spec, input.state);
50
64
  const record = {
51
65
  version: "coinrithm.decision-input.v1",
52
- projectionVersion: "coinrithm.decision-input-projection.v2",
66
+ projectionVersion: PROJECTION_V3,
53
67
  visibility: "private",
54
68
  completeness: "partial",
55
69
  phase: input.phase,
@@ -115,6 +129,12 @@ export function buildDecisionInputRecord(input) {
115
129
  equityMusd: num(obs.equityMusd),
116
130
  polledBeforeWrite: bool(obs.polledBeforeWrite),
117
131
  };
132
+ record.marketMood = obs.marketMood
133
+ ? {
134
+ fearGreed: fearGreed(obs.marketMood.fearGreed),
135
+ fetchedAt: sourceTimestamp(obs.marketMood.fetchedAt) ?? null,
136
+ }
137
+ : null;
118
138
  const add = (name, items, project) => {
119
139
  const all = arr(items);
120
140
  // Bound work/space independently of the upstream list limit. Preserve order,
@@ -137,7 +157,36 @@ export function buildDecisionInputRecord(input) {
137
157
  "change7d",
138
158
  "sentimentBullishPct",
139
159
  ]),
160
+ // The sample and its own clocks; null = unknown, never "fresh".
161
+ sentimentTotalVotes: count(r.sentimentTotalVotes),
162
+ sentimentDayUtc: sourceTimestamp(r.sentimentDayUtc) ?? null,
163
+ sentimentUpdatedAt: sourceTimestamp(r.sentimentUpdatedAt) ?? null,
140
164
  freshness: freshness(r.freshness),
165
+ ...(r.indicatorContext
166
+ ? {
167
+ indicatorContext: {
168
+ range: code(obj(r.indicatorContext).range, ["1D"]),
169
+ asOf: sourceTimestamp(obj(r.indicatorContext).asOf) ?? null,
170
+ intervalStatus: code(obj(r.indicatorContext).intervalStatus, [
171
+ "regular",
172
+ "irregular",
173
+ "unknown",
174
+ ]),
175
+ ...numeric(obj(r.indicatorContext), [
176
+ "nominalIntervalSeconds",
177
+ "barCount",
178
+ "timestampedBarCount",
179
+ "checkedIntervalCount",
180
+ "irregularIntervalCount",
181
+ "maxGapSeconds",
182
+ ]),
183
+ recent15: {
184
+ ...numeric(obj(obj(r.indicatorContext).recent15), ["barCount"]),
185
+ intervalStatus: code(obj(obj(r.indicatorContext).recent15).intervalStatus, ["regular", "irregular", "unknown"]),
186
+ },
187
+ },
188
+ }
189
+ : {}),
141
190
  indicators: {
142
191
  ...numeric(obj(r.indicators), [
143
192
  "asOfClose",
@@ -332,8 +381,10 @@ const LIST_KEYS = {
332
381
  "change24h",
333
382
  "change7d",
334
383
  "sentimentBullishPct",
384
+ ...SENTIMENT_V3_KEYS,
335
385
  "freshness",
336
386
  "indicators",
387
+ "indicatorContext",
337
388
  "fundamentals",
338
389
  ],
339
390
  futuresPositions: [
@@ -379,6 +430,19 @@ const LIST_KEYS = {
379
430
  universeMovers: ["symbol", "change24hPct", "priceUsd"],
380
431
  };
381
432
  const NESTED_KEYS = {
433
+ indicatorContext: [
434
+ "range",
435
+ "asOf",
436
+ "nominalIntervalSeconds",
437
+ "barCount",
438
+ "timestampedBarCount",
439
+ "checkedIntervalCount",
440
+ "irregularIntervalCount",
441
+ "maxGapSeconds",
442
+ "intervalStatus",
443
+ "recent15",
444
+ ],
445
+ recent15: ["barCount", "intervalStatus"],
382
446
  freshness: ["status", "ageSeconds", "asOf", "basis"],
383
447
  quality: [
384
448
  "decisionEligible",
@@ -428,10 +492,22 @@ function validRow(value, keys) {
428
492
  return true;
429
493
  if (NESTED_KEYS[key])
430
494
  return validRow(v, NESTED_KEYS[key]);
431
- if (key === "asOf" || key === "assessedAt" || key === "openedAt")
495
+ if ([
496
+ "asOf",
497
+ "assessedAt",
498
+ "openedAt",
499
+ "sentimentDayUtc",
500
+ "sentimentUpdatedAt",
501
+ ].includes(key))
432
502
  return sourceTimestamp(v) === v;
503
+ if (key === "sentimentTotalVotes")
504
+ return count(v) !== null;
433
505
  if (key === "basis")
434
506
  return code(v, FRESHNESS_BASES) !== null;
507
+ if (key === "range")
508
+ return code(v, ["1D"]) !== null;
509
+ if (key === "intervalStatus")
510
+ return code(v, ["regular", "irregular", "unknown"]) !== null;
435
511
  if (key === "policyVersion")
436
512
  return typeof v === "string" && /^pm-quality-\d{1,3}$/.test(v);
437
513
  if (key === "warningReasons" || key === "blockReasons") {
@@ -522,13 +598,29 @@ export function sanitizeDecisionInputRecord(value) {
522
598
  "dailyRiskBudget",
523
599
  "guardState",
524
600
  "account",
601
+ "marketMood",
525
602
  "lists",
526
603
  "counts",
527
604
  "omissions",
528
605
  ]))
529
606
  return undefined;
530
607
  if (r.projectionVersion !== undefined &&
531
- r.projectionVersion !== "coinrithm.decision-input-projection.v2")
608
+ r.projectionVersion !== "coinrithm.decision-input-projection.v2" &&
609
+ r.projectionVersion !== PROJECTION_V3)
610
+ return undefined;
611
+ // An older record must never carry, or be mistaken for, v3 fields.
612
+ const v3 = r.projectionVersion === PROJECTION_V3;
613
+ if (!v3 &&
614
+ (r.marketMood !== undefined ||
615
+ arr(obj(r.lists).watch).some((row) => SENTIMENT_V3_KEYS.some((k) => k in obj(row)))))
616
+ return undefined;
617
+ if (r.marketMood !== undefined &&
618
+ r.marketMood !== null &&
619
+ (!keysOnly(r.marketMood, ["fearGreed", "fetchedAt"]) ||
620
+ (r.marketMood.fearGreed !== null &&
621
+ fearGreed(r.marketMood.fearGreed) === null) ||
622
+ (r.marketMood.fetchedAt !== null &&
623
+ sourceTimestamp(r.marketMood.fetchedAt) !== r.marketMood.fetchedAt)))
532
624
  return undefined;
533
625
  if (r.version !== "coinrithm.decision-input.v1" ||
534
626
  r.visibility !== "private" ||
@@ -17,6 +17,44 @@ const PM_MIN_FORECAST_EDGE_POINTS = (() => {
17
17
  return Number.isFinite(raw) && raw >= 0 ? raw : 2;
18
18
  })();
19
19
  const PM_MIN_STAKE_MUSD = 10; // server minimum prediction-market stake
20
+ // Match backend-v2 validateSlTpPrices, including its boundary tolerance. This
21
+ // preflight uses observed evidence only; the API still checks a fresh mark and
22
+ // locked position at execution. Older observations can omit these prices.
23
+ const TRIGGER_PRICE_EPS = 1e-8;
24
+ function validateTriggerUpdate(action, pos) {
25
+ // executeAction omits null as well as undefined; neither clears a trigger in
26
+ // this runner. Check the resulting state, including retained protection.
27
+ const sl = action.stopLossPrice ?? pos.stopLossPrice;
28
+ const tp = action.takeProfitPrice ?? pos.takeProfitPrice;
29
+ const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
30
+ if (sl != null && !positive(sl))
31
+ return fail("invalid_stop_loss_price", `position ${pos.id}: stop loss must be finite and positive`);
32
+ if (tp != null && !positive(tp))
33
+ return fail("invalid_take_profit_price", `position ${pos.id}: take profit must be finite and positive`);
34
+ if (pos.side !== "long" && pos.side !== "short")
35
+ return ok();
36
+ const long = pos.side === "long";
37
+ const mark = pos.markPrice;
38
+ const liq = pos.liquidationPrice;
39
+ if (sl != null) {
40
+ if (positive(mark) &&
41
+ (long ? sl >= mark - TRIGGER_PRICE_EPS : sl <= mark + TRIGGER_PRICE_EPS)) {
42
+ return fail(long ? "stop_loss_not_below_mark" : "stop_loss_not_above_mark", `position ${pos.id}: ${pos.side} effective stop ${sl} must be ${long ? "below" : "above"} observed mark ${mark}; omitted triggers keep their existing values`);
43
+ }
44
+ if (positive(liq) &&
45
+ (long ? sl <= liq + TRIGGER_PRICE_EPS : sl >= liq - TRIGGER_PRICE_EPS)) {
46
+ return fail(long
47
+ ? "stop_loss_not_above_liquidation"
48
+ : "stop_loss_not_below_liquidation", `position ${pos.id}: ${pos.side} effective stop ${sl} must be ${long ? "above" : "below"} observed liquidation ${liq}; omitted triggers keep their existing values`);
49
+ }
50
+ }
51
+ if (tp != null &&
52
+ positive(mark) &&
53
+ (long ? tp <= mark + TRIGGER_PRICE_EPS : tp >= mark - TRIGGER_PRICE_EPS)) {
54
+ return fail(long ? "take_profit_not_above_mark" : "take_profit_not_below_mark", `position ${pos.id}: ${pos.side} effective take profit ${tp} must be ${long ? "above" : "below"} observed mark ${mark}; omitted triggers keep their existing values`);
55
+ }
56
+ return ok();
57
+ }
20
58
  // Entry budgets are exposure budgets, not emergency-action budgets. Closing a
21
59
  // futures position, updating its protection, cancelling an order, or selling
22
60
  // spot reduces/contains risk and must remain available after an entry cap.
@@ -219,6 +257,7 @@ export function validateAction(action, ctx) {
219
257
  if (!hasTrigger) {
220
258
  return fail("sltp_no_op", "futures_set_sltp must set at least one positive stopLossPrice or takeProfitPrice");
221
259
  }
260
+ return validateTriggerUpdate(action, pos);
222
261
  }
223
262
  return ok();
224
263
  }
@@ -316,6 +355,41 @@ export function validateAction(action, ctx) {
316
355
  if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
317
356
  return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
318
357
  }
358
+ // Hard entry-price floor (opt-in, 2026-09-24): compares the CHOSEN
359
+ // outcome's raw market probability at entry (the quote's entryProbability,
360
+ // points) with risk.pmMinEntryProbabilityPct. Fees are deliberately left
361
+ // out: a 19-point outcome fails a 20-point floor even when fees push its
362
+ // all-in cost above 20 (fees belong to the edge check below). No
363
+ // discovery-price fallback: with a floor set and no finite quoted
364
+ // probability the open is rejected, never waved through.
365
+ const entryFloor = spec.risk.pmMinEntryProbabilityPct;
366
+ if (entryFloor !== undefined) {
367
+ // A present-but-invalid floor never silently means "no floor".
368
+ if (typeof entryFloor !== "number" ||
369
+ !Number.isFinite(entryFloor) ||
370
+ entryFloor < 0 ||
371
+ entryFloor > 100) {
372
+ return fail("pm_entry_floor_invalid", `risk.pmMinEntryProbabilityPct ${JSON.stringify(entryFloor)} is not a number between 0 and 100`);
373
+ }
374
+ const market = ctx.quote.entryProbability;
375
+ if (typeof market !== "number" ||
376
+ !Number.isFinite(market) ||
377
+ market < 0 ||
378
+ market > 100) {
379
+ return fail("pm_entry_price_unavailable", `entry floor ${entryFloor}pt is set but the quote carries no usable market probability for the chosen outcome (${JSON.stringify(market ?? null)})`);
380
+ }
381
+ if (market + 1e-9 < entryFloor) {
382
+ const stake = ctx.quote.stakeMusd;
383
+ const shares = ctx.quote.sharesEstimate;
384
+ const cost = typeof stake === "number" &&
385
+ typeof shares === "number" &&
386
+ stake > 0 &&
387
+ shares > 0
388
+ ? ` (fee-inclusive cost ${((stake / shares) * 100).toFixed(1)}pt)`
389
+ : "";
390
+ return fail("pm_entry_below_floor", `market ${market.toFixed(1)}pt is below the ${entryFloor}pt entry floor for the chosen outcome${cost}`);
391
+ }
392
+ }
319
393
  // Forecast consistency. By prompt contract forecastProbability is the
320
394
  // model's own probability (1-99) that the outcome IT IS BACKING wins, so
321
395
  // buying that outcome only makes sense when the forecast clears what the
@@ -0,0 +1,22 @@
1
+ import type { AgentSpec } from "./types.js";
2
+ /** Private, portable definition; deliberately excludes mutable account state. */
3
+ export interface AgentDefinitionSnapshot {
4
+ schema: "coinrithm.agent-definition.v1";
5
+ engine: {
6
+ packageVersion: string;
7
+ resolverVersion: string;
8
+ indicatorVersion: string;
9
+ apiContractVersion: string;
10
+ };
11
+ /** Pass the actual post-overlay spec, without rebuilding it from frontmatter. */
12
+ spec: AgentSpec;
13
+ /** Exact strategy prose supplied to runCycle, after any skills ablation. */
14
+ mergedProse: string;
15
+ definitionHash: string;
16
+ }
17
+ /**
18
+ * Binds the compiled definition and declared engine versions, not the original
19
+ * source folder alone. This is an integrity/comparison hash, not a signature,
20
+ * complete replay record, or attestation of a particular deployed binary.
21
+ */
22
+ export declare function buildAgentDefinitionSnapshot(spec: AgentSpec, mergedProse: string): AgentDefinitionSnapshot;