@coinrithm/mcp-trading 0.1.8 → 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 (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +43 -19
  3. package/dist/agent/act.d.ts +4 -0
  4. package/dist/agent/act.js +114 -0
  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 +382 -0
  9. package/dist/agent/client.d.ts +107 -0
  10. package/dist/agent/client.js +173 -0
  11. package/dist/agent/decision.d.ts +137 -0
  12. package/dist/agent/decision.js +118 -0
  13. package/dist/agent/decisionValidator.d.ts +16 -0
  14. package/dist/agent/decisionValidator.js +215 -0
  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/extract.js +5 -0
  19. package/dist/agent/frontmatter.d.ts +5 -0
  20. package/dist/agent/frontmatter.js +19 -0
  21. package/dist/agent/index.d.ts +2 -0
  22. package/dist/agent/index.js +10 -0
  23. package/dist/agent/indicators.d.ts +44 -0
  24. package/dist/agent/indicators.js +135 -0
  25. package/dist/agent/manifest.d.ts +15 -0
  26. package/dist/agent/manifest.js +40 -0
  27. package/dist/agent/mergeRules.d.ts +11 -0
  28. package/dist/agent/mergeRules.js +82 -0
  29. package/dist/agent/observe.d.ts +7 -0
  30. package/dist/agent/observe.js +244 -0
  31. package/dist/agent/prompt.d.ts +3 -0
  32. package/dist/agent/prompt.js +76 -0
  33. package/dist/agent/providers.d.ts +25 -0
  34. package/dist/agent/providers.js +143 -0
  35. package/dist/agent/resolve.d.ts +11 -0
  36. package/dist/agent/resolve.js +499 -0
  37. package/dist/agent/runEvidence.d.ts +6 -0
  38. package/dist/agent/runEvidence.js +23 -0
  39. package/dist/agent/runner.d.ts +19 -0
  40. package/dist/agent/runner.js +280 -0
  41. package/dist/agent/skill.d.ts +12 -0
  42. package/dist/agent/skill.js +136 -0
  43. package/dist/agent/skillValidator.d.ts +7 -0
  44. package/dist/agent/skillValidator.js +123 -0
  45. package/dist/agent/state.d.ts +7 -0
  46. package/dist/agent/state.js +96 -0
  47. package/dist/agent/strictLint.d.ts +3 -0
  48. package/dist/agent/strictLint.js +165 -0
  49. package/dist/agent/templates.d.ts +14 -0
  50. package/dist/agent/templates.js +192 -0
  51. package/dist/agent/types.d.ts +286 -0
  52. package/dist/agent/types.js +88 -0
  53. package/dist/agent/util.d.ts +13 -0
  54. package/dist/agent/util.js +116 -0
  55. package/dist/agent/version.d.ts +11 -0
  56. package/dist/agent/version.js +16 -0
  57. package/dist/client.d.ts +162 -0
  58. package/dist/http.d.ts +2 -0
  59. package/dist/index.d.ts +2 -0
  60. package/dist/tools.d.ts +3 -0
  61. package/dist/version.d.ts +1 -0
  62. package/package.json +78 -67
@@ -0,0 +1,382 @@
1
+ // coinrithm-agent — the public scaffolder/inspector CLI.
2
+ //
3
+ // Authors, validates, ejects, locks, and inspects agent DEFINITIONS. It does
4
+ // NOT trade, call a model, or hit the live API — it only compiles folders into
5
+ // the AgentSpec the resolver produces. Commands return a structured CmdResult
6
+ // so they are unit-testable without spawning a process.
7
+ import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync, openSync, closeSync, unlinkSync, } from "node:fs";
8
+ import { resolve as resolvePath, dirname, join, basename } from "node:path";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource } from "./resolve.js";
11
+ import { buildSpec, loadAgent } from "./skill.js";
12
+ import { validateSkill } from "./skillValidator.js";
13
+ import { strictLint } from "./strictLint.js";
14
+ import { checkCapabilityDrift } from "./capabilityGuard.js";
15
+ import { buildManifest, writeManifest } from "./manifest.js";
16
+ import { parseFrontmatter } from "./frontmatter.js";
17
+ import { renderFolderOfOne, ejectFiles, PRESET_NAMES } from "./templates.js";
18
+ import { COINRITHM_API } from "./version.js";
19
+ import { stableStringify, envFlag } from "./util.js";
20
+ import { CoinRithmClient } from "./client.js";
21
+ import { selectProvider } from "./providers.js";
22
+ import { runLoop } from "./runner.js";
23
+ import { loadState, saveState } from "./state.js";
24
+ import { makeRunId } from "./runEvidence.js";
25
+ const fail = (lines) => ({ ok: false, code: 1, lines });
26
+ function issuesResult(issues, header) {
27
+ return {
28
+ ok: false,
29
+ code: 1,
30
+ lines: [
31
+ `✗ ${header}`,
32
+ ...issues.map((i) => ` [${i.code}] ${i.path ? `${i.path}: ` : ""}${i.message}`),
33
+ ],
34
+ };
35
+ }
36
+ function agentDirOf(path) {
37
+ const abs = resolvePath(path);
38
+ return existsSync(abs) && statSync(abs).isDirectory() ? abs : dirname(abs);
39
+ }
40
+ function pinWarnings(path) {
41
+ try {
42
+ const pin = join(agentDirOf(path), "functionality", "coinrithm.yaml");
43
+ if (!existsSync(pin))
44
+ return [];
45
+ const parsed = parseYaml(readFileSync(pin, "utf8"));
46
+ const v = parsed?.api?.openapiVersion;
47
+ if (v && v !== COINRITHM_API.openapiVersion) {
48
+ return [
49
+ `⚠ functionality/coinrithm.yaml pins API ${v}; current is ${COINRITHM_API.openapiVersion} (warning only, not a block)`,
50
+ ];
51
+ }
52
+ }
53
+ catch {
54
+ /* ignore */
55
+ }
56
+ return [];
57
+ }
58
+ export function cmdNew(targetPath, opts = {}) {
59
+ const template = opts.template ?? "momentum-futures";
60
+ if (template !== "momentum-futures") {
61
+ return fail([`unknown template "${template}" (only: momentum-futures)`]);
62
+ }
63
+ const preset = (opts.preset ?? "conservative");
64
+ if (!PRESET_NAMES.includes(preset)) {
65
+ return fail([`unknown preset "${preset}" (allowed: ${PRESET_NAMES.join(", ")})`]);
66
+ }
67
+ const dir = resolvePath(targetPath);
68
+ if (!dir || dir === resolvePath("."))
69
+ return fail(["provide a target directory name"]);
70
+ if (existsSync(dir))
71
+ return fail([`refusing to overwrite existing path: ${dir}`]);
72
+ const name = basename(dir);
73
+ mkdirSync(dir, { recursive: true });
74
+ writeFileSync(join(dir, "agent.md"), renderFolderOfOne(name, preset), "utf8");
75
+ return {
76
+ ok: true,
77
+ code: 0,
78
+ lines: [
79
+ `created ${join(dir, "agent.md")} (template=${template}, preset=${preset})`,
80
+ `next: coinrithm-agent validate "${dir}"`,
81
+ ],
82
+ };
83
+ }
84
+ export function cmdValidate(path, mode = "self-host") {
85
+ let resolved;
86
+ try {
87
+ resolved = resolveAgent(path);
88
+ }
89
+ catch (e) {
90
+ if (e instanceof ResolveError)
91
+ return issuesResult(e.issues, "resolve failed");
92
+ throw e;
93
+ }
94
+ const raw = resolved.rawFrontmatter;
95
+ const spec = buildSpec(raw);
96
+ const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
97
+ const v = validateSkill({ spec, body: resolved.mergedProse, raw }, mode);
98
+ const lintFatal = mode === "hosted";
99
+ const lines = [];
100
+ for (const i of lint) {
101
+ lines.push(`${lintFatal ? "✗" : "⚠"} ${i.code}${i.path ? ` (${i.path})` : ""}: ${i.message}`);
102
+ }
103
+ for (const i of v.issues)
104
+ lines.push(`✗ ${i.code}: ${i.reason}`);
105
+ lines.push(...pinWarnings(path));
106
+ const ok = v.valid && (!lintFatal || lint.length === 0);
107
+ lines.unshift(ok ? `✓ valid (${mode})` : `✗ invalid (${mode})`);
108
+ return { ok, code: ok ? 0 : 1, lines, data: { lint, validation: v } };
109
+ }
110
+ export function cmdLock(path) {
111
+ const v = cmdValidate(path, "self-host");
112
+ if (!v.ok)
113
+ return { ...v, lines: ["refusing to lock an invalid agent:", ...v.lines] };
114
+ const resolved = resolveAgent(path);
115
+ const spec = buildSpec(resolved.rawFrontmatter);
116
+ const manifest = buildManifest(resolved, spec);
117
+ const out = writeManifest(agentDirOf(path), manifest);
118
+ // Self-host treats capability drift as advisory (not fatal), but locking past
119
+ // it silently would hide it — surface it so the author isn't surprised when
120
+ // `validate --hosted` later rejects the same folder.
121
+ const drift = (v.data?.lint ?? []).filter((i) => i.code.startsWith("drift_"));
122
+ const warn = drift.length
123
+ ? [
124
+ `⚠ locked with ${drift.length} advisory capability-drift note(s) — \`validate --hosted\` would reject these:`,
125
+ ...drift.map((i) => ` [${i.code}] ${i.path ? `${i.path}: ` : ""}${i.message}`),
126
+ ]
127
+ : [];
128
+ return { ok: true, code: 0, lines: [`wrote ${out}`, `configHash ${manifest.configHash}`, ...warn] };
129
+ }
130
+ export function cmdEject(path) {
131
+ const agentDir = agentDirOf(path);
132
+ const abs = resolvePath(path);
133
+ const keystone = existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "agent.md") : abs;
134
+ if (!existsSync(keystone))
135
+ return fail([`no agent.md at ${keystone}`]);
136
+ const { data: fm, body } = parseFrontmatter(readFileSync(keystone, "utf8"));
137
+ if (Array.isArray(fm.extends)) {
138
+ return fail(["agent already uses `extends` (already ejected?) — nothing to do"]);
139
+ }
140
+ const before = buildSpec(fm);
141
+ const { files } = ejectFiles(fm, body);
142
+ for (const [rel, content] of Object.entries(files)) {
143
+ const p = join(agentDir, rel);
144
+ mkdirSync(dirname(p), { recursive: true });
145
+ writeFileSync(p, content, "utf8");
146
+ }
147
+ let after;
148
+ try {
149
+ after = buildSpec(resolveAgent(agentDir).rawFrontmatter);
150
+ }
151
+ catch (e) {
152
+ return fail([`ejected folder failed to re-resolve: ${e.message}`]);
153
+ }
154
+ const same = stableStringify(before) === stableStringify(after);
155
+ const lines = [
156
+ `ejected into ${agentDir}`,
157
+ ...Object.keys(files).map((f) => ` + ${f}`),
158
+ same ? "✓ resolved spec unchanged" : "✗ WARNING: resolved spec CHANGED after eject",
159
+ ];
160
+ return { ok: same, code: same ? 0 : 1, lines };
161
+ }
162
+ export function cmdInspect(path, json = false) {
163
+ let resolved;
164
+ try {
165
+ resolved = resolveAgent(path);
166
+ }
167
+ catch (e) {
168
+ if (e instanceof ResolveError)
169
+ return issuesResult(e.issues, "resolve failed");
170
+ throw e;
171
+ }
172
+ const spec = buildSpec(resolved.rawFrontmatter);
173
+ const lint = [...strictLint(resolved.rawFrontmatter), ...checkCapabilityDrift(resolved, spec)];
174
+ const v = validateSkill({ spec, body: resolved.mergedProse, raw: resolved.rawFrontmatter }, "self-host");
175
+ const output = {
176
+ resolvedConfig: resolved.rawFrontmatter,
177
+ provenance: resolved.provenance,
178
+ contentHashes: resolved.contentHashes,
179
+ validation: { valid: v.valid, issues: v.issues, lint },
180
+ };
181
+ if (json) {
182
+ return { ok: v.valid, code: 0, lines: [JSON.stringify(output, null, 2)], data: output };
183
+ }
184
+ const lines = [
185
+ `name: ${spec.name}`,
186
+ `venues: ${spec.venues.join(", ")}`,
187
+ `cadence: ${spec.trigger.cadence}`,
188
+ `model: ${spec.model ? `${spec.model.provider}/${spec.model.name}` : "(host free-tier)"}`,
189
+ `risk: maxLeverage=${spec.risk.maxLeverage} perTradeMargin=${spec.risk.perTradeMarginMusd} requireStopLoss=${spec.risk.requireStopLoss}`,
190
+ `sources: ${Object.keys(resolved.contentHashes).length} file(s)`,
191
+ `validation: ${v.valid ? "valid" : "INVALID"}${lint.length ? ` (+${lint.length} lint note(s))` : ""}`,
192
+ ];
193
+ return { ok: v.valid, code: 0, lines, data: output };
194
+ }
195
+ // Acquire an exclusive per-agent run lock (O_EXCL). Returns a release fn, or
196
+ // null if another runner already holds it — so two runners can't race one
197
+ // state file and bypass the daily / write caps.
198
+ function acquireLock(stateFile) {
199
+ const lock = `${stateFile}.lock`;
200
+ let fd;
201
+ try {
202
+ fd = openSync(lock, "wx");
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ try {
208
+ writeFileSync(fd, JSON.stringify({ pid: process.pid }));
209
+ }
210
+ catch {
211
+ /* best effort */
212
+ }
213
+ return () => {
214
+ try {
215
+ closeSync(fd);
216
+ }
217
+ catch {
218
+ /* ignore */
219
+ }
220
+ try {
221
+ unlinkSync(lock);
222
+ }
223
+ catch {
224
+ /* ignore */
225
+ }
226
+ };
227
+ }
228
+ // Run the agent locally (self-host). Dry-run by default; --live (or LIVE=1)
229
+ // places paper trades. Reads COINRITHM_API_KEY + the model key from the ENV.
230
+ export async function cmdRun(path, opts = {}) {
231
+ let loaded;
232
+ try {
233
+ loaded = loadAgent(path, "self-host");
234
+ }
235
+ catch (e) {
236
+ if (e instanceof ResolveError)
237
+ return issuesResult(e.issues, "resolve failed");
238
+ throw e;
239
+ }
240
+ const apiKey = process.env.COINRITHM_API_KEY;
241
+ if (!apiKey)
242
+ return fail(["COINRITHM_API_KEY is not set (needed to read your paper account)"]);
243
+ let provider;
244
+ try {
245
+ provider = selectProvider(loaded.spec, process.env, fetch);
246
+ }
247
+ catch (e) {
248
+ return fail([e.message]);
249
+ }
250
+ const client = new CoinRithmClient({ apiKey, baseUrl: process.env.COINRITHM_API_URL });
251
+ const stateFile = opts.stateFile ?? join(agentDirOf(path), ".agent.state.json");
252
+ const release = acquireLock(stateFile);
253
+ if (!release) {
254
+ return fail([`another runner holds ${stateFile}.lock — only one runner per agent at a time`]);
255
+ }
256
+ try {
257
+ let state;
258
+ try {
259
+ state = loadState(stateFile, makeRunId(loaded.spec));
260
+ }
261
+ catch (e) {
262
+ // Corrupt state is fail-closed: refuse to run rather than reset guards.
263
+ return fail([e.message]);
264
+ }
265
+ if (state.disabled) {
266
+ return fail([`agent is disabled: ${state.disabledReason ?? "kill-switch"} — clear ${stateFile} to reset`]);
267
+ }
268
+ const live = !!opts.live;
269
+ const lines = [
270
+ `run ${live ? "LIVE (paper trades WILL be placed)" : "DRY-RUN (no writes; set --live or LIVE=1)"} — ${loaded.spec.name}`,
271
+ ];
272
+ // Skills ablation kill-switch: drop tactic-skill prose from the prompt for
273
+ // token-cost control or A/B testing. Affects ONLY the run-time prompt — the
274
+ // resolver, manifest, and caps are untouched (the spec is still enforced).
275
+ const disableSkills = envFlag(process.env.COINRITHM_AGENT_DISABLE_SKILLS);
276
+ const mergedProse = disableSkills
277
+ ? mergeProseParts(loaded.resolved.proseParts.filter((p) => !isSkillProseSource(p.source)))
278
+ : loaded.body;
279
+ if (disableSkills) {
280
+ const dropped = loaded.resolved.proseParts.filter((p) => isSkillProseSource(p.source)).length;
281
+ lines.push(`skills DISABLED via COINRITHM_AGENT_DISABLE_SKILLS — ${dropped} tactic skill(s) dropped from the prompt (caps unchanged)`);
282
+ }
283
+ const deps = {
284
+ client,
285
+ provider,
286
+ spec: loaded.spec,
287
+ mergedProse,
288
+ state,
289
+ live,
290
+ stateFile,
291
+ log: (l) => lines.push(l),
292
+ };
293
+ const results = await runLoop(deps, { once: opts.once });
294
+ saveState(stateFile, state);
295
+ const wrote = results.some((res) => res.planned.some((p) => p.executed));
296
+ lines.push(`done: ${results.length} cycle(s)${wrote ? "" : ", no writes"}`);
297
+ return { ok: true, code: 0, lines, data: results };
298
+ }
299
+ finally {
300
+ release();
301
+ }
302
+ }
303
+ function parseFlags(args) {
304
+ const out = { _: [] };
305
+ for (let i = 0; i < args.length; i++) {
306
+ const a = args[i];
307
+ if (a === "--hosted")
308
+ out.hosted = true;
309
+ else if (a === "--self-host")
310
+ out.hosted = false;
311
+ else if (a === "--json")
312
+ out.json = true;
313
+ else if (a === "--once")
314
+ out.once = true;
315
+ else if (a === "--live")
316
+ out.live = true;
317
+ else if (a === "--dry-run")
318
+ out.dryRun = true;
319
+ else if (a === "--template")
320
+ out.template = args[++i];
321
+ else if (a === "--preset")
322
+ out.preset = args[++i];
323
+ else if (a === "--state")
324
+ out.state = args[++i];
325
+ else
326
+ out._.push(a);
327
+ }
328
+ return out;
329
+ }
330
+ function usageLines() {
331
+ return [
332
+ "coinrithm-agent — author + run CoinRithm paper-trading agents (simulated funds only)",
333
+ " new <dir> --template momentum-futures --preset conservative|balanced|bold",
334
+ " validate <path> [--hosted | --self-host]",
335
+ " inspect <path> [--json]",
336
+ " eject <agent.md | dir>",
337
+ " lock <path>",
338
+ " run <path> [--once] [--live] [--dry-run] [--state <file>] (dry-run by default)",
339
+ ];
340
+ }
341
+ export async function main(argv) {
342
+ const [cmd, ...rest] = argv;
343
+ const flags = parseFlags(rest);
344
+ const pos = flags._;
345
+ let r;
346
+ switch (cmd) {
347
+ case "new":
348
+ r = cmdNew(pos[0] ?? "", { template: flags.template, preset: flags.preset });
349
+ break;
350
+ case "validate":
351
+ r = cmdValidate(pos[0] ?? ".", flags.hosted ? "hosted" : "self-host");
352
+ break;
353
+ case "lock":
354
+ r = cmdLock(pos[0] ?? ".");
355
+ break;
356
+ case "eject":
357
+ r = cmdEject(pos[0] ?? ".");
358
+ break;
359
+ case "inspect":
360
+ r = cmdInspect(pos[0] ?? ".", !!flags.json);
361
+ break;
362
+ case "run": {
363
+ // dry-run is the default; only --live (or LIVE=1) AND not --dry-run trades.
364
+ const live = (!!flags.live || process.env.LIVE === "1") && !flags.dryRun;
365
+ r = await cmdRun(pos[0] ?? ".", { once: flags.once, live, stateFile: flags.state });
366
+ break;
367
+ }
368
+ case undefined:
369
+ case "help":
370
+ case "--help":
371
+ case "-h":
372
+ r = { ok: true, code: 0, lines: usageLines() };
373
+ break;
374
+ default:
375
+ r = { ok: false, code: 1, lines: [`unknown command "${cmd}"`, ...usageLines()] };
376
+ }
377
+ for (const line of r.lines) {
378
+ // eslint-disable-next-line no-console
379
+ console.log(line);
380
+ }
381
+ return r.code;
382
+ }
@@ -0,0 +1,107 @@
1
+ import { AgentTrace, ApiResult } from "./types.js";
2
+ export declare const DEFAULT_BASE_URL = "https://api.coinrithm.com";
3
+ export interface ClientConfig {
4
+ apiKey: string;
5
+ baseUrl?: string;
6
+ fetchFn?: typeof fetch;
7
+ sleepFn?: (ms: number) => Promise<void>;
8
+ maxRetries?: number;
9
+ }
10
+ export declare class CoinRithmClient {
11
+ private readonly apiKey;
12
+ private readonly baseUrl;
13
+ private readonly fetchFn;
14
+ private readonly sleepFn;
15
+ private readonly maxRetries;
16
+ rateLimitHits: number;
17
+ constructor(cfg: ClientConfig);
18
+ private request;
19
+ me(trace?: AgentTrace): Promise<ApiResult>;
20
+ portfolio(trace?: AgentTrace): Promise<ApiResult>;
21
+ wallet(query?: {
22
+ coinId?: string;
23
+ }, trace?: AgentTrace): Promise<ApiResult>;
24
+ resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
25
+ market(coinId: string, trace?: AgentTrace): Promise<ApiResult>;
26
+ candles(coinId: string, range: string, trace?: AgentTrace): Promise<ApiResult>;
27
+ trades(query?: {
28
+ venue?: string;
29
+ limit?: number;
30
+ updatedSince?: string;
31
+ }, trace?: AgentTrace): Promise<ApiResult>;
32
+ futuresPositions(query?: {
33
+ updatedSince?: string;
34
+ }, trace?: AgentTrace): Promise<ApiResult>;
35
+ futuresQuote(body: {
36
+ coinId: string;
37
+ side: string;
38
+ leverage: number;
39
+ marginMusd: number;
40
+ }, trace?: AgentTrace): Promise<ApiResult>;
41
+ openOrders(query?: {
42
+ coinId?: string;
43
+ updatedSince?: string;
44
+ }, trace?: AgentTrace): Promise<ApiResult>;
45
+ spotQuote(body: {
46
+ coinId: string;
47
+ side: string;
48
+ quantity: number;
49
+ }, trace?: AgentTrace): Promise<ApiResult>;
50
+ discoverPmMarkets(query?: {
51
+ q?: string;
52
+ source?: string;
53
+ limit?: number;
54
+ }, trace?: AgentTrace): Promise<ApiResult>;
55
+ pmPositions(query?: {
56
+ updatedSince?: string;
57
+ }, trace?: AgentTrace): Promise<ApiResult>;
58
+ pmQuote(body: {
59
+ source: string;
60
+ slug: string;
61
+ outcomeExternalMarketId: string;
62
+ stakeMusd: number;
63
+ }, trace?: AgentTrace): Promise<ApiResult>;
64
+ openFutures(body: {
65
+ coinId: string;
66
+ side: string;
67
+ leverage: number;
68
+ marginMusd: number;
69
+ idempotencyKey: string;
70
+ stopLossPrice?: number | null;
71
+ takeProfitPrice?: number | null;
72
+ agentTrace?: AgentTrace;
73
+ }): Promise<ApiResult>;
74
+ closeFutures(body: {
75
+ positionId: number;
76
+ fraction?: number;
77
+ idempotencyKey: string;
78
+ agentTrace?: AgentTrace;
79
+ }): Promise<ApiResult>;
80
+ setFuturesSlTp(body: {
81
+ positionId: number;
82
+ stopLossPrice?: number | null;
83
+ takeProfitPrice?: number | null;
84
+ agentTrace?: AgentTrace;
85
+ }): Promise<ApiResult>;
86
+ placeSpotOrder(body: {
87
+ coinId: string;
88
+ side: string;
89
+ orderType: string;
90
+ quantity: number;
91
+ limitPrice?: number;
92
+ stopPrice?: number;
93
+ idempotencyKey: string;
94
+ agentTrace?: AgentTrace;
95
+ }): Promise<ApiResult>;
96
+ cancelSpotOrder(orderId: number, trace?: AgentTrace): Promise<ApiResult>;
97
+ openPmPosition(body: {
98
+ source: string;
99
+ slug: string;
100
+ outcomeExternalMarketId: string;
101
+ stakeMusd: number;
102
+ idempotencyKey: string;
103
+ agentTrace?: AgentTrace;
104
+ }): Promise<ApiResult>;
105
+ exportRunEvidence(runId: string): Promise<ApiResult>;
106
+ }
107
+ export declare function isFailClosed(status: number): boolean;
@@ -0,0 +1,173 @@
1
+ // Thin CoinRithm agent-API client for the runner (futures-focused v1).
2
+ //
3
+ // Auth: the user's own crk_live_ key from COINRITHM_API_KEY (env only — never
4
+ // from an agent file). 429 backs off on Retry-After; 401/403/409/422 are
5
+ // FAIL-CLOSED cycle outcomes (returned, not retried). fetch + sleep are
6
+ // injectable so tests run with no network and no real waits.
7
+ import { sleep as realSleep } from "./util.js";
8
+ export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
9
+ function traceHeaders(trace) {
10
+ const h = {};
11
+ if (!trace)
12
+ return h;
13
+ if (trace.runId)
14
+ h["X-CoinRithm-Run-Id"] = trace.runId;
15
+ if (trace.decisionId)
16
+ h["X-CoinRithm-Decision-Id"] = trace.decisionId;
17
+ if (trace.strategyLabel)
18
+ h["X-CoinRithm-Strategy-Label"] = trace.strategyLabel;
19
+ if (typeof trace.confidence === "number")
20
+ h["X-CoinRithm-Confidence"] = String(trace.confidence);
21
+ return h;
22
+ }
23
+ export class CoinRithmClient {
24
+ apiKey;
25
+ baseUrl;
26
+ fetchFn;
27
+ sleepFn;
28
+ maxRetries;
29
+ // Every 429 seen this session (read or write, retried or not) — feeds the
30
+ // rate-limit-pressure kill-switch, which a write-only counter would miss.
31
+ rateLimitHits = 0;
32
+ constructor(cfg) {
33
+ this.apiKey = cfg.apiKey;
34
+ this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
35
+ this.fetchFn = cfg.fetchFn ?? fetch;
36
+ this.sleepFn = cfg.sleepFn ?? realSleep;
37
+ this.maxRetries = cfg.maxRetries ?? 3;
38
+ }
39
+ async request(method, path, opts = {}) {
40
+ const url = new URL(this.baseUrl + path);
41
+ if (opts.query) {
42
+ for (const [k, v] of Object.entries(opts.query)) {
43
+ if (v !== undefined && v !== null && v !== "")
44
+ url.searchParams.set(k, String(v));
45
+ }
46
+ }
47
+ const headers = {
48
+ Authorization: `Bearer ${this.apiKey}`,
49
+ Accept: "application/json",
50
+ ...traceHeaders(opts.trace),
51
+ };
52
+ if (opts.body !== undefined)
53
+ headers["Content-Type"] = "application/json";
54
+ for (let attempt = 0;; attempt++) {
55
+ let res;
56
+ try {
57
+ res = await this.fetchFn(url.toString(), {
58
+ method,
59
+ headers,
60
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
61
+ });
62
+ }
63
+ catch (err) {
64
+ return {
65
+ ok: false,
66
+ status: 0,
67
+ data: { error: "network_error", message: err instanceof Error ? err.message : String(err) },
68
+ };
69
+ }
70
+ const retryAfter = Number(res.headers.get("retry-after"));
71
+ if (res.status === 429)
72
+ this.rateLimitHits += 1;
73
+ if (res.status === 429 && attempt < this.maxRetries) {
74
+ await this.sleepFn((Number.isFinite(retryAfter) ? retryAfter : 5) * 1000);
75
+ continue;
76
+ }
77
+ const text = await res.text();
78
+ let data = text;
79
+ if (text) {
80
+ try {
81
+ data = JSON.parse(text);
82
+ }
83
+ catch {
84
+ /* leave as text */
85
+ }
86
+ }
87
+ return {
88
+ ok: res.ok,
89
+ status: res.status,
90
+ data,
91
+ retryAfterSeconds: res.status === 429 && Number.isFinite(retryAfter) ? retryAfter : undefined,
92
+ rateLimitRemaining: Number(res.headers.get("ratelimit-remaining")) || undefined,
93
+ ledgerEventId: res.headers.get("x-coinrithm-ledger-event-id"),
94
+ };
95
+ }
96
+ }
97
+ // ── reads ──────────────────────────────────────────────────────────────────
98
+ me(trace) {
99
+ return this.request("GET", "/api/agent/me", { trace });
100
+ }
101
+ portfolio(trace) {
102
+ return this.request("GET", "/api/agent/portfolio", { trace });
103
+ }
104
+ wallet(query, trace) {
105
+ return this.request("GET", "/api/agent/wallet", { query, trace });
106
+ }
107
+ resolve(q, trace) {
108
+ return this.request("GET", "/api/agent/resolve", { query: { q }, trace });
109
+ }
110
+ market(coinId, trace) {
111
+ return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}`, { trace });
112
+ }
113
+ candles(coinId, range, trace) {
114
+ return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}/candles`, {
115
+ query: { range },
116
+ trace,
117
+ });
118
+ }
119
+ trades(query, trace) {
120
+ return this.request("GET", "/api/agent/trades", { query, trace });
121
+ }
122
+ futuresPositions(query, trace) {
123
+ return this.request("GET", "/api/agent/positions/futures", { query, trace });
124
+ }
125
+ futuresQuote(body, trace) {
126
+ return this.request("POST", "/api/agent/futures/quote", { body: { ...body, agentTrace: trace } });
127
+ }
128
+ // ── spot ─────────────────────────────────────────────────────────────────
129
+ openOrders(query, trace) {
130
+ return this.request("GET", "/api/agent/orders/open", { query, trace });
131
+ }
132
+ spotQuote(body, trace) {
133
+ return this.request("POST", "/api/agent/spot/quote", { body: { ...body, agentTrace: trace } });
134
+ }
135
+ // ── prediction markets ───────────────────────────────────────────────────
136
+ discoverPmMarkets(query, trace) {
137
+ return this.request("GET", "/api/agent/pm/discover", { query, trace });
138
+ }
139
+ pmPositions(query, trace) {
140
+ return this.request("GET", "/api/agent/positions/pm", { query, trace });
141
+ }
142
+ pmQuote(body, trace) {
143
+ return this.request("POST", "/api/agent/pm/quote", { body: { ...body, agentTrace: trace } });
144
+ }
145
+ // ── writes ─────────────────────────────────────────────────────────────────
146
+ openFutures(body) {
147
+ return this.request("POST", "/api/agent/futures/open", { body });
148
+ }
149
+ closeFutures(body) {
150
+ return this.request("POST", "/api/agent/futures/close", { body });
151
+ }
152
+ setFuturesSlTp(body) {
153
+ return this.request("POST", "/api/agent/futures/sl-tp", { body });
154
+ }
155
+ placeSpotOrder(body) {
156
+ return this.request("POST", "/api/agent/spot/order", { body });
157
+ }
158
+ cancelSpotOrder(orderId, trace) {
159
+ return this.request("POST", `/api/agent/spot/order/${orderId}/cancel`, { trace });
160
+ }
161
+ openPmPosition(body) {
162
+ return this.request("POST", "/api/agent/pm/open", { body });
163
+ }
164
+ // Run-evidence export — runId is URL-encoded into the query.
165
+ exportRunEvidence(runId) {
166
+ return this.request("GET", "/api/agent/ledger/export", { query: { runId } });
167
+ }
168
+ }
169
+ // 401/403/409/422 are terminal, fail-closed outcomes for a cycle (auth/scope,
170
+ // conflict, or a risk-gate rejection) — never retried as if transient.
171
+ export function isFailClosed(status) {
172
+ return status === 401 || status === 403 || status === 409 || status === 422;
173
+ }