@coinrithm/mcp-trading 0.7.6 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +37 -8
  3. package/dist/agent/act.js +19 -6
  4. package/dist/agent/capitalSizing.d.ts +32 -0
  5. package/dist/agent/capitalSizing.js +257 -0
  6. package/dist/agent/client.d.ts +2 -0
  7. package/dist/agent/client.js +4 -0
  8. package/dist/agent/decision.d.ts +392 -0
  9. package/dist/agent/decision.js +177 -0
  10. package/dist/agent/decisionProbe.d.ts +17 -0
  11. package/dist/agent/decisionProbe.js +70 -0
  12. package/dist/agent/decisionReceipt.d.ts +45 -0
  13. package/dist/agent/decisionReceipt.js +595 -0
  14. package/dist/agent/decisionValidator.d.ts +19 -2
  15. package/dist/agent/decisionValidator.js +97 -3
  16. package/dist/agent/engine.d.ts +5 -1
  17. package/dist/agent/engine.js +8 -1
  18. package/dist/agent/observe.js +194 -35
  19. package/dist/agent/pmContext.d.ts +13 -0
  20. package/dist/agent/pmContext.js +136 -0
  21. package/dist/agent/prompt.d.ts +14 -2
  22. package/dist/agent/prompt.js +232 -35
  23. package/dist/agent/providerCapabilities.d.ts +23 -0
  24. package/dist/agent/providerCapabilities.js +105 -0
  25. package/dist/agent/providers.d.ts +29 -1
  26. package/dist/agent/providers.js +159 -88
  27. package/dist/agent/resolve.d.ts +1 -1
  28. package/dist/agent/resolve.js +21 -1
  29. package/dist/agent/runner.d.ts +4 -1
  30. package/dist/agent/runner.js +418 -47
  31. package/dist/agent/scorecard.js +7 -1
  32. package/dist/agent/skill.js +23 -0
  33. package/dist/agent/skillValidator.d.ts +1 -0
  34. package/dist/agent/skillValidator.js +63 -0
  35. package/dist/agent/state.js +7 -1
  36. package/dist/agent/strictLint.js +20 -0
  37. package/dist/agent/thesis.d.ts +40 -0
  38. package/dist/agent/thesis.js +319 -0
  39. package/dist/agent/types.d.ts +151 -0
  40. package/dist/http.js +21 -0
  41. package/dist/tools.js +10 -10
  42. package/package.json +1 -1
@@ -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
@@ -36,6 +75,29 @@ export function validateAction(action, ctx) {
36
75
  }
37
76
  }
38
77
  }
