@coinrithm/mcp-trading 0.7.13 → 0.7.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
5
5
  self-host agent runner) — versioned together. The CoinRithm **API contract** is
6
6
  versioned separately (see `openapi.yaml` `info.version`, currently `1.7.0`).
7
7
 
8
+ ## 0.7.14
9
+
10
+ - Scope permanent model-error streaks to the attempted provider/model. Discard
11
+ legacy unattributed streaks and reset availability failures on a successful
12
+ provider response, including malformed decisions. Old-route failures cannot
13
+ request an early hold on a replacement model; agent risk limits are unchanged.
14
+ - Attribute a routed permanent-error hold to the attempt that produced the error,
15
+ even when a later fallback is rate-limited; keep actual-call metering unchanged.
16
+ - Preflight futures protection updates against observed side, mark and liquidation
17
+ prices, including retained triggers. Reject known invalid end-states with an
18
+ actionable reason before a write; the API remains authoritative as prices move.
19
+ - Add the optional `risk.pmMinEntryProbabilityPct` policy (0..100 points): the
20
+ runner rejects `pm_open` when the chosen outcome's raw market probability at
21
+ entry is below the floor, with a reason that names both numbers. Fees stay
22
+ in the forecast-edge check, so a 19-point outcome fails a 20-point floor even
23
+ when fees lift its cost above 20. Absent keeps today's behaviour; a set floor
24
+ with no quoted probability fails closed. The floor is rendered in the prompt's
25
+ hard caps.
26
+ - Retain candle timestamps and compact coverage/interval evidence alongside
27
+ indicators, separately from market-price freshness. Missing timestamps stay
28
+ unknown; nominal five-minute cadence does not imply current or regular bars.
29
+ Model input and private decision evidence retain the same context.
30
+ - Load optional `character/entries.md`, `exits.md`, `sizing.md` and `research.md`
31
+ from local bundles so Studio strategy sections reach the model, manifest and
32
+ compiled definition. Existing bundles without these files are unchanged.
33
+ - Include the exact compiled strategy definition and its digest in local
34
+ inspection; expose the same snapshot builder to engine consumers.
35
+ - Add `run --expect-definition` to reject a changed baseline before model or
36
+ account access. The digest is not a full replay record or broker adapter.
37
+ - Hosted validation accepts `limits.maxDailyLossMusd: 0` as no daily loss cap,
38
+ matching the runner and the hosted API; negative and non-finite values are
39
+ still rejected.
40
+ - Tactic cap merges treat 0 as unlimited for `maxTradesPerDay` and
41
+ `maxDailyLossMusd`: a tactic may tighten 0 to a positive cap but can no
42
+ longer turn a positive cap into 0. Writes and margins keep lower-is-tighter.
43
+
44
+ Publication and hosted deployment are verified separately. See the repository's
45
+ [release status](https://github.com/CoinRithm/coinrithm-agent-trading#version-clarity)
46
+ for registry availability and the hosted runtime evidence.
47
+
8
48
  ## 0.7.13
9
49
 
10
50
  - Preserve optional per-outcome venue terms in compact prediction-market output,
package/README.md CHANGED
@@ -43,14 +43,16 @@ This package ships two binaries:
43
43
 
44
44
  > **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
45
45
 
46
- ## Version 0.7.13
47
-
48
- This patch corrects provider-capacity classification and exports
49
- `classifyProviderFailure` from the supported engine entry point. Retry counts
50
- and trading behavior are unchanged. See [CHANGELOG.md](./CHANGELOG.md).
51
- This version also includes public whale-wallet context and the clarified
52
- market-price calibration definitions on the 40-tool surface. Check
53
- `npm view @coinrithm/mcp-trading version` and the
46
+ ## Version 0.7.14
47
+
48
+ This release makes a configured prediction-market entry floor executable
49
+ (`risk.pmMinEntryProbabilityPct`: runner preflight plus the API's own re-check
50
+ at execution), preflights futures stop/target updates against observed prices,
51
+ loads the optional strategy sections from local bundles, pins compiled strategy
52
+ definitions (`run --expect-definition`), retains candle timing evidence, and
53
+ scopes permanent model-error streaks to the attempted provider/model. Trading
54
+ limits and retry counts are otherwise unchanged. See [CHANGELOG.md](./CHANGELOG.md).
55
+ Check `npm view @coinrithm/mcp-trading version` and the
54
56
  [release status](https://github.com/CoinRithm/coinrithm-agent-trading#version-clarity)
55
57
  for registry availability; hosted deployments and npm releases are separate.
56
58
 
@@ -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
  }
@@ -138,6 +138,31 @@ export function buildDecisionInputRecord(input) {
138
138
  "sentimentBullishPct",
139
139
  ]),
