@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 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
@@ -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.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",