@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,13 +4,14 @@
4
4
  // are injected so the loop is fully unit-testable with no network/model calls.
5
5
  import { COINRITHM_API } from "./version.js";
6
6
  import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
7
+ import { usesCapitalSizing, prepareCapitalAction, validateCapitalAction, capitalCashCost, } from "./capitalSizing.js";
7
8
  import { decideMechanical } from "./mechanical.js";
8
9
  import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
9
- import { baseSymbol } from "./setups.js";
10
+ import { baseSymbol, scanSetups } from "./setups.js";
10
11
  import { observe } from "./observe.js";
11
- import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
12
+ import { buildDailyRiskBudget, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
12
13
  import { parseDecision } from "./decision.js";
13
- import { validateAction } from "./decisionValidator.js";
14
+ import { validateAction, isRiskIncreasingAction, } from "./decisionValidator.js";
14
15
  import { resolvePmRef } from "./resolvePm.js";
15
16
  import { fetchQuote, executeAction } from "./act.js";
16
17
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
@@ -18,6 +19,8 @@ import { rollDay, checkKillSwitch, accrueRealized, saveState, isPermanentModelEr
18
19
  import { asObj, asNum, asStr } from "./extract.js";
19
20
  import { parseCadenceMs, sleep } from "./util.js";
20
21
  import { buildObservationReceipt } from "./observationReceipt.js";
22
+ import { buildDecisionInputRecord, sanitizeDecisionInputRecord, unavailableDecisionInputRecord, } from "./decisionReceipt.js";
23
+ import { attachTheses, bindThesis, forgetThesis, rememberThesis, thesisExits, thesisKey, } from "./thesis.js";
21
24
  // Independent-forecast kill-switch. Default ON: the fleet elicits + submits its
22
25
  // OWN forecastProbability on PM opens. Set HOUSE_AGENT_FORECAST_ENABLED to
23
26
  // "false"/"0"/"no"/"off" to ship pm/open requests WITHOUT the field, byte-identical
@@ -132,7 +135,7 @@ function cashConsumed(action, quote) {
132
135
  // risk is computable) and the TP is actually missing/wrong-side; a correct TP is
133
136
  // left untouched. Systemic fix for the whole free-tier 8B/instruct fleet.
134
137
  const DEFAULT_TP_RR = 1.5; // reward:risk of the substituted take-profit
135
- export function repairFuturesTakeProfit(action, quote) {
138
+ export function repairFuturesTakeProfit(action, quote, capitalMinimumRewardRisk) {
136
139
  if (action.type !== "futures_open")
137
140
  return { action, repaired: false };
138
141
  const entry = quote?.entryPrice;
@@ -154,30 +157,95 @@ export function repairFuturesTakeProfit(action, quote) {
154
157
  const risk = isLong ? entry - sl : sl - entry;
155
158
  if (!(risk > 0))
156
159
  return { action, repaired: false };
157
- const target = isLong
160
+ let target = isLong
158
161
  ? entry + DEFAULT_TP_RR * risk
159
162
  : entry - DEFAULT_TP_RR * risk;
163
+ if (capitalMinimumRewardRisk !== undefined) {
164
+ const bps = quote?.futuresFeeBps, entryFee = quote?.estimatedEntryFeeMusd;
165
+ if (typeof bps !== "number" ||
166
+ !Number.isFinite(bps) ||
167
+ bps < 0 ||
168
+ typeof entryFee !== "number" ||
169
+ !Number.isFinite(entryFee) ||
170
+ entryFee < 0)
171
+ return { action, repaired: false };
172
+ const notional = action.marginMusd * action.leverage;
173
+ const fee = bps / 10_000;
174
+ const stopRisk = (notional * risk) / entry + entryFee + ((notional * sl) / entry) * fee;
175
+ const required = (capitalMinimumRewardRisk + 1e-8) * stopRisk;
176
+ target = isLong
177
+ ? (entry * (notional + entryFee + required)) / (notional * (1 - fee))
178
+ : (entry * (notional - entryFee - required)) / (notional * (1 + fee));
179
+ }
160
180
  if (!(target > 0))
161
181
  return { action, repaired: false };
162
182
  return { action: { ...action, takeProfitPrice: target }, repaired: true };
163
183
  }
164
184
  // One-line, human-readable summary of an executed action for the agent's journal
165
185
  // (slice-3 memory). Compact so a few entries cost almost nothing in the prompt.
186
+ // The thesis an open was made on, kept in the journal so next cycle's manage
187
+ // step remembers WHY the position exists (slice 2).
188
+ function thesisTail(t) {
189
+ return t ? ` on thesis: ${t.summary.slice(0, 70)}` : "";
190
+ }
166
191
  function summarizeAction(a) {
167
192
  switch (a.type) {
168
193
  case "futures_open":
169
- return `opened ${a.side} ${a.symbol} (x${a.leverage}, ${a.marginMusd}mUSD)`;
194
+ return `opened ${a.side} ${a.symbol} (x${a.leverage}, ${a.marginMusd}mUSD)${thesisTail(a.thesis)}`;
170
195
  case "futures_close":
171
196
  return `closed pos#${a.positionId}`;
172
197
  case "futures_set_sltp":
173
198
  return `trailed stop on pos#${a.positionId}`;
174
199
  case "spot_order":
175
- return `${a.side} ${a.symbol} (${a.orderType})`;
200
+ return `${a.side} ${a.symbol} (${a.orderType})${thesisTail(a.thesis)}`;
176
201
  case "spot_cancel":
177
202
  return `cancelled order#${a.orderId}`;
178
203
  case "pm_open":
179
- return `bet PM ${a.slug} ${a.stakeMusd}mUSD`;
204
+ return `bet PM ${a.slug} ${a.stakeMusd}mUSD${thesisTail(a.thesis)}`;
205
+ }
206
+ }
207
+ // Bind the thesis a successful open was made on to the position the server
208
+ // returned ({ position: { id, entryPrice | entryProbability, side, openedAt } }
209
+ // on both /futures/open and /pm/open, probed 2026-09-02). Returns the key even
210
+ // when the model stated no thesis so the caller can log the gap. Spot orders
211
+ // carry no position id, so a spot thesis is parsed but never bound.
212
+ function bindOpenedThesis(action, responseData, asOf, quote) {
213
+ const pos = asObj(asObj(responseData).position);
214
+ const id = asNum(pos.id);
215
+ if (id == null)
216
+ return {};
217
+ const key = thesisKey(action.type === "futures_open" ? "futures" : "pm", id);
218
+ if (!action.thesis)
219
+ return { key };
220
+ const openedAt = asStr(pos.openedAt) ?? asOf;
221
+ if (action.type === "futures_open") {
222
+ return {
223
+ key,
224
+ bound: bindThesis({
225
+ thesis: action.thesis,
226
+ venue: "futures",
227
+ positionId: id,
228
+ openedAt,
229
+ side: action.side,
230
+ symbol: action.symbol.toUpperCase(),
231
+ entryPrice: asNum(pos.entryPrice) ?? quote?.entryPrice,
232
+ }),
233
+ };
180
234
  }
235
+ return {
236
+ key,
237
+ bound: bindThesis({
238
+ thesis: action.thesis,
239
+ venue: "pm",
240
+ positionId: id,
241
+ openedAt,
242
+ side: asStr(pos.side) ?? "yes",
243
+ source: action.source,
244
+ slug: action.slug,
245
+ outcomeExternalMarketId: action.outcomeExternalMarketId,
246
+ entryProbability: asNum(pos.entryProbability),
247
+ }),
248
+ };
181
249
  }
182
250
  // Tokens that identify the MARKET an action is on, for checking a rationale is
183
251
  // actually about it. close/sltp/cancel reference a position/order id (not a market
@@ -304,6 +372,45 @@ function blockReasonsOf(data) {
304
372
  return reasons.length > 0 ? reasons.join(",") : undefined;
305
373
  }
306
374
  export async function runCycle(deps) {
375
+ let record = unavailableDecisionInputRecord();
376
+ const capture = (input) => {
377
+ try {
378
+ record =
379
+ sanitizeDecisionInputRecord(buildDecisionInputRecord(input)) ??
380
+ unavailableDecisionInputRecord();
381
+ }
382
+ catch {
383
+ record = unavailableDecisionInputRecord();
384
+ }
385
+ };
386
+ const notify = () => {
387
+ try {
388
+ void Promise.resolve(deps.onDecisionInputRecord?.(JSON.parse(JSON.stringify(record)))).catch(() => { });
389
+ }
390
+ catch {
391
+ /* private evidence must not change inference or execution */
392
+ }
393
+ };
394
+ try {
395
+ const result = await runCycleCore(deps, capture);
396
+ record.outcome = "returned";
397
+ // Non-enumerable on purpose: CLI data/results JSON and object spreads must
398
+ // not accidentally expose this private account/position snapshot.
399
+ Object.defineProperty(result, "decisionInputRecord", {
400
+ value: record,
401
+ enumerable: false,
402
+ });
403
+ notify();
404
+ return result;
405
+ }
406
+ catch (error) {
407
+ record.outcome = "runtime_error";
408
+ record.omissions.push("runtime_exception_after_snapshot");
409
+ notify();
410
+ throw error;
411
+ }
412
+ }
413
+ async function runCycleCore(deps, capture) {
307
414
  const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
308
415
  const log = deps.log ?? (() => { });
309
416
  // One flag read per cycle governs BOTH the prompt extension and the submission,
@@ -315,6 +422,10 @@ export async function runCycle(deps) {
315
422
  const provenance = buildRunnerProvenance(spec);
316
423
  state.cyclesRun += 1;
317
424
  rollDay(state);
425
+ const runId = state.runId;
426
+ const decisionId = makeDecisionId(state.cyclesRun);
427
+ const captureBase = { runId, decisionId, spec, mergedProse, state };
428
+ capture({ ...captureBase, phase: "before_observation" });
318
429
  // Kill-switch pre-check: a disabled agent never observes, decides, or acts.
319
430
  const tripped = checkKillSwitch(spec, state);
320
431
  if (tripped) {
@@ -330,8 +441,6 @@ export async function runCycle(deps) {
330
441
  live,
331
442
  };
332
443
  }
333
- const runId = state.runId;
334
- const decisionId = makeDecisionId(state.cyclesRun);
335
444
  const baseTrace = makeTrace(runId, decisionId, spec);
336
445
  // Opportunity capture (kills evaluation selection bias). Post at most ONE
337
446
  // non-opened opportunity per cycle, LIVE only (dry-run never writes), best-effort
@@ -372,7 +481,18 @@ export async function runCycle(deps) {
372
481
  // OBSERVE
373
482
  const obs = await observe(client, spec, state, baseTrace);
374
483
  const observation = obs.observation;
375
- const observationReceipt = buildObservationReceipt(observation);
484
+ const nowMs = Date.now();
485
+ // Slice 2: attach to each open position the thesis it was opened on (from
486
+ // state.theses), evaluated for THIS cycle, and drop theses whose position is
487
+ // gone. Before the receipt, so the hash covers exactly what the model sees.
488
+ // PM theses are pruned only when the pm venue was actually read.
489
+ const thesisMaint = attachTheses(observation, state, nowMs, {
490
+ prunePm: spec.venues.includes("pm"),
491
+ });
492
+ for (const key of thesisMaint.pruned)
493
+ log(`thesis dropped: ${key} (position no longer open)`);
494
+ let observationReceipt = buildObservationReceipt(observation);
495
+ const preThesisObservationFingerprint = observationReceipt.observationHash;
376
496
  // Reads build the observation, so its hash cannot exist before they finish.
377
497
  // From this point every durable write carries the exact decision-input receipt.
378
498
  Object.assign(baseTrace, observationReceipt);
@@ -405,6 +525,13 @@ export async function runCycle(deps) {
405
525
  ].slice(-12);
406
526
  }
407
527
  }
528
+ capture({
529
+ ...captureBase,
530
+ phase: "observed",
531
+ observation,
532
+ observationFingerprint: observationReceipt.observationHash,
533
+ preThesisObservationFingerprint,
534
+ });
408
535
  // Equity-aware drawdown: open mark-to-market losses trip the kill-switch too,
409
536
  // not only realized losses. Includes BOTH futures AND prediction-market books —
410
537
  // a PM-only agent (or one with a large PM stake) was previously invisible to the
@@ -464,13 +591,86 @@ export async function runCycle(deps) {
464
591
  }
465
592
  // A full observation implies /me succeeded — the auth-failure streak is over.
466
593
  state.consecutiveAuthFailures = 0;
594
+ // THESIS EXITS (slice 2, 2026-09-02): a futures position whose STATED
595
+ // invalidation is breached (price level / time stop) is closed by the runner
596
+ // now, before the model is asked anything: the exit the trade was designed
597
+ // with, on top of the server-side stop-loss / take-profit. Runs only after
598
+ // the kill-switch + drawdown checks above (a disabled agent never gets here)
599
+ // and never widens a cap: a close is risk-reducing. Dry-run plans the exit
600
+ // without writing. PM has no close endpoint, so an invalidated PM thesis is
601
+ // surfaced to the model instead (do not add; let it settle).
602
+ const exitPlanned = [];
603
+ const closedByThesis = [];
604
+ for (const pos of thesisExits(observation)) {
605
+ const why = pos.thesis?.invalidatedBy ?? "thesis invalidated";
606
+ const label = `${pos.side ?? ""} ${pos.symbol ?? ""} pos#${pos.id}`.trim();
607
+ const action = {
608
+ type: "futures_close",
609
+ positionId: pos.id,
610
+ rationaleSummary: `Thesis exit: ${why}`,
611
+ };
612
+ if (!live) {
613
+ exitPlanned.push({
614
+ action,
615
+ accepted: true,
616
+ code: "thesis_invalidated",
617
+ reason: why,
618
+ executed: false,
619
+ });
620
+ log(`DRY-RUN: would close ${label} (thesis exit: ${why})`);
621
+ continue;
622
+ }
623
+ const intentKey = intentKeyOf(action);
624
+ const seq = state.intentSeq[intentKey] ?? 0;
625
+ const idem = `${runId}:${intentKey}:${seq}`;
626
+ const trace = {
627
+ ...makeTrace(runId, decisionId, spec, undefined, `Thesis exit: ${why}`),
628
+ ...observationReceipt,
629
+ };
630
+ const r = await executeAction(client, action, observation, trace, idem, provenance);
631
+ exitPlanned.push({
632
+ action,
633
+ accepted: true,
634
+ code: "thesis_invalidated",
635
+ reason: why,
636
+ executed: r.ok,
637
+ result: r.data,
638
+ });
639
+ if (r.ok) {
640
+ state.intentSeq[intentKey] = seq + 1;
641
+ state.writesToday += 1;
642
+ forgetThesis(state, thesisKey("futures", pos.id));
643
+ closedByThesis.push(pos.id);
644
+ state.journal = [
645
+ ...(state.journal ?? []),
646
+ { at: observation.asOf, did: `thesis exit: closed ${label} (${why})` },
647
+ ].slice(-12);
648
+ }
649
+ log(`${r.ok ? "executed" : "FAILED"} thesis exit on ${label} (HTTP ${r.status}): ${why}`);
650
+ }
651
+ if (closedByThesis.length > 0) {
652
+ // The model must see the book as it IS now: drop the closed positions,
653
+ // re-scan setups (an exited symbol is no longer "held") and re-issue the
654
+ // receipt over the observation the model actually decides on. The exit
655
+ // traces above keep the pre-exit receipt they were decided on.
656
+ observation.openPositions = observation.openPositions.filter((p) => !closedByThesis.includes(p.id));
657
+ observation.setups = scanSetups(observation.watch, observation.openPositions);
658
+ observationReceipt = buildObservationReceipt(observation);
659
+ Object.assign(baseTrace, observationReceipt);
660
+ }
467
661
  // GATE (slice 2): only SPEND an LLM call when a deterministic trigger fires — a
468
662
  // flagged entry setup or an open position to manage. No trigger => a cheap
469
663
  // heartbeat (zero tokens). A heartbeat is neither a model reject nor a failure,
470
664
  // so it touches NO kill-switch counter; it just records the cheap cycle.
471
665
  const policy = spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY;
472
- const nowMs = Date.now();
473
666
  const gate = evaluateGate(observation, state, policy, nowMs);
667
+ capture({
668
+ ...captureBase,
669
+ phase: "decision_input",
670
+ observation,
671
+ observationFingerprint: observationReceipt.observationHash,
672
+ preThesisObservationFingerprint,
673
+ });
474
674
  const providerName = spec.model?.provider ?? "nvidia";
475
675
  if (!gate.fire) {
476
676
  saveState(stateFile, state);
@@ -478,7 +678,7 @@ export async function runCycle(deps) {
478
678
  return {
479
679
  decision: "skip",
480
680
  skipReason: gate.reason,
481
- planned: [],
681
+ planned: exitPlanned,
482
682
  live,
483
683
  triggerCodes: gate.codes,
484
684
  llmCallMade: false,
@@ -518,55 +718,115 @@ export async function runCycle(deps) {
518
718
  };
519
719
  }
520
720
  else {
521
- noteLlmCall(state, gate.codes, nowMs);
522
721
  const system = buildSystemPrompt(spec, mergedProse, {
523
722
  includeForecast: forecastEnabled,
524
723
  });
525
- const user = buildUserPrompt(observation, state.journal);
724
+ const user = buildUserPrompt(observation, state.journal, {
725
+ venues: spec.venues,
726
+ dailyRiskBudget: buildDailyRiskBudget(spec, state),
727
+ ...(usesCapitalSizing(spec) ? { capitalSizing: spec.capitalSizing } : {}),
728
+ });
526
729
  const tokensInEst = Math.round((system.length + user.length) / 4);
527
730
  // Prompt-size + trigger visibility in the live terminal.
528
731
  log(`prompt ~${tokensInEst} tok ` +
529
732
  `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
530
733
  const res = await provider.decide({ system, user });
734
+ const route = res.route;
735
+ const actualCallMade = route
736
+ ? route.attempts.some((attempt) => attempt.outcome !== "deferred")
737
+ : res.ok || !res.deferred;
738
+ // Capacity deferral made no provider call, so it must not consume the
739
+ // runner's debounce/LLM budget or delay recovery after capacity returns.
740
+ if (actualCallMade)
741
+ noteLlmCall(state, gate.codes, nowMs);
742
+ const effectiveProvider = actualCallMade
743
+ ? (route?.effectiveProvider ?? providerName)
744
+ : undefined;
531
745
  // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
532
- const tokensIn = res.ok
533
- ? (res.usage?.promptTokens ?? tokensInEst)
534
- : tokensInEst;
746
+ const tokensIn = !actualCallMade
747
+ ? 0
748
+ : res.ok
749
+ ? (res.usage?.promptTokens ?? tokensInEst)
750
+ : tokensInEst;
535
751
  const tokensOut = res.ok
536
752
  ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
537
753
  : 0;
538
- const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
754
+ const estimatedCostUsd = estimateCostUsd(effectiveProvider ?? providerName, tokensIn, tokensOut);
539
755
  meter = {
540
756
  triggerCodes: gate.codes,
541
- llmCallMade: true,
757
+ llmCallMade: actualCallMade,
542
758
  tokensIn,
543
759
  tokensOut,
544
760
  estimatedCostUsd,
761
+ effectiveProvider,
762
+ effectiveModel: actualCallMade
763
+ ? (route?.effectiveModel ?? spec.model?.name)
764
+ : undefined,
765
+ // Direct is not necessarily BYO: the self-host runner and a non-routed
766
+ // hosted shared provider use this same path. Attribute only actual calls.
767
+ routeReason: route?.reason ?? (actualCallMade ? "configured_direct" : undefined),
768
+ routeAttempts: route?.attempts,
545
769
  };
546
770
  if (!res.ok) {
771
+ // Upstream 429s are expected provider backpressure, not evidence that the
772
+ // model is broken. A routed/BYO call may have reached the provider (so we
773
+ // retain the exact attempted model + usage receipt) and still finish with
774
+ // only capacity failures. Treat that exactly like a local capacity defer:
775
+ // no action, no fallback invented here, and no model-failure streak that
776
+ // could stop an otherwise healthy agent after repeated quota pressure.
777
+ const capacityOnlyFailure = (!route && res.status === 429) ||
778
+ (!!route?.attempts?.length &&
779
+ route.attempts.every((attempt) => attempt.failureClass === "capacity"));
780
+ if (res.deferred || !actualCallMade || capacityOnlyFailure) {
781
+ saveState(stateFile, state);
782
+ log(`capacity deferred: ${res.error}`);
783
+ return {
784
+ decision: "skip",
785
+ skipReason: actualCallMade
786
+ ? "provider rate-limited; retry next cycle"
787
+ : "provider capacity deferred",
788
+ planned: exitPlanned,
789
+ modelFailed: false,
790
+ live,
791
+ ...meter,
792
+ decisionType: "gate_skip",
793
+ writeAttempted: 0,
794
+ writeAccepted: 0,
795
+ ...observationReceipt,
796
+ };
797
+ }
547
798
  state.consecutiveModelFailures += 1;
548
- // Permanent-failure classification: a 404/model_not_found is a
799
+ // Permanent-failure classification: a 404/410/model_not_found is a
549
800
  // DECOMMISSIONED or misconfigured model that will fail every cycle
550
- // forever (live-measured: 93% of one agent's cycles for days, revived
551
- // 7x in 3h). Three consecutive occurrences rules out a routing fluke;
552
- // then disable with the 'model_unavailable' prefix the scheduler's
553
- // self-heal exempts. Transient errors reset the permanent streak.
801
+ // until something changes (live-measured 2026-08-26: NVIDIA EOL'd the
802
+ // whole Llama 3.x line and 35 agents died on the old disable path).
803
+ // Reliability slice 1: this class must NEVER disable the agent —
804
+ // provider failures are the PLATFORM's problem, not the user's. Three
805
+ // consecutive occurrences (rules out a routing fluke) now emit a
806
+ // providerHold: hosted, the scheduler folds holds into a fleet-wide
807
+ // (provider, model) circuit that skip-claims matching agents with
808
+ // backoff probes; self-host, the runner simply keeps retrying each
809
+ // cadence and recovers the moment the provider does. Disables remain
810
+ // for what deserves them: revoked credentials, drawdown, kill-switch,
811
+ // user action. Transient errors reset the permanent streak.
554
812
  if (isPermanentModelError(res.error)) {
555
813
  state.consecutivePermanentModelErrors =
556
814
  (state.consecutivePermanentModelErrors ?? 0) + 1;
557
815
  if (state.consecutivePermanentModelErrors >=
558
816
  PERMANENT_MODEL_ERROR_THRESHOLD) {
559
- state.disabled = true;
560
- state.disabledReason = `model_unavailable: ${res.error.slice(0, 160)}`;
817
+ const hold = {
818
+ provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
819
+ model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
820
+ error: res.error.slice(0, 200),
821
+ };
561
822
  saveState(stateFile, state);
562
- log(`disabled: ${state.disabledReason}`);
823
+ log(`provider hold: ${hold.provider}/${hold.model} — ${res.error.slice(0, 120)}`);
563
824
  return {
564
825
  decision: "skip",
565
- skipReason: `model error: ${res.error}`,
566
- planned: [],
826
+ skipReason: `provider hold: ${res.error}`,
827
+ planned: exitPlanned,
567
828
  modelFailed: true,
568
- disabled: true,
569
- disabledReason: state.disabledReason,
829
+ providerHold: hold,
570
830
  live,
571
831
  ...meter,
572
832
  decisionType: "model_error",
@@ -584,7 +844,7 @@ export async function runCycle(deps) {
584
844
  return {
585
845
  decision: "skip",
586
846
  skipReason: `model error: ${res.error}`,
587
- planned: [],
847
+ planned: exitPlanned,
588
848
  modelFailed: true,
589
849
  live,
590
850
  ...meter,
@@ -605,7 +865,7 @@ export async function runCycle(deps) {
605
865
  // Never persist raw model text (no-CoT privacy policy) — the parse error
606
866
  // in skipReason is the diagnostic; the malformed output is not stored.
607
867
  rawModelOutput: undefined,
608
- planned: [],
868
+ planned: exitPlanned,
609
869
  modelFailed: true,
610
870
  live,
611
871
  ...meter,
@@ -646,7 +906,7 @@ export async function runCycle(deps) {
646
906
  rationale,
647
907
  confidence,
648
908
  rawModelOutput,
649
- planned: [],
909
+ planned: exitPlanned,
650
910
  live,
651
911
  ...meter,
652
912
  decisionType: "skip",
@@ -658,13 +918,22 @@ export async function runCycle(deps) {
658
918
  }
659
919
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
660
920
  const planned = [];
661
- let writesThisCycle = 0;
921
+ let riskIncreasesThisCycle = 0;
662
922
  let openCount = observation.openPositions.length;
663
923
  // RUNNING totals so multiple opens in one cycle accumulate correctly.
664
924
  let openMarginMusd = observation.openPositions
665
925
  .filter((p) => p.venue === "futures")
666
926
  .reduce((s, p) => s + (p.marginMusd ?? 0), 0);
667
927
  let cashAvailableMusd = observation.cashAvailableMusd;
928
+ const capitalSizingEnabled = usesCapitalSizing(spec, providerName === "mechanical");
929
+ let committedCapitalMusd = observation.capitalBook?.status === "ready"
930
+ ? observation.capitalBook.committedCapitalMusd
931
+ : 0;
932
+ const capitalBudget = () => ({
933
+ cashAvailableMusd: cashAvailableMusd ?? Number.NaN,
934
+ committedCapitalMusd,
935
+ openMarginMusd,
936
+ });
668
937
  const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
669
938
  const targetedPositionIds = [];
670
939
  const targetedOrderIds = [];
@@ -672,6 +941,7 @@ export async function runCycle(deps) {
672
941
  let anyExecuted = false;
673
942
  let anyExecFailed = false;
674
943
  for (let action of decision.actions) {
944
+ let capitalSizing;
675
945
  // Resolve a short PM ref (pm1…pmN) to the canonical {source,slug,outcome} BEFORE
676
946
  // any quote/validation. Small models copy a 3-char ref reliably but mis-copy the
677
947
  // long outcomeExternalMarketId; an unknown/missing ref is rejected here with a
@@ -724,16 +994,19 @@ export async function runCycle(deps) {
724
994
  Number.isFinite(mkt.probability)
725
995
  ? Math.round(mkt.probability * 100)
726
996
  : undefined;
727
- // Anti-echo: an EXACT match on the market's integer probability is still
728
- // submitted (a forecast can legitimately agree) — but we LOG it so echo
729
- // rates stay observable; we never silently mutate the value. NOTE: the
997
+ // Anti-echo: an exact match on the market's integer probability is
998
+ // LOGGED here so echo rates stay observable, and the value is never
999
+ // silently mutated. The open itself no longer proceeds on it: an
1000
+ // echo is a zero-edge trade by the model's own numbers, so the
1001
+ // forecast-edge gate in decisionValidator rejects it downstream
1002
+ // (forecast_no_positive_edge). NOTE: the
730
1003
  // market-implied BENCHMARK agent (provider "mechanical", model.name
731
1004
  // "market-implied") echoes the market probability BY DESIGN — it IS the
732
1005
  // baseline definition — so a 100% echo rate there is expected, not a
733
1006
  // defect. Its agentModel/description say BENCHMARK so this log line is
734
1007
  // never mistaken for a mispriced skill agent.
735
1008
  if (marketPct != null && Math.round(fc) === marketPct) {
736
- log(`pm_open forecast ${fc} == market prob ${marketPct}% (echo) — submitting as-is`);
1009
+ log(`pm_open forecast ${fc} == market prob ${marketPct}% (echo) — zero claimed edge`);
737
1010
  }
738
1011
  action = { ...pm, forecastProbability: fc };
739
1012
  }
@@ -747,6 +1020,24 @@ export async function runCycle(deps) {
747
1020
  action = { ...pm, forecastProbability: undefined };
748
1021
  }
749
1022
  }
1023
+ if (capitalSizingEnabled) {
1024
+ const sized = prepareCapitalAction(action, spec, observation, capitalBudget());
1025
+ capitalSizing = sized.adjustment;
1026
+ if (sized.rejection) {
1027
+ planned.push({
1028
+ action,
1029
+ accepted: false,
1030
+ code: "capital_sizing_unavailable",
1031
+ reason: sized.rejection,
1032
+ capitalSizing,
1033
+ });
1034
+ log(`reject ${action.type}: capital sizing (${sized.rejection})`);
1035
+ continue;
1036
+ }
1037
+ action = sized.action;
1038
+ if (capitalSizing?.sizedAmountMusd !== undefined)
1039
+ log(`paper capital ${capitalSizing.version}: ${capitalSizing.proposedAmountMusd} -> ${capitalSizing.sizedAmountMusd}mUSD (${capitalSizing.basis})`);
1040
+ }
750
1041
  // Anti-churn critic: block re-opening a futures position we ALREADY hold unless
751
1042
  // it's a confirmed WINNER with room (a legit scale-in). Stops the re-open-a-
752
1043
  // loser / re-open-into-the-cap churn deterministically — before we even spend a
@@ -771,6 +1062,7 @@ export async function runCycle(deps) {
771
1062
  action,
772
1063
  accepted: false,
773
1064
  code: "duplicate_intent",
1065
+ ...(capitalSizing ? { capitalSizing } : {}),
774
1066
  reason: `already hold ${fo.symbol} ${fo.side}${winning ? " (no margin room to add)" : " — manage it, do not average down or re-open"}`,
775
1067
  });
776
1068
  log(`reject ${action.type}: duplicate_intent (hold ${fo.symbol} ${fo.side})`);
@@ -791,6 +1083,7 @@ export async function runCycle(deps) {
791
1083
  action,
792
1084
  accepted: false,
793
1085
  code: "pm_open_blocked",
1086
+ ...(capitalSizing ? { capitalSizing } : {}),
794
1087
  reason: `open-time quality gate would reject this (422): ${JSON.stringify(reasons)}`,
795
1088
  quote,
796
1089
  });
@@ -801,7 +1094,7 @@ export async function runCycle(deps) {
801
1094
  // off the stop, so the open isn't silently rejected server-side (the runner
802
1095
  // owns trigger orientation; weak models routinely mis-sign it).
803
1096
  {
804
- const fixed = repairFuturesTakeProfit(action, quote);
1097
+ const fixed = repairFuturesTakeProfit(action, quote, capitalSizingEnabled ? spec.capitalSizing?.minRewardRisk : undefined);
805
1098
  if (fixed.repaired) {
806
1099
  action = fixed.action;
807
1100
  log(`repaired ${action.type} take-profit -> ${action.takeProfitPrice} (R:R off stop; model TP was missing/wrong-side)`);
@@ -809,14 +1102,16 @@ export async function runCycle(deps) {
809
1102
  }
810
1103
  const ctx = {
811
1104
  spec,
1105
+ // Benchmarks submit a known forecast on purpose (see DecisionContext).
1106
+ mechanical: providerName === "mechanical",
812
1107
  // Inherit the decision-level confidence so the per-action abstention gate
813
1108
  // doesn't reject a model that reports conviction on the decision (the
814
1109
  // output contract) rather than on each action.
815
1110
  decisionConfidence: decision.confidence,
816
1111
  observation,
817
1112
  quote,
818
- writesThisCycle,
819
- writesToday: state.writesToday,
1113
+ riskIncreasesThisCycle,
1114
+ riskIncreasesToday: state.riskIncreasesToday,
820
1115
  openCount,
821
1116
  cashAvailableMusd,
822
1117
  openMarginMusd,
@@ -832,10 +1127,26 @@ export async function runCycle(deps) {
832
1127
  code: v.code,
833
1128
  reason: v.reason,
834
1129
  quote,
1130
+ ...(capitalSizing ? { capitalSizing } : {}),
835
1131
  });
836
1132
  log(`reject ${action.type}: ${v.code} (${v.reason})`);
837
1133
  continue;
838
1134
  }
1135
+ const capitalRejection = capitalSizingEnabled
1136
+ ? validateCapitalAction(action, spec, observation, capitalBudget(), quote)
1137
+ : undefined;
1138
+ if (capitalRejection) {
1139
+ planned.push({
1140
+ action,
1141
+ accepted: false,
1142
+ code: capitalRejection,
1143
+ reason: "paper capital policy rejected quoted economics",
1144
+ quote,
1145
+ capitalSizing,
1146
+ });
1147
+ log(`reject ${action.type}: ${capitalRejection}`);
1148
+ continue;
1149
+ }
839
1150
  anyAccepted = true;
840
1151
  if (action.type === "futures_close" || action.type === "futures_set_sltp") {
841
1152
  targetedPositionIds.push(action.positionId);
@@ -844,7 +1155,25 @@ export async function runCycle(deps) {
844
1155
  targetedOrderIds.push(action.orderId);
845
1156
  }
846
1157
  if (!live) {
847
- planned.push({ action, accepted: true, quote, executed: false });
1158
+ planned.push({
1159
+ action,
1160
+ accepted: true,
1161
+ quote,
1162
+ executed: false,
1163
+ ...(capitalSizing ? { capitalSizing } : {}),
1164
+ });
1165
+ // Opt-in simulations reserve the same accepted capital as paper-live.
1166
+ // No close/sell proceeds are credited until a future observed wallet read.
1167
+ if (capitalSizingEnabled && isRiskIncreasingAction(action)) {
1168
+ riskIncreasesThisCycle += 1;
1169
+ const spent = capitalCashCost(action, quote);
1170
+ cashAvailableMusd = (cashAvailableMusd ?? 0) - spent;
1171
+ committedCapitalMusd += spent;
1172
+ if (action.type === "futures_open") {
1173
+ openCount += 1;
1174
+ openMarginMusd += action.marginMusd;
1175
+ }
1176
+ }
848
1177
  log(`DRY-RUN: would ${action.type}`);
849
1178
  continue;
850
1179
  }
@@ -871,23 +1200,65 @@ export async function runCycle(deps) {
871
1200
  quote,
872
1201
  executed: r.ok,
873
1202
  result: r.data,
1203
+ ...(capitalSizing ? { capitalSizing } : {}),
874
1204
  });
875
1205
  if (r.ok) {
876
1206
  anyExecuted = true;
877
1207
  state.intentSeq[intentKey] = seq + 1;
878
- writesThisCycle += 1;
879
1208
  state.writesToday += 1;
1209
+ if (isRiskIncreasingAction(action)) {
1210
+ riskIncreasesThisCycle += 1;
1211
+ state.riskIncreasesToday += 1;
1212
+ }
880
1213
  if (action.type === "futures_open") {
881
1214
  openCount += 1;
882
1215
  openMarginMusd += action.marginMusd;
883
1216
  }
1217
+ // Slice 2: persist the thesis this open was made on, keyed to the position
1218
+ // the server returned and sanitized side-aware (a wrong-side level would
1219
+ // fire on the next tick). An add to a held position keeps the ORIGINAL
1220
+ // thesis: restating a looser one on a loser is the churn this ends.
1221
+ if (action.type === "futures_open" || action.type === "pm_open") {
1222
+ const { key, bound } = bindOpenedThesis(action, r.data, observation.asOf, quote);
1223
+ if (key && state.theses?.[key]) {
1224
+ log(`thesis: ${key} keeps its original thesis (add)`);
1225
+ }
1226
+ else if (key && bound) {
1227
+ rememberThesis(state, bound.thesis);
1228
+ log(`thesis bound to ${key}: "${bound.thesis.summary}"` +
1229
+ (bound.notes.length > 0 ? ` (${bound.notes.join("; ")})` : ""));
1230
+ }
1231
+ else if (key) {
1232
+ log(`thesis: none stated for ${key}; only its stop/target will exit it`);
1233
+ }
1234
+ }
884
1235
  // Decrement running cash by what this action consumed (futures margin /
885
1236
  // spot buy notional / PM stake) so a later action this cycle sees it spent.
886
1237
  if (cashAvailableMusd != null)
887
- cashAvailableMusd -= cashConsumed(action, quote);
1238
+ cashAvailableMusd -= capitalSizingEnabled
1239
+ ? capitalCashCost(action, quote)
1240
+ : cashConsumed(action, quote);
1241
+ if (capitalSizingEnabled)
1242
+ committedCapitalMusd += capitalCashCost(action, quote);
888
1243
  }
889
1244
  else {
890
1245
  anyExecFailed = true;
1246
+ // A transport/server failure does not prove an entry stayed unfilled.
1247
+ // Reserve its possible spend for subsequent actions this cycle, without
1248
+ // recording a confirmed trade or advancing its idempotency sequence.
1249
+ if (capitalSizingEnabled &&
1250
+ isRiskIncreasingAction(action) &&
1251
+ (r.status <= 0 || r.status >= 500)) {
1252
+ riskIncreasesThisCycle += 1;
1253
+ const spent = capitalCashCost(action, quote);
1254
+ cashAvailableMusd = (cashAvailableMusd ?? 0) - spent;
1255
+ committedCapitalMusd += spent;
1256
+ if (action.type === "futures_open") {
1257
+ openCount += 1;
1258
+ openMarginMusd += action.marginMusd;
1259
+ }
1260
+ log(`paper capital reserved after ambiguous ${action.type} response`);
1261
+ }
891
1262
  // Quote-expiry capture: a validated pm_open the SERVER rejected at act time
892
1263
  // with a 422 mock_entry_blocked — the eligibility/quality/pricing state moved
893
1264
  // between the quote we validated and the act, so the quote expired. Report it
@@ -940,7 +1311,7 @@ export async function runCycle(deps) {
940
1311
  rationale,
941
1312
  confidence,
942
1313
  rawModelOutput,
943
- planned,
1314
+ planned: [...exitPlanned, ...planned],
944
1315
  live,
945
1316
  ...meter,
946
1317
  decisionType: "act",