@coinrithm/mcp-trading 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +9 -7
  3. package/dist/agent/act.d.ts +2 -2
  4. package/dist/agent/act.js +24 -3
  5. package/dist/agent/cli.js +59 -18
  6. package/dist/agent/client.d.ts +33 -0
  7. package/dist/agent/client.js +34 -7
  8. package/dist/agent/decision.d.ts +3 -0
  9. package/dist/agent/decision.js +26 -3
  10. package/dist/agent/decisionValidator.js +2 -1
  11. package/dist/agent/deploymentOverlay.js +25 -5
  12. package/dist/agent/engine.d.ts +2 -1
  13. package/dist/agent/engine.js +4 -1
  14. package/dist/agent/extract.js +3 -1
  15. package/dist/agent/gate.js +25 -5
  16. package/dist/agent/index.js +0 -1
  17. package/dist/agent/indicators.js +4 -2
  18. package/dist/agent/manifest.js +1 -1
  19. package/dist/agent/mechanical.d.ts +36 -0
  20. package/dist/agent/mechanical.js +286 -0
  21. package/dist/agent/observe.js +120 -52
  22. package/dist/agent/prompt.d.ts +3 -1
  23. package/dist/agent/prompt.js +17 -6
  24. package/dist/agent/providers.js +39 -4
  25. package/dist/agent/resolve.js +23 -6
  26. package/dist/agent/resolvePm.js +14 -3
  27. package/dist/agent/runEvidence.js +6 -2
  28. package/dist/agent/runner.d.ts +8 -2
  29. package/dist/agent/runner.js +363 -59
  30. package/dist/agent/scorecard.js +12 -4
  31. package/dist/agent/setups.js +57 -9
  32. package/dist/agent/skill.js +1 -1
  33. package/dist/agent/state.js +9 -4
  34. package/dist/agent/types.d.ts +17 -2
  35. package/dist/agent/types.js +2 -1
  36. package/dist/agent/util.js +11 -4
  37. package/dist/agent/version.d.ts +1 -1
  38. package/dist/agent/version.js +1 -1
  39. package/dist/client.d.ts +33 -0
  40. package/dist/client.js +12 -3
  41. package/dist/executionPolicy.d.ts +2 -0
  42. package/dist/executionPolicy.js +21 -0
  43. package/dist/http.js +10 -2
  44. package/dist/tools.d.ts +1 -0
  45. package/dist/tools.js +214 -29
  46. package/package.json +9 -1
@@ -2,7 +2,9 @@
2
2
  // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
3
  // idempotency keys + agentTrace and exports run evidence. The client + provider
4
4
  // are injected so the loop is fully unit-testable with no network/model calls.
5
+ import { COINRITHM_API } from "./version.js";
5
6
  import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
7
+ import { decideMechanical } from "./mechanical.js";
6
8
  import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
7
9
  import { baseSymbol } from "./setups.js";
8
10
  import { observe } from "./observe.js";
@@ -15,6 +17,70 @@ import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
15
17
  import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
16
18
  import { asObj, asNum, asStr } from "./extract.js";
17
19
  import { parseCadenceMs, sleep } from "./util.js";
