@coinrithm/mcp-trading 0.4.0 → 0.7.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 +37 -1
- package/README.md +48 -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/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/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 +19 -0
- package/dist/client.js +56 -0
- package/dist/tools.js +139 -5
- package/package.json +78 -78
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,133 @@
|
|
|
1
|
+
// Deterministic setup scan — the first slice of the preflight gate.
|
|
2
|
+
//
|
|
3
|
+
// The problem it solves: cautious free-tier brains were skipping 100% of cycles
|
|
4
|
+
// with "no clear setup" even while the tape moved 3-4%. The fix (drawn straight
|
|
5
|
+
// from content-engine's gate design): do NOT make the model decide whether a setup
|
|
6
|
+
// exists. Compute it deterministically from the indicators we already have, then
|
|
7
|
+
// hand the flagged setups to the model so it decides HOW to act, not WHETHER
|
|
8
|
+
// anything is happening. This flips the default from "no setup -> skip" to "here
|
|
9
|
+
// is the structure -> trade it (in your style) or give a real reason not to".
|
|
10
|
+
//
|
|
11
|
+
// Pure + stateless: no I/O, no model. Strategy-neutral — it reports the structure
|
|
12
|
+
// and the trend-following bias; a contrarian agent fades the same facts.
|
|
13
|
+
// Thresholds tuned to FIRE readily on a normal moving market (the failure mode we
|
|
14
|
+
// are fixing is under-firing). A genuinely flat tape still yields an empty list,
|
|
15
|
+
// which is the correct "nothing to do" signal.
|
|
16
|
+
const STRONG_MOVE_PCT = 2.0; // |24h %| that counts as a real directional push
|
|
17
|
+
const LEAN_MOVE_PCT = 0.8; // smaller move that still confirms an EMA-stack trend
|
|
18
|
+
const RSI_OVERSOLD = 35;
|
|
19
|
+
const RSI_OVERBOUGHT = 68;
|
|
20
|
+
const MIN_STRENGTH = 0.5; // below this we do not flag (avoid noise)
|
|
21
|
+
function pct(n) {
|
|
22
|
+
return `${n >= 0 ? "+" : ""}${n.toFixed(1)}%`;
|
|
23
|
+
}
|
|
24
|
+
// Normalize a symbol to its base asset so a watch "BTC" matches an open position
|
|
25
|
+
// "BTC-PERP" / "BTCUSDT" when checking whether we already hold it.
|
|
26
|
+
export function baseSymbol(s) {
|
|
27
|
+
return (s ?? "")
|
|
28
|
+
.toUpperCase()
|
|
29
|
+
.replace(/[-/]?(PERP|USDT|USDC|USD)$/i, "")
|
|
30
|
+
.replace(/[^A-Z0-9]/g, "");
|
|
31
|
+
}
|
|
32
|
+
// Classify ONE coin into its setups. Usually one (the trend/breakout read), but a
|
|
33
|
+
// TRENDING coin that is also RSI-extreme emits a SECOND, counter-trend "fade"
|
|
34
|
+
// signal — the same structure is a momentum trade to a trend-follower and a
|
|
35
|
+
// mean-reversion trade to a contrarian, so we surface both and let each agent pick
|
|
36
|
+
// the one matching its style (fixes contrarians skipping "no setup fits me").
|
|
37
|
+
function classify(w, openPositions) {
|
|
38
|
+
const ind = w.indicators;
|
|
39
|
+
if (!ind)
|
|
40
|
+
return [];
|
|
41
|
+
const ch = w.change24h ?? 0;
|
|
42
|
+
const rsi = ind.rsi14;
|
|
43
|
+
const up = ind.ema20AboveEma50 === true && ind.aboveEma20 === true;
|
|
44
|
+
const down = ind.ema20AboveEma50 === false && ind.aboveEma20 === false;
|
|
45
|
+
const oversold = rsi != null && rsi <= RSI_OVERSOLD;
|
|
46
|
+
const overbought = rsi != null && rsi >= RSI_OVERBOUGHT;
|
|
47
|
+
// Compact, factual note the model reads (no interpretation — just the structure).
|
|
48
|
+
const facts = [`${pct(ch)} 24h`];
|
|
49
|
+
if (up)
|
|
50
|
+
facts.push("price>EMA20>EMA50 (uptrend)");
|
|
51
|
+
else if (down)
|
|
52
|
+
facts.push("price<EMA20<EMA50 (downtrend)");
|
|
53
|
+
if (rsi != null)
|
|
54
|
+
facts.push(`RSI ${Math.round(rsi)}${oversold ? " oversold" : overbought ? " overbought" : ""}`);
|
|
55
|
+
if (ind.brokeRecentHigh === true)
|
|
56
|
+
facts.push("broke 20-bar high");
|
|
57
|
+
if (ind.brokeRecentLow === true)
|
|
58
|
+
facts.push("broke 20-bar low");
|
|
59
|
+
if (ind.atr14 != null && ind.asOfClose)
|
|
60
|
+
facts.push(`ATR ${((100 * ind.atr14) / ind.asOfClose).toFixed(1)}% (stop ~1.5xATR)`);
|
|
61
|
+
const note = facts.join(" · ");
|
|
62
|
+
const out = [];
|
|
63
|
+
// Primary trend-following / breakout read.
|
|
64
|
+
if (ind.brokeRecentHigh === true) {
|
|
65
|
+
out.push({ symbol: w.symbol, kind: "breakout", bias: "long", strength: 0.8, note });
|
|
66
|
+
}
|
|
67
|
+
else if (ind.brokeRecentLow === true) {
|
|
68
|
+
out.push({ symbol: w.symbol, kind: "breakdown", bias: "short", strength: 0.8, note });
|
|
69
|
+
}
|
|
70
|
+
else if (up && ch >= LEAN_MOVE_PCT) {
|
|
71
|
+
out.push({ symbol: w.symbol, kind: "uptrend", bias: "long", strength: ch >= STRONG_MOVE_PCT ? 0.75 : 0.6, note });
|
|
72
|
+
}
|
|
73
|
+
else if (down && ch <= -LEAN_MOVE_PCT) {
|
|
74
|
+
out.push({ symbol: w.symbol, kind: "downtrend", bias: "short", strength: ch <= -STRONG_MOVE_PCT ? 0.75 : 0.6, note });
|
|
75
|
+
}
|
|
76
|
+
else if (overbought) {
|
|
77
|
+
out.push({ symbol: w.symbol, kind: "stretched", bias: "fade-short", strength: 0.55, note });
|
|
78
|
+
}
|
|
79
|
+
else if (oversold) {
|
|
80
|
+
out.push({ symbol: w.symbol, kind: "stretched", bias: "fade-long", strength: 0.55, note });
|
|
81
|
+
}
|
|
82
|
+
else if (Math.abs(ch) >= STRONG_MOVE_PCT) {
|
|
83
|
+
// A strong move with no clean EMA stack — still tradeable momentum.
|
|
84
|
+
out.push({ symbol: w.symbol, kind: ch > 0 ? "uptrend" : "downtrend", bias: ch > 0 ? "long" : "short", strength: 0.55, note });
|
|
85
|
+
}
|
|
86
|
+
// Secondary COUNTER-TREND fade: a standing trend that is ALSO RSI-extreme is a
|
|
87
|
+
// mean-reversion candidate. Only add it when the primary was the trend itself
|
|
88
|
+
// (so we don't double-list a pure stretched read).
|
|
89
|
+
const primaryIsTrend = out[0] && (out[0].kind === "uptrend" || out[0].kind === "downtrend" || out[0].kind === "breakout" || out[0].kind === "breakdown");
|
|
90
|
+
if (primaryIsTrend && (oversold || overbought)) {
|
|
91
|
+
out.push({
|
|
92
|
+
symbol: w.symbol,
|
|
93
|
+
kind: "stretched",
|
|
94
|
+
bias: oversold ? "fade-long" : "fade-short",
|
|
95
|
+
strength: 0.6,
|
|
96
|
+
note: `${note} — counter-trend fade (mean-reversion: ${oversold ? "oversold within downtrend" : "overbought within uptrend"})`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Position awareness: if we already hold this symbol, tag every signal with the
|
|
100
|
+
// side held AND the position's win/loss state right in the note — so the model
|
|
101
|
+
// ADDS to a winner (only with free margin), trails, or cuts a loser, instead of
|
|
102
|
+
// pointlessly re-opening the same size into the margin cap (the open_margin_
|
|
103
|
+
// exceeds_cap churn). A winner with room is the one case a same-side "open" is OK
|
|
104
|
+
// (scaling in); otherwise it's manage-only.
|
|
105
|
+
const wb = baseSymbol(w.symbol);
|
|
106
|
+
const pos = wb ? openPositions.find((p) => baseSymbol(p.symbol) === wb) : undefined;
|
|
107
|
+
const held = pos && (pos.side === "long" || pos.side === "short") ? pos.side : undefined;
|
|
108
|
+
if (held) {
|
|
109
|
+
const u = pos?.unrealizedPnlMusd;
|
|
110
|
+
const tag = u == null
|
|
111
|
+
? ` [HELD ${held} — manage, do NOT re-open]`
|
|
112
|
+
: u >= 0
|
|
113
|
+
? ` [HELD ${held}, +${Math.round(u)}mUSD WINNER — ADD only if you have free margin (scale into strength), else trail the stop; never re-open the same size]`
|
|
114
|
+
: ` [HELD ${held}, ${Math.round(u)}mUSD loser — trail or cut; do NOT average down or re-open]`;
|
|
115
|
+
for (const s of out) {
|
|
116
|
+
s.held = held;
|
|
117
|
+
s.note = s.note + tag;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
// Scan the whole watchlist, return the flagged setups strongest-first. An empty
|
|
123
|
+
// list = a flat tape = a legitimate reason to skip new entries this cycle. Setups
|
|
124
|
+
// on a symbol we already hold are tagged `held` (manage, don't re-open).
|
|
125
|
+
export function scanSetups(watch, openPositions = []) {
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const w of watch) {
|
|
128
|
+
for (const s of classify(w, openPositions))
|
|
129
|
+
if (s.strength >= MIN_STRENGTH)
|
|
130
|
+
out.push(s);
|
|
131
|
+
}
|
|
132
|
+
return out.sort((a, b) => b.strength - a.strength);
|
|
133
|
+
}
|
package/dist/agent/skill.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentSpec, ParsedSkill, ResolvedAgent, ResolveIssue } from "./types.js";
|
|
2
|
+
export declare const UNLIMITED_TRADES_PER_DAY = 1000000;
|
|
2
3
|
export declare function buildSpec(raw: Record<string, unknown>): AgentSpec;
|
|
3
4
|
export declare function parseSkill(text: string): ParsedSkill;
|
|
4
5
|
export declare function loadSkill(path: string): ParsedSkill;
|
package/dist/agent/skill.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
3
|
-
import { VENUES, PROVIDERS, ALLOWED_CAPABILITIES, } from "./types.js";
|
|
3
|
+
import { VENUES, PROVIDERS, ALLOWED_CAPABILITIES, DEFAULT_TRIGGER_POLICY, } from "./types.js";
|
|
4
4
|
import { resolveAgent, ResolveError } from "./resolve.js";
|
|
5
5
|
import { strictLint } from "./strictLint.js";
|
|
6
6
|
import { checkCapabilityDrift } from "./capabilityGuard.js";
|
|
7
7
|
// Safe defaults for the OPTIONAL policy blocks. A minimal self-host skill
|
|
8
8
|
// (name/description/spec/trigger/model/venues/risk) runs under these. Hosted
|
|
9
9
|
// mode requires them to be explicit (see skillValidator).
|
|
10
|
+
// A daily TRADE-COUNT cap of 0 (or absent) means UNLIMITED. We don't throttle how often
|
|
11
|
+
// an agent trades — the risk caps (daily loss, open margin, leverage, stops) are the real
|
|
12
|
+
// guardrails. "Unlimited" is normalised to a large finite value so cap-merge arithmetic
|
|
13
|
+
// (most-restrictive-wins) and JSON serialisation stay simple.
|
|
14
|
+
export const UNLIMITED_TRADES_PER_DAY = 1_000_000;
|
|
15
|
+
const normalizeTradeCap = (v) => (v <= 0 ? UNLIMITED_TRADES_PER_DAY : v);
|
|
10
16
|
const DEFAULT_LIMITS = {
|
|
11
|
-
maxTradesPerDay:
|
|
17
|
+
maxTradesPerDay: UNLIMITED_TRADES_PER_DAY,
|
|
12
18
|
maxWritesPerCycle: 2,
|
|
13
19
|
maxDailyLossMusd: 5_000,
|
|
14
20
|
maxOpenMarginMusd: 5_000,
|
|
@@ -67,6 +73,7 @@ export function buildSpec(raw) {
|
|
|
67
73
|
const abst = obj(raw.abstention);
|
|
68
74
|
const sync = obj(raw.sync);
|
|
69
75
|
const ks = obj(raw.killSwitch);
|
|
76
|
+
const trig = obj(raw.triggerPolicy);
|
|
70
77
|
const venues = strArr(raw.venues).filter((v) => VENUES.includes(v));
|
|
71
78
|
return {
|
|
72
79
|
name: typeof raw.name === "string" ? raw.name : "",
|
|
@@ -87,7 +94,7 @@ export function buildSpec(raw) {
|
|
|
87
94
|
blocklist: strArr(risk.blocklist),
|
|
88
95
|
},
|
|
89
96
|
limits: {
|
|
90
|
-
maxTradesPerDay: num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay),
|
|
97
|
+
maxTradesPerDay: normalizeTradeCap(num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay)),
|
|
91
98
|
maxWritesPerCycle: num(limits.maxWritesPerCycle, DEFAULT_LIMITS.maxWritesPerCycle),
|
|
92
99
|
maxDailyLossMusd: num(limits.maxDailyLossMusd, DEFAULT_LIMITS.maxDailyLossMusd),
|
|
93
100
|
maxOpenMarginMusd: num(limits.maxOpenMarginMusd, DEFAULT_LIMITS.maxOpenMarginMusd),
|
|
@@ -110,6 +117,17 @@ export function buildSpec(raw) {
|
|
|
110
117
|
},
|
|
111
118
|
objective: buildObjective(raw.objective),
|
|
112
119
|
capabilities: strArr(raw.capabilities).filter((c) => ALLOWED_CAPABILITIES.includes(c)),
|
|
120
|
+
// OKF v2 (load-bearing): the gate reads this; omitted -> DEFAULT_TRIGGER_POLICY.
|
|
121
|
+
// This is the agent's INTENT — the platform deployment overlay may tighten it
|
|
122
|
+
// server-side, and it can never widen a hard cap (caps live in the runner).
|
|
123
|
+
triggerPolicy: {
|
|
124
|
+
mode: trig.mode === "always" ? "always" : DEFAULT_TRIGGER_POLICY.mode,
|
|
125
|
+
skipLlmWhenNoTrigger: bool(trig.skipLlmWhenNoTrigger, DEFAULT_TRIGGER_POLICY.skipLlmWhenNoTrigger),
|
|
126
|
+
alwaysManageOpenPositions: bool(trig.alwaysManageOpenPositions, DEFAULT_TRIGGER_POLICY.alwaysManageOpenPositions),
|
|
127
|
+
maxLlmCallsPerHour: num(trig.maxLlmCallsPerHour, DEFAULT_TRIGGER_POLICY.maxLlmCallsPerHour),
|
|
128
|
+
debounceMinutes: num(trig.debounceMinutes, DEFAULT_TRIGGER_POLICY.debounceMinutes),
|
|
129
|
+
pmEvalCooldownMinutes: num(trig.pmEvalCooldownMinutes, DEFAULT_TRIGGER_POLICY.pmEvalCooldownMinutes),
|
|
130
|
+
},
|
|
113
131
|
};
|
|
114
132
|
}
|
|
115
133
|
export function parseSkill(text) {
|
|
@@ -2,6 +2,8 @@ import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAP
|
|
|
2
2
|
import { parseCadenceMs, scanForSecrets } from "./util.js";
|
|
3
3
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
4
|
const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
5
|
+
// maxTradesPerDay accepts 0 as the explicit "unlimited daily trades" sentinel.
|
|
6
|
+
const isNonNegNum = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
5
7
|
export function validateSkill(parsed, mode = "self-host") {
|
|
6
8
|
const raw = parsed.raw;
|
|
7
9
|
const issues = [];
|
|
@@ -99,8 +101,8 @@ export function validateSkill(parsed, mode = "self-host") {
|
|
|
99
101
|
}
|
|
100
102
|
else {
|
|
101
103
|
const l = raw.limits;
|
|
102
|
-
if (!
|
|
103
|
-
add("skill_limits_trades", "limits.maxTradesPerDay must be a
|
|
104
|
+
if (!isNonNegNum(l.maxTradesPerDay))
|
|
105
|
+
add("skill_limits_trades", "limits.maxTradesPerDay must be a number >= 0 (0 = unlimited daily trades)");
|
|
104
106
|
if (!isPosNum(l.maxWritesPerCycle))
|
|
105
107
|
add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
|
|
106
108
|
if (!isPosNum(l.maxDailyLossMusd))
|
package/dist/agent/state.js
CHANGED
|
@@ -77,11 +77,19 @@ export function accrueRealized(state, closedTrades) {
|
|
|
77
77
|
if (state.realizedPnlMusd > state.peakRealizedMusd)
|
|
78
78
|
state.peakRealizedMusd = state.realizedPnlMusd;
|
|
79
79
|
}
|
|
80
|
+
// A transient model-failure streak (free models occasionally time out/hang) must
|
|
81
|
+
// never disable an agent on a hair-trigger, so the model-failure kill-switch is
|
|
82
|
+
// floored at this many consecutive failures regardless of an agent's own (lower)
|
|
83
|
+
// setting. The scheduler additionally auto-revives any model-failure disable.
|
|
84
|
+
const MODEL_FAILURE_FLOOR = 10;
|
|
80
85
|
// Returns a disable reason if any kill-switch condition is tripped, else null.
|
|
81
86
|
export function checkKillSwitch(spec, state) {
|
|
82
87
|
const ks = spec.killSwitch;
|
|
83
|
-
if (ks.maxConsecutiveModelFailures > 0
|
|
84
|
-
|
|
88
|
+
if (ks.maxConsecutiveModelFailures > 0) {
|
|
89
|
+
const threshold = Math.max(ks.maxConsecutiveModelFailures, MODEL_FAILURE_FLOOR);
|
|
90
|
+
if (state.consecutiveModelFailures >= threshold) {
|
|
91
|
+
return `consecutive model failures ${state.consecutiveModelFailures} >= ${threshold}`;
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
94
|
if (ks.maxConsecutiveRejects > 0 && state.consecutiveRejectCycles >= ks.maxConsecutiveRejects) {
|
|
87
95
|
return `consecutive reject cycles ${state.consecutiveRejectCycles} >= ${ks.maxConsecutiveRejects}`;
|