@coinrithm/mcp-trading 0.7.7 → 0.7.8

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.
@@ -4,7 +4,43 @@
4
4
  // market text cannot widen a limit or force a trade. Covers futures + spot + PM.
5
5
  import { ok, fail, actionVenue, spotBuyCost, } from "./types.js";
6
6
  const SERVER_MAX_LEVERAGE = 20;
7
+ // Minimum edge, in probability POINTS, between the model's own forecast for
8
+ // the outcome it is backing and what that outcome currently costs. Live
9
+ // 2026-09-02: of 7 executed pm_opens in the first release window, 3 backed an
10
+ // outcome their own forecast priced at or BELOW the market (worst -53 points,
11
+ // mean -0.7), i.e. an agent paid 65 for something it thought was worth 45. A
12
+ // few points of cushion also covers spread and fees rather than trading a
13
+ // rounding difference. Env-tunable for a fleet retune without a redeploy.
14
+ const PM_MIN_FORECAST_EDGE_POINTS = (() => {
15
+ const raw = Number(process.env.AGENT_PM_MIN_FORECAST_EDGE_POINTS);
16
+ return Number.isFinite(raw) && raw >= 0 ? raw : 2;
17
+ })();
7
18
  const PM_MIN_STAKE_MUSD = 10; // server minimum prediction-market stake
