@coinrithm/mcp-trading 0.3.0 → 0.5.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 (41) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +36 -9
  3. package/dist/agent/act.js +8 -1
  4. package/dist/agent/cli.d.ts +1 -0
  5. package/dist/agent/cli.js +58 -4
  6. package/dist/agent/client.d.ts +8 -1
  7. package/dist/agent/client.js +14 -2
  8. package/dist/agent/decision.d.ts +66 -63
  9. package/dist/agent/decision.js +95 -24
  10. package/dist/agent/decisionValidator.js +41 -1
  11. package/dist/agent/deploymentOverlay.d.ts +22 -0
  12. package/dist/agent/deploymentOverlay.js +55 -0
  13. package/dist/agent/gate.d.ts +9 -0
  14. package/dist/agent/gate.js +114 -0
  15. package/dist/agent/indicators.js +22 -7
  16. package/dist/agent/observe.js +201 -18
  17. package/dist/agent/prompt.d.ts +6 -2
  18. package/dist/agent/prompt.js +116 -26
  19. package/dist/agent/providers.d.ts +6 -0
  20. package/dist/agent/providers.js +89 -12
  21. package/dist/agent/resolve.js +28 -0
  22. package/dist/agent/resolvePm.d.ts +14 -0
  23. package/dist/agent/resolvePm.js +69 -0
  24. package/dist/agent/runner.d.ts +6 -1
  25. package/dist/agent/runner.js +312 -10
  26. package/dist/agent/scorecard.d.ts +24 -0
  27. package/dist/agent/scorecard.js +177 -0
  28. package/dist/agent/setups.d.ts +3 -0
  29. package/dist/agent/setups.js +133 -0
  30. package/dist/agent/skill.d.ts +1 -0
  31. package/dist/agent/skill.js +21 -3
  32. package/dist/agent/skillValidator.js +4 -2
  33. package/dist/agent/state.js +10 -2
  34. package/dist/agent/templates.js +8 -4
  35. package/dist/agent/types.d.ts +75 -2
  36. package/dist/agent/types.js +14 -1
  37. package/dist/agent/version.d.ts +2 -2
  38. package/dist/agent/version.js +12 -2
  39. package/dist/client.d.ts +2 -0
  40. package/dist/tools.js +28 -5
  41. package/package.json +1 -1
@@ -2,11 +2,14 @@
2
2
  // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
3
  // idempotency keys + agentTrace and exports run evidence. The client + provider
4
4
  // are injected so the loop is fully unit-testable with no network/model calls.
5
- import { spotBuyCost, } from "./types.js";
5
+ import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
6
+ import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
7
+ import { baseSymbol } from "./setups.js";
6
8
  import { observe } from "./observe.js";
7
9
  import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
8
10
  import { parseDecision } from "./decision.js";
9
11
  import { validateAction } from "./decisionValidator.js";
12
+ import { resolvePmRef } from "./resolvePm.js";
10
13
  import { fetchQuote, executeAction } from "./act.js";
11
14
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
12
15
  import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
@@ -52,6 +55,105 @@ function cashConsumed(action, quote) {
52
55
  }
53
56
  return 0;
54
57
  }
