@coinrithm/mcp-trading 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +36 -9
  3. package/dist/agent/act.js +8 -1
  4. package/dist/agent/cli.d.ts +1 -0
  5. package/dist/agent/cli.js +58 -4
  6. package/dist/agent/client.d.ts +8 -1
  7. package/dist/agent/client.js +14 -2
  8. package/dist/agent/decision.d.ts +66 -63
  9. package/dist/agent/decision.js +95 -24
  10. package/dist/agent/decisionValidator.js +41 -1
  11. package/dist/agent/deploymentOverlay.d.ts +22 -0
  12. package/dist/agent/deploymentOverlay.js +55 -0
  13. package/dist/agent/gate.d.ts +9 -0
  14. package/dist/agent/gate.js +114 -0
  15. package/dist/agent/indicators.js +22 -7
  16. package/dist/agent/observe.js +201 -18
  17. package/dist/agent/prompt.d.ts +6 -2
  18. package/dist/agent/prompt.js +116 -26
  19. package/dist/agent/providers.d.ts +6 -0
  20. package/dist/agent/providers.js +89 -12
  21. package/dist/agent/resolve.js +28 -0
  22. package/dist/agent/resolvePm.d.ts +14 -0
  23. package/dist/agent/resolvePm.js +69 -0
  24. package/dist/agent/runner.d.ts +6 -1
  25. package/dist/agent/runner.js +312 -10
  26. package/dist/agent/scorecard.d.ts +24 -0
  27. package/dist/agent/scorecard.js +177 -0
  28. package/dist/agent/setups.d.ts +3 -0
  29. package/dist/agent/setups.js +133 -0
  30. package/dist/agent/skill.d.ts +1 -0
  31. package/dist/agent/skill.js +21 -3
  32. package/dist/agent/skillValidator.js +4 -2
  33. package/dist/agent/state.js +10 -2
  34. package/dist/agent/templates.js +8 -4
  35. package/dist/agent/types.d.ts +75 -2
  36. package/dist/agent/types.js +14 -1
  37. package/dist/agent/version.d.ts +2 -2
  38. package/dist/agent/version.js +12 -2
  39. package/dist/client.d.ts +2 -0
  40. package/dist/tools.js +28 -5
  41. package/package.json +1 -1
@@ -3,34 +3,45 @@
3
3
  // JSON, an unknown action type, a free-form endpoint/tool name, extra unknown
4
4
  // fields, or a missing required field — fails closed (the runner skips).
5
5
  import { z } from "zod";
6
+ // Small models (especially Llama 3.1 8B) frequently emit numbers as JSON strings
7
+ // ("12345", "0.8", "62000"). Coerce a *clean* numeric string to a number before
8
+ // validating; leave anything else untouched so genuine garbage ("abc", "pos#5")
9
+ // still fails closed. This is why actions were being rejected with
10
+ // "actions.0.positionId: Expected number, received string".
11
+ const num = (inner) => z.preprocess((v) => (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)) ? Number(v) : v), inner);
12
+ // Optional 0..1 confidence, tolerant of stringified/null input.
13
+ const confidence = num(z.number().min(0).max(1))
14
+ .nullable()
15
+ .optional()
16
+ .transform((v) => v ?? undefined);
6
17
  const futuresOpen = z
7
18
  .object({
8
19
  type: z.literal("futures_open"),
9
20
  symbol: z.string().min(1),
10
21
  side: z.enum(["long", "short"]),
11
- leverage: z.number().positive(),
12
- marginMusd: z.number().positive(),
13
- stopLossPrice: z.number().nullable().optional(),
14
- takeProfitPrice: z.number().nullable().optional(),
15
- confidence: z.number().min(0).max(1).optional(),
22
+ leverage: num(z.number().positive()),
23
+ marginMusd: num(z.number().positive()),
24
+ stopLossPrice: num(z.number()).nullable().optional(),
25
+ takeProfitPrice: num(z.number()).nullable().optional(),
26
+ confidence,
16
27
  rationaleSummary: z.string().optional(),
17
28
  })
18
29
  .strict();
19
30
  const futuresClose = z
