@coinrithm/mcp-trading 0.7.14 → 0.7.16
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 +811 -613
- package/README.md +415 -366
- package/dist/agent/bench/bench.d.ts +59 -0
- package/dist/agent/bench/bench.js +319 -0
- package/dist/agent/bench/cassette.d.ts +97 -0
- package/dist/agent/bench/cassette.js +181 -0
- package/dist/agent/bench/costs.d.ts +28 -0
- package/dist/agent/bench/costs.js +60 -0
- package/dist/agent/bench/labelBuilder.d.ts +123 -0
- package/dist/agent/bench/labelBuilder.js +282 -0
- package/dist/agent/bench/labels.d.ts +75 -0
- package/dist/agent/bench/labels.js +259 -0
- package/dist/agent/bench/recordingClient.d.ts +51 -0
- package/dist/agent/bench/recordingClient.js +193 -0
- package/dist/agent/bench/replayClient.d.ts +41 -0
- package/dist/agent/bench/replayClient.js +262 -0
- package/dist/agent/bench/report.d.ts +115 -0
- package/dist/agent/bench/report.js +435 -0
- package/dist/agent/cli.d.ts +25 -0
- package/dist/agent/cli.js +300 -2
- package/dist/agent/client.d.ts +14 -0
- package/dist/agent/client.js +113 -4
- package/dist/agent/configurationWarnings.js +11 -0
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +85 -3
- package/dist/agent/decisionReceipt.d.ts +5 -1
- package/dist/agent/decisionReceipt.js +52 -3
- package/dist/agent/decisionValidator.d.ts +30 -0
- package/dist/agent/decisionValidator.js +147 -2
- package/dist/agent/engine.d.ts +3 -3
- package/dist/agent/engine.js +3 -3
- package/dist/agent/futuresEligibility.d.ts +36 -0
- package/dist/agent/futuresEligibility.js +90 -0
- package/dist/agent/observe.d.ts +35 -2
- package/dist/agent/observe.js +969 -128
- package/dist/agent/pmContext.d.ts +10 -1
- package/dist/agent/pmContext.js +163 -1
- package/dist/agent/prompt.d.ts +18 -1
- package/dist/agent/prompt.js +229 -33
- package/dist/agent/promptTables.d.ts +1 -0
- package/dist/agent/promptTables.js +33 -0
- package/dist/agent/providerCapabilities.d.ts +9 -0
- package/dist/agent/providerCapabilities.js +57 -4
- package/dist/agent/providers.d.ts +28 -7
- package/dist/agent/providers.js +200 -50
- package/dist/agent/resolve.js +3 -0
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.js +159 -26
- package/dist/agent/setups.d.ts +2 -1
- package/dist/agent/setups.js +17 -16
- package/dist/agent/signals.d.ts +33 -0
- package/dist/agent/signals.js +89 -0
- package/dist/agent/skill.js +37 -0
- package/dist/agent/skillValidator.js +33 -0
- package/dist/agent/strictLint.js +29 -0
- package/dist/agent/templates.js +30 -30
- package/dist/agent/types.d.ts +266 -13
- package/dist/agent/types.js +4 -0
- package/dist/agent/universe.d.ts +78 -0
- package/dist/agent/universe.js +215 -0
- package/dist/client.d.ts +9 -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 +98 -97
package/dist/agent/observe.js
CHANGED
|
@@ -1,15 +1,379 @@
|
|
|
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 { INDICATOR_RANGES, indicatorRangeOf, signalThresholdsOf, } from "./signals.js";
|
|
5
|
+
import { filterUniverseRows, scansUniverse, universeQuery, universeQueryParams, universeResolveTop, universeSectorsOf, UNIVERSE_MAX_SCAN_LIMIT, } from "./universe.js";
|
|
6
|
+
import { futuresEntryEligibilityOf } from "./futuresEligibility.js";
|
|
4
7
|
import { asObj, asArr, asNum, asStr } from "./extract.js";
|
|
5
8
|
import { computeIndicators } from "./indicators.js";
|
|
6
9
|
import { scanSetups } from "./setups.js";
|
|
7
|
-
import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
|
|
10
|
+
import { freshnessOf, pmQualityOf, pmDecisionSupportOf, pmConsensusOf, pmCalibrationOf, pmSettlementRuleOf, pmOutcomeRuleOf, } from "./pmContext.js";
|
|
8
11
|
import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
function contextTimestamp(value) {
|
|
13
|
+
const text = asStr(value);
|
|
14
|
+
return text &&
|
|
15
|
+
text.length <= 40 &&
|
|
16
|
+
/^\d{4}-\d{2}-\d{2}T/.test(text) &&
|
|
17
|
+
Number.isFinite(Date.parse(text))
|
|
18
|
+
? text
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Keep sentiment's sample and dates together on both watchlist paths. */
|
|
22
|
+
function sentimentContextOf(market) {
|
|
23
|
+
const sentiment = asObj(market.sentiment);
|
|
24
|
+
const votes = asNum(sentiment.totalVotes);
|
|
25
|
+
return {
|
|
26
|
+
sentimentBullishPct: asNum(sentiment.bullishPct),
|
|
27
|
+
sentimentTotalVotes: votes !== undefined && Number.isSafeInteger(votes) && votes >= 0
|
|
28
|
+
? votes
|
|
29
|
+
: undefined,
|
|
30
|
+
sentimentDayUtc: contextTimestamp(sentiment.dayUtc),
|
|
31
|
+
sentimentUpdatedAt: contextTimestamp(sentiment.updatedAt),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const WHALE_READ_TIMEOUT_MS = 5_000;
|
|
35
|
+
const MAX_WHALE_TRADES = 10;
|
|
36
|
+
const MAX_WHALE_WALLETS = 2;
|
|
37
|
+
const MAX_WALLET_DAILY = 30;
|
|
38
|
+
const MAX_WALLET_FILLS = 6;
|
|
39
|
+
const MAX_EVENT_SLUG_LENGTH = 240;
|
|
40
|
+
const MAX_TIMESTAMP_LENGTH = 40;
|
|
41
|
+
const PUBLIC_WALLET_SOURCES = new Set(["polymarket", "limitless", "myriad"]);
|
|
42
|
+
const FULL_EVM_ADDRESS = /^0x[a-fA-F0-9]{40}$/;
|
|
43
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
44
|
+
function whaleFailure(result) {
|
|
45
|
+
if (result.status === 0) {
|
|
46
|
+
return asObj(result.data).error === "timeout" ? "timeout" : "network_error";
|
|
47
|
+
}
|
|
48
|
+
return "http_error";
|
|
49
|
+
}
|
|
50
|
+
function compactWalletFill(value) {
|
|
51
|
+
const row = asObj(value);
|
|
52
|
+
const event = asObj(row.event);
|
|
53
|
+
const side = asStr(row.side);
|
|
54
|
+
if (!side)
|
|
55
|
+
return undefined;
|
|
56
|
+
return {
|
|
57
|
+
...(asStr(event.slug)
|
|
58
|
+
? { eventSlug: asStr(event.slug)?.slice(0, MAX_EVENT_SLUG_LENGTH) }
|
|
59
|
+
: {}),
|
|
60
|
+
...(asStr(event.title)
|
|
61
|
+
? { eventTitle: asStr(event.title)?.slice(0, 180) }
|
|
62
|
+
: {}),
|
|
63
|
+
...(asStr(row.marketQuestion)
|
|
64
|
+
? { marketQuestion: asStr(row.marketQuestion)?.slice(0, 240) }
|
|
65
|
+
: {}),
|
|
66
|
+
side: side.slice(0, 24),
|
|
67
|
+
...(asStr(row.outcome)
|
|
68
|
+
? { outcome: asStr(row.outcome)?.slice(0, 120) }
|
|
69
|
+
: {}),
|
|
70
|
+
...(asNum(row.usdValue) !== undefined
|
|
71
|
+
? { usdValue: asNum(row.usdValue) }
|
|
72
|
+
: {}),
|
|
73
|
+
...(asNum(row.price) !== undefined ? { price: asNum(row.price) } : {}),
|
|
74
|
+
...(asStr(row.evidenceType)
|
|
75
|
+
? { evidenceType: asStr(row.evidenceType)?.slice(0, 48) }
|
|
76
|
+
: {}),
|
|
77
|
+
...(asStr(row.tradedAt)
|
|
78
|
+
? { tradedAt: asStr(row.tradedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
79
|
+
: {}),
|
|
80
|
+
...(asStr(row.sourceMarketRef)
|
|
81
|
+
? { sourceMarketRef: asStr(row.sourceMarketRef)?.slice(0, 120) }
|
|
82
|
+
: {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function compactWallet(value, expectedSource, expectedAddress) {
|
|
86
|
+
const row = asObj(value);
|
|
87
|
+
const returnedSource = asStr(row.source)?.toLowerCase();
|
|
88
|
+
const returnedAddress = asStr(row.wallet) ?? asStr(row.address);
|
|
89
|
+
if (returnedSource !== expectedSource ||
|
|
90
|
+
!returnedAddress ||
|
|
91
|
+
returnedAddress.toLowerCase() !== expectedAddress) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (!row.summary30d ||
|
|
95
|
+
typeof row.summary30d !== "object" ||
|
|
96
|
+
Array.isArray(row.summary30d)) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(row.daily) || !Array.isArray(row.recentFills)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const summary = asObj(row.summary30d);
|
|
103
|
+
const daily = asArr(row.daily)
|
|
104
|
+
.filter((item) => ISO_DAY.test(asStr(asObj(item).day) ?? ""))
|
|
105
|
+
.map((item) => {
|
|
106
|
+
const day = asObj(item);
|
|
107
|
+
return {
|
|
108
|
+
...(asStr(day.day) ? { day: asStr(day.day) } : {}),
|
|
109
|
+
...(asNum(day.tradeCount) !== undefined
|
|
110
|
+
? { tradeCount: asNum(day.tradeCount) }
|
|
111
|
+
: {}),
|
|
112
|
+
...(asNum(day.buyCount) !== undefined
|
|
113
|
+
? { buyCount: asNum(day.buyCount) }
|
|
114
|
+
: {}),
|
|
115
|
+
...(asNum(day.sellCount) !== undefined
|
|
116
|
+
? { sellCount: asNum(day.sellCount) }
|
|
117
|
+
: {}),
|
|
118
|
+
...(asNum(day.notionalUsd) !== undefined
|
|
119
|
+
? { notionalUsd: asNum(day.notionalUsd) }
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
})
|
|
123
|
+
.sort((a, b) => (b.day ?? "").localeCompare(a.day ?? ""))
|
|
124
|
+
.slice(0, MAX_WALLET_DAILY);
|
|
125
|
+
const recentFills = asArr(row.recentFills)
|
|
126
|
+
.map(compactWalletFill)
|
|
127
|
+
.filter((item) => !!item)
|
|
128
|
+
.slice(0, MAX_WALLET_FILLS);
|
|
129
|
+
return {
|
|
130
|
+
source: expectedSource,
|
|
131
|
+
address: expectedAddress,
|
|
132
|
+
...(asStr(row.asOf)
|
|
133
|
+
? { asOf: asStr(row.asOf)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
134
|
+
: {}),
|
|
135
|
+
summary30d: {
|
|
136
|
+
...(asStr(summary.basis)
|
|
137
|
+
? { basis: asStr(summary.basis)?.slice(0, 48) }
|
|
138
|
+
: {}),
|
|
139
|
+
...(asStr(summary.windowStart)
|
|
140
|
+
? {
|
|
141
|
+
windowStart: asStr(summary.windowStart)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
...(asNum(summary.tradeCount) !== undefined
|
|
145
|
+
? { tradeCount: asNum(summary.tradeCount) }
|
|
146
|
+
: {}),
|
|
147
|
+
...(asNum(summary.notionalUsd) !== undefined
|
|
148
|
+
? { notionalUsd: asNum(summary.notionalUsd) }
|
|
149
|
+
: {}),
|
|
150
|
+
...(asNum(summary.buyNotionalUsd) !== undefined
|
|
151
|
+
? { buyNotionalUsd: asNum(summary.buyNotionalUsd) }
|
|
152
|
+
: {}),
|
|
153
|
+
...(asNum(summary.sellNotionalUsd) !== undefined
|
|
154
|
+
? { sellNotionalUsd: asNum(summary.sellNotionalUsd) }
|
|
155
|
+
: {}),
|
|
156
|
+
},
|
|
157
|
+
rollup: {
|
|
158
|
+
...(typeof asObj(row.rollup).available === "boolean"
|
|
159
|
+
? { available: asObj(row.rollup).available }
|
|
160
|
+
: {}),
|
|
161
|
+
...(asNum(asObj(row.rollup).coveredDays) !== undefined
|
|
162
|
+
? { coveredDays: asNum(asObj(row.rollup).coveredDays) }
|
|
163
|
+
: {}),
|
|
164
|
+
...(asStr(asObj(row.rollup).from)
|
|
165
|
+
? {
|
|
166
|
+
from: asStr(asObj(row.rollup).from)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
167
|
+
}
|
|
168
|
+
: {}),
|
|
169
|
+
...(asStr(asObj(row.rollup).to)
|
|
170
|
+
? { to: asStr(asObj(row.rollup).to)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
171
|
+
: {}),
|
|
172
|
+
...(asStr(asObj(row.rollup).computedAt)
|
|
173
|
+
? {
|
|
174
|
+
computedAt: asStr(asObj(row.rollup).computedAt)?.slice(0, MAX_TIMESTAMP_LENGTH),
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
177
|
+
},
|
|
178
|
+
daily,
|
|
179
|
+
recentFills,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Add bounded public whale context after the deterministic gate. This is
|
|
184
|
+
* deliberately separate from observe(): a skipped or mechanical cycle never
|
|
185
|
+
* pays for the public reads, and this context cannot widen candidate selection.
|
|
186
|
+
*/
|
|
187
|
+
export async function enrichWhaleContext(client, observation) {
|
|
188
|
+
const fetchedAt = () => new Date().toISOString();
|
|
189
|
+
const relevant = new Set([...observation.pmMarkets, ...observation.pmPositions]
|
|
190
|
+
.map((item) => item.source && item.slug
|
|
191
|
+
? `${item.source.toLowerCase()}|${item.slug.toLowerCase()}`
|
|
192
|
+
: undefined)
|
|
193
|
+
.filter((item) => !!item));
|
|
194
|
+
const unavailable = (reason) => ({
|
|
195
|
+
status: "unavailable",
|
|
196
|
+
fetchedAt: fetchedAt(),
|
|
197
|
+
coverage: "relevant_events",
|
|
198
|
+
trades: [],
|
|
199
|
+
wallets: [],
|
|
200
|
+
omitted: 0,
|
|
201
|
+
...(reason ? { reason } : {}),
|
|
202
|
+
});
|
|
203
|
+
if (relevant.size === 0) {
|
|
204
|
+
return {
|
|
205
|
+
status: "available",
|
|
206
|
+
fetchedAt: fetchedAt(),
|
|
207
|
+
coverage: "no_relevant_events",
|
|
208
|
+
trades: [],
|
|
209
|
+
wallets: [],
|
|
210
|
+
omitted: 0,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const tape = await client.getPublicPmWhales({
|
|
214
|
+
limit: 50,
|
|
215
|
+
timeoutMs: WHALE_READ_TIMEOUT_MS,
|
|
216
|
+
});
|
|
217
|
+
if (!tape.ok)
|
|
218
|
+
return unavailable(whaleFailure(tape));
|
|
219
|
+
const root = asObj(tape.data);
|
|
220
|
+
if (!Array.isArray(root.trades))
|
|
221
|
+
return unavailable("invalid_payload");
|
|
222
|
+
let omitted = 0;
|
|
223
|
+
const trades = [];
|
|
224
|
+
const walletRequests = [];
|
|
225
|
+
for (const value of root.trades) {
|
|
226
|
+
const row = asObj(value);
|
|
227
|
+
const source = asStr(row.source)?.toLowerCase();
|
|
228
|
+
const eventSlug = asStr(row.eventSlug);
|
|
229
|
+
const side = asStr(row.side);
|
|
230
|
+
if (!source ||
|
|
231
|
+
!eventSlug ||
|
|
232
|
+
!side ||
|
|
233
|
+
!relevant.has(`${source}|${eventSlug.toLowerCase()}`)) {
|
|
234
|
+
omitted += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const walletAddress = asStr(row.walletAddress)?.trim();
|
|
238
|
+
const validWalletAddress = walletAddress &&
|
|
239
|
+
PUBLIC_WALLET_SOURCES.has(source) &&
|
|
240
|
+
FULL_EVM_ADDRESS.test(walletAddress)
|
|
241
|
+
? walletAddress.toLowerCase()
|
|
242
|
+
: undefined;
|
|
243
|
+
if (validWalletAddress &&
|
|
244
|
+
!walletRequests.some((item) => item.source === source && item.address === validWalletAddress)) {
|
|
245
|
+
walletRequests.push({ source, address: validWalletAddress });
|
|
246
|
+
}
|
|
247
|
+
if (trades.length >= MAX_WHALE_TRADES) {
|
|
248
|
+
omitted += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const trade = {
|
|
252
|
+
source,
|
|
253
|
+
eventSlug: eventSlug.slice(0, MAX_EVENT_SLUG_LENGTH),
|
|
254
|
+
side: side.slice(0, 24),
|
|
255
|
+
...(asStr(row.eventTitle)
|
|
256
|
+
? { eventTitle: asStr(row.eventTitle)?.slice(0, 180) }
|
|
257
|
+
: {}),
|
|
258
|
+
...(asStr(row.marketQuestion)
|
|
259
|
+
? { marketQuestion: asStr(row.marketQuestion)?.slice(0, 240) }
|
|
260
|
+
: {}),
|
|
261
|
+
...(asStr(row.outcome)
|
|
262
|
+
? { outcome: asStr(row.outcome)?.slice(0, 120) }
|
|
263
|
+
: {}),
|
|
264
|
+
...(asNum(row.usdValue) !== undefined
|
|
265
|
+
? { usdValue: asNum(row.usdValue) }
|
|
266
|
+
: {}),
|
|
267
|
+
...(asNum(row.price) !== undefined ? { price: asNum(row.price) } : {}),
|
|
268
|
+
...(asStr(row.sourceMarketRef)
|
|
269
|
+
? { sourceMarketRef: asStr(row.sourceMarketRef)?.slice(0, 120) }
|
|
270
|
+
: {}),
|
|
271
|
+
...(asStr(row.valueBasis)
|
|
272
|
+
? { valueBasis: asStr(row.valueBasis)?.slice(0, 64) }
|
|
273
|
+
: {}),
|
|
274
|
+
...(asStr(row.evidenceType)
|
|
275
|
+
? { evidenceType: asStr(row.evidenceType)?.slice(0, 48) }
|
|
276
|
+
: {}),
|
|
277
|
+
...(asStr(row.evidenceRef)
|
|
278
|
+
? { evidenceRef: asStr(row.evidenceRef)?.slice(0, 160) }
|
|
279
|
+
: {}),
|
|
280
|
+
...(asNum(row.nativeValue) !== undefined
|
|
281
|
+
? { nativeValue: asNum(row.nativeValue) }
|
|
282
|
+
: {}),
|
|
283
|
+
...(asStr(row.nativeCurrency)
|
|
284
|
+
? { nativeCurrency: asStr(row.nativeCurrency)?.slice(0, 24) }
|
|
285
|
+
: {}),
|
|
286
|
+
...(asStr(row.availability) &&
|
|
287
|
+
["live", "delayed", "unavailable"].includes(asStr(row.availability))
|
|
288
|
+
? {
|
|
289
|
+
availability: asStr(row.availability),
|
|
290
|
+
}
|
|
291
|
+
: {}),
|
|
292
|
+
...(asStr(row.tradedAt)
|
|
293
|
+
? { tradedAt: asStr(row.tradedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
294
|
+
: {}),
|
|
295
|
+
...(asStr(row.observedAt)
|
|
296
|
+
? { observedAt: asStr(row.observedAt)?.slice(0, MAX_TIMESTAMP_LENGTH) }
|
|
297
|
+
: {}),
|
|
298
|
+
...(asNum(row.latencySeconds) !== undefined
|
|
299
|
+
? { latencySeconds: asNum(row.latencySeconds) }
|
|
300
|
+
: {}),
|
|
301
|
+
...(validWalletAddress ? { walletAddress: validWalletAddress } : {}),
|
|
302
|
+
};
|
|
303
|
+
trades.push(trade);
|
|
304
|
+
}
|
|
305
|
+
const wallets = [];
|
|
306
|
+
let reason;
|
|
307
|
+
for (const request of walletRequests.slice(0, MAX_WHALE_WALLETS)) {
|
|
308
|
+
const detail = await client.getPublicPmWhaleWallet(request.source, request.address, { timeoutMs: WHALE_READ_TIMEOUT_MS });
|
|
309
|
+
if (!detail.ok) {
|
|
310
|
+
omitted += 1;
|
|
311
|
+
reason ??= whaleFailure(detail);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const wallet = compactWallet(detail.data, request.source, request.address);
|
|
315
|
+
if (!wallet) {
|
|
316
|
+
omitted += 1;
|
|
317
|
+
reason ??= "invalid_payload";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
wallets.push(wallet);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
status: reason ? "partial" : "available",
|
|
324
|
+
fetchedAt: fetchedAt(),
|
|
325
|
+
coverage: "relevant_events",
|
|
326
|
+
trades,
|
|
327
|
+
wallets,
|
|
328
|
+
omitted,
|
|
329
|
+
...(reason ? { reason } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// The agent's own settled PM forecast record changes only as bets settle, so
|
|
333
|
+
// it is read at most once per PM_CALIBRATION_TTL_MS per credential. The cache
|
|
334
|
+
// is module-level because the hosted scheduler builds a new client every
|
|
335
|
+
// cycle; it is keyed by a hash of base URL + API key, never the key itself.
|
|
336
|
+
// Every attempt (success, failure, or a backend without the field) is cached
|
|
337
|
+
// for the full TTL, a read is never retried (no 429 retry either) and it has
|
|
338
|
+
// its own short deadline, so it can neither block nor repeat within a cycle.
|
|
339
|
+
const PM_CALIBRATION_TTL_MS = 30 * 60_000;
|
|
340
|
+
const PM_CALIBRATION_READ_TIMEOUT_MS = 5_000;
|
|
341
|
+
const PM_CALIBRATION_CACHE_MAX = 2_000;
|
|
342
|
+
const pmCalibrationCache = new Map();
|
|
343
|
+
export function clearPmCalibrationCache() {
|
|
344
|
+
pmCalibrationCache.clear();
|
|
345
|
+
}
|
|
346
|
+
export async function readPmCalibration(client, trace, nowMs = Date.now()) {
|
|
347
|
+
try {
|
|
348
|
+
const key = client.credentialFingerprint();
|
|
349
|
+
const hit = pmCalibrationCache.get(key);
|
|
350
|
+
if (hit && nowMs >= hit.at && nowMs - hit.at < PM_CALIBRATION_TTL_MS)
|
|
351
|
+
return await hit.value;
|
|
352
|
+
const value = client
|
|
353
|
+
.performance(trace, {
|
|
354
|
+
timeoutMs: PM_CALIBRATION_READ_TIMEOUT_MS,
|
|
355
|
+
maxRetries: 0,
|
|
356
|
+
})
|
|
357
|
+
.then((r) => r.ok ? pmCalibrationOf(asObj(r.data).pmCalibration) : undefined)
|
|
358
|
+
.catch(() => undefined);
|
|
359
|
+
// Re-insert so Map order tracks recency, then drop the oldest entries.
|
|
360
|
+
pmCalibrationCache.delete(key);
|
|
361
|
+
pmCalibrationCache.set(key, { at: nowMs, value });
|
|
362
|
+
for (const oldest of pmCalibrationCache.keys()) {
|
|
363
|
+
if (pmCalibrationCache.size <= PM_CALIBRATION_CACHE_MAX)
|
|
364
|
+
break;
|
|
365
|
+
pmCalibrationCache.delete(oldest);
|
|
366
|
+
}
|
|
367
|
+
return await value;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// A client without the read (or any synchronous failure) omits the block.
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// The candle range is the agent's data diet (spec.data.indicatorRange, default
|
|
375
|
+
// 1D = 288 five-minute bars; 1W = 15m, 1M = 1h, 3M = 4h). Timestamps and gaps
|
|
376
|
+
// are checked separately; requesting a range does not establish freshness.
|
|
13
377
|
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
14
378
|
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
15
379
|
// resolve + market [+ candles] call).
|
|
@@ -64,11 +428,14 @@ const PM_COIN_NAMES = {
|
|
|
64
428
|
SUI: "Sui",
|
|
65
429
|
};
|
|
66
430
|
// Repeated micro-contracts are useful for execution smoke tests but are a poor
|
|
67
|
-
//
|
|
68
|
-
// meaningful independent research, and
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
431
|
+
// forecasting universe: outcomes overlap heavily, resolve too quickly to admit
|
|
432
|
+
// meaningful independent research, and are priced off the same public spot
|
|
433
|
+
// prices the agent sees. In production (30 days to 2026-09-29) these churn rows
|
|
434
|
+
// were 72% of all LLM PM decisions and drowned the public scorecard in Bitcoin
|
|
435
|
+
// coin flips. Every non-mechanical agent (any objective, not only
|
|
436
|
+
// calibration) therefore receives a deeper discovery page with these rows
|
|
437
|
+
// removed. Mechanical baselines intentionally keep the unmodified universe so
|
|
438
|
+
// their reference contract remains reproducible.
|
|
72
439
|
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
440
|
export function isCalibrationChurnMarket(market) {
|
|
74
441
|
return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
|
|
@@ -83,7 +450,7 @@ function candleTimestamp(value) {
|
|
|
83
450
|
? value
|
|
84
451
|
: undefined;
|
|
85
452
|
}
|
|
86
|
-
function candleIntervals(times) {
|
|
453
|
+
function candleIntervals(times, intervalSeconds) {
|
|
87
454
|
let timestampedBarCount = 0;
|
|
88
455
|
let checkedIntervalCount = 0;
|
|
89
456
|
let irregularIntervalCount = 0;
|
|
@@ -98,7 +465,7 @@ function candleIntervals(times) {
|
|
|
98
465
|
continue;
|
|
99
466
|
const gap = current - previous;
|
|
100
467
|
checkedIntervalCount++;
|
|
101
|
-
if (gap !==
|
|
468
|
+
if (gap !== intervalSeconds)
|
|
102
469
|
irregularIntervalCount++;
|
|
103
470
|
maxGapSeconds = Math.max(maxGapSeconds ?? 0, gap);
|
|
104
471
|
}
|
|
@@ -120,13 +487,14 @@ function candleIntervals(times) {
|
|
|
120
487
|
// the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
121
488
|
// candles) yields null indicators so the cycle proceeds with price-only context
|
|
122
489
|
// rather than skipping.
|
|
123
|
-
async function fetchCandleContext(client, coinId, trace) {
|
|
490
|
+
async function fetchCandleContext(client, coinId, range, trace) {
|
|
491
|
+
const intervalSeconds = INDICATOR_RANGES[range];
|
|
124
492
|
// The try honors the documented tolerance for SYNCHRONOUS throws too (an
|
|
125
493
|
// unexpected client error must degrade to price-only context, never kill
|
|
126
494
|
// the cycle).
|
|
127
495
|
let cr;
|
|
128
496
|
try {
|
|
129
|
-
cr = await client.candles(coinId,
|
|
497
|
+
cr = await client.candles(coinId, range, trace);
|
|
130
498
|
}
|
|
131
499
|
catch {
|
|
132
500
|
return { indicators: null };
|
|
@@ -160,13 +528,13 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
160
528
|
: undefined;
|
|
161
529
|
}
|
|
162
530
|
const latestTime = times.at(-1);
|
|
163
|
-
const recent = candleIntervals(times.slice(-15));
|
|
531
|
+
const recent = candleIntervals(times.slice(-15), intervalSeconds);
|
|
164
532
|
return {
|
|
165
533
|
indicators: computeIndicators(candles),
|
|
166
534
|
indicatorContext: {
|
|
167
|
-
range
|
|
168
|
-
nominalIntervalSeconds:
|
|
169
|
-
...candleIntervals(times),
|
|
535
|
+
range,
|
|
536
|
+
nominalIntervalSeconds: intervalSeconds,
|
|
537
|
+
...candleIntervals(times, intervalSeconds),
|
|
170
538
|
...(latestTime === undefined
|
|
171
539
|
? {}
|
|
172
540
|
: { asOf: new Date(latestTime * 1000).toISOString() }),
|
|
@@ -200,10 +568,320 @@ function coinFundamentalsOf(m) {
|
|
|
200
568
|
out.marketCapUsd = marketCapUsd;
|
|
201
569
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
202
570
|
}
|
|
571
|
+
// Perpetual open interest from the same /market context (no extra call).
|
|
572
|
+
// Keep venue coverage with each delta: it need not cover the whole total.
|
|
573
|
+
// A malformed or absent block is omitted; a real zero reading is valid.
|
|
574
|
+
export function openInterestOf(m, nowMs = Date.now()) {
|
|
575
|
+
const oi = asObj(asObj(m.derivatives).openInterest);
|
|
576
|
+
const totalUsd = asNum(oi.totalUsd);
|
|
577
|
+
const asOf = asStr(oi.asOf);
|
|
578
|
+
const sourceMs = asOf ? Date.parse(asOf) : NaN;
|
|
579
|
+
if (totalUsd == null ||
|
|
580
|
+
totalUsd < 0 ||
|
|
581
|
+
!asOf ||
|
|
582
|
+
!Number.isFinite(sourceMs) ||
|
|
583
|
+
sourceMs > nowMs + 60_000)
|
|
584
|
+
return undefined;
|
|
585
|
+
const names = (v) => Array.isArray(v)
|
|
586
|
+
? [
|
|
587
|
+
...new Set(v.filter((x) => typeof x === "string" && x.length > 0)),
|
|
588
|
+
].sort()
|
|
589
|
+
: [];
|
|
590
|
+
const change1hVenues = names(oi.change1hVenues);
|
|
591
|
+
const change24hVenues = names(oi.change24hVenues);
|
|
592
|
+
const pct = (v) => {
|
|
593
|
+
const n = asNum(v);
|
|
594
|
+
return n == null ? null : n;
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
totalUsd,
|
|
598
|
+
venues: names(asArr(oi.venues).map((v) => asObj(v).venue)),
|
|
599
|
+
change1hPct: change1hVenues.length ? pct(oi.change1hPct) : null,
|
|
600
|
+
change1hVenues,
|
|
601
|
+
change24hPct: change24hVenues.length ? pct(oi.change24hPct) : null,
|
|
602
|
+
change24hVenues,
|
|
603
|
+
asOf,
|
|
604
|
+
stale: oi.stale !== false || nowMs - sourceMs > 45 * 60_000,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
// A provider time we can show: parseable and not more than 60 s ahead.
|
|
608
|
+
const shownTime = (v, nowMs) => {
|
|
609
|
+
const s = asStr(v);
|
|
610
|
+
if (!s)
|
|
611
|
+
return undefined;
|
|
612
|
+
const ms = Date.parse(s);
|
|
613
|
+
return Number.isFinite(ms) && ms <= nowMs + 60_000 ? s : undefined;
|
|
614
|
+
};
|
|
615
|
+
// A dated metric from the API's {value, asOf, stale}; omitted when malformed,
|
|
616
|
+
// negative or dated in the future. A real zero is valid.
|
|
617
|
+
const datedValueOf = (v, nowMs) => {
|
|
618
|
+
const o = asObj(v);
|
|
619
|
+
const value = asNum(o.value);
|
|
620
|
+
const asOf = shownTime(o.asOf, nowMs);
|
|
621
|
+
if (value == null || value < 0 || !asOf)
|
|
622
|
+
return undefined;
|
|
623
|
+
return { value, asOf, stale: o.stale !== false };
|
|
624
|
+
};
|
|
625
|
+
// Binance positioning ratios from the same /market context (no extra call).
|
|
626
|
+
// Each metric keeps its own provider time; malformed metrics are dropped and
|
|
627
|
+
// the block is omitted when none is usable.
|
|
628
|
+
export function positioningOf(m, nowMs = Date.now()) {
|
|
629
|
+
const p = asObj(asObj(m.derivatives).positioning);
|
|
630
|
+
const venue = asStr(p.venue);
|
|
631
|
+
const symbol = asStr(p.symbol);
|
|
632
|
+
if (!venue || !symbol)
|
|
633
|
+
return undefined;
|
|
634
|
+
const out = { venue, symbol };
|
|
635
|
+
const a = datedValueOf(p.longShortAccountRatio, nowMs);
|
|
636
|
+
const l = datedValueOf(p.longAccountPct, nowMs);
|
|
637
|
+
const t = datedValueOf(p.topTraderPositionRatio, nowMs);
|
|
638
|
+
const k = datedValueOf(p.takerBuySellRatio, nowMs);
|
|
639
|
+
if (a)
|
|
640
|
+
out.longShortAccountRatio = a;
|
|
641
|
+
if (l && l.value <= 100)
|
|
642
|
+
out.longAccountPct = l;
|
|
643
|
+
if (t)
|
|
644
|
+
out.topTraderPositionRatio = t;
|
|
645
|
+
if (k)
|
|
646
|
+
out.takerBuySellRatio = k;
|
|
647
|
+
return Object.keys(out).length > 2 ? out : undefined;
|
|
648
|
+
}
|
|
649
|
+
const liquidationWindowOf = (v) => {
|
|
650
|
+
const w = asObj(v);
|
|
651
|
+
const longU = asNum(w.longLiquidatedUsdt);
|
|
652
|
+
const shortU = asNum(w.shortLiquidatedUsdt);
|
|
653
|
+
const events = asNum(w.events);
|
|
654
|
+
const captured = asNum(w.capturedPct);
|
|
655
|
+
if (longU == null ||
|
|
656
|
+
shortU == null ||
|
|
657
|
+
events == null ||
|
|
658
|
+
captured == null ||
|
|
659
|
+
longU < 0 ||
|
|
660
|
+
shortU < 0 ||
|
|
661
|
+
events < 0 ||
|
|
662
|
+
!Number.isInteger(events) ||
|
|
663
|
+
captured < 0 ||
|
|
664
|
+
captured > 100)
|
|
665
|
+
return undefined;
|
|
666
|
+
return {
|
|
667
|
+
longLiquidatedUsdt: longU,
|
|
668
|
+
shortLiquidatedUsdt: shortU,
|
|
669
|
+
events,
|
|
670
|
+
capturedPct: captured,
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
// OKX liquidations from the same /market context. Both windows must be well
|
|
674
|
+
// formed; capturedPct travels with every sum (a lower bound of our capture
|
|
675
|
+
// uptime, not exchange completeness). Event counts must be whole numbers.
|
|
676
|
+
export function liquidationsOf(m, nowMs = Date.now()) {
|
|
677
|
+
const q = asObj(asObj(m.derivatives).liquidations);
|
|
678
|
+
const venue = asStr(q.venue);
|
|
679
|
+
const instId = asStr(q.instId);
|
|
680
|
+
const last1h = liquidationWindowOf(q.last1h);
|
|
681
|
+
const last24h = liquidationWindowOf(q.last24h);
|
|
682
|
+
if (!venue || !instId || !last1h || !last24h)
|
|
683
|
+
return undefined;
|
|
684
|
+
const lastEventAt = q.lastEventAt == null ? null : (shownTime(q.lastEventAt, nowMs) ?? null);
|
|
685
|
+
return { venue, instId, last1h, last24h, lastEventAt };
|
|
686
|
+
}
|
|
687
|
+
// Macro proxies from the /market context (same block for every coin). Quotes
|
|
688
|
+
// that are malformed or future-dated are dropped; omitted when none remain.
|
|
689
|
+
const optPct = (v) => {
|
|
690
|
+
const n = asNum(v);
|
|
691
|
+
return n == null ? null : n;
|
|
692
|
+
};
|
|
693
|
+
const depthSideOf = (v) => {
|
|
694
|
+
const o = asObj(v);
|
|
695
|
+
const usd = asNum(o.usd);
|
|
696
|
+
const reachPct = asNum(o.reachPct);
|
|
697
|
+
const levels = asNum(o.levels);
|
|
698
|
+
if (usd == null || usd < 0 || reachPct == null || reachPct < 0)
|
|
699
|
+
return null;
|
|
700
|
+
if (levels == null || !Number.isInteger(levels) || levels < 1 || levels > 20)
|
|
701
|
+
return null;
|
|
702
|
+
if (typeof o.complete !== "boolean")
|
|
703
|
+
return null;
|
|
704
|
+
if (o.complete && levels >= 20)
|
|
705
|
+
return null; // a full side is never whole
|
|
706
|
+
return { usd, reachPct, levels, complete: o.complete };
|
|
707
|
+
};
|
|
708
|
+
// Observed depth from the same /market context (no extra call). One venue's
|
|
709
|
+
// visible book; omitted when malformed, future-dated or without any side.
|
|
710
|
+
/**
|
|
711
|
+
* `{ coins }` from an additive `relatedCoins` array (direct coin slugs), or
|
|
712
|
+
* nothing: non-arrays and non-string/blank entries are dropped, duplicates
|
|
713
|
+
* merged. Every valid slug is kept: the API never truncates the list, and
|
|
714
|
+
* dropping a linked coin could let a bet bypass that coin's cap. An empty
|
|
715
|
+
* result adds no field.
|
|
716
|
+
*/
|
|
717
|
+
export function coinsField(value) {
|
|
718
|
+
if (!Array.isArray(value))
|
|
719
|
+
return {};
|
|
720
|
+
const coins = [
|
|
721
|
+
...new Set(value
|
|
722
|
+
.filter((c) => typeof c === "string")
|
|
723
|
+
.map((c) => c.trim().toLowerCase())
|
|
724
|
+
.filter((c) => c.length > 0 && c.length <= 100)),
|
|
725
|
+
];
|
|
726
|
+
return coins.length ? { coins } : {};
|
|
727
|
+
}
|
|
728
|
+
export function fundingByVenueOf(m, nowMs = Date.now()) {
|
|
729
|
+
const block = asObj(asObj(m.derivatives).fundingByVenue);
|
|
730
|
+
if (block.sameTime !== false ||
|
|
731
|
+
!Array.isArray(block.rates) ||
|
|
732
|
+
block.rates.length > 2)
|
|
733
|
+
return undefined;
|
|
734
|
+
const rates = [];
|
|
735
|
+
const venues = new Set();
|
|
736
|
+
for (const value of block.rates) {
|
|
737
|
+
const r = asObj(value);
|
|
738
|
+
const role = r.role, venue = asStr(r.venue), symbol = asStr(r.symbol);
|
|
739
|
+
const fetchedAt = shownTime(r.fetchedAt, nowMs), rateFraction = asNum(r.rateFraction);
|
|
740
|
+
if ((role !== "context_only" && role !== "settlement_reference") ||
|
|
741
|
+
!venue ||
|
|
742
|
+
!symbol ||
|
|
743
|
+
!fetchedAt ||
|
|
744
|
+
rateFraction === undefined ||
|
|
745
|
+
r.sourceAt !== null ||
|
|
746
|
+
r.freshnessBasis !== "collection_time")
|
|
747
|
+
continue;
|
|
748
|
+
const source = role === "context_only"
|
|
749
|
+
? "hyperliquid_predicted_fundings"
|
|
750
|
+
: "paper_futures_reference";
|
|
751
|
+
if (r.source !== source ||
|
|
752
|
+
(role === "context_only" &&
|
|
753
|
+
(venue !== "hyperliquid" ||
|
|
754
|
+
r.intervalHours !== 1 ||
|
|
755
|
+
Math.abs(rateFraction) > 0.04)))
|
|
756
|
+
continue;
|
|
757
|
+
const intervalHours = typeof r.intervalHours === "number" &&
|
|
758
|
+
Number.isInteger(r.intervalHours) &&
|
|
759
|
+
r.intervalHours > 0 &&
|
|
760
|
+
r.intervalHours <= 24
|
|
761
|
+
? r.intervalHours
|
|
762
|
+
: null;
|
|
763
|
+
const hourlyEquivalentFraction = intervalHours === null ? null : rateFraction / intervalHours;
|
|
764
|
+
if (hourlyEquivalentFraction !== null &&
|
|
765
|
+
!Number.isFinite(hourlyEquivalentFraction))
|
|
766
|
+
continue;
|
|
767
|
+
// A duplicate venue makes identity ambiguous; never pick the first silently.
|
|
768
|
+
if (venues.has(venue))
|
|
769
|
+
return undefined;
|
|
770
|
+
venues.add(venue);
|
|
771
|
+
const ageSeconds = Math.max(0, Math.floor((nowMs - Date.parse(fetchedAt)) / 1000));
|
|
772
|
+
const next = typeof r.nextFundingTime === "string"
|
|
773
|
+
? Date.parse(r.nextFundingTime)
|
|
774
|
+
: NaN;
|
|
775
|
+
const nextFundingTime = Number.isFinite(next) &&
|
|
776
|
+
next > nowMs &&
|
|
777
|
+
next <=
|
|
778
|
+
Date.parse(fetchedAt) +
|
|
779
|
+
(role === "context_only" ? 61 * 60_000 : 24 * 3_600_000)
|
|
780
|
+
? new Date(next).toISOString()
|
|
781
|
+
: null;
|
|
782
|
+
rates.push({
|
|
783
|
+
role,
|
|
784
|
+
venue,
|
|
785
|
+
symbol,
|
|
786
|
+
source,
|
|
787
|
+
rateFraction,
|
|
788
|
+
intervalHours,
|
|
789
|
+
hourlyEquivalentFraction,
|
|
790
|
+
nextFundingTime,
|
|
791
|
+
fetchedAt,
|
|
792
|
+
sourceAt: null,
|
|
793
|
+
ageSeconds,
|
|
794
|
+
stale: r.stale !== false || ageSeconds > 1800,
|
|
795
|
+
freshnessBasis: "collection_time",
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return rates.some((r) => r.role === "context_only")
|
|
799
|
+
? { rates, sameTime: false }
|
|
800
|
+
: undefined;
|
|
801
|
+
}
|
|
802
|
+
export function depthOf(m, nowMs = Date.now()) {
|
|
803
|
+
const d = asObj(asObj(m.derivatives).depth);
|
|
804
|
+
const venue = asStr(d.venue);
|
|
805
|
+
const asOf = shownTime(d.asOf, nowMs);
|
|
806
|
+
if (!venue || !asOf)
|
|
807
|
+
return undefined;
|
|
808
|
+
const bid = depthSideOf(d.bid);
|
|
809
|
+
const ask = depthSideOf(d.ask);
|
|
810
|
+
if (!bid && !ask)
|
|
811
|
+
return undefined;
|
|
812
|
+
return { venue, bid, ask, asOf, stale: d.stale !== false };
|
|
813
|
+
}
|
|
814
|
+
// Chain TVL from the same /market context (no extra call). Omitted when the
|
|
815
|
+
// coin has no verified chain association or the block is malformed. publishedAt is
|
|
816
|
+
// response provenance, never the TVL's observation time (unknown, null); a
|
|
817
|
+
// future-dated time is never shown and an unknown one stays stale.
|
|
818
|
+
export function chainTvlOf(m, nowMs = Date.now()) {
|
|
819
|
+
const c = asObj(asObj(m.defi).chainTvl);
|
|
820
|
+
const chain = asStr(c.chain);
|
|
821
|
+
const tvl = asNum(c.tvlUsd);
|
|
822
|
+
if (!chain || tvl == null || tvl < 0)
|
|
823
|
+
return undefined;
|
|
824
|
+
const publishedAt = c.publishedAt == null ? null : (shownTime(c.publishedAt, nowMs) ?? null);
|
|
825
|
+
const fetchedAt = c.fetchedAt == null ? null : (shownTime(c.fetchedAt, nowMs) ?? null);
|
|
826
|
+
const dayAt = c.dayAt == null ? null : (shownTime(c.dayAt, nowMs) ?? null);
|
|
827
|
+
return {
|
|
828
|
+
chain,
|
|
829
|
+
tvlUsd: tvl,
|
|
830
|
+
publishedAt,
|
|
831
|
+
fetchedAt,
|
|
832
|
+
sourceObservedAt: null,
|
|
833
|
+
// Unknown publication OR collection time is never fresh (root 56890).
|
|
834
|
+
stale: publishedAt == null || fetchedAt == null || c.stale !== false,
|
|
835
|
+
dayAt,
|
|
836
|
+
change1dPct: dayAt ? optPct(c.change1dPct) : null,
|
|
837
|
+
change7dPct: dayAt ? optPct(c.change7dPct) : null,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
// Market-wide stablecoin supply from the /market context (same for every coin).
|
|
841
|
+
export function stablecoinSupplyOf(m, nowMs = Date.now()) {
|
|
842
|
+
const s = asObj(asObj(m.defi).stablecoinSupply);
|
|
843
|
+
const total = asNum(s.totalUsd);
|
|
844
|
+
const dayAt = shownTime(s.dayAt, nowMs);
|
|
845
|
+
if (total == null || total < 0 || !dayAt)
|
|
846
|
+
return undefined;
|
|
847
|
+
return {
|
|
848
|
+
totalUsd: total,
|
|
849
|
+
dayAt,
|
|
850
|
+
change1dPct: optPct(s.change1dPct),
|
|
851
|
+
change7dPct: optPct(s.change7dPct),
|
|
852
|
+
stale: s.stale !== false,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
export function macroOf(m, nowMs = Date.now()) {
|
|
856
|
+
const mc = asObj(m.macro);
|
|
857
|
+
const note = asStr(mc.note);
|
|
858
|
+
const quotes = [];
|
|
859
|
+
for (const raw of asArr(mc.quotes)) {
|
|
860
|
+
const q = asObj(raw);
|
|
861
|
+
const symbol = asStr(q.symbol);
|
|
862
|
+
const label = asStr(q.label);
|
|
863
|
+
const kind = asStr(q.kind);
|
|
864
|
+
const price = asNum(q.price);
|
|
865
|
+
const asOf = shownTime(q.asOf, nowMs);
|
|
866
|
+
if (!symbol || !label || !kind || price == null || price <= 0 || !asOf)
|
|
867
|
+
continue;
|
|
868
|
+
const change = asNum(q.change24hPct);
|
|
869
|
+
quotes.push({
|
|
870
|
+
symbol,
|
|
871
|
+
label,
|
|
872
|
+
kind,
|
|
873
|
+
price,
|
|
874
|
+
change24hPct: change == null ? null : change,
|
|
875
|
+
asOf,
|
|
876
|
+
stale: q.stale !== false,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
return note && quotes.length > 0 ? { note, quotes } : undefined;
|
|
880
|
+
}
|
|
203
881
|
// Enrich a watch entry with what the candles fetch yields (indicators + 24h
|
|
204
882
|
// volume) when the `indicators` capability is on. One call, both fields.
|
|
205
|
-
async function enrichFromCandles(client, entry, coinId, trace) {
|
|
206
|
-
const cc = await fetchCandleContext(client, coinId, trace);
|
|
883
|
+
async function enrichFromCandles(client, entry, coinId, range, trace) {
|
|
884
|
+
const cc = await fetchCandleContext(client, coinId, range, trace);
|
|
207
885
|
if (cc.indicators)
|
|
208
886
|
entry.indicators = cc.indicators;
|
|
209
887
|
if (cc.indicatorContext)
|
|
@@ -222,6 +900,22 @@ async function enrichFromCandles(client, entry, coinId, trace) {
|
|
|
222
900
|
}
|
|
223
901
|
}
|
|
224
902
|
const HEADLINES_PER_COIN = 3;
|
|
903
|
+
const NEWS_URL_MAX_CHARS = 300;
|
|
904
|
+
// A source link is kept only when it is a plain http(s) URL of bounded length;
|
|
905
|
+
// anything else (javascript:, data:, oversized) is dropped, never rewritten.
|
|
906
|
+
function newsUrl(raw) {
|
|
907
|
+
if (!raw || raw.length > NEWS_URL_MAX_CHARS)
|
|
908
|
+
return undefined;
|
|
909
|
+
try {
|
|
910
|
+
const url = new URL(raw);
|
|
911
|
+
return url.protocol === "https:" || url.protocol === "http:"
|
|
912
|
+
? url.toString()
|
|
913
|
+
: undefined;
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return undefined;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
225
919
|
const HEADLINE_TITLE_CHARS = 110;
|
|
226
920
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
921
|
// Attribute the fetched news to the coins on watch: by the curated slug link
|
|
@@ -296,6 +990,12 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
296
990
|
// the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
|
|
297
991
|
// Same for every outcome of the event; undefined on an older backend.
|
|
298
992
|
const volumeUsd = asNum(ev.volume24h) ?? undefined;
|
|
993
|
+
// Event-level cross-venue consensus, copied onto every outcome row of
|
|
994
|
+
// the event as-is (never mapped to or flipped for a specific outcome).
|
|
995
|
+
const consensus = pmConsensusOf(ev);
|
|
996
|
+
// Event-level settlement terms, likewise copied to every outcome row;
|
|
997
|
+
// the prompt prints them once per event.
|
|
998
|
+
const rules = pmSettlementRuleOf(ev);
|
|
299
999
|
// At most a few outcomes per event so a wide multi-outcome market
|
|
300
1000
|
// (e.g. dozens of price buckets) can't explode the prompt. Drop
|
|
301
1001
|
// outcomes the backend flagged NOT openable (eligible === false) so the
|
|
@@ -312,26 +1012,37 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
312
1012
|
.slice(0, 3);
|
|
313
1013
|
// Only an absent legacy outcomes field permits the flat fallback. A
|
|
314
1014
|
// present empty/malformed/all-rejected array must never resurrect ev.
|
|
315
|
-
return outcomes.map((o) =>
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
1015
|
+
return outcomes.map((o) => {
|
|
1016
|
+
// This outcome's own rule for a per-outcome event (bound by market
|
|
1017
|
+
// id at the API); undefined when the rule is not per-outcome.
|
|
1018
|
+
const outcomeRule = pmOutcomeRuleOf(rules, o);
|
|
1019
|
+
return {
|
|
1020
|
+
source,
|
|
1021
|
+
slug,
|
|
1022
|
+
outcomeExternalMarketId: asStr(o.externalMarketId) ??
|
|
1023
|
+
asStr(o.outcomeExternalMarketId) ??
|
|
1024
|
+
"",
|
|
1025
|
+
// Carry the odds through: the model needs the outcome label + current
|
|
1026
|
+
// probability to spot a mispriced market and bet it.
|
|
1027
|
+
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
1028
|
+
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
1029
|
+
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
1030
|
+
probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
|
|
1031
|
+
title,
|
|
1032
|
+
freshness,
|
|
1033
|
+
quality,
|
|
1034
|
+
decisionSupport,
|
|
1035
|
+
volumeUsd,
|
|
1036
|
+
// Event-level fundamentals from the same payload (slice 2): the
|
|
1037
|
+
// resolution date and the venue-reported liquidity (USD).
|
|
1038
|
+
endDate: asStr(ev.endDate) ?? undefined,
|
|
1039
|
+
liquidityUsd: asNum(ev.liquidity) ?? undefined,
|
|
1040
|
+
...coinsField(ev.relatedCoins),
|
|
1041
|
+
...(consensus !== undefined ? { consensus } : {}),
|
|
1042
|
+
...(rules !== undefined ? { rules } : {}),
|
|
1043
|
+
...(outcomeRule ? { outcomeRule } : {}),
|
|
1044
|
+
};
|
|
1045
|
+
});
|
|
335
1046
|
})
|
|
336
1047
|
.filter((m) => m.source &&
|
|
337
1048
|
m.slug &&
|
|
@@ -359,7 +1070,7 @@ function emptyObservation(state, scopes = []) {
|
|
|
359
1070
|
polledBeforeWrite: false,
|
|
360
1071
|
};
|
|
361
1072
|
}
|
|
362
|
-
export async function observe(client, spec, state, trace) {
|
|
1073
|
+
export async function observe(client, spec, state, trace, options = {}) {
|
|
363
1074
|
const meR = await client.me(trace);
|
|
364
1075
|
if (!meR.ok)
|
|
365
1076
|
return {
|
|
@@ -452,6 +1163,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
452
1163
|
// Bounded RAG: the market-wide Fear & Greed regime, captured once from the first
|
|
453
1164
|
// coin's /market context (it's market-wide, identical across coins).
|
|
454
1165
|
let marketMood;
|
|
1166
|
+
let macro;
|
|
1167
|
+
let stablecoinSupply;
|
|
455
1168
|
const wantIndicators = spec.capabilities.includes("indicators");
|
|
456
1169
|
const wantNews = spec.capabilities.includes("news");
|
|
457
1170
|
for (const symbol of spec.risk.watchlist) {
|
|
@@ -475,9 +1188,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
475
1188
|
change24h: asNum(price.change24h),
|
|
476
1189
|
change7d: asNum(price.change7d),
|
|
477
1190
|
// Community sentiment (already in the /market context, was stripped).
|
|
478
|
-
|
|
1191
|
+
...sentimentContextOf(m),
|
|
479
1192
|
// Freshness lives under the response's `observation` block.
|
|
480
1193
|
freshness: freshnessOf(asObj(m.observation)),
|
|
1194
|
+
// Server futures-reference eligibility (undefined = unknown, older API).
|
|
1195
|
+
futuresEntryEligibility: futuresEntryEligibilityOf(m),
|
|
481
1196
|
// Canonical slug (the news graph's key): from the resolve match, else
|
|
482
1197
|
// the market context's observation.dataset.coinSlug.
|
|
483
1198
|
slug: asStr(match.slug) ??
|
|
@@ -486,18 +1201,46 @@ export async function observe(client, spec, state, trace) {
|
|
|
486
1201
|
const fundamentals = coinFundamentalsOf(m);
|
|
487
1202
|
if (fundamentals)
|
|
488
1203
|
entry.fundamentals = fundamentals;
|
|
1204
|
+
const openInterest = openInterestOf(m);
|
|
1205
|
+
if (openInterest)
|
|
1206
|
+
entry.openInterest = openInterest;
|
|
1207
|
+
const positioning = positioningOf(m);
|
|
1208
|
+
if (positioning)
|
|
1209
|
+
entry.positioning = positioning;
|
|
1210
|
+
const liquidations = liquidationsOf(m);
|
|
1211
|
+
if (liquidations)
|
|
1212
|
+
entry.liquidations = liquidations;
|
|
1213
|
+
const chainTvl = chainTvlOf(m);
|
|
1214
|
+
if (chainTvl)
|
|
1215
|
+
entry.chainTvl = chainTvl;
|
|
1216
|
+
const depth = depthOf(m);
|
|
1217
|
+
if (depth)
|
|
1218
|
+
entry.depth = depth;
|
|
1219
|
+
const fundingByVenue = fundingByVenueOf(m);
|
|
1220
|
+
if (fundingByVenue)
|
|
1221
|
+
entry.fundingByVenue = fundingByVenue;
|
|
1222
|
+
// Macro proxies are the same for every coin: keep the first usable block.
|
|
1223
|
+
if (!macro)
|
|
1224
|
+
macro = macroOf(m);
|
|
1225
|
+
if (!stablecoinSupply)
|
|
1226
|
+
stablecoinSupply = stablecoinSupplyOf(m);
|
|
489
1227
|
// Capture the market-wide Fear & Greed regime once (same across coins).
|
|
490
1228
|
if (!marketMood) {
|
|
491
1229
|
const fg = asObj(m.fearGreed);
|
|
492
1230
|
const v = asNum(fg.value);
|
|
1231
|
+
const fetchedAt = contextTimestamp(fg.fetchedAt);
|
|
493
1232
|
if (v != null)
|
|
494
|
-
marketMood = {
|
|
1233
|
+
marketMood = {
|
|
1234
|
+
fearGreed: v,
|
|
1235
|
+
label: asStr(fg.label) ?? "",
|
|
1236
|
+
...(fetchedAt ? { fetchedAt } : {}),
|
|
1237
|
+
};
|
|
495
1238
|
}
|
|
496
1239
|
// `indicators` capability: enrich the observation with computed TA so the
|
|
497
1240
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
498
1241
|
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
499
1242
|
if (wantIndicators)
|
|
500
|
-
await enrichFromCandles(client, entry, coinId, trace);
|
|
1243
|
+
await enrichFromCandles(client, entry, coinId, indicatorRangeOf(spec), trace);
|
|
501
1244
|
watch.push(entry);
|
|
502
1245
|
}
|
|
503
1246
|
// `universe_scan` capability (2026-08-18, direct user request): discover the
|
|
@@ -510,78 +1253,159 @@ export async function observe(client, spec, state, trace) {
|
|
|
510
1253
|
// never a skipped cycle. Watchlist + blocklist symbols are excluded up
|
|
511
1254
|
// front so a discovered row can never duplicate or bypass the deny-list.
|
|
512
1255
|
let universeMovers;
|
|
513
|
-
if (spec
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1256
|
+
if (scansUniverse(spec)) {
|
|
1257
|
+
const excluded = new Set([...spec.risk.watchlist, ...(spec.risk.blocklist ?? [])].map((s) => s.toUpperCase()));
|
|
1258
|
+
// Declared boundaries ask the screener; otherwise keep the original
|
|
1259
|
+
// top-gainers scan exactly. Either way the rows share one resolve path.
|
|
1260
|
+
let rows = [];
|
|
1261
|
+
let resolveTopCount = UNIVERSE_RESOLVE_TOP;
|
|
1262
|
+
if (spec.universe) {
|
|
1263
|
+
const q = universeQuery(spec.universe);
|
|
1264
|
+
resolveTopCount = universeResolveTop(spec.universe);
|
|
1265
|
+
const ur = await client.agentUniverse(universeQueryParams(q), trace);
|
|
1266
|
+
if (ur.ok) {
|
|
1267
|
+
rows = filterUniverseRows(asArr(asObj(ur.data).rows)
|
|
1268
|
+
.map(asObj)
|
|
1269
|
+
.map((r) => ({
|
|
1270
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1271
|
+
name: asStr(r.name),
|
|
1272
|
+
slug: asStr(r.slug),
|
|
1273
|
+
coinId: asStr(r.ucid),
|
|
1274
|
+
marketCapRank: asNum(r.marketCapRank) ?? undefined,
|
|
1275
|
+
priceUsd: asNumLoose(r.priceUsd),
|
|
1276
|
+
change24h: asNumLoose(r.change24h),
|
|
1277
|
+
volume24hUsd: asNumLoose(r.volume24hUsd),
|
|
1278
|
+
// Missing stays missing: the filter must not read an absent
|
|
1279
|
+
// sector list as "no excluded sector".
|
|
1280
|
+
sectors: universeSectorsOf(r.sectors),
|
|
1281
|
+
})), q);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
const mv = await client.cryptoMovers("gainers", UNIVERSE_SCAN_LIMIT, trace);
|
|
1286
|
+
if (mv.ok && Array.isArray(mv.data)) {
|
|
1287
|
+
rows = mv.data.map(asObj).map((r) => ({
|
|
1288
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1289
|
+
name: asStr(r.name),
|
|
1290
|
+
// Both serialize as decimal STRINGS on the live feed (openapi
|
|
1291
|
+
// PublicCryptoMover; probed 2026-09-02: "72.34"), so the strict
|
|
1292
|
+
// asNum read left them undefined. Parse the numeric string.
|
|
1293
|
+
change24h: asNumLoose(r.change24h),
|
|
1294
|
+
priceUsd: asNumLoose(r.currentPrice),
|
|
1295
|
+
slug: asStr(r.slug),
|
|
1296
|
+
// The movers row already carries the ucid, which IS the coinId every
|
|
1297
|
+
// downstream call takes. Kept so the resolve round-trip below can be
|
|
1298
|
+
// skipped — see the comment there.
|
|
1299
|
+
coinId: asStr(r.ucid),
|
|
1300
|
+
}));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
rows = rows.filter((r) => r.symbol && !excluded.has(r.symbol));
|
|
1304
|
+
for (const row of rows.slice(0, resolveTopCount)) {
|
|
1305
|
+
// Prefer the ucid the feed already gave us. Resolving the SYMBOL
|
|
1306
|
+
// instead was both a wasted call per discovered mover and a correctness
|
|
1307
|
+
// hazard: symbols collide across listings, so the resolver could hand
|
|
1308
|
+
// back a different coin than the one that actually moved, and the
|
|
1309
|
+
// agent would analyze (and trade) that other coin.
|
|
1310
|
+
let coinId = row.coinId;
|
|
1311
|
+
let resolvedName;
|
|
1312
|
+
if (!coinId) {
|
|
1313
|
+
const rs = await client.resolve(row.symbol, trace);
|
|
1314
|
+
const match = asObj(asObj(rs.data).match);
|
|
1315
|
+
coinId =
|
|
1316
|
+
rs.ok && match.coinId != null ? String(match.coinId) : undefined;
|
|
1317
|
+
resolvedName = asStr(match.name);
|
|
1318
|
+
}
|
|
1319
|
+
if (!coinId)
|
|
1320
|
+
continue;
|
|
1321
|
+
const mk = await client.market(coinId, trace);
|
|
1322
|
+
const m = asObj(mk.data);
|
|
1323
|
+
const price = asObj(m.price);
|
|
1324
|
+
const entry = {
|
|
1325
|
+
symbol: row.symbol,
|
|
1326
|
+
coinId,
|
|
1327
|
+
name: resolvedName ?? row.name ?? undefined,
|
|
1328
|
+
priceUsd: asNum(price.usd) ?? row.priceUsd,
|
|
1329
|
+
change1h: asNum(price.change1h),
|
|
1330
|
+
change24h: asNum(price.change24h) ?? row.change24h,
|
|
1331
|
+
change7d: asNum(price.change7d),
|
|
1332
|
+
...sentimentContextOf(m),
|
|
1333
|
+
freshness: freshnessOf(asObj(m.observation)),
|
|
1334
|
+
futuresEntryEligibility: futuresEntryEligibilityOf(m),
|
|
1335
|
+
discovered: true,
|
|
1336
|
+
slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
1337
|
+
};
|
|
1338
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
1339
|
+
if (fundamentals)
|
|
1340
|
+
entry.fundamentals = fundamentals;
|
|
1341
|
+
const openInterest = openInterestOf(m);
|
|
1342
|
+
if (openInterest)
|
|
1343
|
+
entry.openInterest = openInterest;
|
|
1344
|
+
const positioning = positioningOf(m);
|
|
1345
|
+
if (positioning)
|
|
1346
|
+
entry.positioning = positioning;
|
|
1347
|
+
const liquidations = liquidationsOf(m);
|
|
1348
|
+
if (liquidations)
|
|
1349
|
+
entry.liquidations = liquidations;
|
|
1350
|
+
const chainTvl = chainTvlOf(m);
|
|
1351
|
+
if (chainTvl)
|
|
1352
|
+
entry.chainTvl = chainTvl;
|
|
1353
|
+
const depth = depthOf(m);
|
|
1354
|
+
if (depth)
|
|
1355
|
+
entry.depth = depth;
|
|
1356
|
+
const fundingByVenue = fundingByVenueOf(m);
|
|
1357
|
+
if (fundingByVenue)
|
|
1358
|
+
entry.fundingByVenue = fundingByVenue;
|
|
1359
|
+
if (!macro)
|
|
1360
|
+
macro = macroOf(m);
|
|
1361
|
+
if (!stablecoinSupply)
|
|
1362
|
+
stablecoinSupply = stablecoinSupplyOf(m);
|
|
1363
|
+
if (wantIndicators)
|
|
1364
|
+
await enrichFromCandles(client, entry, coinId, indicatorRangeOf(spec), trace);
|
|
1365
|
+
watch.push(entry);
|
|
1366
|
+
}
|
|
1367
|
+
const context = rows
|
|
1368
|
+
.slice(resolveTopCount)
|
|
1369
|
+
.map(({ symbol, name, change24h, priceUsd }) => ({
|
|
1370
|
+
symbol,
|
|
1371
|
+
name,
|
|
1372
|
+
change24hPct: change24h,
|
|
1373
|
+
priceUsd,
|
|
1374
|
+
}));
|
|
1375
|
+
if (context.length > 0)
|
|
1376
|
+
universeMovers = context;
|
|
1377
|
+
// Watchlist coins must also prove they are inside declared boundaries
|
|
1378
|
+
// before a new entry (universeEntryBlock). Bounded membership batches use
|
|
1379
|
+
// the same filters; a failed batch leaves its coins unverified (no entries),
|
|
1380
|
+
// never silently eligible. Held positions stay closable either way.
|
|
1381
|
+
if (spec.universe) {
|
|
1382
|
+
const toCheck = watch.filter((w) => !w.discovered && w.coinId);
|
|
1383
|
+
const ids = [...new Set(toCheck.map((w) => w.coinId))];
|
|
1384
|
+
for (let offset = 0; offset < ids.length; offset += UNIVERSE_MAX_SCAN_LIMIT) {
|
|
1385
|
+
const batch = ids.slice(offset, offset + UNIVERSE_MAX_SCAN_LIMIT);
|
|
1386
|
+
const q = {
|
|
1387
|
+
...universeQuery(spec.universe),
|
|
1388
|
+
sort: "rank",
|
|
1389
|
+
limit: batch.length,
|
|
567
1390
|
};
|
|
568
|
-
const
|
|
569
|
-
if (
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1391
|
+
const mr = await client.agentUniverse(universeQueryParams(q, batch), trace);
|
|
1392
|
+
if (mr.ok) {
|
|
1393
|
+
const inside = new Set(filterUniverseRows(asArr(asObj(mr.data).rows)
|
|
1394
|
+
.map(asObj)
|
|
1395
|
+
.map((r) => ({
|
|
1396
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1397
|
+
coinId: asStr(r.ucid),
|
|
1398
|
+
marketCapRank: asNum(r.marketCapRank) ?? undefined,
|
|
1399
|
+
volume24hUsd: asNumLoose(r.volume24hUsd),
|
|
1400
|
+
sectors: universeSectorsOf(r.sectors),
|
|
1401
|
+
})), q).map((r) => r.coinId));
|
|
1402
|
+
const checked = new Set(batch);
|
|
1403
|
+
for (const w of toCheck) {
|
|
1404
|
+
if (checked.has(w.coinId))
|
|
1405
|
+
w.withinBoundaries = inside.has(w.coinId);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
574
1408
|
}
|
|
575
|
-
const context = rows
|
|
576
|
-
.slice(UNIVERSE_RESOLVE_TOP)
|
|
577
|
-
.map(({ symbol, name, change24hPct, priceUsd }) => ({
|
|
578
|
-
symbol,
|
|
579
|
-
name,
|
|
580
|
-
change24hPct,
|
|
581
|
-
priceUsd,
|
|
582
|
-
}));
|
|
583
|
-
if (context.length > 0)
|
|
584
|
-
universeMovers = context;
|
|
585
1409
|
}
|
|
586
1410
|
}
|
|
587
1411
|
// Spot resting orders (for cancel + affordability) — only if spot is enabled.
|
|
@@ -611,22 +1435,30 @@ export async function observe(client, spec, state, trace) {
|
|
|
611
1435
|
let capitalPmData;
|
|
612
1436
|
let pmResolutions = [];
|
|
613
1437
|
let pmMarkets = [];
|
|
1438
|
+
let pmCalibration;
|
|
614
1439
|
if (wantPm) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
//
|
|
620
|
-
//
|
|
1440
|
+
// Curated board for every non-mechanical agent: churn rows removed and a
|
|
1441
|
+
// deeper page so the filter does not empty it (see PM_CALIBRATION_CHURN_RE).
|
|
1442
|
+
const curatedPmBoard = options.curatedPmBoard ?? spec.model?.provider !== "mechanical";
|
|
1443
|
+
const primaryDiscoveryLimit = curatedPmBoard ? 30 : 12;
|
|
1444
|
+
// Bias PM discovery toward CRYPTO markets the agent has a price view on, the
|
|
1445
|
+
// only PM markets where a price agent's view is even relevant (probed
|
|
1446
|
+
// 2026-06-24: the default board is World Cup / elections / F1). The discover
|
|
621
1447
|
// `q` is an AND/phrase match, so query ONE coin — the agent's TOP watchlist coin,
|
|
622
1448
|
// where its price view is sharpest — never the joined list (matches ~nothing).
|
|
623
1449
|
// Fall back to Bitcoin (always plentiful) — NEVER the general non-crypto board.
|
|
624
1450
|
const topCoin = (spec.risk.watchlist[0] ?? "").toUpperCase();
|
|
625
1451
|
const pmQuery = PM_COIN_NAMES[topCoin] ?? spec.risk.watchlist[0] ?? "Bitcoin";
|
|
626
|
-
|
|
1452
|
+
// The own-calibration read runs alongside the PM reads (cached, bounded,
|
|
1453
|
+
// never fatal). Mechanical agents have no prompt, so they skip it.
|
|
1454
|
+
const [pmPosR, pmDiscFirst, calibration] = await Promise.all([
|
|
627
1455
|
client.pmPositions(undefined, trace),
|
|
628
1456
|
client.discoverPmMarkets({ q: pmQuery, limit: primaryDiscoveryLimit }, trace),
|
|
1457
|
+
curatedPmBoard
|
|
1458
|
+
? readPmCalibration(client, trace)
|
|
1459
|
+
: Promise.resolve(undefined),
|
|
629
1460
|
]);
|
|
1461
|
+
pmCalibration = calibration;
|
|
630
1462
|
let pmDiscR = pmDiscFirst;
|
|
631
1463
|
const firstCount = pmDiscR.ok
|
|
632
1464
|
? asArr(asObj(pmDiscR.data).data ??
|
|
@@ -634,7 +1466,9 @@ export async function observe(client, spec, state, trace) {
|
|
|
634
1466
|
asObj(pmDiscR.data).results).length
|
|
635
1467
|
: 0;
|
|
636
1468
|
if (firstCount < 3 && pmQuery !== "Bitcoin") {
|
|
637
|
-
|
|
1469
|
+
// Same page depth as the primary: the Bitcoin board is the most
|
|
1470
|
+
// churn-heavy query, so a 12-row page could filter down to nothing.
|
|
1471
|
+
const fb = await client.discoverPmMarkets({ q: "Bitcoin", limit: primaryDiscoveryLimit }, trace);
|
|
638
1472
|
if (fb.ok)
|
|
639
1473
|
pmDiscR = fb;
|
|
640
1474
|
}
|
|
@@ -670,6 +1504,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
670
1504
|
entryProbability: asNum(p.entryProbability),
|
|
671
1505
|
currentProbability: asNum(p.currentProbability),
|
|
672
1506
|
openedAt: asStr(p.openedAt),
|
|
1507
|
+
...coinsField(p.relatedCoins),
|
|
1508
|
+
...(asStr(p.eventEndDate) ? { endDate: asStr(p.eventEndDate) } : {}),
|
|
673
1509
|
}));
|
|
674
1510
|
// Settlement-feedback loop: the SAME /positions/pm response carries an
|
|
675
1511
|
// additive `recentlyResolved` array — the agent's OWN bets that settled
|
|
@@ -714,13 +1550,13 @@ export async function observe(client, spec, state, trace) {
|
|
|
714
1550
|
// quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
|
|
715
1551
|
// that into one row per quoteable outcome (eligible + not-held filtered).
|
|
716
1552
|
let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
|
|
717
|
-
if (
|
|
1553
|
+
if (curatedPmBoard) {
|
|
718
1554
|
mergedRows = mergedRows.filter((market) => !isCalibrationChurnMarket(market));
|
|
719
1555
|
}
|
|
720
1556
|
// ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
|
|
721
|
-
// The prompt tells the model
|
|
722
|
-
//
|
|
723
|
-
// market for the coin it analysed. The primary board is keyed to ONE query
|
|
1557
|
+
// The prompt tells the model crypto price markets are where its price view
|
|
1558
|
+
// is at least relevant, but that is only usable if the board actually LISTS
|
|
1559
|
+
// a market for the coin it analysed. The primary board is keyed to ONE query
|
|
724
1560
|
// (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
|
|
725
1561
|
// so an agent whose top coin got displaced by the Bitcoin fallback sees NO
|
|
726
1562
|
// market for the coin it has a view on and an 8B model invents a pmN ref
|
|
@@ -751,7 +1587,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
751
1587
|
let secRows = expandPmMarkets(secR.data, heldPmKeys)
|
|
752
1588
|
.filter((m) => titleMentionsCoin(m.title, topAnalyzed))
|
|
753
1589
|
.filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`));
|
|
754
|
-
if (
|
|
1590
|
+
if (curatedPmBoard) {
|
|
755
1591
|
secRows = secRows.filter((market) => !isCalibrationChurnMarket(market));
|
|
756
1592
|
}
|
|
757
1593
|
secRows = secRows.slice(0, 4);
|
|
@@ -800,6 +1636,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
800
1636
|
importance: asNum(it.importance) ?? undefined,
|
|
801
1637
|
ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
|
|
802
1638
|
publishedAt: asStr(it.publishedAt) ?? undefined,
|
|
1639
|
+
url: newsUrl(asStr(it.url)),
|
|
1640
|
+
category: asStr(it.category)?.slice(0, 40) ?? undefined,
|
|
803
1641
|
coins: asArr(it.coins)
|
|
804
1642
|
.map((c) => asStr(c))
|
|
805
1643
|
.filter((c) => !!c),
|
|
@@ -827,14 +1665,17 @@ export async function observe(client, spec, state, trace) {
|
|
|
827
1665
|
pmPositions,
|
|
828
1666
|
pmResolutions,
|
|
829
1667
|
pmMarkets,
|
|
1668
|
+
...(pmCalibration ? { pmCalibration } : {}),
|
|
830
1669
|
watch,
|
|
831
1670
|
news,
|
|
832
1671
|
// Deterministic structure flags computed from the watch indicators — the
|
|
833
1672
|
// model acts on these instead of re-deciding "is there a setup?" from scratch.
|
|
834
1673
|
// openPositions are passed so setups on a held symbol are tagged "manage,
|
|
835
1674
|
// don't re-open".
|
|
836
|
-
setups: scanSetups(watch, openPositions),
|
|
1675
|
+
setups: scanSetups(watch, openPositions, signalThresholdsOf(spec)),
|
|
837
1676
|
marketMood,
|
|
1677
|
+
...(macro ? { macro } : {}),
|
|
1678
|
+
...(stablecoinSupply ? { stablecoinSupply } : {}),
|
|
838
1679
|
syncCursor,
|
|
839
1680
|
newClosedTrades,
|
|
840
1681
|
polledBeforeWrite,
|