@coinrithm/mcp-trading 0.1.7 → 0.2.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/README.md +39 -14
- package/dist/agent/act.js +65 -0
- package/dist/agent/cli.js +360 -0
- package/dist/agent/client.js +147 -0
- package/dist/agent/decision.js +85 -0
- package/dist/agent/decisionValidator.js +107 -0
- package/dist/agent/extract.js +5 -0
- package/dist/agent/frontmatter.js +19 -0
- package/dist/agent/index.js +10 -0
- package/dist/agent/manifest.js +40 -0
- package/dist/agent/mergeRules.js +82 -0
- package/dist/agent/observe.js +110 -0
- package/dist/agent/prompt.js +45 -0
- package/dist/agent/providers.js +134 -0
- package/dist/agent/resolve.js +490 -0
- package/dist/agent/runEvidence.js +23 -0
- package/dist/agent/runner.js +197 -0
- package/dist/agent/skill.js +132 -0
- package/dist/agent/skillValidator.js +123 -0
- package/dist/agent/state.js +94 -0
- package/dist/agent/strictLint.js +164 -0
- package/dist/agent/templates.js +192 -0
- package/dist/agent/types.js +50 -0
- package/dist/agent/util.js +112 -0
- package/dist/agent/version.js +16 -0
- package/dist/tools.js +14 -0
- package/package.json +73 -67
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The execution loop: observe -> decide (BYO model) -> validate -> act. v1
|
|
2
|
+
// futures only. Dry-run never writes. Live uses idempotency keys + agentTrace
|
|
3
|
+
// and exports run evidence. The client + provider are injected so the loop is
|
|
4
|
+
// fully unit-testable with no network and no model calls.
|
|
5
|
+
import { observe } from "./observe.js";
|
|
6
|
+
import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
|
|
7
|
+
import { parseDecision } from "./decision.js";
|
|
8
|
+
import { validateAction } from "./decisionValidator.js";
|
|
9
|
+
import { fetchQuote, executeAction } from "./act.js";
|
|
10
|
+
import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
|
|
11
|
+
import { rollDay, checkKillSwitch, accrueRealized, saveState } from "./state.js";
|
|
12
|
+
import { asObj, asNum, asStr } from "./extract.js";
|
|
13
|
+
import { parseCadenceMs, sleep } from "./util.js";
|
|
14
|
+
// A stable idempotency-key component per distinct intent (so a lost response
|
|
15
|
+
// replays rather than re-trades, but a genuinely new intent gets a new key).
|
|
16
|
+
function intentKeyOf(action) {
|
|
17
|
+
if (action.type === "futures_open") {
|
|
18
|
+
return `open:${action.symbol.toUpperCase()}:${action.side}:${action.leverage}:${action.marginMusd}`;
|
|
19
|
+
}
|
|
20
|
+
if (action.type === "futures_close") {
|
|
21
|
+
return `close:${action.positionId}:${action.fraction ?? "full"}`;
|
|
22
|
+
}
|
|
23
|
+
if (action.type === "futures_set_sltp") {
|
|
24
|
+
return `sltp:${action.positionId}`;
|
|
25
|
+
}
|
|
26
|
+
return `other:${action.type}`; // unreachable: only futures actions are executed
|
|
27
|
+
}
|
|
28
|
+
export async function runCycle(deps) {
|
|
29
|
+
const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
|
|
30
|
+
const log = deps.log ?? (() => { });
|
|
31
|
+
state.cyclesRun += 1;
|
|
32
|
+
rollDay(state);
|
|
33
|
+
// Kill-switch pre-check: a disabled agent never observes, decides, or acts.
|
|
34
|
+
const tripped = checkKillSwitch(spec, state);
|
|
35
|
+
if (tripped) {
|
|
36
|
+
state.disabled = true;
|
|
37
|
+
state.disabledReason = tripped;
|
|
38
|
+
saveState(stateFile, state);
|
|
39
|
+
log(`disabled: ${tripped}`);
|
|
40
|
+
return { decision: "skip", planned: [], disabled: true, disabledReason: tripped, live };
|
|
41
|
+
}
|
|
42
|
+
const runId = state.runId;
|
|
43
|
+
const decisionId = makeDecisionId(state.cyclesRun);
|
|
44
|
+
const baseTrace = makeTrace(runId, decisionId, spec);
|
|
45
|
+
// OBSERVE
|
|
46
|
+
const obs = await observe(client, spec, state, baseTrace);
|
|
47
|
+
const observation = obs.observation;
|
|
48
|
+
accrueRealized(state, observation.newClosedTrades);
|
|
49
|
+
state.cursor = observation.syncCursor;
|
|
50
|
+
for (const t of observation.newClosedTrades) {
|
|
51
|
+
state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
|
|
52
|
+
}
|
|
53
|
+
state.seen = state.seen.slice(-500);
|
|
54
|
+
// Equity-aware drawdown: open mark-to-market losses trip the kill-switch too,
|
|
55
|
+
// not only realized losses.
|
|
56
|
+
const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
|
|
57
|
+
if (spec.killSwitch.maxDrawdownMusd > 0 &&
|
|
58
|
+
state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >= spec.killSwitch.maxDrawdownMusd) {
|
|
59
|
+
state.disabled = true;
|
|
60
|
+
state.disabledReason = `equity drawdown >= ${spec.killSwitch.maxDrawdownMusd}`;
|
|
61
|
+
saveState(stateFile, state);
|
|
62
|
+
log(`disabled: ${state.disabledReason}`);
|
|
63
|
+
return { decision: "skip", planned: [], disabled: true, disabledReason: state.disabledReason, live };
|
|
64
|
+
}
|
|
65
|
+
if (obs.skip) {
|
|
66
|
+
state.consecutiveRejectCycles += 1;
|
|
67
|
+
saveState(stateFile, state);
|
|
68
|
+
log(`skip: ${obs.skip}`);
|
|
69
|
+
return { decision: "skip", skipReason: obs.skip, planned: [], live };
|
|
70
|
+
}
|
|
71
|
+
// DECIDE
|
|
72
|
+
const res = await provider.decide({
|
|
73
|
+
system: buildSystemPrompt(spec, mergedProse),
|
|
74
|
+
user: buildUserPrompt(observation),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
state.consecutiveModelFailures += 1;
|
|
78
|
+
saveState(stateFile, state);
|
|
79
|
+
log(`model error: ${res.error}`);
|
|
80
|
+
return { decision: "skip", skipReason: `model error: ${res.error}`, planned: [], modelFailed: true, live };
|
|
81
|
+
}
|
|
82
|
+
const parsed = parseDecision(res.text);
|
|
83
|
+
if (!parsed.ok) {
|
|
84
|
+
state.consecutiveModelFailures += 1;
|
|
85
|
+
saveState(stateFile, state);
|
|
86
|
+
log(`model output invalid: ${parsed.error}`);
|
|
87
|
+
return { decision: "skip", skipReason: `model output invalid: ${parsed.error}`, planned: [], modelFailed: true, live };
|
|
88
|
+
}
|
|
89
|
+
state.consecutiveModelFailures = 0;
|
|
90
|
+
const decision = parsed.decision;
|
|
91
|
+
if (decision.decision === "skip" || decision.actions.length === 0) {
|
|
92
|
+
state.consecutiveRejectCycles += 1;
|
|
93
|
+
saveState(stateFile, state);
|
|
94
|
+
log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
|
|
95
|
+
return { decision: "skip", skipReason: decision.reason ?? "model chose skip", planned: [], live };
|
|
96
|
+
}
|
|
97
|
+
// VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
|
|
98
|
+
const planned = [];
|
|
99
|
+
let writesThisCycle = 0;
|
|
100
|
+
let openCount = observation.openPositions.length;
|
|
101
|
+
// RUNNING totals so multiple opens in one cycle accumulate correctly.
|
|
102
|
+
let openMarginMusd = observation.openPositions
|
|
103
|
+
.filter((p) => p.venue === "futures")
|
|
104
|
+
.reduce((s, p) => s + (p.marginMusd ?? 0), 0);
|
|
105
|
+
let cashAvailableMusd = observation.cashAvailableMusd;
|
|
106
|
+
const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
|
|
107
|
+
const targetedPositionIds = [];
|
|
108
|
+
let anyAccepted = false;
|
|
109
|
+
let anyExecuted = false;
|
|
110
|
+
let anyExecFailed = false;
|
|
111
|
+
for (const action of decision.actions) {
|
|
112
|
+
const quote = await fetchQuote(client, action, observation, baseTrace);
|
|
113
|
+
const ctx = {
|
|
114
|
+
spec,
|
|
115
|
+
observation,
|
|
116
|
+
quote,
|
|
117
|
+
writesThisCycle,
|
|
118
|
+
writesToday: state.writesToday,
|
|
119
|
+
openCount,
|
|
120
|
+
cashAvailableMusd,
|
|
121
|
+
openMarginMusd,
|
|
122
|
+
realizedLossTodayMusd,
|
|
123
|
+
targetedPositionIds,
|
|
124
|
+
};
|
|
125
|
+
const v = validateAction(action, ctx);
|
|
126
|
+
if (!v.valid) {
|
|
127
|
+
planned.push({ action, accepted: false, code: v.code, reason: v.reason, quote });
|
|
128
|
+
log(`reject ${action.type}: ${v.code} (${v.reason})`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
anyAccepted = true;
|
|
132
|
+
if (action.type === "futures_close" || action.type === "futures_set_sltp") {
|
|
133
|
+
targetedPositionIds.push(action.positionId);
|
|
134
|
+
}
|
|
135
|
+
if (!live) {
|
|
136
|
+
planned.push({ action, accepted: true, quote, executed: false });
|
|
137
|
+
log(`DRY-RUN: would ${action.type}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// Deterministic idempotency key: stable per intent, advanced only on
|
|
141
|
+
// confirmed success — a lost response replays, a new intent gets a new key.
|
|
142
|
+
const intentKey = intentKeyOf(action);
|
|
143
|
+
const seq = state.intentSeq[intentKey] ?? 0;
|
|
144
|
+
const idem = `${runId}:${intentKey}:${seq}`;
|
|
145
|
+
const meta = action;
|
|
146
|
+
const trace = makeTrace(runId, decisionId, spec, meta.confidence, meta.rationaleSummary);
|
|
147
|
+
const r = await executeAction(client, action, observation, trace, idem);
|
|
148
|
+
planned.push({ action, accepted: true, quote, executed: r.ok, result: r.data });
|
|
149
|
+
if (r.ok) {
|
|
150
|
+
anyExecuted = true;
|
|
151
|
+
state.intentSeq[intentKey] = seq + 1;
|
|
152
|
+
writesThisCycle += 1;
|
|
153
|
+
state.writesToday += 1;
|
|
154
|
+
if (action.type === "futures_open") {
|
|
155
|
+
openCount += 1;
|
|
156
|
+
openMarginMusd += action.marginMusd;
|
|
157
|
+
if (cashAvailableMusd != null)
|
|
158
|
+
cashAvailableMusd -= action.marginMusd;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
anyExecFailed = true;
|
|
163
|
+
}
|
|
164
|
+
log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
|
|
165
|
+
}
|
|
166
|
+
// Reset the reject kill-switch only on real PROGRESS: an accepted-but-FAILED
|
|
167
|
+
// live write is not progress, or a persistently failing live agent would
|
|
168
|
+
// never trip the kill-switch.
|
|
169
|
+
const progressed = live ? anyExecuted : anyAccepted;
|
|
170
|
+
state.consecutiveRejectCycles = progressed ? 0 : state.consecutiveRejectCycles + 1;
|
|
171
|
+
state.consecutiveExecFailures = anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
|
|
172
|
+
state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
|
|
173
|
+
saveState(stateFile, state);
|
|
174
|
+
if (live && anyExecuted)
|
|
175
|
+
await exportRunEvidence(client, runId);
|
|
176
|
+
return { decision: "act", planned, live };
|
|
177
|
+
}
|
|
178
|
+
export async function runLoop(deps, opts = {}) {
|
|
179
|
+
const results = [];
|
|
180
|
+
const cadenceMs = parseCadenceMs(deps.spec.trigger.cadence) ?? 3_600_000;
|
|
181
|
+
const log = deps.log ?? (() => { });
|
|
182
|
+
let cycles = 0;
|
|
183
|
+
for (;;) {
|
|
184
|
+
const r = await runCycle(deps);
|
|
185
|
+
results.push(r);
|
|
186
|
+
cycles += 1;
|
|
187
|
+
if (r.disabled)
|
|
188
|
+
break;
|
|
189
|
+
if (opts.once)
|
|
190
|
+
break;
|
|
191
|
+
if (opts.maxCycles && cycles >= opts.maxCycles)
|
|
192
|
+
break;
|
|
193
|
+
log(`sleeping ${Math.round(cadenceMs / 1000)}s until next cycle`);
|
|
194
|
+
await sleep(cadenceMs);
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
3
|
+
import { VENUES, PROVIDERS, ALLOWED_CAPABILITIES, } from "./types.js";
|
|
4
|
+
import { resolveAgent, ResolveError } from "./resolve.js";
|
|
5
|
+
import { strictLint } from "./strictLint.js";
|
|
6
|
+
// Safe defaults for the OPTIONAL policy blocks. A minimal self-host skill
|
|
7
|
+
// (name/description/spec/trigger/model/venues/risk) runs under these. Hosted
|
|
8
|
+
// mode requires them to be explicit (see skillValidator).
|
|
9
|
+
const DEFAULT_LIMITS = {
|
|
10
|
+
maxTradesPerDay: 20,
|
|
11
|
+
maxWritesPerCycle: 2,
|
|
12
|
+
maxDailyLossMusd: 5_000,
|
|
13
|
+
maxOpenMarginMusd: 5_000,
|
|
14
|
+
};
|
|
15
|
+
const DEFAULT_ABSTENTION = {
|
|
16
|
+
onStaleData: true,
|
|
17
|
+
onWeakSignal: true,
|
|
18
|
+
onMissingQuote: true,
|
|
19
|
+
onInsufficientBalance: true,
|
|
20
|
+
minConfidence: 0,
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_SYNC = { requirePollBeforeWrite: true };
|
|
23
|
+
const DEFAULT_KILLSWITCH = {
|
|
24
|
+
maxDrawdownMusd: 0, // 0 = disabled
|
|
25
|
+
maxConsecutiveRejects: 0, // 0 = disabled
|
|
26
|
+
maxConsecutiveModelFailures: 5,
|
|
27
|
+
onRateLimitPressure: true,
|
|
28
|
+
};
|
|
29
|
+
const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
30
|
+
const bool = (v, fallback) => typeof v === "boolean" ? v : fallback;
|
|
31
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
32
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
33
|
+
? v
|
|
34
|
+
: {};
|
|
35
|
+
function buildObjective(raw) {
|
|
36
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
37
|
+
return undefined;
|
|
38
|
+
const o = raw;
|
|
39
|
+
if (typeof o.primary !== "string")
|
|
40
|
+
return undefined;
|
|
41
|
+
return {
|
|
42
|
+
primary: o.primary,
|
|
43
|
+
secondary: strArr(o.secondary),
|
|
44
|
+
horizon: typeof o.horizon === "string" ? o.horizon : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function buildModel(raw) {
|
|
48
|
+
if (raw === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
const m = obj(raw);
|
|
51
|
+
return {
|
|
52
|
+
provider: (PROVIDERS.includes(m.provider)
|
|
53
|
+
? m.provider
|
|
54
|
+
: "openai-compatible"),
|
|
55
|
+
name: typeof m.name === "string" ? m.name : "",
|
|
56
|
+
baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
|
|
60
|
+
// values — it fills in what it can and lets validateSkill report problems
|
|
61
|
+
// against the raw frontmatter. The runner only proceeds when validation passes.
|
|
62
|
+
export function buildSpec(raw) {
|
|
63
|
+
const trigger = obj(raw.trigger);
|
|
64
|
+
const risk = obj(raw.risk);
|
|
65
|
+
const limits = obj(raw.limits);
|
|
66
|
+
const abst = obj(raw.abstention);
|
|
67
|
+
const sync = obj(raw.sync);
|
|
68
|
+
const ks = obj(raw.killSwitch);
|
|
69
|
+
const venues = strArr(raw.venues).filter((v) => VENUES.includes(v));
|
|
70
|
+
return {
|
|
71
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
72
|
+
description: typeof raw.description === "string" ? raw.description : "",
|
|
73
|
+
spec: typeof raw.spec === "string" ? raw.spec : "",
|
|
74
|
+
trigger: {
|
|
75
|
+
cadence: typeof trigger.cadence === "string" ? trigger.cadence : "",
|
|
76
|
+
timezone: typeof trigger.timezone === "string" ? trigger.timezone : undefined,
|
|
77
|
+
},
|
|
78
|
+
model: buildModel(raw.model),
|
|
79
|
+
venues,
|
|
80
|
+
risk: {
|
|
81
|
+
maxLeverage: num(risk.maxLeverage, 1),
|
|
82
|
+
perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
|
|
83
|
+
maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
|
|
84
|
+
requireStopLoss: bool(risk.requireStopLoss, true),
|
|
85
|
+
watchlist: strArr(risk.watchlist),
|
|
86
|
+
},
|
|
87
|
+
limits: {
|
|
88
|
+
maxTradesPerDay: num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay),
|
|
89
|
+
maxWritesPerCycle: num(limits.maxWritesPerCycle, DEFAULT_LIMITS.maxWritesPerCycle),
|
|
90
|
+
maxDailyLossMusd: num(limits.maxDailyLossMusd, DEFAULT_LIMITS.maxDailyLossMusd),
|
|
91
|
+
maxOpenMarginMusd: num(limits.maxOpenMarginMusd, DEFAULT_LIMITS.maxOpenMarginMusd),
|
|
92
|
+
},
|
|
93
|
+
abstention: {
|
|
94
|
+
onStaleData: bool(abst.onStaleData, DEFAULT_ABSTENTION.onStaleData),
|
|
95
|
+
onWeakSignal: bool(abst.onWeakSignal, DEFAULT_ABSTENTION.onWeakSignal),
|
|
96
|
+
onMissingQuote: bool(abst.onMissingQuote, DEFAULT_ABSTENTION.onMissingQuote),
|
|
97
|
+
onInsufficientBalance: bool(abst.onInsufficientBalance, DEFAULT_ABSTENTION.onInsufficientBalance),
|
|
98
|
+
minConfidence: num(abst.minConfidence, DEFAULT_ABSTENTION.minConfidence),
|
|
99
|
+
},
|
|
100
|
+
sync: {
|
|
101
|
+
requirePollBeforeWrite: bool(sync.requirePollBeforeWrite, DEFAULT_SYNC.requirePollBeforeWrite),
|
|
102
|
+
},
|
|
103
|
+
killSwitch: {
|
|
104
|
+
maxDrawdownMusd: num(ks.maxDrawdownMusd, DEFAULT_KILLSWITCH.maxDrawdownMusd),
|
|
105
|
+
maxConsecutiveRejects: num(ks.maxConsecutiveRejects, DEFAULT_KILLSWITCH.maxConsecutiveRejects),
|
|
106
|
+
maxConsecutiveModelFailures: num(ks.maxConsecutiveModelFailures, DEFAULT_KILLSWITCH.maxConsecutiveModelFailures),
|
|
107
|
+
onRateLimitPressure: bool(ks.onRateLimitPressure, DEFAULT_KILLSWITCH.onRateLimitPressure),
|
|
108
|
+
},
|
|
109
|
+
objective: buildObjective(raw.objective),
|
|
110
|
+
capabilities: strArr(raw.capabilities).filter((c) => ALLOWED_CAPABILITIES.includes(c)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function parseSkill(text) {
|
|
114
|
+
const { data, body } = parseFrontmatter(text);
|
|
115
|
+
return { spec: buildSpec(data), body, raw: data };
|
|
116
|
+
}
|
|
117
|
+
export function loadSkill(path) {
|
|
118
|
+
return parseSkill(readFileSync(path, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
// Compile an agent (single file OR decomposed folder) into a spec + the prose
|
|
121
|
+
// the LLM reads. Fail-closed: resolveAgent() throws on any structural/secret
|
|
122
|
+
// problem. In hosted mode the strict key/enum lint is ALSO fatal (no silent
|
|
123
|
+
// coercion of a typo'd knob); self-host returns lint as advisory issues.
|
|
124
|
+
export function loadAgent(inputPath, mode = "self-host") {
|
|
125
|
+
const resolved = resolveAgent(inputPath);
|
|
126
|
+
const raw = resolved.rawFrontmatter;
|
|
127
|
+
const lint = strictLint(raw);
|
|
128
|
+
if (mode === "hosted" && lint.length)
|
|
129
|
+
throw new ResolveError(lint);
|
|
130
|
+
const spec = buildSpec(raw);
|
|
131
|
+
return { resolved, spec, body: resolved.mergedProse, raw, lint };
|
|
132
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAPABILITIES, } from "./types.js";
|
|
2
|
+
import { parseCadenceMs, scanForSecrets } from "./util.js";
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
5
|
+
export function validateSkill(parsed, mode = "self-host") {
|
|
6
|
+
const raw = parsed.raw;
|
|
7
|
+
const issues = [];
|
|
8
|
+
const add = (code, reason) => issues.push(fail(code, reason));
|
|
9
|
+
// Secrets must NEVER live in a committable skill file.
|
|
10
|
+
for (const finding of scanForSecrets(raw))
|
|
11
|
+
add("skill_secret", finding);
|
|
12
|
+
// Identity
|
|
13
|
+
if (typeof raw.name !== "string" || !raw.name.trim())
|
|
14
|
+
add("skill_name", "name is required (string)");
|
|
15
|
+
if (typeof raw.description !== "string" || !raw.description.trim())
|
|
16
|
+
add("skill_description", "description is required (string)");
|
|
17
|
+
if (raw.spec !== SPEC_VERSION)
|
|
18
|
+
add("skill_spec_version", `spec must be "${SPEC_VERSION}"`);
|
|
19
|
+
// Trigger
|
|
20
|
+
if (!isObj(raw.trigger)) {
|
|
21
|
+
add("skill_trigger", "trigger block is required");
|
|
22
|
+
}
|
|
23
|
+
else if (parseCadenceMs(raw.trigger.cadence) === null) {
|
|
24
|
+
add("skill_cadence", `trigger.cadence must look like "15m" / "1h" / "4h" (got ${JSON.stringify(raw.trigger.cadence)})`);
|
|
25
|
+
}
|
|
26
|
+
// Venues
|
|
27
|
+
if (!Array.isArray(raw.venues) || raw.venues.length === 0) {
|
|
28
|
+
add("skill_venues", "venues must be a non-empty list");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
for (const v of raw.venues) {
|
|
32
|
+
if (!VENUES.includes(v))
|
|
33
|
+
add("skill_venue_unknown", `unknown venue "${String(v)}" (allowed: ${VENUES.join(", ")})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Risk — always required
|
|
37
|
+
if (!isObj(raw.risk)) {
|
|
38
|
+
add("skill_risk", "risk block is required (the caps the agent runs under)");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const r = raw.risk;
|
|
42
|
+
if (!isPosNum(r.maxLeverage))
|
|
43
|
+
add("skill_risk_leverage", "risk.maxLeverage must be a positive number");
|
|
44
|
+
else if (r.maxLeverage > 20)
|
|
45
|
+
add("skill_risk_leverage_cap", "risk.maxLeverage cannot exceed the server cap of 20");
|
|
46
|
+
if (!isPosNum(r.perTradeMarginMusd))
|
|
47
|
+
add("skill_risk_margin", "risk.perTradeMarginMusd must be a positive number");
|
|
48
|
+
if (!isPosNum(r.maxConcurrentPositions))
|
|
49
|
+
add("skill_risk_positions", "risk.maxConcurrentPositions must be a positive number");
|
|
50
|
+
if (typeof r.requireStopLoss !== "boolean")
|
|
51
|
+
add("skill_risk_sl", "risk.requireStopLoss must be true or false");
|
|
52
|
+
if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
|
|
53
|
+
add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
|
|
54
|
+
}
|
|
55
|
+
// Model
|
|
56
|
+
if (raw.model === undefined) {
|
|
57
|
+
if (mode === "self-host")
|
|
58
|
+
add("skill_model_required", "model is required for self-host (no hosted free-tier locally). Set model.provider + model.name.");
|
|
59
|
+
}
|
|
60
|
+
else if (!isObj(raw.model)) {
|
|
61
|
+
add("skill_model", "model must be an object with provider + name");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (!PROVIDERS.includes(raw.model.provider))
|
|
65
|
+
add("skill_model_provider", `model.provider must be one of: ${PROVIDERS.join(", ")}`);
|
|
66
|
+
if (typeof raw.model.name !== "string" || !raw.model.name.trim())
|
|
67
|
+
add("skill_model_name", "model.name is required");
|
|
68
|
+
if (raw.model.provider === "openai-compatible" &&
|
|
69
|
+
(typeof raw.model.baseUrl !== "string" || !raw.model.baseUrl.trim()))
|
|
70
|
+
add("skill_model_baseurl", "model.baseUrl is required for provider 'openai-compatible'");
|
|
71
|
+
}
|
|
72
|
+
// objective (optional)
|
|
73
|
+
if (raw.objective !== undefined) {
|
|
74
|
+
if (!isObj(raw.objective)) {
|
|
75
|
+
add("skill_objective", "objective must be an object");
|
|
76
|
+
}
|
|
77
|
+
else if (typeof raw.objective.primary !== "string" ||
|
|
78
|
+
!OBJECTIVE_PRIMARIES.includes(raw.objective.primary)) {
|
|
79
|
+
add("skill_objective_primary", `objective.primary must be one of: ${OBJECTIVE_PRIMARIES.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// capabilities (optional, opt-in, reserved for a later slice)
|
|
83
|
+
if (raw.capabilities !== undefined) {
|
|
84
|
+
if (!Array.isArray(raw.capabilities)) {
|
|
85
|
+
add("skill_capabilities", "capabilities must be a list");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const c of raw.capabilities) {
|
|
89
|
+
if (!ALLOWED_CAPABILITIES.includes(c)) {
|
|
90
|
+
add("skill_capability_unknown", `unknown capability "${String(c)}" (allowed: ${ALLOWED_CAPABILITIES.join(", ")})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Hosted mode: the safety policy blocks must be explicit.
|
|
96
|
+
if (mode === "hosted") {
|
|
97
|
+
if (!isObj(raw.limits)) {
|
|
98
|
+
add("skill_limits_required", "limits block is required in hosted mode");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const l = raw.limits;
|
|
102
|
+
if (!isPosNum(l.maxTradesPerDay))
|
|
103
|
+
add("skill_limits_trades", "limits.maxTradesPerDay must be a positive number");
|
|
104
|
+
if (!isPosNum(l.maxWritesPerCycle))
|
|
105
|
+
add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
|
|
106
|
+
if (!isPosNum(l.maxDailyLossMusd))
|
|
107
|
+
add("skill_limits_loss", "limits.maxDailyLossMusd must be a positive number");
|
|
108
|
+
if (!isPosNum(l.maxOpenMarginMusd))
|
|
109
|
+
add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
|
|
110
|
+
}
|
|
111
|
+
if (!isObj(raw.abstention))
|
|
112
|
+
add("skill_abstention_required", "abstention block is required in hosted mode");
|
|
113
|
+
if (!isObj(raw.sync)) {
|
|
114
|
+
add("skill_sync_required", "sync block is required in hosted mode");
|
|
115
|
+
}
|
|
116
|
+
else if (typeof raw.sync.requirePollBeforeWrite !== "boolean") {
|
|
117
|
+
add("skill_sync_poll", "sync.requirePollBeforeWrite must be true or false");
|
|
118
|
+
}
|
|
119
|
+
if (!isObj(raw.killSwitch))
|
|
120
|
+
add("skill_killswitch_required", "killSwitch block is required in hosted mode");
|
|
121
|
+
}
|
|
122
|
+
return { valid: issues.length === 0, issues };
|
|
123
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Local run state: the cursor, dedupe set, daily counters, and the kill-switch
|
|
2
|
+
// inputs. Persisted to a JSON file so a re-run resumes where it left off.
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { dayKey } from "./util.js";
|
|
6
|
+
import { asObj, asNum } from "./extract.js";
|
|
7
|
+
// Disable after this many 429s in a session when killSwitch.onRateLimitPressure.
|
|
8
|
+
const RATE_LIMIT_PRESSURE_THRESHOLD = 5;
|
|
9
|
+
export function newState(runId) {
|
|
10
|
+
return {
|
|
11
|
+
runId,
|
|
12
|
+
cyclesRun: 0,
|
|
13
|
+
writesToday: 0,
|
|
14
|
+
realizedPnlMusd: 0,
|
|
15
|
+
peakRealizedMusd: 0,
|
|
16
|
+
consecutiveRejectCycles: 0,
|
|
17
|
+
consecutiveModelFailures: 0,
|
|
18
|
+
rateLimitHits: 0,
|
|
19
|
+
disabled: false,
|
|
20
|
+
dayKey: dayKey(),
|
|
21
|
+
cursor: null,
|
|
22
|
+
seen: [],
|
|
23
|
+
realizedPnlTodayMusd: 0,
|
|
24
|
+
consecutiveExecFailures: 0,
|
|
25
|
+
intentSeq: {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// A corrupt EXISTING state file is FAIL-CLOSED: we refuse to run rather than
|
|
29
|
+
// silently reset and (e.g.) re-enable a kill-switched agent or zero the daily
|
|
30
|
+
// counters. A missing file is fine (fresh start).
|
|
31
|
+
export function loadState(file, runId) {
|
|
32
|
+
if (file && existsSync(file)) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new Error(`run state file is corrupt (${file}): ${e instanceof Error ? e.message : String(e)} — fix or delete it before running`);
|
|
39
|
+
}
|
|
40
|
+
const base = newState(parsed.runId ?? runId);
|
|
41
|
+
return rollDay({
|
|
42
|
+
...base,
|
|
43
|
+
...parsed,
|
|
44
|
+
seen: Array.isArray(parsed.seen) ? parsed.seen : [],
|
|
45
|
+
intentSeq: parsed.intentSeq && typeof parsed.intentSeq === "object" ? parsed.intentSeq : {},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return newState(runId);
|
|
49
|
+
}
|
|
50
|
+
export function saveState(file, state) {
|
|
51
|
+
if (!file)
|
|
52
|
+
return;
|
|
53
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
54
|
+
writeFileSync(file, JSON.stringify(state, null, 2), "utf8");
|
|
55
|
+
}
|
|
56
|
+
export function rollDay(state) {
|
|
57
|
+
const today = dayKey();
|
|
58
|
+
if (state.dayKey !== today) {
|
|
59
|
+
state.dayKey = today;
|
|
60
|
+
state.writesToday = 0;
|
|
61
|
+
state.realizedPnlTodayMusd = 0;
|
|
62
|
+
}
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
// Accrue realized PnL from newly-closed trades into both the session total (for
|
|
66
|
+
// drawdown) and today's total (for the daily-loss cap). Dedupe by the caller.
|
|
67
|
+
export function accrueRealized(state, closedTrades) {
|
|
68
|
+
for (const t of closedTrades) {
|
|
69
|
+
const pnl = asNum(asObj(t).realizedPnlMusd);
|
|
70
|
+
if (pnl != null) {
|
|
71
|
+
state.realizedPnlMusd += pnl;
|
|
72
|
+
state.realizedPnlTodayMusd += pnl;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (state.realizedPnlMusd > state.peakRealizedMusd)
|
|
76
|
+
state.peakRealizedMusd = state.realizedPnlMusd;
|
|
77
|
+
}
|
|
78
|
+
// Returns a disable reason if any kill-switch condition is tripped, else null.
|
|
79
|
+
export function checkKillSwitch(spec, state) {
|
|
80
|
+
const ks = spec.killSwitch;
|
|
81
|
+
if (ks.maxConsecutiveModelFailures > 0 && state.consecutiveModelFailures >= ks.maxConsecutiveModelFailures) {
|
|
82
|
+
return `consecutive model failures ${state.consecutiveModelFailures} >= ${ks.maxConsecutiveModelFailures}`;
|
|
83
|
+
}
|
|
84
|
+
if (ks.maxConsecutiveRejects > 0 && state.consecutiveRejectCycles >= ks.maxConsecutiveRejects) {
|
|
85
|
+
return `consecutive reject cycles ${state.consecutiveRejectCycles} >= ${ks.maxConsecutiveRejects}`;
|
|
86
|
+
}
|
|
87
|
+
if (ks.maxDrawdownMusd > 0 && state.peakRealizedMusd - state.realizedPnlMusd >= ks.maxDrawdownMusd) {
|
|
88
|
+
return `drawdown ${(state.peakRealizedMusd - state.realizedPnlMusd).toFixed(2)} >= ${ks.maxDrawdownMusd}`;
|
|
89
|
+
}
|
|
90
|
+
if (ks.onRateLimitPressure && state.rateLimitHits >= RATE_LIMIT_PRESSURE_THRESHOLD) {
|
|
91
|
+
return `rate-limit pressure: ${state.rateLimitHits} 429s this session`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|