@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.
@@ -1,4 +1,12 @@
1
- import { AgentSpec, Observation, PmResolution } from "./types.js";
1
+ import { AgentSpec, Observation, PmResolution, RunState } from "./types.js";
2
+ export interface DailyRiskBudget {
3
+ version: "coinrithm.daily-risk-budget.v1";
4
+ utcDay: string;
5
+ limit: number | null;
6
+ used: number;
7
+ remaining: number | null;
8
+ }
9
+ export declare function buildDailyRiskBudget(spec: AgentSpec, state: Pick<RunState, "dayKey" | "riskIncreasesToday">): DailyRiskBudget;
2
10
  export declare function formatPmResolutions(resolutions: PmResolution[]): string[];
3
11
  export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string, opts?: {
4
12
  includeForecast?: boolean;
@@ -8,4 +16,6 @@ export declare function buildUserPrompt(obs: Observation, journal?: Array<{
8
16
  did: string;
9
17
  }>, opts?: {
10
18
  venues?: AgentSpec["venues"];
19
+ dailyRiskBudget?: DailyRiskBudget;
20
+ capitalSizing?: AgentSpec["capitalSizing"];
11
21
  }): string;
@@ -2,6 +2,23 @@
2
2
  // static character (cached prefix); the user prompt is the fresh observation.
3
3
  // The model only PROPOSES — the runner re-checks every action against the caps,
4
4
  // so the prompt states the caps but never relies on the model to honor them.
5
+ import { pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
6
+ import { usesCapitalSizing } from "./capitalSizing.js";
7
+ // The runner has already called rollDay. Use the same counter as validation,
8
+ // including conservative legacy-state migration; never infer it from writes,
9
+ // model calls, or the number of positions that remain open.
10
+ export function buildDailyRiskBudget(spec, state) {
11
+ const limit = spec.limits.maxTradesPerDay > 0 ? spec.limits.maxTradesPerDay : null;
12
+ return {
13
+ version: "coinrithm.daily-risk-budget.v1",
14
+ utcDay: state.dayKey,
15
+ limit,
16
+ used: state.riskIncreasesToday,
17
+ remaining: limit === null ? null : Math.max(0, limit - state.riskIncreasesToday),
18
+ };
19
+ }
20
+ // Whole-dollar rendering for the compact PM rows (tokens, not precision).
21
+ const roundUsd = (v) => typeof v === "number" && Number.isFinite(v) ? Math.round(v) : undefined;
5
22
  // Format the settlement-feedback block: a concise, natural-language recap of the
6
23
  // agent's OWN PM bets that resolved since the last cycle, so the model can REFLECT
7
24
  // (reinforce what worked, avoid what didn't). Capped + compact — this is context,
@@ -55,15 +72,22 @@ opts = {}) {
55
72
  ...(hasSpot ? ["spot buy notional"] : []),
56
73
  ...(hasPm ? ["PM stake"] : []),
57
74
  ];
75
+ const openKinds = [
76
+ ...(hasFutures ? ["futures_open"] : []),
77
+ ...(hasSpot ? ["spot_order"] : []),
78
+ ...(hasPm ? ["pm_open"] : []),
79
+ ];
80
+ const hasIndicators = spec.capabilities.includes("indicators");
81
+ const hasNews = spec.capabilities.includes("news");
58
82
  const actions = [];
59
83
  if (hasFutures) {
60
- actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', "FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG's takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.");
84
+ actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1,"thesis":{"summary","invalidation":{"priceBelow"|"priceAbove","maxHoldMinutes","catalyst"}}}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', "FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG's takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.");
61
85
  }
62
86
  if (hasSpot) {
63
- actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
87
+ actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1,"thesis":{"summary","invalidation":{"priceBelow"|"priceAbove","maxHoldMinutes","catalyst"}}}', '{"type":"spot_cancel","orderId"}');
64
88
  }
65
89
  if (hasPm) {
66
- actions.push(`{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1${includeForecast ? ',"forecastProbability":1..99' : ""}} (set "ref" to one of the refs listed THIS cycle (pm1..pmN) — the \`ref\` of the ONE observation.pmMarkets entry you are betting, e.g. "pm3", copied EXACTLY; a ref NOT in this cycle's list is rejected as pm_ref_unknown and wastes the cycle; stakeMusd >= 10${includeForecast ? '; set "forecastProbability" to YOUR OWN probability 1-99 that this outcome wins — see the forecast rule below' : ""})`);
90
+ actions.push(`{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1${includeForecast ? ',"forecastProbability":1..99' : ""},"thesis":{"summary","invalidation":{"probabilityBelow"|"probabilityAbove","maxHoldMinutes","catalyst"}}} (set "ref" to one of the refs listed THIS cycle (pm1..pmN) — the \`ref\` of the ONE observation.pmMarkets entry you are betting, e.g. "pm3", copied EXACTLY; a ref NOT in this cycle's list is rejected as pm_ref_unknown and wastes the cycle; stakeMusd >= 10${includeForecast ? '; set "forecastProbability" to YOUR OWN probability 1-99 that this outcome wins — see the forecast rule below' : ""})`);
67
91
  }
