@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 +43 -19
- package/dist/agent/act.js +65 -0
- package/dist/agent/cli.js +360 -0
- package/dist/agent/client.js +147 -0
- package/dist/agent/decision.js +85 -0
- package/dist/agent/decisionValidator.js +107 -0
- package/dist/agent/extract.js +5 -0
- package/dist/agent/frontmatter.js +19 -0
- package/dist/agent/index.js +10 -0
- package/dist/agent/manifest.js +40 -0
- package/dist/agent/mergeRules.js +82 -0
- package/dist/agent/observe.js +110 -0
- package/dist/agent/prompt.js +45 -0
- package/dist/agent/providers.js +134 -0
- package/dist/agent/resolve.js +490 -0
- package/dist/agent/runEvidence.js +23 -0
- package/dist/agent/runner.js +197 -0
- package/dist/agent/skill.js +132 -0
- package/dist/agent/skillValidator.js +123 -0
- package/dist/agent/state.js +94 -0
- package/dist/agent/strictLint.js +164 -0
- package/dist/agent/templates.js +192 -0
- package/dist/agent/types.js +50 -0
- package/dist/agent/util.js +112 -0
- package/dist/agent/version.js +16 -0
- package/package.json +73 -67
package/README.md
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# @coinrithm/mcp-trading
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
}
|