78
+ // Direction constraint (2026-08-24): a strategy's side restriction is a HARD
79
+ // cap, not prose-obedience. Live incident: a short-only fade agent opened
80
+ // two momentum LONGS when the flagged-setups act-pressure outweighed its
81
+ // prose. Closes/SL-TP/cancels are never direction-gated — reducing or
82
+ // protecting an existing position is not a directional bet.
83
+ const direction = spec.risk.direction;
84
+ if (direction) {
85
+ if (action.type === "futures_open") {
86
+ if (direction === "short_only" && action.side !== "short") {
87
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "short", got "${action.side}"`);
88
+ }
89
+ if (direction === "long_only" && action.side !== "long") {
90
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "long", got "${action.side}"`);
91
+ }
92
+ }
93
+ // Spot buys are long exposure; a short_only agent must not accumulate
94
+ // them. Spot sells reduce a holding and stay allowed.
95
+ if (action.type === "spot_order" &&
96
+ direction === "short_only" &&
97
+ action.side === "buy") {
98
+ return fail("direction_constraint", `direction ${direction}: spot buys are long exposure`);
99
+ }
100
+ }
39
101
  if (action.type === "futures_open") {
40
102
  // Daily realized-loss stop: once today's loss hits the cap, open no new risk.
41
103
  if (spec.limits.maxDailyLossMusd > 0 &&
@@ -250,6 +312,38 @@ export function validateAction(action, ctx) {
250
312
  if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
251
313
  return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
252
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
+ }
253
347
  return ok();
254
348
  }
255
349
  return fail("unknown_action", "unsupported action type");
@@ -1,5 +1,9 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
- export { selectProvider, type ProviderEnv, type Provider, } from "./providers.js";
2
+ export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
3
+ export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
4
+ export { parseDecision } from "./decision.js";
5
+ export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
6
+ export { probeDecisionContract, type ProbeRoute, type ProbeDecisionResult, } from "./decisionProbe.js";
3
7
  export { CoinRithmClient } from "./client.js";
4
8
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
5
9
  export { resolveAgent } from "./resolve.js";
@@ -6,7 +6,14 @@
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 { selectProvider, } from "./providers.js";
9
+ export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
10
+ export { selectProvider, providerForRoute, } from "./providers.js";
11
+ export { parseDecision } from "./decision.js";
12
+ // Reliability slice A: the declarative request-capability table and the
13
+ // representative decision probe (route eligibility = a REAL parsed decision,
14
+ // never a bare HTTP 200 — the 62f3a12 lesson).
15
+ export { chatShapeFor, buildChatBody, } from "./providerCapabilities.js";
16
+ export { probeDecisionContract, } from "./decisionProbe.js";
10
17
  export { CoinRithmClient } from "./client.js";
11
18
  export { loadAgent, buildSpec } from "./skill.js";
12
19
  export { resolveAgent } from "./resolve.js";
@@ -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.
@@ -11,8 +13,36 @@ const INDICATOR_RANGE = "1D";
11
13
  // `universe_scan` bounds: how many top movers to pull, and how many of those
12
14
  // to fully resolve into tradable watch entries (each resolved row costs a
13
15
  // resolve + market [+ candles] call).
16
+ //
17
+ // RESOLVE_TOP 3 -> 6 on 2026-08-21. Only a resolved row carries indicators, and
18
+ // therefore a `setups` flag; the unresolved remainder is bare symbol + 24h
19
+ // change + price. At 3, a discovery-driven strategy could reason properly about
20
+ // exactly three coins per cycle out of fifteen surfaced.
21
+ //
22
+ // That bit a real user case. A pump-fade agent identifies a candidate from a
23
+ // `stretched`/`fade-short` setup (RSI14 >= 68) and then waits for exhaustion,
24
+ // which by definition means RSI is NO LONGER extreme. Nothing persists between
25
+ // cycles, so the candidate has to still be a resolved row at the moment the
26
+ // exhaustion evidence appears. Discovery is gainers-ranked, so a retracing coin
27
+ // slides down the list — at 3 it fell out almost immediately and went blind
28
+ // exactly when the strategy needed to look at it.
29
+ //
30
+ // 6 roughly doubles how far a coin can slide before losing its indicators. Cost
31
+ // is 3 extra market+candles calls per cycle against CoinRithm's own API (never
32
+ // the model quota) and ~3 more watch entries in the prompt, and only for agents
33
+ // that declare universe_scan.
14
34
  const UNIVERSE_SCAN_LIMIT = 15;
15
- const UNIVERSE_RESOLVE_TOP = 3;
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
+ };
16
46
  // Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
17
47
  // discovers PM markets about the coins it actually has a price view on.
18
48
  const PM_COIN_NAMES = {
@@ -43,10 +73,11 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
43
73
  export function isCalibrationChurnMarket(market) {
44
74
  return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
45
75
  }
46
- // Fetch candles for one coin and reduce them to a compact indicator bundle.
47
- // Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
48
- // null so the cycle proceeds with price-only context rather than skipping.
49
- 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) {
50
81
  // The try honors the documented tolerance for SYNCHRONOUS throws too (an
51
82
  // unexpected client error must degrade to price-only context, never kill
52
83
  // the cycle).
@@ -55,10 +86,10 @@ async function fetchIndicators(client, coinId, trace) {
55
86
  cr = await client.candles(coinId, INDICATOR_RANGE, trace);
56
87
  }
57
88
  catch {
58
- return null;
89
+ return { indicators: null };
59
90
  }
60
91
  if (!cr.ok)
61
- return null;
92
+ return { indicators: null };
62
93
  // Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
63
94
  const candles = [];
64
95
  for (const raw of asArr(asObj(cr.data).candles)) {
@@ -71,12 +102,78 @@ async function fetchIndicators(client, coinId, trace) {
71
102
  continue;
72
103
  candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
73
104
  }
74
- 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
+ };
75
110
  }
76
- function freshnessOf(block) {
77
- const fr = asObj(block.freshness);
78
- const status = asStr(fr.status);
79
- 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
+ }
80
177
  }
81
178
  // Does a market title reference the given watchlist coin? Matches on the PM coin
82
179
  // NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
