@coinrithm/mcp-trading 0.7.12 → 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.
@@ -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
@@ -90,8 +133,11 @@ async function fetchCandleContext(client, coinId, trace) {
90
133
  }
91
134
  if (!cr.ok)
92
135
  return { indicators: null };
93
- // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
136
+ // Endpoint shape: { candles: [{ t, o, h, l, c, v, vm }] } ascending (oldest first).
94
137
  const candles = [];
138
+ const times = [];
139
+ let latestVolume;
140
+ let latestVolumeMissingVenues;
95
141
  for (const raw of asArr(asObj(cr.data).candles)) {
96
142
  const c = asObj(raw);
97
143
  const open = asNum(c.o);
@@ -100,12 +146,37 @@ async function fetchCandleContext(client, coinId, trace) {
100
146
  const close = asNum(c.c);
101
147
  if (open == null || high == null || low == null || close == null)
102
148
  continue;
103
- candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
149
+ const volume = asNum(c.v);
150
+ candles.push({ open, high, low, close, volume: volume ?? undefined });
151
+ times.push(candleTimestamp(c.t));
152
+ latestVolume = volume != null && volume >= 0 ? volume : undefined;
153
+ const coverage = asNum(c.vm);
154
+ latestVolumeMissingVenues =
155
+ latestVolume != null &&
156
+ coverage != null &&
157
+ Number.isInteger(coverage) &&
158
+ coverage >= 0
159
+ ? coverage
160
+ : undefined;
104
161
  }
105
- const lastVolume = candles.length > 0 ? candles[candles.length - 1].volume : undefined;
162
+ const latestTime = times.at(-1);
163
+ const recent = candleIntervals(times.slice(-15));
106
164
  return {
107
165
  indicators: computeIndicators(candles),
108
- volume24hUsd: typeof lastVolume === "number" && lastVolume > 0 ? lastVolume : undefined,
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
+ },
178
+ volume24hUsd: latestVolume,
179
+ volumeMissingVenues: latestVolumeMissingVenues,
109
180
  };
110
181
  }
111
182
  // The fundamentals leg of a watch entry, read from the /market context the
@@ -135,12 +206,20 @@ async function enrichFromCandles(client, entry, coinId, trace) {
135
206
  const cc = await fetchCandleContext(client, coinId, trace);
136
207
  if (cc.indicators)
137
208
  entry.indicators = cc.indicators;
209
+ if (cc.indicatorContext)
210
+ entry.indicatorContext = cc.indicatorContext;
138
211
  if (cc.volume24hUsd != null) {
139
212
  entry.fundamentals = {
140
213
  ...(entry.fundamentals ?? {}),
141
214
  volume24hUsd: cc.volume24hUsd,
142
215
  };
143
216
  }
217
+ if (cc.volumeMissingVenues != null) {
218
+ entry.fundamentals = {
219
+ ...(entry.fundamentals ?? {}),
220
+ volumeMissingVenues: cc.volumeMissingVenues,
221
+ };
222
+ }
144
223
  }
145
224
  const HEADLINES_PER_COIN = 3;
146
225
  const HEADLINE_TITLE_CHARS = 110;
