@coinrithm/mcp-trading 0.7.16 → 0.7.18

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 (48) hide show
  1. package/CHANGELOG.md +903 -811
  2. package/README.md +488 -415
  3. package/dist/agent/capitalSizing.d.ts +10 -1
  4. package/dist/agent/capitalSizing.js +28 -4
  5. package/dist/agent/client.d.ts +13 -0
  6. package/dist/agent/client.js +17 -6
  7. package/dist/agent/decision.d.ts +21 -16
  8. package/dist/agent/decision.js +7 -0
  9. package/dist/agent/decisionValidator.d.ts +1 -0
  10. package/dist/agent/decisionValidator.js +1 -1
  11. package/dist/agent/engine.d.ts +3 -1
  12. package/dist/agent/engine.js +3 -1
  13. package/dist/agent/futuresCloseOutcome.d.ts +19 -0
  14. package/dist/agent/futuresCloseOutcome.js +54 -0
  15. package/dist/agent/futuresEligibility.d.ts +13 -0
  16. package/dist/agent/futuresEligibility.js +43 -5
  17. package/dist/agent/observe.d.ts +2 -1
  18. package/dist/agent/observe.js +1 -1
  19. package/dist/agent/pmPaperCandidates.d.ts +17 -0
  20. package/dist/agent/pmPaperCandidates.js +125 -0
  21. package/dist/agent/pmPaperCycle.d.ts +16 -0
  22. package/dist/agent/pmPaperCycle.js +372 -0
  23. package/dist/agent/pmPaperDecision.d.ts +187 -0
  24. package/dist/agent/pmPaperDecision.js +101 -0
  25. package/dist/agent/pmPaperPolicy.d.ts +71 -0
  26. package/dist/agent/pmPaperPolicy.js +46 -0
  27. package/dist/agent/prompt.d.ts +12 -1
  28. package/dist/agent/prompt.js +90 -3
  29. package/dist/agent/providerCapabilities.d.ts +1 -0
  30. package/dist/agent/providerCapabilities.js +4 -1
  31. package/dist/agent/providers.d.ts +1 -0
  32. package/dist/agent/providers.js +9 -4
  33. package/dist/agent/runner.d.ts +9 -1
  34. package/dist/agent/runner.js +152 -24
  35. package/dist/agent/skill.js +9 -0
  36. package/dist/agent/skillValidator.js +23 -0
  37. package/dist/agent/strictLint.js +1 -0
  38. package/dist/agent/templates.js +30 -30
  39. package/dist/agent/types.d.ts +31 -0
  40. package/dist/agent/version.d.ts +1 -1
  41. package/dist/agent/version.js +1 -1
  42. package/dist/client.d.ts +14 -0
  43. package/dist/client.js +31 -1
  44. package/dist/http.js +1 -1
  45. package/dist/pmPaperV2.d.ts +81 -0
  46. package/dist/pmPaperV2.js +53 -0
  47. package/dist/tools.js +106 -14
  48. package/package.json +100 -98
@@ -3,7 +3,7 @@
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
5
  import { retryAfterSeconds } from "../retryAfter.js";
6
- import { parseDecision } from "./decision.js";
6
+ import { parseDecisionForContract, } from "./decision.js";
7
7
  import { chatShapeFor, buildChatBody, withJsonContentTransport, canRetrySuperToolOutput, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
8
8
  // A token count as Anthropic reports it: a non-negative safe integer. Zero
9
9
  // is a real count; anything else (missing, null, negative, NaN, fractional,
@@ -334,6 +334,7 @@ class OpenAiCompatProvider {
334
334
  user: input.user,
335
335
  maxTokens: input.maxTokens ?? 1024,
336
336
  excludeActionTypes: input.excludeActionTypes,
337
+ decisionContract: input.decisionContract,
337
338
  })),
338
339
  });
