@coinrithm/mcp-trading 0.1.8 → 0.2.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.
package/README.md CHANGED
@@ -1,7 +1,12 @@
1
1
  # @coinrithm/mcp-trading
2
2
 
3
- An MCP server that lets an AI agent paper-trade on CoinRithm (spot, futures,
4
- prediction markets) using a personal API key.
3
+ CoinRithm paper-trading toolkit. Two binaries:
4
+
5
+ - **`coinrithm-mcp`** — an MCP server that lets an AI agent paper-trade on
6
+ CoinRithm (spot, futures, prediction markets) using a personal API key.
7
+ - **`coinrithm-agent`** — a self-host **agent runner**: author an agent as a
8
+ folder and run an observe→decide→validate→act loop with your own model key
9
+ (paper, futures v1). See [Agent runner](#agent-runner-coinrithm-agent) below.
5
10
 
6
11
  > **Paper trading only** — virtual funds (50,000 mUSD). Not financial advice.
7
12
 
@@ -12,6 +17,25 @@ npm install
12
17
  npm run build
13
18
  ```
14
19
 
20
+ ## Agent runner (`coinrithm-agent`)
21
+
22
+ This package also ships a **self-host agent runner**. You write an agent as a
23
+ folder (strategy + hard caps in markdown/YAML); the runner compiles it and runs
24
+ an `observe → decide → validate → act` loop, asking *your* model (bring-your-own
25
+ key) for structured decisions and executing only the ones that pass your caps —
26
+ **dry-run by default**, paper-only, futures in v1.
27
+
28
+ ```bash
29
+ coinrithm-agent new my-agent --preset conservative
30
+ coinrithm-agent validate my-agent
31
+ COINRITHM_API_KEY=crk_live_… ANTHROPIC_API_KEY=sk-ant-… \
32
+ coinrithm-agent run my-agent --once --dry-run
33
+ ```
34
+
35
+ Full guide (env vars, fail-closed guarantees, folder layout):
36
+ **[docs/agent-runner.md](../../docs/agent-runner.md)**. The CoinRithm hosted
37
+ scheduler runs this same engine for you (later).
38
+
15
39
  ## Two ways to run
16
40
 
17
41
  | Mode | Entry | Auth | Who it's for |
@@ -59,11 +83,11 @@ key upstream. See [`DEPLOY.md`](./DEPLOY.md).
59
83
  | `get_market_context` (coinId) | read | `GET /api/agent/market/:coinId` |
60
84
  | `get_candles` (coinId, range) | read | `GET /api/agent/market/:coinId/candles` |
61
85
  | `discover_pm_markets` | read | `GET /api/agent/pm/discover` |
62
- | `get_performance` | read | `GET /api/agent/performance` |
63
- | `get_agent_ledger` | read | `GET /api/agent/ledger` |
64
- | `export_agent_ledger` | read | `GET /api/agent/ledger/export` |
65
- | `export_run_evidence` | read | `GET /api/agent/ledger/export?runId=...` |
66
- | `get_arena_leaderboard` | read | `GET /api/arena` |
86
+ | `get_performance` | read | `GET /api/agent/performance` |
87
+ | `get_agent_ledger` | read | `GET /api/agent/ledger` |
88
+ | `export_agent_ledger` | read | `GET /api/agent/ledger/export` |
89
+ | `export_run_evidence` | read | `GET /api/agent/ledger/export?runId=...` |
90
+ | `get_arena_leaderboard` | read | `GET /api/arena` |
67
91
  | `get_arena_agent` (handle) | read | `GET /api/arena/:handle` |
68
92
  | `list_open_orders` | read | `GET /api/agent/orders/open` |
69
93
  | `get_positions` (venue) | read | `GET /api/agent/positions/{futures,pm}` |
@@ -125,18 +149,18 @@ account only, latest stored market/probability snapshots, no explicit
125
149
  commission/slippage in v1, no futures funding/fees, and worker-driven resting
126
150
  order / SL / TP / settlement timing. It is a reproducibility artifact for your
127
151
  run; it is not a full point-in-time market archive and does not expose hidden
128
- reasoning. Aggregate audit stats include trace coverage for `runId` and
129
- `decisionId`. Run exports also include `retentionPolicy`: private ledger rows
130
- use a rolling retention window and exports are capped. They include
131
- `evidenceChecklist`, a derived pass/warn/fail checklist for trace completeness,
132
- decision ids, quote-before-trade coverage, rejected calls, export truncation,
133
- execution assumptions, and outcome attribution; it does not create additional
134
- retained data. `outcomeSummary` derives best-effort realized PnL from existing
135
- related trade/position ids, and spot orders can also match through their
136
- idempotency keys once a terminal `ClosedOrder` exists. It reports whether
137
- coverage is `none`, `partial`, or `complete`; it does not store new data. Public
138
- Arena surfaces only aggregate audit stats; raw request logs and rationale
139
- summaries stay private.
152
+ reasoning. Aggregate audit stats include trace coverage for `runId` and
153
+ `decisionId`. Run exports also include `retentionPolicy`: private ledger rows
154
+ use a rolling retention window and exports are capped. They include
155
+ `evidenceChecklist`, a derived pass/warn/fail checklist for trace completeness,
156
+ decision ids, quote-before-trade coverage, rejected calls, export truncation,
157
+ execution assumptions, and outcome attribution; it does not create additional
158
+ retained data. `outcomeSummary` derives best-effort realized PnL from existing
159
+ related trade/position ids, and spot orders can also match through their
160
+ idempotency keys once a terminal `ClosedOrder` exists. It reports whether
161
+ coverage is `none`, `partial`, or `complete`; it does not store new data. Public
162
+ Arena surfaces only aggregate audit stats; raw request logs and rationale
163
+ summaries stay private.
140
164
 
141
165
  `get_my_trades`, `list_open_orders`, and `get_positions` accept an optional
142
166
  `updatedSince` cursor and their responses carry `asOf` — pass it back to poll
@@ -0,0 +1,65 @@
1
+ // Act phase: fetch the quote evidence for an open (the runner does this, never
2
+ // the model) and execute a validated futures action with an idempotency key.
3
+ import { asObj, asNum, asStr } from "./extract.js";
4
+ function coinIdFor(observation, symbol) {
5
+ return observation.watch.find((w) => w.symbol.toUpperCase() === symbol.toUpperCase())?.coinId ?? undefined;
6
+ }
7
+ function freshnessOf(block) {
8
+ const fr = asObj(block.freshness);
9
+ const status = asStr(fr.status);
10
+ return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
11
+ }
12
+ // Read-only quote BEFORE any open. Returns ineligible (never throws) on error.
13
+ export async function fetchQuote(client, action, observation, trace) {
14
+ if (action.type !== "futures_open")
15
+ return undefined;
16
+ const coinId = coinIdFor(observation, action.symbol);
17
+ if (!coinId)
18
+ return { eligible: false, blockReasons: ["unresolved_symbol"] };
19
+ const r = await client.futuresQuote({ coinId, side: action.side, leverage: action.leverage, marginMusd: action.marginMusd }, trace);
20
+ if (!r.ok)
21
+ return { eligible: false, blockReasons: [`quote_http_${r.status}`] };
22
+ const d = asObj(r.data);
23
+ return {
24
+ eligible: d.eligible === true,
25
+ blockReasons: d.blockReasons,
26
+ entryPrice: asNum(d.entryPrice),
27
+ liquidationPrice: asNum(d.liquidationPrice),
28
+ // Freshness lives in the response's `observation` block (anti-look-ahead).
29
+ freshness: freshnessOf(asObj(d.observation)),
30
+ };
31
+ }
32
+ export async function executeAction(client, action, observation, trace, idempotencyKey) {
33
+ if (action.type === "futures_open") {
34
+ const coinId = coinIdFor(observation, action.symbol);
35
+ if (!coinId)
36
+ return { ok: false, status: 0, data: { error: "unresolved_symbol" } };
37
+ return client.openFutures({
38
+ coinId,
39
+ side: action.side,
40
+ leverage: action.leverage,
41
+ marginMusd: action.marginMusd,
42
+ idempotencyKey,
43
+ stopLossPrice: action.stopLossPrice ?? null,
44
+ takeProfitPrice: action.takeProfitPrice ?? null,
45
+ agentTrace: trace,
46
+ });
47
+ }
48
+ if (action.type === "futures_close") {
49
+ return client.closeFutures({
50
+ positionId: action.positionId,
51
+ fraction: action.fraction,
52
+ idempotencyKey,
53
+ agentTrace: trace,
54
+ });
55
+ }
56
+ if (action.type === "futures_set_sltp") {
57
+ return client.setFuturesSlTp({
58
+ positionId: action.positionId,
59
+ stopLossPrice: action.stopLossPrice ?? undefined,
60
+ takeProfitPrice: action.takeProfitPrice ?? undefined,
61
+ agentTrace: trace,
62
+ });
63
+ }
64
+ return { ok: false, status: 0, data: { error: "unsupported_action" } };
65
+ }
@@ -0,0 +1,360 @@
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 } from "./resolve.js";
11
+ import { buildSpec, loadAgent } from "./skill.js";
12
+ import { validateSkill } from "./skillValidator.js";
13
+ import { strictLint } from "./strictLint.js";
14
+ import { buildManifest, writeManifest } from "./manifest.js";
15
+ import { parseFrontmatter } from "./frontmatter.js";
16
+ import { renderFolderOfOne, ejectFiles, PRESET_NAMES } from "./templates.js";
17
+ import { COINRITHM_API } from "./version.js";
18
+ import { stableStringify } from "./util.js";
19
+ import { CoinRithmClient } from "./client.js";
20
+ import { selectProvider } from "./providers.js";
21
+ import { runLoop } from "./runner.js";
22
+ import { loadState, saveState } from "./state.js";
23
+ import { makeRunId } from "./runEvidence.js";
24
+ const fail = (lines) => ({ ok: false, code: 1, lines });
25
+ function issuesResult(issues, header) {
26
+ return {
27
+ ok: false,
28
+ code: 1,
29
+ lines: [
30
+ `✗ ${header}`,
31
+ ...issues.map((i) => ` [${i.code}] ${i.path ? `${i.path}: ` : ""}${i.message}`),
32
+ ],
33
+ };
34
+ }
35
+ function agentDirOf(path) {
36
+ const abs = resolvePath(path);
37
+ return existsSync(abs) && statSync(abs).isDirectory() ? abs : dirname(abs);
38
+ }
39
+ function pinWarnings(path) {
40
+ try {
41
+ const pin = join(agentDirOf(path), "functionality", "coinrithm.yaml");
42
+ if (!existsSync(pin))
43
+ return [];
44
+ const parsed = parseYaml(readFileSync(pin, "utf8"));
45
+ const v = parsed?.api?.openapiVersion;
46
+ if (v && v !== COINRITHM_API.openapiVersion) {
47
+ return [
48
+ `⚠ functionality/coinrithm.yaml pins API ${v}; current is ${COINRITHM_API.openapiVersion} (warning only, not a block)`,
49
+ ];
50
+ }
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ return [];
56
+ }
57
+ export function cmdNew(targetPath, opts = {}) {
58
+ const template = opts.template ?? "momentum-futures";
59
+ if (template !== "momentum-futures") {
60
+ return fail([`unknown template "${template}" (only: momentum-futures)`]);
61
+ }
62
+ const preset = (opts.preset ?? "conservative");
63
+ if (!PRESET_NAMES.includes(preset)) {
64
+ return fail([`unknown preset "${preset}" (allowed: ${PRESET_NAMES.join(", ")})`]);
65
+ }
66
+ const dir = resolvePath(targetPath);
67
+ if (!dir || dir === resolvePath("."))
68
+ return fail(["provide a target directory name"]);
69
+ if (existsSync(dir))
70
+ return fail([`refusing to overwrite existing path: ${dir}`]);
71
+ const name = basename(dir);
72
+ mkdirSync(dir, { recursive: true });
73
+ writeFileSync(join(dir, "agent.md"), renderFolderOfOne(name, preset), "utf8");
74
+ return {
75
+ ok: true,
76
+ code: 0,
77
+ lines: [
78
+ `created ${join(dir, "agent.md")} (template=${template}, preset=${preset})`,
79
+ `next: coinrithm-agent validate "${dir}"`,
80
+ ],
81
+ };
82
+ }
83
+ export function cmdValidate(path, mode = "self-host") {
84
+ let resolved;
85
+ try {
86
+ resolved = resolveAgent(path);
87
+ }
88
+ catch (e) {
89
+ if (e instanceof ResolveError)
90
+ return issuesResult(e.issues, "resolve failed");
91
+ throw e;
92
+ }
93
+ const raw = resolved.rawFrontmatter;
94
+ const lint = strictLint(raw);
95
+ const spec = buildSpec(raw);
96
+ const v = validateSkill({ spec, body: resolved.mergedProse, raw }, mode);
97
+ const lintFatal = mode === "hosted";
98
+ const lines = [];
99
+ for (const i of lint) {
100
+ lines.push(`${lintFatal ? "✗" : "⚠"} ${i.code}${i.path ? ` (${i.path})` : ""}: ${i.message}`);
101
+ }
102
+ for (const i of v.issues)
103
+ lines.push(`✗ ${i.code}: ${i.reason}`);
104
+ lines.push(...pinWarnings(path));
105
+ const ok = v.valid && (!lintFatal || lint.length === 0);
106
+ lines.unshift(ok ? `✓ valid (${mode})` : `✗ invalid (${mode})`);
107
+ return { ok, code: ok ? 0 : 1, lines, data: { lint, validation: v } };
108
+ }
109
+ export function cmdLock(path) {
110
+ const v = cmdValidate(path, "self-host");
111
+ if (!v.ok)
112
+ return { ...v, lines: ["refusing to lock an invalid agent:", ...v.lines] };
113
+ const resolved = resolveAgent(path);
114
+ const spec = buildSpec(resolved.rawFrontmatter);
115
+ const manifest = buildManifest(resolved, spec);
116
+ const out = writeManifest(agentDirOf(path), manifest);
117
+ return { ok: true, code: 0, lines: [`wrote ${out}`, `configHash ${manifest.configHash}`] };
118
+ }
119
+ export function cmdEject(path) {
120
+ const agentDir = agentDirOf(path);
121
+ const abs = resolvePath(path);
122
+ const keystone = existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "agent.md") : abs;
123
+ if (!existsSync(keystone))
124
+ return fail([`no agent.md at ${keystone}`]);
125
+ const { data: fm, body } = parseFrontmatter(readFileSync(keystone, "utf8"));
126
+ if (Array.isArray(fm.extends)) {
127
+ return fail(["agent already uses `extends` (already ejected?) — nothing to do"]);
128
+ }
129
+ const before = buildSpec(fm);
130
+ const { files } = ejectFiles(fm, body);
131
+ for (const [rel, content] of Object.entries(files)) {
132
+ const p = join(agentDir, rel);
133
+ mkdirSync(dirname(p), { recursive: true });
134
+ writeFileSync(p, content, "utf8");
135
+ }
136
+ let after;
137
+ try {
138
+ after = buildSpec(resolveAgent(agentDir).rawFrontmatter);
139
+ }
140
+ catch (e) {
141
+ return fail([`ejected folder failed to re-resolve: ${e.message}`]);
142
+ }
143
+ const same = stableStringify(before) === stableStringify(after);
144
+ const lines = [
145
+ `ejected into ${agentDir}`,
146
+ ...Object.keys(files).map((f) => ` + ${f}`),
147
+ same ? "✓ resolved spec unchanged" : "✗ WARNING: resolved spec CHANGED after eject",
148
+ ];
149
+ return { ok: same, code: same ? 0 : 1, lines };
150
+ }
151
+ export function cmdInspect(path, json = false) {
152
+ let resolved;
153
+ try {
154
+ resolved = resolveAgent(path);
155
+ }
156
+ catch (e) {
157
+ if (e instanceof ResolveError)
158
+ return issuesResult(e.issues, "resolve failed");
159
+ throw e;
160
+ }
161
+ const spec = buildSpec(resolved.rawFrontmatter);
162
+ const lint = strictLint(resolved.rawFrontmatter);
163
+ const v = validateSkill({ spec, body: resolved.mergedProse, raw: resolved.rawFrontmatter }, "self-host");
164
+ const output = {
165
+ resolvedConfig: resolved.rawFrontmatter,
166
+ provenance: resolved.provenance,
167
+ contentHashes: resolved.contentHashes,
168
+ validation: { valid: v.valid, issues: v.issues, lint },
169
+ };
170
+ if (json) {
171
+ return { ok: v.valid, code: 0, lines: [JSON.stringify(output, null, 2)], data: output };
172
+ }
173
+ const lines = [
174
+ `name: ${spec.name}`,
175
+ `venues: ${spec.venues.join(", ")}`,
176
+ `cadence: ${spec.trigger.cadence}`,
177
+ `model: ${spec.model ? `${spec.model.provider}/${spec.model.name}` : "(host free-tier)"}`,
178
+ `risk: maxLeverage=${spec.risk.maxLeverage} perTradeMargin=${spec.risk.perTradeMarginMusd} requireStopLoss=${spec.risk.requireStopLoss}`,
179
+ `sources: ${Object.keys(resolved.contentHashes).length} file(s)`,
180
+ `validation: ${v.valid ? "valid" : "INVALID"}${lint.length ? ` (+${lint.length} lint note(s))` : ""}`,
181
+ ];
182
+ return { ok: v.valid, code: 0, lines, data: output };
183
+ }
184
+ // Acquire an exclusive per-agent run lock (O_EXCL). Returns a release fn, or
185
+ // null if another runner already holds it — so two runners can't race one
186
+ // state file and bypass the daily / write caps.
187
+ function acquireLock(stateFile) {
188
+ const lock = `${stateFile}.lock`;
189
+ let fd;
190
+ try {
191
+ fd = openSync(lock, "wx");
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ try {
197
+ writeFileSync(fd, JSON.stringify({ pid: process.pid }));
198
+ }
199
+ catch {
200
+ /* best effort */
201
+ }
202
+ return () => {
203
+ try {
204
+ closeSync(fd);
205
+ }
206
+ catch {
207
+ /* ignore */
208
+ }
209
+ try {
210
+ unlinkSync(lock);
211
+ }
212
+ catch {
213
+ /* ignore */
214
+ }
215
+ };
216
+ }
217
+ // Run the agent locally (self-host). Dry-run by default; --live (or LIVE=1)
218
+ // places paper trades. Reads COINRITHM_API_KEY + the model key from the ENV.
219
+ export async function cmdRun(path, opts = {}) {
220
+ let loaded;
221
+ try {
222
+ loaded = loadAgent(path, "self-host");
223
+ }
224
+ catch (e) {
225
+ if (e instanceof ResolveError)
226
+ return issuesResult(e.issues, "resolve failed");
227
+ throw e;
228
+ }
229
+ const apiKey = process.env.COINRITHM_API_KEY;
230
+ if (!apiKey)
231
+ return fail(["COINRITHM_API_KEY is not set (needed to read your paper account)"]);
232
+ let provider;
233
+ try {
234
+ provider = selectProvider(loaded.spec, process.env, fetch);
235
+ }
236
+ catch (e) {
237
+ return fail([e.message]);
238
+ }
239
+ const client = new CoinRithmClient({ apiKey, baseUrl: process.env.COINRITHM_API_URL });
240
+ const stateFile = opts.stateFile ?? join(agentDirOf(path), ".agent.state.json");
241
+ const release = acquireLock(stateFile);
242
+ if (!release) {
243
+ return fail([`another runner holds ${stateFile}.lock — only one runner per agent at a time`]);
244
+ }
245
+ try {
246
+ let state;
247
+ try {
248
+ state = loadState(stateFile, makeRunId(loaded.spec));
249
+ }
250
+ catch (e) {
251
+ // Corrupt state is fail-closed: refuse to run rather than reset guards.
252
+ return fail([e.message]);
253
+ }
254
+ if (state.disabled) {
255
+ return fail([`agent is disabled: ${state.disabledReason ?? "kill-switch"} — clear ${stateFile} to reset`]);
256
+ }
257
+ const live = !!opts.live;
258
+ const lines = [
259
+ `run ${live ? "LIVE (paper trades WILL be placed)" : "DRY-RUN (no writes; set --live or LIVE=1)"} — ${loaded.spec.name}`,
260
+ ];
261
+ const deps = {
262
+ client,
263
+ provider,
264
+ spec: loaded.spec,
265
+ mergedProse: loaded.body,
266
+ state,
267
+ live,
268
+ stateFile,
269
+ log: (l) => lines.push(l),
270
+ };
271
+ const results = await runLoop(deps, { once: opts.once });
272
+ saveState(stateFile, state);
273
+ const wrote = results.some((res) => res.planned.some((p) => p.executed));
274
+ lines.push(`done: ${results.length} cycle(s)${wrote ? "" : ", no writes"}`);
275
+ return { ok: true, code: 0, lines, data: results };
276
+ }
277
+ finally {
278
+ release();
279
+ }
280
+ }
281
+ function parseFlags(args) {
282
+ const out = { _: [] };
283
+ for (let i = 0; i < args.length; i++) {
284
+ const a = args[i];
285
+ if (a === "--hosted")
286
+ out.hosted = true;
287
+ else if (a === "--self-host")
288
+ out.hosted = false;
289
+ else if (a === "--json")
290
+ out.json = true;
291
+ else if (a === "--once")
292
+ out.once = true;
293
+ else if (a === "--live")
294
+ out.live = true;
295
+ else if (a === "--dry-run")
296
+ out.dryRun = true;
297
+ else if (a === "--template")
298
+ out.template = args[++i];
299
+ else if (a === "--preset")
300
+ out.preset = args[++i];
301
+ else if (a === "--state")
302
+ out.state = args[++i];
303
+ else
304
+ out._.push(a);
305
+ }
306
+ return out;
307
+ }
308
+ function usageLines() {
309
+ return [
310
+ "coinrithm-agent — author + run CoinRithm paper-trading agents (simulated funds only)",
311
+ " new <dir> --template momentum-futures --preset conservative|balanced|bold",
312
+ " validate <path> [--hosted | --self-host]",
313
+ " inspect <path> [--json]",
314
+ " eject <agent.md | dir>",
315
+ " lock <path>",
316
+ " run <path> [--once] [--live] [--dry-run] [--state <file>] (dry-run by default)",
317
+ ];
318
+ }
319
+ export async function main(argv) {
320
+ const [cmd, ...rest] = argv;
321
+ const flags = parseFlags(rest);
322
+ const pos = flags._;
323
+ let r;
324
+ switch (cmd) {
325
+ case "new":
326
+ r = cmdNew(pos[0] ?? "", { template: flags.template, preset: flags.preset });
327
+ break;
328
+ case "validate":
329
+ r = cmdValidate(pos[0] ?? ".", flags.hosted ? "hosted" : "self-host");
330
+ break;
331
+ case "lock":
332
+ r = cmdLock(pos[0] ?? ".");
333
+ break;
334
+ case "eject":
335
+ r = cmdEject(pos[0] ?? ".");
336
+ break;
337
+ case "inspect":
338
+ r = cmdInspect(pos[0] ?? ".", !!flags.json);
339
+ break;
340
+ case "run": {
341
+ // dry-run is the default; only --live (or LIVE=1) AND not --dry-run trades.
342
+ const live = (!!flags.live || process.env.LIVE === "1") && !flags.dryRun;
343
+ r = await cmdRun(pos[0] ?? ".", { once: flags.once, live, stateFile: flags.state });
344
+ break;
345
+ }
346
+ case undefined:
347
+ case "help":
348
+ case "--help":
349
+ case "-h":
350
+ r = { ok: true, code: 0, lines: usageLines() };
351
+ break;
352
+ default:
353
+ r = { ok: false, code: 1, lines: [`unknown command "${cmd}"`, ...usageLines()] };
354
+ }
355
+ for (const line of r.lines) {
356
+ // eslint-disable-next-line no-console
357
+ console.log(line);
358
+ }
359
+ return r.code;
360
+ }
@@ -0,0 +1,147 @@
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
+ // ── writes ─────────────────────────────────────────────────────────────────
129
+ openFutures(body) {
130
+ return this.request("POST", "/api/agent/futures/open", { body });
131
+ }
132
+ closeFutures(body) {
133
+ return this.request("POST", "/api/agent/futures/close", { body });
134
+ }
135
+ setFuturesSlTp(body) {
136
+ return this.request("POST", "/api/agent/futures/sl-tp", { body });
137
+ }
138
+ // Run-evidence export — runId is URL-encoded into the query.
139
+ exportRunEvidence(runId) {
140
+ return this.request("GET", "/api/agent/ledger/export", { query: { runId } });
141
+ }
142
+ }
143
+ // 401/403/409/422 are terminal, fail-closed outcomes for a cycle (auth/scope,
144
+ // conflict, or a risk-gate rejection) — never retried as if transient.
145
+ export function isFailClosed(status) {
146
+ return status === 401 || status === 403 || status === 409 || status === 422;
147
+ }