@coinrithm/mcp-trading 0.7.13 → 0.7.15

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 (49) hide show
  1. package/CHANGELOG.md +692 -573
  2. package/README.md +400 -364
  3. package/dist/agent/act.d.ts +7 -2
  4. package/dist/agent/act.js +10 -3
  5. package/dist/agent/cli.d.ts +1 -0
  6. package/dist/agent/cli.js +25 -4
  7. package/dist/agent/client.d.ts +13 -0
  8. package/dist/agent/client.js +107 -4
  9. package/dist/agent/decisionReceipt.d.ts +5 -1
  10. package/dist/agent/decisionReceipt.js +95 -3
  11. package/dist/agent/decisionValidator.js +74 -0
  12. package/dist/agent/definitionSnapshot.d.ts +22 -0
  13. package/dist/agent/definitionSnapshot.js +28 -0
  14. package/dist/agent/engine.d.ts +1 -0
  15. package/dist/agent/engine.js +1 -0
  16. package/dist/agent/futuresEligibility.d.ts +36 -0
  17. package/dist/agent/futuresEligibility.js +90 -0
  18. package/dist/agent/mergeRules.d.ts +1 -1
  19. package/dist/agent/mergeRules.js +17 -2
  20. package/dist/agent/observe.d.ts +9 -1
  21. package/dist/agent/observe.js +507 -45
  22. package/dist/agent/pmContext.d.ts +10 -1
  23. package/dist/agent/pmContext.js +163 -1
  24. package/dist/agent/prompt.d.ts +14 -0
  25. package/dist/agent/prompt.js +138 -27
  26. package/dist/agent/providerCapabilities.d.ts +3 -0
  27. package/dist/agent/providerCapabilities.js +34 -4
  28. package/dist/agent/providers.d.ts +3 -0
  29. package/dist/agent/providers.js +12 -2
  30. package/dist/agent/resolve.js +8 -1
  31. package/dist/agent/runner.js +140 -18
  32. package/dist/agent/skill.js +9 -0
  33. package/dist/agent/skillValidator.js +11 -2
  34. package/dist/agent/strictLint.js +1 -0
  35. package/dist/agent/templates.js +30 -30
  36. package/dist/agent/types.d.ts +138 -1
  37. package/dist/agent/types.js +4 -0
  38. package/dist/client.d.ts +11 -2
  39. package/dist/client.js +22 -10
  40. package/dist/http.d.ts +1 -0
  41. package/dist/http.js +17 -2
  42. package/dist/httpCompletion.d.ts +1 -1
  43. package/dist/httpCompletion.js +1 -0
  44. package/dist/index.js +2 -1
  45. package/dist/instructions.d.ts +1 -0
  46. package/dist/instructions.js +16 -0
  47. package/dist/tools.d.ts +1 -0
  48. package/dist/tools.js +150 -66
  49. package/package.json +97 -97
@@ -54,18 +54,48 @@ export function chatShapeFor(provider, model, baseUrl) {
54
54
  minProbeCompletionTokens: 1024,
55
55
  };
56
56
  }
57
+ // Transport-only restriction: drop the excluded variants from the action oneOf
58
+ // a route decodes against. parseDecision keeps validating the FULL contract, so
59
+ // a route that does not enforce a schema can still propose the action and the
60
+ // validator rejects it. Never returns an empty oneOf.
61
+ export function restrictDecisionSchema(schema, exclude) {
62
+ if (!exclude?.length)
63
+ return schema;
64
+ const properties = schema.properties;
65
+ const actions = properties?.actions;
66
+ const variants = actions?.items?.oneOf;
67
+ if (!properties || !actions || !Array.isArray(variants))
68
+ return schema;
69
+ const kept = variants.filter((variant) => {
70
+ const type = variant
71
+ .properties?.type?.const;
72
+ return !exclude.includes(type);
73
+ });
74
+ if (kept.length === variants.length || kept.length === 0)
75
+ return schema;
76
+ return {
77
+ ...schema,
78
+ properties: {
79
+ ...properties,
80
+ actions: { ...actions, items: { ...actions.items, oneOf: kept } },
81
+ },
82
+ };
83
+ }
57
84
  /** Build the chat-completions body for a route from its capability shape. */