339
340
  if (!res.ok) {
@@ -423,7 +424,9 @@ class SameModelRetryProvider {
423
424
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
424
425
  const first = await this.delegate.decide(input);
425
426
  const firstFinished = Date.now();
426
- const parsedFirst = first.ok ? parseDecision(first.text) : undefined;
427
+ const parsedFirst = first.ok
428
+ ? parseDecisionForContract(first.text, input.decisionContract)
429
+ : undefined;
427
430
  const retryContent = first.ok &&
428
431
  parsedFirst?.ok === false &&
429
432
  this.contentRetry !== undefined &&
@@ -450,7 +453,7 @@ class SameModelRetryProvider {
450
453
  timeoutMs: remainingMs,
451
454
  });
452
455
  const parsedRetry = retryResult.ok
453
- ? parseDecision(retryResult.text)
456
+ ? parseDecisionForContract(retryResult.text, input.decisionContract)
454
457
  : undefined;
455
458
  const result = retryResult.ok && parsedRetry?.ok === false
456
459
  ? {
@@ -461,7 +464,9 @@ class SameModelRetryProvider {
461
464
  }
462
465
  : retryResult;
463
466
  const attempt = (res, latencyMs) => {
464
- const parsed = res.ok ? parseDecision(res.text) : undefined;
467
+ const parsed = res.ok
468
+ ? parseDecisionForContract(res.text, input.decisionContract)
469
+ : undefined;
465
470
  return {
466
471
  provider: this.provider,
467
472
  model: this.model,
@@ -1,6 +1,6 @@
1
1
  import { CoinRithmClient, ProvenanceReport } from "./client.js";
2
2
  import { Provider } from "./providers.js";
3
- import { AgentSpec, RunState, CycleResult, Decision, ProposedAction, PmMarket, PostedOpportunity, QuoteEvidence } from "./types.js";
3
+ import { AgentSpec, RunState, CycleResult, Decision, PlannedAction, ProposedAction, PmMarket, PostedOpportunity, QuoteEvidence, LastRejection, TakeProfitRepair } from "./types.js";
4
4
  import { type ObserveOptions } from "./observe.js";
5
5
  import { type DecisionInputRecord } from "./decisionReceipt.js";
6
6
  export interface RunnerDeps {
@@ -30,7 +30,15 @@ export declare function sanitizeForecastProbability(raw: unknown): number | unde
30
30
  export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence, capitalMinimumRewardRisk?: number): {
31
31
  action: ProposedAction;
32
32
  repaired: boolean;
33
+ reason?: TakeProfitRepair["reason"];
33
34
  };
35
+ export declare const LAST_REJECTIONS_MAX = 5;
36
+ /**
37
+ * Pure: the rejected actions of one validated decision, newest decision only,
38
+ * for the next prompt. Undefined when nothing was rejected, so a fixed
39
+ * mistake drops out at once.
40
+ */
41
+ export declare function lastRejectionsOf(planned: readonly PlannedAction[], at: string): LastRejection[] | undefined;
34
42
  export declare function rationaleForAction(a: ProposedAction, decisionRationale: string | undefined, perActionSummary: string | undefined, totalActions: number): string | undefined;
35
43
  export declare function buildSkipOpportunity(decision: Decision, pmMarkets: PmMarket[], forecastEnabled: boolean): PostedOpportunity | null;
36
44
  export declare function runCycle(deps: RunnerDeps): Promise<CycleResult>;
@@ -19,6 +19,7 @@ import { parseDecision } from "./decision.js";
19
19
  import { validateAction, isRiskIncreasingAction, pmEventKey, pmCoinHorizonOf, pmReferenceMs, } from "./decisionValidator.js";
20
20
  import { resolvePmRef } from "./resolvePm.js";
21
21
  import { fetchQuote, executeAction } from "./act.js";
22
+ import { executionOutcome, exitOutcomeMetadata, } from "./futuresCloseOutcome.js";
22
23
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
23
24
  import { rollDay, checkKillSwitch, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
24
25
  import { asObj, asNum, asStr } from "./extract.js";
@@ -137,8 +138,10 @@ function cashConsumed(action, quote) {
137
138
  // WHOLE open (take_profit_not_*_mark) — the position never opens, so the model
138
139
  // can't "let winners run". Here we substitute a sensible R:R target off the stop so
139
140
  // the open succeeds with a valid TP. Only fires when a usable stop is present (so
140
- // risk is computable) and the TP is actually missing/wrong-side; a correct TP is
141
- // left untouched. Systemic fix for the whole free-tier 8B/instruct fleet.
141
+ // risk is computable) and the TP is missing/wrong-side, or (capital policy only,
142
+ // owner GO 2026-10-08) on the right side but inside the agent's own reward:risk
143
+ // floor, where it is widened to the floor and never narrowed. Any other correct
144
+ // TP is left untouched. Systemic fix for the whole free-tier 8B/instruct fleet.
142
145
  const DEFAULT_TP_RR = 1.5; // reward:risk of the substituted take-profit
143
146
  export function repairFuturesTakeProfit(action, quote, capitalMinimumRewardRisk) {
144
147
  if (action.type !== "futures_open")
@@ -155,7 +158,10 @@ export function repairFuturesTakeProfit(action, quote, capitalMinimumRewardRisk)
155
158
  Number.isFinite(tp) &&
156
159
  tp > 0 &&
157
160
  (isLong ? tp > entry : tp < entry);
158
- if (tpValid)
161
+ // A correct-side TP is only revisited against the agent's own capital R:R
162
+ // floor (owner GO 2026-10-08: widen a too-close target instead of refusing
163
+ // the trade; 114 capital_quote_reward_risk_too_low refusals in 10 h).
164
+ if (tpValid && capitalMinimumRewardRisk === undefined)
159
165
  return { action, repaired: false };
160
166
  // Stop must be on the correct side to imply a positive risk distance; if it
161
167
  // isn't, leave the action for the validator to reject (don't fabricate).
@@ -176,15 +182,30 @@ export function repairFuturesTakeProfit(action, quote, capitalMinimumRewardRisk)
176
182
  return { action, repaired: false };
177
183
  const notional = action.marginMusd * action.leverage;
178
184
  const fee = bps / 10_000;
185
+ // Fail closed on economics that cannot yield a finite target.
186
+ if (!(Number.isFinite(notional) && notional > 0) || !(fee < 1))
187
+ return { action, repaired: false };
179
188
  const stopRisk = (notional * risk) / entry + entryFee + ((notional * sl) / entry) * fee;
180
189
  const required = (capitalMinimumRewardRisk + 1e-8) * stopRisk;
181
190
  target = isLong
182
191
  ? (entry * (notional + entryFee + required)) / (notional * (1 - fee))
183
192
  : (entry * (notional - entryFee - required)) / (notional * (1 + fee));
184
193
  }
185
- if (!(target > 0))
194
+ if (!Number.isFinite(target) || !(target > 0))
195
+ return { action, repaired: false };
196
+ // Widen only: a valid TP at or beyond the floor is the model's choice.
197
+ if (tpValid && (isLong ? tp >= target : tp <= target))
186
198
  return { action, repaired: false };
187
- return { action: { ...action, takeProfitPrice: target }, repaired: true };
199
+ const reason = tpValid
200
+ ? "below_reward_risk_floor"
201
+ : typeof tp === "number" && Number.isFinite(tp) && tp > 0
202
+ ? "wrong_side"
203
+ : "missing";
204
+ return {
205
+ action: { ...action, takeProfitPrice: target },
206
+ repaired: true,
207
+ reason,
208
+ };
188
209
  }
189
210
  // One-line, human-readable summary of an executed action for the agent's journal
190
211
  // (slice-3 memory). Compact so a few entries cost almost nothing in the prompt.
@@ -193,6 +214,48 @@ export function repairFuturesTakeProfit(action, quote, capitalMinimumRewardRisk)
193
214
  function thesisTail(t) {
194
215
  return t ? ` on thesis: ${t.summary.slice(0, 70)}` : "";
195
216
  }
217
+ /** What the model tried, for its own rejection feedback (not a completed move). */
218
+ function describeAttempt(a) {
219
+ switch (a.type) {
220
+ case "futures_open":
221
+ return `futures_open ${a.side} ${a.symbol}`;
222
+ case "futures_close":
223
+ return `futures_close pos#${a.positionId}`;
224
+ case "futures_set_sltp":
225
+ return `futures_set_sltp pos#${a.positionId} (stopLossPrice ${a.stopLossPrice ?? "unchanged"}, takeProfitPrice ${a.takeProfitPrice ?? "unchanged"})`;
226
+ case "spot_order":
227
+ return `spot_order ${a.side} ${a.symbol}`;
228
+ case "spot_cancel":
229
+ return `spot_cancel order#${a.orderId}`;
230
+ case "pm_open":
231
+ return `pm_open ${a.ref ?? a.slug}`;
232
+ }
233
+ }
234
+ export const LAST_REJECTIONS_MAX = 5;
235
+ const REJECTION_REASON_MAX = 200;
236
+ /**
237
+ * Pure: the rejected actions of one validated decision, newest decision only,
238
+ * for the next prompt. Undefined when nothing was rejected, so a fixed
239
+ * mistake drops out at once.
240
+ */
241
+ export function lastRejectionsOf(planned, at) {
242
+ const rejected = planned.filter((p) => !p.accepted && p.code);
243
+ if (rejected.length === 0)
244
+ return undefined;
245
+ return rejected.slice(0, LAST_REJECTIONS_MAX).map((p) => ({
246
+ at,
247
+ action: describeAttempt(p.action),
248
+ code: p.code,
249
+ ...(p.reason
250
+ ? {
251
+ reason: p.reason
252
+ .replace(/\s+/g, " ")
253
+ .trim()
254
+ .slice(0, REJECTION_REASON_MAX),
255
+ }
256
+ : {}),
257
+ }));
258
+ }
196
259
  function summarizeAction(a) {
197
260
  switch (a.type) {
198
261
  case "futures_open":
@@ -607,25 +670,33 @@ async function runCycleCore(deps, capture) {
607
670
  ...observationReceipt,
608
671
  };
609
672
  const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
673
+ const outcome = executionOutcome(action, r);
610
674
  exitPlanned.push({
611
675
  action,
612
676
  accepted: true,
613
677
  code: "thesis_invalidated",
614
678
  reason: why,
615
- executed: r.ok,
679
+ executed: outcome.executed,
680
+ ...exitOutcomeMetadata(outcome),
616
681
  result: r.data,
617
682
  });
618
- if (r.ok) {
683
+ if (outcome.newWrite) {
619
684
  state.intentSeq[intentKey] = seq + 1;
620
685
  state.writesToday += 1;
686
+ }
687
+ if (outcome.executed) {
621
688
  forgetThesis(state, thesisKey("futures", pos.id));
622
689
  closedByThesis.push(pos.id);
623
- state.journal = [
624
- ...(state.journal ?? []),
625
- { at: observation.asOf, did: `thesis exit: closed ${label} (${why})` },
626
- ].slice(-12);
690
+ if (!outcome.replayed)
691
+ state.journal = [
692
+ ...(state.journal ?? []),
693
+ {
694
+ at: observation.asOf,
695
+ did: `thesis exit: closed ${label} (${why})`,
696
+ },
697
+ ].slice(-12);
627
698
  }
628
- log(`${r.ok ? "executed" : "FAILED"} thesis exit on ${label} (HTTP ${r.status}): ${why}`);
699
+ log(`${outcome.executed ? "executed" : outcome.pending ? "pending" : "FAILED"} thesis exit on ${label} (HTTP ${r.status}): ${why}`);
629
700
  }
630
701
  if (closedByThesis.length > 0) {
631
702
  // The model must see the book as it IS now: drop the closed positions,
@@ -677,7 +748,8 @@ async function runCycleCore(deps, capture) {
677
748
  tokensOut: 0,
678
749
  estimatedCostUsd: 0,
679
750
  writeAttempted: live ? exitPlanned.length : 0,
680
- writeAccepted: exitPlanned.filter((p) => p.executed).length,
751
+ writeAccepted: exitPlanned.filter((p) => p.writeRecorded ?? p.executed)
752
+ .length,
681
753
  ...observationReceipt,
682
754
  };
683
755
  }
@@ -740,6 +812,7 @@ async function runCycleCore(deps, capture) {
740
812
  const userOptions = {
741
813
  venues: spec.venues,
742
814
  dailyRiskBudget: buildDailyRiskBudget(spec, state),
815
+ ...(state.lastRejections ? { lastRejections: state.lastRejections } : {}),
743
816
  ...(usesCapitalSizing(spec) ? { capitalSizing: spec.capitalSizing } : {}),
744
817
  ...(futuresCapacity ? { futuresCapacity } : {}),
745
818
  ...actionAvailability,
@@ -975,7 +1048,9 @@ async function runCycleCore(deps, capture) {
975
1048
  // undefined (not the raw text) → db.ts stores NULL for raw_model_output.
976
1049
  const rawModelOutput = undefined;
977
1050
  if (decision.decision === "skip" || decision.actions.length === 0) {
978
- state.consecutiveRejectCycles += 1;
1051
+ if (!exitPlanned.length ||
1052
+ !exitPlanned.every((p) => p.executionPending === true))
1053
+ state.consecutiveRejectCycles += 1;
979
1054
  // Capture the abstention / forecast-only: the model evaluated a non-empty PM
980
1055
  // universe and chose NOT to open, so the public evaluation must see it (else an
981
1056
  // agent looks skilled by exposure choice alone). One post carries the whole
@@ -983,6 +1058,8 @@ async function runCycleCore(deps, capture) {
983
1058
  const skipOpp = buildSkipOpportunity(decision, observation.pmMarkets, forecastEnabled);
984
1059
  if (skipOpp)
985
1060
  await opportunities.post(skipOpp);
1061
+ // A valid new decision saw the feedback and did not repeat it.
1062
+ state.lastRejections = undefined;
986
1063
  saveState(stateFile, state);
987
1064
  log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
988
1065
  return {
@@ -1006,6 +1083,7 @@ async function runCycleCore(deps, capture) {
1006
1083
  }
1007
1084
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
1008
1085
  const planned = [];
1086
+ const protectionRefusals = [];
1009
1087
  let riskIncreasesThisCycle = 0;
1010
1088
  let openCount = observation.openPositions.length;
1011
1089
  // RUNNING totals so multiple opens in one cycle accumulate correctly.
@@ -1030,6 +1108,7 @@ async function runCycleCore(deps, capture) {
1030
1108
  let anyAccepted = false;
1031
1109
  let anyExecuted = false;
1032
1110
  let anyExecFailed = false;
1111
+ let anyPending = false;
1033
1112
  for (let action of decision.actions) {
1034
1113
  let capitalSizing;
1035
1114
  // Resolve a short PM ref (pm1…pmN) to the canonical {source,slug,outcome} BEFORE
@@ -1201,12 +1280,25 @@ async function runCycleCore(deps, capture) {
1201
1280
  }
1202
1281
  // Auto-clamp a missing/wrong-side futures take-profit to a valid R:R target
1203
1282
  // off the stop, so the open isn't silently rejected server-side (the runner
1204
- // owns trigger orientation; weak models routinely mis-sign it).
1283
+ // owns trigger orientation; weak models routinely mis-sign it). Under a
1284
+ // capital policy a correct-side TP inside the R:R floor is widened to it.
1285
+ let takeProfitRepair;
1205
1286
  {
1206
1287
  const fixed = repairFuturesTakeProfit(action, quote, capitalSizingEnabled ? spec.capitalSizing?.minRewardRisk : undefined);
1207
1288
  if (fixed.repaired) {
1289
+ // Diagnostics keep the model's own request next to the applied target.
1290
+ const requested = action
1291
+ .takeProfitPrice;
1208
1292
  action = fixed.action;
1209
- log(`repaired ${action.type} take-profit -> ${action.takeProfitPrice} (R:R off stop; model TP was missing/wrong-side)`);
1293
+ takeProfitRepair = {
1294
+ requestedTakeProfitPrice: typeof requested === "number" && Number.isFinite(requested)
1295
+ ? requested
1296
+ : null,
1297
+ appliedTakeProfitPrice: action
1298
+ .takeProfitPrice,
1299
+ reason: fixed.reason ?? "missing",
1300
+ };
1301
+ log(`repaired ${action.type} take-profit ${requested ?? "missing"} -> ${action.takeProfitPrice} (R:R off stop; model TP was missing, wrong-side or inside the reward:risk floor)`);
1210
1302
  }
1211
1303
  }
1212
1304
  const ctx = {
@@ -1240,6 +1332,7 @@ async function runCycleCore(deps, capture) {
1240
1332
  reason: v.reason,
1241
1333
  quote,
1242
1334
  ...(capitalSizing ? { capitalSizing } : {}),
1335
+ ...(takeProfitRepair ? { takeProfitRepair } : {}),
1243
1336
  });
1244
1337
  log(`reject ${action.type}: ${v.code} (${v.reason})`);
1245
1338
  continue;
@@ -1255,6 +1348,7 @@ async function runCycleCore(deps, capture) {
1255
1348
  reason: "paper capital policy rejected quoted economics",
1256
1349
  quote,
1257
1350
  capitalSizing,
1351
+ ...(takeProfitRepair ? { takeProfitRepair } : {}),
1258
1352
  });
1259
1353
  log(`reject ${action.type}: ${capitalRejection}`);
1260
1354
  continue;
@@ -1284,6 +1378,7 @@ async function runCycleCore(deps, capture) {
1284
1378
  quote,
1285
1379
  executed: false,
1286
1380
  ...(capitalSizing ? { capitalSizing } : {}),
1381
+ ...(takeProfitRepair ? { takeProfitRepair } : {}),
1287
1382
  });
1288
1383
  // Opt-in simulations reserve the same accepted capital as paper-live.
1289
1384
  // No close/sell proceeds are credited until a future observed wallet read.
@@ -1317,18 +1412,27 @@ async function runCycleCore(deps, capture) {
1317
1412
  ...observationReceipt,
1318
1413
  };
1319
1414
  const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
1415
+ const outcome = executionOutcome(action, r);
1320
1416
  planned.push({
1321
1417
  action,
1322
1418
  accepted: true,
1323
1419
  quote,
1324
- executed: r.ok,
1420
+ executed: outcome.executed,
1421
+ ...exitOutcomeMetadata(outcome),
1325
1422
  result: r.data,
1326
1423
  ...(capitalSizing ? { capitalSizing } : {}),
1424
+ ...(takeProfitRepair ? { takeProfitRepair } : {}),
1327
1425
  });
1328
- if (r.ok) {
1329
- anyExecuted = true;
1426
+ if (outcome.newWrite) {
1330
1427
  state.intentSeq[intentKey] = seq + 1;
1331
1428
  state.writesToday += 1;
1429
+ }
1430
+ if (outcome.pending)
1431
+ anyPending = true;
1432
+ if (outcome.executed) {
1433
+ anyExecuted = true;
1434
+ if (outcome.v2 && action.type === "futures_close")
1435
+ forgetThesis(state, thesisKey("futures", action.positionId));
1332
1436
  if (isRiskIncreasingAction(action)) {
1333
1437
  riskIncreasesThisCycle += 1;
1334
1438
  state.riskIncreasesToday += 1;
@@ -1364,8 +1468,25 @@ async function runCycleCore(deps, capture) {
1364
1468
  if (capitalSizingEnabled)
1365
1469
  committedCapitalMusd += capitalCashCost(action, quote);
1366
1470
  }
1367
- else {
1471
+ else if (!outcome.pending) {
1368
1472
  anyExecFailed = true;
1473
+ // The mark can move after local validation. This explicit API rejection
1474
+ // confirms that the protection update was refused. A timeout/5xx cannot
1475
+ // establish that nothing happened and must never enter this feedback.
1476
+ const response = asObj(r.data);
1477
+ if (action.type === "futures_set_sltp" &&
1478
+ r.status === 422 &&
1479
+ response.error === "sl_tp_invalid") {
1480
+ const codes = Array.isArray(response.blockReasons)
1481
+ ? response.blockReasons.filter((code) => typeof code === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(code))
1482
+ : [];
1483
+ protectionRefusals.push({
1484
+ action,
1485
+ accepted: false,
1486
+ code: "sl_tp_invalid",
1487
+ reason: codes.slice(0, 5).join(", ") || "Recheck the current mark.",
1488
+ });
1489
+ }
1369
1490
  // A transport/server failure does not prove an entry stayed unfilled.
1370
1491
  // Reserve its possible spend for subsequent actions this cycle, without
1371
1492
  // recording a confirmed trade or advancing its idempotency sequence.
@@ -1402,23 +1523,30 @@ async function runCycleCore(deps, capture) {
1402
1523
  });
1403
1524
  }
1404
1525
  }
1405
- log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
1526
+ log(`${outcome.executed ? "executed" : outcome.pending ? "pending" : "FAILED"} ${action.type} (HTTP ${r.status})`);
1406
1527
  }
1407
1528
  // Reset the reject kill-switch only on real PROGRESS: an accepted-but-FAILED
1408
1529
  // live write is not progress, or a persistently failing live agent would
1409
1530
  // never trip the kill-switch.
1410
1531
  const progressed = live ? anyExecuted : anyAccepted;
1532
+ state.lastRejections = lastRejectionsOf([...planned, ...protectionRefusals], observation.asOf);
1411
1533
  state.consecutiveRejectCycles = progressed
1412
1534
  ? 0
1413
- : state.consecutiveRejectCycles + 1;
1535
+ : live && anyPending && planned.every((p) => p.executionPending === true)
1536
+ ? state.consecutiveRejectCycles
1537
+ : state.consecutiveRejectCycles + 1;
1414
1538
  state.consecutiveExecFailures =
1415
- anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
1539
+ anyExecFailed && !anyExecuted
1540
+ ? state.consecutiveExecFailures + 1
1541
+ : !anyExecuted && anyPending
1542
+ ? state.consecutiveExecFailures
1543
+ : 0;
1416
1544
  state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
1417
1545
  // Memory describes completed moves, not policy acceptance. A backend rejection,
1418
1546
  // lost response, or dry-run plan must not become "opened" / "trailed stop" in
1419
1547
  // the next model prompt. Attempts remain in planned[] and the audit ledger.
1420
1548
  const moves = planned
1421
- .filter((p) => p.accepted && p.executed === true)
1549
+ .filter((p) => p.accepted && p.executed === true && p.executionReplayed !== true)
1422
1550
  .map((p) => summarizeAction(p.action));
1423
1551
  if (moves.length > 0) {
1424
1552
  // A cycle-wide rationale may describe a failed/rejected sibling action.
@@ -75,6 +75,15 @@ function buildCapitalSizing(raw) {
75
75
  totalCapitalPct: num(policy.totalCapitalPct, Number.NaN),
76
76
  cashReservePct: num(policy.cashReservePct, Number.NaN),
77
77
  minRewardRisk: num(policy.minRewardRisk, Number.NaN),
78
+ ...(policy.highConviction !== undefined
79
+ ? {
80
+ highConviction: {
81
+ minConfidence: num(obj(policy.highConviction).minConfidence, Number.NaN),
82
+ perTicketCapitalPct: num(obj(policy.highConviction).perTicketCapitalPct, Number.NaN),
83
+ futuresRiskPct: num(obj(policy.highConviction).futuresRiskPct, Number.NaN),
84
+ },
85
+ }
86
+ : {}),
78
87
  };
79
88
  }
80
89
  // Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
@@ -28,6 +28,7 @@ export function validateCapitalSizingPolicy(value) {
28
28
  ...percentKeys,
29
29
  "cashReservePct",
30
30
  "minRewardRisk",
31
+ "highConviction",
31
32
  ]);
32
33
  for (const key of Object.keys(p)) {
33
34
  if (!allowedKeys.has(key))
@@ -57,6 +58,28 @@ export function validateCapitalSizingPolicy(value) {
57
58
  p.totalCapitalPct + p.cashReservePct > 100) {
58
59
  add("skill_capital_sizing_total_reserve", "capitalSizing.totalCapitalPct + cashReservePct cannot exceed 100");
59
60
  }
61
+ if (p.highConviction !== undefined) {
62
+ const hc = p.highConviction;
63
+ if (!isObj(hc)) {
64
+ add("skill_capital_sizing_high_conviction", "capitalSizing.highConviction must be an object");
65
+ }
66
+ else {
67
+ for (const key of Object.keys(hc)) {
68
+ if (!["minConfidence", "perTicketCapitalPct", "futuresRiskPct"].includes(key))
69
+ add("skill_capital_sizing_unknown_key", `unknown capitalSizing.highConviction key "${key}"`);
70
+ }
71
+ if (!isPosNum(hc.minConfidence) || hc.minConfidence > 1)
72
+ add("skill_capital_sizing_high_conviction", "capitalSizing.highConviction.minConfidence must be a finite number in (0, 1]");
73
+ for (const key of ["perTicketCapitalPct", "futuresRiskPct"]) {
74
+ if (!isPosNum(hc[key]) || hc[key] > 100)
75
+ add("skill_capital_sizing_percent", `capitalSizing.highConviction.${key} must be a finite number in (0, 100]`);
76
+ }
77
+ if (isPosNum(hc.perTicketCapitalPct) &&
78
+ isPosNum(p.totalCapitalPct) &&
79
+ hc.perTicketCapitalPct > p.totalCapitalPct)
80
+ add("skill_capital_sizing_ticket_cap", "capitalSizing.highConviction.perTicketCapitalPct cannot exceed totalCapitalPct");
81
+ }
82
+ }
60
83
  return issues;
61
84
  }
62
85
  export function validateSkill(parsed, mode = "self-host") {
@@ -87,6 +87,7 @@ const ALLOWED_KEYS = {
87
87
  "totalCapitalPct",
88
88
  "cashReservePct",
89
89
  "minRewardRisk",
90
+ "highConviction",
90
91
  ],
91
92
  limits: [
92
93
  "maxTradesPerDay",
@@ -50,37 +50,37 @@ const PRESETS = {
50
50
  modelFail: 5,
51
51
  },
52
52
  };
53
- const THESIS_BODY = `# Momentum Futures — strategy
54
-
55
- You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
- Everything here is simulated; it is not financial advice and never touches real
57
- money. Edit this prose freely (any language) — it is your agent's borders.
58
-
59
- ## Each cycle
60
-
61
- 1. Ground yourself: read your portfolio and open positions first. Never assume
62
- balances or what is already open.
63
- 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
- agree (both up, or both down) and is not already an open position.
65
- 3. Pick the strongest candidate and commit when the read is clear — even a
66
- moderate-confidence one — sized small with a stop. Skip only when the signals
67
- contradict or the data is stale; a quiet tape where your edge is still real
68
- is an act, not a skip.
69
- 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
- the quote is not eligible, relay the reason and stop.
71
- 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
- open. Place the take-profit a touch wider than the stop.
73
- 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
- fired while you were not looking, and react to what actually happened.
75
-
76
- The hard caps (leverage, margin, watchlist) live in the config blocks above and
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick the strongest candidate and commit when the read is clear — even a
66
+ moderate-confidence one — sized small with a stop. Skip only when the signals
67
+ contradict or the data is stale; a quiet tape where your edge is still real
68
+ is an act, not a skip.
69
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
+ the quote is not eligible, relay the reason and stop.
71
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
+ open. Place the take-profit a touch wider than the stop.
73
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
+ fired while you were not looking, and react to what actually happened.
75
+
76
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
77
77
  are enforced by the runner — change them there, not in this prose.`;
78
- const PERSONA_STUB = `# Persona
79
-
80
- Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
- — sized small and protected with a stop, and skips only when the read is
82
- contradictory or the data is stale. States its reasoning plainly in its own
83
- voice, and never frames paper results as real-money advice.
78
+ const PERSONA_STUB = `# Persona
79
+
80
+ Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
+ — sized small and protected with a stop, and skips only when the read is
82
+ contradictory or the data is stale. States its reasoning plainly in its own
83
+ voice, and never frames paper results as real-money advice.
84
84
  `;
85
85
  export function buildAgentObject(name, preset) {
86
86
  const p = PRESETS[preset];
@@ -58,6 +58,15 @@ export interface CapitalSizingPolicy {
58
58
  totalCapitalPct: number;
59
59
  cashReservePct: number;
60
60
  minRewardRisk: number;
61
+ /** Opt-in (owner 2026-10-08): a futures_open the model rates at or above
62
+ * minConfidence uses these larger ticket and stop-risk limits instead of
63
+ * the base ones. Every other cap (total, reserve, open margin, R:R) holds. */
64
+ highConviction?: CapitalHighConviction;
65
+ }
66
+ export interface CapitalHighConviction {
67
+ minConfidence: number;
68
+ perTicketCapitalPct: number;
69
+ futuresRiskPct: number;
61
70
  }
62
71
  export type CapitalBook = {
63
72
  status: "unavailable";
@@ -687,6 +696,8 @@ export interface FuturesFundingQuoteEvidence {
687
696
  annualizedRate?: number;
688
697
  }
689
698
  export interface RunState {
699
+ /** Exact checkpointed v2 request; validated before replay, never model-controlled. */
700
+ pmPaperV2Pending?: unknown;
690
701
  runId: string;
691
702
  cyclesRun: number;
692
703
  writesToday: number;
@@ -718,6 +729,13 @@ export interface RunState {
718
729
  did: string;
719
730
  }>;
720
731
  theses?: Record<string, PositionThesis>;
732
+ lastRejections?: LastRejection[];
733
+ }
734
+ export interface LastRejection {
735
+ at: string;
736
+ action: string;
737
+ code: string;
738
+ reason?: string;
721
739
  }
722
740
  export interface AgentTrace {
723
741
  runId?: string;
@@ -743,8 +761,21 @@ export interface PlannedAction {
743
761
  reason?: string;
744
762
  quote?: QuoteEvidence;
745
763
  executed?: boolean;
764
+ /** V2 close accepted but exposure remains open; accounting is separate. */
765
+ executionPending?: boolean;
766
+ executionReplayed?: boolean;
767
+ /** A newly accepted v2 intent/fill consumes write budget; replay does not. */
768
+ writeRecorded?: boolean;
746
769
  result?: unknown;
747
770
  capitalSizing?: CapitalSizingAdjustment;
771
+ /** Set when the runner replaced the model's futures take-profit; persisted
772
+ * with the cycle so the requested target is never lost. */
773
+ takeProfitRepair?: TakeProfitRepair;
774
+ }
775
+ export interface TakeProfitRepair {
776
+ requestedTakeProfitPrice: number | null;
777
+ appliedTakeProfitPrice: number;
778
+ reason: "missing" | "wrong_side" | "below_reward_risk_floor";
748
779
  }
749
780
  export interface CycleResult {
750
781
  /** Private non-enumerable evidence. Never forward through AgentTrace/public APIs. */
@@ -5,7 +5,7 @@ export declare const COINRITHM_API: {
5
5
  readonly kind: "coinrithm-agent-api";
6
6
  readonly baseUrl: "https://api.coinrithm.com";
7
7
  readonly mcpUrl: "https://mcp.coinrithm.com/mcp";
8
- readonly openapiVersion: "1.7.0";
8
+ readonly openapiVersion: "1.9.0";
9
9
  readonly mcpPackage: "@coinrithm/mcp-trading";
10
10
  readonly mcpVersion: string;
11
11
  };
@@ -20,7 +20,7 @@ export const COINRITHM_API = {
20
20
  mcpUrl: "https://mcp.coinrithm.com/mcp",
21
21
  // The API CONTRACT version (openapi.yaml info.version). Versioned independently
22
22
  // from the npm package below — hand-bump this when the OpenAPI contract changes.
23
- openapiVersion: "1.7.0",
23
+ openapiVersion: "1.9.0",
24
24
  mcpPackage: "@coinrithm/mcp-trading",
25
25
  mcpVersion: PACKAGE_VERSION,
26
26
  };