@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/observe.js
CHANGED
|
@@ -3,10 +3,31 @@
|
|
|
3
3
|
// required read fails, or no watchlist symbol resolves, the cycle SKIPS writes.
|
|
4
4
|
import { asObj, asArr, asNum, asStr } from "./extract.js";
|
|
5
5
|
import { computeIndicators } from "./indicators.js";
|
|
6
|
+
import { scanSetups } from "./setups.js";
|
|
6
7
|
// Candle granularity feeding the indicators: the 1D range = 5-minute candles
|
|
7
8
|
// (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
|
|
8
9
|
// short cadence the hosted house agents run on. Probe-verified 2026-06-17.
|
|
9
10
|
const INDICATOR_RANGE = "1D";
|
|
11
|
+
// Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
|
|
12
|
+
// discovers PM markets about the coins it actually has a price view on.
|
|
13
|
+
const PM_COIN_NAMES = {
|
|
14
|
+
BTC: "Bitcoin",
|
|
15
|
+
ETH: "Ethereum",
|
|
16
|
+
SOL: "Solana",
|
|
17
|
+
XRP: "XRP",
|
|
18
|
+
DOGE: "Dogecoin",
|
|
19
|
+
ADA: "Cardano",
|
|
20
|
+
AVAX: "Avalanche",
|
|
21
|
+
LINK: "Chainlink",
|
|
22
|
+
BNB: "BNB",
|
|
23
|
+
MATIC: "Polygon",
|
|
24
|
+
DOT: "Polkadot",
|
|
25
|
+
LTC: "Litecoin",
|
|
26
|
+
SHIB: "Shiba",
|
|
27
|
+
TRX: "Tron",
|
|
28
|
+
UNI: "Uniswap",
|
|
29
|
+
SUI: "Sui",
|
|
30
|
+
};
|
|
10
31
|
// Fetch candles for one coin and reduce them to a compact indicator bundle.
|
|
11
32
|
// Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
|
|
12
33
|
// null so the cycle proceeds with price-only context rather than skipping.
|
|
@@ -42,8 +63,10 @@ function emptyObservation(state, scopes = []) {
|
|
|
42
63
|
openPositions: [],
|
|
43
64
|
openOrders: [],
|
|
44
65
|
pmPositions: [],
|
|
66
|
+
pmResolutions: [],
|
|
45
67
|
pmMarkets: [],
|
|
46
68
|
watch: [],
|
|
69
|
+
setups: [],
|
|
47
70
|
syncCursor: state.cursor,
|
|
48
71
|
newClosedTrades: [],
|
|
49
72
|
polledBeforeWrite: false,
|
|
@@ -75,21 +98,41 @@ export async function observe(client, spec, state, trace) {
|
|
|
75
98
|
const openPositions = asArr(asObj(posR.data).positions)
|
|
76
99
|
.map(asObj)
|
|
77
100
|
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
78
|
-
.map((p) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
.map((p) => {
|
|
102
|
+
// /positions/futures returns the coin NESTED ({ucid,symbol,name}); the old
|
|
103
|
+
// p.coinId/p.symbol reads were undefined (same field-drift class as the PM
|
|
104
|
+
// dup-guard bug) — the model couldn't tell which coin a position was on.
|
|
105
|
+
// It ALSO dropped every per-position price the backend already returns, so
|
|
106
|
+
// the model proposed SL/TP blind to mark + liquidation (→ the
|
|
107
|
+
// take_profit_not_*_mark + stop_loss_not_above_liquidation reject waves)
|
|
108
|
+
// and could not tell a winner from a small loser before a manual close.
|
|
109
|
+
// Tolerant fallbacks keep older/mocked shapes working.
|
|
110
|
+
const coin = asObj(p.coin);
|
|
111
|
+
return {
|
|
112
|
+
venue: "futures",
|
|
113
|
+
id: Number(asNum(p.id) ?? p.id),
|
|
114
|
+
coinId: asStr(coin.ucid) ?? asStr(p.coinId),
|
|
115
|
+
symbol: asStr(coin.symbol) ?? asStr(p.symbol),
|
|
116
|
+
side: asStr(p.side),
|
|
117
|
+
status: asStr(p.status) ?? "open",
|
|
118
|
+
leverage: asNum(p.leverage),
|
|
119
|
+
marginMusd: asNum(p.marginMusd),
|
|
120
|
+
unrealizedPnlMusd: asNum(p.unrealizedPnlMusd),
|
|
121
|
+
entryPrice: asNum(p.entryPrice),
|
|
122
|
+
markPrice: asNum(p.markPrice),
|
|
123
|
+
liquidationPrice: asNum(p.liquidationPrice),
|
|
124
|
+
stopLossPrice: asNum(p.stopLossPrice),
|
|
125
|
+
takeProfitPrice: asNum(p.takeProfitPrice),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
88
128
|
// Sync poll: /trades since the persisted cursor.
|
|
89
129
|
const tradesR = await client.trades({
|
|
90
130
|
venue: "futures",
|
|
91
131
|
updatedSince: state.cursor ?? undefined,
|
|
92
|
-
|
|
132
|
+
// Cap the sync poll: an unbounded fetch against a SHARED trade book (or an
|
|
133
|
+
// old cursor) could pull thousands of rows into the prompt. 50 newest is
|
|
134
|
+
// ample for the agent to react to its own fills/stops since last cycle.
|
|
135
|
+
limit: state.cursor ? 50 : 1,
|
|
93
136
|
}, trace);
|
|
94
137
|
let polledBeforeWrite = false;
|
|
95
138
|
let newClosedTrades = [];
|
|
@@ -105,7 +148,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
105
148
|
// Watchlist market context.
|
|
106
149
|
const watch = [];
|
|
107
150
|
let resolvedAny = false;
|
|
151
|
+
// Bounded RAG: the market-wide Fear & Greed regime, captured once from the first
|
|
152
|
+
// coin's /market context (it's market-wide, identical across coins).
|
|
153
|
+
let marketMood;
|
|
108
154
|
const wantIndicators = spec.capabilities.includes("indicators");
|
|
155
|
+
const wantNews = spec.capabilities.includes("news");
|
|
109
156
|
for (const symbol of spec.risk.watchlist) {
|
|
110
157
|
const rs = await client.resolve(symbol, trace);
|
|
111
158
|
const match = asObj(asObj(rs.data).match);
|
|
@@ -126,9 +173,18 @@ export async function observe(client, spec, state, trace) {
|
|
|
126
173
|
change1h: asNum(price.change1h),
|
|
127
174
|
change24h: asNum(price.change24h),
|
|
128
175
|
change7d: asNum(price.change7d),
|
|
176
|
+
// Community sentiment (already in the /market context, was stripped).
|
|
177
|
+
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
129
178
|
// Freshness lives under the response's `observation` block.
|
|
130
179
|
freshness: freshnessOf(asObj(m.observation)),
|
|
131
180
|
};
|
|
181
|
+
// Capture the market-wide Fear & Greed regime once (same across coins).
|
|
182
|
+
if (!marketMood) {
|
|
183
|
+
const fg = asObj(m.fearGreed);
|
|
184
|
+
const v = asNum(fg.value);
|
|
185
|
+
if (v != null)
|
|
186
|
+
marketMood = { fearGreed: v, label: asStr(fg.label) ?? "" };
|
|
187
|
+
}
|
|
132
188
|
// `indicators` capability: enrich the observation with computed TA so the
|
|
133
189
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
134
190
|
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
@@ -163,27 +219,94 @@ export async function observe(client, spec, state, trace) {
|
|
|
163
219
|
}
|
|
164
220
|
// PM open positions + discovered quote-ready candidates — only if pm enabled.
|
|
165
221
|
let pmPositions = [];
|
|
222
|
+
let pmResolutions = [];
|
|
166
223
|
let pmMarkets = [];
|
|
167
224
|
if (wantPm) {
|
|
168
|
-
|
|
225
|
+
// Bias PM discovery toward CRYPTO markets the agent has a price view on — the
|
|
226
|
+
// only PM edge a price agent reliably has (probed 2026-06-24: the default board
|
|
227
|
+
// is World Cup / elections / F1, which an agent has no edge on). The discover
|
|
228
|
+
// `q` is an AND/phrase match, so query ONE coin — the agent's TOP watchlist coin,
|
|
229
|
+
// where its price view is sharpest — never the joined list (matches ~nothing).
|
|
230
|
+
// Fall back to Bitcoin (always plentiful) — NEVER the general non-crypto board.
|
|
231
|
+
const topCoin = (spec.risk.watchlist[0] ?? "").toUpperCase();
|
|
232
|
+
const pmQuery = PM_COIN_NAMES[topCoin] ?? spec.risk.watchlist[0] ?? "Bitcoin";
|
|
233
|
+
const [pmPosR, pmDiscFirst] = await Promise.all([
|
|
169
234
|
client.pmPositions(undefined, trace),
|
|
170
|
-
client.discoverPmMarkets({ limit:
|
|
235
|
+
client.discoverPmMarkets({ q: pmQuery, limit: 12 }, trace),
|
|
171
236
|
]);
|
|
237
|
+
let pmDiscR = pmDiscFirst;
|
|
238
|
+
const firstCount = pmDiscR.ok
|
|
239
|
+
? asArr(asObj(pmDiscR.data).data ??
|
|
240
|
+
asObj(pmDiscR.data).markets ??
|
|
241
|
+
asObj(pmDiscR.data).results).length
|
|
242
|
+
: 0;
|
|
243
|
+
if (firstCount < 3 && pmQuery !== "Bitcoin") {
|
|
244
|
+
const fb = await client.discoverPmMarkets({ q: "Bitcoin", limit: 12 }, trace);
|
|
245
|
+
if (fb.ok)
|
|
246
|
+
pmDiscR = fb;
|
|
247
|
+
}
|
|
172
248
|
if (pmPosR.ok) {
|
|
173
249
|
pmPositions = asArr(asObj(pmPosR.data).positions)
|
|
174
250
|
.map(asObj)
|
|
175
251
|
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
176
252
|
.map((p) => ({
|
|
177
253
|
id: Number(asNum(p.id) ?? p.id),
|
|
254
|
+
// The /positions/pm API returns `eventSlug` and the outcome id NESTED at
|
|
255
|
+
// outcome.externalMarketId — NOT `slug` / `outcomeExternalMarketId`.
|
|
256
|
+
// Reading the wrong keys left both undefined, which silently broke the
|
|
257
|
+
// PM anti-churn guard (it could never match a held position) AND the
|
|
258
|
+
// model's view of what it holds. Tolerant fallbacks keep older/mocked
|
|
259
|
+
// shapes working.
|
|
178
260
|
source: asStr(p.source),
|
|
179
|
-
slug: asStr(p.slug),
|
|
180
|
-
outcomeExternalMarketId: asStr(p.
|
|
261
|
+
slug: asStr(p.eventSlug) ?? asStr(p.slug),
|
|
262
|
+
outcomeExternalMarketId: asStr(asObj(p.outcome).externalMarketId) ??
|
|
263
|
+
asStr(p.outcomeExternalMarketId),
|
|
181
264
|
stakeMusd: asNum(p.stakeMusd),
|
|
265
|
+
// Mark-to-market unrealized (field is `unrealizedPnl` on /positions/pm).
|
|
266
|
+
// Feeds the equity-drawdown kill-switch so a large PM book that marks
|
|
267
|
+
// down trips the stop too — not just futures.
|
|
268
|
+
unrealizedPnlMusd: asNum(p.unrealizedPnl) ?? asNum(p.unrealizedPnlMusd),
|
|
182
269
|
status: asStr(p.status) ?? "open",
|
|
183
270
|
}));
|
|
271
|
+
// Settlement-feedback loop: the SAME /positions/pm response carries an
|
|
272
|
+
// additive `recentlyResolved` array — the agent's OWN bets that settled
|
|
273
|
+
// win/loss/void since last cycle, with realized pnl. Surface it as reflective
|
|
274
|
+
// context so the model learns from how its predictions actually resolved
|
|
275
|
+
// (reinforce what worked, avoid what didn't). NOT an action — the runner never
|
|
276
|
+
// bets off this. Fail-safe: an absent/old backend omits the key → [] (the
|
|
277
|
+
// ?? [] in asArr + the guarded map), so this never breaks the open feed.
|
|
278
|
+
pmResolutions = asArr(asObj(pmPosR.data).recentlyResolved)
|
|
279
|
+
.map(asObj)
|
|
280
|
+
.map((r) => ({
|
|
281
|
+
id: Number(asNum(r.id) ?? r.id),
|
|
282
|
+
// The backend nests the outcome label/title; carry the human-readable
|
|
283
|
+
// title (or fall back to the slug) so the prompt can name the market.
|
|
284
|
+
eventTitle: asStr(r.eventTitle) ?? asStr(asObj(r.event).title) ?? undefined,
|
|
285
|
+
slug: asStr(r.eventSlug) ?? asStr(r.slug),
|
|
286
|
+
side: asStr(r.side),
|
|
287
|
+
status: asStr(r.status),
|
|
288
|
+
pnlMusd: asNum(r.pnlMusd),
|
|
289
|
+
stakeMusd: asNum(r.stakeMusd),
|
|
290
|
+
}))
|
|
291
|
+
// A resolution with no id is unusable for the model's reflection; drop it.
|
|
292
|
+
.filter((r) => Number.isFinite(r.id))
|
|
293
|
+
// Bound the block: a short recent window is enough reflective context, and
|
|
294
|
+
// the backend already caps at ~25; cap again so a noisy response can't bloat
|
|
295
|
+
// the prompt.
|
|
296
|
+
.slice(0, 25);
|
|
184
297
|
}
|
|
185
298
|
if (pmDiscR.ok) {
|
|
186
299
|
const dd = asObj(pmDiscR.data);
|
|
300
|
+
// Anti-churn: exclude markets the agent ALREADY holds an open position in
|
|
301
|
+
// from the candidate list BEFORE it reaches the prompt — so the model never
|
|
302
|
+
// sees (and re-picks) a held market only to have the runner/server reject it
|
|
303
|
+
// as a duplicate, burning a whole cycle. Keyed source|slug|outcomeExternalMarketId
|
|
304
|
+
// (lower-cased to match the discover rows below). The runner preflight guard
|
|
305
|
+
// (duplicate_intent) + server dedup (duplicate_open) remain the backstops.
|
|
306
|
+
// Side-agnostic = no re-bet/hedge on a held outcome, matching the runner policy.
|
|
307
|
+
const heldPmKeys = new Set(pmPositions
|
|
308
|
+
.filter((p) => (p.status ?? "open") === "open")
|
|
309
|
+
.map((p) => `${(p.source ?? "").toLowerCase()}|${(p.slug ?? "").toLowerCase()}|${p.outcomeExternalMarketId ?? ""}`));
|
|
187
310
|
// Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
|
|
188
311
|
// Each EVENT carries source/slug/title/freshness at the top level and the
|
|
189
312
|
// quoteable id NESTED at outcomes[].externalMarketId — so expand one
|
|
@@ -194,9 +317,21 @@ export async function observe(client, spec, state, trace) {
|
|
|
194
317
|
.flatMap((ev) => {
|
|
195
318
|
const source = (asStr(ev.source) ?? "").toLowerCase();
|
|
196
319
|
const slug = (asStr(ev.slug) ?? "").toLowerCase();
|
|
197
|
-
|
|
320
|
+
// Keep titles SHORT: the model only needs to recognise the market.
|
|
321
|
+
// Untrimmed titles, one per outcome across many events, ballooned the
|
|
322
|
+
// prompt to ~69k tokens (413s on small-context free models).
|
|
323
|
+
const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
|
|
198
324
|
const freshness = freshnessOf(ev); // freshness is event-level
|
|
199
|
-
|
|
325
|
+
// At most a few outcomes per event so a wide multi-outcome market
|
|
326
|
+
// (e.g. dozens of price buckets) can't explode the prompt. Drop
|
|
327
|
+
// outcomes the backend flagged NOT openable (eligible === false) so the
|
|
328
|
+
// model never bets a market that would fail the binary entry gate at
|
|
329
|
+
// quote. Back-compat: an older backend omits `eligible` (undefined) →
|
|
330
|
+
// the outcome is kept (current behaviour).
|
|
331
|
+
const outcomes = asArr(ev.outcomes)
|
|
332
|
+
.map(asObj)
|
|
333
|
+
.filter((o) => o.eligible !== false)
|
|
334
|
+
.slice(0, 3);
|
|
200
335
|
// A market with no outcomes array still round-trips a flat fallback row.
|
|
201
336
|
const rows = outcomes.length > 0 ? outcomes : [ev];
|
|
202
337
|
return rows.map((o) => ({
|
|
@@ -205,11 +340,51 @@ export async function observe(client, spec, state, trace) {
|
|
|
205
340
|
outcomeExternalMarketId: asStr(o.externalMarketId) ??
|
|
206
341
|
asStr(o.outcomeExternalMarketId) ??
|
|
207
342
|
"",
|
|
343
|
+
// Carry the odds through: the model needs the outcome label + current
|
|
344
|
+
// probability to spot a mispriced market and bet it (was stripped).
|
|
345
|
+
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
346
|
+
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
347
|
+
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
348
|
+
probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
|
|
208
349
|
title,
|
|
209
350
|
freshness,
|
|
210
351
|
}));
|
|
211
352
|
})
|
|
212
|
-
.filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
|
|
353
|
+
.filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
|
|
354
|
+
// Drop already-held markets (see heldPmKeys above) so the model only sees
|
|
355
|
+
// markets it can actually open — done BEFORE the slice so held positions
|
|
356
|
+
// don't consume the limited candidate slots.
|
|
357
|
+
.filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`))
|
|
358
|
+
// Hard cap the PM block: a handful of fresh markets is plenty to pick from.
|
|
359
|
+
.slice(0, 12)
|
|
360
|
+
// Stamp a short, stable per-cycle ref (pm1…pmN) the model copies instead of
|
|
361
|
+
// the long outcomeExternalMarketId. Assigned AFTER the slice so refs are a
|
|
362
|
+
// contiguous 1..N matching exactly what the prompt shows.
|
|
363
|
+
.map((m, i) => ({ ...m, ref: `pm${i + 1}` }));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// News context (only with the `news` capability): recent high-importance news
|
|
367
|
+
// for the watchlist coins, fed into the decide prompt as a market-catalyst
|
|
368
|
+
// layer the price chart can't show. One cached call; degrades to no news on
|
|
369
|
+
// failure (never blocks a cycle).
|
|
370
|
+
let news;
|
|
371
|
+
if (wantNews && spec.risk.watchlist.length > 0) {
|
|
372
|
+
const nr = await client.agentNews({ coins: spec.risk.watchlist.join(","), limit: 8, hours: 48 }, trace);
|
|
373
|
+
if (nr.ok) {
|
|
374
|
+
news = asArr(asObj(nr.data).items)
|
|
375
|
+
.map(asObj)
|
|
376
|
+
.map((it) => ({
|
|
377
|
+
title: (asStr(it.title) ?? "").slice(0, 160),
|
|
378
|
+
source: asStr(it.source) ?? undefined,
|
|
379
|
+
sentiment: asStr(it.sentiment) ?? undefined,
|
|
380
|
+
importance: asNum(it.importance) ?? undefined,
|
|
381
|
+
ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
|
|
382
|
+
coins: asArr(it.coins)
|
|
383
|
+
.map((c) => asStr(c))
|
|
384
|
+
.filter((c) => !!c),
|
|
385
|
+
}))
|
|
386
|
+
.filter((n) => n.title.length > 0)
|
|
387
|
+
.slice(0, 6);
|
|
213
388
|
}
|
|
214
389
|
}
|
|
215
390
|
const observation = {
|
|
@@ -220,8 +395,16 @@ export async function observe(client, spec, state, trace) {
|
|
|
220
395
|
openPositions,
|
|
221
396
|
openOrders,
|
|
222
397
|
pmPositions,
|
|
398
|
+
pmResolutions,
|
|
223
399
|
pmMarkets,
|
|
224
400
|
watch,
|
|
401
|
+
news,
|
|
402
|
+
// Deterministic structure flags computed from the watch indicators — the
|
|
403
|
+
// model acts on these instead of re-deciding "is there a setup?" from scratch.
|
|
404
|
+
// openPositions are passed so setups on a held symbol are tagged "manage,
|
|
405
|
+
// don't re-open".
|
|
406
|
+
setups: scanSetups(watch, openPositions),
|
|
407
|
+
marketMood,
|
|
225
408
|
syncCursor,
|
|
226
409
|
newClosedTrades,
|
|
227
410
|
polledBeforeWrite,
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
import { AgentSpec, Observation } from "./types.js";
|
|
1
|
+
import { AgentSpec, Observation, PmResolution } from "./types.js";
|
|
2
|
+
export declare function formatPmResolutions(resolutions: PmResolution[]): string[];
|
|
2
3
|
export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string): string;
|
|
3
|
-
export declare function buildUserPrompt(obs: Observation
|
|
4
|
+
export declare function buildUserPrompt(obs: Observation, journal?: Array<{
|
|
5
|
+
at: string;
|
|
6
|
+
did: string;
|
|
7
|
+
}>): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -2,18 +2,49 @@
|
|
|
2
2
|
// static character (cached prefix); the user prompt is the fresh observation.
|
|
3
3
|
// The model only PROPOSES — the runner re-checks every action against the caps,
|
|
4
4
|
// so the prompt states the caps but never relies on the model to honor them.
|
|
5
|
+
// Format the settlement-feedback block: a concise, natural-language recap of the
|
|
6
|
+
// agent's OWN PM bets that resolved since the last cycle, so the model can REFLECT
|
|
7
|
+
// (reinforce what worked, avoid what didn't). Capped + compact — this is context,
|
|
8
|
+
// not a new action. Returns [] when there is nothing to show. Example line:
|
|
9
|
+
// - "Will BTC top $80k?" — YES, WON +320 mUSD; "ETH flips SOL by Fri?" — NO,
|
|
10
|
+
// LOST -100 mUSD; "Election tie?" — VOID (stake refunded).
|
|
11
|
+
export function formatPmResolutions(resolutions) {
|
|
12
|
+
if (!resolutions || resolutions.length === 0)
|
|
13
|
+
return [];
|
|
14
|
+
// Cap defensively (the backend + observe already cap at ~25); a handful is the
|
|
15
|
+
// useful reflective window and keeps the prompt small.
|
|
16
|
+
const items = resolutions.slice(0, 12).map((r) => {
|
|
17
|
+
const title = (r.eventTitle ?? r.slug ?? "(market)").slice(0, 70);
|
|
18
|
+
const side = (r.side ?? "").toUpperCase();
|
|
19
|
+
const sidePart = side ? `${side}, ` : "";
|
|
20
|
+
if (r.status === "void_refunded") {
|
|
21
|
+
return `"${title}" — ${sidePart}VOID (stake refunded)`;
|
|
22
|
+
}
|
|
23
|
+
const outcome = r.status === "settled_win" ? "WON" : "LOST";
|
|
24
|
+
const pnl = typeof r.pnlMusd === "number"
|
|
25
|
+
? ` ${r.pnlMusd >= 0 ? "+" : ""}${Math.round(r.pnlMusd)} mUSD`
|
|
26
|
+
: "";
|
|
27
|
+
return `"${title}" — ${sidePart}${outcome}${pnl}`;
|
|
28
|
+
});
|
|
29
|
+
return [
|
|
30
|
+
"",
|
|
31
|
+
"## Your prediction markets that just resolved (settlement feedback — learn from these)",
|
|
32
|
+
"These are YOUR OWN PM bets that settled since last cycle. Reflect: where your read was RIGHT, lean into that edge; where it was WRONG, adjust. This is context to learn from, NOT a position to manage (they are closed).",
|
|
33
|
+
`Resolved since last cycle: ${items.join("; ")}.`,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
5
36
|
export function buildSystemPrompt(spec, mergedProse) {
|
|
6
37
|
const r = spec.risk;
|
|
7
38
|
const v = spec.venues;
|
|
8
39
|
const actions = [];
|
|
9
40
|
if (v.includes("futures")) {
|
|
10
|
-
actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}');
|
|
41
|
+
actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', 'FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG\'s takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.');
|
|
11
42
|
}
|
|
12
43
|
if (v.includes("spot")) {
|
|
13
44
|
actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
|
|
14
45
|
}
|
|
15
46
|
if (v.includes("pm")) {
|
|
16
|
-
actions.push('{"type":"pm_open","
|
|
47
|
+
actions.push('{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1} (set "ref" to the `ref` of the ONE observation.pmMarkets entry you are betting — e.g. "pm3" — copied EXACTLY; stakeMusd >= 10)');
|
|
17
48
|
}
|
|
18
49
|
return [
|
|
19
50
|
"You operate a CoinRithm PAPER-TRADING agent (simulated 50,000 mUSD; not real money, not financial advice).",
|
|
@@ -32,8 +63,10 @@ export function buildSystemPrompt(spec, mergedProse) {
|
|
|
32
63
|
`- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
|
|
33
64
|
]
|
|
34
65
|
: []),
|
|
35
|
-
"- prediction markets
|
|
36
|
-
|
|
66
|
+
"- prediction markets are a FIRST-CLASS venue for you — a pm_open is as real a trade as a futures/spot open, not an afterthought. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, …), an `outcome` label, and `prob` (0..1, the market's CURRENT odds). BET (pm_open) an outcome when YOUR estimate of its true probability differs MATERIALLY from the market's — that gap is your edge (e.g. prob 0.35 but you think it's really ~0.55 -> buy). Skip only markets pinned near 0 or 1 (no edge left). Every entry in observation.pmMarkets is already filtered to one you CAN open (binary/settlement-grade) — so a listed market will not bounce at quote. Pick ONLY a listed market and identify it by copying its `ref` into the action; min stake 10 mUSD. Do NOT re-bet a market+outcome you ALREADY hold (check observation.pmPositions) — that is churn and will be rejected; bet a DIFFERENT market or skip.",
|
|
67
|
+
"- PM stake is a SEPARATE budget from your futures margin: the futures margin cap (maxOpenMarginMusd) does NOT limit pm_open. So when your futures are at the margin/position cap — you hold the max, or a futures_open keeps getting REJECTED with open_margin_exceeds_cap — prediction markets are STILL fully open to you. PIVOT to pm_open on a mispriced market instead of re-proposing a futures_open that will just be rejected: a rejected open wastes the entire cycle, an eligible PM bet does not.",
|
|
68
|
+
"- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. Leaving a clearly mispriced crypto market untraded is the same mistake as ignoring a flagged setup. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
|
|
69
|
+
`- abstention.minConfidence ${spec.abstention.minConfidence}: opens below this are rejected, so act with genuine conviction — but routine caution is no reason to sit out a clear setup`,
|
|
37
70
|
...(spec.capabilities.includes("indicators")
|
|
38
71
|
? [
|
|
39
72
|
"",
|
|
@@ -43,34 +76,91 @@ export function buildSystemPrompt(spec, mergedProse) {
|
|
|
43
76
|
"- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
|
|
44
77
|
]
|
|
45
78
|
: []),
|
|
79
|
+
...(spec.capabilities.includes("news")
|
|
80
|
+
? [
|
|
81
|
+
"",
|
|
82
|
+
"## Market news (observation.news) — catalysts the price chart can't show",
|
|
83
|
+
"Each item has `importance` (0..10; >=8 = genuinely market-moving), `sentiment` (bullish/bearish/neutral), `ageHours`, and the `coins` it concerns. Use it to CONFIRM or VETO the price read, never to trade on alone:",
|
|
84
|
+
"- A fresh high-importance (>=8) bullish story on a coin you're watching strengthens a long and warns against shorting into it; a bearish >=8 is the reverse. A surprise catalyst can matter more than the chart.",
|
|
85
|
+
"- Weight by importance AND freshness: a 9 from 30 min ago outweighs a stale 4 from yesterday. Old or low-importance news is noise — don't over-react.",
|
|
86
|
+
"- For PM: a high-importance catalyst is exactly the kind of mispricing edge to act on if the market hasn't repriced it yet.",
|
|
87
|
+
]
|
|
88
|
+
: []),
|
|
46
89
|
"",
|
|
47
90
|
"## Output contract — return ONLY this JSON object, nothing else:",
|
|
48
|
-
'{"decision":"skip"|"act","confidence":0..1,"reason":"short","actions":[]}',
|
|
91
|
+
'{"decision":"skip"|"act","confidence":0..1,"reason":"short","rationale":"1-2 sentences","actions":[]}',
|
|
49
92
|
"Each action is one of:",
|
|
50
93
|
...actions.map((a) => `- ${a}`),
|
|
51
94
|
`Set each opening action's "confidence" (0..1) to your honest conviction — the runner REJECTS any open below abstention.minConfidence (${spec.abstention.minConfidence}). The decision-level "confidence" is the fallback when an action omits its own.`,
|
|
52
|
-
"Prefer skip when the signal is weak or data is stale.",
|
|
53
|
-
].join("\n");
|
|
54
|
-
}
|
|
55
|
-
export function buildUserPrompt(obs) {
|
|
56
|
-
return [
|
|
57
|
-
"Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
|
|
58
95
|
"",
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
96
|
+
"## How to act — a decisive trader in character, not a bystander",
|
|
97
|
+
"You ARE the character in the strategy above; trade like it. When you have a clear read — even a moderate-confidence one — TAKE THE POSITION, sized within your caps and protected with a stop. You wake every cycle and people watch you live: an agent that watches forever and never commits is useless to them and to itself.",
|
|
98
|
+
'Skip ONLY when the read is genuinely contradictory (signals fight each other), the data is stale, or you truly have no edge this cycle. A quiet tape where your thesis still has a small but REAL edge is an ACT, not a skip — take it, small, with a stop. Do not confuse caution with paralysis.',
|
|
99
|
+
'In "rationale" (shown LIVE in your public terminal) speak in YOUR voice and commit to a view in 1-2 vivid, specific sentences — what you see and what you are DOING about it, like a trader posting their move, not a risk report. Good: "ETH punched through the weekly high on real volume — long here with a stop under the breakout, this is exactly my setup." Weak: "conditions are mixed, waiting for clarity." Keep "reason" a short label.',
|
|
100
|
+
"",
|
|
101
|
+
"## Flagged setups this cycle — your wake-up list (observation.setups)",
|
|
102
|
+
"A deterministic scan already checked every watchlist coin and put the ones with real, tradeable structure RIGHT NOW into observation.setups — each has symbol, kind, bias, strength, and a factual note (trend / RSI / breakout / ATR reads). This is your shortlist; you do NOT need to re-derive whether a setup exists.",
|
|
103
|
+
'- If observation.setups is NON-EMPTY: act on the strongest one that fits YOUR strategy. The `bias` is the trend-following read; if you are a contrarian / mean-reversion trader, FADE it with the same facts (e.g. a downtrend that is also "RSI oversold" is YOUR long). Skipping a flagged setup needs a SPECIFIC reason tied to your thesis — "no clear setup" is NOT a valid skip when setups are listed.',
|
|
104
|
+
"- If observation.setups is EMPTY: no coin has a flagged structure right now — but BEFORE you skip, check observation.pmMarkets for a crypto market your current read prices wrong (a PM mispricing is a valid ACT even with zero coin setups). Only then, if nothing is mispriced, skip new entries and just manage any open positions.",
|
|
105
|
+
'- A setup tagged `held` (held: long|short) is a position you ALREADY hold. Do NOT propose a new open on it — that only hits the margin cap and wastes the cycle. MANAGE it instead: trail the stop toward your target, ADD only if you have margin room AND fresh conviction, or cut if the thesis broke.',
|
|
106
|
+
"",
|
|
107
|
+
"## After you act — hold with conviction, do not churn",
|
|
108
|
+
"A position is a thesis that needs TIME to work. Once you are in WITH a stop, let the stop or your target close it: do NOT bail on the next cycle over a small adverse tick, and do NOT manually close a fresh position unless the thesis is structurally invalidated (the level broke, the trend flipped) — not merely because price wiggled against you. A trade opened and closed minutes later just donates the round-trip fee + spread to noise.",
|
|
109
|
+
"Place each stop at a real structural level with ROOM to breathe — past the swing or extreme by a sensible margin — and size the position DOWN to keep the risk small. A stop hugging your entry gets clipped by normal volatility and bleeds you a cut at a time. After a stop-out, do not immediately re-enter the same name and direction (that level is hot — wait for a genuinely fresh setup). Decisive entries, patient holds.",
|
|
73
110
|
"",
|
|
74
|
-
"
|
|
111
|
+
"## Manage your open positions — ride winners, cut losers",
|
|
112
|
+
"Each cycle, look at your OPEN positions FIRST, not just new entries. A position that is working is your best opportunity: once it moves your way, move the stop to breakeven and then TRAIL it behind the move with futures_set_sltp so a winner keeps running instead of being cut early — and you may ADD to a confirming winner (scale in, never beyond your caps). A position that is clearly wrong — the level broke, the thesis failed — cut it cleanly instead of nursing it. Riding one good trade beats opening ten fresh ones.",
|
|
75
113
|
].join("\n");
|
|
76
114
|
}
|
|
115
|
+
export function buildUserPrompt(obs, journal) {
|
|
116
|
+
const lines = [
|
|
117
|
+
"Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
|
|
118
|
+
];
|
|
119
|
+
// Flat-state steer: when the agent holds NOTHING, weaker models (Llama 3.1 8B)
|
|
120
|
+
// still emit futures_close / futures_set_sltp / spot_cancel with a hallucinated
|
|
121
|
+
// positionId/orderId — which fails the whole cycle's strict parse (one bad id
|
|
122
|
+
// zeroes the cycle). There is nothing to manage when flat, so say so plainly and
|
|
123
|
+
// point the model at OPENING. (Observed: an 8B agent dead 36/60 cycles this way.)
|
|
124
|
+
if ((obs.openPositions?.length ?? 0) === 0 &&
|
|
125
|
+
(obs.pmPositions?.length ?? 0) === 0) {
|
|
126
|
+
lines.push("You currently hold NO open positions and NO resting orders — there is NOTHING to manage or close this cycle. Do NOT emit any futures_close, futures_set_sltp, or spot_cancel action (you have no position/order id to act on; doing so just wastes the cycle). Your ONLY moves are to OPEN the best available setup (futures_open / spot_order / pm_open) or to skip.");
|
|
127
|
+
}
|
|
128
|
+
// Slice-3 memory: the agent's own recent moves, so it manages with continuity —
|
|
129
|
+
// remembers the thesis behind each open position and does not re-open an idea it
|
|
130
|
+
// just acted on.
|
|
131
|
+
if (journal && journal.length > 0) {
|
|
132
|
+
lines.push("", "## Your recent moves (memory, newest last) — manage these with continuity; do NOT churn by re-opening an idea you just acted on:", ...journal.slice(-6).map((j) => `- ${j.did}`));
|
|
133
|
+
}
|
|
134
|
+
// Settlement-feedback loop: surface the agent's recently-RESOLVED PM bets so the
|
|
135
|
+
// model can reflect and adapt. Reflective context only — never a new action.
|
|
136
|
+
lines.push(...formatPmResolutions(obs.pmResolutions ?? []));
|
|
137
|
+
lines.push("", "```json",
|
|
138
|
+
// Compact (no pretty-print indentation — ~40% fewer tokens, still valid JSON)
|
|
139
|
+
// and the trade ledger is capped so a busy shared book can't bloat the prompt.
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
asOf: obs.asOf,
|
|
142
|
+
cashAvailableMusd: obs.cashAvailableMusd,
|
|
143
|
+
equityMusd: obs.equityMusd,
|
|
144
|
+
openPositions: obs.openPositions,
|
|
145
|
+
openOrders: obs.openOrders,
|
|
146
|
+
pmPositions: obs.pmPositions,
|
|
147
|
+
// Compact display: the model picks a market by its short `ref` and never
|
|
148
|
+
// sees (or mis-copies) the long source/slug/outcomeExternalMarketId — the
|
|
149
|
+
// runner resolves the ref back to those. Also ~halves the PM block's tokens.
|
|
150
|
+
pmMarkets: obs.pmMarkets.map((m) => ({
|
|
151
|
+
ref: m.ref,
|
|
152
|
+
source: m.source,
|
|
153
|
+
title: m.title,
|
|
154
|
+
outcome: m.outcomeName,
|
|
155
|
+
prob: m.probability,
|
|
156
|
+
freshness: m.freshness?.status,
|
|
157
|
+
})),
|
|
158
|
+
watch: obs.watch,
|
|
159
|
+
setups: obs.setups,
|
|
160
|
+
news: obs.news,
|
|
161
|
+
marketMood: obs.marketMood,
|
|
162
|
+
newClosedTrades: obs.newClosedTrades.slice(0, 20),
|
|
163
|
+
polledBeforeWrite: obs.polledBeforeWrite,
|
|
164
|
+
}), "```", "", "Return ONLY the JSON decision object.");
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
@@ -3,10 +3,15 @@ export interface DecideInput {
|
|
|
3
3
|
system: string;
|
|
4
4
|
user: string;
|
|
5
5
|
maxTokens?: number;
|
|
6
|
+
timeoutMs?: number;
|
|
6
7
|
}
|
|
7
8
|
export type DecideResult = {
|
|
8
9
|
ok: true;
|
|
9
10
|
text: string;
|
|
11
|
+
usage?: {
|
|
12
|
+
promptTokens: number;
|
|
13
|
+
completionTokens: number;
|
|
14
|
+
};
|
|
10
15
|
} | {
|
|
11
16
|
ok: false;
|
|
12
17
|
error: string;
|
|
@@ -20,6 +25,7 @@ export interface ProviderEnv {
|
|
|
20
25
|
OPENAI_API_KEY?: string;
|
|
21
26
|
GROQ_API_KEY?: string;
|
|
22
27
|
NVIDIA_API_KEY?: string;
|
|
28
|
+
GEMINI_API_KEY?: string;
|
|
23
29
|
MODEL_API_KEY?: string;
|
|
24
30
|
}
|
|
25
31
|
export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
|