@coinrithm/mcp-trading 0.3.0 → 0.4.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/CHANGELOG.md +17 -0
- package/dist/agent/resolve.js +28 -0
- package/dist/agent/scorecard.d.ts +24 -0
- package/dist/agent/scorecard.js +177 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
|
|
|
5
5
|
self-host agent runner) — versioned together. The CoinRithm **API contract** is
|
|
6
6
|
versioned separately (see `openapi.yaml` `info.version`, currently `1.4.0`).
|
|
7
7
|
|
|
8
|
+
## 0.4.0
|
|
9
|
+
|
|
10
|
+
- **Deterministic scorecard engine (`computeScorecard`).** The reproducible-
|
|
11
|
+
evaluation engine for `coinrithm.agent.scorecard.v1` — pure math over an
|
|
12
|
+
agent's realized track record (no network, no model): realized PnL, win rate,
|
|
13
|
+
expectancy, profit factor, reward-to-risk, Sharpe, Sortino, deflated /
|
|
14
|
+
probabilistic Sharpe (Bailey & López de Prado — skill vs luck with a multiple-
|
|
15
|
+
testing penalty), max drawdown, and Brier + ECE calibration for probabilistic
|
|
16
|
+
calls. Same inputs → identical metrics **and** a sha256 `contentHash` of the
|
|
17
|
+
canonicalized result, so a scorecard whose hash doesn't reproduce isn't
|
|
18
|
+
trusted. Metrics are computed AFTER the run from immutable evidence (leakage-
|
|
19
|
+
separation), so tuning-to-the-metric is structurally impossible. Returns
|
|
20
|
+
`null` for thin records — never a fabricated number.
|
|
21
|
+
- **Resolver: committable file metadata + functionality pin.** The OKF resolver
|
|
22
|
+
now carries per-file metadata and pins functionality through resolution, so a
|
|
23
|
+
bundle's behavior is reproducible from its committed files.
|
|
24
|
+
|
|
8
25
|
## 0.3.0
|
|
9
26
|
|
|
10
27
|
- **Agent risk config: coin deny-list (`blocklist`).** `risk.blocklist` lets an
|
package/dist/agent/resolve.js
CHANGED
|
@@ -48,6 +48,7 @@ const JOURNAL_MAX_LINES = 200;
|
|
|
48
48
|
const JOURNAL_MAX_BYTES = 8_000;
|
|
49
49
|
// Optional prose files (markdown the LLM reads), in assembly order.
|
|
50
50
|
const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
|
|
51
|
+
const FUNCTIONALITY_PIN = "functionality/coinrithm.yaml";
|
|
51
52
|
// Enforced cap field names. sizing.yaml is SOFT guidance and must NOT contain
|
|
52
53
|
// any of these (or a user could think a limit binds when it does not).
|
|
53
54
|
const ENFORCED_FIELD_NAMES = new Set([
|
|
@@ -58,6 +59,7 @@ const ENFORCED_FIELD_NAMES = new Set([
|
|
|
58
59
|
"maxConsecutiveModelFailures",
|
|
59
60
|
"onRateLimitPressure",
|
|
60
61
|
]);
|
|
62
|
+
const SKILL_METADATA_KEYS = new Set(["type", "title", "description", "tags"]);
|
|
61
63
|
// A $ref must be a LOCAL, RELATIVE path inside the agent folder — never a URL,
|
|
62
64
|
// an absolute path, a home/drive path, or a Windows backslash path.
|
|
63
65
|
function refSyntaxIssue(ref) {
|
|
@@ -391,6 +393,13 @@ function resolveDirectory(dir) {
|
|
|
391
393
|
// a skill file may be pure prose (no frontmatter) — treat whole as body.
|
|
392
394
|
body = readFileSync(abs, "utf8");
|
|
393
395
|
}
|
|
396
|
+
for (const f of scanForSecrets(patch)) {
|
|
397
|
+
ctx.issues.push({
|
|
398
|
+
code: "secret_in_frontmatter",
|
|
399
|
+
path: refPath,
|
|
400
|
+
message: `${f} (skill frontmatter is committable metadata — remove secrets)`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
394
403
|
includeOrder.push(name);
|
|
395
404
|
skillProse.push({ source: refPath, text: body });
|
|
396
405
|
applySkillPatch(ctx, rawFrontmatter, patch, refPath);
|
|
@@ -420,6 +429,23 @@ function resolveDirectory(dir) {
|
|
|
420
429
|
});
|
|
421
430
|
}
|
|
422
431
|
}
|
|
432
|
+
// Optional API/tool contract pin. It is locked for reproducibility and stale
|
|
433
|
+
// warnings, but it is not part of AgentSpec and is never sent to the model.
|
|
434
|
+
const functionalityPath = join(dir, FUNCTIONALITY_PIN);
|
|
435
|
+
if (existsSync(functionalityPath)) {
|
|
436
|
+
const abs = safePath(ctx, FUNCTIONALITY_PIN, "functionality pin");
|
|
437
|
+
if (abs) {
|
|
438
|
+
const parsed = parseYamlSafe(ctx, readHashed(ctx, abs), FUNCTIONALITY_PIN);
|
|
439
|
+
for (const f of scanForSecrets(parsed)) {
|
|
440
|
+
ctx.issues.push({
|
|
441
|
+
code: "secret_in_functionality",
|
|
442
|
+
path: FUNCTIONALITY_PIN,
|
|
443
|
+
message: `${f} (the functionality pin is committable metadata — remove secrets)`,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
sources.functionality = FUNCTIONALITY_PIN;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
423
449
|
const mergedProse = mergeProseParts(proseParts);
|
|
424
450
|
checkSizing(ctx, rawFrontmatter);
|
|
425
451
|
scanSecrets(ctx, rawFrontmatter, mergedProse);
|
|
@@ -444,6 +470,8 @@ function resolveDirectory(dir) {
|
|
|
444
470
|
// permitted, tighten-only; anything else is rejected (no permission expansion).
|
|
445
471
|
function applySkillPatch(ctx, rawFrontmatter, patch, sourceLabel) {
|
|
446
472
|
for (const key of Object.keys(patch)) {
|
|
473
|
+
if (SKILL_METADATA_KEYS.has(key))
|
|
474
|
+
continue;
|
|
447
475
|
if (key === "risk" || key === "limits") {
|
|
448
476
|
const caps = key === "risk" ? RISK_CAPS : LIMIT_CAPS;
|
|
449
477
|
const base = rawFrontmatter[key] ?? {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ScorecardInput {
|
|
2
|
+
realizedPnls: number[];
|
|
3
|
+
cumulative?: number[];
|
|
4
|
+
returns?: number[];
|
|
5
|
+
annualizationFactor?: number;
|
|
6
|
+
trials?: number;
|
|
7
|
+
predictions?: Array<{
|
|
8
|
+
p: number;
|
|
9
|
+
outcome: 0 | 1;
|
|
10
|
+
}>;
|
|
11
|
+
gates?: {
|
|
12
|
+
stopCoverage?: number;
|
|
13
|
+
evidenceCoverage?: number;
|
|
14
|
+
leakageClean?: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface Scorecard {
|
|
18
|
+
schema: "coinrithm.agent.scorecard.v1";
|
|
19
|
+
sampleSize: number;
|
|
20
|
+
returnsBasis: "returns" | "realized_pnl";
|
|
21
|
+
metrics: Record<string, number | null>;
|
|
22
|
+
contentHash: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function computeScorecard(input: ScorecardInput): Scorecard;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Deterministic scorecard engine — pure math over an agent's realized track
|
|
2
|
+
// record. The reproducible-evaluation half of coinrithm.agent.scorecard.v1
|
|
3
|
+
// (see examples/agents/_shared/scorecard.metrics.yaml + DECISIONS D17).
|
|
4
|
+
//
|
|
5
|
+
// DETERMINISM CONTRACT: the same inputs always yield the same metrics AND the
|
|
6
|
+
// same contentHash (sha256 of the canonicalized result), mirroring
|
|
7
|
+
// meta/manifest.lock.json. The engine NEVER calls the network or the model — it
|
|
8
|
+
// reads the run-evidence ledger export + realized equity curve (fetched by the
|
|
9
|
+
// caller) and computes, so tuning-to-the-metric is structurally impossible
|
|
10
|
+
// (leakage separation, arXiv 2512.02227). Every function returns null when there
|
|
11
|
+
// is too little data, so a thin record reports "n/a" rather than a fake number.
|
|
12
|
+
//
|
|
13
|
+
// SCIENTIFIC BASIS: risk-adjusted ratios (Sharpe/Sortino), skill-vs-luck
|
|
14
|
+
// deflation (probabilistic + deflated Sharpe, Bailey & Lopez de Prado), and
|
|
15
|
+
// calibration (Brier/ECE) for probabilistic calls — the reproducible-evaluation
|
|
16
|
+
// layer the field lacks (arXiv 2605.19337).
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
const round = (n, d = 6) => {
|
|
19
|
+
const f = 10 ** d;
|
|
20
|
+
return Math.round(n * f) / f;
|
|
21
|
+
};
|
|
22
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
23
|
+
const mean = (xs) => (xs.length ? sum(xs) / xs.length : 0);
|
|
24
|
+
// Sample standard deviation (n-1). null for < 2 points (undefined dispersion).
|
|
25
|
+
function sampleStd(xs) {
|
|
26
|
+
if (xs.length < 2)
|
|
27
|
+
return null;
|
|
28
|
+
const m = mean(xs);
|
|
29
|
+
const v = sum(xs.map((x) => (x - m) ** 2)) / (xs.length - 1);
|
|
30
|
+
return Math.sqrt(v);
|
|
31
|
+
}
|
|
32
|
+
// Downside deviation about a 0 minimum-acceptable-return (Sortino denominator).
|
|
33
|
+
function downsideDev(xs) {
|
|
34
|
+
if (xs.length < 2)
|
|
35
|
+
return null;
|
|
36
|
+
const sq = xs.map((x) => (x < 0 ? x * x : 0));
|
|
37
|
+
return Math.sqrt(sum(sq) / xs.length);
|
|
38
|
+
}
|
|
39
|
+
// Population moments used by the (probabilistic) Sharpe formula.
|
|
40
|
+
function moment(xs, k) {
|
|
41
|
+
const m = mean(xs);
|
|
42
|
+
return sum(xs.map((x) => (x - m) ** k)) / xs.length;
|
|
43
|
+
}
|
|
44
|
+
// Standard normal CDF via an Abramowitz & Stegun erf approximation (max err ~1e-7).
|
|
45
|
+
function normalCdf(z) {
|
|
46
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
|
47
|
+
const d = 0.3989422804014327 * Math.exp(-(z * z) / 2);
|
|
48
|
+
const p = d *
|
|
49
|
+
t *
|
|
50
|
+
(0.31938153 +
|
|
51
|
+
t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
|
52
|
+
return z >= 0 ? 1 - p : p;
|
|
53
|
+
}
|
|
54
|
+
// Max peak-to-trough drawdown (mUSD, >= 0) on a cumulative series.
|
|
55
|
+
function maxDrawdown(cumulative) {
|
|
56
|
+
let peak = -Infinity;
|
|
57
|
+
let maxDd = 0;
|
|
58
|
+
for (const c of cumulative) {
|
|
59
|
+
if (!Number.isFinite(c))
|
|
60
|
+
continue;
|
|
61
|
+
peak = Math.max(peak, c);
|
|
62
|
+
maxDd = Math.max(maxDd, peak - c);
|
|
63
|
+
}
|
|
64
|
+
return Number.isFinite(maxDd) ? maxDd : 0;
|
|
65
|
+
}
|
|
66
|
+
// Per-observation Sharpe (mean/std), the basis for the probabilistic SR test.
|
|
67
|
+
function rawSharpe(rs) {
|
|
68
|
+
const sd = sampleStd(rs);
|
|
69
|
+
if (sd == null || sd === 0)
|
|
70
|
+
return null;
|
|
71
|
+
return mean(rs) / sd;
|
|
72
|
+
}
|
|
73
|
+
// Probabilistic / deflated Sharpe (Bailey & Lopez de Prado, approximated).
|
|
74
|
+
// PSR(SR0) = Phi( (SR - SR0) * sqrt(n-1) / sqrt(1 - skew*SR + ((kurt-1)/4)*SR^2) ).
|
|
75
|
+
// Deflation: SR0 = sqrt(2*ln(trials)) / sqrt(n) — the expected max per-obs Sharpe
|
|
76
|
+
// of `trials` random strategies (extreme-value heuristic). trials=1 -> SR0=0, so
|
|
77
|
+
// it reduces to the probabilistic Sharpe (already penalizing short, skewed tracks).
|
|
78
|
+
function deflatedSharpe(rs, trials) {
|
|
79
|
+
const n = rs.length;
|
|
80
|
+
if (n < 3)
|
|
81
|
+
return null;
|
|
82
|
+
const sr = rawSharpe(rs);
|
|
83
|
+
if (sr == null)
|
|
84
|
+
return null;
|
|
85
|
+
const m2 = moment(rs, 2);
|
|
86
|
+
if (m2 === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const skew = moment(rs, 3) / m2 ** 1.5;
|
|
89
|
+
const kurt = moment(rs, 4) / m2 ** 2; // 3 for a normal distribution
|
|
90
|
+
const denom = Math.sqrt(Math.max(1e-9, 1 - skew * sr + ((kurt - 1) / 4) * sr * sr));
|
|
91
|
+
const sr0 = Math.sqrt(2 * Math.log(Math.max(1, trials))) / Math.sqrt(n);
|
|
92
|
+
const z = ((sr - sr0) * Math.sqrt(n - 1)) / denom;
|
|
93
|
+
return normalCdf(z);
|
|
94
|
+
}
|
|
95
|
+
// Brier score: mean((p - outcome)^2). Lower = better-calibrated.
|
|
96
|
+
function brier(preds) {
|
|
97
|
+
if (preds.length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
return mean(preds.map((x) => (x.p - x.outcome) ** 2));
|
|
100
|
+
}
|
|
101
|
+
// Expected calibration error over 10 equal-width probability buckets.
|
|
102
|
+
function ece(preds) {
|
|
103
|
+
if (preds.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const buckets = 10;
|
|
106
|
+
let total = 0;
|
|
107
|
+
for (let b = 0; b < buckets; b += 1) {
|
|
108
|
+
const lo = b / buckets;
|
|
109
|
+
const hi = (b + 1) / buckets;
|
|
110
|
+
const inB = preds.filter((x) => (b === buckets - 1 ? x.p >= lo && x.p <= hi : x.p >= lo && x.p < hi));
|
|
111
|
+
if (inB.length === 0)
|
|
112
|
+
continue;
|
|
113
|
+
const avgP = mean(inB.map((x) => x.p));
|
|
114
|
+
const avgO = mean(inB.map((x) => x.outcome));
|
|
115
|
+
total += (inB.length / preds.length) * Math.abs(avgP - avgO);
|
|
116
|
+
}
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
export function computeScorecard(input) {
|
|
120
|
+
const pnls = input.realizedPnls.filter(Number.isFinite);
|
|
121
|
+
const n = pnls.length;
|
|
122
|
+
const wins = pnls.filter((x) => x > 0);
|
|
123
|
+
const losses = pnls.filter((x) => x < 0);
|
|
124
|
+
const decided = wins.length + losses.length;
|
|
125
|
+
const grossWin = sum(wins);
|
|
126
|
+
const grossLoss = Math.abs(sum(losses));
|
|
127
|
+
const avgWin = wins.length ? grossWin / wins.length : 0;
|
|
128
|
+
const avgLoss = losses.length ? grossLoss / losses.length : 0;
|
|
129
|
+
const pWin = decided ? wins.length / decided : null;
|
|
130
|
+
const rs = input.returns && input.returns.length ? input.returns.filter(Number.isFinite) : pnls;
|
|
131
|
+
const returnsBasis = input.returns && input.returns.length ? "returns" : "realized_pnl";
|
|
132
|
+
const ann = input.annualizationFactor ?? 1;
|
|
133
|
+
const cumulative = input.cumulative && input.cumulative.length
|
|
134
|
+
? input.cumulative
|
|
135
|
+
: pnls.reduce((acc, x) => {
|
|
136
|
+
acc.push((acc.length ? acc[acc.length - 1] : 0) + x);
|
|
137
|
+
return acc;
|
|
138
|
+
}, []);
|
|
139
|
+
const sd = sampleStd(rs);
|
|
140
|
+
const dd = downsideDev(rs);
|
|
141
|
+
const sharpe = sd && sd !== 0 ? (mean(rs) / sd) * ann : null;
|
|
142
|
+
const sortino = dd && dd !== 0 ? (mean(rs) / dd) * ann : null;
|
|
143
|
+
const metrics = {
|
|
144
|
+
realized_pnl_musd: round(sum(pnls)),
|
|
145
|
+
trade_count: n,
|
|
146
|
+
decided_count: decided,
|
|
147
|
+
win_rate: pWin == null ? null : round(pWin),
|
|
148
|
+
expectancy_musd: pWin == null ? null : round(pWin * avgWin - (1 - pWin) * avgLoss),
|
|
149
|
+
profit_factor: grossLoss > 0 ? round(grossWin / grossLoss) : grossWin > 0 ? null : 0, // null = ∞ (no losses)
|
|
150
|
+
reward_to_risk: avgLoss > 0 ? round(avgWin / avgLoss) : null,
|
|
151
|
+
sharpe: sharpe == null ? null : round(sharpe),
|
|
152
|
+
sortino: sortino == null ? null : round(sortino),
|
|
153
|
+
deflated_sharpe: round0(deflatedSharpe(rs, input.trials ?? 1)),
|
|
154
|
+
max_drawdown_musd: round(maxDrawdown(cumulative)),
|
|
155
|
+
brier_score: round0(brier(input.predictions ?? [])),
|
|
156
|
+
calibration_error: round0(ece(input.predictions ?? [])),
|
|
157
|
+
stop_coverage: input.gates?.stopCoverage ?? null,
|
|
158
|
+
evidence_coverage: input.gates?.evidenceCoverage ?? null,
|
|
159
|
+
leakage_clean: input.gates?.leakageClean == null ? null : input.gates.leakageClean ? 1 : 0,
|
|
160
|
+
};
|
|
161
|
+
// Canonicalize (sorted keys) and hash, so the report card carries a stable,
|
|
162
|
+
// verifiable fingerprint — a scorecard whose hash does not reproduce is not trusted.
|
|
163
|
+
const canonical = JSON.stringify(Object.keys(metrics)
|
|
164
|
+
.sort()
|
|
165
|
+
.map((k) => [k, metrics[k]]));
|
|
166
|
+
const contentHash = createHash("sha256").update(canonical).digest("hex");
|
|
167
|
+
return {
|
|
168
|
+
schema: "coinrithm.agent.scorecard.v1",
|
|
169
|
+
sampleSize: n,
|
|
170
|
+
returnsBasis,
|
|
171
|
+
metrics,
|
|
172
|
+
contentHash,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function round0(n) {
|
|
176
|
+
return n == null ? null : round(n);
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinrithm/mcp-trading",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.CoinRithm/mcp-trading",
|
|
5
5
|
"description": "CoinRithm paper-trading toolkit: an MCP server (coinrithm-mcp) AND a self-host agent runner (coinrithm-agent) for spot, futures, and prediction markets with a user-minted API key.",
|
|
6
6
|
"type": "module",
|