20
+ // Independent-forecast kill-switch. Default ON: the fleet elicits + submits its
21
+ // OWN forecastProbability on PM opens. Set HOUSE_AGENT_FORECAST_ENABLED to
22
+ // "false"/"0"/"no"/"off" to ship pm/open requests WITHOUT the field, byte-identical
23
+ // to pre-forecast behavior (prompt extension also drops out). Read at CALL TIME
24
+ // (not module-load) so a process env / a test can toggle it without re-import.
25
+ export function houseAgentForecastEnabled() {
26
+ const v = (process.env.HOUSE_AGENT_FORECAST_ENABLED ?? "")
27
+ .trim()
28
+ .toLowerCase();
29
+ return !["false", "0", "no", "off"].includes(v);
30
+ }
31
+ // Opportunity-capture kill-switch. Default ON: the runner reports the NON-opened
32
+ // opportunities the trade paths cannot see — the model ABSTAINING while PM markets
33
+ // were listed, forecasting WITHOUT trading (forecast_only), or a validated pm_open
34
+ // whose quote EXPIRED at act time (quote_expired) — so the public evaluation is not
35
+ // selection-biased toward opened trades. Mirrors the backend env name; set
36
+ // AGENT_OPPORTUNITY_CAPTURE_ENABLED to "false"/"0"/"no"/"off" to disable. Read at
37
+ // CALL TIME so a process env / a test can toggle it without re-import. Only ever
38
+ // posts on a LIVE cycle (dry-run never writes), at most ONCE per cycle.
39
+ export function agentOpportunityCaptureEnabled() {
40
+ const v = (process.env.AGENT_OPPORTUNITY_CAPTURE_ENABLED ?? "")
41
+ .trim()
42
+ .toLowerCase();
43
+ return !["false", "0", "no", "off"].includes(v);
44
+ }
45
+ // The runner's SELF-REPORTED runtime kind. This is the npm coinrithm-agent runner,
46
+ // so the honest default is "self_host_runner". When the house scheduler spawns the
47
+ // runner it may set COINRITHM_RUNTIME_KIND=hosted_scheduler — a purely DESCRIPTIVE
48
+ // label that carries NO trust (the backend forces providerVerified=false for every
49
+ // keyed caller regardless), so it can never be used to fake verification. Any
50
+ // unrecognized value falls back to self_host_runner.
51
+ export function runnerRuntimeKind() {
52
+ const v = (process.env.COINRITHM_RUNTIME_KIND ?? "").trim().toLowerCase();
53
+ return v === "hosted_scheduler" ? "hosted_scheduler" : "self_host_runner";
54
+ }
55
+ // Build the runner's REAL provenance from what it honestly knows: its runtime kind,
56
+ // the published package version (read from package.json via version.ts), and the
57
+ // self-reported model provider/name from the resolved agent spec. Everything the
58
+ // runner cannot truthfully attest (bundle/skill/prompt/config hashes, evidence refs)
59
+ // is OMITTED — absent > fabricated. Sending this block makes the artifact v2; the
60
+ // server still stamps the policy versions + providerVerified itself.
61
+ export function buildRunnerProvenance(spec) {
62
+ const prov = {
63
+ runtimeKind: runnerRuntimeKind(),
64
+ packageVersion: COINRITHM_API.mcpVersion,
65
+ };
66
+ if (spec.model?.provider)
67
+ prov.modelProvider = spec.model.provider;
68
+ if (spec.model?.name)
69
+ prov.modelName = spec.model.name;
70
+ return prov;
71
+ }
72
+ // Clamp a model-proposed forecast to the backend's exclusive (0,100) rail as a
73
+ // whole-or-one-decimal value in [1,99]. Returns undefined for missing / null /
74
+ // NaN / non-finite input (absent > fake — the trade proceeds WITHOUT the field;
75
+ // the value is NEVER defaulted to the market price / entryProbability). The
76
+ // decision parser already tolerates non-numeric model output down to undefined,
77
+ // so this mainly enforces the numeric range.
78
+ export function sanitizeForecastProbability(raw) {
79
+ if (typeof raw !== "number" || !Number.isFinite(raw))
80
+ return undefined;
81
+ const clamped = Math.min(99, Math.max(1, raw));
82
+ return Math.round(clamped * 10) / 10; // one-decimal precision
83
+ }
18
84
  // A stable idempotency-key component per distinct intent (so a lost response
19
85
  // replays rather than re-trades, but a genuinely new intent gets a new key).