58
85
  export function buildChatBody(shape, args) {
59
86
  const system = shape.systemHint
60
87
  ? `${shape.systemHint}\n\n${args.system}`
61
88
  : args.system;
89
+ const jsonSchema = shape.jsonSchema
90
+ ? restrictDecisionSchema(shape.jsonSchema, args.excludeActionTypes)
91
+ : undefined;
62
92
  return {
63
93
  model: args.model,
64
94
  ...(shape.allowsTemperature
65
95
  ? { temperature: args.temperature ?? 0.2 }
66
96
  : {}),
67
97
  [shape.tokenParam]: args.maxTokens,
68
- ...(shape.jsonSchema && shape.jsonSchemaTransport === "tool_call"
98
+ ...(jsonSchema && shape.jsonSchemaTransport === "tool_call"
69
99
  ? {
70
100
  tools: [
71
101
  {
@@ -73,7 +103,7 @@ export function buildChatBody(shape, args) {
73
103
  function: {
74
104
  name: DECISION_TOOL_NAME,
75
105
  description: "Submit the complete CoinRithm paper-trading decision for this cycle.",
76
- parameters: shape.jsonSchema,
106
+ parameters: jsonSchema,
77
107
  },
78
108
  },
79
109
  ],
@@ -85,12 +115,12 @@ export function buildChatBody(shape, args) {
85
115
  : {}),
86
116
  ...(shape.jsonResponseFormat && shape.jsonSchemaTransport !== "tool_call"
87
117
  ? {
88
- response_format: shape.jsonSchema
118
+ response_format: jsonSchema
89
119
  ? {
90
120
  type: "json_schema",
91
121
  json_schema: {
92
122
  name: "coinrithm_trading_decision",
93
- schema: shape.jsonSchema,
123
+ schema: jsonSchema,
94
124
  },
95
125
  }
96
126
  : { type: "json_object" },
@@ -1,9 +1,11 @@
1
1
  import { AgentSpec, ProviderName } from "./types.js";
2
+ import { type DecisionActionExclusion } from "./providerCapabilities.js";
2
3
  export interface DecideInput {
3
4
  system: string;
4
5
  user: string;
5
6
  maxTokens?: number;
6
7
  timeoutMs?: number;
8
+ excludeActionTypes?: readonly DecisionActionExclusion[];
7
9
  }
8
10
  export interface DecideRouteAttempt {
9
11
  provider: string;
@@ -26,6 +28,7 @@ export interface DecideRouteMeta {
26
28
  export type DecideResult = {
27
29
  ok: true;
28
30
  text: string;
31
+ responseSource?: "tool_call" | "content_fallback" | "content";
29
32
  usage?: {
30
33
  promptTokens: number;
31
34
  completionTokens: number;
@@ -193,7 +193,7 @@ class AnthropicProvider {
193
193
  }
194
194
  : undefined;
195
195
  return text
196
- ? { ok: true, text, usage }
196
+ ? { ok: true, text, usage, responseSource: "content" }
197
197
  : { ok: false, error: "anthropic returned empty content" };
198
198
  });
199
199
  }
@@ -246,6 +246,7 @@ class OpenAiCompatProvider {
246
246
  system: input.system,
247
247
  user: input.user,
248
248
  maxTokens: input.maxTokens ?? 1024,
249
+ excludeActionTypes: input.excludeActionTypes,
249
250
  })),
250
251
  });
251
252
  if (!res.ok) {
@@ -273,7 +274,16 @@ class OpenAiCompatProvider {
273
274
  }
274
275
  : undefined;
275
276
  return text
276
- ? { ok: true, text, usage }
277
+ ? {
278
+ ok: true,
279
+ text,
280
+ usage,
281
+ responseSource: decisionArguments != null
282
+ ? "tool_call"
283
+ : shape.jsonSchemaTransport === "tool_call"
284
+ ? "content_fallback"
285
+ : "content",
286
+ }
277
287
  : { ok: false, error: "provider returned empty content" };
278
288
  });
279
289
  }
@@ -47,7 +47,14 @@ const CONFIG_BLOCKS = [
47
47
  const JOURNAL_MAX_LINES = 200;
48
48
  const JOURNAL_MAX_BYTES = 8_000;
49
49
  // Optional prose files (markdown the LLM reads), in assembly order.
50
- const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
50
+ const PROSE_FILES = [
51
+ "character/thesis.md",
52
+ "character/persona.md",
53
+ "character/entries.md",
54
+ "character/exits.md",
55
+ "character/sizing.md",
56
+ "character/research.md",
57
+ ];
51
58
  // Prose files carry an OPTIONAL YAML frontmatter block (type/title/description/
52
59
  // tags) that is authoring metadata, not doctrine — the model gains nothing from
53
60
  // `tags: [agent, persona, mean-reversion]`. It was being merged verbatim into
@@ -9,10 +9,11 @@ import { usesCapitalSizing, prepareCapitalAction, validateCapitalAction, capital
9
9
  import { decideMechanical } from "./mechanical.js";
10
10
  import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
11
11
  import { baseSymbol, scanSetups } from "./setups.js";
12
+ import { futuresEntryPreflight } from "./futuresEligibility.js";
12
13
  import { reconcileObservation } from "./reconcileObservation.js";
13
14
  import { createOpportunityReporter } from "./opportunityReporter.js";
14
- import { observe } from "./observe.js";
15
- import { buildDailyRiskBudget, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
15
+ import { enrichWhaleContext, observe } from "./observe.js";
16
+ import { buildDailyRiskBudget, buildFuturesCapacity, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
16
17
  import { parseDecision } from "./decision.js";
17
18
  import { validateAction, isRiskIncreasingAction, } from "./decisionValidator.js";
18
19
  import { resolvePmRef } from "./resolvePm.js";
@@ -425,10 +426,29 @@ async function runCycleCore(deps, capture) {
425
426
  const provenance = buildRunnerProvenance(spec);
426
427
  state.cyclesRun += 1;
427
428
  rollDay(state);
429
+ // Old state did not identify which route failed. Carrying that count into
430
+ // a replacement model can request a hold on its very first error.
431
+ if (state.consecutivePermanentModelErrors &&
432
+ !state.permanentModelErrorRoute) {
433
+ state.consecutivePermanentModelErrors = 0;
434
+ }
428
435
  const runId = state.runId;
429
436
  const decisionId = makeDecisionId(state.cyclesRun);
430
437
  const captureBase = { runId, decisionId, spec, mergedProse, state };
431
438
  capture({ ...captureBase, phase: "before_observation" });
439
+ // These exits happen before both provider invocation and action execution.
440
+ // Record that known absence explicitly; missing metering means unknown to
441
+ // audit consumers. Do not default later failures to zero: a call/write may
442
+ // already have occurred by then.
443
+ const preDecisionMeter = {
444
+ llmCallMade: false,
445
+ decisionType: "skip",
446
+ tokensIn: 0,
447
+ tokensOut: 0,
448
+ estimatedCostUsd: 0,
449
+ writeAttempted: 0,
450
+ writeAccepted: 0,
451
+ };
432
452
  // Kill-switch pre-check: a disabled agent never observes, decides, or acts.
433
453
  const tripped = checkKillSwitch(spec, state);
434
454
  if (tripped) {
@@ -442,6 +462,7 @@ async function runCycleCore(deps, capture) {
442
462
  disabled: true,
443
463
  disabledReason: tripped,
444
464
  live,
465
+ ...preDecisionMeter,
445
466
  };
446
467
  }
447
468
  // Establish durable run identity before the first possible write. A process
@@ -505,6 +526,7 @@ async function runCycleCore(deps, capture) {
505
526
  disabled: true,
506
527
  disabledReason: state.disabledReason,
507
528
  live,
529
+ ...preDecisionMeter,
508
530
  ...observationReceipt,
509
531
  };
510
532
  }
@@ -529,6 +551,7 @@ async function runCycleCore(deps, capture) {
529
551
  disabled: true,
530
552
  disabledReason: state.disabledReason,
531
553
  live,
554
+ ...preDecisionMeter,
532
555
  ...observationReceipt,
533
556
  };
534
557
  }
@@ -540,6 +563,7 @@ async function runCycleCore(deps, capture) {
540
563
  skipReason: obs.skip,
541
564
  planned: [],
542
565
  live,
566
+ ...preDecisionMeter,
543
567
  ...observationReceipt,
544
568
  };
545
569
  }
@@ -581,7 +605,7 @@ async function runCycleCore(deps, capture) {
581
605
  ...makeTrace(runId, decisionId, spec, undefined, `Thesis exit: ${why}`),
582
606
  ...observationReceipt,
583
607
  };
584
- const r = await executeAction(client, action, observation, trace, idem, provenance);
608
+ const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
585
609
  exitPlanned.push({
586
610
  action,
587
611
  accepted: true,
@@ -618,15 +642,15 @@ async function runCycleCore(deps, capture) {
618
642
  // so it touches NO kill-switch counter; it just records the cheap cycle.
619
643
  const policy = spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY;
620
644
  const gate = evaluateGate(observation, state, policy, nowMs);
621
- capture({
622
- ...captureBase,
623
- phase: "decision_input",
624
- observation,
625
- observationFingerprint: observationReceipt.observationHash,
626
- preThesisObservationFingerprint,
627
- });
628
645
  const providerName = spec.model?.provider ?? "nvidia";
629
646
  if (!gate.fire) {
647
+ capture({
648
+ ...captureBase,
649
+ phase: "decision_input",
650
+ observation,
651
+ observationFingerprint: observationReceipt.observationHash,
652
+ preThesisObservationFingerprint,
653
+ });
630
654
  saveState(stateFile, state);
631
655
  log(`gate: skip (${gate.reason})`);
632
656
  return {
@@ -645,6 +669,20 @@ async function runCycleCore(deps, capture) {
645
669
  ...observationReceipt,
646
670
  };
647
671
  }
672
+ if (providerName !== "mechanical" &&
673
+ spec.capabilities.includes("whale_context") &&
674
+ spec.venues.includes("pm")) {
675
+ observation.whaleContext = await enrichWhaleContext(client, observation);
676
+ observationReceipt = buildObservationReceipt(observation);
677
+ Object.assign(baseTrace, observationReceipt);
678
+ }
679
+ capture({
680
+ ...captureBase,
681
+ phase: "decision_input",
682
+ observation,
683
+ observationFingerprint: observationReceipt.observationHash,
684
+ preThesisObservationFingerprint,
685
+ });
648
686
  // DECIDE. Two paths share the same downstream validate+act loop:
649
687
  // • mechanical benchmark agents (provider "mechanical") compute the decision
650
688
  // deterministically from the observation — no prompt, no model call, no
@@ -672,19 +710,43 @@ async function runCycleCore(deps, capture) {
672
710
  };
673
711
  }
674
712
  else {
713
+ const futuresCapacity = spec.venues.includes("futures")
714
+ ? buildFuturesCapacity(spec, observation)
715
+ : undefined;
716
+ // One exclusion drives both the textual action menu and transport schema.
717
+ // It is a proposal hint only; parsing and validation stay unchanged.
718
+ const capacitySpent = futuresCapacity !== undefined &&
719
+ (futuresCapacity.slotsLeft === 0 ||
720
+ futuresCapacity.marginHeadroomMusd <= 0);
721
+ const actionAvailability = capacitySpent
722
+ ? { excludeActionTypes: ["futures_open"] }
723
+ : {};
675
724
  const system = buildSystemPrompt(spec, mergedProse, {
676
725
  includeForecast: forecastEnabled,
726
+ ...actionAvailability,
677
727
  });
678
728
  const user = buildUserPrompt(observation, state.journal, {
679
729
  venues: spec.venues,
680
730
  dailyRiskBudget: buildDailyRiskBudget(spec, state),
681
731
  ...(usesCapitalSizing(spec) ? { capitalSizing: spec.capitalSizing } : {}),
732
+ ...(futuresCapacity ? { futuresCapacity } : {}),
733
+ ...actionAvailability,
682
734
  });
683
735
  const tokensInEst = Math.round((system.length + user.length) / 4);
684
736
  // Prompt-size + trigger visibility in the live terminal.
685
737
  log(`prompt ~${tokensInEst} tok ` +
686
738
  `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
687
- const res = await provider.decide({ system, user });
739
+ // With either futures cap spent, a schema-enforcing route is not offered
740
+ // futures_open at all. Other routes can still propose it; the validator
741
+ // rejects it there exactly as before.
742
+ if (capacitySpent) {
743
+ log(`futures capacity spent (slotsLeft ${futuresCapacity.slotsLeft}, marginHeadroomMusd ${futuresCapacity.marginHeadroomMusd}): futures_open withheld from schema-enforcing routes`);
744
+ }
745
+ const res = await provider.decide({
746
+ system,
747
+ user,
748
+ ...actionAvailability,
749
+ });
688
750
  const route = res.route;
689
751
  const actualCallMade = route
690
752
  ? route.attempts.some((attempt) => attempt.outcome !== "deferred")
@@ -764,13 +826,34 @@ async function runCycleCore(deps, capture) {
764
826
  // for what deserves them: revoked credentials, drawdown, kill-switch,
765
827
  // user action. Transient errors reset the permanent streak.
766
828
  if (isPermanentModelError(res.error)) {
829
+ // The router retains the last non-capacity failure even when a later
830
+ // fallback returns 429. Its effective route still describes the last
831
+ // actual call for metering, not necessarily the error returned here.
832
+ const failureAttempt = route?.attempts
833
+ .slice()
834
+ .reverse()
835
+ .find((attempt) => attempt.outcome === "failed" &&
836
+ attempt.failureClass !== "capacity" &&
837
+ attempt.error === res.error);
838
+ const failureRoute = {
839
+ provider: failureAttempt?.provider ??
840
+ route?.effectiveProvider ??
841
+ providerName,
842
+ model: failureAttempt?.model ??
843
+ route?.effectiveModel ??
844
+ spec.model?.name ??
845
+ "unknown",
846
+ };
847
+ const previousRoute = state.permanentModelErrorRoute;
848
+ const sameRoute = previousRoute?.provider === failureRoute.provider &&
849
+ previousRoute?.model === failureRoute.model;
767
850
  state.consecutivePermanentModelErrors =
768
- (state.consecutivePermanentModelErrors ?? 0) + 1;
851
+ (sameRoute ? (state.consecutivePermanentModelErrors ?? 0) : 0) + 1;
852
+ state.permanentModelErrorRoute = failureRoute;
769
853
  if (state.consecutivePermanentModelErrors >=
770
854
  PERMANENT_MODEL_ERROR_THRESHOLD) {
771
855
  const hold = {
772
- provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
773
- model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
856
+ ...failureRoute,
774
857
  error: res.error.slice(0, 200),
775
858
  };
776
859
  saveState(stateFile, state);
@@ -792,6 +875,7 @@ async function runCycleCore(deps, capture) {
792
875
  }
793
876
  else {
794
877
  state.consecutivePermanentModelErrors = 0;
878
+ delete state.permanentModelErrorRoute;
795
879
  }
796
880
  saveState(stateFile, state);
797
881
  log(`model error: ${res.error}`);
@@ -808,7 +892,27 @@ async function runCycleCore(deps, capture) {
808
892
  ...observationReceipt,
809
893
  };
810
894
  }
895
+ // A returned model response proves availability even if its decision JSON
896
+ // is invalid. Output validation keeps its own failure counter below.
897
+ state.consecutivePermanentModelErrors = 0;
898
+ delete state.permanentModelErrorRoute;
811
899
  const parsed = parseDecision(res.text);
900
+ if (capacitySpent) {
901
+ // Persist only route provenance and a parsed action-presence bit in the
902
+ // existing cycle log. Unknown adapters and malformed decisions must not
903
+ // be miscounted as tool compliance; raw output stays unretained.
904
+ const responseSource = res.responseSource === "tool_call" ||
905
+ res.responseSource === "content_fallback" ||
906
+ res.responseSource === "content"
907
+ ? res.responseSource
908
+ : "unknown";
909
+ log(`capacity-response ${JSON.stringify({
910
+ responseSource,
911
+ withheldActionProposed: parsed.ok
912
+ ? parsed.decision.actions.some((action) => action.type === "futures_open")
913
+ : null,
914
+ })}`);
915
+ }
812
916
  if (!parsed.ok) {
813
917
  state.consecutiveModelFailures += 1;
814
918
  saveState(stateFile, state);
@@ -830,7 +934,6 @@ async function runCycleCore(deps, capture) {
830
934
  };
831
935
  }
832
936
  state.consecutiveModelFailures = 0;
833
- state.consecutivePermanentModelErrors = 0;
834
937
  decision = parsed.decision;
835
938
  }
836
939
  // Reasoning captured for the Arena terminal (keystone transparency): the
@@ -935,7 +1038,7 @@ async function runCycleCore(deps, capture) {
935
1038
  log(`reject pm_open: duplicate_intent (hold PM ${pm.slug})`);
936
1039
  continue;
937
1040
  }
938
- // Independent forecast submission (HOUSE_AGENT_FORECAST_ENABLED, default ON).
1041
+ // Market-aware forecast submission (HOUSE_AGENT_FORECAST_ENABLED, default ON).
939
1042
  // Attach the model's OWN probability the backed side wins — clamped to [1,99],
940
1043
  // OMITTED when absent/unparseable (a bad forecast never blocks the trade), and
941
1044
  // NEVER defaulted to the market price. Flag OFF strips any forecast so the open
@@ -1027,7 +1130,26 @@ async function runCycleCore(deps, capture) {
1027
1130
  }
1028
1131
  }
1029
1132
  }
1030
- const quote = await fetchQuote(client, action, observation, baseTrace);
1133
+ // Supported-reference preflight (backend-v2 #106, J-74): skip a NEW futures
1134
+ // open on a coin the observation EXPLICITLY reports has no supported (or only
1135
+ // a stale) perpetual reference, before spending a quote the server gate would
1136
+ // refuse. Unknown eligibility, adds/management on a held coin, spot and PM
1137
+ // pass; the server gate stays authoritative at quote/open time.
1138
+ const preflight = futuresEntryPreflight(action, observation);
1139
+ if (preflight) {
1140
+ planned.push({
1141
+ action,
1142
+ accepted: false,
1143
+ code: preflight.code,
1144
+ ...(capitalSizing ? { capitalSizing } : {}),
1145
+ reason: preflight.reason,
1146
+ });
1147
+ log(`reject ${action.type}: ${preflight.code}`);
1148
+ continue;
1149
+ }
1150
+ const quote = await fetchQuote(client, action, observation, baseTrace, {
1151
+ pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct,
1152
+ });
1031
1153
  // Early PM skip: the quote's openBlocked preview tells us a pm/open right now
1032
1154
  // would be rejected 422 by the open-time quality gate (distinct from the
1033
1155
  // eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
@@ -1150,7 +1272,7 @@ async function runCycleCore(deps, capture) {
1150
1272
  rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length)),
1151
1273
  ...observationReceipt,
1152
1274
  };
1153
- const r = await executeAction(client, action, observation, trace, idem, provenance);
1275
+ const r = await executeAction(client, action, observation, trace, idem, provenance, { pmMinEntryProbabilityPct: spec.risk.pmMinEntryProbabilityPct });
1154
1276
  planned.push({
1155
1277
  action,
1156
1278
  accepted: true,
@@ -109,6 +109,15 @@ export function buildSpec(raw) {
109
109
  entryPredicates: risk.entryPredicates,
110
110
  }
111
111
  : {}),
112
+ // Optional PM entry floor: an absent key stays absent (no floor); an
113
+ // explicit value is carried AS WRITTEN, malformed or not, so hosted
114
+ // validation fails closed instead of a typo silently meaning "no floor"
115
+ // (the same rule entryPredicates follow).
116
+ ...(risk.pmMinEntryProbabilityPct !== undefined
117
+ ? {
118
+ pmMinEntryProbabilityPct: risk.pmMinEntryProbabilityPct,
119
+ }
120
+ : {}),
112
121
  maxLeverage: num(risk.maxLeverage, 1),
113
122
  perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
114
123
  maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
@@ -108,6 +108,13 @@ export function validateSkill(parsed, mode = "self-host") {
108
108
  add("skill_risk_sl", "risk.requireStopLoss must be true or false");
109
109
  if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
110
110
  add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
111
+ // Optional PM entry floor: absent means no floor; present must be a finite
112
+ // number of probability points in [0, 100].
113
+ if (r.pmMinEntryProbabilityPct !== undefined) {
114
+ const f = r.pmMinEntryProbabilityPct;
115
+ if (typeof f !== "number" || !Number.isFinite(f) || f < 0 || f > 100)
116
+ add("skill_risk_pm_entry_floor", "risk.pmMinEntryProbabilityPct must be a number between 0 and 100 (points; omit for no floor)");
117
+ }
111
118
  // Fail-closed on the side restriction: a typo ("shorts_only") must never
112
119
  // silently mean "unrestricted" — that is exactly how a prose-only
113
120
  // constraint failed live on 2026-08-24.
@@ -171,8 +178,10 @@ export function validateSkill(parsed, mode = "self-host") {
171
178
  add("skill_limits_trades", "limits.maxTradesPerDay must be a number >= 0 (0 = unlimited daily trades)");
172
179
  if (!isPosNum(l.maxWritesPerCycle))
173
180
  add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
174
- if (!isPosNum(l.maxDailyLossMusd))
175
- add("skill_limits_loss", "limits.maxDailyLossMusd must be a positive number");
181
+ // 0 = no daily loss cap: the runner only enforces a positive value and
182
+ // the hosted API already accepts 0..50,000, so hosted validation must too.
183
+ if (!isNonNegNum(l.maxDailyLossMusd))
184
+ add("skill_limits_loss", "limits.maxDailyLossMusd must be a number >= 0 (0 = no daily loss cap)");
176
185
  if (!isPosNum(l.maxOpenMarginMusd))
177
186
  add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
178
187
  }
@@ -50,6 +50,7 @@ const ALLOWED_KEYS = {
50
50
  "blocklist",
51
51
  "direction",
52
52
  "entryPredicates",
53
+ "pmMinEntryProbabilityPct",
53
54
  ],
54
55
  sizing: null,
55
56
  capitalSizing: [
@@ -50,37 +50,37 @@ const PRESETS = {
50
50
  modelFail: 5,
51
51
  },
52
52
  };
53
- const THESIS_BODY = `# Momentum Futures — strategy
54
-
55
- You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
- Everything here is simulated; it is not financial advice and never touches real
57
- money. Edit this prose freely (any language) — it is your agent's borders.
58
-
59
- ## Each cycle
60
-
61
- 1. Ground yourself: read your portfolio and open positions first. Never assume
62
- balances or what is already open.
63
- 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
- agree (both up, or both down) and is not already an open position.
65
- 3. Pick the strongest candidate and commit when the read is clear — even a
66
- moderate-confidence one — sized small with a stop. Skip only when the signals
67
- contradict or the data is stale; a quiet tape where your edge is still real
68
- is an act, not a skip.
69
- 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
- the quote is not eligible, relay the reason and stop.
71
- 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
- open. Place the take-profit a touch wider than the stop.
73
- 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
- fired while you were not looking, and react to what actually happened.
75
-
76
- The hard caps (leverage, margin, watchlist) live in the config blocks above and
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick the strongest candidate and commit when the read is clear — even a
66
+ moderate-confidence one — sized small with a stop. Skip only when the signals
67
+ contradict or the data is stale; a quiet tape where your edge is still real
68
+ is an act, not a skip.
69
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
70
+ the quote is not eligible, relay the reason and stop.
71
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
72
+ open. Place the take-profit a touch wider than the stop.
73
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
74
+ fired while you were not looking, and react to what actually happened.
75
+
76
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
77
77
  are enforced by the runner — change them there, not in this prose.`;
78
- const PERSONA_STUB = `# Persona
79
-
80
- Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
- — sized small and protected with a stop, and skips only when the read is
82
- contradictory or the data is stale. States its reasoning plainly in its own
83
- voice, and never frames paper results as real-money advice.
78
+ const PERSONA_STUB = `# Persona
79
+
80
+ Decisive and in character. Acts on a clear read — even a moderate-confidence one
81
+ — sized small and protected with a stop, and skips only when the read is
82
+ contradictory or the data is stale. States its reasoning plainly in its own
83
+ voice, and never frames paper results as real-money advice.
84
84
  `;
85
85
  export function buildAgentObject(name, preset) {
86
86
  const p = PRESETS[preset];