@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.
- package/CHANGELOG.md +83 -0
- 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/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -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 +74 -3
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/observe.js +175 -34
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +11 -1
- package/dist/agent/prompt.js +135 -7
- package/dist/agent/providerCapabilities.d.ts +3 -0
- package/dist/agent/providerCapabilities.js +41 -3
- package/dist/agent/providers.js +119 -73
- package/dist/agent/resolve.js +1 -0
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +360 -34
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +17 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +56 -0
- package/dist/agent/state.js +6 -0
- package/dist/agent/strictLint.js +19 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +127 -0
- 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,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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
573
|
-
|
|
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
|
|
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
|
|
773
|
-
//
|
|
774
|
-
//
|
|
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) —
|
|
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
|
-
|
|
864
|
-
|
|
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({
|
|
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 -=
|
|
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",
|