@@ -316,6 +395,14 @@ export async function observe(client, spec, state, trace) {
316
395
  // and could not tell a winner from a small loser before a manual close.
317
396
  // Tolerant fallbacks keep older/mocked shapes working.
318
397
  const coin = asObj(p.coin);
398
+ const fundingPaidMusd = Object.prototype.hasOwnProperty.call(p, "fundingPaidMusd")
399
+ ? (asNum(p.fundingPaidMusd) ??
400
+ (p.fundingPaidMusd === null ? null : undefined))
401
+ : undefined;
402
+ const fundingAppliedThrough = Object.prototype.hasOwnProperty.call(p, "fundingAppliedThrough")
403
+ ? (asStr(p.fundingAppliedThrough) ??
404
+ (p.fundingAppliedThrough === null ? null : undefined))
405
+ : undefined;
319
406
  return {
320
407
  venue: "futures",
321
408
  id: Number(asNum(p.id) ?? p.id),
@@ -327,6 +414,10 @@ export async function observe(client, spec, state, trace) {
327
414
  leverage: asNum(p.leverage),
328
415
  marginMusd: asNum(p.marginMusd),
329
416
  unrealizedPnlMusd: asNum(p.unrealizedPnlMusd),
417
+ ...(fundingPaidMusd !== undefined ? { fundingPaidMusd } : {}),
418
+ ...(fundingAppliedThrough !== undefined
419
+ ? { fundingAppliedThrough }
420
+ : {}),
330
421
  entryPrice: asNum(p.entryPrice),
331
422
  markPrice: asNum(p.markPrice),
332
423
  liquidationPrice: asNum(p.liquidationPrice),
@@ -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.",
@@ -338,6 +344,10 @@ export function buildUserPrompt(obs, journal, opts = {}) {
338
344
  if (brokenTheses.length > 0) {
339
345
  lines.push("", "## Positions whose thesis is INVALIDATED this cycle", ...brokenTheses.map((b) => `- ${b}`));
340
346
  }
347
+ if (obs.openPositions.some((p) => p.fundingPaidMusd !== undefined ||
348
+ p.fundingAppliedThrough !== undefined)) {
349
+ lines.push("Futures funding shown in the position is already reflected in margin and balances; do not deduct it again when judging available capital or PnL.");
350
+ }
341
351
  // Settlement-feedback loop: surface the agent's recently-RESOLVED PM bets so the
342
352
  // model can reflect and adapt. Reflective context only — never a new action.
343
353
  if (hasPm)
@@ -52,6 +52,10 @@ export interface ProviderEnv {
52
52
  GEMINI_API_KEY?: string;
53
53
  MODEL_API_KEY?: string;
54
54
  }
55
+ /** Keep direct and routed cycle evidence consistent about NVIDIA backpressure. */
56
+ export declare function classifyProviderFailure(result: Extract<DecideResult, {
57
+ ok: false;
58
+ }>): "capacity" | "permanent" | "transient";
55
59
  export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
56
60
  export declare function providerForRoute(route: {
57
61
  provider: ProviderName;
@@ -29,6 +29,16 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai
29
29
  // MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
30
30
  const DEFAULT_TIMEOUT_MS = 300_000;
31
31
  const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
32
+ /** Keep direct and routed cycle evidence consistent about NVIDIA backpressure. */
33
+ export function classifyProviderFailure(result) {
34
+ if (result.status === 429 ||
35
+ (result.status === 503 &&
36
+ /resourceexhausted|worker local total request limit/i.test(result.error ?? "")))
37
+ return "capacity";
38
+ if (result.status === 404 || result.status === 410)
39
+ return "permanent";
40
+ return "transient";
41
+ }
32
42
  // Per-route request quirks (reasoning toggles, token param, temperature) live
33
43
  // in the capability table — providerCapabilities.ts is the single source; this
34
44
  // module only assembles and sends.
@@ -332,15 +342,11 @@ class SameModelRetryProvider {
332
342
  error: res.error,
333
343
  status: res.status,
334
344
  retryAfterMs: res.retryAfterMs,
335
- ...(res.status !== undefined
336
- ? {
337
- failureClass: res.status === 429
338
- ? "capacity"
339
- : res.status >= 500
340
- ? "transient"
341
- : "permanent",
342
- }
343
- : {}),
345
+ failureClass: classifyProviderFailure(res) === "capacity"
346
+ ? "capacity"
347
+ : res.status !== undefined && res.status < 500
348
+ ? "permanent"
349
+ : "transient",
344
350
  }
345
351
  : {}),
346
352
  });
@@ -350,7 +356,7 @@ class SameModelRetryProvider {
350
356
  // or a certificate that returned text passed the trading-decision parser.
351
357
  ...result,
352
358
  route: {
353
- policyVersion: "coinrithm.configured-same-model-retry.v1",
359
+ policyVersion: "coinrithm.configured-same-model-retry.v2",
354
360
  profile: "configured",
355
361
  effectiveProvider: this.provider,
356
362
  effectiveModel: this.model,
@@ -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
@@ -2,6 +2,7 @@
2
2
  // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
3
  // idempotency keys + agentTrace and exports run evidence. The client + provider
4
4
  // are injected so the loop is fully unit-testable with no network/model calls.
5
+ import { classifyProviderFailure } from "./providers.js";
5
6
  import { COINRITHM_API } from "./version.js";
6
7
  import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
7
8
  import { usesCapitalSizing, prepareCapitalAction, validateCapitalAction, capitalCashCost, } from "./capitalSizing.js";
@@ -424,6 +425,12 @@ async function runCycleCore(deps, capture) {
424
425
  const provenance = buildRunnerProvenance(spec);
425
426
  state.cyclesRun += 1;
426
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
+ }
427
434
  const runId = state.runId;
428
435
  const decisionId = makeDecisionId(state.cyclesRun);
429
436
  const captureBase = { runId, decisionId, spec, mergedProse, state };
@@ -580,7 +587,7 @@ async function runCycleCore(deps, capture) {
580
587
  ...makeTrace(runId, decisionId, spec, undefined, `Thesis exit: ${why}`),
581
588
  ...observationReceipt,
582
589
  };
583
- 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 });
584
591
  exitPlanned.push({
585
592
  action,
586
593
  accepted: true,
@@ -727,7 +734,7 @@ async function runCycleCore(deps, capture) {
727
734
  // only capacity failures. Treat that exactly like a local capacity defer:
728
735
  // no action, no fallback invented here, and no model-failure streak that
729
736
  // could stop an otherwise healthy agent after repeated quota pressure.
730
- const capacityOnlyFailure = (!route && res.status === 429) ||
737
+ const capacityOnlyFailure = (!route && classifyProviderFailure(res) === "capacity") ||
731
738
  (!!route?.attempts?.length &&
732
739
  route.attempts.every((attempt) => attempt.failureClass === "capacity"));
733
740
  if (res.deferred || !actualCallMade || capacityOnlyFailure) {
@@ -763,13 +770,34 @@ async function runCycleCore(deps, capture) {
763
770
  // for what deserves them: revoked credentials, drawdown, kill-switch,
764
771
  // user action. Transient errors reset the permanent streak.
765
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;
766
794
  state.consecutivePermanentModelErrors =
767
- (state.consecutivePermanentModelErrors ?? 0) + 1;
795
+ (sameRoute ? (state.consecutivePermanentModelErrors ?? 0) : 0) + 1;
796
+ state.permanentModelErrorRoute = failureRoute;
768
797
  if (state.consecutivePermanentModelErrors >=
769
798
  PERMANENT_MODEL_ERROR_THRESHOLD) {
770
799
  const hold = {
771
- provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
772
- model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
800
+ ...failureRoute,
773
801
  error: res.error.slice(0, 200),
774
802
  };
775
803
  saveState(stateFile, state);
@@ -791,6 +819,7 @@ async function runCycleCore(deps, capture) {
791
819
  }
792
820
  else {
793
821
  state.consecutivePermanentModelErrors = 0;
822
+ delete state.permanentModelErrorRoute;
794
823
  }
795
824
  saveState(stateFile, state);
796
825
  log(`model error: ${res.error}`);
@@ -807,6 +836,10 @@ async function runCycleCore(deps, capture) {
807
836
  ...observationReceipt,
808
837
  };
809
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;
810
843
  const parsed = parseDecision(res.text);
811
844
  if (!parsed.ok) {
812
845
  state.consecutiveModelFailures += 1;
@@ -829,7 +862,6 @@ async function runCycleCore(deps, capture) {
829
862
  };
830
863
  }
831
864
  state.consecutiveModelFailures = 0;
832
- state.consecutivePermanentModelErrors = 0;
833
865
  decision = parsed.decision;
834
866
  }
835
867
  // Reasoning captured for the Arena terminal (keystone transparency): the
@@ -1026,7 +1058,9 @@ async function runCycleCore(deps, capture) {
1026
1058
  }
1027
1059
  }
1028
1060
  }
1029
- const quote = await fetchQuote(client, action, observation, baseTrace);
1061
+ const quote = await fetchQuote(client, action, observation, baseTrace, {
1062
+ pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct,
1063
+ });
1030
1064
  // Early PM skip: the quote's openBlocked preview tells us a pm/open right now
1031
1065
  // would be rejected 422 by the open-time quality gate (distinct from the
1032
1066
  // eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
@@ -1149,7 +1183,7 @@ async function runCycleCore(deps, capture) {
1149
1183
  rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length)),
1150
1184
  ...observationReceipt,
1151
1185
  };
1152
- 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 });
1153
1187
  planned.push({
1154
1188
  action,
1155
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: [
@@ -50,37 +50,37 @@ const PRESETS = {
50
50
  modelFail: 5,
51
51
  },
52
52
  };
53
- const THESIS_BODY = `# Momentum Futures — strategy
54
-
55
- You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
- Everything here is simulated; it is not financial advice and never touches real
57
- money. Edit this prose freely (any language) — it is your agent's borders.
58
-
59
- ## Each cycle
60
-
61
- 1. Ground yourself: read your portfolio and open positions first. Never assume
62
- balances or what is already open.
63
- 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
- agree (both up, or both down) and is not already an open position.
65
- 3. Pick the strongest candidate and commit when the read is clear — even a
66
- moderate-confidence one — sized small with a stop. Skip only when the signals
67
- contradict or the data is stale; a quiet tape where your edge is still real
68
- is an act, not a skip.
69
- 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
- the quote is not eligible, relay the reason and stop.
71
- 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
- open. Place the take-profit a touch wider than the stop.
73
- 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
- fired while you were not looking, and react to what actually happened.
75
-
76
- The hard caps (leverage, margin, watchlist) live in the config blocks above and
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick the strongest candidate and commit when the read is clear — even a
66
+ moderate-confidence one — sized small with a stop. Skip only when the signals
67
+ contradict or the data is stale; a quiet tape where your edge is still real
68
+ is an act, not a skip.
69
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
+ the quote is not eligible, relay the reason and stop.
71
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
+ open. Place the take-profit a touch wider than the stop.
73
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
+ fired while you were not looking, and react to what actually happened.
75
+
76
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
77
77
  are enforced by the runner — change them there, not in this prose.`;
78
- const PERSONA_STUB = `# Persona
79
-
80
- Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
- — sized small and protected with a stop, and skips only when the read is
82
- contradictory or the data is stale. States its reasoning plainly in its own
83
- voice, and never frames paper results as real-money advice.
78
+ const PERSONA_STUB = `# Persona
79
+
80
+ Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
+ — sized small and protected with a stop, and skips only when the read is
82
+ contradictory or the data is stale. States its reasoning plainly in its own
83
+ voice, and never frames paper results as real-money advice.
84
84
  `;
85
85
  export function buildAgentObject(name, preset) {
86
86
  const p = PRESETS[preset];
@@ -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;
@@ -153,6 +170,7 @@ export interface CoinFundamentals {
153
170
  marketCapRank?: number;
154
171
  marketCapUsd?: number;
155
172
  volume24hUsd?: number;
173
+ volumeMissingVenues?: number;
156
174
  headlines?: Array<{
157
175
  title: string;
158
176
  at?: string;
@@ -171,6 +189,8 @@ export interface OpenPosition {
171
189
  leverage?: number;
172
190
  marginMusd?: number;
173
191
  unrealizedPnlMusd?: number;
192
+ fundingPaidMusd?: number | null;
193
+ fundingAppliedThrough?: string | null;
174
194
  entryPrice?: number;
175
195
  markPrice?: number;
176
196
  liquidationPrice?: number;
@@ -397,10 +417,21 @@ export interface QuoteEvidence {
397
417
  futuresFeeBps?: number;
398
418
  estimatedEntryFeeMusd?: number;
399
419
  cashRequiredMusd?: number;
420
+ funding?: FuturesFundingQuoteEvidence | null;
400
421
  freshness?: Freshness;
401
422
  openBlocked?: boolean;
402
423
  openBlockReasons?: unknown;
403
424
  }
425
+ export interface FuturesFundingQuoteEvidence {
426
+ venue?: string;
427
+ symbol?: string;
428
+ rate?: number;
429
+ intervalHours?: number;
430
+ nextFundingTime?: string;
431
+ asOf?: string;
432
+ estimatedPerIntervalMusd?: number | null;
433
+ annualizedRate?: number;
434
+ }
404
435
  export interface RunState {
405
436
  runId: string;
406
437
  cyclesRun: number;
@@ -423,6 +454,10 @@ export interface RunState {
423
454
  lastLlmCallAt?: number;
424
455
  lastTriggerFingerprint?: string;
425
456
  consecutivePermanentModelErrors?: number;
457
+ permanentModelErrorRoute?: {
458
+ provider: string;
459
+ model: string;
460
+ };
426
461
  consecutiveAuthFailures?: number;
427
462
  journal?: Array<{
428
463
  at: string;
package/dist/client.d.ts CHANGED
@@ -64,7 +64,11 @@ export declare class CoinRithmClient {
64
64
  getPublicPmEvent(source: string, slug: string, query?: {
65
65
  fiat?: string;
66
66
  }): Promise<ApiResult>;
67
+ /** Best-effort anonymous interest marker for an explicitly viewed open event. */
68
+ markPublicPmEventViewed(source: string, slug: string): Promise<ApiResult>;
67
69
  getPublicPmWhales(): Promise<ApiResult>;
70
+ getPublicPmWhaleWallets(window?: "7d" | "30d", source?: "polymarket" | "limitless" | "myriad"): Promise<ApiResult>;
71
+ getPublicPmWhaleWallet(source: string, wallet: string): Promise<ApiResult>;
68
72
  getPublicPmMatches(query?: {
69
73
  limit?: number;
70
74
  offset?: number;
@@ -168,6 +172,9 @@ export declare class CoinRithmClient {
168
172
  outcomeExternalMarketId: string;
169
173
  side?: "yes" | "no";
170
174
  stakeMusd: number;
175
+ forecastProbability?: number;
176
+ bankrollMusd?: number;
177
+ minEntryProbabilityPct?: number;
171
178
  } & {
172
179
  agentTrace?: AgentTrace;
173
180
  }, apiKey?: string): Promise<ApiResult>;
@@ -215,6 +222,8 @@ export declare class CoinRithmClient {
215
222
  stakeMusd: number;
216
223
  idempotencyKey: string;
217
224
  forecastProbability?: number;
225
+ minEntryProbabilityPct?: number;
226
+ thesis?: string;
218
227
  provenance?: ProvenanceReport;
219
228
  }>, apiKey?: string): Promise<ApiResult>;
220
229
  reportPmOpportunity(body: TraceableBody<{