@coinrithm/mcp-trading 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,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,31 +157,96 @@ 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)}`;
180
205
  }
181
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
+ };
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
+ };
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
184
252
  // name) so they return none and keep their position-management rationale as-is.
@@ -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,
@@ -523,6 +723,8 @@ export async function runCycle(deps) {
523
723
  });
524
724
  const user = buildUserPrompt(observation, state.journal, {
525
725
  venues: spec.venues,
726
+ dailyRiskBudget: buildDailyRiskBudget(spec, state),
727
+ ...(usesCapitalSizing(spec) ? { capitalSizing: spec.capitalSizing } : {}),
526
728
  });
527
729
  const tokensInEst = Math.round((system.length + user.length) / 4);
528
730
  // Prompt-size + trigger visibility in the live terminal.
@@ -532,7 +734,7 @@ export async function runCycle(deps) {
532
734
  const route = res.route;
533
735
  const actualCallMade = route
534
736
  ? route.attempts.some((attempt) => attempt.outcome !== "deferred")
535
- : true;
737
+ : res.ok || !res.deferred;
536
738
  // Capacity deferral made no provider call, so it must not consume the
537
739
  // runner's debounce/LLM budget or delay recovery after capacity returns.
538
740
  if (actualCallMade)
@@ -560,17 +762,30 @@ export async function runCycle(deps) {
560
762
  effectiveModel: actualCallMade
561
763
  ? (route?.effectiveModel ?? spec.model?.name)
562
764
  : undefined,
563
- routeReason: route?.reason,
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),
564
768
  routeAttempts: route?.attempts,
565
769
  };
566
770
  if (!res.ok) {
567
- if (res.deferred || !actualCallMade) {
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) {
568
781
  saveState(stateFile, state);
569
782
  log(`capacity deferred: ${res.error}`);
570
783
  return {
571
784
  decision: "skip",
572
- skipReason: "provider capacity deferred",
573
- planned: [],
785
+ skipReason: actualCallMade
786
+ ? "provider rate-limited; retry next cycle"
787
+ : "provider capacity deferred",
788
+ planned: exitPlanned,
574
789
  modelFailed: false,
575
790
  live,
576
791
  ...meter,
@@ -609,7 +824,7 @@ export async function runCycle(deps) {
609
824
  return {
610
825
  decision: "skip",
611
826
  skipReason: `provider hold: ${res.error}`,
612
- planned: [],
827
+ planned: exitPlanned,
613
828
  modelFailed: true,
614
829
  providerHold: hold,
615
830
  live,
@@ -629,7 +844,7 @@ export async function runCycle(deps) {
629
844
  return {
630
845
  decision: "skip",
631
846
  skipReason: `model error: ${res.error}`,
632
- planned: [],
847
+ planned: exitPlanned,
633
848
  modelFailed: true,
634
849
  live,
635
850
  ...meter,
@@ -650,7 +865,7 @@ export async function runCycle(deps) {
650
865
  // Never persist raw model text (no-CoT privacy policy) — the parse error
651
866
  // in skipReason is the diagnostic; the malformed output is not stored.
652
867
  rawModelOutput: undefined,
653
- planned: [],
868
+ planned: exitPlanned,
654
869
  modelFailed: true,
655
870
  live,
656
871
  ...meter,
@@ -691,7 +906,7 @@ export async function runCycle(deps) {
691
906
  rationale,
692
907
  confidence,
693
908
  rawModelOutput,
694
- planned: [],
909
+ planned: exitPlanned,
695
910
  live,
696
911
  ...meter,
697
912
  decisionType: "skip",
@@ -703,13 +918,22 @@ export async function runCycle(deps) {
703
918
  }
704
919
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
705
920
  const planned = [];
706
- let writesThisCycle = 0;
921
+ let riskIncreasesThisCycle = 0;
707
922
  let openCount = observation.openPositions.length;
708
923
  // RUNNING totals so multiple opens in one cycle accumulate correctly.
709
924
  let openMarginMusd = observation.openPositions
710
925
  .filter((p) => p.venue === "futures")
711
926
  .reduce((s, p) => s + (p.marginMusd ?? 0), 0);
712
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
+ });
713
937
  const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
714
938
  const targetedPositionIds = [];
715
939
  const targetedOrderIds = [];
@@ -717,6 +941,7 @@ export async function runCycle(deps) {
717
941
  let anyExecuted = false;
718
942
  let anyExecFailed = false;
719
943
  for (let action of decision.actions) {
944
+ let capitalSizing;
720
945
  // Resolve a short PM ref (pm1…pmN) to the canonical {source,slug,outcome} BEFORE
721
946
  // any quote/validation. Small models copy a 3-char ref reliably but mis-copy the
722
947
  // long outcomeExternalMarketId; an unknown/missing ref is rejected here with a
@@ -769,16 +994,19 @@ export async function runCycle(deps) {
769
994
  Number.isFinite(mkt.probability)
770
995
  ? Math.round(mkt.probability * 100)
771
996
  : undefined;
772
- // Anti-echo: an EXACT match on the market's integer probability is still
773
- // submitted (a forecast can legitimately agree) — but we LOG it so echo
774
- // 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
775
1003
  // market-implied BENCHMARK agent (provider "mechanical", model.name
776
1004
  // "market-implied") echoes the market probability BY DESIGN — it IS the
777
1005
  // baseline definition — so a 100% echo rate there is expected, not a
778
1006
  // defect. Its agentModel/description say BENCHMARK so this log line is
779
1007
  // never mistaken for a mispriced skill agent.
780
1008
  if (marketPct != null && Math.round(fc) === marketPct) {
781
- 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`);
782
1010
  }
