@coinrithm/mcp-trading 0.7.14 → 0.7.16

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 (72) hide show
  1. package/CHANGELOG.md +811 -613
  2. package/README.md +415 -366
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/cli.d.ts +25 -0
  20. package/dist/agent/cli.js +300 -2
  21. package/dist/agent/client.d.ts +14 -0
  22. package/dist/agent/client.js +113 -4
  23. package/dist/agent/configurationWarnings.js +11 -0
  24. package/dist/agent/decision.d.ts +3 -0
  25. package/dist/agent/decision.js +85 -3
  26. package/dist/agent/decisionReceipt.d.ts +5 -1
  27. package/dist/agent/decisionReceipt.js +52 -3
  28. package/dist/agent/decisionValidator.d.ts +30 -0
  29. package/dist/agent/decisionValidator.js +147 -2
  30. package/dist/agent/engine.d.ts +3 -3
  31. package/dist/agent/engine.js +3 -3
  32. package/dist/agent/futuresEligibility.d.ts +36 -0
  33. package/dist/agent/futuresEligibility.js +90 -0
  34. package/dist/agent/observe.d.ts +35 -2
  35. package/dist/agent/observe.js +969 -128
  36. package/dist/agent/pmContext.d.ts +10 -1
  37. package/dist/agent/pmContext.js +163 -1
  38. package/dist/agent/prompt.d.ts +18 -1
  39. package/dist/agent/prompt.js +229 -33
  40. package/dist/agent/promptTables.d.ts +1 -0
  41. package/dist/agent/promptTables.js +33 -0
  42. package/dist/agent/providerCapabilities.d.ts +9 -0
  43. package/dist/agent/providerCapabilities.js +57 -4
  44. package/dist/agent/providers.d.ts +28 -7
  45. package/dist/agent/providers.js +200 -50
  46. package/dist/agent/resolve.js +3 -0
  47. package/dist/agent/runner.d.ts +8 -0
  48. package/dist/agent/runner.js +159 -26
  49. package/dist/agent/setups.d.ts +2 -1
  50. package/dist/agent/setups.js +17 -16
  51. package/dist/agent/signals.d.ts +33 -0
  52. package/dist/agent/signals.js +89 -0
  53. package/dist/agent/skill.js +37 -0
  54. package/dist/agent/skillValidator.js +33 -0
  55. package/dist/agent/strictLint.js +29 -0
  56. package/dist/agent/templates.js +30 -30
  57. package/dist/agent/types.d.ts +266 -13
  58. package/dist/agent/types.js +4 -0
  59. package/dist/agent/universe.d.ts +78 -0
  60. package/dist/agent/universe.js +215 -0
  61. package/dist/client.d.ts +9 -2
  62. package/dist/client.js +22 -10
  63. package/dist/http.d.ts +1 -0
  64. package/dist/http.js +17 -2
  65. package/dist/httpCompletion.d.ts +1 -1
  66. package/dist/httpCompletion.js +1 -0
  67. package/dist/index.js +2 -1
  68. package/dist/instructions.d.ts +1 -0
  69. package/dist/instructions.js +16 -0
  70. package/dist/tools.d.ts +1 -0
  71. package/dist/tools.js +150 -66
  72. package/package.json +98 -97
@@ -3,6 +3,7 @@
3
3
  // JSON, an unknown action type, a free-form endpoint/tool name, extra unknown
4
4
  // fields, or a missing required field — fails closed (the runner skips).
5
5
  import { z } from "zod";
6
+ import JSON5 from "json5";
6
7
  import { coerceThesis } from "./thesis.js";
7
8
  // Small models (especially Llama 3.1 8B) frequently emit numbers as JSON strings
8
9
  // ("12345", "0.8", "62000"). Coerce a *clean* numeric string to a number before
@@ -363,15 +364,95 @@ function coerceJson(text) {
363
364
  }
364
365
  return JSON.parse(s); // throws on invalid JSON -> caller treats as fail-closed
365
366
  }