19
+ // Entry budgets are exposure budgets, not emergency-action budgets. Closing a
20
+ // futures position, updating its protection, cancelling an order, or selling
21
+ // spot reduces/contains risk and must remain available after an entry cap.
22
+ /**
23
+ * True when a thesis says, in so many words, that it is betting AGAINST the
24
+ * outcome the action is buying. Deliberately narrow: it fires only when the
25
+ * negation names the backed outcome directly ("betting against the Up
26
+ * outcome" while buying Up), because a false positive here silences a
27
+ * legitimate trade. Live shape 2026-09-02, cycle 863728.
28
+ */
29
+ export function thesisContradictsOutcome(summary, outcomeName) {
30
+ if (!summary || !outcomeName)
31
+ return false;
32
+ const outcome = outcomeName.trim().toLowerCase();
33
+ if (!outcome)
34
+ return false;
35
+ const escaped = outcome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ const negation = new RegExp("\\b(?:against|fade|fading)\\s+(?:the\\s+)?[\"']?" + escaped + "\\b", "i");
37
+ return negation.test(summary);
38
+ }
39
+ export function isRiskIncreasingAction(action) {
40
+ return (action.type === "futures_open" ||
41
+ action.type === "pm_open" ||
42
+ (action.type === "spot_order" && action.side === "buy"));
43
+ }
8
44
  export function validateAction(action, ctx) {
9
45
  const { spec, observation } = ctx;
10
46
  const venue = actionVenue(action);
@@ -14,15 +50,18 @@ export function validateAction(action, ctx) {
14
50
  if (spec.sync.requirePollBeforeWrite && !observation.polledBeforeWrite) {
15
51
  return fail("no_poll_before_write", "must successfully poll /trades before writing");
16
52
  }
17
- if (ctx.writesThisCycle >= spec.limits.maxWritesPerCycle) {
53
+ const increasesRisk = isRiskIncreasingAction(action);
54
+ if (increasesRisk &&
55
+ ctx.riskIncreasesThisCycle >= spec.limits.maxWritesPerCycle) {
18
56
  return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
19
57
  }
20
58
  // maxTradesPerDay <= 0 means UNLIMITED daily trade count — house agents are never
21
59
  // throttled (we want an active Arena), and hosted agents only when the customer sets a
22
60
  // positive cap. The risk caps below (daily loss, open margin, leverage, stops) are the
23
61
  // real guardrails and always apply regardless of the trade-count cap.
24
- if (spec.limits.maxTradesPerDay > 0 &&
25
- ctx.writesToday >= spec.limits.maxTradesPerDay) {
62
+ if (increasesRisk &&
63
+ spec.limits.maxTradesPerDay > 0 &&
64
+ ctx.riskIncreasesToday >= spec.limits.maxTradesPerDay) {
26
65
  return fail("daily_trade_cap", `maxTradesPerDay ${spec.limits.maxTradesPerDay} reached`);
27
66
  }
28
67
  // Deny-list: an open on a blocked symbol is rejected up front (deny wins over
@@ -273,6 +312,38 @@ export function validateAction(action, ctx) {
273
312
  if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
274
313
  return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
275
314
  }
315
+ // Forecast consistency. By prompt contract forecastProbability is the
316
+ // model's own probability (1-99) that the outcome IT IS BACKING wins, so
317
+ // buying that outcome only makes sense when the forecast clears what the
318
+ // market charges for it. An ABSENT forecast still never blocks a bet (the
319
+ // prompt promises that); a PRESENT one that contradicts the trade does.
320
+ // The API's entryProbability is the RAW mid, and executionModel's effective
321
+ // probability excludes fee. Total stake / net shares is the fee-inclusive
322
+ // break-even cost. Do not guess units or fall back to a discovery mid.
323
+ if (!ctx.mechanical && action.forecastProbability != null) {
324
+ const stake = ctx.quote.stakeMusd;
325
+ const shares = ctx.quote.sharesEstimate;
326
+ const entryPct = typeof stake === "number" &&
327
+ Number.isFinite(stake) &&
328
+ stake > 0 &&
329
+ stake === action.stakeMusd &&
330
+ typeof shares === "number" &&
331
+ Number.isFinite(shares) &&
332
+ shares > 0
333
+ ? (stake / shares) * 100
334
+ : NaN;
335
+ if (!Number.isFinite(entryPct) || entryPct <= 0) {
336
+ return fail("pm_quote_cost_unavailable", "forecast edge requires a matching stake and positive finite net share estimate");
337
+ }
338
+ const edge = action.forecastProbability - entryPct;
339
+ // A cost over 100 is possible; do not clamp an uneconomic quote into range.
340
+ if (edge + 1e-9 < PM_MIN_FORECAST_EDGE_POINTS) {
341
+ return fail("forecast_no_positive_edge", `forecast ${action.forecastProbability} vs entry ${entryPct.toFixed(1)} = ${edge.toFixed(1)}pt edge, under the ${PM_MIN_FORECAST_EDGE_POINTS}pt minimum`);
342
+ }
343
+ }
344
+ if (thesisContradictsOutcome(action.thesis?.summary, mkt.outcomeName)) {
345
+ return fail("thesis_action_conflict", `thesis bets against ${mkt.outcomeName}, which this action buys`);
346
+ }
276
347
  return ok();
277
348
  }
278
349
  return fail("unknown_action", "unsupported action type");
@@ -1,4 +1,5 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
+ export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
2
3
  export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
3
4
  export { parseDecision } from "./decision.js";
4
5
  export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
@@ -6,6 +6,7 @@
6
6
  // This barrel is the ONE import a host scheduler needs; it re-exports only the
7
7
  // stable engine pieces, never the CLI.
8
8
  export { runCycle } from "./runner.js";
9
+ export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
9
10
  export { selectProvider, providerForRoute, } from "./providers.js";
10
11
  export { parseDecision } from "./decision.js";
11
12
  // Reliability slice A: the declarative request-capability table and the
@@ -4,6 +4,8 @@
4
4
  import { asObj, asArr, asNum, asStr } from "./extract.js";
5
5
  import { computeIndicators } from "./indicators.js";
6
6
  import { scanSetups } from "./setups.js";
7
+ import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
8
+ import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
7
9
  // Candle granularity feeding the indicators: the 1D range = 5-minute candles
8
10
  // (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
9
11
  // short cadence the hosted house agents run on. Probe-verified 2026-06-17.
@@ -31,6 +33,16 @@ const INDICATOR_RANGE = "1D";
31
33
  // that declare universe_scan.
32
34
  const UNIVERSE_SCAN_LIMIT = 15;
33
35
  const UNIVERSE_RESOLVE_TOP = 6;
36
+ // A number that may arrive as a decimal string (the public movers feed).
37
+ const asNumLoose = (v) => {
38
+ if (typeof v === "number")
39
+ return Number.isFinite(v) ? v : undefined;
40
+ if (typeof v === "string" && v.trim() !== "") {
41
+ const n = Number(v);
42
+ return Number.isFinite(n) ? n : undefined;
43
+ }
44
+ return undefined;
45
+ };
34
46
  // Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
35
47
  // discovers PM markets about the coins it actually has a price view on.
36
48
  const PM_COIN_NAMES = {
@@ -61,10 +73,11 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
61
73
  export function isCalibrationChurnMarket(market) {
62
74
  return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
63
75
  }
64
- // Fetch candles for one coin and reduce them to a compact indicator bundle.
65
- // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
66
- // null so the cycle proceeds with price-only context rather than skipping.
67
- async function fetchIndicators(client, coinId, trace) {
76
+ // Fetch candles for one coin and reduce them to a compact indicator bundle plus
77
+ // the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
78
+ // candles) yields null indicators so the cycle proceeds with price-only context
79
+ // rather than skipping.
80
+ async function fetchCandleContext(client, coinId, trace) {
68
81
  // The try honors the documented tolerance for SYNCHRONOUS throws too (an
69
82
  // unexpected client error must degrade to price-only context, never kill
70
83
  // the cycle).
@@ -73,10 +86,10 @@ async function fetchIndicators(client, coinId, trace) {
73
86
  cr = await client.candles(coinId, INDICATOR_RANGE, trace);
74
87
  }
75
88
  catch {
76
- return null;
89
+ return { indicators: null };
77
90
  }
78
91
  if (!cr.ok)
79
- return null;
92
+ return { indicators: null };
80
93
  // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
81
94
  const candles = [];
82
95
  for (const raw of asArr(asObj(cr.data).candles)) {
@@ -89,12 +102,78 @@ async function fetchIndicators(client, coinId, trace) {
89
102
  continue;
90
103
  candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
91
104
  }
92
- return computeIndicators(candles);
105
+ const lastVolume = candles.length > 0 ? candles[candles.length - 1].volume : undefined;
106
+ return {
107
+ indicators: computeIndicators(candles),
108
+ volume24hUsd: typeof lastVolume === "number" && lastVolume > 0 ? lastVolume : undefined,
109
+ };
93
110
  }
94
- function freshnessOf(block) {
95
- const fr = asObj(block.freshness);
96
- const status = asStr(fr.status);
97
- return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
111
+ // The fundamentals leg of a watch entry, read from the /market context the
112
+ // entry is already built from (coin.categories, coin.marketCapRank,
113
+ // price.marketCapUsd). Absent fields stay absent.
114
+ function coinFundamentalsOf(m) {
115
+ const coin = asObj(m.coin);
116
+ const price = asObj(m.price);
117
+ const out = {};
118
+ const categories = asArr(coin.categories)
119
+ .map((c) => asStr(c))
120
+ .filter((c) => !!c)
121
+ .slice(0, 3);
122
+ if (categories.length > 0)
123
+ out.categories = categories;
124
+ const rank = asNum(coin.marketCapRank);
125
+ if (rank != null)
126
+ out.marketCapRank = rank;
127
+ const marketCapUsd = asNum(price.marketCapUsd);
128
+ if (marketCapUsd != null)
129
+ out.marketCapUsd = marketCapUsd;
130
+ return Object.keys(out).length > 0 ? out : undefined;
131
+ }
132
+ // Enrich a watch entry with what the candles fetch yields (indicators + 24h
133
+ // volume) when the `indicators` capability is on. One call, both fields.
134
+ async function enrichFromCandles(client, entry, coinId, trace) {
135
+ const cc = await fetchCandleContext(client, coinId, trace);
136
+ if (cc.indicators)
137
+ entry.indicators = cc.indicators;
138
+ if (cc.volume24hUsd != null) {
139
+ entry.fundamentals = {
140
+ ...(entry.fundamentals ?? {}),
141
+ volume24hUsd: cc.volume24hUsd,
142
+ };
143
+ }
144
+ }
145
+ const HEADLINES_PER_COIN = 3;
146
+ const HEADLINE_TITLE_CHARS = 110;
147
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ // Attribute the fetched news to the coins on watch: by the curated slug link
149
+ // when the entry's slug is known (the graph, never a fuzzy match), else by a
150
+ // case-insensitive coin-name or exact-case ticker mention in the title. At most
151
+ // HEADLINES_PER_COIN per coin, in the API's importance-then-recency order.
152
+ function attachHeadlines(watch, items) {
153
+ for (const w of watch) {
154
+ const slug = (w.slug ?? "").toLowerCase();
155
+ const name = (w.name ?? "").toLowerCase();
156
+ const tickerRe = new RegExp(`\\b${escapeRegExp(w.symbol)}\\b`);
157
+ const mine = items
158
+ .filter((it) => {
159
+ if (slug)
160
+ return (it.coins ?? []).some((c) => c.toLowerCase() === slug);
161
+ const title = it.title.toLowerCase();
162
+ return ((name.length >= 3 && title.includes(name)) || tickerRe.test(it.title));
163
+ })
164
+ .slice(0, HEADLINES_PER_COIN);
165
+ if (mine.length === 0)
166
+ continue;
167
+ w.fundamentals = {
168
+ ...(w.fundamentals ?? {}),
169
+ headlines: mine.map((it) => ({
170
+ title: it.title.slice(0, HEADLINE_TITLE_CHARS),
171
+ ...(it.publishedAt ? { at: it.publishedAt } : {}),
172
+ ...(it.importance != null ? { importance: it.importance } : {}),
173
+ ...(it.sentiment ? { sentiment: it.sentiment } : {}),
174
+ })),
175
+ };
176
+ }
98
177
  }
99
178
  // Does a market title reference the given watchlist coin? Matches on the PM coin
100
179
  // NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
@@ -120,6 +199,11 @@ function expandPmMarkets(discData, heldPmKeys) {
120
199
  return (asArr(dd.data ?? dd.markets ?? dd.results)
121
200
  .map(asObj)
122
201
  .flatMap((ev) => {
202
+ // Explicit negative evidence removes only NEW-entry candidates. Unknown
203
+ // quality stays unknown; fresh quote + transactional guards remain final.
204
+ if (ev.eligible === false ||
205
+ asObj(ev.quality).decisionEligible === false)
206
+ return [];
123
207
  const source = (asStr(ev.source) ?? "").toLowerCase();
124
208
  const slug = (asStr(ev.slug) ?? "").toLowerCase();
125
209
  // Keep titles SHORT: the model only needs to recognise the market.
@@ -127,6 +211,8 @@ function expandPmMarkets(discData, heldPmKeys) {
127
211
  // prompt to ~69k tokens (413s on small-context free models).
128
212
  const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
129
213
  const freshness = freshnessOf(ev); // freshness is event-level
214
+ const quality = pmQualityOf(ev.quality);
215
+ const decisionSupport = pmDecisionSupportOf(ev.decisionSupport);
130
216
  // Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
131
217
  // the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
132
218
  // Same for every outcome of the event; undefined on an older backend.
@@ -137,13 +223,17 @@ function expandPmMarkets(discData, heldPmKeys) {
137
223
  // model never bets a market that would fail the binary entry gate at
138
224
  // quote. Back-compat: an older backend omits `eligible` (undefined) ->
139
225
  // the outcome is kept (current behaviour).
140
- const outcomes = asArr(ev.outcomes)
226
+ const outcomes = (Object.hasOwn(ev, "outcomes") ? asArr(ev.outcomes) : [ev])
141
227
  .map(asObj)
142
228
  .filter((o) => o.eligible !== false)
229
+ .filter((o) => {
230
+ const p = asNum(o.probability);
231
+ return p != null && p >= 0 && p <= 100;
232
+ })
143
233
  .slice(0, 3);
144
- // A market with no outcomes array still round-trips a flat fallback row.
145
- const rows = outcomes.length > 0 ? outcomes : [ev];
146
- return rows.map((o) => ({
234
+ // Only an absent legacy outcomes field permits the flat fallback. A
235
+ // present empty/malformed/all-rejected array must never resurrect ev.
236
+ return outcomes.map((o) => ({
147
237
  source,
148
238
  slug,
149
239
  outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
@@ -152,13 +242,22 @@ function expandPmMarkets(discData, heldPmKeys) {
152
242
  outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
153
243
  // Backend returns probability as 0..100 (percent) — normalise to 0..1
154
244
  // to match the prompt's "0..1" framing (probed 2026-06-24).
155
- probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
245
+ probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
156
246
  title,
157
247
  freshness,
248
+ quality,
249
+ decisionSupport,
158
250
  volumeUsd,
251
+ // Event-level fundamentals from the same payload (slice 2): the
252
+ // resolution date and the venue-reported liquidity (USD).
253
+ endDate: asStr(ev.endDate) ?? undefined,
254
+ liquidityUsd: asNum(ev.liquidity) ?? undefined,
159
255
  }));
160
256
  })
161
- .filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
257
+ .filter((m) => m.source &&
258
+ m.slug &&
259
+ m.outcomeExternalMarketId &&
260
+ m.probability != null)
162
261
  // Drop already-held markets so the model only sees markets it can actually
163
262
  // open — done BEFORE any slice so held positions don't consume candidate slots.
164
263
  .filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
@@ -220,6 +319,7 @@ export async function observe(client, spec, state, trace) {
220
319
  return {
221
320
  venue: "futures",
222
321
  id: Number(asNum(p.id) ?? p.id),
322
+ ...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
223
323
  coinId: asStr(coin.ucid) ?? asStr(p.coinId),
224
324
  symbol: asStr(coin.symbol) ?? asStr(p.symbol),
225
325
  side: asStr(p.side),
@@ -232,6 +332,7 @@ export async function observe(client, spec, state, trace) {
232
332
  liquidationPrice: asNum(p.liquidationPrice),
233
333
  stopLossPrice: asNum(p.stopLossPrice),
234
334
  takeProfitPrice: asNum(p.takeProfitPrice),
335
+ openedAt: asStr(p.openedAt),
235
336
  };
236
337
  });
237
338
  // Sync poll: /trades since the persisted cursor.
@@ -286,7 +387,14 @@ export async function observe(client, spec, state, trace) {
286
387
  sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
287
388
  // Freshness lives under the response's `observation` block.
288
389
  freshness: freshnessOf(asObj(m.observation)),
390
+ // Canonical slug (the news graph's key): from the resolve match, else
391
+ // the market context's observation.dataset.coinSlug.
392
+ slug: asStr(match.slug) ??
393
+ asStr(asObj(asObj(m.observation).dataset).coinSlug),
289
394
  };
395
+ const fundamentals = coinFundamentalsOf(m);
396
+ if (fundamentals)
397
+ entry.fundamentals = fundamentals;
290
398
  // Capture the market-wide Fear & Greed regime once (same across coins).
291
399
  if (!marketMood) {
292
400
  const fg = asObj(m.fearGreed);
@@ -297,11 +405,8 @@ export async function observe(client, spec, state, trace) {
297
405
  // `indicators` capability: enrich the observation with computed TA so the
298
406
  // model reasons over structure (trend/momentum/volatility/breakout) instead
299
407
  // of price + %change alone. Backed by the candles endpoint's shared cache.
300
- if (wantIndicators) {
301
- const ind = await fetchIndicators(client, coinId, trace);
302
- if (ind)
303
- entry.indicators = ind;
304
- }
408
+ if (wantIndicators)
409
+ await enrichFromCandles(client, entry, coinId, trace);
305
410
  watch.push(entry);
306
411
  }
307
412
  // `universe_scan` capability (2026-08-18, direct user request): discover the
@@ -323,8 +428,12 @@ export async function observe(client, spec, state, trace) {
323
428
  .map((r) => ({
324
429
  symbol: (asStr(r.symbol) ?? "").toUpperCase(),
325
430
  name: asStr(r.name),
326
- change24hPct: asNum(r.change24h),
327
- priceUsd: asNum(r.currentPrice),
431
+ // Both serialize as decimal STRINGS on the live feed (openapi
432
+ // PublicCryptoMover; probed 2026-09-02: "72.34"), so the strict
433
+ // asNum read left them undefined. Parse the numeric string.
434
+ change24hPct: asNumLoose(r.change24h),
435
+ priceUsd: asNumLoose(r.currentPrice),
436
+ slug: asStr(r.slug),
328
437
  // The movers row already carries the ucid, which IS the coinId every
329
438
  // downstream call takes. Kept so the resolve round-trip below can be
330
439
  // skipped — see the comment there.
@@ -363,15 +472,23 @@ export async function observe(client, spec, state, trace) {
363
472
  sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
364
473
  freshness: freshnessOf(asObj(m.observation)),
365
474
  discovered: true,
475
+ slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
366
476
  };
367
- if (wantIndicators) {
368
- const ind = await fetchIndicators(client, coinId, trace);
369
- if (ind)
370
- entry.indicators = ind;
371
- }
477
+ const fundamentals = coinFundamentalsOf(m);
478
+ if (fundamentals)
479
+ entry.fundamentals = fundamentals;
480
+ if (wantIndicators)
481
+ await enrichFromCandles(client, entry, coinId, trace);
372
482
  watch.push(entry);
373
483
  }
374
- const context = rows.slice(UNIVERSE_RESOLVE_TOP);
484
+ const context = rows
485
+ .slice(UNIVERSE_RESOLVE_TOP)
486
+ .map(({ symbol, name, change24hPct, priceUsd }) => ({
487
+ symbol,
488
+ name,
489
+ change24hPct,
490
+ priceUsd,
491
+ }));
375
492
  if (context.length > 0)
376
493
  universeMovers = context;
377
494
  }
@@ -400,6 +517,7 @@ export async function observe(client, spec, state, trace) {
400
517
  }
401
518
  // PM open positions + discovered quote-ready candidates — only if pm enabled.
402
519
  let pmPositions = [];
520
+ let capitalPmData;
403
521
  let pmResolutions = [];
404
522
  let pmMarkets = [];
405
523
  if (wantPm) {
@@ -430,11 +548,13 @@ export async function observe(client, spec, state, trace) {
430
548
  pmDiscR = fb;
431
549
  }
432
550
  if (pmPosR.ok) {
551
+ capitalPmData = pmPosR.data;
433
552
  pmPositions = asArr(asObj(pmPosR.data).positions)
434
553
  .map(asObj)
435
554
  .filter((p) => (asStr(p.status) ?? "open") === "open")
436
555
  .map((p) => ({
437
556
  id: Number(asNum(p.id) ?? p.id),
557
+ ...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
438
558
  // The /positions/pm API returns `eventSlug` and the outcome id NESTED at
439
559
  // outcome.externalMarketId — NOT `slug` / `outcomeExternalMarketId`.
440
560
  // Reading the wrong keys left both undefined, which silently broke the
@@ -451,6 +571,14 @@ export async function observe(client, spec, state, trace) {
451
571
  // down trips the stop too — not just futures.
452
572
  unrealizedPnlMusd: asNum(p.unrealizedPnl) ?? asNum(p.unrealizedPnlMusd),
453
573
  status: asStr(p.status) ?? "open",
574
+ // Slice 2: what the bet IS (title, side) and its entry vs CURRENT
575
+ // outcome probability (0..100 points; current only while open), so
576
+ // the model and the thesis evaluator can re-judge a held bet.
577
+ title: (asStr(p.eventTitle) ?? asStr(p.title))?.slice(0, 80),
578
+ side: asStr(p.side),
579
+ entryProbability: asNum(p.entryProbability),
580
+ currentProbability: asNum(p.currentProbability),
581
+ openedAt: asStr(p.openedAt),
454
582
  }));
455
583
  // Settlement-feedback loop: the SAME /positions/pm response carries an
456
584
  // additive `recentlyResolved` array — the agent's OWN bets that settled
@@ -567,9 +695,12 @@ export async function observe(client, spec, state, trace) {
567
695
  ...watch.map((w) => w.symbol.toUpperCase()),
568
696
  ]));
569
697
  if (wantNews && newsCoins.length > 0) {
570
- const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 8, hours: 48 }, trace);
698
+ // limit 12 (was 8): the same single cached call now also feeds up to 3
699
+ // headlines per coin (slice 2 fundamentals); the prompt's news block is
700
+ // still capped at 6 below.
701
+ const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 12, hours: 48 }, trace);
571
702
  if (nr.ok) {
572
- news = asArr(asObj(nr.data).items)
703
+ const fetched = asArr(asObj(nr.data).items)
573
704
  .map(asObj)
574
705
  .map((it) => ({
575
706
  title: (asStr(it.title) ?? "").slice(0, 160),
@@ -577,12 +708,17 @@ export async function observe(client, spec, state, trace) {
577
708
  sentiment: asStr(it.sentiment) ?? undefined,
578
709
  importance: asNum(it.importance) ?? undefined,
579
710
  ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
711
+ publishedAt: asStr(it.publishedAt) ?? undefined,
580
712
  coins: asArr(it.coins)
581
713
  .map((c) => asStr(c))
582
714
  .filter((c) => !!c),
583
715
  }))
584
- .filter((n) => n.title.length > 0)
585
- .slice(0, 6);
716
+ .filter((n) => n.title.length > 0);
717
+ news = fetched.slice(0, 6);
718
+ // Per-coin headlines (with timestamps) on the watch entries themselves,
719
+ // drawn from the full fetched list so a busy BTC tape cannot crowd a
720
+ // second coin's story out of the fundamentals.
721
+ attachHeadlines(watch, fetched);
586
722
  }
587
723
  }
588
724
  const observation = {
@@ -590,6 +726,11 @@ export async function observe(client, spec, state, trace) {
590
726
  scopes,
591
727
  cashAvailableMusd,
592
728
  equityMusd,
729
+ ...(usesCapitalSizing(spec)
730
+ ? {
731
+ capitalBook: deriveCapitalBook(portR.data, walletR.data, posR.data, capitalPmData),
732
+ }
733
+ : {}),
593
734
  openPositions,
594
735
  openOrders,
595
736
  pmPositions,
@@ -0,0 +1,13 @@
1
+ import type { Freshness, PmDecisionSupport, PmQuality } from "./types.js";
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
+ 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
+ export declare const PM_FLAGS: readonly ["thinMarket", "inactiveMarket", "highAmbiguity", "nearResolution", "staleData"];
5
+ export declare const FRESHNESS_BASES: readonly ["latest_snapshot", "source_update", "processed", "event_update", "unknown"];
6
+ export declare const PM_TIERS: readonly ["high", "medium", "low", "unknown"];
7
+ export declare const PM_SPREAD_TIERS: readonly ["tight", "moderate", "wide", "unknown"];
8
+ export declare const PM_QUALITY_CAPS: readonly ["unassessable", "raw_book", "pinned_outcome"];
9
+ export declare const knownCode: (value: unknown, allowed: readonly string[]) => string | undefined;
10
+ export declare function sourceTimestamp(value: unknown): string | undefined;
11
+ export declare function freshnessOf(block: Record<string, unknown>): Freshness | undefined;
12
+ export declare function pmQualityOf(value: unknown): PmQuality | undefined;
13
+ export declare function pmDecisionSupportOf(value: unknown): PmDecisionSupport | undefined;
@@ -0,0 +1,136 @@
1
+ import { asArr, asNum, asObj } from "./extract.js";
2
+ export const PM_BLOCK_REASONS = [
3
+ "structurally_invalid",
4
+ "stale_freshness",
5
+ "freshness_unknown",
6
+ "unpriced",
7
+ "quote_dead",
8
+ "dead_zero",
9
+ "not_open",
10
+ "source_degraded",
11
+ "settlement_limbo",
12
+ ];
13
+ export const PM_WARNING_REASONS = [
14
+ "lagging_freshness",
15
+ "unproven_no_activity",
16
+ "untraded_default",
17
+ "play_money",
18
+ "anomaly_flagged",
19
+ "source_time_unverified",
20
+ "sum_atypical_independent",
21
+ ];
22
+ export const PM_FLAGS = [
23
+ "thinMarket",
24
+ "inactiveMarket",
25
+ "highAmbiguity",
26
+ "nearResolution",
27
+ "staleData",
28
+ ];
29
+ export const FRESHNESS_BASES = [
30
+ "latest_snapshot",
31
+ "source_update",
32
+ "processed",
33
+ "event_update",
34
+ "unknown",
35
+ ];
36
+ export const PM_TIERS = ["high", "medium", "low", "unknown"];
37
+ export const PM_SPREAD_TIERS = [
38
+ "tight",
39
+ "moderate",
40
+ "wide",
41
+ "unknown",
42
+ ];
43
+ export const PM_QUALITY_CAPS = [
44
+ "unassessable",
45
+ "raw_book",
46
+ "pinned_outcome",
47
+ ];
48
+ export const knownCode = (value, allowed) => typeof value === "string" && allowed.includes(value) ? value : undefined;
49
+ export function sourceTimestamp(value) {
50
+ if (typeof value !== "string" ||
51
+ value.length > 30 ||
52
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value))
53
+ return undefined;
54
+ const ms = Date.parse(value);
55
+ if (!Number.isFinite(ms))
56
+ return undefined;
57
+ const iso = new Date(ms).toISOString();
58
+ const normalized = value.replace(/(?:\.(\d{1,3}))?Z$/, (_match, fraction) => `.${(fraction ?? "").padEnd(3, "0")}Z`);
59
+ // Date.parse otherwise silently rolls impossible calendar dates forward.
60
+ return iso === normalized ? iso : undefined;
61
+ }
62
+ export function freshnessOf(block) {
63
+ const fr = asObj(block.freshness);
64
+ const status = knownCode(fr.status, [
65
+ "fresh",
66
+ "stale",
67
+ "lagging",
68
+ "never_ingested",
69
+ "unknown",
70
+ ]);
71
+ if (!status)
72
+ return undefined;
73
+ const seconds = asNum(fr.ageSeconds);
74
+ const minutes = asNum(fr.ageMinutes);
75
+ const age = seconds ?? (minutes == null ? undefined : minutes * 60);
76
+ return {
77
+ status,
78
+ ...(age != null && Number.isFinite(age) && age >= 0
79
+ ? { ageSeconds: age }
80
+ : {}),
81
+ ...(sourceTimestamp(fr.asOf) ? { asOf: sourceTimestamp(fr.asOf) } : {}),
82
+ ...(knownCode(fr.basis, FRESHNESS_BASES)
83
+ ? { basis: knownCode(fr.basis, FRESHNESS_BASES) }
84
+ : {}),
85
+ };
86
+ }
87
+ export function pmQualityOf(value) {
88
+ const raw = asObj(value);
89
+ if (Object.keys(raw).length === 0)
90
+ return undefined;
91
+ const reasons = (v, allowed) => [
92
+ ...new Set(asArr(v)
93
+ .slice(0, 32)
94
+ .filter((r) => !!knownCode(r, allowed))),
95
+ ];
96
+ const warningReasons = reasons(raw.warningReasons, PM_WARNING_REASONS);
97
+ const blockReasons = reasons(raw.blockReasons, PM_BLOCK_REASONS);
98
+ const omitted = (v, allowed) => !Array.isArray(v) || v.length > 32 || v.some((r) => !knownCode(r, allowed));
99
+ return {
100
+ ...(typeof raw.decisionEligible === "boolean"
101
+ ? { decisionEligible: raw.decisionEligible }
102
+ : {}),
103
+ warningReasons,
104
+ blockReasons,
105
+ ...(typeof raw.policyVersion === "string" &&
106
+ /^pm-quality-\d{1,3}$/.test(raw.policyVersion)
107
+ ? { policyVersion: raw.policyVersion }
108
+ : {}),
109
+ ...(sourceTimestamp(raw.assessedAt)
110
+ ? { assessedAt: sourceTimestamp(raw.assessedAt) }
111
+ : {}),
112
+ reasonsOmitted: raw.reasonsOmitted === true ||
113
+ omitted(raw.warningReasons, PM_WARNING_REASONS) ||
114
+ omitted(raw.blockReasons, PM_BLOCK_REASONS),
115
+ };
116
+ }
117
+ export function pmDecisionSupportOf(value) {
118
+ const raw = asObj(value);
119
+ if (Object.keys(raw).length === 0)
120
+ return undefined;
121
+ const score = asNum(raw.qualityScore);
122
+ const rawFlags = asObj(raw.flags);
123
+ return {
124
+ ...(score != null && score >= 0 && score <= 100
125
+ ? { qualityScore: score }
126
+ : {}),
127
+ qualityTier: knownCode(raw.qualityTier, PM_TIERS),
128
+ qualityCapReason: raw.qualityCapReason === null
129
+ ? null
130
+ : knownCode(raw.qualityCapReason, PM_QUALITY_CAPS),
131
+ spreadTier: knownCode(raw.spreadTier, PM_SPREAD_TIERS),
132
+ liquidityTier: knownCode(raw.liquidityTier, PM_TIERS),
133
+ volumeTier: knownCode(raw.volumeTier, PM_TIERS),
134
+ flags: Object.fromEntries(PM_FLAGS.filter((key) => typeof rawFlags[key] === "boolean").map((key) => [key, rawFlags[key]])),
135
+ };
136
+ }