20
86
  function intentKeyOf(action) {
@@ -154,9 +220,98 @@ export function rationaleForAction(a, decisionRationale, perActionSummary, total
154
220
  ? decisionRationale
155
221
  : summarizeAction(a);
156
222
  }
223
+ // A market's current probability (stored 0..1) as whole percentage POINTS (0..100),
224
+ // the basis the opportunity/decision record uses. undefined when not reported.
225
+ function marketPct(prob) {
226
+ return typeof prob === "number" && Number.isFinite(prob)
227
+ ? Math.round(prob * 100)
228
+ : undefined;
229
+ }
230
+ // Find the discovered market matching a resolved pm_open triple (case-insensitive
231
+ // source/slug, exact outcome id) — for the observed market probability.
232
+ function findPmMarket(pmMarkets, ref) {
233
+ return pmMarkets.find((m) => (m.source ?? "").toLowerCase() === (ref.source ?? "").toLowerCase() &&
234
+ (m.slug ?? "").toLowerCase() === (ref.slug ?? "").toLowerCase() &&
235
+ m.outcomeExternalMarketId === ref.outcomeExternalMarketId);
236
+ }
237
+ // Build the NON-opened opportunity for a MODEL-SKIP cycle, or null when there is no
238
+ // PM universe to report on. The top listed market is the subject and the universe
239
+ // breadth rides in universeSize so ONE post captures the whole cohort (never one
240
+ // per market). A pm_open the model listed WITH a usable forecast makes it
241
+ // forecast_only (it produced a probability but chose not to trade); otherwise it is
242
+ // a plain abstention. NEVER defaults the forecast to the market price.
243
+ //
244
+ // NOTE: parseDecision() clears a pure `skip` decision's actions to [] (an act with
245
+ // actions goes down the act path instead), so in the live runner this yields
246
+ // `abstained`. The forecast_only rule is exercised whenever a decision reaches here
247
+ // WITH surviving actions (kept faithful to the capture spec + future-proof; covered
248
+ // directly by the unit test).
249
+ export function buildSkipOpportunity(decision, pmMarkets, forecastEnabled) {
250
+ if (pmMarkets.length === 0)
251
+ return null;
252
+ const universeSize = pmMarkets.length;
253
+ const reasonCode = decision.reason?.trim() || undefined;
254
+ const pmAction = decision.actions.find((a) => a.type === "pm_open");
255
+ if (forecastEnabled && pmAction) {
256
+ const fc = sanitizeForecastProbability(pmAction.forecastProbability);
257
+ if (fc != null) {
258
+ // Resolve the forecasted market for an honest subject; fall back to the top
259
+ // listed market when the ref can't be resolved (still a valid forecast_only).
260
+ const resolved = resolvePmRef(pmAction, pmMarkets);
261
+ const subject = (resolved.ok ? findPmMarket(pmMarkets, resolved.action) : undefined) ??
262
+ pmMarkets[0];
263
+ return {
264
+ kind: "forecast_only",
265
+ source: subject.source,
266
+ slug: subject.slug,
267
+ outcomeExternalMarketId: subject.outcomeExternalMarketId,
268
+ universeSize,
269
+ forecastProbability: fc,
270
+ marketProbability: marketPct(subject.probability),
271
+ reasonCode,
272
+ };
273
+ }
274
+ }
275
+ const top = pmMarkets[0];
276
+ return {
277
+ kind: "abstained",
278
+ source: top.source,
279
+ slug: top.slug,
280
+ outcomeExternalMarketId: top.outcomeExternalMarketId,
281
+ universeSize,
282
+ marketProbability: marketPct(top.probability),
283
+ reasonCode,
284
+ };
285
+ }
286
+ // A validated pm_open that FAILED at act with quote-expiry semantics — the server
287
+ // rejected the open with a 422 mock_entry_blocked, i.e. the eligibility/quality/
288
+ // pricing state moved between the quote the runner validated and the act, so the
289
+ // quote it acted on had effectively expired. Distinct from a risk/balance 422
290
+ // (insufficient_balance), which is not a quote-expiry.
291
+ function isQuoteExpiredResult(status, data) {
292
+ if (status !== 422)
293
+ return false;
294
+ const d = asObj(data);
295
+ return asStr(d.error) === "mock_entry_blocked";
296
+ }
297
+ // Joined server block reasons (reasonCode) for a quote_expired opportunity.
298
+ function blockReasonsOf(data) {
299
+ const d = asObj(data);
300
+ const reasons = Array.isArray(d.blockReasons)
301
+ ? d.blockReasons.map(String).filter((s) => s.length > 0)
302
+ : [];
303
+ return reasons.length > 0 ? reasons.join(",") : undefined;
304
+ }
157
305
  export async function runCycle(deps) {
158
306
  const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
159
307
  const log = deps.log ?? (() => { });
308
+ // One flag read per cycle governs BOTH the prompt extension and the submission,
309
+ // so they can never diverge (prompt asks for it iff we would submit it).
310
+ const forecastEnabled = houseAgentForecastEnabled();
311
+ // The runner's REAL provenance for this cycle (runtime kind + package version +
312
+ // self-reported model). Attached to every durable-artifact write (pm_open,
313
+ // opportunity) so the artifact records WHAT RAN. Constant per cycle.
314
+ const provenance = buildRunnerProvenance(spec);
160
315
  state.cyclesRun += 1;
161
316
  rollDay(state);
162
317
  // Kill-switch pre-check: a disabled agent never observes, decides, or acts.
@@ -177,6 +332,42 @@ export async function runCycle(deps) {
177
332
  const runId = state.runId;
178
333
  const decisionId = makeDecisionId(state.cyclesRun);
179
334
  const baseTrace = makeTrace(runId, decisionId, spec);
335
+ // Opportunity capture (kills evaluation selection bias). Post at most ONE
336
+ // non-opened opportunity per cycle, LIVE only (dry-run never writes), best-effort
337
+ // — a failed post never affects the cycle result. The latch is set BEFORE the
338
+ // await so a failure never retries within the cycle (respects the write budget);
339
+ // the cohort/universe field carries the breadth, so we never post per-market.
340
+ const captureOpportunity = agentOpportunityCaptureEnabled();
341
+ let opportunityPosted = false;
342
+ let postedOpportunity;
343
+ const postOpportunity = async (o) => {
344
+ if (!captureOpportunity || !live || opportunityPosted)
345
+ return;
346
+ opportunityPosted = true;
347
+ postedOpportunity = o;
348
+ try {
349
+ await client.reportPmOpportunity({
350
+ kind: o.kind,
351
+ source: o.source,
352
+ slug: o.slug,
353
+ outcomeExternalMarketId: o.outcomeExternalMarketId,
354
+ forecastProbability: o.forecastProbability,
355
+ marketProbability: o.marketProbability,
356
+ reasonCode: o.reasonCode,
357
+ cohort: {
358
+ universeSize: o.universeSize,
359
+ horizon: spec.objective?.horizon,
360
+ },
361
+ decisionId,
362
+ runId,
363
+ provenance,
364
+ }, baseTrace);
365
+ log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
366
+ }
367
+ catch (err) {
368
+ log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
369
+ }
370
+ };
180
371
  // OBSERVE
181
372
  const obs = await observe(client, spec, state, baseTrace);
182
373
  const observation = obs.observation;
@@ -262,68 +453,97 @@ export async function runCycle(deps) {
262
453
  writeAccepted: 0,
263
454
  };
264
455
  }
265
- noteLlmCall(state, gate.codes, nowMs);
266
- // DECIDE
267
- const system = buildSystemPrompt(spec, mergedProse);
268
- const user = buildUserPrompt(observation, state.journal);
269
- const tokensInEst = Math.round((system.length + user.length) / 4);
270
- // Prompt-size + trigger visibility in the live terminal.
271
- log(`prompt ~${tokensInEst} tok ` +
272
- `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
273
- const res = await provider.decide({ system, user });
274
- // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
275
- const tokensIn = res.ok
276
- ? (res.usage?.promptTokens ?? tokensInEst)
277
- : tokensInEst;
278
- const tokensOut = res.ok
279
- ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
280
- : 0;
281
- const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
282
- const meter = {
283
- triggerCodes: gate.codes,
284
- llmCallMade: true,
285
- tokensIn,
286
- tokensOut,
287
- estimatedCostUsd,
288
- };
289
- if (!res.ok) {
290
- state.consecutiveModelFailures += 1;
291
- saveState(stateFile, state);
292
- log(`model error: ${res.error}`);
293
- return {
294
- decision: "skip",
295
- skipReason: `model error: ${res.error}`,
296
- planned: [],
297
- modelFailed: true,
298
- live,
299
- ...meter,
300
- decisionType: "model_error",
301
- writeAttempted: 0,
302
- writeAccepted: 0,
456
+ // DECIDE. Two paths share the same downstream validate+act loop:
457
+ // mechanical benchmark agents (provider "mechanical") compute the decision
458
+ // deterministically from the observation — no prompt, no model call, no
459
+ // inference cost. They never touch the LLM budget/debounce (noteLlmCall).
460
+ // • every other agent asks its BYO model.
461
+ let decision;
462
+ let meter;
463
+ if (providerName === "mechanical") {
464
+ const mech = decideMechanical({
465
+ strategy: spec.model?.name ?? "",
466
+ observation,
467
+ dateKey: state.dayKey,
468
+ });
469
+ for (const l of mech.log)
470
+ log(l);
471
+ decision = mech.decision;
472
+ state.consecutiveModelFailures = 0;
473
+ // Zero-cost cycle: a mechanical decision made no LLM call.
474
+ meter = {
475
+ triggerCodes: gate.codes,
476
+ llmCallMade: false,
477
+ tokensIn: 0,
478
+ tokensOut: 0,
479
+ estimatedCostUsd: 0,
303
480
  };
304
481
  }
305
- const parsed = parseDecision(res.text);
306
- if (!parsed.ok) {
307
- state.consecutiveModelFailures += 1;
308
- saveState(stateFile, state);
309
- log(`model output invalid: ${parsed.error}`);
310
- return {
311
- decision: "skip",
312
- skipReason: `model output invalid: ${parsed.error}`,
313
- // Never persist raw model text (no-CoT privacy policy) the parse error
314
- // in skipReason is the diagnostic; the malformed output is not stored.
315
- rawModelOutput: undefined,
316
- planned: [],
317
- modelFailed: true,
318
- live,
319
- ...meter,
320
- decisionType: "model_error",
321
- writeAttempted: 0,
322
- writeAccepted: 0,
482
+ else {
483
+ noteLlmCall(state, gate.codes, nowMs);
484
+ const system = buildSystemPrompt(spec, mergedProse, {
485
+ includeForecast: forecastEnabled,
486
+ });
487
+ const user = buildUserPrompt(observation, state.journal);
488
+ const tokensInEst = Math.round((system.length + user.length) / 4);
489
+ // Prompt-size + trigger visibility in the live terminal.
490
+ log(`prompt ~${tokensInEst} tok ` +
491
+ `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
492
+ const res = await provider.decide({ system, user });
493
+ // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
494
+ const tokensIn = res.ok
495
+ ? (res.usage?.promptTokens ?? tokensInEst)
496
+ : tokensInEst;
497
+ const tokensOut = res.ok
498
+ ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
499
+ : 0;
500
+ const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
501
+ meter = {
502
+ triggerCodes: gate.codes,
503
+ llmCallMade: true,
504
+ tokensIn,
505
+ tokensOut,
506
+ estimatedCostUsd,
323
507
  };
508
+ if (!res.ok) {
509
+ state.consecutiveModelFailures += 1;
510
+ saveState(stateFile, state);
511
+ log(`model error: ${res.error}`);
512
+ return {
513
+ decision: "skip",
514
+ skipReason: `model error: ${res.error}`,
515
+ planned: [],
516
+ modelFailed: true,
517
+ live,
518
+ ...meter,
519
+ decisionType: "model_error",
520
+ writeAttempted: 0,
521
+ writeAccepted: 0,
522
+ };
523
+ }
524
+ const parsed = parseDecision(res.text);
525
+ if (!parsed.ok) {
526
+ state.consecutiveModelFailures += 1;
527
+ saveState(stateFile, state);
528
+ log(`model output invalid: ${parsed.error}`);
529
+ return {
530
+ decision: "skip",
531
+ skipReason: `model output invalid: ${parsed.error}`,
532
+ // Never persist raw model text (no-CoT privacy policy) — the parse error
533
+ // in skipReason is the diagnostic; the malformed output is not stored.
534
+ rawModelOutput: undefined,
535
+ planned: [],
536
+ modelFailed: true,
537
+ live,
538
+ ...meter,
539
+ decisionType: "model_error",
540
+ writeAttempted: 0,
541
+ writeAccepted: 0,
542
+ };
543
+ }
544
+ state.consecutiveModelFailures = 0;
545
+ decision = parsed.decision;
324
546
  }
325
- state.consecutiveModelFailures = 0;
326
- const decision = parsed.decision;
327
547
  // Reasoning captured for the Arena terminal (keystone transparency): the
328
548
  // model's PARSED, sanitized short analysis + decision confidence. We do NOT
329
549
  // persist the full raw model text — a response can carry prose reasoning
@@ -336,6 +556,13 @@ export async function runCycle(deps) {
336
556
  const rawModelOutput = undefined;
337
557
  if (decision.decision === "skip" || decision.actions.length === 0) {
338
558
  state.consecutiveRejectCycles += 1;
559
+ // Capture the abstention / forecast-only: the model evaluated a non-empty PM
560
+ // universe and chose NOT to open, so the public evaluation must see it (else an
561
+ // agent looks skilled by exposure choice alone). One post carries the whole
562
+ // cohort via universeSize.
563
+ const skipOpp = buildSkipOpportunity(decision, observation.pmMarkets, forecastEnabled);
564
+ if (skipOpp)
565
+ await postOpportunity(skipOpp);
339
566
  saveState(stateFile, state);
340
567
  log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
341
568
  return {
@@ -350,6 +577,7 @@ export async function runCycle(deps) {
350
577
  decisionType: "skip",
351
578
  writeAttempted: decision.actions.length,
352
579
  writeAccepted: 0,
580
+ ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
353
581
  };
354
582
  }
355
583
  // VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
@@ -404,6 +632,44 @@ export async function runCycle(deps) {
404
632
  log(`reject pm_open: duplicate_intent (hold PM ${pm.slug})`);
405
633
  continue;
406
634
  }
635
+ // Independent forecast submission (HOUSE_AGENT_FORECAST_ENABLED, default ON).
636
+ // Attach the model's OWN probability the backed side wins — clamped to [1,99],
637
+ // OMITTED when absent/unparseable (a bad forecast never blocks the trade), and
638
+ // NEVER defaulted to the market price. Flag OFF strips any forecast so the open
639
+ // request is byte-identical to pre-forecast behavior.
640
+ if (forecastEnabled) {
641
+ const fc = sanitizeForecastProbability(pm.forecastProbability);
642
+ if (fc != null) {
643
+ const mkt = observation.pmMarkets.find((m) => (m.source ?? "").toLowerCase() ===
644
+ (pm.source ?? "").toLowerCase() &&
645
+ (m.slug ?? "").toLowerCase() === (pm.slug ?? "").toLowerCase() &&
646
+ m.outcomeExternalMarketId === pm.outcomeExternalMarketId);
647
+ const marketPct = typeof mkt?.probability === "number" &&
648
+ Number.isFinite(mkt.probability)
649
+ ? Math.round(mkt.probability * 100)
650
+ : undefined;
651
+ // Anti-echo: an EXACT match on the market's integer probability is still
652
+ // submitted (a forecast can legitimately agree) — but we LOG it so echo
653
+ // rates stay observable; we never silently mutate the value. NOTE: the
654
+ // market-implied BENCHMARK agent (provider "mechanical", model.name
655
+ // "market-implied") echoes the market probability BY DESIGN — it IS the
656
+ // baseline definition — so a 100% echo rate there is expected, not a
657
+ // defect. Its agentModel/description say BENCHMARK so this log line is
658
+ // never mistaken for a mispriced skill agent.
659
+ if (marketPct != null && Math.round(fc) === marketPct) {
660
+ log(`pm_open forecast ${fc} == market prob ${marketPct}% (echo) — submitting as-is`);
661
+ }
662
+ action = { ...pm, forecastProbability: fc };
663
+ }
664
+ else {
665
+ action = { ...pm, forecastProbability: undefined };
666
+ }
667
+ }
668
+ else if (pm.forecastProbability != null) {
669
+ // Flag off but the model still emitted a forecast — strip it so the request
670
+ // carries NO forecastProbability field (byte-identical to pre-forecast).
671
+ action = { ...pm, forecastProbability: undefined };
672
+ }
407
673
  }
408
674
  // Anti-churn critic: block re-opening a futures position we ALREADY hold unless
409
675
  // it's a confirmed WINNER with room (a legit scale-in). Stops the re-open-a-
@@ -437,6 +703,24 @@ export async function runCycle(deps) {
437
703
  }
438
704
  }
439
705
  const quote = await fetchQuote(client, action, observation, baseTrace);
706
+ // Early PM skip: the quote's openBlocked preview tells us a pm/open right now
707
+ // would be rejected 422 by the open-time quality gate (distinct from the
708
+ // eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
709
+ // reason instead of burning the open attempt on a guaranteed 422.
710
+ if (action.type === "pm_open" && quote?.openBlocked === true) {
711
+ const reasons = Array.isArray(quote.openBlockReasons)
712
+ ? quote.openBlockReasons.map(String)
713
+ : [];
714
+ planned.push({
715
+ action,
716
+ accepted: false,
717
+ code: "pm_open_blocked",
718
+ reason: `open-time quality gate would reject this (422): ${JSON.stringify(reasons)}`,
719
+ quote,
720
+ });
721
+ log(`skip pm_open early: openBlocked (${reasons.join(",") || "quality gate"})`);
722
+ continue;
723
+ }
440
724
  // Auto-clamp a missing/wrong-side futures take-profit to a valid R:R target
441
725
  // off the stop, so the open isn't silently rejected server-side (the runner
442
726
  // owns trigger orientation; weak models routinely mis-sign it).
@@ -501,7 +785,7 @@ export async function runCycle(deps) {
501
785
  // than a secondary trade — see rationaleForAction). Sanitized short reasoning
502
786
  // only — never raw chain-of-thought.
503
787
  rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length));
504
- const r = await executeAction(client, action, observation, trace, idem);
788
+ const r = await executeAction(client, action, observation, trace, idem, provenance);
505
789
  planned.push({
506
790
  action,
507
791
  accepted: true,
@@ -525,6 +809,25 @@ export async function runCycle(deps) {
525
809
  }
526
810
  else {
527
811
  anyExecFailed = true;
812
+ // Quote-expiry capture: a validated pm_open the SERVER rejected at act time
813
+ // with a 422 mock_entry_blocked — the eligibility/quality/pricing state moved
814
+ // between the quote we validated and the act, so the quote expired. Report it
815
+ // (once-per-cycle; carries the universe breadth in the cohort field).
816
+ if (action.type === "pm_open" && isQuoteExpiredResult(r.status, r.data)) {
817
+ const pm = action;
818
+ await postOpportunity({
819
+ kind: "quote_expired",
820
+ source: pm.source,
821
+ slug: pm.slug,
822
+ outcomeExternalMarketId: pm.outcomeExternalMarketId,
823
+ universeSize: observation.pmMarkets.length,
824
+ forecastProbability: forecastEnabled
825
+ ? sanitizeForecastProbability(pm.forecastProbability)
826
+ : undefined,
827
+ marketProbability: marketPct(findPmMarket(observation.pmMarkets, pm)?.probability),
828
+ reasonCode: blockReasonsOf(r.data),
829
+ });
830
+ }
528
831
  }
529
832
  log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
530
833
  }
@@ -564,6 +867,7 @@ export async function runCycle(deps) {
564
867
  decisionType: "act",
565
868
  writeAttempted: decision.actions.length,
566
869
  writeAccepted: planned.filter((p) => p.accepted).length,
870
+ ...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
567
871
  };
568
872
  }
569
873
  export async function runLoop(deps, opts = {}) {
@@ -48,7 +48,9 @@ function normalCdf(z) {
48
48
  const p = d *
49
49
  t *
50
50
  (0.31938153 +
51
- t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
51
+ t *
52
+ (-0.356563782 +
53
+ t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
52
54
  return z >= 0 ? 1 - p : p;
53
55
  }
54
56
  // Max peak-to-trough drawdown (mUSD, >= 0) on a cumulative series.
@@ -107,7 +109,7 @@ function ece(preds) {
107
109
  for (let b = 0; b < buckets; b += 1) {
108
110
  const lo = b / buckets;
109
111
  const hi = (b + 1) / buckets;
110
- const inB = preds.filter((x) => (b === buckets - 1 ? x.p >= lo && x.p <= hi : x.p >= lo && x.p < hi));
112
+ const inB = preds.filter((x) => b === buckets - 1 ? x.p >= lo && x.p <= hi : x.p >= lo && x.p < hi);
111
113
  if (inB.length === 0)
112
114
  continue;
113
115
  const avgP = mean(inB.map((x) => x.p));
@@ -127,7 +129,9 @@ export function computeScorecard(input) {
127
129
  const avgWin = wins.length ? grossWin / wins.length : 0;
128
130
  const avgLoss = losses.length ? grossLoss / losses.length : 0;
129
131
  const pWin = decided ? wins.length / decided : null;
130
- const rs = input.returns && input.returns.length ? input.returns.filter(Number.isFinite) : pnls;
132
+ const rs = input.returns && input.returns.length
133
+ ? input.returns.filter(Number.isFinite)
134
+ : pnls;
131
135
  const returnsBasis = input.returns && input.returns.length ? "returns" : "realized_pnl";
132
136
  const ann = input.annualizationFactor ?? 1;
133
137
  const cumulative = input.cumulative && input.cumulative.length
@@ -156,7 +160,11 @@ export function computeScorecard(input) {
156
160
  calibration_error: round0(ece(input.predictions ?? [])),
157
161
  stop_coverage: input.gates?.stopCoverage ?? null,
158
162
  evidence_coverage: input.gates?.evidenceCoverage ?? null,
159
- leakage_clean: input.gates?.leakageClean == null ? null : input.gates.leakageClean ? 1 : 0,
163
+ leakage_clean: input.gates?.leakageClean == null
164
+ ? null
165
+ : input.gates.leakageClean
166
+ ? 1
167
+ : 0,
160
168
  };
161
169
  // Canonicalize (sorted keys) and hash, so the report card carries a stable,
162
170
  // verifiable fingerprint — a scorecard whose hash does not reproduce is not trusted.
@@ -62,31 +62,77 @@ function classify(w, openPositions) {
62
62
  const out = [];
63
63
  // Primary trend-following / breakout read.
64
64
  if (ind.brokeRecentHigh === true) {
65
- out.push({ symbol: w.symbol, kind: "breakout", bias: "long", strength: 0.8, note });
65
+ out.push({
66
+ symbol: w.symbol,
67
+ kind: "breakout",
68
+ bias: "long",
69
+ strength: 0.8,
70
+ note,
71
+ });
66
72
  }
67
73
  else if (ind.brokeRecentLow === true) {
68
- out.push({ symbol: w.symbol, kind: "breakdown", bias: "short", strength: 0.8, note });
74
+ out.push({
75
+ symbol: w.symbol,
76
+ kind: "breakdown",
77
+ bias: "short",
78
+ strength: 0.8,
79
+ note,
80
+ });
69
81
  }
70
82
  else if (up && ch >= LEAN_MOVE_PCT) {
71
- out.push({ symbol: w.symbol, kind: "uptrend", bias: "long", strength: ch >= STRONG_MOVE_PCT ? 0.75 : 0.6, note });
83
+ out.push({
84
+ symbol: w.symbol,
85
+ kind: "uptrend",
86
+ bias: "long",
87
+ strength: ch >= STRONG_MOVE_PCT ? 0.75 : 0.6,
88
+ note,
89
+ });
72
90
  }
73
91
  else if (down && ch <= -LEAN_MOVE_PCT) {
74
- out.push({ symbol: w.symbol, kind: "downtrend", bias: "short", strength: ch <= -STRONG_MOVE_PCT ? 0.75 : 0.6, note });
92
+ out.push({
93
+ symbol: w.symbol,
94
+ kind: "downtrend",
95
+ bias: "short",
96
+ strength: ch <= -STRONG_MOVE_PCT ? 0.75 : 0.6,
97
+ note,
98
+ });
75
99
  }
76
100
  else if (overbought) {
77
- out.push({ symbol: w.symbol, kind: "stretched", bias: "fade-short", strength: 0.55, note });
101
+ out.push({
102
+ symbol: w.symbol,
103
+ kind: "stretched",
104
+ bias: "fade-short",
105
+ strength: 0.55,
106
+ note,
107
+ });
78
108
  }
79
109
  else if (oversold) {
80
- out.push({ symbol: w.symbol, kind: "stretched", bias: "fade-long", strength: 0.55, note });
110
+ out.push({
111
+ symbol: w.symbol,
112
+ kind: "stretched",
113
+ bias: "fade-long",
114
+ strength: 0.55,
115
+ note,
116
+ });
81
117
  }
82
118
  else if (Math.abs(ch) >= STRONG_MOVE_PCT) {
83
119
  // A strong move with no clean EMA stack — still tradeable momentum.
84
- out.push({ symbol: w.symbol, kind: ch > 0 ? "uptrend" : "downtrend", bias: ch > 0 ? "long" : "short", strength: 0.55, note });
120
+ out.push({
121
+ symbol: w.symbol,
122
+ kind: ch > 0 ? "uptrend" : "downtrend",
123
+ bias: ch > 0 ? "long" : "short",
124
+ strength: 0.55,
125
+ note,
126
+ });
85
127
  }
86
128
  // Secondary COUNTER-TREND fade: a standing trend that is ALSO RSI-extreme is a
87
129
  // mean-reversion candidate. Only add it when the primary was the trend itself
88
130
  // (so we don't double-list a pure stretched read).
89
- const primaryIsTrend = out[0] && (out[0].kind === "uptrend" || out[0].kind === "downtrend" || out[0].kind === "breakout" || out[0].kind === "breakdown");
131
+ const primaryIsTrend = out[0] &&
132
+ (out[0].kind === "uptrend" ||
133
+ out[0].kind === "downtrend" ||
134
+ out[0].kind === "breakout" ||
135
+ out[0].kind === "breakdown");
90
136
  if (primaryIsTrend && (oversold || overbought)) {
91
137
  out.push({
92
138
  symbol: w.symbol,
@@ -103,7 +149,9 @@ function classify(w, openPositions) {
103
149
  // exceeds_cap churn). A winner with room is the one case a same-side "open" is OK
104
150
  // (scaling in); otherwise it's manage-only.
105
151
  const wb = baseSymbol(w.symbol);
106
- const pos = wb ? openPositions.find((p) => baseSymbol(p.symbol) === wb) : undefined;
152
+ const pos = wb
153
+ ? openPositions.find((p) => baseSymbol(p.symbol) === wb)
154
+ : undefined;
107
155
  const held = pos && (pos.side === "long" || pos.side === "short") ? pos.side : undefined;
108
156
  if (held) {
109
157
  const u = pos?.unrealizedPnlMusd;
@@ -12,7 +12,7 @@ import { checkCapabilityDrift } from "./capabilityGuard.js";
12
12
  // guardrails. "Unlimited" is normalised to a large finite value so cap-merge arithmetic
13
13
  // (most-restrictive-wins) and JSON serialisation stay simple.
14
14
  export const UNLIMITED_TRADES_PER_DAY = 1_000_000;
15
- const normalizeTradeCap = (v) => (v <= 0 ? UNLIMITED_TRADES_PER_DAY : v);
15
+ const normalizeTradeCap = (v) => v <= 0 ? UNLIMITED_TRADES_PER_DAY : v;
16
16
  const DEFAULT_LIMITS = {
17
17
  maxTradesPerDay: UNLIMITED_TRADES_PER_DAY,
18
18
  maxWritesPerCycle: 2,