783
1011
  action = { ...pm, forecastProbability: fc };
784
1012
  }
@@ -792,6 +1020,24 @@ export async function runCycle(deps) {
792
1020
  action = { ...pm, forecastProbability: undefined };
793
1021
  }
794
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
+ }
795
1041
  // Anti-churn critic: block re-opening a futures position we ALREADY hold unless
796
1042
  // it's a confirmed WINNER with room (a legit scale-in). Stops the re-open-a-
797
1043
  // loser / re-open-into-the-cap churn deterministically — before we even spend a
@@ -816,6 +1062,7 @@ export async function runCycle(deps) {
816
1062
  action,
817
1063
  accepted: false,
818
1064
  code: "duplicate_intent",
1065
+ ...(capitalSizing ? { capitalSizing } : {}),
819
1066
  reason: `already hold ${fo.symbol} ${fo.side}${winning ? " (no margin room to add)" : " — manage it, do not average down or re-open"}`,
820
1067
  });
821
1068
  log(`reject ${action.type}: duplicate_intent (hold ${fo.symbol} ${fo.side})`);
@@ -836,6 +1083,7 @@ export async function runCycle(deps) {
836
1083
  action,
837
1084
  accepted: false,
838
1085
  code: "pm_open_blocked",
1086
+ ...(capitalSizing ? { capitalSizing } : {}),
839
1087
  reason: `open-time quality gate would reject this (422): ${JSON.stringify(reasons)}`,
840
1088
  quote,
841
1089
  });
