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