68
92
  return [
69
93
  "You operate a CoinRithm PAPER-TRADING agent (simulated 50,000 mUSD; not real money, not financial advice).",
@@ -73,8 +97,14 @@ opts = {}) {
73
97
  mergedProse.trim() || "(no strategy prose provided)",
74
98
  "",
75
99
  "## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
100
+ "- When supplied, the user prompt's dailyRiskBudget is the remaining UTC-day entry/add allowance. It outranks setup/entry pressure: exhaustion is a legitimate skip for new risk, never a reason to skip otherwise-valid closes or protection. All other caps still apply, even when this daily count is unlimited.",
76
101
  `- venues you may act in: ${v.join(", ")}`,
77
102
  `- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (${sizeKinds.join(" / ")})`,
103
+ ...(usesCapitalSizing(spec)
104
+ ? [
105
+ `- Opt-in paper capital policy ${spec.capitalSizing?.version ?? "invalid"}: the runner REPLACES proposed futures margins and PM stakes using current owned-book evidence, stops and fixed policy limits; it does not treat your confidence or the nominal starting grant as a sizing instruction. Choose the market, direction and meaningful protection; invalid policy, quoted costs, shared allocation and cash reserve can still reject an entry.`,
106
+ ]
107
+ : []),
78
108
  ...(hasFutures
79
109
  ? [
80
110
  `- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
@@ -107,14 +137,14 @@ opts = {}) {
107
137
  : []),
108
138
  ...(hasPm
109
139
  ? [
110
- "- prediction markets are a FIRST-CLASS venue for you — a pm_open is as real a trade as a futures/spot open, not an afterthought. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, …), an `outcome` label, and `prob` (0..1, the market's CURRENT odds). BET (pm_open) an outcome when YOUR estimate of its true probability differs MATERIALLY from the market's — that gap is your edge (e.g. prob 0.35 but you think it's really ~0.55 -> buy). Skip only markets pinned near 0 or 1 (no edge left). Every entry in observation.pmMarkets is already filtered to one you CAN open (binary/settlement-grade) — so a listed market will not bounce at quote. Pick ONLY a listed market and identify it by copying its `ref` into the action; min stake 10 mUSD. Do NOT re-bet a market+outcome you ALREADY hold (check observation.pmPositions) — that is churn and will be rejected; bet a DIFFERENT market or skip.",
140
+ "- prediction markets are a FIRST-CLASS venue for you. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, ...), an `outcome` label, and `prob` (0..1, the market's current odds). BET (pm_open) when your independently formed estimate differs materially from the market's, after costs. Discovery filters known ineligible candidates but is NOT an execution promise: fresh quote and open-time guards still apply. `quality` contains eligibility and warning evidence; `decisionSupport` describes liquidity/activity/structure, NOT winning probability or forecast accuracy. Missing quality is unknown, not approval. Check warning reasons, freshness age and flags before deciding. Pick ONLY a listed market by its `ref`; min stake 10 mUSD. Do NOT re-bet a market+outcome already held (check observation.pmPositions); choose a different market or skip.",
111
141
  "- PM stake is a SEPARATE budget from your futures margin: the futures margin cap (maxOpenMarginMusd) does NOT limit pm_open. So when your futures are at the margin/position cap — you hold the max, or a futures_open keeps getting REJECTED with open_margin_exceeds_cap — prediction markets are STILL fully open to you. PIVOT to pm_open on a mispriced market instead of re-proposing a futures_open that will just be rejected: a rejected open wastes the entire cycle, an eligible PM bet does not.",
112
142
  "- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a LISTED crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open by its `ref` — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. ESCAPE HATCH — only the markets actually listed in observation.pmMarkets THIS cycle (pm1..pmN) are bettable: if NONE of them matches the coin or view you formed, that is a legitimate SKIP for PM (say so in one clause and move on) — do NOT invent, guess, or increment a ref for a market you wish existed, because a made-up ref is rejected (pm_ref_unknown) and wastes the whole cycle exactly like a rejected open. The mistake to avoid is leaving a LISTED, clearly mispriced crypto market untraded — a mispricing that is NOT on this cycle's board is simply not actionable now, not a miss. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
113
143
  ]
114
144
  : []),
115
145
  ...(includeForecast
116
146
  ? [
117
- "- FORECAST RULE (pm_open forecastProbability): before you look at what the market is pricing, decide YOUR OWN probability the outcome you are backing actually WINS — reason ONLY from the question, its resolution criteria, and the deadline. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must be YOUR judgement, NOT the market's: do NOT copy, round, or anchor it to the observation.pmMarkets `prob`. It is FINE if your honest forecast happens to land on the market's number — but reaching that by echoing the price defeats the point. If you genuinely cannot form an independent view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet).",
147
+ "- FORECAST RULE (pm_open forecastProbability): before you look at what the market is pricing, decide YOUR OWN probability the outcome you are backing actually WINS — reason ONLY from the question, its resolution criteria, and the deadline. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must be YOUR judgement, NOT the market's: do NOT copy, round, or anchor it to the observation.pmMarkets `prob`. It is FINE if your honest forecast happens to land on the market's number — but reaching that by echoing the price defeats the point. If you genuinely cannot form an independent view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet). A forecast you DO give is enforced: if it is not above what the outcome currently costs, the open is rejected, because buying something you price below the market is a losing trade by your own numbers.",
118
148
  ]
119
149
  : []),
120
150
  `- abstention.minConfidence ${spec.abstention.minConfidence}: opens below this are rejected, so act with genuine conviction — but routine caution is no reason to sit out a clear setup`,
@@ -151,12 +181,53 @@ opts = {}) {
151
181
  ]
152
182
  : []),
153
183
  "",
184
+ `## Fundamentals (observation.watch[].fundamentals${hasPm ? ", observation.pmMarkets" : ""}): the fundamental leg of every decision`,
185
+ ...(hasCoinVenue
186
+ ? [
187
+ `Each watch entry carries \`fundamentals\`: \`categories\` (sector tags), \`marketCapRank\`, \`marketCapUsd\`${hasIndicators ? ", `volume24hUsd` (24h volume on the tracked exchanges)" : ""}${hasNews ? ", and `headlines` (up to 3 recent stories about that coin, each with an `at` timestamp, `importance` 0..10 and `sentiment`)" : ""}. Next to \`change24h\` / \`change7d\` this is your fundamental read; it GRADES the trade, it never replaces your technical rules:`,
188
+ "- A fresh, high-importance headline that explains the move is what turns a B-grade setup into A-grade size; a big move with no headline and thin volume is more often exhaustion than a beginning.",
189
+ "- Rank and volume set the size ceiling: a top-20 coin with deep volume can take your full per-trade margin; a rank-300 name on thin volume gets half at most, a wider stop and a shorter time stop.",
190
+ "- Categories tell you what else moves with it: a sector-wide story (an L2 narrative, an exchange listing wave, a regulatory hit) applies to peers on your watchlist too; a coin whose only story is its own pump has no fundamental leg.",
191
+ ]
192
+ : []),
193
+ ...(hasPm
194
+ ? [
195
+ "- Each pmMarkets row carries `end` (resolution date), `vol24h` and `liq` (USD): thin liquidity means a smaller stake and a wider required edge; your time stop must sit before `end`; a probability that moved on heavy volume is information, one that moved on none is noise.",
196
+ ]
197
+ : []),
198
+ "",
154
199
  "## Output contract — return ONLY this JSON object, nothing else:",
155
- '{"decision":"skip"|"act","confidence":0..1,"reason":"short","rationale":"1-2 sentences","actions":[]}',
200
+ '{"decision":"skip"|"act","confidence":0..1,"reason":"brief label","rationale":"1-2 sentences","actions":[]}',
201
+ 'Decision/action consistency is mandatory: decision="act" requires at least one complete action object; decision="skip" requires actions=[]. Never describe entering or managing a trade while returning an empty actions array.',
156
202
  "Each action is one of:",
157
203
  ...actions.map((a) => `- ${a}`),
158
204
  `Set each opening action's "confidence" (0..1) to your honest conviction — the runner REJECTS any open below abstention.minConfidence (${spec.abstention.minConfidence}). The decision-level "confidence" is the fallback when an action omits its own.`,
159
205
  "",
206
+ "## Thesis on every open, and thesis exits (the runner enforces the exit)",
207
+ `Every opening action (${openKinds.join(" / ")}) MUST carry a \`thesis\`: \`summary\` = one sentence with the edge and why NOW; \`invalidation\` = what proves it wrong, with at least ONE machine-checkable condition:`,
208
+ ...(hasCoinVenue
209
+ ? [
210
+ "- coins: `priceBelow` for a long or `priceAbove` for a short = the level at which the idea is dead (a real structure level inside your stop-loss); and/or `maxHoldMinutes` = a time stop (minimum 60, at most 43200) after which an idea that has not worked is closed.",
211
+ ]
212
+ : []),
213
+ ...(hasPm
214
+ ? [
215
+ "- prediction markets: `probabilityBelow` for a YES or `probabilityAbove` for a NO, in 0..100 points of the outcome's market probability (the `currentProbability` shown on the position) = the odds at which your read is wrong; and/or `maxHoldMinutes`. Never set a time stop past the market's `end` date.",
216
+ ]
217
+ : []),
218
+ '- `catalyst`: free text naming the event whose outcome kills the idea (e.g. "CPI prints hot", "the ETF decision slips"). The runner never evaluates it; YOU re-judge it every cycle you manage the position.',
219
+ ...(hasFutures
220
+ ? [
221
+ "The runner re-checks every open futures position each cycle: when its price level or time stop is breached, the position is CLOSED automatically (logged as a thesis exit) on top of your stop-loss / take-profit. A wrong-side level (a long's priceBelow above entry) is dropped at open, so place it properly.",
222
+ ]
223
+ : []),
224
+ ...(hasPm
225
+ ? [
226
+ "Prediction-market positions cannot be closed before settlement: an invalidated PM thesis is shown to you so you do not add to it.",
227
+ ]
228
+ : []),
229
+ "Each open position shows its `thesis` with `status` (intact | invalidated), `holdMinutes` and, when broken, `invalidatedBy`. While the status is intact, HOLD: a discretionary close must name the broken condition or the resolved catalyst in its `rationaleSummary`. A small loss, an early profit below your target or a wiggle against you is not an exit. A position with no thesis (opened before this rule) is managed by its stop and target only.",
230
+ "",
160
231
  "## How to act — a decisive trader in character, not a bystander",
161
232
  "You ARE the character in the strategy above; trade like it. When you have a clear read — even a moderate-confidence one — TAKE THE POSITION, sized within your caps and protected with a stop. You wake every cycle and people watch you live: an agent that watches forever and never commits is useless to them and to itself.",
162
233
  "Skip ONLY when the read is genuinely contradictory (signals fight each other), the data is stale, or you truly have no edge this cycle. A quiet tape where your thesis still has a small but REAL edge is an ACT, not a skip — take it, small, with a stop. Do not confuse caution with paralysis.",
@@ -185,7 +256,7 @@ opts = {}) {
185
256
  "Place each stop at a real structural level with ROOM to breathe — past the swing or extreme by a sensible margin — and size the position DOWN to keep the risk small. A stop hugging your entry gets clipped by normal volatility and bleeds you a cut at a time. After a stop-out, do not immediately re-enter the same name and direction (that level is hot — wait for a genuinely fresh setup). Decisive entries, patient holds.",
186
257
  "",
187
258
  "## Manage your open positions — ride winners, cut losers",
188
- "Each cycle, look at your OPEN positions FIRST, not just new entries. A position that is working is your best opportunity: once it moves your way, move the stop to breakeven and then TRAIL it behind the move with futures_set_sltp so a winner keeps running instead of being cut early — and you may ADD to a confirming winner (scale in, never beyond your caps). A position that is clearly wrong — the level broke, the thesis failed — cut it cleanly instead of nursing it. Riding one good trade beats opening ten fresh ones.",
259
+ "Each cycle, look at your OPEN positions FIRST, not just new entries. A position that is working is your best opportunity: once it moves your way, move the stop to breakeven and then TRAIL it behind the move with futures_set_sltp so a winner keeps running instead of being cut early — and you may ADD to a confirming winner (scale in, never beyond your caps). A position that is clearly wrong (its `thesis.status` reads invalidated, the level broke, the catalyst resolved against you) is cut cleanly instead of nursed; a position whose thesis is intact is held. Riding one good trade beats opening ten fresh ones.",
189
260
  ].join("\n");
190
261
  }
191
262
  export function buildUserPrompt(obs, journal, opts = {}) {
@@ -199,6 +270,29 @@ export function buildUserPrompt(obs, journal, opts = {}) {
199
270
  const lines = [
200
271
  "Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
201
272
  ];
273
+ if (opts.capitalSizing) {
274
+ lines.push("capitalSizingPolicy is the opt-in paper sizing policy (percent fields use percentage points). capitalBook is captured owned-book collateral plus marked spot, reduced only by negative futures/PM marks on its walletId; positive open-position gains are excluded, so this is NOT complete marked equity. Positions on other walletIds remain visible for management but their collateral, marks and close proceeds do not fund this book. Missing/unavailable capitalBook means no new entries; otherwise-valid closes, protection, cancellations and spot sells remain available.");
275
+ }
276
+ if (opts.dailyRiskBudget) {
277
+ const entryActions = [
278
+ ...(hasFutures ? ["futures_open (including adds)"] : []),
279
+ ...(hasSpot ? ["spot_order buys"] : []),
280
+ ...(hasPm ? ["pm_open"] : []),
281
+ ];
282
+ const protectiveActions = [
283
+ ...(hasFutures ? ["futures_close", "futures_set_sltp"] : []),
284
+ ...(hasSpot ? ["spot_order sells", "spot_cancel"] : []),
285
+ ];
286
+ lines.push(`dailyRiskBudget below is a runtime-state snapshot: each successful ${entryActions.join(" / ")} uses one slot. It is NOT a model-call, API-call or total-write budget. Multiple entries/adds in one decision share the remaining slots; propose no more than remain. Closing does not restore a used slot. A null limit/remaining means no daily count cap; other risk caps still apply.`, ...(protectiveActions.length > 0
287
+ ? [
288
+ `Otherwise-valid ${protectiveActions.join(" / ")} do not consume these slots and remain available when the entry/add budget is exhausted.`,
289
+ ]
290
+ : []), ...(opts.dailyRiskBudget.remaining === 0
291
+ ? [
292
+ "Today's entry/add budget is EXHAUSTED until the next UTC day: propose no new entries or adds. Manage/protect existing positions and orders where valid, or skip; a flagged setup does not override this budget.",
293
+ ]
294
+ : []));
295
+ }
202
296
  // Flat-state steer: when the agent holds NOTHING, weaker models (Llama 3.1 8B)
203
297
  // still emit futures_close / futures_set_sltp / spot_cancel with a hallucinated
204
298
  // positionId/orderId — which fails the whole cycle's strict parse (one bad id
@@ -223,6 +317,22 @@ export function buildUserPrompt(obs, journal, opts = {}) {
223
317
  if (journal && journal.length > 0) {
224
318
  lines.push("", "## Your recent moves (memory, newest last) — manage these with continuity; do NOT churn by re-opening an idea you just acted on:", ...journal.slice(-6).map((j) => `- ${j.did}`));
225
319
  }
320
+ // Slice 2: name the positions whose stated thesis broke this cycle. On a live
321
+ // run the runner has already closed the futures ones (they are no longer in
322
+ // openPositions); whatever is listed here is for the MODEL to act on.
323
+ const brokenTheses = [
324
+ ...obs.openPositions
325
+ .filter((p) => p.thesis?.status === "invalidated")
326
+ .map((p) => `futures pos#${p.id} ${p.side ?? ""} ${p.symbol ?? ""}: ${p.thesis?.invalidatedBy ?? "invalidated"} (close it with futures_close)`),
327
+ ...(hasPm
328
+ ? (obs.pmPositions ?? [])
329
+ .filter((p) => p.thesis?.status === "invalidated")
330
+ .map((p) => `PM pos#${p.id} "${(p.title ?? p.slug ?? "").slice(0, 50)}": ${p.thesis?.invalidatedBy ?? "invalidated"} (no close endpoint: do NOT add, let it settle)`)
331
+ : []),
332
+ ];
333
+ if (brokenTheses.length > 0) {
334
+ lines.push("", "## Positions whose thesis is INVALIDATED this cycle", ...brokenTheses.map((b) => `- ${b}`));
335
+ }
226
336
  // Settlement-feedback loop: surface the agent's recently-RESOLVED PM bets so the
227
337
  // model can reflect and adapt. Reflective context only — never a new action.
228
338
  if (hasPm)
@@ -232,8 +342,17 @@ export function buildUserPrompt(obs, journal, opts = {}) {
232
342
  // and the trade ledger is capped so a busy shared book can't bloat the prompt.
233
343
  JSON.stringify({
234
344
  asOf: obs.asOf,
345
+ ...(opts.dailyRiskBudget
346
+ ? { dailyRiskBudget: opts.dailyRiskBudget }
347
+ : {}),
235
348
  cashAvailableMusd: obs.cashAvailableMusd,
236
349
  equityMusd: obs.equityMusd,
350
+ ...(opts.capitalSizing
351
+ ? {
352
+ capitalSizingPolicy: opts.capitalSizing,
353
+ ...(obs.capitalBook ? { capitalBook: obs.capitalBook } : {}),
354
+ }
355
+ : {}),
237
356
  openPositions: obs.openPositions,
238
357
  openOrders: obs.openOrders,
239
358
  ...(hasPm ? { pmPositions: obs.pmPositions } : {}),
@@ -249,6 +368,15 @@ export function buildUserPrompt(obs, journal, opts = {}) {
249
368
  outcome: m.outcomeName,
250
369
  prob: m.probability,
251
370
  freshness: m.freshness?.status,
371
+ ageSeconds: m.freshness?.ageSeconds,
372
+ sourceAsOf: m.freshness?.asOf,
373
+ freshnessBasis: m.freshness?.basis,
374
+ quality: pmQualityOf(m.quality),
375
+ decisionSupport: pmDecisionSupportOf(m.decisionSupport),
376
+ // Slice 2 fundamentals: resolution date, 24h volume, liquidity.
377
+ end: m.endDate,
378
+ vol24h: roundUsd(m.volumeUsd),
379
+ liq: roundUsd(m.liquidityUsd),
252
380
  })),
253
381
  }
254
382
  : {}),
@@ -1,10 +1,13 @@
1
1
  import { ProviderName } from "./types.js";
2
2
  export declare const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
3
+ export declare const DECISION_TOOL_NAME = "submit_trading_decision";
3
4
  export interface ChatShape {
4
5
  family: "openai-reasoning" | "nvidia-nemotron" | "anthropic" | "openai-compat";
5
6
  tokenParam: "max_tokens" | "max_completion_tokens";
6
7
  allowsTemperature: boolean;
7
8
  jsonResponseFormat: boolean;
9
+ jsonSchema?: Record<string, unknown>;
10
+ jsonSchemaTransport?: "tool_call" | "response_format";
8
11
  extraBody?: Record<string, unknown>;
9
12
  systemHint?: string;
10
13
  minProbeCompletionTokens: number;
@@ -1,4 +1,6 @@
1
+ import { DECISION_JSON_SCHEMA } from "./decision.js";
1
2
  export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
3
+ export const DECISION_TOOL_NAME = "submit_trading_decision";
2
4
  // gpt-5*, o1/o3/o4* — the OpenAI reasoning-API family, wherever it is served.
3
5
  const OPENAI_REASONING_MODEL = /^(gpt-5|o[0-9])/i;
4
6
  const NEMOTRON_MODEL = /nemotron/i;
@@ -22,14 +24,22 @@ export function chatShapeFor(provider, model, baseUrl) {
22
24
  };
23
25
  }
24
26
  if (NEMOTRON_MODEL.test(model)) {
27
+ const isNvidiaEndpoint = baseUrl === NVIDIA_BASE_URL;
25
28
  return {
26
29
  family: "nvidia-nemotron",
27
30
  tokenParam: "max_tokens",
28
31
  allowsTemperature: true,
29
32
  jsonResponseFormat: true,
33
+ jsonSchema: isNvidiaEndpoint
34
+ ? DECISION_JSON_SCHEMA
35
+ : undefined,
36
+ // integrate.api.nvidia.com currently ignores both response_format
37
+ // json_schema and guided_json for these hosted models. Its forced tool
38
+ // call path is the live-probed contract-enforcing transport.
39
+ jsonSchemaTransport: isNvidiaEndpoint ? "tool_call" : undefined,
30
40
  // The kwargs switch is only honored (and only safe to send) on the NVIDIA
31
41
  // endpoint; the system hint helps on any endpoint serving a Nemotron.
32
- extraBody: baseUrl === NVIDIA_BASE_URL
42
+ extraBody: isNvidiaEndpoint
33
43
  ? { chat_template_kwargs: { enable_thinking: false } }
34
44
  : undefined,
35
45
  systemHint: "detailed thinking off",
@@ -55,8 +65,36 @@ export function buildChatBody(shape, args) {
55
65
  ? { temperature: args.temperature ?? 0.2 }
56
66
  : {}),
57
67
  [shape.tokenParam]: args.maxTokens,
58
- ...(shape.jsonResponseFormat
59
- ? { response_format: { type: "json_object" } }
68
+ ...(shape.jsonSchema && shape.jsonSchemaTransport === "tool_call"
69
+ ? {
70
+ tools: [
71
+ {
72
+ type: "function",
73
+ function: {
74
+ name: DECISION_TOOL_NAME,
75
+ description: "Submit the complete CoinRithm paper-trading decision for this cycle.",
76
+ parameters: shape.jsonSchema,
77
+ },
78
+ },
79
+ ],
80
+ tool_choice: {
81
+ type: "function",
82
+ function: { name: DECISION_TOOL_NAME },
83
+ },
84
+ }
85
+ : {}),
86
+ ...(shape.jsonResponseFormat && shape.jsonSchemaTransport !== "tool_call"
87
+ ? {
88
+ response_format: shape.jsonSchema
89
+ ? {
90
+ type: "json_schema",
91
+ json_schema: {
92
+ name: "coinrithm_trading_decision",
93
+ schema: shape.jsonSchema,
94
+ },
95
+ }
96
+ : { type: "json_object" },
97
+ }
60
98
  : {}),
61
99
  ...(shape.extraBody ?? {}),
62
100
  messages: [
@@ -2,7 +2,7 @@
2
2
  // never from an agent file. One call returns one chunk of text that must be a
3
3
  // single structured-JSON decision (parsed in decision.ts). No free-form tool
4
4
  // execution — the model only proposes; the runner disposes.
5
- import { chatShapeFor, buildChatBody, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
5
+ import { chatShapeFor, buildChatBody, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
6
6
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
7
7
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
8
8
  const NVIDIA_BASE_URL = CAP_NVIDIA_BASE_URL;
@@ -30,13 +30,23 @@ const DEFAULT_TIMEOUT_MS = 300_000;
30
30
  // Per-route request quirks (reasoning toggles, token param, temperature) live
31
31
  // in the capability table — providerCapabilities.ts is the single source; this
32
32
  // module only assembles and sends.
33
- // fetch with a hard timeout via AbortController. A custom fetchFn (tests) that
34
- // ignores `signal` still works — the timer just never fires for it.
35
- async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
33
+ // One deadline covers both headers AND response-body consumption. fetch resolves
34
+ // at headers, so clearing a fetch-only timer there leaves text/json unbounded.
35
+ // Abort native I/O and race the deadline as well: an injected implementation that
36
+ // ignores AbortSignal must still release the caller rather than its run lock.
37
+ async function withProviderTimeout(timeoutMs, operation) {
36
38
  const controller = new AbortController();
37
- const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ let timer;
40
+ const deadline = new Promise((_resolve, reject) => {
41
+ timer = setTimeout(() => {
42
+ reject(Object.assign(new Error("model deadline exceeded"), {
43
+ name: "AbortError",
44
+ }));
45
+ controller.abort();
46
+ }, timeoutMs);
47
+ });
38
48
  try {
39
- return await fetchFn(url, { ...init, signal: controller.signal });
49
+ return await Promise.race([operation(controller.signal), deadline]);
40
50
  }
41
51
  finally {
42
52
  clearTimeout(timer);
@@ -131,44 +141,61 @@ class AnthropicProvider {
131
141
  }
132
142
  async decide(input) {
133
143
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
+ let failureResponse;
134
145
  try {
135
- const res = await fetchWithTimeout(this.fetchFn, "https://api.anthropic.com/v1/messages", {
136
- method: "POST",
137
- headers: {
138
- "x-api-key": this.apiKey,
139
- "anthropic-version": "2023-06-01",
140
- "content-type": "application/json",
141
- },
142
- body: JSON.stringify({
143
- model: this.model,
144
- max_tokens: input.maxTokens ?? 1024,
145
- system: input.system,
146
- messages: [{ role: "user", content: input.user }],
147
- }),
148
- }, timeoutMs);
149
- if (!res.ok)
150
- return {
151
- ok: false,
152
- // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
153
- // unbounded provider error page must not bloat the ledger row.
154
- error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
155
- status: res.status,
156
- retryAfterMs: retryAfterMs(res),
157
- };
158
- const json = (await res.json());
159
- const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
160
- const usage = json.usage
161
- ? {
162
- promptTokens: json.usage.input_tokens ?? 0,
163
- completionTokens: json.usage.output_tokens ?? 0,
146
+ return await withProviderTimeout(timeoutMs, async (signal) => {
147
+ const res = await this.fetchFn("https://api.anthropic.com/v1/messages", {
148
+ method: "POST",
149
+ signal,
150
+ headers: {
151
+ "x-api-key": this.apiKey,
152
+ "anthropic-version": "2023-06-01",
153
+ "content-type": "application/json",
154
+ },
155
+ body: JSON.stringify({
156
+ model: this.model,
157
+ max_tokens: input.maxTokens ?? 1024,
158
+ system: input.system,
159
+ messages: [{ role: "user", content: input.user }],
160
+ }),
161
+ });
162
+ if (!res.ok) {
163
+ failureResponse = res;
164
+ return {
165
+ ok: false,
166
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
167
+ // unbounded provider error page must not bloat the ledger row.
168
+ error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
169
+ status: res.status,
170
+ retryAfterMs: retryAfterMs(res),
171
+ };
164
172
  }
165
- : undefined;
166
- return text
167
- ? { ok: true, text, usage }
168
- : { ok: false, error: "anthropic returned empty content" };
173
+ const json = (await res.json());
174
+ const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
175
+ const usage = json.usage
176
+ ? {
177
+ promptTokens: json.usage.input_tokens ?? 0,
178
+ completionTokens: json.usage.output_tokens ?? 0,
179
+ }
180
+ : undefined;
181
+ return text
182
+ ? { ok: true, text, usage }
183
+ : { ok: false, error: "anthropic returned empty content" };
184
+ });
169
185
  }
170
186
  catch (err) {
171
- return { ok: false, error: callError(err, timeoutMs) };
187
+ return {
188
+ ok: false,
189
+ error: failureResponse
190
+ ? `anthropic HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
191
+ : callError(err, timeoutMs),
192
+ ...(failureResponse
193
+ ? {
194
+ status: failureResponse.status,
195
+ retryAfterMs: retryAfterMs(failureResponse),
196
+ }
197
+ : {}),
198
+ };
172
199
  }
173
200
  }
174
201
  }
@@ -190,43 +217,62 @@ class OpenAiCompatProvider {
190
217
  async decide(input) {
191
218
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
192
219
  const shape = chatShapeFor(this.provider, this.model, this.baseUrl);
220
+ let failureResponse;
193
221
  try {
194
- const res = await fetchWithTimeout(this.fetchFn, `${this.baseUrl}/chat/completions`, {
195
- method: "POST",
196
- headers: {
197
- Authorization: `Bearer ${this.apiKey}`,
198
- "content-type": "application/json",
199
- },
200
- body: JSON.stringify(buildChatBody(shape, {
201
- model: this.model,
202
- system: input.system,
203
- user: input.user,
204
- maxTokens: input.maxTokens ?? 1024,
205
- })),
206
- }, timeoutMs);
207
- if (!res.ok)
208
- return {
209
- ok: false,
210
- // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
211
- // unbounded provider error page must not bloat the ledger row.
212
- error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
213
- status: res.status,
214
- retryAfterMs: retryAfterMs(res),
215
- };
216
- const json = (await res.json());
217
- const text = json.choices?.[0]?.message?.content ?? "";
218
- const usage = json.usage
219
- ? {
220
- promptTokens: json.usage.prompt_tokens ?? 0,
221
- completionTokens: json.usage.completion_tokens ?? 0,
222
+ return await withProviderTimeout(timeoutMs, async (signal) => {
223
+ const res = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
224
+ method: "POST",
225
+ signal,
226
+ headers: {
227
+ Authorization: `Bearer ${this.apiKey}`,
228
+ "content-type": "application/json",
229
+ },
230
+ body: JSON.stringify(buildChatBody(shape, {
231
+ model: this.model,
232
+ system: input.system,
233
+ user: input.user,
234
+ maxTokens: input.maxTokens ?? 1024,
235
+ })),
236
+ });
237
+ if (!res.ok) {
238
+ failureResponse = res;
239
+ return {
240
+ ok: false,
241
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
242
+ // unbounded provider error page must not bloat the ledger row.
243
+ error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
244
+ status: res.status,
245
+ retryAfterMs: retryAfterMs(res),
246
+ };
222
247
  }
223
- : undefined;
224
- return text
225
- ? { ok: true, text, usage }
226
- : { ok: false, error: "provider returned empty content" };
248
+ const json = (await res.json());
249
+ const message = json.choices?.[0]?.message;
250
+ const decisionArguments = message?.tool_calls?.find((call) => call.function?.name === DECISION_TOOL_NAME)?.function?.arguments;
251
+ const text = decisionArguments ?? message?.content ?? "";
252
+ const usage = json.usage
253
+ ? {
254
+ promptTokens: json.usage.prompt_tokens ?? 0,
255
+ completionTokens: json.usage.completion_tokens ?? 0,
256
+ }
257
+ : undefined;
258
+ return text
259
+ ? { ok: true, text, usage }
260
+ : { ok: false, error: "provider returned empty content" };
261
+ });
227
262
  }
228
263
  catch (err) {
229
- return { ok: false, error: callError(err, timeoutMs) };
264
+ return {
265
+ ok: false,
266
+ error: failureResponse
267
+ ? `provider HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
268
+ : callError(err, timeoutMs),
269
+ ...(failureResponse
270
+ ? {
271
+ status: failureResponse.status,
272
+ retryAfterMs: retryAfterMs(failureResponse),
273
+ }
274
+ : {}),
275
+ };
230
276
  }
231
277
  }
232
278
  }
@@ -36,6 +36,7 @@ const CONFIG_BLOCKS = [
36
36
  "venues",
37
37
  "risk",
38
38
  "sizing",
39
+ "capitalSizing",
39
40
  "limits",
40
41
  "abstention",
41
42
  "sync",
@@ -1,6 +1,7 @@
1
1
  import { CoinRithmClient, ProvenanceReport } from "./client.js";
2
2
  import { Provider } from "./providers.js";
3
3
  import { AgentSpec, RunState, CycleResult, Decision, ProposedAction, PmMarket, PostedOpportunity, QuoteEvidence } from "./types.js";
4
+ import { type DecisionInputRecord } from "./decisionReceipt.js";
4
5
  export interface RunnerDeps {
5
6
  client: CoinRithmClient;
6
7
  provider: Provider;
@@ -10,13 +11,15 @@ export interface RunnerDeps {
10
11
  live: boolean;
11
12
  stateFile?: string;
12
13
  log?: (line: string) => void;
14
+ /** Optional private storage hook. Its failure never changes cycle execution. */
15
+ onDecisionInputRecord?: (record: DecisionInputRecord) => void;
13
16
  }
14
17
  export declare function houseAgentForecastEnabled(): boolean;
15
18
  export declare function agentOpportunityCaptureEnabled(): boolean;
16
19
  export declare function runnerRuntimeKind(): ProvenanceReport["runtimeKind"];
17
20
  export declare function buildRunnerProvenance(spec: AgentSpec): ProvenanceReport;
18
21
  export declare function sanitizeForecastProbability(raw: unknown): number | undefined;
19
- export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence): {
22
+ export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence, capitalMinimumRewardRisk?: number): {
20
23
  action: ProposedAction;
21
24
  repaired: boolean;
22
25
  };