367
+ // Fixed, non-content diagnostics only. Never retain model text, identifiers,
368
+ // action values or reasoning to investigate a provider's encoding failures.
369
+ export const ACTIONS_STRING_DIAGNOSTICS = [
370
+ "json_array_empty_valid_decision",
371
+ "json_array_nonempty_valid_decision",
372
+ "json_array_invalid_decision",
373
+ "json_nonarray",
374
+ "invalid_json",
375
+ "empty_string_valid_skip",
376
+ "empty_string_other_decision",
377
+ "non_json_array_text",
378
+ "json5_array_valid_decision",
379
+ "json5_array_invalid_decision",
380
+ "over_size_limit",
381
+ ];
382
+ function diagnoseActionsString(obj) {
383
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
384
+ return undefined;
385
+ const record = obj;
386
+ if (typeof record.actions !== "string")
387
+ return undefined;
388
+ if (record.actions.length > 65_536)
389
+ return "over_size_limit";
390
+ const text = record.actions.trim();
391
+ if (text === "") {
392
+ return record.decision === "skip" &&
393
+ decisionSchema.safeParse({ ...record, actions: [] }).success
394
+ ? "empty_string_valid_skip"
395
+ : "empty_string_other_decision";
396
+ }
397
+ let actions;
398
+ try {
399
+ actions = JSON.parse(record.actions);
400
+ }
401
+ catch {
402
+ if (!text.startsWith("["))
403
+ return "invalid_json";
404
+ // Diagnostic only: identify alternate literal encodings without accepting
405
+ // them or retaining content. JSON5 never evaluates expressions. Requiring
406
+ // finite JSON values keeps Infinity/NaN from looking like usable actions.
407
+ try {
408
+ const candidate = JSON5.parse(text);
409
+ JSON.stringify(candidate, (key, value) => {
410
+ if (["__proto__", "constructor", "prototype"].includes(key) ||
411
+ (typeof value === "number" && !Number.isFinite(value)))
412
+ throw new Error("Unsafe diagnostic value");
413
+ return value;
414
+ });
415
+ return Array.isArray(candidate) &&
416
+ decisionSchema.safeParse({ ...record, actions: candidate }).success
417
+ ? "json5_array_valid_decision"
418
+ : "json5_array_invalid_decision";
419
+ }
420
+ catch {
421
+ return "non_json_array_text";
422
+ }
423
+ }
424
+ if (!Array.isArray(actions))
425
+ return "json_nonarray";
426
+ if (!decisionSchema.safeParse({ ...record, actions }).success)
427
+ return "json_array_invalid_decision";
428
+ return actions.length === 0
429
+ ? "json_array_empty_valid_decision"
430
+ : "json_array_nonempty_valid_decision";
431
+ }
432
+ function safeIssueMessage(issue) {
433
+ // These Zod messages echo model-supplied values or unknown field names.
434
+ // Other messages in this fixed schema describe types or contract limits.
435
+ switch (issue.code) {
436
+ case "invalid_enum_value":
437
+ return "Invalid enum value";
438
+ case "invalid_literal":
439
+ return "Invalid literal value";
440
+ case "unrecognized_keys":
441
+ return "Unrecognized field(s)";
442
+ default:
443
+ return issue.message;
444
+ }
445
+ }
366
446
  export function parseDecision(text) {
367
447
  let obj;
368
448
  try {
369
449
  obj = normalizeDecisionVerb(coerceJson(text));
370
450
  }
371
- catch (err) {
451
+ catch {
372
452
  return {
373
453
  ok: false,
374
- error: `model output is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
454
+ // Node's JSON syntax errors can include excerpts of private model text.
455
+ error: "model output is not valid JSON",
375
456
  };
376
457
  }
377
458
  const res = decisionSchema.safeParse(obj);
@@ -379,8 +460,9 @@ export function parseDecision(text) {
379
460
  return {
380
461
  ok: false,
381
462
  error: res.error.issues
382
- .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
463
+ .map((i) => `${i.path.join(".") || "(root)"}: ${safeIssueMessage(i)}`)
383
464
  .join("; "),
465
+ actionsStringDiagnostic: diagnoseActionsString(obj),
384
466
  };
385
467
  }
386
468
  const d = res.data;
@@ -8,7 +8,7 @@ interface Row {
8
8
  }
9
9
  export interface DecisionInputRecord {
10
10
  version: "coinrithm.decision-input.v1";
11
- projectionVersion?: "coinrithm.decision-input-projection.v2";
11
+ projectionVersion?: "coinrithm.decision-input-projection.v2" | "coinrithm.decision-input-projection.v3";
12
12
  visibility: "private";
13
13
  completeness: "partial";
14
14
  phase: DecisionInputPhase;
@@ -21,6 +21,10 @@ export interface DecisionInputRecord {
21
21
  dailyRiskBudget: DailyRiskBudget | null;
22
22
  guardState: Record<string, number | boolean | null>;
23
23
  account: Record<string, number | boolean | string | null> | null;
24
+ marketMood?: {
25
+ fearGreed: number | null;
26
+ fetchedAt: string | null;
27
+ } | null;
24
28
  lists: Record<string, Row[]>;
25
29
  counts: Record<string, {
26
30
  source: number;
@@ -22,6 +22,20 @@ const fingerprint = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64
22
22
  ? value
23
23
  : null;
24
24
  const numeric = (raw, keys) => Object.fromEntries(keys.map((key) => [key, num(raw[key])]));
25
+ const count = (value) => Number.isSafeInteger(value) && value >= 0
26
+ ? value
27
+ : null;
28
+ const fearGreed = (value) => {
29
+ const v = num(value);
30
+ return v !== null && v >= 0 && v <= 100 ? v : null;
31
+ };
32
+ const PROJECTION_V3 = "coinrithm.decision-input-projection.v3";
33
+ // Watch-row fields that exist only from projection v3 on.
34
+ const SENTIMENT_V3_KEYS = [
35
+ "sentimentTotalVotes",
36
+ "sentimentDayUtc",
37
+ "sentimentUpdatedAt",
38
+ ];
25
39
  const freshness = (value) => {
26
40
  const raw = obj(parseFreshness({ freshness: value }));
27
41
  return {
@@ -49,7 +63,7 @@ export function buildDecisionInputRecord(input) {
49
63
  const budget = buildDailyRiskBudget(input.spec, input.state);
50
64
  const record = {
51
65
  version: "coinrithm.decision-input.v1",
52
- projectionVersion: "coinrithm.decision-input-projection.v2",
66
+ projectionVersion: PROJECTION_V3,
53
67
  visibility: "private",
54
68
  completeness: "partial",
55
69
  phase: input.phase,
@@ -115,6 +129,12 @@ export function buildDecisionInputRecord(input) {
115
129
  equityMusd: num(obs.equityMusd),
116
130
  polledBeforeWrite: bool(obs.polledBeforeWrite),
117
131
  };
132
+ record.marketMood = obs.marketMood
133
+ ? {
134
+ fearGreed: fearGreed(obs.marketMood.fearGreed),
135
+ fetchedAt: sourceTimestamp(obs.marketMood.fetchedAt) ?? null,
136
+ }
137
+ : null;
118
138
  const add = (name, items, project) => {
119
139
  const all = arr(items);
120
140
  // Bound work/space independently of the upstream list limit. Preserve order,
@@ -137,6 +157,10 @@ export function buildDecisionInputRecord(input) {
137
157
  "change7d",
138
158
  "sentimentBullishPct",
139
159
  ]),
160
+ // The sample and its own clocks; null = unknown, never "fresh".
161
+ sentimentTotalVotes: count(r.sentimentTotalVotes),
162
+ sentimentDayUtc: sourceTimestamp(r.sentimentDayUtc) ?? null,
163
+ sentimentUpdatedAt: sourceTimestamp(r.sentimentUpdatedAt) ?? null,
140
164
  freshness: freshness(r.freshness),
141
165
  ...(r.indicatorContext
142
166
  ? {
@@ -357,6 +381,7 @@ const LIST_KEYS = {
357
381
  "change24h",
358
382
  "change7d",
359
383
  "sentimentBullishPct",
384
+ ...SENTIMENT_V3_KEYS,
360
385
  "freshness",
361
386
  "indicators",
362
387
  "indicatorContext",
@@ -467,8 +492,16 @@ function validRow(value, keys) {
467
492
  return true;
468
493
  if (NESTED_KEYS[key])
469
494
  return validRow(v, NESTED_KEYS[key]);
470
- if (key === "asOf" || key === "assessedAt" || key === "openedAt")
495
+ if ([
496
+ "asOf",
497
+ "assessedAt",
498
+ "openedAt",
499
+ "sentimentDayUtc",
500
+ "sentimentUpdatedAt",
501
+ ].includes(key))
471
502
  return sourceTimestamp(v) === v;
503
+ if (key === "sentimentTotalVotes")
504
+ return count(v) !== null;
472
505
  if (key === "basis")
473
506
  return code(v, FRESHNESS_BASES) !== null;
474
507
  if (key === "range")
@@ -565,13 +598,29 @@ export function sanitizeDecisionInputRecord(value) {
565
598
  "dailyRiskBudget",
566
599
  "guardState",
567
600
  "account",
601
+ "marketMood",
568
602
  "lists",
569
603
  "counts",
570
604
  "omissions",
571
605
  ]))
572
606
  return undefined;
573
607
  if (r.projectionVersion !== undefined &&
574
- r.projectionVersion !== "coinrithm.decision-input-projection.v2")
608
+ r.projectionVersion !== "coinrithm.decision-input-projection.v2" &&
609
+ r.projectionVersion !== PROJECTION_V3)
610
+ return undefined;
611
+ // An older record must never carry, or be mistaken for, v3 fields.
612
+ const v3 = r.projectionVersion === PROJECTION_V3;
613
+ if (!v3 &&
614
+ (r.marketMood !== undefined ||
615
+ arr(obj(r.lists).watch).some((row) => SENTIMENT_V3_KEYS.some((k) => k in obj(row)))))
616
+ return undefined;
617
+ if (r.marketMood !== undefined &&
618
+ r.marketMood !== null &&
619
+ (!keysOnly(r.marketMood, ["fearGreed", "fetchedAt"]) ||
620
+ (r.marketMood.fearGreed !== null &&
621
+ fearGreed(r.marketMood.fearGreed) === null) ||
622
+ (r.marketMood.fetchedAt !== null &&
623
+ sourceTimestamp(r.marketMood.fetchedAt) !== r.marketMood.fetchedAt)))
575
624
  return undefined;
576
625
  if (r.version !== "coinrithm.decision-input.v1" ||
577
626
  r.visibility !== "private" ||
@@ -20,7 +20,37 @@ export interface DecisionContext {
20
20
  realizedLossTodayMusd: number;
21
21
  targetedPositionIds: number[];
22
22
  targetedOrderIds: number[];
23
+ pmEventsOpenedThisCycle?: string[];
24
+ pmCoinHorizonsOpenedThisCycle?: PmCoinHorizon[];
25
+ nowMs?: number;
23
26
  }
27
+ /**
28
+ * Edge (points) risk.pmMinEdgeGapPct requires at a fee-inclusive entry cost:
29
+ * pct% of the room left to 100. Cost at or above 100 leaves no room, so any
30
+ * positive gap rule makes the requirement unreachable rather than zero.
31
+ */
32
+ export declare function requiredGapEdgePoints(entryPct: number, gapPct: number): number;
33
+ /** Ends within this of each other share a coin horizon (venue date splits). */
34
+ export declare const PM_COIN_HORIZON_WINDOW_MS: number;
35
+ /** An end beyond this is a venue sentinel, never a real horizon. */
36
+ export declare const PM_PLAUSIBLE_END_MAX_MS: number;
37
+ export interface PmCoinHorizon {
38
+ coins: string[];
39
+ endMs: number;
40
+ }
41
+ /** The later of observation.asOf and the validation clock, when known. */
42
+ export declare function pmReferenceMs(asOf: string, nowMs?: number): number;
43
+ /**
44
+ * The coin horizon of a market or held position, or null when it cannot be
45
+ * grouped: no linked coin, or an end that is missing, unparseable, not after
46
+ * the reference time, or more than 5 years after it (venue sentinels).
47
+ */
48
+ export declare function pmCoinHorizonOf(item: {
49
+ coins?: string[];
50
+ endDate?: string;
51
+ }, refMs: number): PmCoinHorizon | null;
52
+ /** Event key shared by pm_open actions, held positions and markets. */
53
+ export declare function pmEventKey(source: string, slug: string): string;
24
54
  /**
25
55
  * True when a thesis says, in so many words, that it is betting AGAINST the
26
56
  * outcome the action is buying. Deliberately narrow: it fires only when the
@@ -4,6 +4,46 @@
4
4
  // market text cannot widen a limit or force a trade. Covers futures + spot + PM.
5
5
  import { ok, fail, actionVenue, spotBuyCost, } from "./types.js";
6
6
  import { checkEntryPredicates } from "./entryPredicates.js";
7
+ import { universeEntryBlock } from "./universe.js";
8
+ /**
9
+ * Edge (points) risk.pmMinEdgeGapPct requires at a fee-inclusive entry cost:
10
+ * pct% of the room left to 100. Cost at or above 100 leaves no room, so any
11
+ * positive gap rule makes the requirement unreachable rather than zero.
12
+ */
13
+ export function requiredGapEdgePoints(entryPct, gapPct) {
14
+ const room = 100 - entryPct;
15
+ if (room <= 0)
16
+ return gapPct > 0 ? Number.POSITIVE_INFINITY : 0;
17
+ return (gapPct / 100) * room;
18
+ }
19
+ /** Ends within this of each other share a coin horizon (venue date splits). */
20
+ export const PM_COIN_HORIZON_WINDOW_MS = 24 * 60 * 60 * 1000;
21
+ /** An end beyond this is a venue sentinel, never a real horizon. */
22
+ export const PM_PLAUSIBLE_END_MAX_MS = 5 * 365 * 24 * 60 * 60 * 1000;
23
+ /** The later of observation.asOf and the validation clock, when known. */
24
+ export function pmReferenceMs(asOf, nowMs) {
25
+ return Math.max(...[Date.parse(asOf), nowMs ?? NaN].filter((t) => Number.isFinite(t)));
26
+ }
27
+ /**
28
+ * The coin horizon of a market or held position, or null when it cannot be
29
+ * grouped: no linked coin, or an end that is missing, unparseable, not after
30
+ * the reference time, or more than 5 years after it (venue sentinels).
31
+ */
32
+ export function pmCoinHorizonOf(item, refMs) {
33
+ const coins = item.coins ?? [];
34
+ if (coins.length === 0 || !item.endDate || !Number.isFinite(refMs))
35
+ return null;
36
+ const endMs = Date.parse(item.endDate);
37
+ if (!Number.isFinite(endMs) ||
38
+ endMs <= refMs ||
39
+ endMs - refMs > PM_PLAUSIBLE_END_MAX_MS)
40
+ return null;
41
+ return { coins, endMs };
42
+ }
43
+ /** Event key shared by pm_open actions, held positions and markets. */
44
+ export function pmEventKey(source, slug) {
45
+ return `${source.toLowerCase()}/${slug.toLowerCase()}`;
46
+ }
7
47
  const SERVER_MAX_LEVERAGE = 20;
8
48
  // Minimum edge, in probability POINTS, between the model's own forecast for
9
49
  // the outcome it is backing and what that outcome currently costs. Live
@@ -151,6 +191,9 @@ export function validateAction(action, ctx) {
151
191
  return fail("unknown_symbol", `${action.symbol} is not on the watchlist`);
152
192
  if (!entry.coinId)
153
193
  return fail("unresolved_symbol", `${action.symbol} did not resolve to a coin`);
194
+ const outsideUniverse = universeEntryBlock(spec, entry);
195
+ if (outsideUniverse)
196
+ return fail("outside_universe", `${action.symbol} is ${outsideUniverse}`);
154
197
  // A futures_open on a symbol you ALREADY hold is treated as an ADD by the
155
198
  // server, which REJECTS any SL/TP on an add (sl_tp_not_supported_on_add) and
156
199
  // fails the whole open — the single biggest rejection class (e.g. Sam 61/61).
@@ -267,6 +310,11 @@ export function validateAction(action, ctx) {
267
310
  return fail("unknown_symbol", `${action.symbol} is not on the watchlist`);
268
311
  if (!entry.coinId)
269
312
  return fail("unresolved_symbol", `${action.symbol} did not resolve to a coin`);
313
+ if (action.side === "buy") {
314
+ const outside = universeEntryBlock(spec, entry);
315
+ if (outside)
316
+ return fail("outside_universe", `${action.symbol} is ${outside}`);
317
+ }
270
318
  if (action.orderType === "limit" &&
271
319
  !(typeof action.limitPrice === "number" && action.limitPrice > 0)) {
272
320
  return fail("missing_limit_price", "a limit order needs a positive limitPrice");
@@ -333,6 +381,68 @@ export function validateAction(action, ctx) {
333
381
  if (!mkt) {
334
382
  return fail("pm_market_not_discovered", `${action.source}/${action.slug} is not in the discovered PM markets`);
335
383
  }
384
+ const perEventCap = spec.risk.pmMaxOpenPerEvent;
385
+ if (perEventCap !== undefined) {
386
+ if (typeof perEventCap !== "number" ||
387
+ !Number.isInteger(perEventCap) ||
388
+ perEventCap < 1) {
389
+ return fail("pm_event_cap_invalid", `risk.pmMaxOpenPerEvent ${JSON.stringify(perEventCap)} is not a whole number of at least 1`);
390
+ }
391
+ const key = pmEventKey(mkt.source, mkt.slug);
392
+ const held = observation.pmPositions.filter((p) => (p.status ?? "open") === "open" &&
393
+ p.source !== undefined &&
394
+ p.slug !== undefined &&
395
+ pmEventKey(p.source, p.slug) === key).length;
396
+ const thisCycle = (ctx.pmEventsOpenedThisCycle ?? []).filter((k) => k === key).length;
397
+ if (held + thisCycle >= perEventCap) {
398
+ return fail("pm_event_cap", `${held + thisCycle} open bet(s) on ${key} >= per-event cap ${perEventCap}`);
399
+ }
400
+ }
401
+ const coinHorizonCap = spec.risk.pmMaxOpenPerCoinHorizon;
402
+ if (coinHorizonCap !== undefined) {
403
+ if (typeof coinHorizonCap !== "number" ||
404
+ !Number.isInteger(coinHorizonCap) ||
405
+ coinHorizonCap < 1 ||
406
+ coinHorizonCap > 50) {
407
+ return fail("pm_coin_horizon_cap_invalid", `risk.pmMaxOpenPerCoinHorizon ${JSON.stringify(coinHorizonCap)} is not a whole number from 1 to 50`);
408
+ }
409
+ const refMs = pmReferenceMs(observation.asOf, ctx.nowMs);
410
+ const candidate = pmCoinHorizonOf(mkt, refMs);
411
+ // Unknown coin or end: never grouped, never blocked.
412
+ if (candidate) {
413
+ const near = (endMs) => Math.abs(endMs - candidate.endMs) <= PM_COIN_HORIZON_WINDOW_MS;
414
+ for (const coin of candidate.coins) {
415
+ const held = observation.pmPositions.filter((p) => {
416
+ if ((p.status ?? "open") !== "open")
417
+ return false;
418
+ const h = pmCoinHorizonOf(p, refMs);
419
+ return !!h && h.coins.includes(coin) && near(h.endMs);
420
+ }).length;
421
+ const thisCycle = (ctx.pmCoinHorizonsOpenedThisCycle ?? []).filter((h) => h.coins.includes(coin) && near(h.endMs)).length;
422
+ if (held + thisCycle >= coinHorizonCap) {
423
+ return fail("pm_coin_horizon_cap", `${held + thisCycle} open bet(s) on ${coin} ending within 24 h of ${new Date(candidate.endMs).toISOString()} >= per-coin-horizon cap ${coinHorizonCap}`);
424
+ }
425
+ }
426
+ }
427
+ }
428
+ const closeCutoff = spec.risk.pmMinMinutesToClose;
429
+ if (closeCutoff !== undefined) {
430
+ if (typeof closeCutoff !== "number" ||
431
+ !Number.isFinite(closeCutoff) ||
432
+ closeCutoff < 0) {
433
+ return fail("pm_close_cutoff_invalid", `risk.pmMinMinutesToClose ${JSON.stringify(closeCutoff)} is not a non-negative number`);
434
+ }
435
+ // Only a KNOWN close can trip the cutoff: venues publish null/sentinel
436
+ // end dates, and an unparseable one is unknown, never "closing now".
437
+ const endMs = mkt.endDate ? Date.parse(mkt.endDate) : NaN;
438
+ const refMs = Math.max(...[Date.parse(observation.asOf), ctx.nowMs ?? NaN].filter((t) => Number.isFinite(t)));
439
+ if (Number.isFinite(endMs) && Number.isFinite(refMs)) {
440
+ const minutesLeft = (endMs - refMs) / 60_000;
441
+ if (minutesLeft < closeCutoff) {
442
+ return fail("pm_closes_too_soon", `market closes in ${minutesLeft.toFixed(1)} min, inside the ${closeCutoff} min cutoff`);
443
+ }
444
+ }
445
+ }
336
446
  if (action.stakeMusd < PM_MIN_STAKE_MUSD) {
337
447
  return fail("pm_stake_below_min", `stake ${action.stakeMusd} < $${PM_MIN_STAKE_MUSD} minimum`);
338
448
  }
@@ -393,11 +503,37 @@ export function validateAction(action, ctx) {
393
503
  // Forecast consistency. By prompt contract forecastProbability is the
394
504
  // model's own probability (1-99) that the outcome IT IS BACKING wins, so
395
505
  // buying that outcome only makes sense when the forecast clears what the
396
- // market charges for it. An ABSENT forecast still never blocks a bet (the
397
- // prompt promises that); a PRESENT one that contradicts the trade does.
506
+ // market charges for it. A forecast is optional unless a configured edge
507
+ // rule requires one; a present forecast must satisfy the enforced rules.
398
508
  // The API's entryProbability is the RAW mid, and executionModel's effective
399
509
  // probability excludes fee. Total stake / net shares is the fee-inclusive
400
510
  // break-even cost. Do not guess units or fall back to a discovery mid.
511
+ const maxEdge = spec.risk.pmMaxEdgePoints;
512
+ if (!ctx.mechanical && maxEdge !== undefined) {
513
+ if (typeof maxEdge !== "number" ||
514
+ !Number.isFinite(maxEdge) ||
515
+ maxEdge < 0 ||
516
+ maxEdge > 100) {
517
+ return fail("pm_max_edge_invalid", `risk.pmMaxEdgePoints ${JSON.stringify(maxEdge)} is not a number between 0 and 100`);
518
+ }
519
+ // An overconfidence cap cannot be checked against a missing number.
520
+ if (action.forecastProbability == null) {
521
+ return fail("pm_forecast_required", `risk.pmMaxEdgePoints ${maxEdge} needs forecastProbability on every pm_open`);
522
+ }
523
+ }
524
+ const edgeGapPct = spec.risk.pmMinEdgeGapPct;
525
+ if (!ctx.mechanical && edgeGapPct !== undefined) {
526
+ if (typeof edgeGapPct !== "number" ||
527
+ !Number.isFinite(edgeGapPct) ||
528
+ edgeGapPct < 0 ||
529
+ edgeGapPct > 100) {
530
+ return fail("pm_edge_gap_invalid", `risk.pmMinEdgeGapPct ${JSON.stringify(edgeGapPct)} is not a number between 0 and 100`);
531
+ }
532
+ // An edge rule cannot be checked against a missing number.
533
+ if (action.forecastProbability == null) {
534
+ return fail("pm_forecast_required", `risk.pmMinEdgeGapPct ${edgeGapPct} needs forecastProbability on every pm_open`);
535
+ }
536
+ }
401
537
  if (!ctx.mechanical && action.forecastProbability != null) {
402
538
  const stake = ctx.quote.stakeMusd;
403
539
  const shares = ctx.quote.sharesEstimate;
@@ -418,6 +554,15 @@ export function validateAction(action, ctx) {
418
554
  if (edge + 1e-9 < PM_MIN_FORECAST_EDGE_POINTS) {
419
555
  return fail("forecast_no_positive_edge", `forecast ${action.forecastProbability} vs entry ${entryPct.toFixed(1)} = ${edge.toFixed(1)}pt edge, under the ${PM_MIN_FORECAST_EDGE_POINTS}pt minimum`);
420
556
  }
557
+ if (maxEdge !== undefined && edge > maxEdge + 1e-9) {
558
+ return fail("pm_edge_overconfident", `forecast ${action.forecastProbability} vs entry ${entryPct.toFixed(1)} = ${edge.toFixed(1)}pt edge, over the ${maxEdge}pt cap`);
559
+ }
560
+ if (edgeGapPct !== undefined) {
561
+ const required = requiredGapEdgePoints(entryPct, edgeGapPct);
562
+ if (edge + 1e-9 < required) {
563
+ return fail("pm_edge_below_gap_rule", `forecast ${action.forecastProbability} vs entry ${entryPct.toFixed(1)} = ${edge.toFixed(1)}pt edge, under the ${required.toFixed(1)}pt required by ${edgeGapPct}% of the gap to 100`);
564
+ }
565
+ }
421
566
  }
422
567
  if (thesisContradictsOutcome(action.thesis?.summary, mkt.outcomeName)) {
423
568
  return fail("thesis_action_conflict", `thesis bets against ${mkt.outcomeName}, which this action buys`);
@@ -1,9 +1,9 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
2
  export { buildAgentDefinitionSnapshot, type AgentDefinitionSnapshot, } from "./definitionSnapshot.js";
3
3
  export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
4
- export { selectProvider, providerForRoute, classifyProviderFailure, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
5
- export { parseDecision } from "./decision.js";
6
- export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
4
+ export { selectProvider, providerForRoute, classifyProviderFailure, anthropicUsage, type ProviderUsage, type ProviderEnv, type ProviderRouteOptions, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
5
+ export { parseDecision, ACTIONS_STRING_DIAGNOSTICS, type ActionsStringDiagnostic, } from "./decision.js";
6
+ export { chatShapeFor, buildChatBody, canRetrySuperToolOutput, type ChatShape, } from "./providerCapabilities.js";
7
7
  export { probeDecisionContract, type ProbeRoute, type ProbeDecisionResult, } from "./decisionProbe.js";
8
8
  export { CoinRithmClient } from "./client.js";
9
9
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
@@ -8,12 +8,12 @@
8
8
  export { runCycle } from "./runner.js";
9
9
  export { buildAgentDefinitionSnapshot, } from "./definitionSnapshot.js";
10
10
  export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
11
- export { selectProvider, providerForRoute, classifyProviderFailure, } from "./providers.js";
12
- export { parseDecision } from "./decision.js";
11
+ export { selectProvider, providerForRoute, classifyProviderFailure, anthropicUsage, } from "./providers.js";
12
+ export { parseDecision, ACTIONS_STRING_DIAGNOSTICS, } from "./decision.js";
13
13
  // Reliability slice A: the declarative request-capability table and the
14
14
  // representative decision probe (route eligibility = a REAL parsed decision,
15
15
  // never a bare HTTP 200 — the 62f3a12 lesson).
16
- export { chatShapeFor, buildChatBody, } from "./providerCapabilities.js";
16
+ export { chatShapeFor, buildChatBody, canRetrySuperToolOutput, } from "./providerCapabilities.js";
17
17
  export { probeDecisionContract, } from "./decisionProbe.js";
18
18
  export { CoinRithmClient } from "./client.js";
19
19
  export { loadAgent, buildSpec } from "./skill.js";
@@ -0,0 +1,36 @@
1
+ import type { Observation, ProposedAction } from "./types.js";
2
+ export type FuturesEntryEligibilityStatus = "eligible" | "reference_stale" | "reference_unavailable";
3
+ export interface FuturesEntryEligibility {
4
+ status: FuturesEntryEligibilityStatus;
5
+ /** Whether the server gate currently requires a reference for NEW opens. */
6
+ referenceRequired: boolean;
7
+ venue: string | null;
8
+ symbol: string | null;
9
+ referenceFetchedAt: string | null;
10
+ maxReferenceAgeHours: number;
11
+ evaluatedAt: string;
12
+ }
13
+ /**
14
+ * Strict parse of the market context field against its contract (backend-v2
15
+ * #106). Anything missing or malformed (an older API, a partial response, an
16
+ * unknown status, a non-boolean referenceRequired, a missing or invalid
17
+ * evaluatedAt, a non-positive or non-finite maxReferenceAgeHours, or a present
18
+ * but invalid referenceFetchedAt) is `undefined` = UNKNOWN. Unknown never
19
+ * blocks: the runner keeps today's behaviour and the server gate decides.
20
+ */
21
+ export declare function futuresEntryEligibilityOf(marketContext: unknown): FuturesEntryEligibility | undefined;
22
+ export interface FuturesEntryPreflightRejection {
23
+ code: "futures_reference_unavailable" | "futures_reference_stale";
24
+ reason: string;
25
+ }
26
+ /**
27
+ * Pre-quote check for a NEW futures open. Returns a rejection only when the
28
+ * observation EXPLICITLY reports that the server gate requires a reference and
29
+ * has none (or only a stale one) for this coin. Everything else passes:
30
+ * - non-futures_open actions (spot, PM, futures_close / futures_set_sltp);
31
+ * - any open futures position on the same coin (the open is an add or position
32
+ * management, which the server never refuses for the reference);
33
+ * - unknown eligibility (field absent on an older API);
34
+ * - referenceRequired false.
35
+ */
36
+ export declare function futuresEntryPreflight(action: ProposedAction, observation: Observation): FuturesEntryPreflightRejection | null;
@@ -0,0 +1,90 @@
1
+ // Futures entry eligibility (API >= backend-v2 #106): GET /api/agent/market/{coinId}
2
+ // carries `futuresEntryEligibility`, what the server entry gate's own
3
+ // perpetual-reference rule says about a NEW futures open on the coin. Before it,
4
+ // universe_scan surfaced coins with no supported perpetual reference (tokenized
5
+ // ETFs such as SLVON/NAKA) and the runner spent a futures quote only to be
6
+ // refused with perpetual_reference_unavailable (J-74). The server gate stays
7
+ // authoritative: eligibility can change between observation and execution.
8
+ import { asObj, asStr } from "./extract.js";
9
+ import { baseSymbol } from "./setups.js";
10
+ const STATUSES = [
11
+ "eligible",
12
+ "reference_stale",
13
+ "reference_unavailable",
14
+ ];
15
+ // A zoned ISO 8601 date-time (the contract's `format: date-time`):
16
+ // YYYY-MM-DDTHH:MM[:SS[.fraction]] with Z or a +/-HH:MM offset. Date-only,
17
+ // zone-less and bare numbers ("0") are rejected even though Date.parse
18
+ // accepts them.
19
+ const ZONED_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
20
+ const isIsoDate = (v) => typeof v === "string" &&
21
+ ZONED_DATE_TIME.test(v) &&
22
+ Number.isFinite(Date.parse(v));
23
+ /**
24
+ * Strict parse of the market context field against its contract (backend-v2
25
+ * #106). Anything missing or malformed (an older API, a partial response, an
26
+ * unknown status, a non-boolean referenceRequired, a missing or invalid
27
+ * evaluatedAt, a non-positive or non-finite maxReferenceAgeHours, or a present
28
+ * but invalid referenceFetchedAt) is `undefined` = UNKNOWN. Unknown never
29
+ * blocks: the runner keeps today's behaviour and the server gate decides.
30
+ */
31
+ export function futuresEntryEligibilityOf(marketContext) {
32
+ const raw = asObj(asObj(marketContext).futuresEntryEligibility);
33
+ const status = asStr(raw.status);
34
+ if (!status || !STATUSES.includes(status))
35
+ return undefined;
36
+ if (typeof raw.referenceRequired !== "boolean")
37
+ return undefined;
38
+ const hours = raw.maxReferenceAgeHours;
39
+ if (typeof hours !== "number" || !Number.isFinite(hours) || hours <= 0)
40
+ return undefined;
41
+ if (!isIsoDate(raw.evaluatedAt))
42
+ return undefined;
43
+ const fetchedAt = raw.referenceFetchedAt;
44
+ if (fetchedAt != null && !isIsoDate(fetchedAt))
45
+ return undefined;
46
+ return {
47
+ status,
48
+ referenceRequired: raw.referenceRequired,
49
+ venue: asStr(raw.venue) ?? null,
50
+ symbol: asStr(raw.symbol) ?? null,
51
+ referenceFetchedAt: fetchedAt ?? null,
52
+ maxReferenceAgeHours: hours,
53
+ evaluatedAt: raw.evaluatedAt,
54
+ };
55
+ }
56
+ /**
57
+ * Pre-quote check for a NEW futures open. Returns a rejection only when the
58
+ * observation EXPLICITLY reports that the server gate requires a reference and
59
+ * has none (or only a stale one) for this coin. Everything else passes:
60
+ * - non-futures_open actions (spot, PM, futures_close / futures_set_sltp);
61
+ * - any open futures position on the same coin (the open is an add or position
62
+ * management, which the server never refuses for the reference);
63
+ * - unknown eligibility (field absent on an older API);
64
+ * - referenceRequired false.
65
+ */
66
+ export function futuresEntryPreflight(action, observation) {
67
+ if (action.type !== "futures_open")
68
+ return null;
69
+ const base = baseSymbol(action.symbol);
70
+ const holdsCoin = (observation.openPositions ?? []).some((p) => p.venue === "futures" &&
71
+ (p.status ?? "open") === "open" &&
72
+ baseSymbol(p.symbol) === base);
73
+ if (holdsCoin)
74
+ return null;
75
+ const entry = observation.watch.find((w) => baseSymbol(w.symbol) === base);
76
+ const e = entry?.futuresEntryEligibility;
77
+ if (!e || !e.referenceRequired || e.status === "eligible")
78
+ return null;
79
+ const when = ` at ${e.evaluatedAt}`;
80
+ if (e.status === "reference_stale") {
81
+ return {
82
+ code: "futures_reference_stale",
83
+ reason: `${action.symbol}: the perpetual reference${e.venue ? ` (${e.venue})` : ""} is stale or unusable for a NEW futures open${when} (too old, or its refresh time is missing, invalid or in the future); the server would refuse it (perpetual_reference_stale). Trade another coin or wait for a fresh reference.`,
84
+ };
85
+ }
86
+ return {
87
+ code: "futures_reference_unavailable",
88
+ reason: `${action.symbol}: CoinRithm has no supported perpetual reference for this coin${when}, so the server refuses a NEW futures open (perpetual_reference_unavailable). Use spot for it, or another coin for futures.`,
89
+ };
90
+ }