@@ -102,6 +199,11 @@ function expandPmMarkets(discData, heldPmKeys) {
102
199
  return (asArr(dd.data ?? dd.markets ?? dd.results)
103
200
  .map(asObj)
104
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 [];
105
207
  const source = (asStr(ev.source) ?? "").toLowerCase();
106
208
  const slug = (asStr(ev.slug) ?? "").toLowerCase();
107
209
  // Keep titles SHORT: the model only needs to recognise the market.
@@ -109,6 +211,8 @@ function expandPmMarkets(discData, heldPmKeys) {
109
211
  // prompt to ~69k tokens (413s on small-context free models).
110
212
  const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
111
213
  const freshness = freshnessOf(ev); // freshness is event-level
214
+ const quality = pmQualityOf(ev.quality);
215
+ const decisionSupport = pmDecisionSupportOf(ev.decisionSupport);
112
216
  // Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
113
217
  // the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
114
218
  // Same for every outcome of the event; undefined on an older backend.
@@ -119,13 +223,17 @@ function expandPmMarkets(discData, heldPmKeys) {
119
223
  // model never bets a market that would fail the binary entry gate at
120
224
  // quote. Back-compat: an older backend omits `eligible` (undefined) ->
121
225
  // the outcome is kept (current behaviour).
122
- const outcomes = asArr(ev.outcomes)
226
+ const outcomes = (Object.hasOwn(ev, "outcomes") ? asArr(ev.outcomes) : [ev])
123
227
  .map(asObj)
124
228
  .filter((o) => o.eligible !== false)
229
+ .filter((o) => {
230
+ const p = asNum(o.probability);
231
+ return p != null && p >= 0 && p <= 100;
232
+ })
125
233
  .slice(0, 3);
126
- // A market with no outcomes array still round-trips a flat fallback row.
127
- const rows = outcomes.length > 0 ? outcomes : [ev];
128
- 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) => ({
129
237
  source,
130
238
  slug,
131
239
  outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
@@ -134,13 +242,22 @@ function expandPmMarkets(discData, heldPmKeys) {
134
242
  outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
135
243
  // Backend returns probability as 0..100 (percent) — normalise to 0..1
136
244
  // to match the prompt's "0..1" framing (probed 2026-06-24).
137
- 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)),
138
246
  title,
139
247
  freshness,
248
+ quality,
249
+ decisionSupport,
140
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,
141
255
  }));
142
256
  })
143
- .filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
257
+ .filter((m) => m.source &&
258
+ m.slug &&
259
+ m.outcomeExternalMarketId &&
260
+ m.probability != null)
144
261
  // Drop already-held markets so the model only sees markets it can actually
145
262
  // open — done BEFORE any slice so held positions don't consume candidate slots.
146
263
  .filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
@@ -202,6 +319,7 @@ export async function observe(client, spec, state, trace) {
202
319
  return {
203
320
  venue: "futures",
204
321
  id: Number(asNum(p.id) ?? p.id),
322
+ ...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
205
323
  coinId: asStr(coin.ucid) ?? asStr(p.coinId),
206
324
  symbol: asStr(coin.symbol) ?? asStr(p.symbol),
207
325
  side: asStr(p.side),
@@ -214,6 +332,7 @@ export async function observe(client, spec, state, trace) {
214
332
  liquidationPrice: asNum(p.liquidationPrice),
215
333
  stopLossPrice: asNum(p.stopLossPrice),
216
334
  takeProfitPrice: asNum(p.takeProfitPrice),
335
+ openedAt: asStr(p.openedAt),
217
336
  };
218
337
  });
219
338
  // Sync poll: /trades since the persisted cursor.
@@ -268,7 +387,14 @@ export async function observe(client, spec, state, trace) {
268
387
  sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
269
388
  // Freshness lives under the response's `observation` block.
270
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),
271
394
  };
395
+ const fundamentals = coinFundamentalsOf(m);
396
+ if (fundamentals)
397
+ entry.fundamentals = fundamentals;
272
398
  // Capture the market-wide Fear & Greed regime once (same across coins).
