@coinrithm/mcp-trading 0.7.8 → 0.7.10

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 (40) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +93 -69
  3. package/dist/agent/capitalSizing.js +17 -4
  4. package/dist/agent/cli.js +11 -1
  5. package/dist/agent/client.d.ts +8 -1
  6. package/dist/agent/client.js +98 -39
  7. package/dist/agent/configurationWarnings.d.ts +1 -0
  8. package/dist/agent/configurationWarnings.js +21 -0
  9. package/dist/agent/decisionReceipt.d.ts +4 -1
  10. package/dist/agent/decisionReceipt.js +27 -3
  11. package/dist/agent/decisionValidator.js +4 -0
  12. package/dist/agent/engine.d.ts +1 -1
  13. package/dist/agent/engine.js +1 -1
  14. package/dist/agent/entryPredicates.d.ts +10 -0
  15. package/dist/agent/entryPredicates.js +67 -0
  16. package/dist/agent/gate.js +14 -11
  17. package/dist/agent/opportunityReporter.d.ts +16 -0
  18. package/dist/agent/opportunityReporter.js +38 -0
  19. package/dist/agent/prompt.js +5 -0
  20. package/dist/agent/providers.d.ts +2 -1
  21. package/dist/agent/providers.js +109 -19
  22. package/dist/agent/reconcileObservation.d.ts +2 -0
  23. package/dist/agent/reconcileObservation.js +33 -0
  24. package/dist/agent/runner.js +32 -74
  25. package/dist/agent/skill.js +6 -0
  26. package/dist/agent/skillValidator.js +3 -0
  27. package/dist/agent/state.js +17 -2
  28. package/dist/agent/strictLint.js +1 -0
  29. package/dist/agent/templates.js +34 -30
  30. package/dist/agent/types.d.ts +1 -0
  31. package/dist/agent/util.js +3 -3
  32. package/dist/client.js +3 -2
  33. package/dist/http.d.ts +7 -1
  34. package/dist/http.js +36 -16
  35. package/dist/httpCompletion.d.ts +33 -0
  36. package/dist/httpCompletion.js +220 -0
  37. package/dist/retryAfter.d.ts +1 -0
  38. package/dist/retryAfter.js +16 -0
  39. package/dist/tools.js +19 -1
  40. package/package.json +13 -2
@@ -2,6 +2,7 @@
2
2
  // never from an agent file. One call returns one chunk of text that must be a
3
3
  // single structured-JSON decision (parsed in decision.ts). No free-form tool
4
4
  // execution — the model only proposes; the runner disposes.
5
+ import { retryAfterSeconds } from "../retryAfter.js";
5
6
  import { chatShapeFor, buildChatBody, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
6
7
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
7
8
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
@@ -27,6 +28,7 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai
27
28
  // (the recurring Leo/70B timeout). A real hang still aborts -> retried next cadence.
28
29
  // MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
29
30
  const DEFAULT_TIMEOUT_MS = 300_000;
31
+ const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
30
32
  // Per-route request quirks (reasoning toggles, token param, temperature) live
31
33
  // in the capability table — providerCapabilities.ts is the single source; this
32
34
  // module only assembles and sends.
@@ -58,22 +60,24 @@ function callError(err, timeoutMs) {
58
60
  }
59
61
  return err instanceof Error ? err.message : String(err);
60
62
  }
