@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.
- package/CHANGELOG.md +54 -1
- package/README.md +36 -9
- package/dist/agent/act.js +8 -1
- package/dist/agent/cli.d.ts +1 -0
- package/dist/agent/cli.js +58 -4
- package/dist/agent/client.d.ts +8 -1
- package/dist/agent/client.js +14 -2
- package/dist/agent/decision.d.ts +66 -63
- package/dist/agent/decision.js +95 -24
- package/dist/agent/decisionValidator.js +41 -1
- package/dist/agent/deploymentOverlay.d.ts +22 -0
- package/dist/agent/deploymentOverlay.js +55 -0
- package/dist/agent/gate.d.ts +9 -0
- package/dist/agent/gate.js +114 -0
- package/dist/agent/indicators.js +22 -7
- package/dist/agent/observe.js +201 -18
- package/dist/agent/prompt.d.ts +6 -2
- package/dist/agent/prompt.js +116 -26
- package/dist/agent/providers.d.ts +6 -0
- package/dist/agent/providers.js +89 -12
- package/dist/agent/resolve.js +28 -0
- package/dist/agent/resolvePm.d.ts +14 -0
- package/dist/agent/resolvePm.js +69 -0
- package/dist/agent/runner.d.ts +6 -1
- package/dist/agent/runner.js +312 -10
- package/dist/agent/scorecard.d.ts +24 -0
- package/dist/agent/scorecard.js +177 -0
- package/dist/agent/setups.d.ts +3 -0
- package/dist/agent/setups.js +133 -0
- package/dist/agent/skill.d.ts +1 -0
- package/dist/agent/skill.js +21 -3
- package/dist/agent/skillValidator.js +4 -2
- package/dist/agent/state.js +10 -2
- package/dist/agent/templates.js +8 -4
- package/dist/agent/types.d.ts +75 -2
- package/dist/agent/types.js +14 -1
- package/dist/agent/version.d.ts +2 -2
- package/dist/agent/version.js +12 -2
- package/dist/client.d.ts +2 -0
- package/dist/tools.js +28 -5
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
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,
|
|
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 {
|
|
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
|
+
}
|