273
399
  if (!marketMood) {
274
400
  const fg = asObj(m.fearGreed);
@@ -279,11 +405,8 @@ export async function observe(client, spec, state, trace) {
279
405
  // `indicators` capability: enrich the observation with computed TA so the
280
406
  // model reasons over structure (trend/momentum/volatility/breakout) instead
281
407
  // of price + %change alone. Backed by the candles endpoint's shared cache.
282
- if (wantIndicators) {
283
- const ind = await fetchIndicators(client, coinId, trace);
284
- if (ind)
285
- entry.indicators = ind;
286
- }
408
+ if (wantIndicators)
409
+ await enrichFromCandles(client, entry, coinId, trace);
287
410
  watch.push(entry);
288
411
  }
289
412
  // `universe_scan` capability (2026-08-18, direct user request): discover the
@@ -305,8 +428,12 @@ export async function observe(client, spec, state, trace) {
305
428
  .map((r) => ({
306
429
  symbol: (asStr(r.symbol) ?? "").toUpperCase(),
307
430
  name: asStr(r.name),
308
- change24hPct: asNum(r.change24h),
309
- 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),
310
437
  // The movers row already carries the ucid, which IS the coinId every
311
438
  // downstream call takes. Kept so the resolve round-trip below can be
312
439
  // skipped — see the comment there.
@@ -345,15 +472,23 @@ export async function observe(client, spec, state, trace) {
345
472
  sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
346
473
  freshness: freshnessOf(asObj(m.observation)),
347
474
  discovered: true,
475
+ slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
348
476
  };
349
- if (wantIndicators) {
350
- const ind = await fetchIndicators(client, coinId, trace);
351
- if (ind)
352
- entry.indicators = ind;
353
- }
477
+ const fundamentals = coinFundamentalsOf(m);
478
+ if (fundamentals)
479
+ entry.fundamentals = fundamentals;
480
+ if (wantIndicators)
481
+ await enrichFromCandles(client, entry, coinId, trace);
354
482
  watch.push(entry);
355
483
  }
356
- 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
+ }));
357
492
  if (context.length > 0)
358
493
  universeMovers = context;
359
494
  }
@@ -382,6 +517,7 @@ export async function observe(client, spec, state, trace) {
382
517
  }
383
518
  // PM open positions + discovered quote-ready candidates — only if pm enabled.
384
519
  let pmPositions = [];
520
+ let capitalPmData;
385
521
  let pmResolutions = [];
386
522
  let pmMarkets = [];
387
523
  if (wantPm) {
@@ -412,11 +548,13 @@ export async function observe(client, spec, state, trace) {
412
548
  pmDiscR = fb;
413
549
  }
414
550
  if (pmPosR.ok) {
551
+ capitalPmData = pmPosR.data;
415
552
  pmPositions = asArr(asObj(pmPosR.data).positions)
416
553
  .map(asObj)
417
554
  .filter((p) => (asStr(p.status) ?? "open") === "open")
418
555
  .map((p) => ({
419
556
  id: Number(asNum(p.id) ?? p.id),
557
+ ...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
420
558
  // The /positions/pm API returns `eventSlug` and the outcome id NESTED at
421
559
  // outcome.externalMarketId — NOT `slug` / `outcomeExternalMarketId`.
422
560
  // Reading the wrong keys left both undefined, which silently broke the
@@ -433,6 +571,14 @@ export async function observe(client, spec, state, trace) {
433
571
  // down trips the stop too — not just futures.
434
572
  unrealizedPnlMusd: asNum(p.unrealizedPnl) ?? asNum(p.unrealizedPnlMusd),
435
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),
436
582
  }));
437
583
  // Settlement-feedback loop: the SAME /positions/pm response carries an
438
584
  // additive `recentlyResolved` array — the agent's OWN bets that settled
@@ -549,9 +695,12 @@ export async function observe(client, spec, state, trace) {
549
695
  ...watch.map((w) => w.symbol.toUpperCase()),
550
696
  ]));
551
697
  if (wantNews && newsCoins.length > 0) {
552
- 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);
553
702
  if (nr.ok) {
554
- news = asArr(asObj(nr.data).items)
703
+ const fetched = asArr(asObj(nr.data).items)
555
704
  .map(asObj)
556
705
  .map((it) => ({
557
706
  title: (asStr(it.title) ?? "").slice(0, 160),
@@ -559,12 +708,17 @@ export async function observe(client, spec, state, trace) {
559
708
  sentiment: asStr(it.sentiment) ?? undefined,
560
709
  importance: asNum(it.importance) ?? undefined,
561
710
  ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
711
+ publishedAt: asStr(it.publishedAt) ?? undefined,
562
712
  coins: asArr(it.coins)
563
713
  .map((c) => asStr(c))
564
714
  .filter((c) => !!c),
565
715
  }))
566
- .filter((n) => n.title.length > 0)
567
- .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);
568
722
  }
569
723
  }
570
724
  const observation = {
@@ -572,6 +726,11 @@ export async function observe(client, spec, state, trace) {
572
726
  scopes,
573
727
  cashAvailableMusd,
574
728
  equityMusd,
729
+ ...(usesCapitalSizing(spec)
730
+ ? {
731
+ capitalBook: deriveCapitalBook(portR.data, walletR.data, posR.data, capitalPmData),
732
+ }
733
+ : {}),
575
734
  openPositions,
576
735
  openOrders,
577
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;