20
31
  .object({
21
32
  type: z.literal("futures_close"),
22
- positionId: z.number(),
23
- fraction: z.number().positive().max(1).optional(),
24
- confidence: z.number().min(0).max(1).optional(),
33
+ positionId: num(z.number()),
34
+ fraction: num(z.number().positive().max(1)).optional(),
35
+ confidence,
25
36
  rationaleSummary: z.string().optional(),
26
37
  })
27
38
  .strict();
28
39
  const futuresSetSltp = z
29
40
  .object({
30
41
  type: z.literal("futures_set_sltp"),
31
- positionId: z.number(),
32
- stopLossPrice: z.number().nullable().optional(),
33
- takeProfitPrice: z.number().nullable().optional(),
42
+ positionId: num(z.number()),
43
+ stopLossPrice: num(z.number()).nullable().optional(),
44
+ takeProfitPrice: num(z.number()).nullable().optional(),
34
45
  })
35
46
  .strict();
36
47
  const spotOrder = z
@@ -39,27 +50,34 @@ const spotOrder = z
39
50
  symbol: z.string().min(1),
40
51
  side: z.enum(["buy", "sell"]),
41
52
  orderType: z.enum(["market", "limit", "stop"]),
42
- quantity: z.number().positive(),
43
- limitPrice: z.number().positive().optional(),
44
- stopPrice: z.number().positive().optional(),
45
- confidence: z.number().min(0).max(1).optional(),
53
+ quantity: num(z.number().positive()),
54
+ limitPrice: num(z.number().positive()).optional(),
55
+ stopPrice: num(z.number().positive()).optional(),
56
+ confidence,
46
57
  rationaleSummary: z.string().optional(),
47
58
  })
48
59
  .strict();
49
60
  const spotCancel = z
50
61
  .object({
51
62
  type: z.literal("spot_cancel"),
52
- orderId: z.number(),
63
+ orderId: num(z.number()),
53
64
  })
54
65
  .strict();
66
+ // pm_open accepts EITHER a short ref (pm1…pmN, what the prompt now asks for) OR
67
+ // the full {source,slug,outcomeExternalMarketId} triple (back-compat for models
68
+ // that copy ids correctly). The id fields are optional here; the runner's
69
+ // resolvePmRef() fills them from the ref (or rejects a bad/missing ref) BEFORE
70
+ // the validator/act phase, which require the triple. Kept a plain `.strict()`
71
+ // object (not refined) so it stays valid inside the discriminatedUnion.
55
72
  const pmOpen = z
56
73
  .object({
57
74
  type: z.literal("pm_open"),
58
- source: z.string().min(1),
59
- slug: z.string().min(1),
60
- outcomeExternalMarketId: z.string().min(1),
61
- stakeMusd: z.number().positive(),
62
- confidence: z.number().min(0).max(1).optional(),
75
+ ref: z.string().min(1).optional(),
76
+ source: z.string().min(1).optional(),
77
+ slug: z.string().min(1).optional(),
78
+ outcomeExternalMarketId: z.string().min(1).optional(),
79
+ stakeMusd: num(z.number().positive()),
80
+ confidence,
63
81
  rationaleSummary: z.string().optional(),
64
82
  })
65
83
  .strict();
