@coinrithm/mcp-trading 0.7.14 → 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.
@@ -1,11 +1,374 @@
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";
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
+ }
9
372
  // The 1D endpoint nominally returns 288 five-minute bars. Timestamps and gaps
10
373
  // are checked separately; requesting this range does not establish freshness.
11
374
  const INDICATOR_RANGE = "1D";
@@ -64,11 +427,14 @@ 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
- // calibration universe: outcomes overlap heavily, resolve too quickly to admit
68
- // meaningful independent research, and drown the public scorecard in Bitcoin
69
- // coin flips. Non-mechanical calibration agents receive a deeper discovery
70
- // page with these rows removed. Mechanical baselines intentionally keep the
71
- // unmodified universe so their reference contract remains reproducible.
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 ?? ""}`);
@@ -296,6 +662,12 @@ function expandPmMarkets(discData, heldPmKeys) {
296
662
  // the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
297
663
  // Same for every outcome of the event; undefined on an older backend.
298
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);
299
671
  // At most a few outcomes per event so a wide multi-outcome market
300
672
  // (e.g. dozens of price buckets) can't explode the prompt. Drop
301
673
  // outcomes the backend flagged NOT openable (eligible === false) so the
@@ -312,26 +684,36 @@ function expandPmMarkets(discData, heldPmKeys) {
312
684
  .slice(0, 3);
313
685
  // Only an absent legacy outcomes field permits the flat fallback. A
314
686
  // present empty/malformed/all-rejected array must never resurrect ev.
315
- return outcomes.map((o) => ({
316
- source,
317
- slug,
318
- outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
319
- // Carry the odds through: the model needs the outcome label + current
320
- // probability to spot a mispriced market and bet it.
321
- outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
322
- // Backend returns probability as 0..100 (percent) — normalise to 0..1
323
- // to match the prompt's "0..1" framing (probed 2026-06-24).
324
- probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
325
- title,
326
- freshness,
327
- quality,
328
- decisionSupport,
329
- volumeUsd,
330
- // Event-level fundamentals from the same payload (slice 2): the
331
- // resolution date and the venue-reported liquidity (USD).
332
- endDate: asStr(ev.endDate) ?? undefined,
333
- liquidityUsd: asNum(ev.liquidity) ?? undefined,
334
- }));
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
+ });
335
717
  })
336
718
  .filter((m) => m.source &&
337
719
  m.slug &&
@@ -475,9 +857,11 @@ export async function observe(client, spec, state, trace) {
475
857
  change24h: asNum(price.change24h),
476
858
  change7d: asNum(price.change7d),
477
859
  // Community sentiment (already in the /market context, was stripped).
478
- sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
860
+ ...sentimentContextOf(m),
479
861
  // Freshness lives under the response's `observation` block.
480
862
  freshness: freshnessOf(asObj(m.observation)),
863
+ // Server futures-reference eligibility (undefined = unknown, older API).
864
+ futuresEntryEligibility: futuresEntryEligibilityOf(m),
481
865
  // Canonical slug (the news graph's key): from the resolve match, else
482
866
  // the market context's observation.dataset.coinSlug.
483
867
  slug: asStr(match.slug) ??
@@ -490,8 +874,13 @@ export async function observe(client, spec, state, trace) {
490
874
  if (!marketMood) {
491
875
  const fg = asObj(m.fearGreed);
492
876
  const v = asNum(fg.value);
877
+ const fetchedAt = contextTimestamp(fg.fetchedAt);
493
878
  if (v != null)
494
- marketMood = { fearGreed: v, label: asStr(fg.label) ?? "" };
879
+ marketMood = {
880
+ fearGreed: v,
881
+ label: asStr(fg.label) ?? "",
882
+ ...(fetchedAt ? { fetchedAt } : {}),
883
+ };
495
884
  }
496
885
  // `indicators` capability: enrich the observation with computed TA so the
497
886
  // model reasons over structure (trend/momentum/volatility/breakout) instead
@@ -560,8 +949,9 @@ export async function observe(client, spec, state, trace) {
560
949
  change1h: asNum(price.change1h),
561
950
  change24h: asNum(price.change24h) ?? row.change24hPct,
562
951
  change7d: asNum(price.change7d),
563
- sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
952
+ ...sentimentContextOf(m),
564
953
  freshness: freshnessOf(asObj(m.observation)),
954
+ futuresEntryEligibility: futuresEntryEligibilityOf(m),
565
955
  discovered: true,
566
956
  slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
567
957
  };
@@ -611,22 +1001,30 @@ export async function observe(client, spec, state, trace) {
611
1001
  let capitalPmData;
612
1002
  let pmResolutions = [];
613
1003
  let pmMarkets = [];
1004
+ let pmCalibration;
614
1005
  if (wantPm) {
615
- const curatedCalibrationBoard = spec.objective?.primary === "calibration" &&
616
- spec.model?.provider !== "mechanical";
617
- const primaryDiscoveryLimit = curatedCalibrationBoard ? 30 : 12;
618
- // Bias PM discovery toward CRYPTO markets the agent has a price view on — the
619
- // only PM edge a price agent reliably has (probed 2026-06-24: the default board
620
- // is World Cup / elections / F1, which an agent has no edge on). The discover
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
621
1013
  // `q` is an AND/phrase match, so query ONE coin — the agent's TOP watchlist coin,
622
1014
  // where its price view is sharpest — never the joined list (matches ~nothing).
623
1015
  // Fall back to Bitcoin (always plentiful) — NEVER the general non-crypto board.
624
1016
  const topCoin = (spec.risk.watchlist[0] ?? "").toUpperCase();
625
1017
  const pmQuery = PM_COIN_NAMES[topCoin] ?? spec.risk.watchlist[0] ?? "Bitcoin";
626
- const [pmPosR, pmDiscFirst] = await Promise.all([
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([
627
1021
  client.pmPositions(undefined, trace),
628
1022
  client.discoverPmMarkets({ q: pmQuery, limit: primaryDiscoveryLimit }, trace),
1023
+ curatedPmBoard
1024
+ ? readPmCalibration(client, trace)
1025
+ : Promise.resolve(undefined),
629
1026
  ]);
1027
+ pmCalibration = calibration;
630
1028
  let pmDiscR = pmDiscFirst;
631
1029
  const firstCount = pmDiscR.ok
632
1030
  ? asArr(asObj(pmDiscR.data).data ??
@@ -634,7 +1032,9 @@ export async function observe(client, spec, state, trace) {
634
1032
  asObj(pmDiscR.data).results).length
635
1033
  : 0;
636
1034
  if (firstCount < 3 && pmQuery !== "Bitcoin") {
637
- const fb = await client.discoverPmMarkets({ q: "Bitcoin", limit: 12 }, trace);
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);
638
1038
  if (fb.ok)
639
1039
  pmDiscR = fb;
640
1040
  }
@@ -714,13 +1114,13 @@ export async function observe(client, spec, state, trace) {
714
1114
  // quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
715
1115
  // that into one row per quoteable outcome (eligible + not-held filtered).
716
1116
  let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
717
- if (curatedCalibrationBoard) {
1117
+ if (curatedPmBoard) {
718
1118
  mergedRows = mergedRows.filter((market) => !isCalibrationChurnMarket(market));
719
1119
  }
720
1120
  // ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
721
- // The prompt tells the model its SHARPEST PM edge is the crypto price view it
722
- // JUST formed — but that is only actionable if the board actually LISTS a
723
- // 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
724
1124
  // (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
725
1125
  // so an agent whose top coin got displaced by the Bitcoin fallback sees NO
726
1126
  // market for the coin it has a view on and an 8B model invents a pmN ref
@@ -751,7 +1151,7 @@ export async function observe(client, spec, state, trace) {
751
1151
  let secRows = expandPmMarkets(secR.data, heldPmKeys)
752
1152
  .filter((m) => titleMentionsCoin(m.title, topAnalyzed))
753
1153
  .filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`));
