@coinrithm/mcp-trading 0.2.0 → 0.3.0

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 (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -2
  3. package/dist/agent/act.d.ts +4 -0
  4. package/dist/agent/act.js +58 -9
  5. package/dist/agent/capabilityGuard.d.ts +2 -0
  6. package/dist/agent/capabilityGuard.js +131 -0
  7. package/dist/agent/cli.d.ts +21 -0
  8. package/dist/agent/cli.js +28 -6
  9. package/dist/agent/client.d.ts +107 -0
  10. package/dist/agent/client.js +26 -0
  11. package/dist/agent/decision.d.ts +137 -0
  12. package/dist/agent/decision.js +37 -4
  13. package/dist/agent/decisionValidator.d.ts +16 -0
  14. package/dist/agent/decisionValidator.js +118 -10
  15. package/dist/agent/engine.d.ts +10 -0
  16. package/dist/agent/engine.js +16 -0
  17. package/dist/agent/extract.d.ts +4 -0
  18. package/dist/agent/frontmatter.d.ts +5 -0
  19. package/dist/agent/index.d.ts +2 -0
  20. package/dist/agent/indicators.d.ts +44 -0
  21. package/dist/agent/indicators.js +135 -0
  22. package/dist/agent/manifest.d.ts +15 -0
  23. package/dist/agent/mergeRules.d.ts +11 -0
  24. package/dist/agent/observe.d.ts +7 -0
  25. package/dist/agent/observe.js +142 -8
  26. package/dist/agent/prompt.d.ts +3 -0
  27. package/dist/agent/prompt.js +39 -8
  28. package/dist/agent/providers.d.ts +25 -0
  29. package/dist/agent/providers.js +10 -1
  30. package/dist/agent/resolve.d.ts +11 -0
  31. package/dist/agent/resolve.js +12 -3
  32. package/dist/agent/runEvidence.d.ts +6 -0
  33. package/dist/agent/runner.d.ts +19 -0
  34. package/dist/agent/runner.js +102 -19
  35. package/dist/agent/skill.d.ts +12 -0
  36. package/dist/agent/skill.js +6 -2
  37. package/dist/agent/skillValidator.d.ts +7 -0
  38. package/dist/agent/state.d.ts +7 -0
  39. package/dist/agent/state.js +3 -1
  40. package/dist/agent/strictLint.d.ts +3 -0
  41. package/dist/agent/strictLint.js +2 -1
  42. package/dist/agent/templates.d.ts +14 -0
  43. package/dist/agent/types.d.ts +286 -0
  44. package/dist/agent/types.js +39 -1
  45. package/dist/agent/util.d.ts +13 -0
  46. package/dist/agent/util.js +4 -0
  47. package/dist/agent/version.d.ts +11 -0
  48. package/dist/agent/version.js +1 -1
  49. package/dist/client.d.ts +162 -0
  50. package/dist/http.d.ts +2 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/tools.d.ts +3 -0
  53. package/dist/version.d.ts +1 -0
  54. package/package.json +7 -2
@@ -2,6 +2,32 @@
2
2
  // before any write (polledBeforeWrite=true only after that succeeds). If a
3
3
  // required read fails, or no watchlist symbol resolves, the cycle SKIPS writes.
4
4
  import { asObj, asArr, asNum, asStr } from "./extract.js";
5
+ import { computeIndicators } from "./indicators.js";
6
+ // Candle granularity feeding the indicators: the 1D range = 5-minute candles
7
+ // (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
8
+ // short cadence the hosted house agents run on. Probe-verified 2026-06-17.
9
+ const INDICATOR_RANGE = "1D";
10
+ // Fetch candles for one coin and reduce them to a compact indicator bundle.
11
+ // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
12
+ // null so the cycle proceeds with price-only context rather than skipping.
13
+ async function fetchIndicators(client, coinId, trace) {
14
+ const cr = await client.candles(coinId, INDICATOR_RANGE, trace);
15
+ if (!cr.ok)
16
+ return null;
17
+ // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
18
+ const candles = [];
19
+ for (const raw of asArr(asObj(cr.data).candles)) {
20
+ const c = asObj(raw);
21
+ const open = asNum(c.o);
22
+ const high = asNum(c.h);
23
+ const low = asNum(c.l);
24
+ const close = asNum(c.c);
25
+ if (open == null || high == null || low == null || close == null)
26
+ continue;
27
+ candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
28
+ }
29
+ return computeIndicators(candles);
30
+ }
5
31
  function freshnessOf(block) {
6
32
  const fr = asObj(block.freshness);
7
33
  const status = asStr(fr.status);
@@ -14,6 +40,9 @@ function emptyObservation(state, scopes = []) {
14
40
  cashAvailableMusd: null,
15
41
  equityMusd: null,
16
42
  openPositions: [],
43
+ openOrders: [],
44
+ pmPositions: [],
45
+ pmMarkets: [],
17
46
  watch: [],
18
47
  syncCursor: state.cursor,
19
48
  newClosedTrades: [],
@@ -23,7 +52,10 @@ function emptyObservation(state, scopes = []) {
23
52
  export async function observe(client, spec, state, trace) {
24
53
  const meR = await client.me(trace);
25
54
  if (!meR.ok)
26
- return { observation: emptyObservation(state), skip: `me failed (HTTP ${meR.status})` };
55
+ return {
56
+ observation: emptyObservation(state),
57
+ skip: `me failed (HTTP ${meR.status})`,
58
+ };
27
59
  const scopes = asArr(asObj(meR.data).scopes).filter((s) => typeof s === "string");
28
60
  const [portR, walletR, posR] = await Promise.all([
29
61
  client.portfolio(trace),
@@ -31,7 +63,10 @@ export async function observe(client, spec, state, trace) {
31
63
  client.futuresPositions(undefined, trace),
32
64
  ]);
33
65
  if (!portR.ok || !walletR.ok || !posR.ok) {
34
- return { observation: emptyObservation(state, scopes), skip: "required reads failed (portfolio/wallet/positions)" };
66
+ return {
67
+ observation: emptyObservation(state, scopes),
68
+ skip: "required reads failed (portfolio/wallet/positions)",
69
+ };
35
70
  }
36
71
  const usdt = asObj(asObj(walletR.data).usdt);
37
72
  const equity = asObj(asObj(portR.data).equity);
@@ -51,7 +86,11 @@ export async function observe(client, spec, state, trace) {
51
86
  unrealizedPnlMusd: asNum(p.unrealizedPnlMusd),
52
87
  }));
53
88
  // Sync poll: /trades since the persisted cursor.
54
- const tradesR = await client.trades({ venue: "futures", updatedSince: state.cursor ?? undefined, limit: state.cursor ? undefined : 1 }, trace);
89
+ const tradesR = await client.trades({
90
+ venue: "futures",
91
+ updatedSince: state.cursor ?? undefined,
92
+ limit: state.cursor ? undefined : 1,
93
+ }, trace);
55
94
  let polledBeforeWrite = false;
56
95
  let newClosedTrades = [];
57
96
  let syncCursor = state.cursor;
@@ -66,6 +105,7 @@ export async function observe(client, spec, state, trace) {
66
105
  // Watchlist market context.
67
106
  const watch = [];
68
107
  let resolvedAny = false;
108
+ const wantIndicators = spec.capabilities.includes("indicators");
69
109
  for (const symbol of spec.risk.watchlist) {
70
110
  const rs = await client.resolve(symbol, trace);
71
111
  const match = asObj(asObj(rs.data).match);
@@ -78,7 +118,7 @@ export async function observe(client, spec, state, trace) {
78
118
  const mk = await client.market(coinId, trace);
79
119
  const m = asObj(mk.data);
80
120
  const price = asObj(m.price);
81
- watch.push({
121
+ const entry = {
82
122
  symbol,
83
123
  coinId,
84
124
  name: asStr(match.name),
@@ -88,7 +128,89 @@ export async function observe(client, spec, state, trace) {
88
128
  change7d: asNum(price.change7d),
89
129
  // Freshness lives under the response's `observation` block.
90
130
  freshness: freshnessOf(asObj(m.observation)),
91
- });
131
+ };
132
+ // `indicators` capability: enrich the observation with computed TA so the
133
+ // model reasons over structure (trend/momentum/volatility/breakout) instead
134
+ // of price + %change alone. Backed by the candles endpoint's shared cache.
135
+ if (wantIndicators) {
136
+ const ind = await fetchIndicators(client, coinId, trace);
137
+ if (ind)
138
+ entry.indicators = ind;
139
+ }
140
+ watch.push(entry);
141
+ }
142
+ // Spot resting orders (for cancel + affordability) — only if spot is enabled.
143
+ const wantSpot = spec.venues.includes("spot");
144
+ const wantPm = spec.venues.includes("pm");
145
+ let openOrders = [];
146
+ if (wantSpot) {
147
+ const ordR = await client.openOrders(undefined, trace);
148
+ if (ordR.ok) {
149
+ const od = asObj(ordR.data);
150
+ openOrders = asArr(od.orders ?? od.openOrders)
151
+ .map(asObj)
152
+ .filter((o) => (asStr(o.status) ?? "open") === "open")
153
+ .map((o) => ({
154
+ id: Number(asNum(o.id) ?? o.id),
155
+ coinId: asStr(o.coinId),
156
+ symbol: asStr(o.symbol),
157
+ side: asStr(o.side),
158
+ orderType: asStr(o.orderType),
159
+ quantity: asNum(o.quantity),
160
+ status: asStr(o.status) ?? "open",
161
+ }));
162
+ }
163
+ }
164
+ // PM open positions + discovered quote-ready candidates — only if pm enabled.
165
+ let pmPositions = [];
166
+ let pmMarkets = [];
167
+ if (wantPm) {
168
+ const [pmPosR, pmDiscR] = await Promise.all([
169
+ client.pmPositions(undefined, trace),
170
+ client.discoverPmMarkets({ limit: 8 }, trace),
171
+ ]);
172
+ if (pmPosR.ok) {
173
+ pmPositions = asArr(asObj(pmPosR.data).positions)
174
+ .map(asObj)
175
+ .filter((p) => (asStr(p.status) ?? "open") === "open")
176
+ .map((p) => ({
177
+ id: Number(asNum(p.id) ?? p.id),
178
+ source: asStr(p.source),
179
+ slug: asStr(p.slug),
180
+ outcomeExternalMarketId: asStr(p.outcomeExternalMarketId),
181
+ stakeMusd: asNum(p.stakeMusd),
182
+ status: asStr(p.status) ?? "open",
183
+ }));
184
+ }
185
+ if (pmDiscR.ok) {
186
+ const dd = asObj(pmDiscR.data);
187
+ // Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
188
+ // Each EVENT carries source/slug/title/freshness at the top level and the
189
+ // quoteable id NESTED at outcomes[].externalMarketId — so expand one
190
+ // PmMarket per quoteable outcome. (Tolerant `markets`/`results` and flat
191
+ // `outcomeExternalMarketId` fallbacks kept for older/mocked shapes.)
192
+ pmMarkets = asArr(dd.data ?? dd.markets ?? dd.results)
193
+ .map(asObj)
194
+ .flatMap((ev) => {
195
+ const source = (asStr(ev.source) ?? "").toLowerCase();
196
+ const slug = (asStr(ev.slug) ?? "").toLowerCase();
197
+ const title = asStr(ev.title) ?? asStr(ev.question);
198
+ const freshness = freshnessOf(ev); // freshness is event-level
199
+ const outcomes = asArr(ev.outcomes).map(asObj);
200
+ // A market with no outcomes array still round-trips a flat fallback row.
201
+ const rows = outcomes.length > 0 ? outcomes : [ev];
202
+ return rows.map((o) => ({
203
+ source,
204
+ slug,
205
+ outcomeExternalMarketId: asStr(o.externalMarketId) ??
206
+ asStr(o.outcomeExternalMarketId) ??
207
+ "",
208
+ title,
209
+ freshness,
210
+ }));
211
+ })
212
+ .filter((m) => m.source && m.slug && m.outcomeExternalMarketId);
213
+ }
92
214
  }
93
215
  const observation = {
94
216
  asOf: syncCursor ?? new Date().toISOString(),
@@ -96,15 +218,27 @@ export async function observe(client, spec, state, trace) {
96
218
  cashAvailableMusd,
97
219
  equityMusd,
98
220
  openPositions,
221
+ openOrders,
222
+ pmPositions,
223
+ pmMarkets,
99
224
  watch,
100
225
  syncCursor,
101
226
  newClosedTrades,
102
227
  polledBeforeWrite,
103
228
  };
104
- if (!resolvedAny)
105
- return { observation, skip: "no watchlist symbol resolved to a coin" };
229
+ // Skip only when there is NOTHING actionable: no coin resolved (futures/spot)
230
+ // AND no PM candidate (pm). A pm-only agent proceeds on its discovered markets.
231
+ if (!resolvedAny && pmMarkets.length === 0) {
232
+ return {
233
+ observation,
234
+ skip: "no watchlist coin resolved and no PM markets available",
235
+ };
236
+ }
106
237
  if (spec.sync.requirePollBeforeWrite && !polledBeforeWrite) {
107
- return { observation, skip: "poll-before-write required but /trades poll failed" };
238
+ return {
239
+ observation,
240
+ skip: "poll-before-write required but /trades poll failed",
241
+ };
108
242
  }
109
243
  return { observation };
110
244
  }
@@ -0,0 +1,3 @@
1
+ import { AgentSpec, Observation } from "./types.js";
2
+ export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string): string;
3
+ export declare function buildUserPrompt(obs: Observation): string;
@@ -4,24 +4,52 @@
4
4
  // so the prompt states the caps but never relies on the model to honor them.
5
5
  export function buildSystemPrompt(spec, mergedProse) {
6
6
  const r = spec.risk;
7
+ const v = spec.venues;
8
+ const actions = [];
9
+ if (v.includes("futures")) {
10
+ actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}');
11
+ }
12
+ if (v.includes("spot")) {
13
+ actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
14
+ }
15
+ if (v.includes("pm")) {
16
+ actions.push('{"type":"pm_open","source","slug","outcomeExternalMarketId","stakeMusd","confidence":0..1} (ONLY a market from observation.pmMarkets; stakeMusd >= 10)');
17
+ }
7
18
  return [
8
- "You operate a CoinRithm PAPER-TRADING futures agent (simulated 50,000 mUSD; not real money, not financial advice).",
19
+ "You operate a CoinRithm PAPER-TRADING agent (simulated 50,000 mUSD; not real money, not financial advice).",
9
20
  "You only PROPOSE actions as structured JSON. A separate runner re-validates every action against hard caps and executes it; you cannot bypass a cap.",
10
21
  "",
11
22
  "## Your strategy (your borders)",
12
23
  mergedProse.trim() || "(no strategy prose provided)",
13
24
  "",
14
25
  "## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
15
- `- venues: ${spec.venues.join(", ")} (v1 executes FUTURES only)`,
16
- `- maxLeverage ${r.maxLeverage}; perTradeMarginMusd ${r.perTradeMarginMusd}; maxConcurrentPositions ${r.maxConcurrentPositions}; requireStopLoss ${r.requireStopLoss}`,
17
- `- watchlist (only these): ${r.watchlist.join(", ")}`,
26
+ `- venues you may act in: ${v.join(", ")}`,
27
+ `- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (futures margin / spot buy notional / PM stake)`,
28
+ `- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
29
+ `- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
30
+ ...(r.blocklist && r.blocklist.length > 0
31
+ ? [
32
+ `- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
33
+ ]
34
+ : []),
35
+ "- prediction markets: pick ONLY a market listed in observation.pmMarkets; minimum stake 10 mUSD",
18
36
  `- abstention.minConfidence ${spec.abstention.minConfidence}; a skipped cycle is correct and cheap`,
37
+ ...(spec.capabilities.includes("indicators")
38
+ ? [
39
+ "",
40
+ "## Signals — each watch entry may carry `indicators` (computed from 5-minute candles)",
41
+ "- rsi14: momentum (>70 overbought, <30 oversold); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.",
42
+ "- boolean reads: aboveEma20, ema20AboveEma50 (uptrend when both true), brokeRecentHigh (breakout), brokeRecentLow (breakdown).",
43
+ "- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
44
+ ]
45
+ : []),
19
46
  "",
20
47
  "## Output contract — return ONLY this JSON object, nothing else:",
21
- `{"decision":"skip"|"act","confidence":0..1,"reason":"short","actions":[]}`,
22
- 'Each action is one of: {"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice"},',
23
- '{"type":"futures_close","positionId","fraction"}, {"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}.',
24
- "Always include a stopLossPrice on a futures_open. Prefer skip when the signal is weak or data is stale.",
48
+ '{"decision":"skip"|"act","confidence":0..1,"reason":"short","actions":[]}',
49
+ "Each action is one of:",
50
+ ...actions.map((a) => `- ${a}`),
51
+ `Set each opening action's "confidence" (0..1) to your honest conviction — the runner REJECTS any open below abstention.minConfidence (${spec.abstention.minConfidence}). The decision-level "confidence" is the fallback when an action omits its own.`,
52
+ "Prefer skip when the signal is weak or data is stale.",
25
53
  ].join("\n");
26
54
  }
27
55
  export function buildUserPrompt(obs) {
@@ -34,6 +62,9 @@ export function buildUserPrompt(obs) {
34
62
  cashAvailableMusd: obs.cashAvailableMusd,
35
63
  equityMusd: obs.equityMusd,
36
64
  openPositions: obs.openPositions,
65
+ openOrders: obs.openOrders,
66
+ pmPositions: obs.pmPositions,
67
+ pmMarkets: obs.pmMarkets,
37
68
  watch: obs.watch,
38
69
  newClosedTrades: obs.newClosedTrades,
39
70
  polledBeforeWrite: obs.polledBeforeWrite,
@@ -0,0 +1,25 @@
1
+ import { AgentSpec } from "./types.js";
2
+ export interface DecideInput {
3
+ system: string;
4
+ user: string;
5
+ maxTokens?: number;
6
+ }
7
+ export type DecideResult = {
8
+ ok: true;
9
+ text: string;
10
+ } | {
11
+ ok: false;
12
+ error: string;
13
+ };
14
+ export interface Provider {
15
+ label: string;
16
+ decide(input: DecideInput): Promise<DecideResult>;
17
+ }
18
+ export interface ProviderEnv {
19
+ ANTHROPIC_API_KEY?: string;
20
+ OPENAI_API_KEY?: string;
21
+ GROQ_API_KEY?: string;
22
+ NVIDIA_API_KEY?: string;
23
+ MODEL_API_KEY?: string;
24
+ }
25
+ export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
@@ -2,6 +2,9 @@
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
+ // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
6
+ // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
7
+ const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
5
8
  function envKey(provider, env) {
6
9
  switch (provider) {
7
10
  case "anthropic":
@@ -10,6 +13,8 @@ function envKey(provider, env) {
10
13
  return env.OPENAI_API_KEY;
11
14
  case "groq":
12
15
  return env.GROQ_API_KEY;
16
+ case "nvidia":
17
+ return env.NVIDIA_API_KEY ?? env.MODEL_API_KEY;
13
18
  case "openai-compatible":
14
19
  return env.MODEL_API_KEY ?? env.OPENAI_API_KEY;
15
20
  }
@@ -20,6 +25,8 @@ function baseUrlFor(provider, configured) {
20
25
  return "https://api.openai.com/v1";
21
26
  case "groq":
22
27
  return "https://api.groq.com/openai/v1";
28
+ case "nvidia":
29
+ return NVIDIA_BASE_URL;
23
30
  case "openai-compatible":
24
31
  return (configured ?? "").replace(/\/+$/, "");
25
32
  case "anthropic":
@@ -121,7 +128,9 @@ export function selectProvider(spec, env, fetchFn = fetch) {
121
128
  ? "ANTHROPIC_API_KEY"
122
129
  : provider === "groq"
123
130
  ? "GROQ_API_KEY"
124
- : "OPENAI_API_KEY / MODEL_API_KEY";
131
+ : provider === "nvidia"
132
+ ? "NVIDIA_API_KEY"
133
+ : "OPENAI_API_KEY / MODEL_API_KEY";
125
134
  throw new Error(`missing model API key: set ${varName} in the environment (never in an agent file)`);
126
135
  }
127
136
  if (provider === "anthropic")
@@ -0,0 +1,11 @@
1
+ import { ResolvedAgent, ResolveIssue } from "./types.js";
2
+ export declare class ResolveError extends Error {
3
+ issues: ResolveIssue[];
4
+ constructor(issues: ResolveIssue[]);
5
+ }
6
+ export declare function isSkillProseSource(source: string): boolean;
7
+ export declare function mergeProseParts(parts: Array<{
8
+ source: string;
9
+ text: string;
10
+ }>): string;
11
+ export declare function resolveAgent(inputPath: string): ResolvedAgent;
@@ -420,9 +420,7 @@ function resolveDirectory(dir) {
420
420
  });
421
421
  }
422
422
  }
423
- const mergedProse = proseParts
424
- .map((p) => `<!-- ${p.source} -->\n${p.text.trim()}`)
425
- .join("\n\n");
423
+ const mergedProse = mergeProseParts(proseParts);
426
424
  checkSizing(ctx, rawFrontmatter);
427
425
  scanSecrets(ctx, rawFrontmatter, mergedProse);
428
426
  if (ctx.issues.length)
@@ -472,6 +470,17 @@ function applySkillPatch(ctx, rawFrontmatter, patch, sourceLabel) {
472
470
  }
473
471
  }
474
472
  // ── entry point ──────────────────────────────────────────────────────────────
473
+ // Is this prose part a tactic skill? (Used by the skills ablation kill-switch to
474
+ // drop skill bodies from the run-time prompt without touching the resolver.)
475
+ export function isSkillProseSource(source) {
476
+ return source.startsWith("character/skills/");
477
+ }
478
+ // The canonical prose assembly: each part labelled with its source, joined by a
479
+ // blank line. The resolver uses this for mergedProse; the run path reuses it to
480
+ // re-assemble a skills-ablated prompt deterministically.
481
+ export function mergeProseParts(parts) {
482
+ return parts.map((p) => `<!-- ${p.source} -->\n${p.text.trim()}`).join("\n\n");
483
+ }
475
484
  export function resolveAgent(inputPath) {
476
485
  const abs = resolvePath(inputPath);
477
486
  if (!existsSync(abs)) {
@@ -0,0 +1,6 @@
1
+ import { CoinRithmClient } from "./client.js";
2
+ import { AgentSpec, AgentTrace } from "./types.js";
3
+ export declare function makeRunId(spec: AgentSpec): string;
4
+ export declare function makeDecisionId(cycle: number): string;
5
+ export declare function makeTrace(runId: string, decisionId: string, spec: AgentSpec, confidence?: number, rationaleSummary?: string): AgentTrace;
6
+ export declare function exportRunEvidence(client: CoinRithmClient, runId: string): Promise<unknown>;
@@ -0,0 +1,19 @@
1
+ import { CoinRithmClient } from "./client.js";
2
+ import { Provider } from "./providers.js";
3
+ import { AgentSpec, RunState, CycleResult } from "./types.js";
4
+ export interface RunnerDeps {
5
+ client: CoinRithmClient;
6
+ provider: Provider;
7
+ spec: AgentSpec;
8
+ mergedProse: string;
9
+ state: RunState;
10
+ live: boolean;
11
+ stateFile?: string;
12
+ log?: (line: string) => void;
13
+ }
14
+ export declare function runCycle(deps: RunnerDeps): Promise<CycleResult>;
15
+ export interface LoopOptions {
16
+ once?: boolean;
17
+ maxCycles?: number;
18
+ }
19
+ export declare function runLoop(deps: RunnerDeps, opts?: LoopOptions): Promise<CycleResult[]>;
@@ -1,14 +1,15 @@
1
- // The execution loop: observe -> decide (BYO model) -> validate -> act. v1
2
- // futures only. Dry-run never writes. Live uses idempotency keys + agentTrace
3
- // and exports run evidence. The client + provider are injected so the loop is
4
- // fully unit-testable with no network and no model calls.
1
+ // The execution loop: observe -> decide (BYO model) -> validate -> act across
2
+ // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
+ // idempotency keys + agentTrace and exports run evidence. The client + provider
4
+ // are injected so the loop is fully unit-testable with no network/model calls.
5
+ import { spotBuyCost, } from "./types.js";
5
6
  import { observe } from "./observe.js";
6
7
  import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
7
8
  import { parseDecision } from "./decision.js";
8
9
  import { validateAction } from "./decisionValidator.js";
9
10
  import { fetchQuote, executeAction } from "./act.js";
10
11
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
11
- import { rollDay, checkKillSwitch, accrueRealized, saveState } from "./state.js";
12
+ import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
12
13
  import { asObj, asNum, asStr } from "./extract.js";
13
14
  import { parseCadenceMs, sleep } from "./util.js";
14
15
  // A stable idempotency-key component per distinct intent (so a lost response
@@ -23,7 +24,33 @@ function intentKeyOf(action) {
23
24
  if (action.type === "futures_set_sltp") {
24
25
  return `sltp:${action.positionId}`;
25
26
  }
26
- return `other:${action.type}`; // unreachable: only futures actions are executed
27
+ if (action.type === "spot_order") {
28
+ return `spot:${action.symbol.toUpperCase()}:${action.side}:${action.orderType}:${action.quantity}:${action.limitPrice ?? ""}:${action.stopPrice ?? ""}`;
29
+ }
30
+ if (action.type === "spot_cancel") {
31
+ return `cancel:${action.orderId}`;
32
+ }
33
+ if (action.type === "pm_open") {
34
+ return `pm:${action.source.toLowerCase()}:${action.slug.toLowerCase()}:${action.outcomeExternalMarketId}:${action.stakeMusd}`;
35
+ }
36
+ return "other"; // unreachable: every action type is handled above
37
+ }
38
+ // Estimated cash a successful action consumes (for the running-cash guard):
39
+ // futures margin, spot buy notional, or a PM stake. Closes/cancels/sells free
40
+ // cash or are neutral, so they consume nothing here. Spot buys use the SAME
41
+ // `spotBuyCost` helper the validator gates on, so the gate and this decrement
42
+ // never diverge. The `?? 0` is unreachable for an EXECUTED buy: the validator
43
+ // fails closed (missing_quote_price) on any buy whose cost can't be sized, so
44
+ // nothing with an undefined cost ever reaches execution to be decremented.
45
+ function cashConsumed(action, quote) {
46
+ if (action.type === "futures_open")
47
+ return action.marginMusd;
48
+ if (action.type === "pm_open")
49
+ return action.stakeMusd;
50
+ if (action.type === "spot_order" && action.side === "buy") {
51
+ return spotBuyCost(action, quote) ?? 0;
52
+ }
53
+ return 0;
27
54
  }
28
55
  export async function runCycle(deps) {
29
56
  const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
@@ -37,7 +64,13 @@ export async function runCycle(deps) {
37
64
  state.disabledReason = tripped;
38
65
  saveState(stateFile, state);
39
66
  log(`disabled: ${tripped}`);
40
- return { decision: "skip", planned: [], disabled: true, disabledReason: tripped, live };
67
+ return {
68
+ decision: "skip",
69
+ planned: [],
70
+ disabled: true,
71
+ disabledReason: tripped,
72
+ live,
73
+ };
41
74
  }
42
75
  const runId = state.runId;
43
76
  const decisionId = makeDecisionId(state.cyclesRun);
@@ -55,12 +88,19 @@ export async function runCycle(deps) {
55
88
  // not only realized losses.
56
89
  const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
57
90
  if (spec.killSwitch.maxDrawdownMusd > 0 &&
58
- state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >= spec.killSwitch.maxDrawdownMusd) {
91
+ state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >=
92
+ spec.killSwitch.maxDrawdownMusd) {
59
93
  state.disabled = true;
60
94
  state.disabledReason = `equity drawdown >= ${spec.killSwitch.maxDrawdownMusd}`;
61
95
  saveState(stateFile, state);
62
96
  log(`disabled: ${state.disabledReason}`);
63
- return { decision: "skip", planned: [], disabled: true, disabledReason: state.disabledReason, live };
97
+ return {
98
+ decision: "skip",
99
+ planned: [],
100
+ disabled: true,
101
+ disabledReason: state.disabledReason,
102
+ live,
103
+ };
64
104
  }
65
105
  if (obs.skip) {
66
106
  state.consecutiveRejectCycles += 1;
@@ -77,14 +117,26 @@ export async function runCycle(deps) {
77
117
  state.consecutiveModelFailures += 1;
78
118
  saveState(stateFile, state);
79
119
  log(`model error: ${res.error}`);
80
- return { decision: "skip", skipReason: `model error: ${res.error}`, planned: [], modelFailed: true, live };
120
+ return {
121
+ decision: "skip",
122
+ skipReason: `model error: ${res.error}`,
123
+ planned: [],
124
+ modelFailed: true,
125
+ live,
126
+ };
81
127
  }
82
128
  const parsed = parseDecision(res.text);
83
129
  if (!parsed.ok) {
84
130
  state.consecutiveModelFailures += 1;
85
131
  saveState(stateFile, state);
86
132
  log(`model output invalid: ${parsed.error}`);
87
- return { decision: "skip", skipReason: `model output invalid: ${parsed.error}`, planned: [], modelFailed: true, live };
133
+ return {
134
+ decision: "skip",
135
+ skipReason: `model output invalid: ${parsed.error}`,
136
+ planned: [],
137
+ modelFailed: true,
138
+ live,
139
+ };
88
140
  }
89
141
  state.consecutiveModelFailures = 0;
90
142
  const decision = parsed.decision;
@@ -92,7 +144,12 @@ export async function runCycle(deps) {
92
144
  state.consecutiveRejectCycles += 1;
93
145
  saveState(stateFile, state);
94
146
  log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
95
- return { decision: "skip", skipReason: decision.reason ?? "model chose skip", planned: [], live };
147
+ return {
148
+ decision: "skip",
149
+ skipReason: decision.reason ?? "model chose skip",
150
+ planned: [],
151
+ live,
152
+ };
96
153
  }
97
154
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
98
155
  const planned = [];
@@ -105,6 +162,7 @@ export async function runCycle(deps) {
105
162
  let cashAvailableMusd = observation.cashAvailableMusd;
106
163
  const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
107
164
  const targetedPositionIds = [];
165
+ const targetedOrderIds = [];
108
166
  let anyAccepted = false;
109
167
  let anyExecuted = false;
110
168
  let anyExecFailed = false;
@@ -112,6 +170,10 @@ export async function runCycle(deps) {
112
170
  const quote = await fetchQuote(client, action, observation, baseTrace);
113
171
  const ctx = {
114
172
  spec,
173
+ // Inherit the decision-level confidence so the per-action abstention gate
174
+ // doesn't reject a model that reports conviction on the decision (the
175
+ // output contract) rather than on each action.
176
+ decisionConfidence: decision.confidence,
115
177
  observation,
116
178
  quote,
117
179
  writesThisCycle,
@@ -121,10 +183,17 @@ export async function runCycle(deps) {
121
183
  openMarginMusd,
122
184
  realizedLossTodayMusd,
123
185
  targetedPositionIds,
186
+ targetedOrderIds,
124
187
  };
125
188
  const v = validateAction(action, ctx);
126
189
  if (!v.valid) {
127
- planned.push({ action, accepted: false, code: v.code, reason: v.reason, quote });
190
+ planned.push({
191
+ action,
192
+ accepted: false,
193
+ code: v.code,
194
+ reason: v.reason,
195
+ quote,
196
+ });
128
197
  log(`reject ${action.type}: ${v.code} (${v.reason})`);
129
198
  continue;
130
199
  }
@@ -132,6 +201,9 @@ export async function runCycle(deps) {
132
201
  if (action.type === "futures_close" || action.type === "futures_set_sltp") {
133
202
  targetedPositionIds.push(action.positionId);
134
203
  }
204
+ if (action.type === "spot_cancel") {
205
+ targetedOrderIds.push(action.orderId);
206
+ }
135
207
  if (!live) {
136
208
  planned.push({ action, accepted: true, quote, executed: false });
137
209
  log(`DRY-RUN: would ${action.type}`);
@@ -143,9 +215,15 @@ export async function runCycle(deps) {
143
215
  const seq = state.intentSeq[intentKey] ?? 0;
144
216
  const idem = `${runId}:${intentKey}:${seq}`;
145
217
  const meta = action;
146
- const trace = makeTrace(runId, decisionId, spec, meta.confidence, meta.rationaleSummary);
218
+ const trace = makeTrace(runId, decisionId, spec, meta.confidence ?? decision.confidence, meta.rationaleSummary);
147
219
  const r = await executeAction(client, action, observation, trace, idem);
148
- planned.push({ action, accepted: true, quote, executed: r.ok, result: r.data });
220
+ planned.push({
221
+ action,
222
+ accepted: true,
223
+ quote,
224
+ executed: r.ok,
225
+ result: r.data,
226
+ });
149
227
  if (r.ok) {
150
228
  anyExecuted = true;
151
229
  state.intentSeq[intentKey] = seq + 1;
@@ -154,9 +232,11 @@ export async function runCycle(deps) {
154
232
  if (action.type === "futures_open") {
155
233
  openCount += 1;
156
234
  openMarginMusd += action.marginMusd;
157
- if (cashAvailableMusd != null)
158
- cashAvailableMusd -= action.marginMusd;
159
235
  }
236
+ // Decrement running cash by what this action consumed (futures margin /
237
+ // spot buy notional / PM stake) so a later action this cycle sees it spent.
238
+ if (cashAvailableMusd != null)
239
+ cashAvailableMusd -= cashConsumed(action, quote);
160
240
  }
161
241
  else {
162
242
  anyExecFailed = true;
@@ -167,8 +247,11 @@ export async function runCycle(deps) {
167
247
  // live write is not progress, or a persistently failing live agent would
168
248
  // never trip the kill-switch.
169
249
  const progressed = live ? anyExecuted : anyAccepted;
170
- state.consecutiveRejectCycles = progressed ? 0 : state.consecutiveRejectCycles + 1;
171
- state.consecutiveExecFailures = anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
250
+ state.consecutiveRejectCycles = progressed
251
+ ? 0
252
+ : state.consecutiveRejectCycles + 1;
253
+ state.consecutiveExecFailures =
254
+ anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
172
255
  state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
173
256
  saveState(stateFile, state);
174
257
  if (live && anyExecuted)