@@ -74,11 +92,58 @@ export const actionSchema = z.discriminatedUnion("type", [
74
92
  const decisionSchema = z
75
93
  .object({
76
94
  decision: z.enum(["skip", "act"]),
77
- confidence: z.number().min(0).max(1).optional(),
95
+ confidence,
78
96
  reason: z.string().optional(),
97
+ // The model's short analysis for this cycle. Allowed here so a model that
98
+ // explains its thinking isn't fail-closed by .strict(); capped so a runaway
99
+ // generation can't bloat the cycle record. Surfaced in the Arena terminal.
100
+ rationale: z.string().max(1200).optional(),
79
101
  actions: z.array(actionSchema).default([]),
80
102
  })
81
103
  .strict();
104
+ // Weak/instruct models routinely answer the decision verb with a natural-language
105
+ // synonym ("manage", "trade", "hold", "wait") instead of the strict skip|act enum
106
+ // — which fail-closed the ENTIRE cycle on a zod enum error (observed live: Carl /
107
+ // Nemotron 49B returning "manage" repeatedly, burning whole cycles). Normalize the
108
+ // common synonyms to the canonical enum before validation; anything unrecognised
109
+ // still falls through to the enum error. ("act" with no actions is already treated
110
+ // as a skip downstream, so mapping a manage-with-no-action to "act" is harmless.)
111
+ const DECISION_ALIASES = {
112
+ act: "act",
113
+ manage: "act",
114
+ trade: "act",
115
+ open: "act",
116
+ close: "act",
117
+ adjust: "act",
118
+ add: "act",
119
+ reduce: "act",
120
+ execute: "act",
121
+ enter: "act",
122
+ exit: "act",
123
+ rebalance: "act",
124
+ skip: "skip",
125
+ hold: "skip",
126
+ wait: "skip",
127
+ none: "skip",
128
+ nothing: "skip",
129
+ noop: "skip",
130
+ no_action: "skip",
131
+ pass: "skip",
132
+ monitor: "skip",
133
+ observe: "skip",
134
+ stay: "skip",
135
+ };
136
+ function normalizeDecisionVerb(obj) {
137
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
138
+ const o = obj;
139
+ if (typeof o.decision === "string") {
140
+ const mapped = DECISION_ALIASES[o.decision.trim().toLowerCase()];
141
+ if (mapped)
142
+ o.decision = mapped;
143
+ }
144
+ }
145
+ return obj;
146
+ }
82
147
  // Pull a JSON object out of a model response that may be fenced or wrapped.
83
148
  function coerceJson(text) {
84
149
  let s = text.trim();
@@ -96,7 +161,7 @@ function coerceJson(text) {
96
161
  export function parseDecision(text) {
97
162
  let obj;
98
163
  try {
99
- obj = coerceJson(text);
164
+ obj = normalizeDecisionVerb(coerceJson(text));
100
165
  }
101
166
  catch (err) {
102
167
  return { ok: false, error: `model output is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
@@ -113,6 +178,12 @@ export function parseDecision(text) {
113
178
  const actions = d.decision === "act" ? d.actions : [];
114
179
  return {
115
180
  ok: true,
116
- decision: { decision: d.decision, confidence: d.confidence, reason: d.reason, actions },
181
+ decision: {
182
+ decision: d.decision,
183
+ confidence: d.confidence,
184
+ reason: d.reason,
185
+ rationale: d.rationale,
186
+ actions,
187
+ },
117
188
  };
118
189
  }
@@ -17,7 +17,11 @@ export function validateAction(action, ctx) {
17
17
  if (ctx.writesThisCycle >= spec.limits.maxWritesPerCycle) {
18
18
  return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
19
19
  }
20
- if (ctx.writesToday >= spec.limits.maxTradesPerDay) {
20
+ // maxTradesPerDay <= 0 means UNLIMITED daily trade count — house agents are never
21
+ // throttled (we want an active Arena), and hosted agents only when the customer sets a
22
+ // positive cap. The risk caps below (daily loss, open margin, leverage, stops) are the
23
+ // real guardrails and always apply regardless of the trade-count cap.
24
+ if (spec.limits.maxTradesPerDay > 0 && ctx.writesToday >= spec.limits.maxTradesPerDay) {
21
25
  return fail("daily_trade_cap", `maxTradesPerDay ${spec.limits.maxTradesPerDay} reached`);
22
26
  }
23
27
  // Deny-list: an open on a blocked symbol is rejected up front (deny wins over
@@ -42,6 +46,20 @@ export function validateAction(action, ctx) {
42
46
  return fail("unknown_symbol", `${action.symbol} is not on the watchlist`);
43
47
  if (!entry.coinId)
44
48
  return fail("unresolved_symbol", `${action.symbol} did not resolve to a coin`);
49
+ // A futures_open on a symbol you ALREADY hold is treated as an ADD by the
50
+ // server, which REJECTS any SL/TP on an add (sl_tp_not_supported_on_add) and
51
+ // fails the whole open — the single biggest rejection class (e.g. Sam 61/61).
52
+ // Catch it here with an actionable reason: manage triggers on the held
53
+ // position via futures_set_sltp instead of re-opening with SL/TP. (Relies on
54
+ // the observation now carrying a populated `symbol` per position.)
55
+ if (action.stopLossPrice != null || action.takeProfitPrice != null) {
56
+ const heldSame = (observation.openPositions ?? []).find((p) => p.venue === "futures" &&
57
+ (p.status ?? "open") === "open" &&
58
+ (p.symbol ?? "").toUpperCase() === action.symbol.toUpperCase());
59
+ if (heldSame) {
60
+ return fail("add_cannot_carry_sltp", `already hold a ${action.symbol} futures position (#${heldSame.id}); the server rejects SL/TP on an add — manage triggers with futures_set_sltp on positionId ${heldSame.id}`);
61
+ }
62
+ }
45
63
  if (action.leverage > spec.risk.maxLeverage) {
46
64
  return fail("leverage_exceeds_cap", `leverage ${action.leverage} > cap ${spec.risk.maxLeverage}`);
47
65
  }
@@ -88,6 +106,28 @@ export function validateAction(action, ctx) {
88
106
  }
89
107
  }
90
108
  }
109
+ // Take-profit must sit on the PROFIT side of entry, mirroring the server rule
110
+ // (long: TP above mark; short: below). A wrong-side TP makes the server reject
111
+ // the ENTIRE open (take_profit_not_above_mark / take_profit_not_below_mark) so
112
+ // NO trade is placed at all — the biggest silent missed-trade failure after
113
+ // the add case. Catch it here so the model gets a clear, self-correctable
114
+ // reason (and, with markPrice now in the observation, stops producing it).
115
+ {
116
+ const tp = action.takeProfitPrice;
117
+ const e = ctx.quote?.entryPrice;
118
+ if (tp != null &&
119
+ Number.isFinite(tp) &&
120
+ tp > 0 &&
121
+ typeof e === "number" &&
122
+ Number.isFinite(e)) {
123
+ if (action.side === "long" && tp <= e) {
124
+ return fail("take_profit_wrong_side", `long take-profit ${tp} must be above entry ${e}`);
125
+ }
126
+ if (action.side === "short" && tp >= e) {
127
+ return fail("take_profit_wrong_side", `short take-profit ${tp} must be below entry ${e}`);
128
+ }
129
+ }
130
+ }
91
131
  if (!ctx.quote)
92
132
  return fail("missing_quote", "no quote evidence was fetched for this open");
93
133
  if (!ctx.quote.eligible) {
@@ -0,0 +1,22 @@
1
+ import { TriggerPolicy } from "./types.js";
2
+ export type Tier = "free_demo" | "builder" | "pro" | "byok" | "house";
3
+ export interface TierLimits {
4
+ maxLlmCallsPerHour: number;
5
+ minCadenceSeconds: number;
6
+ maxConcurrentAgents: number;
7
+ }
8
+ export declare const TIER_LIMITS: Record<Tier, TierLimits>;
9
+ export declare function applyDeploymentOverlay(requested: TriggerPolicy, tier: Tier): TriggerPolicy;
10
+ export declare function effectiveCadenceSeconds(requestedSeconds: number, tier: Tier): number;
11
+ export interface EffectivePolicyView {
12
+ tier: Tier;
13
+ requested: {
14
+ maxLlmCallsPerHour: number;
15
+ cadenceSeconds: number;
16
+ };
17
+ effective: {
18
+ maxLlmCallsPerHour: number;
19
+ cadenceSeconds: number;
20
+ };
21
+ }
22
+ export declare function effectivePolicyView(requested: TriggerPolicy, requestedCadenceSeconds: number, tier: Tier): EffectivePolicyView;
@@ -0,0 +1,55 @@
1
+ // The deployment overlay — CoinRithm's server-side AUTHORITY over an agent's
2
+ // runtime capacity. The OKF declares INTENT (its TriggerPolicy + cadence); the
3
+ // platform caps it to the agent's effective TIER here. It only ever TIGHTENS,
4
+ // never widens — so a forked OKF that sets `tier: pro` or `maxLlmCallsPerHour: 999`
5
+ // can't self-grant capacity. This is where "the OKF asks, CoinRithm decides" lives,
6
+ // and it's the prerequisite the monetization rule names: no paid tier without the
7
+ // overlay. (Billing/Stripe wires a real tier onto the agent later; until then every
8
+ // agent runs on a default tier and this still enforces it.)
9
+ // Effective caps per tier. Tightenable later from config; these are the defaults.
10
+ export const TIER_LIMITS = {
11
+ free_demo: { maxLlmCallsPerHour: 4, minCadenceSeconds: 3600, maxConcurrentAgents: 1 },
12
+ builder: { maxLlmCallsPerHour: 20, minCadenceSeconds: 900, maxConcurrentAgents: 3 },
13
+ pro: { maxLlmCallsPerHour: 120, minCadenceSeconds: 60, maxConcurrentAgents: 10 },
14
+ // BYO key = the user's own model quota, so we don't cap their calls; we still
15
+ // host + meter + verify (that's what they pay the infra fee for).
16
+ byok: { maxLlmCallsPerHour: 0, minCadenceSeconds: 60, maxConcurrentAgents: 5 },
17
+ // The house showcase fleet — uncapped, runs on our pooled keys.
18
+ house: { maxLlmCallsPerHour: 0, minCadenceSeconds: 60, maxConcurrentAgents: 0 },
19
+ };
20
+ // Tighten one numeric cap: 0 means "unlimited" on either side. The tier always wins
21
+ // where it imposes a finite cap; it can never raise a request.
22
+ function tighten(requested, tierCap) {
23
+ if (tierCap === 0)
24
+ return requested; // tier unlimited -> honor the request
25
+ if (requested === 0)
26
+ return tierCap; // request unlimited -> tier caps it
27
+ return Math.min(requested, tierCap); // both finite -> the tighter wins
28
+ }
29
+ // Cap the OKF's requested TriggerPolicy by the tier. NEVER widens.
30
+ export function applyDeploymentOverlay(requested, tier) {
31
+ const lim = TIER_LIMITS[tier];
32
+ return {
33
+ ...requested,
34
+ maxLlmCallsPerHour: tighten(requested.maxLlmCallsPerHour, lim.maxLlmCallsPerHour),
35
+ };
36
+ }
37
+ // Effective cadence (seconds): the agent's requested cadence floored by the tier
38
+ // (a free agent can't run every minute even if its OKF asks to).
39
+ export function effectiveCadenceSeconds(requestedSeconds, tier) {
40
+ return Math.max(requestedSeconds, TIER_LIMITS[tier].minCadenceSeconds);
41
+ }
42
+ export function effectivePolicyView(requested, requestedCadenceSeconds, tier) {
43
+ const eff = applyDeploymentOverlay(requested, tier);
44
+ return {
45
+ tier,
46
+ requested: {
47
+ maxLlmCallsPerHour: requested.maxLlmCallsPerHour,
48
+ cadenceSeconds: requestedCadenceSeconds,
49
+ },
50
+ effective: {
51
+ maxLlmCallsPerHour: eff.maxLlmCallsPerHour,
52
+ cadenceSeconds: effectiveCadenceSeconds(requestedCadenceSeconds, tier),
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,9 @@
1
+ import { Observation, RunState, TriggerPolicy } from "./types.js";
2
+ export interface GateResult {
3
+ fire: boolean;
4
+ codes: string[];
5
+ reason: string;
6
+ }
7
+ export declare function evaluateGate(observation: Observation, state: RunState, policy: TriggerPolicy, nowMs: number): GateResult;
8
+ export declare function noteLlmCall(state: RunState, codes: string[], nowMs: number): void;
9
+ export declare function estimateCostUsd(provider: string, tokensIn: number, tokensOut: number): number;
@@ -0,0 +1,114 @@
1
+ // Slice-2 preflight gate: decides whether a cycle SPENDS an LLM call.
2
+ //
3
+ // content-engine's lesson made load-bearing: don't pay the model to stare at a
4
+ // flat tape. A cycle only "fires" (calls the LLM) when a deterministic trigger is
5
+ // present — a flagged entry setup, or an open position to manage. No trigger => a
6
+ // cheap heartbeat skip, zero tokens. This is the cost/scale win (a free pool hosts
7
+ // far more agents), the agentic feel (waits, then strikes), AND the metering
8
+ // substrate (every cycle records what fired).
9
+ //
10
+ // Pure + deterministic: the caller injects nowMs so debounce/budget are testable
11
+ // without timers. The gate NEVER widens a hard cap — it only decides whether to
12
+ // think; the runner still validates every action against the caps.
13
+ // Abs unrealized PnL (mUSD) on a single open position that counts as a swing worth
14
+ // a fresh look even if no entry trigger fired.
15
+ const BIG_SWING_MUSD = 150;
16
+ // Map a flagged setup to its entry trigger code.
17
+ function entryCode(s) {
18
+ switch (s.kind) {
19
+ case "breakout":
20
+ return "PRICE_BREAKOUT";
21
+ case "breakdown":
22
+ return "PRICE_BREAKDOWN";
23
+ case "uptrend":
24
+ case "downtrend":
25
+ return "MOMENTUM_TREND";
26
+ case "stretched":
27
+ return "RSI_EXTREME";
28
+ }
29
+ }
30
+ export function evaluateGate(observation, state, policy, nowMs) {
31
+ const codes = new Set();
32
+ // ENTRY triggers: a flagged setup we do NOT already hold is a fresh entry signal.
33
+ for (const s of observation.setups) {
34
+ if (!s.held)
35
+ codes.add(entryCode(s));
36
+ }
37
+ // MANAGE triggers: an open position is always evaluated through the manage path.
38
+ const hasPosition = observation.openPositions.length > 0;
39
+ if (hasPosition && policy.alwaysManageOpenPositions) {
40
+ codes.add("POSITION_OPEN");
41
+ for (const p of observation.openPositions) {
42
+ if (Math.abs(p.unrealizedPnlMusd ?? 0) >= BIG_SWING_MUSD) {
43
+ codes.add("POSITION_BIG_PNL_SWING");
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ const codeList = [...codes];
49
+ // Legacy / explicit always-on: never gate (call every cycle).
50
+ if (policy.mode === "always" || !policy.skipLlmWhenNoTrigger) {
51
+ return {
52
+ fire: true,
53
+ codes: codeList,
54
+ reason: codeList.length ? `triggers: ${codeList.join(",")}` : "always-on",
55
+ };
56
+ }
57
+ // No price setup and no open position. Before skipping, periodically wake to
58
+ // evaluate PREDICTION MARKETS — they carry edge even when crypto prices are flat,
59
+ // so an agent on a quiet tape shouldn't go dark on PM. At most once per cooldown
60
+ // (gated on the last LLM call, which any fire resets), so it's not every cycle.
61
+ if (codeList.length === 0) {
62
+ const pmAvailable = observation.pmMarkets.length > 0;
63
+ const sinceLastCall = state.lastLlmCallAt == null ? Infinity : nowMs - state.lastLlmCallAt;
64
+ if (pmAvailable &&
65
+ policy.pmEvalCooldownMinutes > 0 &&
66
+ sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
67
+ return { fire: true, codes: ["PM_PERIODIC"], reason: "PM periodic eval (quiet price tape)" };
68
+ }
69
+ return { fire: false, codes: [], reason: "no trigger (flat tape, no open position)" };
70
+ }
71
+ // A real trigger exists. Open positions are NEVER starved by budget/debounce
72
+ // (managing a live position is always allowed); the caps below only throttle
73
+ // fresh entry-only cycles so a chop-storm of entry setups can't burn the budget.
74
+ if (!hasPosition) {
75
+ if (policy.maxLlmCallsPerHour > 0) {
76
+ const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
77
+ if (recent.length >= policy.maxLlmCallsPerHour) {
78
+ return { fire: false, codes: codeList, reason: `hourly LLM budget ${policy.maxLlmCallsPerHour} reached` };
79
+ }
80
+ }
81
+ if (policy.debounceMinutes > 0) {
82
+ const fp = [...codeList].sort().join(",");
83
+ if (state.lastTriggerFingerprint === fp &&
84
+ state.lastLlmCallAt != null &&
85
+ nowMs - state.lastLlmCallAt < policy.debounceMinutes * 60_000) {
86
+ return { fire: false, codes: codeList, reason: `debounced (same triggers within ${policy.debounceMinutes}m)` };
87
+ }
88
+ }
89
+ }
90
+ return { fire: true, codes: codeList, reason: `triggers: ${codeList.join(",")}` };
91
+ }
92
+ // Record that this cycle spent an LLM call — feeds the budget + debounce next
93
+ // cycle. Mutates state; the caller persists it.
94
+ export function noteLlmCall(state, codes, nowMs) {
95
+ state.llmCallTimestamps = [...(state.llmCallTimestamps ?? []), nowMs]
96
+ .filter((t) => nowMs - t < 3_600_000)
97
+ .slice(-200);
98
+ state.lastLlmCallAt = nowMs;
99
+ state.lastTriggerFingerprint = [...codes].sort().join(",");
100
+ }
101
+ // Notional cost from a coarse per-provider blended rate ($/1M tokens). Free tiers
102
+ // are ~$0; the number exists so tier pricing has real usage data to model from.
103
+ const RATE_PER_MTOK = {
104
+ anthropic: 6,
105
+ openai: 2.5,
106
+ gemini: 0.3,
107
+ groq: 0.1,
108
+ nvidia: 0, // free hosted tier
109
+ "openai-compatible": 0.5,
110
+ };
111
+ export function estimateCostUsd(provider, tokensIn, tokensOut) {
112
+ const rate = RATE_PER_MTOK[provider] ?? 0;
113
+ return Math.round(((tokensIn + tokensOut) / 1_000_000) * rate * 1e6) / 1e6;
114
+ }
@@ -119,14 +119,29 @@ export function computeIndicators(candles, opts = {}) {
119
119
  const ema50 = ema(closes, emaSlow);
120
120
  // Breakout vs the window BEFORE the latest candle (exclude the current bar).
121
121
  const prior = recentHighLow(candles.slice(0, -1), breakoutLookback);
122
+ const atr14 = atr(candles, atrPeriod);
123
+ const bb = bollinger(closes, bbPeriod);
124
+ const r20 = recentHighLow(candles, breakoutLookback);
125
+ const rsi14 = rsi(closes, rsiPeriod);
126
+ // Round numbers the model sees/echoes to clean significant figures, so reasoning
127
+ // reads "recent20 low 6.29216" not "6.292164535198927" — and stays clean for both
128
+ // $62k coins and sub-cent ones. The booleans below use the RAW locals, so the
129
+ // trend/breakout reads are unaffected.
130
+ const sig = (n, figs = 6) => {
131
+ if (!Number.isFinite(n) || n === 0)
132
+ return n;
133
+ const f = Math.pow(10, figs - Math.ceil(Math.log10(Math.abs(n))));
134
+ return Math.round(n * f) / f;
135
+ };
136
+ const sigN = (n, figs = 6) => (n == null ? null : sig(n, figs));
122
137
  return {
123
- asOfClose: close,
124
- rsi14: rsi(closes, rsiPeriod),
125
- ema20,
126
- ema50,
127
- atr14: atr(candles, atrPeriod),
128
- bollinger: bollinger(closes, bbPeriod),
129
- recent20: recentHighLow(candles, breakoutLookback),
138
+ asOfClose: sig(close),
139
+ rsi14: rsi14 == null ? null : Math.round(rsi14 * 10) / 10,
140
+ ema20: sigN(ema20),
141
+ ema50: sigN(ema50),
142
+ atr14: sigN(atr14),
143
+ bollinger: bb == null ? null : { upper: sig(bb.upper), mid: sig(bb.mid), lower: sig(bb.lower) },
144
+ recent20: r20 == null ? null : { high: sig(r20.high), low: sig(r20.low) },
130
145
  aboveEma20: ema20 == null ? null : close > ema20,
131
146
  ema20AboveEma50: ema20 == null || ema50 == null ? null : ema20 > ema50,
132
147
  brokeRecentHigh: prior == null ? null : close >= prior.high,