@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.
- package/CHANGELOG.md +134 -0
- package/README.md +37 -8
- package/dist/agent/act.js +19 -6
- package/dist/agent/capitalSizing.d.ts +32 -0
- package/dist/agent/capitalSizing.js +257 -0
- package/dist/agent/client.d.ts +2 -0
- package/dist/agent/client.js +4 -0
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionProbe.d.ts +17 -0
- package/dist/agent/decisionProbe.js +70 -0
- package/dist/agent/decisionReceipt.d.ts +45 -0
- package/dist/agent/decisionReceipt.js +595 -0
- package/dist/agent/decisionValidator.d.ts +19 -2
- package/dist/agent/decisionValidator.js +97 -3
- package/dist/agent/engine.d.ts +5 -1
- package/dist/agent/engine.js +8 -1
- package/dist/agent/observe.js +194 -35
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +14 -2
- package/dist/agent/prompt.js +232 -35
- package/dist/agent/providerCapabilities.d.ts +23 -0
- package/dist/agent/providerCapabilities.js +105 -0
- package/dist/agent/providers.d.ts +29 -1
- package/dist/agent/providers.js +159 -88
- package/dist/agent/resolve.d.ts +1 -1
- package/dist/agent/resolve.js +21 -1
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +418 -47
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +23 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +63 -0
- package/dist/agent/state.js +7 -1
- package/dist/agent/strictLint.js +20 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +151 -0
- package/dist/http.js +21 -0
- package/dist/tools.js +10 -10
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
533
|
-
?
|
|
534
|
-
:
|
|
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:
|
|
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
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
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
|
-
|
|
560
|
-
|
|
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(`
|
|
823
|
+
log(`provider hold: ${hold.provider}/${hold.model} — ${res.error.slice(0, 120)}`);
|
|
563
824
|
return {
|
|
564
825
|
decision: "skip",
|
|
565
|
-
skipReason: `
|
|
566
|
-
planned:
|
|
826
|
+
skipReason: `provider hold: ${res.error}`,
|
|
827
|
+
planned: exitPlanned,
|
|
567
828
|
modelFailed: true,
|
|
568
|
-
|
|
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
|
|
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
|
|
728
|
-
//
|
|
729
|
-
//
|
|
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) —
|
|
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
|
-
|
|
819
|
-
|
|
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({
|
|
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 -=
|
|
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",
|