@coinrithm/mcp-trading 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/README.md +36 -9
- package/dist/agent/act.js +8 -1
- package/dist/agent/cli.d.ts +1 -0
- package/dist/agent/cli.js +58 -4
- package/dist/agent/client.d.ts +8 -1
- package/dist/agent/client.js +14 -2
- package/dist/agent/decision.d.ts +66 -63
- package/dist/agent/decision.js +95 -24
- package/dist/agent/decisionValidator.js +41 -1
- package/dist/agent/deploymentOverlay.d.ts +22 -0
- package/dist/agent/deploymentOverlay.js +55 -0
- package/dist/agent/gate.d.ts +9 -0
- package/dist/agent/gate.js +114 -0
- package/dist/agent/indicators.js +22 -7
- package/dist/agent/observe.js +201 -18
- package/dist/agent/prompt.d.ts +6 -2
- package/dist/agent/prompt.js +116 -26
- package/dist/agent/providers.d.ts +6 -0
- package/dist/agent/providers.js +89 -12
- package/dist/agent/resolve.js +28 -0
- package/dist/agent/resolvePm.d.ts +14 -0
- package/dist/agent/resolvePm.js +69 -0
- package/dist/agent/runner.d.ts +6 -1
- package/dist/agent/runner.js +312 -10
- package/dist/agent/scorecard.d.ts +24 -0
- package/dist/agent/scorecard.js +177 -0
- package/dist/agent/setups.d.ts +3 -0
- package/dist/agent/setups.js +133 -0
- package/dist/agent/skill.d.ts +1 -0
- package/dist/agent/skill.js +21 -3
- package/dist/agent/skillValidator.js +4 -2
- package/dist/agent/state.js +10 -2
- package/dist/agent/templates.js +8 -4
- package/dist/agent/types.d.ts +75 -2
- package/dist/agent/types.js +14 -1
- package/dist/agent/version.d.ts +2 -2
- package/dist/agent/version.js +12 -2
- package/dist/client.d.ts +2 -0
- package/dist/tools.js +28 -5
- package/package.json +1 -1
package/dist/agent/decision.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/agent/indicators.js
CHANGED
|
@@ -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:
|
|
125
|
-
ema20,
|
|
126
|
-
ema50,
|
|
127
|
-
atr14:
|
|
128
|
-
bollinger:
|
|
129
|
-
recent20:
|
|
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,
|