58
+ // Orienting a take-profit relative to side + mark is deterministic arithmetic —
59
+ // the runner should OWN it, not trust a weak model to compute it (the same reason
60
+ // pmN offloads the long-id copy). When a futures_open's takeProfitPrice is missing
61
+ // or on the WRONG side (long TP <= mark / short TP >= mark), the server rejects the
62
+ // WHOLE open (take_profit_not_*_mark) — the position never opens, so the model
63
+ // can't "let winners run". Here we substitute a sensible R:R target off the stop so
64
+ // the open succeeds with a valid TP. Only fires when a usable stop is present (so
65
+ // risk is computable) and the TP is actually missing/wrong-side; a correct TP is
66
+ // left untouched. Systemic fix for the whole free-tier 8B/instruct fleet.
67
+ const DEFAULT_TP_RR = 1.5; // reward:risk of the substituted take-profit
68
+ export function repairFuturesTakeProfit(action, quote) {
69
+ if (action.type !== "futures_open")
70
+ return { action, repaired: false };
71
+ const entry = quote?.entryPrice;
72
+ const sl = action.stopLossPrice;
73
+ if (typeof entry !== "number" || !Number.isFinite(entry) || entry <= 0)
74
+ return { action, repaired: false };
75
+ if (typeof sl !== "number" || !Number.isFinite(sl) || sl <= 0)
76
+ return { action, repaired: false };
77
+ const isLong = action.side === "long";
78
+ const tp = action.takeProfitPrice;
79
+ const tpValid = typeof tp === "number" &&
80
+ Number.isFinite(tp) &&
81
+ tp > 0 &&
82
+ (isLong ? tp > entry : tp < entry);
83
+ if (tpValid)
84
+ return { action, repaired: false };
85
+ // Stop must be on the correct side to imply a positive risk distance; if it
86
+ // isn't, leave the action for the validator to reject (don't fabricate).
87
+ const risk = isLong ? entry - sl : sl - entry;
88
+ if (!(risk > 0))
89
+ return { action, repaired: false };
90
+ const target = isLong
91
+ ? entry + DEFAULT_TP_RR * risk
92
+ : entry - DEFAULT_TP_RR * risk;
93
+ if (!(target > 0))
94
+ return { action, repaired: false };
95
+ return { action: { ...action, takeProfitPrice: target }, repaired: true };
96
+ }
97
+ // One-line, human-readable summary of an executed action for the agent's journal
98
+ // (slice-3 memory). Compact so a few entries cost almost nothing in the prompt.
99
+ function summarizeAction(a) {
100
+ switch (a.type) {
101
+ case "futures_open":
102
+ return `opened ${a.side} ${a.symbol} (x${a.leverage}, ${a.marginMusd}mUSD)`;
103
+ case "futures_close":
104
+ return `closed pos#${a.positionId}`;
105
+ case "futures_set_sltp":
106
+ return `trailed stop on pos#${a.positionId}`;
107
+ case "spot_order":
108
+ return `${a.side} ${a.symbol} (${a.orderType})`;
109
+ case "spot_cancel":
110
+ return `cancelled order#${a.orderId}`;
111
+ case "pm_open":
112
+ return `bet PM ${a.slug} ${a.stakeMusd}mUSD`;
113
+ }
114
+ }
115
+ // Tokens that identify the MARKET an action is on, for checking a rationale is
116
+ // actually about it. close/sltp/cancel reference a position/order id (not a market
117
+ // name) so they return none and keep their position-management rationale as-is.
118
+ function actionMarketTokens(a) {
119
+ switch (a.type) {
120
+ case "futures_open":
121
+ case "spot_order":
122
+ return [a.symbol, baseSymbol(a.symbol)];
123
+ case "pm_open":
124
+ return [a.source, ...a.slug.split("-").filter((w) => w.length >= 4)];
125
+ default:
126
+ return [];
127
+ }
128
+ }
129
+ // Does the decision rationale plausibly name this action's market? Lenient: any
130
+ // market token present counts. An empty token set (close/sltp/cancel) returns true.
131
+ function rationaleMatchesAction(rationale, a) {
132
+ const toks = actionMarketTokens(a).filter((t) => t && t.length >= 2);
133
+ if (toks.length === 0)
134
+ return true;
135
+ const lower = rationale.toLowerCase();
136
+ return toks.some((t) => lower.includes(t.toLowerCase()));
137
+ }
138
+ // The per-trade "why" shown on the Arena floor, kept HONEST about the market it's
139
+ // on. Prefer the model's per-action summary. Else the decision-level rationale —
140
+ // but a MULTI-action decision commits that rationale to its PRIMARY idea, which can
141
+ // contradict a secondary action's market ("reasoning didn't match the selected
142
+ // market"), so attach it only when it names this action's market; otherwise a
143
+ // faithful one-line summary of the move. A single-action decision's rationale IS
144
+ // about that action, so it is always kept (the common case, unchanged).
145
+ export function rationaleForAction(a, decisionRationale, perActionSummary, totalActions) {
146
+ const perAction = perActionSummary?.trim();
147
+ if (perAction)
148
+ return perAction;
149
+ if (!decisionRationale)
150
+ return summarizeAction(a);
151
+ if (totalActions <= 1)
152
+ return decisionRationale;
153
+ return rationaleMatchesAction(decisionRationale, a)
154
+ ? decisionRationale
155
+ : summarizeAction(a);
156
+ }
55
157
  export async function runCycle(deps) {
56
158
  const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
57
159
  const log = deps.log ?? (() => { });
@@ -84,9 +186,35 @@ export async function runCycle(deps) {
84
186
  state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
85
187
  }
86
188
  state.seen = state.seen.slice(-500);
189
+ // Slice-3 reflection: journal closed-trade OUTCOMES (not just opens) so the agent
190
+ // remembers how its theses RESOLVED — a stop-out it should not revenge-trade, a
191
+ // winner its style works on. Defensive field reads (the /trades shape varies);
192
+ // a partial entry is harmless, a missing one is skipped.
193
+ for (const t of observation.newClosedTrades.slice(-5)) {
194
+ const o = asObj(t);
195
+ const sym = asStr(o.symbol) ?? asStr(o.coinSymbol) ?? asStr(o.coinId);
196
+ const pnl = asNum(o.realizedPnlMusd) ??
197
+ asNum(o.pnlMusd) ??
198
+ asNum(o.realizedPnl) ??
199
+ asNum(o.pnl);
200
+ const side = asStr(o.side);
201
+ if (sym || pnl != null) {
202
+ const did = `closed ${side ?? ""} ${sym ?? "position"}`.trim() +
203
+ (pnl != null
204
+ ? `: ${pnl >= 0 ? "+" : ""}${Math.round(pnl)}mUSD ${pnl >= 0 ? "WIN" : "LOSS"}`
205
+ : "");
206
+ state.journal = [
207
+ ...(state.journal ?? []),
208
+ { at: observation.asOf, did },
209
+ ].slice(-12);
210
+ }
211
+ }
87
212
  // Equity-aware drawdown: open mark-to-market losses trip the kill-switch too,
88
- // not only realized losses.
89
- const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
213
+ // not only realized losses. Includes BOTH futures AND prediction-market books —
214
+ // a PM-only agent (or one with a large PM stake) was previously invisible to the
215
+ // drawdown stop, so a marking-down PM book could not trip it (P1b).
216
+ const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0) +
217
+ (observation.pmPositions ?? []).reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
90
218
  if (spec.killSwitch.maxDrawdownMusd > 0 &&
91
219
  state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >=
92
220
  spec.killSwitch.maxDrawdownMusd) {
@@ -108,11 +236,56 @@ export async function runCycle(deps) {
108
236
  log(`skip: ${obs.skip}`);
109
237
  return { decision: "skip", skipReason: obs.skip, planned: [], live };
110
238
  }
239
+ // GATE (slice 2): only SPEND an LLM call when a deterministic trigger fires — a
240
+ // flagged entry setup or an open position to manage. No trigger => a cheap
241
+ // heartbeat (zero tokens). A heartbeat is neither a model reject nor a failure,
242
+ // so it touches NO kill-switch counter; it just records the cheap cycle.
243
+ const policy = spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY;
244
+ const nowMs = Date.now();
245
+ const gate = evaluateGate(observation, state, policy, nowMs);
246
+ const providerName = spec.model?.provider ?? "nvidia";
247
+ if (!gate.fire) {
248
+ saveState(stateFile, state);
249
+ log(`gate: skip (${gate.reason})`);
250
+ return {
251
+ decision: "skip",
252
+ skipReason: gate.reason,
253
+ planned: [],
254
+ live,
255
+ triggerCodes: gate.codes,
256
+ llmCallMade: false,
257
+ decisionType: "gate_skip",
258
+ tokensIn: 0,
259
+ tokensOut: 0,
260
+ estimatedCostUsd: 0,
261
+ writeAttempted: 0,
262
+ writeAccepted: 0,
263
+ };
264
+ }
265
+ noteLlmCall(state, gate.codes, nowMs);
111
266
  // DECIDE
112
- const res = await provider.decide({
113
- system: buildSystemPrompt(spec, mergedProse),
114
- user: buildUserPrompt(observation),
115
- });
267
+ const system = buildSystemPrompt(spec, mergedProse);
268
+ const user = buildUserPrompt(observation, state.journal);
269
+ const tokensInEst = Math.round((system.length + user.length) / 4);
270
+ // Prompt-size + trigger visibility in the live terminal.
271
+ log(`prompt ~${tokensInEst} tok ` +
272
+ `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
273
+ const res = await provider.decide({ system, user });
274
+ // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
275
+ const tokensIn = res.ok
276
+ ? (res.usage?.promptTokens ?? tokensInEst)
277
+ : tokensInEst;
278
+ const tokensOut = res.ok
279
+ ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
280
+ : 0;
281
+ const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
282
+ const meter = {
283
+ triggerCodes: gate.codes,
284
+ llmCallMade: true,
285
+ tokensIn,
286
+ tokensOut,
287
+ estimatedCostUsd,
288
+ };
116
289
  if (!res.ok) {
117
290
  state.consecutiveModelFailures += 1;
118
291
  saveState(stateFile, state);
@@ -123,6 +296,10 @@ export async function runCycle(deps) {
123
296
  planned: [],
124
297
  modelFailed: true,
125
298
  live,
299
+ ...meter,
300
+ decisionType: "model_error",
301
+ writeAttempted: 0,
302
+ writeAccepted: 0,
126
303
  };
127
304
  }
128
305
  const parsed = parseDecision(res.text);
@@ -133,13 +310,24 @@ export async function runCycle(deps) {
133
310
  return {
134
311
  decision: "skip",
135
312
  skipReason: `model output invalid: ${parsed.error}`,
313
+ rawModelOutput: res.text.slice(0, 8000),
136
314
  planned: [],
137
315
  modelFailed: true,
138
316
  live,
317
+ ...meter,
318
+ decisionType: "model_error",
319
+ writeAttempted: 0,
320
+ writeAccepted: 0,
139
321
  };
140
322
  }
141
323
  state.consecutiveModelFailures = 0;
142
324
  const decision = parsed.decision;
325
+ // Reasoning captured for the Arena terminal (keystone transparency): the
326
+ // model's own analysis this cycle + decision confidence + the full raw text
327
+ // (capped) for debugging. Shared across the skip + act return paths.
328
+ const rationale = decision.rationale;
329
+ const confidence = decision.confidence;
330
+ const rawModelOutput = res.text.slice(0, 8000);
143
331
  if (decision.decision === "skip" || decision.actions.length === 0) {
144
332
  state.consecutiveRejectCycles += 1;
145
333
  saveState(stateFile, state);
@@ -147,8 +335,15 @@ export async function runCycle(deps) {
147
335
  return {
148
336
  decision: "skip",
149
337
  skipReason: decision.reason ?? "model chose skip",
338
+ rationale,
339
+ confidence,
340
+ rawModelOutput,
150
341
  planned: [],
151
342
  live,
343
+ ...meter,
344
+ decisionType: "skip",
345
+ writeAttempted: decision.actions.length,
346
+ writeAccepted: 0,
152
347
  };
153
348
  }
154
349
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
@@ -166,8 +361,86 @@ export async function runCycle(deps) {
166
361
  let anyAccepted = false;
167
362
  let anyExecuted = false;
168
363
  let anyExecFailed = false;
169
- for (const action of decision.actions) {
364
+ for (let action of decision.actions) {
365
+ // Resolve a short PM ref (pm1…pmN) to the canonical {source,slug,outcome} BEFORE
366
+ // any quote/validation. Small models copy a 3-char ref reliably but mis-copy the
367
+ // long outcomeExternalMarketId; an unknown/missing ref is rejected here with a
368
+ // clear code instead of crashing the validator on an undefined id.
369
+ if (action.type === "pm_open") {
370
+ const resolved = resolvePmRef(action, observation.pmMarkets);
371
+ if (!resolved.ok) {
372
+ planned.push({
373
+ action,
374
+ accepted: false,
375
+ code: resolved.code,
376
+ reason: resolved.reason,
377
+ });
378
+ log(`reject pm_open: ${resolved.code} (${resolved.reason})`);
379
+ continue;
380
+ }
381
+ action = resolved.action;
382
+ // Anti-churn for PM (the analog of the futures duplicate_intent guard):
383
+ // block re-betting a market+outcome we ALREADY hold an open position on.
384
+ // Without this a model re-bets the same mispriced market every cycle (one
385
+ // agent opened 25 identical bets) — pure churn. The model SEES its holdings
386
+ // in observation.pmPositions; this enforces it. Bet a DIFFERENT market instead.
387
+ const pm = resolved.action; // narrowed to pm_open (canonical triple filled)
388
+ const heldPm = observation.pmPositions.find((p) => (p.source ?? "").toLowerCase() === (pm.source ?? "").toLowerCase() &&
389
+ (p.slug ?? "").toLowerCase() === (pm.slug ?? "").toLowerCase() &&
390
+ p.outcomeExternalMarketId === pm.outcomeExternalMarketId);
391
+ if (heldPm) {
392
+ planned.push({
393
+ action,
394
+ accepted: false,
395
+ code: "duplicate_intent",
396
+ reason: `already hold a PM bet on ${pm.slug} / ${pm.outcomeExternalMarketId} — do not re-bet the same market+outcome (churn); pick a different market or skip`,
397
+ });
398
+ log(`reject pm_open: duplicate_intent (hold PM ${pm.slug})`);
399
+ continue;
400
+ }
401
+ }
402
+ // Anti-churn critic: block re-opening a futures position we ALREADY hold unless
403
+ // it's a confirmed WINNER with room (a legit scale-in). Stops the re-open-a-
404
+ // loser / re-open-into-the-cap churn deterministically — before we even spend a
405
+ // quote — with a clear "duplicate_intent" instead of a cryptic cap reject. The
406
+ // runner caps remain the backstop; this is the cleaner, earlier stop.
407
+ if (action.type === "futures_open") {
408
+ // Bind the narrowed action to a const: `action` is a reassignable loop var
409
+ // (pm-resolve + the take-profit repair below both write to it), and a later
410
+ // reassignment would otherwise widen it back to the union inside this
411
+ // closure's control-flow analysis.
412
+ const fo = action;
413
+ const ab = baseSymbol(fo.symbol);
414
+ const held = observation.openPositions.find((p) => p.venue === "futures" &&
415
+ p.side === fo.side &&
416
+ baseSymbol(p.symbol) === ab);
417
+ if (held) {
418
+ const winning = (held.unrealizedPnlMusd ?? 0) > 0;
419
+ const hasRoom = openCount < spec.risk.maxConcurrentPositions &&
420
+ openMarginMusd + fo.marginMusd <= spec.limits.maxOpenMarginMusd;
421
+ if (!(winning && hasRoom)) {
422
+ planned.push({
423
+ action,
424
+ accepted: false,
425
+ code: "duplicate_intent",
426
+ reason: `already hold ${fo.symbol} ${fo.side}${winning ? " (no margin room to add)" : " — manage it, do not average down or re-open"}`,
427
+ });
428
+ log(`reject ${action.type}: duplicate_intent (hold ${fo.symbol} ${fo.side})`);
429
+ continue;
430
+ }
431
+ }
432
+ }
170
433
  const quote = await fetchQuote(client, action, observation, baseTrace);
434
+ // Auto-clamp a missing/wrong-side futures take-profit to a valid R:R target
435
+ // off the stop, so the open isn't silently rejected server-side (the runner
436
+ // owns trigger orientation; weak models routinely mis-sign it).
437
+ {
438
+ const fixed = repairFuturesTakeProfit(action, quote);
439
+ if (fixed.repaired) {
440
+ action = fixed.action;
441
+ log(`repaired ${action.type} take-profit -> ${action.takeProfitPrice} (R:R off stop; model TP was missing/wrong-side)`);
442
+ }
443
+ }
171
444
  const ctx = {
172
445
  spec,
173
446
  // Inherit the decision-level confidence so the per-action abstention gate
@@ -215,7 +488,13 @@ export async function runCycle(deps) {
215
488
  const seq = state.intentSeq[intentKey] ?? 0;
216
489
  const idem = `${runId}:${intentKey}:${seq}`;
217
490
  const meta = action;
218
- const trace = makeTrace(runId, decisionId, spec, meta.confidence ?? decision.confidence, meta.rationaleSummary);
491
+ const trace = makeTrace(runId, decisionId, spec, meta.confidence ?? decision.confidence,
492
+ // The trade's "why" on the Arena live floor. Prefer the model's per-action
493
+ // summary; else the decision rationale, but kept HONEST about this action's
494
+ // market (a multi-action decision's rationale can be about a DIFFERENT market
495
+ // than a secondary trade — see rationaleForAction). Sanitized short reasoning
496
+ // only — never raw chain-of-thought.
497
+ rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length));
219
498
  const r = await executeAction(client, action, observation, trace, idem);
220
499
  planned.push({
221
500
  action,
@@ -253,10 +532,33 @@ export async function runCycle(deps) {
253
532
  state.consecutiveExecFailures =
254
533
  anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
255
534
  state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
535
+ // Slice-3 memory: journal the accepted move(s) + the thesis behind them so next
536
+ // cycle has continuity (manage with memory of WHY; don't re-open what we just did).
537
+ const moves = planned
538
+ .filter((p) => p.accepted)
539
+ .map((p) => summarizeAction(p.action));
540
+ if (moves.length > 0) {
541
+ const did = `${moves.join("; ")}${rationale ? ` — ${rationale.slice(0, 90)}` : ""}`;
542
+ state.journal = [
543
+ ...(state.journal ?? []),
544
+ { at: observation.asOf, did },
545
+ ].slice(-10);
546
+ }
256
547
  saveState(stateFile, state);
257
548
  if (live && anyExecuted)
258
549
  await exportRunEvidence(client, runId);
259
- return { decision: "act", planned, live };
550
+ return {
551
+ decision: "act",
552
+ rationale,
553
+ confidence,
554
+ rawModelOutput,
555
+ planned,
556
+ live,
557
+ ...meter,
558
+ decisionType: "act",
559
+ writeAttempted: decision.actions.length,
560
+ writeAccepted: planned.filter((p) => p.accepted).length,
561
+ };
260
562
  }
261
563
  export async function runLoop(deps, opts = {}) {
262
564
  const results = [];
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import { OpenPosition, SetupSignal, WatchEntry } from "./types.js";
2
+ export declare function baseSymbol(s: string | undefined): string;
3
+ export declare function scanSetups(watch: WatchEntry[], openPositions?: OpenPosition[]): SetupSignal[];