140
140
  freshness: freshness(r.freshness),
141
+ ...(r.indicatorContext
142
+ ? {
143
+ indicatorContext: {
144
+ range: code(obj(r.indicatorContext).range, ["1D"]),
145
+ asOf: sourceTimestamp(obj(r.indicatorContext).asOf) ?? null,
146
+ intervalStatus: code(obj(r.indicatorContext).intervalStatus, [
147
+ "regular",
148
+ "irregular",
149
+ "unknown",
150
+ ]),
151
+ ...numeric(obj(r.indicatorContext), [
152
+ "nominalIntervalSeconds",
153
+ "barCount",
154
+ "timestampedBarCount",
155
+ "checkedIntervalCount",
156
+ "irregularIntervalCount",
157
+ "maxGapSeconds",
158
+ ]),
159
+ recent15: {
160
+ ...numeric(obj(obj(r.indicatorContext).recent15), ["barCount"]),
161
+ intervalStatus: code(obj(obj(r.indicatorContext).recent15).intervalStatus, ["regular", "irregular", "unknown"]),
162
+ },
163
+ },
164
+ }
165
+ : {}),
141
166
  indicators: {
142
167
  ...numeric(obj(r.indicators), [
143
168
  "asOfClose",
@@ -334,6 +359,7 @@ const LIST_KEYS = {
334
359
  "sentimentBullishPct",
335
360
  "freshness",
336
361
  "indicators",
362
+ "indicatorContext",
337
363
  "fundamentals",
338
364
  ],
339
365
  futuresPositions: [
@@ -379,6 +405,19 @@ const LIST_KEYS = {
379
405
  universeMovers: ["symbol", "change24hPct", "priceUsd"],
380
406
  };
381
407
  const NESTED_KEYS = {
408
+ indicatorContext: [
409
+ "range",
410
+ "asOf",
411
+ "nominalIntervalSeconds",
412
+ "barCount",
413
+ "timestampedBarCount",
414
+ "checkedIntervalCount",
415
+ "irregularIntervalCount",
416
+ "maxGapSeconds",
417
+ "intervalStatus",
418
+ "recent15",
419
+ ],
420
+ recent15: ["barCount", "intervalStatus"],
382
421
  freshness: ["status", "ageSeconds", "asOf", "basis"],
383
422
  quality: [
384
423
  "decisionEligible",
@@ -432,6 +471,10 @@ function validRow(value, keys) {
432
471
  return sourceTimestamp(v) === v;
433
472
  if (key === "basis")
434
473
  return code(v, FRESHNESS_BASES) !== null;
474
+ if (key === "range")
475
+ return code(v, ["1D"]) !== null;
476
+ if (key === "intervalStatus")
477
+ return code(v, ["regular", "irregular", "unknown"]) !== null;
435
478
  if (key === "policyVersion")
436
479
  return typeof v === "string" && /^pm-quality-\d{1,3}$/.test(v);
437
480
  if (key === "warningReasons" || key === "blockReasons") {
@@ -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;
@@ -0,0 +1,28 @@
1
+ import { INDICATOR_VERSION } from "./indicators.js";
2
+ import { COINRITHM_API, RESOLVER_VERSION } from "./version.js";
3
+ import { sha256, stableStringify } from "./util.js";
4
+ /**
5
+ * Binds the compiled definition and declared engine versions, not the original
6
+ * source folder alone. This is an integrity/comparison hash, not a signature,
7
+ * complete replay record, or attestation of a particular deployed binary.
8
+ */
9
+ export function buildAgentDefinitionSnapshot(spec, mergedProse) {
10
+ const definition = {
11
+ schema: "coinrithm.agent-definition.v1",
12
+ engine: {
13
+ packageVersion: COINRITHM_API.mcpVersion,
14
+ resolverVersion: RESOLVER_VERSION,
15
+ indicatorVersion: INDICATOR_VERSION,
16
+ apiContractVersion: COINRITHM_API.openapiVersion,
17
+ },
18
+ spec,
19
+ mergedProse,
20
+ };
21
+ // Detach from caller-owned spec: later mutations must not invalidate a saved
22
+ // artifact while leaving its old hash attached to it.
23
+ const serialized = stableStringify(definition);
24
+ return {
25
+ ...JSON.parse(serialized),
26
+ definitionHash: sha256(serialized),
27
+ };
28
+ }
@@ -1,4 +1,5 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
+ export { buildAgentDefinitionSnapshot, type AgentDefinitionSnapshot, } from "./definitionSnapshot.js";
2
3
  export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
3
4
  export { selectProvider, providerForRoute, classifyProviderFailure, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
4
5
  export { parseDecision } from "./decision.js";
@@ -6,6 +6,7 @@
6
6
  // This barrel is the ONE import a host scheduler needs; it re-exports only the
7
7
  // stable engine pieces, never the CLI.
8
8
  export { runCycle } from "./runner.js";
9
+ export { buildAgentDefinitionSnapshot, } from "./definitionSnapshot.js";
9
10
  export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
10
11
  export { selectProvider, providerForRoute, classifyProviderFailure, } from "./providers.js";
11
12
  export { parseDecision } from "./decision.js";
@@ -1,5 +1,5 @@
1
1
  import { ResolveIssue } from "./types.js";
2
- export type CapDirection = "lower" | "higher" | "true";
2
+ export type CapDirection = "lower" | "lower_zero_off" | "higher" | "true";
3
3
  export declare const RISK_CAPS: Record<string, CapDirection>;
4
4
  export declare const LIMIT_CAPS: Record<string, CapDirection>;
5
5
  export declare function isAtLeastAsRestrictive(dir: CapDirection, candidate: unknown, base: unknown): boolean;
@@ -8,6 +8,9 @@
8
8
  //
9
9
  // Normal (non-cap) config uses simple precedence (inline > $ref > defaults),
10
10
  // handled in the resolver; this module owns only the cap arithmetic.
11
+ // 0 is "no cap" for lower_zero_off; every other value compares as itself.
12
+ const zeroOffLimit = (n) => (n === 0 ? Infinity : n);
13
+ const isZeroOffValue = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
11
14
  // risk.* hard caps a tactic module may tighten.
12
15
  export const RISK_CAPS = {
13
16
  maxLeverage: "lower",
@@ -17,9 +20,9 @@ export const RISK_CAPS = {
17
20
  };
18
21
  // limits.* throughput/spend caps a tactic module may tighten.
19
22
  export const LIMIT_CAPS = {
20
- maxTradesPerDay: "lower",
23
+ maxTradesPerDay: "lower_zero_off",
21
24
  maxWritesPerCycle: "lower",
22
- maxDailyLossMusd: "lower",
25
+ maxDailyLossMusd: "lower_zero_off",
23
26
  maxOpenMarginMusd: "lower",
24
27
  };
25
28
  // Is `candidate` at least as restrictive as `base` (i.e. a legal tightening)?
@@ -29,6 +32,11 @@ export function isAtLeastAsRestrictive(dir, candidate, base) {
29
32
  // legal: base=false→candidate any; base=true→candidate must be true.
30
33
  return base === true ? candidate === true : true;
31
34
  }
35
+ if (dir === "lower_zero_off") {
36
+ if (!isZeroOffValue(candidate) || !isZeroOffValue(base))
37
+ return false;
38
+ return zeroOffLimit(candidate) <= zeroOffLimit(base);
39
+ }
32
40
  if (typeof candidate !== "number" || typeof base !== "number") {
33
41
  // a non-numeric cap candidate where a number is expected is not a legal
34
42
  // tightening (caller will surface a type issue separately).
@@ -40,6 +48,13 @@ export function isAtLeastAsRestrictive(dir, candidate, base) {
40
48
  export function mostRestrictive(dir, a, b) {
41
49
  if (dir === "true")
42
50
  return a === true || b === true ? true : false;
51
+ if (dir === "lower_zero_off") {
52
+ if (!isZeroOffValue(a))
53
+ return b;
54
+ if (!isZeroOffValue(b))
55
+ return a;
56
+ return zeroOffLimit(a) <= zeroOffLimit(b) ? a : b;
57
+ }
43
58
  if (typeof a !== "number")
44
59
  return b;
45
60
  if (typeof b !== "number")
@@ -6,10 +6,10 @@ import { computeIndicators } from "./indicators.js";
6
6
  import { scanSetups } from "./setups.js";
7
7
  import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
8
8
  import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
9
- // Candle granularity feeding the indicators: the 1D range = 5-minute candles
10
- // (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
11
- // short cadence the hosted house agents run on. Probe-verified 2026-06-17.
9
+ // The 1D endpoint nominally returns 288 five-minute bars. Timestamps and gaps
10
+ // are checked separately; requesting this range does not establish freshness.
12
11
  const INDICATOR_RANGE = "1D";
12
+ const INDICATOR_INTERVAL_SECONDS = 300;
13
13
  // `universe_scan` bounds: how many top movers to pull, and how many of those
14
14
  // to fully resolve into tradable watch entries (each resolved row costs a
15
15
  // resolve + market [+ candles] call).
@@ -73,6 +73,49 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
73
73
  export function isCalibrationChurnMarket(market) {
74
74
  return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
75
75
  }
76
+ // The live endpoint's t is Unix seconds. Missing/malformed values (including
77
+ // millisecond epochs) stay unknown; never substitute retrieval time.
78
+ function candleTimestamp(value) {
79
+ return typeof value === "number" &&
80
+ Number.isInteger(value) &&
81
+ value > 0 &&
82
+ value <= 253_402_300_799
83
+ ? value
84
+ : undefined;
85
+ }
86
+ function candleIntervals(times) {
87
+ let timestampedBarCount = 0;
88
+ let checkedIntervalCount = 0;
89
+ let irregularIntervalCount = 0;
90
+ let maxGapSeconds;
91
+ for (let i = 0; i < times.length; i++) {
92
+ const current = times[i];
93
+ if (current === undefined)
94
+ continue;
95
+ timestampedBarCount++;
96
+ const previous = times[i - 1];
97
+ if (previous === undefined)
98
+ continue;
99
+ const gap = current - previous;
100
+ checkedIntervalCount++;
101
+ if (gap !== INDICATOR_INTERVAL_SECONDS)
102
+ irregularIntervalCount++;
103
+ maxGapSeconds = Math.max(maxGapSeconds ?? 0, gap);
104
+ }
105
+ const intervalStatus = irregularIntervalCount > 0
106
+ ? "irregular"
107
+ : times.length < 2 || timestampedBarCount !== times.length
108
+ ? "unknown"
109
+ : "regular";
110
+ return {
111
+ barCount: times.length,
112
+ timestampedBarCount,
113
+ checkedIntervalCount,
114
+ irregularIntervalCount,
115
+ intervalStatus,
116
+ ...(maxGapSeconds === undefined ? {} : { maxGapSeconds }),
117
+ };
118
+ }
76
119
  // Fetch candles for one coin and reduce them to a compact indicator bundle plus
77
120
  // the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
78
121
  // candles) yields null indicators so the cycle proceeds with price-only context
@@ -92,6 +135,7 @@ async function fetchCandleContext(client, coinId, trace) {
92
135
  return { indicators: null };
93
136
  // Endpoint shape: { candles: [{ t, o, h, l, c, v, vm }] } ascending (oldest first).
94
137
  const candles = [];
138
+ const times = [];
95
139
  let latestVolume;
96
140
  let latestVolumeMissingVenues;
97
141
  for (const raw of asArr(asObj(cr.data).candles)) {
@@ -104,6 +148,7 @@ async function fetchCandleContext(client, coinId, trace) {
104
148
  continue;
105
149
  const volume = asNum(c.v);
106
150
  candles.push({ open, high, low, close, volume: volume ?? undefined });
151
+ times.push(candleTimestamp(c.t));
107
152
  latestVolume = volume != null && volume >= 0 ? volume : undefined;
108
153
  const coverage = asNum(c.vm);
109
154
  latestVolumeMissingVenues =
@@ -114,8 +159,22 @@ async function fetchCandleContext(client, coinId, trace) {
114
159
  ? coverage
115
160
  : undefined;
116
161
  }
162
+ const latestTime = times.at(-1);
163
+ const recent = candleIntervals(times.slice(-15));
117
164
  return {
118
165
  indicators: computeIndicators(candles),
166
+ indicatorContext: {
167
+ range: INDICATOR_RANGE,
168
+ nominalIntervalSeconds: INDICATOR_INTERVAL_SECONDS,
169
+ ...candleIntervals(times),
170
+ ...(latestTime === undefined
171
+ ? {}
172
+ : { asOf: new Date(latestTime * 1000).toISOString() }),
173
+ recent15: {
174
+ barCount: recent.barCount,
175
+ intervalStatus: recent.intervalStatus,
176
+ },
177
+ },
119
178
  volume24hUsd: latestVolume,
120
179
  volumeMissingVenues: latestVolumeMissingVenues,
121
180
  };
@@ -147,6 +206,8 @@ async function enrichFromCandles(client, entry, coinId, trace) {
147
206
  const cc = await fetchCandleContext(client, coinId, trace);
148
207
  if (cc.indicators)
149
208
  entry.indicators = cc.indicators;
209
+ if (cc.indicatorContext)
210
+ entry.indicatorContext = cc.indicatorContext;
150
211
  if (cc.volume24hUsd != null) {
151
212
  entry.fundamentals = {
152
213
  ...(entry.fundamentals ?? {}),
@@ -147,6 +147,11 @@ opts = {}) {
147
147
  "- 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.)",
148
148
  ]
149
149
  : []),
150
+ ...(hasPm && typeof r.pmMinEntryProbabilityPct === "number"
151
+ ? [
152
+ `- PM ENTRY FLOOR: pm_open on an outcome whose market probability is below ${r.pmMinEntryProbabilityPct} points is REJECTED by the runner (the chosen outcome's own price; fees are not counted). Do not propose cheaper longshots; look for edge on outcomes priced at or above the floor.`,
153
+ ]
154
+ : []),
150
155
  ...(includeForecast
151
156
  ? [
152
157
  "- 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). A forecast you DO give is enforced: if it is not above what the outcome currently costs, the open is rejected, because buying something you price below the market is a losing trade by your own numbers.",
@@ -156,7 +161,8 @@ opts = {}) {
156
161
  ...(spec.capabilities.includes("indicators")
157
162
  ? [
158
163
  "",
159
- "## Signals — each watch entry may carry `indicators` (computed from 5-minute candles)",
164
+ "## Signals — each watch entry may carry `indicators` (nominal five-minute candles)",
165
+ "- `indicatorContext` reports accepted candle counts, source `asOf` and intervalStatus (regular/irregular/unknown); compare its asOf with observation.asOf for age. /market freshness is separate. nominalIntervalSeconds=300 does not prove fresh, continuous candles. Missing timestamps are unknown; stale, future-dated or irregular candles do not establish a current five-minute signal. `recent15` describes only recent spacing; Wilder atr14 also retains earlier history, so recent regularity does not erase older gaps.",
160
166
  "- rsi14: momentum (>70 overbought, <30 oversold); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.",
161
167
  "- boolean reads: aboveEma20, ema20AboveEma50 (uptrend when both true), brokeRecentHigh (breakout), brokeRecentLow (breakdown).",
162
168
  "- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
@@ -47,7 +47,14 @@ const CONFIG_BLOCKS = [
47
47
  const JOURNAL_MAX_LINES = 200;
48
48
  const JOURNAL_MAX_BYTES = 8_000;
49
49
  // Optional prose files (markdown the LLM reads), in assembly order.
50
- const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
50
+ const PROSE_FILES = [
51
+ "character/thesis.md",
52
+ "character/persona.md",
53
+ "character/entries.md",
54
+ "character/exits.md",
55
+ "character/sizing.md",
56
+ "character/research.md",
57
+ ];
51
58
  // Prose files carry an OPTIONAL YAML frontmatter block (type/title/description/
52
59
  // tags) that is authoring metadata, not doctrine — the model gains nothing from
53
60
  // `tags: [agent, persona, mean-reversion]`. It was being merged verbatim into
@@ -425,6 +425,12 @@ async function runCycleCore(deps, capture) {
425
425
  const provenance = buildRunnerProvenance(spec);
426
426
  state.cyclesRun += 1;
427
427
  rollDay(state);
428
+ // Old state did not identify which route failed. Carrying that count into
429
+ // a replacement model can request a hold on its very first error.
430
+ if (state.consecutivePermanentModelErrors &&
431
+ !state.permanentModelErrorRoute) {
432
+ state.consecutivePermanentModelErrors = 0;
433
+ }
428
434
  const runId = state.runId;
429
435
  const decisionId = makeDecisionId(state.cyclesRun);
430
436
  const captureBase = { runId, decisionId, spec, mergedProse, state };
@@ -581,7 +587,7 @@ async function runCycleCore(deps, capture) {
581
587
  ...makeTrace(runId, decisionId, spec, undefined, `Thesis exit: ${why}`),
582
588
  ...observationReceipt,
583
589
  };
584
- const r = await executeAction(client, action, observation, trace, idem, provenance);
590
+ const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
585
591
  exitPlanned.push({
586
592
  action,
587
593
  accepted: true,
@@ -764,13 +770,34 @@ async function runCycleCore(deps, capture) {
764
770
  // for what deserves them: revoked credentials, drawdown, kill-switch,
765
771
  // user action. Transient errors reset the permanent streak.
766
772
  if (isPermanentModelError(res.error)) {
773
+ // The router retains the last non-capacity failure even when a later
774
+ // fallback returns 429. Its effective route still describes the last
775
+ // actual call for metering, not necessarily the error returned here.
776
+ const failureAttempt = route?.attempts
777
+ .slice()
778
+ .reverse()
779
+ .find((attempt) => attempt.outcome === "failed" &&
780
+ attempt.failureClass !== "capacity" &&
781
+ attempt.error === res.error);
782
+ const failureRoute = {
783
+ provider: failureAttempt?.provider ??
784
+ route?.effectiveProvider ??
785
+ providerName,
786
+ model: failureAttempt?.model ??
787
+ route?.effectiveModel ??
788
+ spec.model?.name ??
789
+ "unknown",
790
+ };
791
+ const previousRoute = state.permanentModelErrorRoute;
792
+ const sameRoute = previousRoute?.provider === failureRoute.provider &&
793
+ previousRoute?.model === failureRoute.model;
767
794
  state.consecutivePermanentModelErrors =
768
- (state.consecutivePermanentModelErrors ?? 0) + 1;
795
+ (sameRoute ? (state.consecutivePermanentModelErrors ?? 0) : 0) + 1;
796
+ state.permanentModelErrorRoute = failureRoute;
769
797
  if (state.consecutivePermanentModelErrors >=
770
798
  PERMANENT_MODEL_ERROR_THRESHOLD) {
771
799
  const hold = {
772
- provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
773
- model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
800
+ ...failureRoute,
774
801
  error: res.error.slice(0, 200),
775
802
  };
776
803
  saveState(stateFile, state);
@@ -792,6 +819,7 @@ async function runCycleCore(deps, capture) {
792
819
  }
793
820
  else {
794
821
  state.consecutivePermanentModelErrors = 0;
822
+ delete state.permanentModelErrorRoute;
795
823
  }
796
824
  saveState(stateFile, state);
797
825
  log(`model error: ${res.error}`);
@@ -808,6 +836,10 @@ async function runCycleCore(deps, capture) {
808
836
  ...observationReceipt,
809
837
  };
810
838
  }
839
+ // A returned model response proves availability even if its decision JSON
840
+ // is invalid. Output validation keeps its own failure counter below.
841
+ state.consecutivePermanentModelErrors = 0;
842
+ delete state.permanentModelErrorRoute;
811
843
  const parsed = parseDecision(res.text);
812
844
  if (!parsed.ok) {
813
845
  state.consecutiveModelFailures += 1;
@@ -830,7 +862,6 @@ async function runCycleCore(deps, capture) {
830
862
  };
831
863
  }
832
864
  state.consecutiveModelFailures = 0;
833
- state.consecutivePermanentModelErrors = 0;
834
865
  decision = parsed.decision;
835
866
  }
836
867
  // Reasoning captured for the Arena terminal (keystone transparency): the
@@ -1027,7 +1058,9 @@ async function runCycleCore(deps, capture) {
1027
1058
  }
1028
1059
  }
1029
1060
  }
1030
- const quote = await fetchQuote(client, action, observation, baseTrace);
1061
+ const quote = await fetchQuote(client, action, observation, baseTrace, {
1062
+ pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct,
1063
+ });
1031
1064
  // Early PM skip: the quote's openBlocked preview tells us a pm/open right now
1032
1065
  // would be rejected 422 by the open-time quality gate (distinct from the
1033
1066
  // eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
@@ -1150,7 +1183,7 @@ async function runCycleCore(deps, capture) {
1150
1183
  rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length)),
1151
1184
  ...observationReceipt,
1152
1185
  };
1153
- const r = await executeAction(client, action, observation, trace, idem, provenance);
1186
+ const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
1154
1187
  planned.push({
1155
1188
  action,
1156
1189
  accepted: true,
@@ -109,6 +109,15 @@ export function buildSpec(raw) {
109
109
  entryPredicates: risk.entryPredicates,
110
110
  }
111
111
  : {}),
112
+ // Optional PM entry floor: an absent key stays absent (no floor); an
113
+ // explicit value is carried AS WRITTEN, malformed or not, so hosted
114
+ // validation fails closed instead of a typo silently meaning "no floor"
115
+ // (the same rule entryPredicates follow).
116
+ ...(risk.pmMinEntryProbabilityPct !== undefined
117
+ ? {
118
+ pmMinEntryProbabilityPct: risk.pmMinEntryProbabilityPct,
119
+ }
120
+ : {}),
112
121
  maxLeverage: num(risk.maxLeverage, 1),
113
122
  perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
114
123
  maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
@@ -108,6 +108,13 @@ export function validateSkill(parsed, mode = "self-host") {
108
108
  add("skill_risk_sl", "risk.requireStopLoss must be true or false");
109
109
  if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
110
110
  add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
111
+ // Optional PM entry floor: absent means no floor; present must be a finite
112
+ // number of probability points in [0, 100].
113
+ if (r.pmMinEntryProbabilityPct !== undefined) {
114
+ const f = r.pmMinEntryProbabilityPct;
115
+ if (typeof f !== "number" || !Number.isFinite(f) || f < 0 || f > 100)
116
+ add("skill_risk_pm_entry_floor", "risk.pmMinEntryProbabilityPct must be a number between 0 and 100 (points; omit for no floor)");
117
+ }
111
118
  // Fail-closed on the side restriction: a typo ("shorts_only") must never
112
119
  // silently mean "unrestricted" — that is exactly how a prose-only
113
120
  // constraint failed live on 2026-08-24.
@@ -171,8 +178,10 @@ export function validateSkill(parsed, mode = "self-host") {
171
178
  add("skill_limits_trades", "limits.maxTradesPerDay must be a number >= 0 (0 = unlimited daily trades)");
172
179
  if (!isPosNum(l.maxWritesPerCycle))
173
180
  add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
174
- if (!isPosNum(l.maxDailyLossMusd))
175
- add("skill_limits_loss", "limits.maxDailyLossMusd must be a positive number");
181
+ // 0 = no daily loss cap: the runner only enforces a positive value and
182
+ // the hosted API already accepts 0..50,000, so hosted validation must too.
183
+ if (!isNonNegNum(l.maxDailyLossMusd))
184
+ add("skill_limits_loss", "limits.maxDailyLossMusd must be a number >= 0 (0 = no daily loss cap)");
176
185
  if (!isPosNum(l.maxOpenMarginMusd))
177
186
  add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
178
187
  }
@@ -50,6 +50,7 @@ const ALLOWED_KEYS = {
50
50
  "blocklist",
51
51
  "direction",
52
52
  "entryPredicates",
53
+ "pmMinEntryProbabilityPct",
53
54
  ],
54
55
  sizing: null,
55
56
  capitalSizing: [
@@ -34,6 +34,7 @@ export interface RiskConfig {
34
34
  watchlist: string[];
35
35
  blocklist?: string[];
36
36
  direction?: "long_only" | "short_only";
37
+ pmMinEntryProbabilityPct?: number;
37
38
  }
38
39
  export interface LimitsConfig {
39
40
  maxTradesPerDay: number;
@@ -133,6 +134,21 @@ export interface Freshness {
133
134
  asOf?: string;
134
135
  basis?: string;
135
136
  }
137
+ export interface IndicatorContext {
138
+ range: "1D";
139
+ nominalIntervalSeconds: 300;
140
+ barCount: number;
141
+ timestampedBarCount: number;
142
+ checkedIntervalCount: number;
143
+ irregularIntervalCount: number;
144
+ intervalStatus: "regular" | "irregular" | "unknown";
145
+ maxGapSeconds?: number;
146
+ asOf?: string;
147
+ recent15: {
148
+ barCount: number;
149
+ intervalStatus: "regular" | "irregular" | "unknown";
150
+ };
151
+ }
136
152
  export interface WatchEntry {
137
153
  symbol: string;
138
154
  coinId: string | null;
@@ -144,6 +160,7 @@ export interface WatchEntry {
144
160
  sentimentBullishPct?: number;
145
161
  freshness?: Freshness;
146
162
  indicators?: IndicatorSet;
163
+ indicatorContext?: IndicatorContext;
147
164
  discovered?: boolean;
148
165
  slug?: string;
149
166
  fundamentals?: CoinFundamentals;
@@ -437,6 +454,10 @@ export interface RunState {
437
454
  lastLlmCallAt?: number;
438
455
  lastTriggerFingerprint?: string;
439
456
  consecutivePermanentModelErrors?: number;
457
+ permanentModelErrorRoute?: {
458
+ provider: string;
459
+ model: string;
460
+ };
440
461
  consecutiveAuthFailures?: number;
441
462
  journal?: Array<{
442
463
  at: string;
package/dist/client.d.ts CHANGED
@@ -174,6 +174,7 @@ export declare class CoinRithmClient {
174
174
  stakeMusd: number;
175
175
  forecastProbability?: number;
176
176
  bankrollMusd?: number;
177
+ minEntryProbabilityPct?: number;
177
178
  } & {
178
179
  agentTrace?: AgentTrace;
179
180
  }, apiKey?: string): Promise<ApiResult>;
@@ -221,6 +222,7 @@ export declare class CoinRithmClient {
221
222
  stakeMusd: number;
222
223
  idempotencyKey: string;
223
224
  forecastProbability?: number;
225
+ minEntryProbabilityPct?: number;
224
226
  thesis?: string;
225
227
  provenance?: ProvenanceReport;
226
228
  }>, apiKey?: string): Promise<ApiResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinrithm/mcp-trading",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
4
4
  "mcpName": "io.github.CoinRithm/mcp-trading",
5
5
  "description": "CoinRithm paper-trading toolkit: an MCP server (coinrithm-mcp) AND a self-host agent runner (coinrithm-agent) for spot, futures, and prediction markets with a user-minted API key.",
6
6
  "type": "module",