@coinrithm/mcp-trading 0.7.13 → 0.7.15
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 +692 -573
- package/README.md +400 -364
- package/dist/agent/act.d.ts +7 -2
- package/dist/agent/act.js +10 -3
- package/dist/agent/cli.d.ts +1 -0
- package/dist/agent/cli.js +25 -4
- package/dist/agent/client.d.ts +13 -0
- package/dist/agent/client.js +107 -4
- package/dist/agent/decisionReceipt.d.ts +5 -1
- package/dist/agent/decisionReceipt.js +95 -3
- package/dist/agent/decisionValidator.js +74 -0
- package/dist/agent/definitionSnapshot.d.ts +22 -0
- package/dist/agent/definitionSnapshot.js +28 -0
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/futuresEligibility.d.ts +36 -0
- package/dist/agent/futuresEligibility.js +90 -0
- package/dist/agent/mergeRules.d.ts +1 -1
- package/dist/agent/mergeRules.js +17 -2
- package/dist/agent/observe.d.ts +9 -1
- package/dist/agent/observe.js +507 -45
- package/dist/agent/pmContext.d.ts +10 -1
- package/dist/agent/pmContext.js +163 -1
- package/dist/agent/prompt.d.ts +14 -0
- package/dist/agent/prompt.js +138 -27
- package/dist/agent/providerCapabilities.d.ts +3 -0
- package/dist/agent/providerCapabilities.js +34 -4
- package/dist/agent/providers.d.ts +3 -0
- package/dist/agent/providers.js +12 -2
- package/dist/agent/resolve.js +8 -1
- package/dist/agent/runner.js +140 -18
- package/dist/agent/skill.js +9 -0
- package/dist/agent/skillValidator.js +11 -2
- package/dist/agent/strictLint.js +1 -0
- package/dist/agent/templates.js +30 -30
- package/dist/agent/types.d.ts +138 -1
- package/dist/agent/types.js +4 -0
- package/dist/client.d.ts +11 -2
- package/dist/client.js +22 -10
- package/dist/http.d.ts +1 -0
- package/dist/http.js +17 -2
- package/dist/httpCompletion.d.ts +1 -1
- package/dist/httpCompletion.js +1 -0
- package/dist/index.js +2 -1
- package/dist/instructions.d.ts +1 -0
- package/dist/instructions.js +16 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +150 -66
- package/package.json +97 -97
package/dist/agent/observe.js
CHANGED
|
@@ -1,15 +1,378 @@
|
|
|
1
1
|
// Observe phase: read CoinRithm state into one Observation. Sync-polls /trades
|
|
2
2
|
// before any write (polledBeforeWrite=true only after that succeeds). If a
|
|
3
3
|
// required read fails, or no watchlist symbol resolves, the cycle SKIPS writes.
|
|
4
|
+
import { futuresEntryEligibilityOf } from "./futuresEligibility.js";
|
|
4
5
|
import { asObj, asArr, asNum, asStr } from "./extract.js";
|
|
5
6
|
import { computeIndicators } from "./indicators.js";
|
|
6
7
|
import { scanSetups } from "./setups.js";
|
|
7
|
-
import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
|
|
8
|
+
import { freshnessOf, pmQualityOf, pmDecisionSupportOf, pmConsensusOf, pmCalibrationOf, pmSettlementRuleOf, pmOutcomeRuleOf, } from "./pmContext.js";
|
|
8
9
|
import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
function contextTimestamp(value) {
|
|
11
|
+
const text = asStr(value);
|
|
12
|
+
return text &&
|
|
13
|
+
text.length <= 40 &&
|
|
14
|
+
/^\d{4}-\d{2}-\d{2}T/.test(text) &&
|
|
15
|
+
Number.isFinite(Date.parse(text))
|
|
16
|
+
? text
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
/** Keep sentiment's sample and dates together on both watchlist paths. */
|
|
20
|
+
function sentimentContextOf(market) {
|
|
21
|
+
const sentiment = asObj(market.sentiment);
|
|
22
|
+
const votes = asNum(sentiment.totalVotes);
|
|
23
|
+
return {
|
|
24
|
+
sentimentBullishPct: asNum(sentiment.bullishPct),
|
|
25
|
+
sentimentTotalVotes: votes !== undefined && Number.isSafeInteger(votes) && votes >= 0
|
|
26
|
+
? votes
|
|
27
|
+
: undefined,
|
|
28
|
+
sentimentDayUtc: contextTimestamp(sentiment.dayUtc),
|
|
29
|
+
sentimentUpdatedAt: contextTimestamp(sentiment.updatedAt),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const WHALE_READ_TIMEOUT_MS = 5_000;
|
|
33
|
+
const MAX_WHALE_TRADES = 10;
|
|
34
|
+
const MAX_WHALE_WALLETS = 2;
|
|
35
|
+
const MAX_WALLET_DAILY = 30;
|
|
36
|
+
const MAX_WALLET_FILLS = 6;
|
|
37
|
+
const MAX_EVENT_SLUG_LENGTH = 240;
|
|
38
|
+
const MAX_TIMESTAMP_LENGTH = 40;
|
|
39
|
+
const PUBLIC_WALLET_SOURCES = new Set(["polymarket", "limitless", "myriad"]);
|
|
40
|
+
const FULL_EVM_ADDRESS = /^0x[a-fA-F0-9]{40}$/;
|
|
41
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
42
|
+
function whaleFailure(result) {
|
|
43
|
+
if (result.status === 0) {
|
|
44
|
+
return asObj(result.data).error === "timeout" ? "timeout" : "network_error";
|
|
45
|
+
}
|
|
46
|
+
return "http_error";
|
|
47
|
+
}
|
|
48
|
+
function compactWalletFill(value) {
|
|
49
|
+
const row = asObj(value);
|
|
50
|
+
const event = asObj(row.event);
|
|
51
|
+
const side = asStr(row.side);
|
|
52
|
+
if (!side)
|
|
53
|
+
return undefined;
|
|
54
|
+
return {
|
|
55
|
+
...(asStr(event.slug)
|
|
56
|
+
? { eventSlug: asStr(event.slug)?.slice(0, MAX_EVENT_SLUG_LENGTH) }
|
|
57
|
+
: {}),
|
|
58
|
+
...(asStr(event.title)
|
|
59
|
+
? { eventTitle: asStr(event.title)?.slice(0, 180) }
|
|
60
|
+
: {}),
|
|
61
|
+
...(asStr(row.marketQuestion)
|
|
62
|
+
? { marketQuestion: asStr(row.marketQuestion)?.slice(0, 240) }
|
|
63
|
+
: {}),
|
|
64
|
+
side: side.slice(0, 24),
|
|
65
|
+
...(asStr(row.outcome)
|
|
66
|
+
? { outcome: asStr(row.outcome)?.slice(0, 120) }
|
|
67
|
+
: {}),
|
|
68
|
+
...(asNum(row.usdValue) !== undefined
|
|
69
|
+
? { usdValue: asNum(row.usdValue) }
|
|
70
|
+
: {}),
|
|
71
|
+
...(asNum(row.price) !== undefined ? { price: asNum(row.price) } : {}),
|
|
72
|
+
...(asStr(row.evidenceType)
|
|
73
|
+
? { evidenceType: asStr(row.evidenceType)?.slice(0, 48) }
|
|
74
|
+
: {}),
|
|
75
|
+
...(asStr(row.tradedAt)
|
|
76
|
+
? { tradedAt: asStr(row.tradedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
77
|
+
: {}),
|
|
78
|
+
...(asStr(row.sourceMarketRef)
|
|
79
|
+
? { sourceMarketRef: asStr(row.sourceMarketRef)?.slice(0, 120) }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function compactWallet(value, expectedSource, expectedAddress) {
|
|
84
|
+
const row = asObj(value);
|
|
85
|
+
const returnedSource = asStr(row.source)?.toLowerCase();
|
|
86
|
+
const returnedAddress = asStr(row.wallet) ?? asStr(row.address);
|
|
87
|
+
if (returnedSource !== expectedSource ||
|
|
88
|
+
!returnedAddress ||
|
|
89
|
+
returnedAddress.toLowerCase() !== expectedAddress) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!row.summary30d ||
|
|
93
|
+
typeof row.summary30d !== "object" ||
|
|
94
|
+
Array.isArray(row.summary30d)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(row.daily) || !Array.isArray(row.recentFills)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const summary = asObj(row.summary30d);
|
|
101
|
+
const daily = asArr(row.daily)
|
|
102
|
+
.filter((item) => ISO_DAY.test(asStr(asObj(item).day) ?? ""))
|
|
103
|
+
.map((item) => {
|
|
104
|
+
const day = asObj(item);
|
|
105
|
+
return {
|
|
106
|
+
...(asStr(day.day) ? { day: asStr(day.day) } : {}),
|
|
107
|
+
...(asNum(day.tradeCount) !== undefined
|
|
108
|
+
? { tradeCount: asNum(day.tradeCount) }
|
|
109
|
+
: {}),
|
|
110
|
+
...(asNum(day.buyCount) !== undefined
|
|
111
|
+
? { buyCount: asNum(day.buyCount) }
|
|
112
|
+
: {}),
|
|
113
|
+
...(asNum(day.sellCount) !== undefined
|
|
114
|
+
? { sellCount: asNum(day.sellCount) }
|
|
115
|
+
: {}),
|
|
116
|
+
...(asNum(day.notionalUsd) !== undefined
|
|
117
|
+
? { notionalUsd: asNum(day.notionalUsd) }
|
|
118
|
+
: {}),
|
|
119
|
+
};
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => (b.day ?? "").localeCompare(a.day ?? ""))
|
|
122
|
+
.slice(0, MAX_WALLET_DAILY);
|
|
123
|
+
const recentFills = asArr(row.recentFills)
|
|
124
|
+
.map(compactWalletFill)
|
|
125
|
+
.filter((item) => !!item)
|
|
126
|
+
.slice(0, MAX_WALLET_FILLS);
|
|
127
|
+
return {
|
|
128
|
+
source: expectedSource,
|
|
129
|
+
address: expectedAddress,
|
|
130
|
+
...(asStr(row.asOf)
|
|
131
|
+
? { asOf: asStr(row.asOf)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
132
|
+
: {}),
|
|
133
|
+
summary30d: {
|
|
134
|
+
...(asStr(summary.basis)
|
|
135
|
+
? { basis: asStr(summary.basis)?.slice(0, 48) }
|
|
136
|
+
: {}),
|
|
137
|
+
...(asStr(summary.windowStart)
|
|
138
|
+
? {
|
|
139
|
+
windowStart: asStr(summary.windowStart)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
...(asNum(summary.tradeCount) !== undefined
|
|
143
|
+
? { tradeCount: asNum(summary.tradeCount) }
|
|
144
|
+
: {}),
|
|
145
|
+
...(asNum(summary.notionalUsd) !== undefined
|
|
146
|
+
? { notionalUsd: asNum(summary.notionalUsd) }
|
|
147
|
+
: {}),
|
|
148
|
+
...(asNum(summary.buyNotionalUsd) !== undefined
|
|
149
|
+
? { buyNotionalUsd: asNum(summary.buyNotionalUsd) }
|
|
150
|
+
: {}),
|
|
151
|
+
...(asNum(summary.sellNotionalUsd) !== undefined
|
|
152
|
+
? { sellNotionalUsd: asNum(summary.sellNotionalUsd) }
|
|
153
|
+
: {}),
|
|
154
|
+
},
|
|
155
|
+
rollup: {
|
|
156
|
+
...(typeof asObj(row.rollup).available === "boolean"
|
|
157
|
+
? { available: asObj(row.rollup).available }
|
|
158
|
+
: {}),
|
|
159
|
+
...(asNum(asObj(row.rollup).coveredDays) !== undefined
|
|
160
|
+
? { coveredDays: asNum(asObj(row.rollup).coveredDays) }
|
|
161
|
+
: {}),
|
|
162
|
+
...(asStr(asObj(row.rollup).from)
|
|
163
|
+
? {
|
|
164
|
+
from: asStr(asObj(row.rollup).from)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
...(asStr(asObj(row.rollup).to)
|
|
168
|
+
? { to: asStr(asObj(row.rollup).to)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
169
|
+
: {}),
|
|
170
|
+
...(asStr(asObj(row.rollup).computedAt)
|
|
171
|
+
? {
|
|
172
|
+
computedAt: asStr(asObj(row.rollup).computedAt)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
173
|
+
}
|
|
174
|
+
: {}),
|
|
175
|
+
},
|
|
176
|
+
daily,
|
|
177
|
+
recentFills,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Add bounded public whale context after the deterministic gate. This is
|
|
182
|
+
* deliberately separate from observe(): a skipped or mechanical cycle never
|
|
183
|
+
* pays for the public reads, and this context cannot widen candidate selection.
|
|
184
|
+
*/
|
|
185
|
+
export async function enrichWhaleContext(client, observation) {
|
|
186
|
+
const fetchedAt = () => new Date().toISOString();
|
|
187
|
+
const relevant = new Set([...observation.pmMarkets, ...observation.pmPositions]
|
|
188
|
+
.map((item) => item.source && item.slug
|
|
189
|
+
? `${item.source.toLowerCase()}|${item.slug.toLowerCase()}`
|
|
190
|
+
: undefined)
|
|
191
|
+
.filter((item) => !!item));
|
|
192
|
+
const unavailable = (reason) => ({
|
|
193
|
+
status: "unavailable",
|
|
194
|
+
fetchedAt: fetchedAt(),
|
|
195
|
+
coverage: "relevant_events",
|
|
196
|
+
trades: [],
|
|
197
|
+
wallets: [],
|
|
198
|
+
omitted: 0,
|
|
199
|
+
...(reason ? { reason } : {}),
|
|
200
|
+
});
|
|
201
|
+
if (relevant.size === 0) {
|
|
202
|
+
return {
|
|
203
|
+
status: "available",
|
|
204
|
+
fetchedAt: fetchedAt(),
|
|
205
|
+
coverage: "no_relevant_events",
|
|
206
|
+
trades: [],
|
|
207
|
+
wallets: [],
|
|
208
|
+
omitted: 0,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const tape = await client.getPublicPmWhales({
|
|
212
|
+
limit: 50,
|
|
213
|
+
timeoutMs: WHALE_READ_TIMEOUT_MS,
|
|
214
|
+
});
|
|
215
|
+
if (!tape.ok)
|
|
216
|
+
return unavailable(whaleFailure(tape));
|
|
217
|
+
const root = asObj(tape.data);
|
|
218
|
+
if (!Array.isArray(root.trades))
|
|
219
|
+
return unavailable("invalid_payload");
|
|
220
|
+
let omitted = 0;
|
|
221
|
+
const trades = [];
|
|
222
|
+
const walletRequests = [];
|
|
223
|
+
for (const value of root.trades) {
|
|
224
|
+
const row = asObj(value);
|
|
225
|
+
const source = asStr(row.source)?.toLowerCase();
|
|
226
|
+
const eventSlug = asStr(row.eventSlug);
|
|
227
|
+
const side = asStr(row.side);
|
|
228
|
+
if (!source ||
|
|
229
|
+
!eventSlug ||
|
|
230
|
+
!side ||
|
|
231
|
+
!relevant.has(`${source}|${eventSlug.toLowerCase()}`)) {
|
|
232
|
+
omitted += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const walletAddress = asStr(row.walletAddress)?.trim();
|
|
236
|
+
const validWalletAddress = walletAddress &&
|
|
237
|
+
PUBLIC_WALLET_SOURCES.has(source) &&
|
|
238
|
+
FULL_EVM_ADDRESS.test(walletAddress)
|
|
239
|
+
? walletAddress.toLowerCase()
|
|
240
|
+
: undefined;
|
|
241
|
+
if (validWalletAddress &&
|
|
242
|
+
!walletRequests.some((item) => item.source === source && item.address === validWalletAddress)) {
|
|
243
|
+
walletRequests.push({ source, address: validWalletAddress });
|
|
244
|
+
}
|
|
245
|
+
if (trades.length >= MAX_WHALE_TRADES) {
|
|
246
|
+
omitted += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const trade = {
|
|
250
|
+
source,
|
|
251
|
+
eventSlug: eventSlug.slice(0, MAX_EVENT_SLUG_LENGTH),
|
|
252
|
+
side: side.slice(0, 24),
|
|
253
|
+
...(asStr(row.eventTitle)
|
|
254
|
+
? { eventTitle: asStr(row.eventTitle)?.slice(0, 180) }
|
|
255
|
+
: {}),
|
|
256
|
+
...(asStr(row.marketQuestion)
|
|
257
|
+
? { marketQuestion: asStr(row.marketQuestion)?.slice(0, 240) }
|
|
258
|
+
: {}),
|
|
259
|
+
...(asStr(row.outcome)
|
|
260
|
+
? { outcome: asStr(row.outcome)?.slice(0, 120) }
|
|
261
|
+
: {}),
|
|
262
|
+
...(asNum(row.usdValue) !== undefined
|
|
263
|
+
? { usdValue: asNum(row.usdValue) }
|
|
264
|
+
: {}),
|
|
265
|
+
...(asNum(row.price) !== undefined ? { price: asNum(row.price) } : {}),
|
|
266
|
+
...(asStr(row.sourceMarketRef)
|
|
267
|
+
? { sourceMarketRef: asStr(row.sourceMarketRef)?.slice(0, 120) }
|
|
268
|
+
: {}),
|
|
269
|
+
...(asStr(row.valueBasis)
|
|
270
|
+
? { valueBasis: asStr(row.valueBasis)?.slice(0, 64) }
|
|
271
|
+
: {}),
|
|
272
|
+
...(asStr(row.evidenceType)
|
|
273
|
+
? { evidenceType: asStr(row.evidenceType)?.slice(0, 48) }
|
|
274
|
+
: {}),
|
|
275
|
+
...(asStr(row.evidenceRef)
|
|
276
|
+
? { evidenceRef: asStr(row.evidenceRef)?.slice(0, 160) }
|
|
277
|
+
: {}),
|
|
278
|
+
...(asNum(row.nativeValue) !== undefined
|
|
279
|
+
? { nativeValue: asNum(row.nativeValue) }
|
|
280
|
+
: {}),
|
|
281
|
+
...(asStr(row.nativeCurrency)
|
|
282
|
+
? { nativeCurrency: asStr(row.nativeCurrency)?.slice(0, 24) }
|
|
283
|
+
: {}),
|
|
284
|
+
...(asStr(row.availability) &&
|
|
285
|
+
["live", "delayed", "unavailable"].includes(asStr(row.availability))
|
|
286
|
+
? {
|
|
287
|
+
availability: asStr(row.availability),
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
...(asStr(row.tradedAt)
|
|
291
|
+
? { tradedAt: asStr(row.tradedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
292
|
+
: {}),
|
|
293
|
+
...(asStr(row.observedAt)
|
|
294
|
+
? { observedAt: asStr(row.observedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
295
|
+
: {}),
|
|
296
|
+
...(asNum(row.latencySeconds) !== undefined
|
|
297
|
+
? { latencySeconds: asNum(row.latencySeconds) }
|
|
298
|
+
: {}),
|
|
299
|
+
...(validWalletAddress ? { walletAddress: validWalletAddress } : {}),
|
|
300
|
+
};
|
|
301
|
+
trades.push(trade);
|
|
302
|
+
}
|
|
303
|
+
const wallets = [];
|
|
304
|
+
let reason;
|
|
305
|
+
for (const request of walletRequests.slice(0, MAX_WHALE_WALLETS)) {
|
|
306
|
+
const detail = await client.getPublicPmWhaleWallet(request.source, request.address, { timeoutMs: WHALE_READ_TIMEOUT_MS });
|
|
307
|
+
if (!detail.ok) {
|
|
308
|
+
omitted += 1;
|
|
309
|
+
reason ??= whaleFailure(detail);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const wallet = compactWallet(detail.data, request.source, request.address);
|
|
313
|
+
if (!wallet) {
|
|
314
|
+
omitted += 1;
|
|
315
|
+
reason ??= "invalid_payload";
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
wallets.push(wallet);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
status: reason ? "partial" : "available",
|
|
322
|
+
fetchedAt: fetchedAt(),
|
|
323
|
+
coverage: "relevant_events",
|
|
324
|
+
trades,
|
|
325
|
+
wallets,
|
|
326
|
+
omitted,
|
|
327
|
+
...(reason ? { reason } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// The agent's own settled PM forecast record changes only as bets settle, so
|
|
331
|
+
// it is read at most once per PM_CALIBRATION_TTL_MS per credential. The cache
|
|
332
|
+
// is module-level because the hosted scheduler builds a new client every
|
|
333
|
+
// cycle; it is keyed by a hash of base URL + API key, never the key itself.
|
|
334
|
+
// Every attempt (success, failure, or a backend without the field) is cached
|
|
335
|
+
// for the full TTL, a read is never retried (no 429 retry either) and it has
|
|
336
|
+
// its own short deadline, so it can neither block nor repeat within a cycle.
|
|
337
|
+
const PM_CALIBRATION_TTL_MS = 30 * 60_000;
|
|
338
|
+
const PM_CALIBRATION_READ_TIMEOUT_MS = 5_000;
|
|
339
|
+
const PM_CALIBRATION_CACHE_MAX = 2_000;
|
|
340
|
+
const pmCalibrationCache = new Map();
|
|
341
|
+
export function clearPmCalibrationCache() {
|
|
342
|
+
pmCalibrationCache.clear();
|
|
343
|
+
}
|
|
344
|
+
export async function readPmCalibration(client, trace, nowMs = Date.now()) {
|
|
345
|
+
try {
|
|
346
|
+
const key = client.credentialFingerprint();
|
|
347
|
+
const hit = pmCalibrationCache.get(key);
|
|
348
|
+
if (hit && nowMs >= hit.at && nowMs - hit.at < PM_CALIBRATION_TTL_MS)
|
|
349
|
+
return await hit.value;
|
|
350
|
+
const value = client
|
|
351
|
+
.performance(trace, {
|
|
352
|
+
timeoutMs: PM_CALIBRATION_READ_TIMEOUT_MS,
|
|
353
|
+
maxRetries: 0,
|
|
354
|
+
})
|
|
355
|
+
.then((r) => r.ok ? pmCalibrationOf(asObj(r.data).pmCalibration) : undefined)
|
|
356
|
+
.catch(() => undefined);
|
|
357
|
+
// Re-insert so Map order tracks recency, then drop the oldest entries.
|
|
358
|
+
pmCalibrationCache.delete(key);
|
|
359
|
+
pmCalibrationCache.set(key, { at: nowMs, value });
|
|
360
|
+
for (const oldest of pmCalibrationCache.keys()) {
|
|
361
|
+
if (pmCalibrationCache.size <= PM_CALIBRATION_CACHE_MAX)
|
|
362
|
+
break;
|
|
363
|
+
pmCalibrationCache.delete(oldest);
|
|
364
|
+
}
|
|
365
|
+
return await value;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// A client without the read (or any synchronous failure) omits the block.
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// The 1D endpoint nominally returns 288 five-minute bars. Timestamps and gaps
|
|
373
|
+
// are checked separately; requesting this range does not establish freshness.
|
|
12
374
|
const INDICATOR_RANGE = "1D";
|
|
375
|
+
const INDICATOR_INTERVAL_SECONDS = 300;
|
|
13
376
|
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
14
377
|
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
15
378
|
// resolve + market [+ candles] call).
|
|
@@ -64,15 +427,61 @@ const PM_COIN_NAMES = {
|
|
|
64
427
|
SUI: "Sui",
|
|
65
428
|
};
|
|
66
429
|
// Repeated micro-contracts are useful for execution smoke tests but are a poor
|
|
67
|
-
//
|
|
68
|
-
// meaningful independent research, and
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
430
|
+
// forecasting universe: outcomes overlap heavily, resolve too quickly to admit
|
|
431
|
+
// meaningful independent research, and are priced off the same public spot
|
|
432
|
+
// prices the agent sees. In production (30 days to 2026-09-29) these churn rows
|
|
433
|
+
// were 72% of all LLM PM decisions and drowned the public scorecard in Bitcoin
|
|
434
|
+
// coin flips. Every non-mechanical agent (any objective, not only
|
|
435
|
+
// calibration) therefore receives a deeper discovery page with these rows
|
|
436
|
+
// removed. Mechanical baselines intentionally keep the unmodified universe so
|
|
437
|
+
// their reference contract remains reproducible.
|
|
72
438
|
const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)|(?:5|15)\s+min(?:ute)?s?|-1h-|hourly|-daily-|\bdaily\b|what-price-will[^\n]*(?:today|tomorrow)|-above-on-|-price-on-|this[ -]week|of[ -]the[ -]week|-weekly-)/i;
|
|
73
439
|
export function isCalibrationChurnMarket(market) {
|
|
74
440
|
return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
|
|
75
441
|
}
|
|
442
|
+
// The live endpoint's t is Unix seconds. Missing/malformed values (including
|
|
443
|
+
// millisecond epochs) stay unknown; never substitute retrieval time.
|
|
444
|
+
function candleTimestamp(value) {
|
|
445
|
+
return typeof value === "number" &&
|
|
446
|
+
Number.isInteger(value) &&
|
|
447
|
+
value > 0 &&
|
|
448
|
+
value <= 253_402_300_799
|
|
449
|
+
? value
|
|
450
|
+
: undefined;
|
|
451
|
+
}
|
|
452
|
+
function candleIntervals(times) {
|
|
453
|
+
let timestampedBarCount = 0;
|
|
454
|
+
let checkedIntervalCount = 0;
|
|
455
|
+
let irregularIntervalCount = 0;
|
|
456
|
+
let maxGapSeconds;
|
|
457
|
+
for (let i = 0; i < times.length; i++) {
|
|
458
|
+
const current = times[i];
|
|
459
|
+
if (current === undefined)
|
|
460
|
+
continue;
|
|
461
|
+
timestampedBarCount++;
|
|
462
|
+
const previous = times[i - 1];
|
|
463
|
+
if (previous === undefined)
|
|
464
|
+
continue;
|
|
465
|
+
const gap = current - previous;
|
|
466
|
+
checkedIntervalCount++;
|
|
467
|
+
if (gap !== INDICATOR_INTERVAL_SECONDS)
|
|
468
|
+
irregularIntervalCount++;
|
|
469
|
+
maxGapSeconds = Math.max(maxGapSeconds ?? 0, gap);
|
|
470
|
+
}
|
|
471
|
+
const intervalStatus = irregularIntervalCount > 0
|
|
472
|
+
? "irregular"
|
|
473
|
+
: times.length < 2 || timestampedBarCount !== times.length
|
|
474
|
+
? "unknown"
|
|
475
|
+
: "regular";
|
|
476
|
+
return {
|
|
477
|
+
barCount: times.length,
|
|
478
|
+
timestampedBarCount,
|
|
479
|
+
checkedIntervalCount,
|
|
480
|
+
irregularIntervalCount,
|
|
481
|
+
intervalStatus,
|
|
482
|
+
...(maxGapSeconds === undefined ? {} : { maxGapSeconds }),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
76
485
|
// Fetch candles for one coin and reduce them to a compact indicator bundle plus
|
|
77
486
|
// the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
78
487
|
// candles) yields null indicators so the cycle proceeds with price-only context
|
|
@@ -92,6 +501,7 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
92
501
|
return { indicators: null };
|
|
93
502
|
// Endpoint shape: { candles: [{ t, o, h, l, c, v, vm }] } ascending (oldest first).
|
|
94
503
|
const candles = [];
|
|
504
|
+
const times = [];
|
|
95
505
|
let latestVolume;
|
|
96
506
|
let latestVolumeMissingVenues;
|
|
97
507
|
for (const raw of asArr(asObj(cr.data).candles)) {
|
|
@@ -104,6 +514,7 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
104
514
|
continue;
|
|
105
515
|
const volume = asNum(c.v);
|
|
106
516
|
candles.push({ open, high, low, close, volume: volume ?? undefined });
|
|
517
|
+
times.push(candleTimestamp(c.t));
|
|
107
518
|
latestVolume = volume != null && volume >= 0 ? volume : undefined;
|
|
108
519
|
const coverage = asNum(c.vm);
|
|
109
520
|
latestVolumeMissingVenues =
|
|
@@ -114,8 +525,22 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
114
525
|
? coverage
|
|
115
526
|
: undefined;
|
|
116
527
|
}
|
|
528
|
+
const latestTime = times.at(-1);
|
|
529
|
+
const recent = candleIntervals(times.slice(-15));
|
|
117
530
|
return {
|
|
118
531
|
indicators: computeIndicators(candles),
|
|
532
|
+
indicatorContext: {
|
|
533
|
+
range: INDICATOR_RANGE,
|
|
534
|
+
nominalIntervalSeconds: INDICATOR_INTERVAL_SECONDS,
|
|
535
|
+
...candleIntervals(times),
|
|
536
|
+
...(latestTime === undefined
|
|
537
|
+
? {}
|
|
538
|
+
: { asOf: new Date(latestTime * 1000).toISOString() }),
|
|
539
|
+
recent15: {
|
|
540
|
+
barCount: recent.barCount,
|
|
541
|
+
intervalStatus: recent.intervalStatus,
|
|
542
|
+
},
|
|
543
|
+
},
|
|
119
544
|
volume24hUsd: latestVolume,
|
|
120
545
|
volumeMissingVenues: latestVolumeMissingVenues,
|
|
121
546
|
};
|
|
@@ -147,6 +572,8 @@ async function enrichFromCandles(client, entry, coinId, trace) {
|
|
|
147
572
|
const cc = await fetchCandleContext(client, coinId, trace);
|
|
148
573
|
if (cc.indicators)
|
|
149
574
|
entry.indicators = cc.indicators;
|
|
575
|
+
if (cc.indicatorContext)
|
|
576
|
+
entry.indicatorContext = cc.indicatorContext;
|
|
150
577
|
if (cc.volume24hUsd != null) {
|
|
151
578
|
entry.fundamentals = {
|
|
152
579
|
...(entry.fundamentals ?? {}),
|
|
@@ -235,6 +662,12 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
235
662
|
// the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
|
|
236
663
|
// Same for every outcome of the event; undefined on an older backend.
|
|
237
664
|
const volumeUsd = asNum(ev.volume24h) ?? undefined;
|
|
665
|
+
// Event-level cross-venue consensus, copied onto every outcome row of
|
|
666
|
+
// the event as-is (never mapped to or flipped for a specific outcome).
|
|
667
|
+
const consensus = pmConsensusOf(ev);
|
|
668
|
+
// Event-level settlement terms, likewise copied to every outcome row;
|
|
669
|
+
// the prompt prints them once per event.
|
|
670
|
+
const rules = pmSettlementRuleOf(ev);
|
|
238
671
|
// At most a few outcomes per event so a wide multi-outcome market
|
|
239
672
|
// (e.g. dozens of price buckets) can't explode the prompt. Drop
|
|
240
673
|
// outcomes the backend flagged NOT openable (eligible === false) so the
|
|
@@ -251,26 +684,36 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
251
684
|
.slice(0, 3);
|
|
252
685
|
// Only an absent legacy outcomes field permits the flat fallback. A
|
|
253
686
|
// present empty/malformed/all-rejected array must never resurrect ev.
|
|
254
|
-
return outcomes.map((o) =>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
687
|
+
return outcomes.map((o) => {
|
|
688
|
+
// This outcome's own rule for a per-outcome event (bound by market
|
|
689
|
+
// id at the API); undefined when the rule is not per-outcome.
|
|
690
|
+
const outcomeRule = pmOutcomeRuleOf(rules, o);
|
|
691
|
+
return {
|
|
692
|
+
source,
|
|
693
|
+
slug,
|
|
694
|
+
outcomeExternalMarketId: asStr(o.externalMarketId) ??
|
|
695
|
+
asStr(o.outcomeExternalMarketId) ??
|
|
696
|
+
"",
|
|
697
|
+
// Carry the odds through: the model needs the outcome label + current
|
|
698
|
+
// probability to spot a mispriced market and bet it.
|
|
699
|
+
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
700
|
+
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
701
|
+
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
702
|
+
probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
|
|
703
|
+
title,
|
|
704
|
+
freshness,
|
|
705
|
+
quality,
|
|
706
|
+
decisionSupport,
|
|
707
|
+
volumeUsd,
|
|
708
|
+
// Event-level fundamentals from the same payload (slice 2): the
|
|
709
|
+
// resolution date and the venue-reported liquidity (USD).
|
|
710
|
+
endDate: asStr(ev.endDate) ?? undefined,
|
|
711
|
+
liquidityUsd: asNum(ev.liquidity) ?? undefined,
|
|
712
|
+
...(consensus !== undefined ? { consensus } : {}),
|
|
713
|
+
...(rules !== undefined ? { rules } : {}),
|
|
714
|
+
...(outcomeRule ? { outcomeRule } : {}),
|
|
715
|
+
};
|
|
716
|
+
});
|
|
274
717
|
})
|
|
275
718
|
.filter((m) => m.source &&
|
|
276
719
|
m.slug &&
|
|
@@ -414,9 +857,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
414
857
|
change24h: asNum(price.change24h),
|
|
415
858
|
change7d: asNum(price.change7d),
|
|
416
859
|
// Community sentiment (already in the /market context, was stripped).
|
|
417
|
-
|
|
860
|
+
...sentimentContextOf(m),
|
|
418
861
|
// Freshness lives under the response's `observation` block.
|
|
419
862
|
freshness: freshnessOf(asObj(m.observation)),
|
|
863
|
+
// Server futures-reference eligibility (undefined = unknown, older API).
|
|
864
|
+
futuresEntryEligibility: futuresEntryEligibilityOf(m),
|
|
420
865
|
// Canonical slug (the news graph's key): from the resolve match, else
|
|
421
866
|
// the market context's observation.dataset.coinSlug.
|
|
422
867
|
slug: asStr(match.slug) ??
|
|
@@ -429,8 +874,13 @@ export async function observe(client, spec, state, trace) {
|
|
|
429
874
|
if (!marketMood) {
|
|
430
875
|
const fg = asObj(m.fearGreed);
|
|
431
876
|
const v = asNum(fg.value);
|
|
877
|
+
const fetchedAt = contextTimestamp(fg.fetchedAt);
|
|
432
878
|
if (v != null)
|
|
433
|
-
marketMood = {
|
|
879
|
+
marketMood = {
|
|
880
|
+
fearGreed: v,
|
|
881
|
+
label: asStr(fg.label) ?? "",
|
|
882
|
+
...(fetchedAt ? { fetchedAt } : {}),
|
|
883
|
+
};
|
|
434
884
|
}
|
|
435
885
|
// `indicators` capability: enrich the observation with computed TA so the
|
|
436
886
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
@@ -499,8 +949,9 @@ export async function observe(client, spec, state, trace) {
|
|
|
499
949
|
change1h: asNum(price.change1h),
|
|
500
950
|
change24h: asNum(price.change24h) ?? row.change24hPct,
|
|
501
951
|
change7d: asNum(price.change7d),
|
|
502
|
-
|
|
952
|
+
...sentimentContextOf(m),
|
|
503
953
|
freshness: freshnessOf(asObj(m.observation)),
|
|
954
|
+
futuresEntryEligibility: futuresEntryEligibilityOf(m),
|
|
504
955
|
discovered: true,
|
|
505
956
|
slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
506
957
|
};
|
|
@@ -550,22 +1001,30 @@ export async function observe(client, spec, state, trace) {
|
|
|
550
1001
|
let capitalPmData;
|
|
551
1002
|
let pmResolutions = [];
|
|
552
1003
|
let pmMarkets = [];
|
|
1004
|
+
let pmCalibration;
|
|
553
1005
|
if (wantPm) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
//
|
|
559
|
-
//
|
|
1006
|
+
// Curated board for every non-mechanical agent: churn rows removed and a
|
|
1007
|
+
// deeper page so the filter does not empty it (see PM_CALIBRATION_CHURN_RE).
|
|
1008
|
+
const curatedPmBoard = spec.model?.provider !== "mechanical";
|
|
1009
|
+
const primaryDiscoveryLimit = curatedPmBoard ? 30 : 12;
|
|
1010
|
+
// Bias PM discovery toward CRYPTO markets the agent has a price view on, the
|
|
1011
|
+
// only PM markets where a price agent's view is even relevant (probed
|
|
1012
|
+
// 2026-06-24: the default board is World Cup / elections / F1). The discover
|
|
560
1013
|
// `q` is an AND/phrase match, so query ONE coin — the agent's TOP watchlist coin,
|
|
561
1014
|
// where its price view is sharpest — never the joined list (matches ~nothing).
|
|
562
1015
|
// Fall back to Bitcoin (always plentiful) — NEVER the general non-crypto board.
|
|
563
1016
|
const topCoin = (spec.risk.watchlist[0] ?? "").toUpperCase();
|
|
564
1017
|
const pmQuery = PM_COIN_NAMES[topCoin] ?? spec.risk.watchlist[0] ?? "Bitcoin";
|
|
565
|
-
|
|
1018
|
+
// The own-calibration read runs alongside the PM reads (cached, bounded,
|
|
1019
|
+
// never fatal). Mechanical agents have no prompt, so they skip it.
|
|
1020
|
+
const [pmPosR, pmDiscFirst, calibration] = await Promise.all([
|
|
566
1021
|
client.pmPositions(undefined, trace),
|
|
567
1022
|
client.discoverPmMarkets({ q: pmQuery, limit: primaryDiscoveryLimit }, trace),
|
|
1023
|
+
curatedPmBoard
|
|
1024
|
+
? readPmCalibration(client, trace)
|
|
1025
|
+
: Promise.resolve(undefined),
|
|
568
1026
|
]);
|
|
1027
|
+
pmCalibration = calibration;
|
|
569
1028
|
let pmDiscR = pmDiscFirst;
|
|
570
1029
|
const firstCount = pmDiscR.ok
|
|
571
1030
|
? asArr(asObj(pmDiscR.data).data ??
|
|
@@ -573,7 +1032,9 @@ export async function observe(client, spec, state, trace) {
|
|
|
573
1032
|
asObj(pmDiscR.data).results).length
|
|
574
1033
|
: 0;
|
|
575
1034
|
if (firstCount < 3 && pmQuery !== "Bitcoin") {
|
|
576
|
-
|
|
1035
|
+
// Same page depth as the primary: the Bitcoin board is the most
|
|
1036
|
+
// churn-heavy query, so a 12-row page could filter down to nothing.
|
|
1037
|
+
const fb = await client.discoverPmMarkets({ q: "Bitcoin", limit: primaryDiscoveryLimit }, trace);
|
|
577
1038
|
if (fb.ok)
|
|
578
1039
|
pmDiscR = fb;
|
|
579
1040
|
}
|
|
@@ -653,13 +1114,13 @@ export async function observe(client, spec, state, trace) {
|
|
|
653
1114
|
// quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
|
|
654
1115
|
// that into one row per quoteable outcome (eligible + not-held filtered).
|
|
655
1116
|
let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
|
|
656
|
-
if (
|
|
1117
|
+
if (curatedPmBoard) {
|
|
657
1118
|
mergedRows = mergedRows.filter((market) => !isCalibrationChurnMarket(market));
|
|
658
1119
|
}
|
|
659
1120
|
// ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
|
|
660
|
-
// The prompt tells the model
|
|
661
|
-
//
|
|
662
|
-
// market for the coin it analysed. The primary board is keyed to ONE query
|
|
1121
|
+
// The prompt tells the model crypto price markets are where its price view
|
|
1122
|
+
// is at least relevant, but that is only usable if the board actually LISTS
|
|
1123
|
+
// a market for the coin it analysed. The primary board is keyed to ONE query
|
|
663
1124
|
// (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
|
|
664
1125
|
// so an agent whose top coin got displaced by the Bitcoin fallback sees NO
|
|
665
1126
|
// market for the coin it has a view on and an 8B model invents a pmN ref
|
|
@@ -690,7 +1151,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
690
1151
|
let secRows = expandPmMarkets(secR.data, heldPmKeys)
|
|
691
1152
|
.filter((m) => titleMentionsCoin(m.title, topAnalyzed))
|
|
692
1153
|
.filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`));
|
|
693
|
-
if (
|
|
1154
|
+
if (curatedPmBoard) {
|
|
694
1155
|
secRows = secRows.filter((market) => !isCalibrationChurnMarket(market));
|
|
695
1156
|
}
|
|
696
1157
|
secRows = secRows.slice(0, 4);
|
|
@@ -766,6 +1227,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
766
1227
|
pmPositions,
|
|
767
1228
|
pmResolutions,
|
|
768
1229
|
pmMarkets,
|
|
1230
|
+
...(pmCalibration ? { pmCalibration } : {}),
|
|
769
1231
|
watch,
|
|
770
1232
|
news,
|
|
771
1233
|
// Deterministic structure flags computed from the watch indicators — the
|