@@ -846,7 +1094,7 @@ export async function runCycle(deps) {
846
1094
  // off the stop, so the open isn't silently rejected server-side (the runner
847
1095
  // owns trigger orientation; weak models routinely mis-sign it).
848
1096
  {
849
- const fixed = repairFuturesTakeProfit(action, quote);
1097
+ const fixed = repairFuturesTakeProfit(action, quote, capitalSizingEnabled ? spec.capitalSizing?.minRewardRisk : undefined);
850
1098
  if (fixed.repaired) {
851
1099
  action = fixed.action;
852
1100
  log(`repaired ${action.type} take-profit -> ${action.takeProfitPrice} (R:R off stop; model TP was missing/wrong-side)`);
@@ -854,14 +1102,16 @@ export async function runCycle(deps) {
854
1102
  }
855
1103
  const ctx = {
856
1104
  spec,
1105
+ // Benchmarks submit a known forecast on purpose (see DecisionContext).
1106
+ mechanical: providerName === "mechanical",
857
1107
  // Inherit the decision-level confidence so the per-action abstention gate
858
1108
  // doesn't reject a model that reports conviction on the decision (the
859
1109
  // output contract) rather than on each action.
860
1110
  decisionConfidence: decision.confidence,
861
1111
  observation,
862
1112
  quote,
863
- writesThisCycle,
864
- writesToday: state.writesToday,
1113
+ riskIncreasesThisCycle,
1114
+ riskIncreasesToday: state.riskIncreasesToday,
865
1115
  openCount,
866
1116
  cashAvailableMusd,
867
1117
  openMarginMusd,
@@ -877,10 +1127,26 @@ export async function runCycle(deps) {
877
1127
  code: v.code,
878
1128
  reason: v.reason,
879
1129
  quote,
1130
+ ...(capitalSizing ? { capitalSizing } : {}),
880
1131
  });
881
1132
  log(`reject ${action.type}: ${v.code} (${v.reason})`);
882
1133
  continue;
883
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
+ }
884
1150
  anyAccepted = true;
885
1151
  if (action.type === "futures_close" || action.type === "futures_set_sltp") {
886
1152
  targetedPositionIds.push(action.positionId);
@@ -889,7 +1155,25 @@ export async function runCycle(deps) {
889
1155
  targetedOrderIds.push(action.orderId);
890
1156
  }
891
1157
  if (!live) {
892
- 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
+ }
893
1177
  log(`DRY-RUN: would ${action.type}`);
894
1178
  continue;
895
1179
  }
@@ -916,23 +1200,65 @@ export async function runCycle(deps) {
916
1200
  quote,
917
1201
  executed: r.ok,
918
1202
  result: r.data,
1203
+ ...(capitalSizing ? { capitalSizing } : {}),
919
1204
  });
920
1205
  if (r.ok) {
921
1206
  anyExecuted = true;
922
1207
  state.intentSeq[intentKey] = seq + 1;
923
- writesThisCycle += 1;
924
1208
  state.writesToday += 1;
1209
+ if (isRiskIncreasingAction(action)) {
1210
+ riskIncreasesThisCycle += 1;
1211
+ state.riskIncreasesToday += 1;
1212
+ }
925
1213
  if (action.type === "futures_open") {
926
1214
  openCount += 1;
927
1215
  openMarginMusd += action.marginMusd;
928
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
+ }
929
1235
  // Decrement running cash by what this action consumed (futures margin /
930
1236
  // spot buy notional / PM stake) so a later action this cycle sees it spent.
931
1237
  if (cashAvailableMusd != null)
932
- cashAvailableMusd -= cashConsumed(action, quote);
1238
+ cashAvailableMusd -= capitalSizingEnabled
1239
+ ? capitalCashCost(action, quote)
1240
+ : cashConsumed(action, quote);
1241
+ if (capitalSizingEnabled)
1242
+ committedCapitalMusd += capitalCashCost(action, quote);
933
1243
  }
934
1244
  else {
935
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
+ }
936
1262
  // Quote-expiry capture: a validated pm_open the SERVER rejected at act time
937
1263
  // with a 422 mock_entry_blocked — the eligibility/quality/pricing state moved
938
1264
  // between the quote we validated and the act, so the quote expired. Report it
@@ -985,7 +1311,7 @@ export async function runCycle(deps) {
985
1311
  rationale,
986
1312
  confidence,
987
1313
  rawModelOutput,
988
- planned,
1314
+ planned: [...exitPlanned, ...planned],
989
1315
  live,
990
1316
  ...meter,
991
1317
  decisionType: "act",