754
- if (curatedCalibrationBoard) {
1154
+ if (curatedPmBoard) {
755
1155
  secRows = secRows.filter((market) => !isCalibrationChurnMarket(market));
756
1156
  }
757
1157
  secRows = secRows.slice(0, 4);
@@ -827,6 +1227,7 @@ export async function observe(client, spec, state, trace) {
827
1227
  pmPositions,
828
1228
  pmResolutions,
829
1229
  pmMarkets,
1230
+ ...(pmCalibration ? { pmCalibration } : {}),
830
1231
  watch,
831
1232
  news,
832
1233
  // Deterministic structure flags computed from the watch indicators — the
@@ -1,4 +1,4 @@
1
- import type { Freshness, PmDecisionSupport, PmQuality } from "./types.js";
1
+ import type { Freshness, PmCalibration, PmConsensus, PmDecisionSupport, PmQuality, PmOutcomeRule, PmSettlementRule } from "./types.js";
2
2
  export declare const PM_BLOCK_REASONS: readonly ["structurally_invalid", "stale_freshness", "freshness_unknown", "unpriced", "quote_dead", "dead_zero", "not_open", "source_degraded", "settlement_limbo"];
3
3
  export declare const PM_WARNING_REASONS: readonly ["lagging_freshness", "unproven_no_activity", "untraded_default", "play_money", "anomaly_flagged", "source_time_unverified", "sum_atypical_independent"];
4
4
  export declare const PM_FLAGS: readonly ["thinMarket", "inactiveMarket", "highAmbiguity", "nearResolution", "staleData"];
@@ -10,4 +10,13 @@ export declare const knownCode: (value: unknown, allowed: readonly string[]) =>
10
10
  export declare function sourceTimestamp(value: unknown): string | undefined;
11
11
  export declare function freshnessOf(block: Record<string, unknown>): Freshness | undefined;
12
12
  export declare function pmQualityOf(value: unknown): PmQuality | undefined;
13
+ export declare const PM_RULES_TEXT_MAX = 500;
14
+ export declare function pmSettlementRuleOf(event: Record<string, unknown>): PmSettlementRule | null | undefined;
15
+ export declare const PM_OUTCOME_PRIMARY_MAX = 400;
16
+ export declare const PM_OUTCOME_SECONDARY_MAX = 300;
17
+ export declare function pmOutcomeRuleOf(rules: PmSettlementRule | null | undefined, outcome: Record<string, unknown>): PmOutcomeRule | undefined;
18
+ export declare const PM_CONSENSUS_KINDS: readonly ["binary", "leader"];
19
+ export declare function pmConsensusOf(event: Record<string, unknown>): PmConsensus | null | undefined;
20
+ export declare const PM_CALIBRATION_MIN_SETTLED = 20;
21
+ export declare function pmCalibrationOf(value: unknown): PmCalibration | undefined;
13
22
  export declare function pmDecisionSupportOf(value: unknown): PmDecisionSupport | undefined;