63
+ // Error text reaches retained cycle evidence. Redact before truncating so a
64
+ // credential crossing the size boundary cannot leave a partial key behind.
65
+ function safeProviderError(text, apiKey) {
66
+ return (apiKey ? text.split(apiKey).join("[redacted]") : text)
67
+ .replace(/Bearer\s+[^\s"'<>\\]+/gi, "Bearer [redacted]")
68
+ .replace(/(?:nvapi-|crk_live_|sk-|sk_live_|ghp_|AIza)[A-Za-z0-9_-]+/g, "[redacted]")
69
+ .replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted]")
70
+ .replace(/((?:api[_-]?key|access[_-]?token|password|secret)["']?\s*[:=]\s*["']?)[^\s"',}&<>]+/gi, "$1[redacted]")
71
+ .slice(0, 2000);
72
+ }
61
73
  // Parse a Retry-After header (delta-seconds or HTTP-date) into ms, capped at
62
74
  // one hour — a provider asking for more is treated as "an hour, then re-probe".
63
75
  const RETRY_AFTER_CAP_MS = 3_600_000;
64
76
  function retryAfterMs(res) {
65
- const raw = res.headers.get("retry-after");
66
- if (!raw)
67
- return undefined;
68
- const secs = Number(raw);
69
- if (Number.isFinite(secs) && secs >= 0) {
70
- return Math.min(Math.round(secs * 1000), RETRY_AFTER_CAP_MS);
71
- }
72
- const at = Date.parse(raw);
73
- if (!Number.isFinite(at))
74
- return undefined;
75
- const ms = at - Date.now();
76
- return ms > 0 ? Math.min(ms, RETRY_AFTER_CAP_MS) : 0;
77
+ const seconds = retryAfterSeconds(res.headers.get("retry-after"));
78
+ return seconds === undefined
79
+ ? undefined
80
+ : Math.min(Math.round(seconds * 1000), RETRY_AFTER_CAP_MS);
77
81
  }
78
82
  function envKey(provider, env) {
79
83
  switch (provider) {
@@ -165,7 +169,7 @@ class AnthropicProvider {
165
169
  ok: false,
166
170
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
167
171
  // unbounded provider error page must not bloat the ledger row.
168
- error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
172
+ error: `anthropic HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
169
173
  status: res.status,
170
174
  retryAfterMs: retryAfterMs(res),
171
175
  };
@@ -186,9 +190,9 @@ class AnthropicProvider {
186
190
  catch (err) {
187
191
  return {
188
192
  ok: false,
189
- error: failureResponse
193
+ error: safeProviderError(failureResponse
190
194
  ? `anthropic HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
191
- : callError(err, timeoutMs),
195
+ : callError(err, timeoutMs), this.apiKey),
192
196
  ...(failureResponse
193
197
  ? {
194
198
  status: failureResponse.status,
@@ -240,9 +244,12 @@ class OpenAiCompatProvider {
240
244
  ok: false,
241
245
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
242
246
  // unbounded provider error page must not bloat the ledger row.
243
- error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
247
+ error: `provider HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
244
248
  status: res.status,
245
249
  retryAfterMs: retryAfterMs(res),
250
+ ...(RETRYABLE_SERVER_STATUSES.has(res.status)
251
+ ? { retryableHttpFailure: true }
252
+ : {}),
246
253
  };
247
254
  }
248
255
  const json = (await res.json());
@@ -263,9 +270,9 @@ class OpenAiCompatProvider {
263
270
  catch (err) {
264
271
  return {
265
272
  ok: false,
266
- error: failureResponse
273
+ error: safeProviderError(failureResponse
267
274
  ? `provider HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
268
- : callError(err, timeoutMs),
275
+ : callError(err, timeoutMs), this.apiKey),
269
276
  ...(failureResponse
270
277
  ? {
271
278
  status: failureResponse.status,
@@ -276,6 +283,86 @@ class OpenAiCompatProvider {
276
283
  }
277
284
  }
278
285
  }
286
+ // A direct NVIDIA 5xx previously lost the whole cycle until the next cadence.
287
+ // Retry only an explicit server refusal, once, on the identical configured
288
+ // route. The shared router owns its own attempt/capacity budget and MUST NOT
289
+ // receive this wrapper. No auth/404/429, network, timeout or decision repair
290
+ // retries; no model substitution or request-parameter changes.
291
+ class SameModelRetryProvider {
292
+ delegate;
293
+ provider;
294
+ model;
295
+ label;
296
+ constructor(delegate, provider, model) {
297
+ this.delegate = delegate;
298
+ this.provider = provider;
299
+ this.model = model;
300
+ this.label = delegate.label;
301
+ }
302
+ async decide(input) {
303
+ const started = Date.now();
304
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
305
+ const first = await this.delegate.decide(input);
306
+ const firstFinished = Date.now();
307
+ if (first.ok || !first.retryableHttpFailure) {
308
+ return first;
309
+ }
310
+ const delayMs = Math.max(1000, first.retryAfterMs ?? 0);
311
+ // Honor the provider's cooldown; a long one waits for the next cycle. Both
312
+ // attempts and this backoff share the ORIGINAL deadline, never two 5m caps.
313
+ if (delayMs > 5000 || delayMs >= timeoutMs - (firstFinished - started)) {
314
+ return first;
315
+ }
316
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
317
+ const retryStarted = Date.now();
318
+ const remainingMs = timeoutMs - (retryStarted - started);
319
+ if (remainingMs <= 0)
320
+ return first;
321
+ const result = await this.delegate.decide({
322
+ ...input,
323
+ timeoutMs: remainingMs,
324
+ });
325
+ const attempt = (res, latencyMs) => ({
326
+ provider: this.provider,
327
+ model: this.model,
328
+ outcome: res.ok ? "success" : "failed",
329
+ latencyMs,
330
+ ...(!res.ok
331
+ ? {
332
+ error: res.error,
333
+ status: res.status,
334
+ 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
+ : {}),
344
+ }
345
+ : {}),
346
+ });
347
+ return {
348
+ // Usage, when present, describes only the final response. The preceding
349
+ // 5xx did not report usage; attempt evidence is not total billed tokens
350
+ // or a certificate that returned text passed the trading-decision parser.
351
+ ...result,
352
+ route: {
353
+ policyVersion: "coinrithm.configured-same-model-retry.v1",
354
+ profile: "configured",
355
+ effectiveProvider: this.provider,
356
+ effectiveModel: this.model,
357
+ reason: "configured_direct",
358
+ attempts: [
359
+ attempt(first, firstFinished - started),
360
+ attempt(result, Date.now() - retryStarted),
361
+ ],
362
+ },
363
+ };
364
+ }
365
+ }
279
366
  // Build the provider from the spec's model block + env key. Throws a clear
280
367
  // error if no model is configured (self-host requires one) or the env key is
281
368
  // missing. fetch is injectable for tests.
@@ -308,7 +395,10 @@ export function selectProvider(spec, env, fetchFn = fetch) {
308
395
  if (!resolvedBase) {
309
396
  throw new Error("openai-compatible provider needs model.baseUrl");
310
397
  }
311
- return new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
398
+ const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
399
+ return provider === "nvidia"
400
+ ? new SameModelRetryProvider(direct, provider, name)
401
+ : direct;
312
402
  }
313
403
  // Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
314
404
  // decision probe's entry point. Same classes as selectProvider, so a probe
@@ -0,0 +1,2 @@
1
+ import type { RunState, Observation } from "./types.js";
2
+ export declare function reconcileObservation(state: RunState, observation: Observation): void;
@@ -0,0 +1,33 @@
1
+ import { accrueRealized } from "./state.js";
2
+ import { asObj, asNum, asStr } from "./extract.js";
3
+ export function reconcileObservation(state, observation) {
4
+ accrueRealized(state, observation.newClosedTrades);
5
+ state.cursor = observation.syncCursor;
6
+ for (const t of observation.newClosedTrades) {
7
+ state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
8
+ }
9
+ state.seen = state.seen.slice(-500);
10
+ // Slice-3 reflection: journal closed-trade OUTCOMES (not just opens) so the agent
11
+ // remembers how its theses RESOLVED — a stop-out it should not revenge-trade, a
12
+ // winner its style works on. Defensive field reads (the /trades shape varies);
13
+ // a partial entry is harmless, a missing one is skipped.
14
+ for (const t of observation.newClosedTrades.slice(-5)) {
15
+ const o = asObj(t);
16
+ const sym = asStr(o.symbol) ?? asStr(o.coinSymbol) ?? asStr(o.coinId);
17
+ const pnl = asNum(o.realizedPnlMusd) ??
18
+ asNum(o.pnlMusd) ??
19
+ asNum(o.realizedPnl) ??
20
+ asNum(o.pnl);
21
+ const side = asStr(o.side);
22
+ if (sym || pnl != null) {
23
+ const did = `closed ${side ?? ""} ${sym ?? "position"}`.trim() +
24
+ (pnl != null
25
+ ? `: ${pnl >= 0 ? "+" : ""}${Math.round(pnl)}mUSD ${pnl >= 0 ? "WIN" : "LOSS"}`
26
+ : "");
27
+ state.journal = [
28
+ ...(state.journal ?? []),
29
+ { at: observation.asOf, did },
30
+ ].slice(-12);
31
+ }
32
+ }
33
+ }
@@ -8,6 +8,8 @@ import { usesCapitalSizing, prepareCapitalAction, validateCapitalAction, capital
8
8
  import { decideMechanical } from "./mechanical.js";
9
9
  import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
10
10
  import { baseSymbol, scanSetups } from "./setups.js";
11
+ import { reconcileObservation } from "./reconcileObservation.js";
12
+ import { createOpportunityReporter } from "./opportunityReporter.js";
11
13
  import { observe } from "./observe.js";
12
14
  import { buildDailyRiskBudget, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
13
15
  import { parseDecision } from "./decision.js";
@@ -15,7 +17,7 @@ import { validateAction, isRiskIncreasingAction, } from "./decisionValidator.js"
15
17
  import { resolvePmRef } from "./resolvePm.js";
16
18
  import { fetchQuote, executeAction } from "./act.js";
17
19
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
18
- import { rollDay, checkKillSwitch, accrueRealized, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
20
+ import { rollDay, checkKillSwitch, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
19
21
  import { asObj, asNum, asStr } from "./extract.js";
20
22
  import { parseCadenceMs, sleep } from "./util.js";
21
23
  import { buildObservationReceipt } from "./observationReceipt.js";
@@ -441,43 +443,22 @@ async function runCycleCore(deps, capture) {
441
443
  live,
442
444
  };
443
445
  }
446
+ // Establish durable run identity before the first possible write. A process
447
+ // killed before its first response must not restart with a new idempotency key.
448
+ if (live)
449
+ saveState(stateFile, state);
444
450
  const baseTrace = makeTrace(runId, decisionId, spec);
445
- // Opportunity capture (kills evaluation selection bias). Post at most ONE
446
- // non-opened opportunity per cycle, LIVE only (dry-run never writes), best-effort
447
- // — a failed post never affects the cycle result. The latch is set BEFORE the
448
- // await so a failure never retries within the cycle (respects the write budget);
449
- // the cohort/universe field carries the breadth, so we never post per-market.
450
- const captureOpportunity = agentOpportunityCaptureEnabled();
451
- let opportunityPosted = false;
452
- let postedOpportunity;
453
- const postOpportunity = async (o) => {
454
- if (!captureOpportunity || !live || opportunityPosted)
455
- return;
456
- opportunityPosted = true;
457
- postedOpportunity = o;
458
- try {
459
- await client.reportPmOpportunity({
460
- kind: o.kind,
461
- source: o.source,
462
- slug: o.slug,
463
- outcomeExternalMarketId: o.outcomeExternalMarketId,
464
- forecastProbability: o.forecastProbability,
465
- marketProbability: o.marketProbability,
466
- reasonCode: o.reasonCode,
467
- cohort: {
468
- universeSize: o.universeSize,
469
- horizon: spec.objective?.horizon,
470
- },
471
- decisionId,
472
- runId,
473
- provenance,
474
- }, baseTrace);
475
- log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
476
- }
477
- catch (err) {
478
- log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
479
- }
480
- };
451
+ const opportunities = createOpportunityReporter({
452
+ client,
453
+ spec,
454
+ live,
455
+ enabled: agentOpportunityCaptureEnabled(),
456
+ runId,
457
+ decisionId,
458
+ provenance,
459
+ baseTrace,
460
+ log,
461
+ });
481
462
  // OBSERVE
482
463
  const obs = await observe(client, spec, state, baseTrace);
483
464
  const observation = obs.observation;
@@ -496,35 +477,7 @@ async function runCycleCore(deps, capture) {
496
477
  // Reads build the observation, so its hash cannot exist before they finish.
497
478
  // From this point every durable write carries the exact decision-input receipt.
498
479
  Object.assign(baseTrace, observationReceipt);
499
- accrueRealized(state, observation.newClosedTrades);
500
- state.cursor = observation.syncCursor;
501
- for (const t of observation.newClosedTrades) {
502
- state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
503
- }
504
- state.seen = state.seen.slice(-500);
505
- // Slice-3 reflection: journal closed-trade OUTCOMES (not just opens) so the agent
506
- // remembers how its theses RESOLVED — a stop-out it should not revenge-trade, a
507
- // winner its style works on. Defensive field reads (the /trades shape varies);
508
- // a partial entry is harmless, a missing one is skipped.
509
- for (const t of observation.newClosedTrades.slice(-5)) {
510
- const o = asObj(t);
511
- const sym = asStr(o.symbol) ?? asStr(o.coinSymbol) ?? asStr(o.coinId);
512
- const pnl = asNum(o.realizedPnlMusd) ??
513
- asNum(o.pnlMusd) ??
514
- asNum(o.realizedPnl) ??
515
- asNum(o.pnl);
516
- const side = asStr(o.side);
517
- if (sym || pnl != null) {
518
- const did = `closed ${side ?? ""} ${sym ?? "position"}`.trim() +
519
- (pnl != null
520
- ? `: ${pnl >= 0 ? "+" : ""}${Math.round(pnl)}mUSD ${pnl >= 0 ? "WIN" : "LOSS"}`
521
- : "");
522
- state.journal = [
523
- ...(state.journal ?? []),
524
- { at: observation.asOf, did },
525
- ].slice(-12);
526
- }
527
- }
480
+ reconcileObservation(state, observation);
528
481
  capture({
529
482
  ...captureBase,
530
483
  phase: "observed",
@@ -897,7 +850,7 @@ async function runCycleCore(deps, capture) {
897
850
  // cohort via universeSize.
898
851
  const skipOpp = buildSkipOpportunity(decision, observation.pmMarkets, forecastEnabled);
899
852
  if (skipOpp)
900
- await postOpportunity(skipOpp);
853
+ await opportunities.post(skipOpp);
901
854
  saveState(stateFile, state);
902
855
  log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
903
856
  return {
@@ -912,7 +865,7 @@ async function runCycleCore(deps, capture) {
912
865
  decisionType: "skip",
913
866
  writeAttempted: decision.actions.length,
914
867
  writeAccepted: 0,
915
- ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
868
+ ...(opportunities.posted ? { opportunity: opportunities.posted } : {}),
916
869
  ...observationReceipt,
917
870
  };
918
871
  }
@@ -1265,7 +1218,7 @@ async function runCycleCore(deps, capture) {
1265
1218
  // (once-per-cycle; carries the universe breadth in the cohort field).
1266
1219
  if (action.type === "pm_open" && isQuoteExpiredResult(r.status, r.data)) {
1267
1220
  const pm = action;
1268
- await postOpportunity({
1221
+ await opportunities.post({
1269
1222
  kind: "quote_expired",
1270
1223
  source: pm.source,
1271
1224
  slug: pm.slug,
@@ -1291,13 +1244,18 @@ async function runCycleCore(deps, capture) {
1291
1244
  state.consecutiveExecFailures =
1292
1245
  anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
1293
1246
  state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
1294
- // Slice-3 memory: journal the accepted move(s) + the thesis behind them so next
1295
- // cycle has continuity (manage with memory of WHY; don't re-open what we just did).
1247
+ // Memory describes completed moves, not policy acceptance. A backend rejection,
1248
+ // lost response, or dry-run plan must not become "opened" / "trailed stop" in
1249
+ // the next model prompt. Attempts remain in planned[] and the audit ledger.
1296
1250
  const moves = planned
1297
- .filter((p) => p.accepted)
1251
+ .filter((p) => p.accepted && p.executed === true)
1298
1252
  .map((p) => summarizeAction(p.action));
1299
1253
  if (moves.length > 0) {
1300
- const did = `${moves.join("; ")}${rationale ? ` — ${rationale.slice(0, 90)}` : ""}`;
1254
+ // A cycle-wide rationale may describe a failed/rejected sibling action.
1255
+ // Keep it only when every proposed action executed; per-action theses are
1256
+ // already included by summarizeAction for confirmed opens.
1257
+ const confirmedRationale = moves.length === planned.length ? rationale : undefined;
1258
+ const did = `${moves.join("; ")}${confirmedRationale ? ` — ${confirmedRationale.slice(0, 90)}` : ""}`;
1301
1259
  state.journal = [
1302
1260
  ...(state.journal ?? []),
1303
1261
  { at: observation.asOf, did },
@@ -1317,7 +1275,7 @@ async function runCycleCore(deps, capture) {
1317
1275
  decisionType: "act",
1318
1276
  writeAttempted: decision.actions.length,
1319
1277
  writeAccepted: planned.filter((p) => p.accepted).length,
1320
- ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
1278
+ ...(opportunities.posted ? { opportunity: opportunities.posted } : {}),
1321
1279
  ...observationReceipt,
1322
1280
  };
1323
1281
  }
@@ -103,6 +103,12 @@ export function buildSpec(raw) {
103
103
  ? { capitalSizing: buildCapitalSizing(raw.capitalSizing) }
104
104
  : {}),
105
105
  risk: {
106
+ // Preserve malformed explicit policies for fail-closed validation.
107
+ ...(risk.entryPredicates !== undefined
108
+ ? {
109
+ entryPredicates: risk.entryPredicates,
110
+ }
111
+ : {}),
106
112
  maxLeverage: num(risk.maxLeverage, 1),
107
113
  perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
108
114
  maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
@@ -1,5 +1,6 @@
1
1
  import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAPABILITIES, } from "./types.js";
2
2
  import { parseCadenceMs, scanForSecrets } from "./util.js";
3
+ import { entryPredicateIssues } from "./entryPredicates.js";
3
4
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4
5
  const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5
6
  // maxTradesPerDay accepts 0 as the explicit "unlimited daily trades" sentinel.
@@ -93,6 +94,8 @@ export function validateSkill(parsed, mode = "self-host") {
93
94
  }
94
95
  else {
95
96
  const r = raw.risk;
97
+ if (r.entryPredicates !== undefined)
98
+ issues.push(...entryPredicateIssues(r.entryPredicates));
96
99
  if (!isPosNum(r.maxLeverage))
97
100
  add("skill_risk_leverage", "risk.maxLeverage must be a positive number");
98
101
  else if (r.maxLeverage > 20)
@@ -1,6 +1,7 @@
1
1
  // Local run state: the cursor, dedupe set, daily counters, and the kill-switch
2
2
  // inputs. Persisted to a JSON file so a re-run resumes where it left off.
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, rmSync, } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { dirname } from "node:path";
5
6
  import { dayKey } from "./util.js";
6
7
  import { asObj, asNum } from "./extract.js";
@@ -59,8 +60,22 @@ export function loadState(file, runId) {
59
60
  export function saveState(file, state) {
60
61
  if (!file)
61
62
  return;
63
+ const serialized = JSON.stringify(state, null, 2);
62
64
  mkdirSync(dirname(file), { recursive: true });
63
- writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
65
+ // A sibling file keeps rename on the same filesystem. Readers see either
66
+ // the previous complete state or the new one, never a truncated JSON write.
67
+ const temporary = `${file}.${randomUUID()}.tmp`;
68
+ try {
69
+ writeFileSync(temporary, serialized, {
70
+ encoding: "utf8",
71
+ flag: "wx",
72
+ mode: 0o600,
73
+ });
74
+ renameSync(temporary, file);
75
+ }
76
+ finally {
77
+ rmSync(temporary, { force: true });
78
+ }
64
79
  }
65
80
  export function rollDay(state) {
66
81
  const today = dayKey();
@@ -49,6 +49,7 @@ const ALLOWED_KEYS = {
49
49
  "watchlist",
50
50
  "blocklist",
51
51
  "direction",
52
+ "entryPredicates",
52
53
  ],
53
54
  sizing: null,
54
55
  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];
@@ -182,6 +182,10 @@ export function ejectFiles(fm, body) {
182
182
  };
183
183
  if (fm.sizing)
184
184
  agentFm.sizing = fm.sizing;
185
+ if (fm.capitalSizing !== undefined)
186
+ agentFm.capitalSizing = fm.capitalSizing;
187
+ if (fm.triggerPolicy !== undefined)
188
+ agentFm.triggerPolicy = fm.triggerPolicy;
185
189
  if (fm.objective)
186
190
  agentFm.objective = fm.objective;
187
191
  if (fm.capabilities)
@@ -26,6 +26,7 @@ export interface TriggerPolicy {
26
26
  }
27
27
  export declare const DEFAULT_TRIGGER_POLICY: TriggerPolicy;
28
28
  export interface RiskConfig {
29
+ entryPredicates?: import("./entryPredicates.js").EntryPredicate[];
29
30
  maxLeverage: number;
30
31
  perTradeMarginMusd: number;
31
32
  maxConcurrentPositions: number;
@@ -1,5 +1,5 @@
1
1
  // Small dependency-free helpers shared across the runner.
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { resolve as resolvePath, relative as relativePath, isAbsolute, } from "node:path";
4
4
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
5
  // Parse a cadence string ("30s", "15m", "1h", "4h", "1d") to milliseconds.
@@ -63,8 +63,8 @@ export function scanForSecrets(value, pathPrefix = "", findings = []) {
63
63
  }
64
64
  // Short random id (for runId/decisionId/idempotencyKey suffixes).
65
65
  export function shortId() {
66
- // randomUUID is available in Node 18+ (globalThis.crypto).
67
- return crypto.randomUUID().slice(0, 8);
66
+ // Import the Node API: globalThis.crypto is not enabled by default in Node 18.
67
+ return randomUUID().slice(0, 8);
68
68
  }
69
69
  // ── Resolver helpers (cross-platform-deterministic) ──────────────────────────
70
70
  // Normalize file content for hashing: CRLF/CR -> LF, strip trailing whitespace,
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@
17
17
  //
18
18
  // IMPORTANT: this module must NEVER write to stdout (stdout is the MCP JSON-RPC
19
19
  // channel). All diagnostics go to stderr via the logger below.
20
+ import { retryAfterSeconds } from "./retryAfter.js";
20
21
  export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
21
22
  export function log(...args) {
22
23
  // stderr only — stdout is reserved for the MCP protocol.
@@ -139,12 +140,12 @@ export class CoinRithmClient {
139
140
  if (res.status === 429) {
140
141
  // Surface the back-off contract so an agent can pace itself instead of
141
142
  // hammering: 120 req/min per key baseline, 20 trade-writes/min.
142
- const retryAfter = Number(res.headers.get("retry-after"));
143
+ const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
143
144
  data = {
144
145
  ...(typeof data === "object" && data !== null
145
146
  ? data
146
147
  : { error: String(data) }),
147
- retryAfterSeconds: Number.isFinite(retryAfter) ? retryAfter : null,
148
+ retryAfterSeconds: retryAfter ?? null,
148
149
  hint: "Rate limited. Wait retryAfterSeconds (or the Retry-After header) before retrying; pace future calls using the RateLimit-Remaining response header.",
149
150
  };
150
151
  }
package/dist/http.d.ts CHANGED
@@ -1,2 +1,8 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { CoinRithmClient } from "./client.js";
4
+ import { type CompletionLogger } from "./httpCompletion.js";
5
+ export declare function createHttpApp(client: CoinRithmClient, options?: {
6
+ completionLogger?: CompletionLogger;
7
+ createServer?: () => McpServer;
8
+ }): import("express-serve-static-core").Express;