@coinrithm/mcp-trading 0.7.8 → 0.7.10

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 (40) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +93 -69
  3. package/dist/agent/capitalSizing.js +17 -4
  4. package/dist/agent/cli.js +11 -1
  5. package/dist/agent/client.d.ts +8 -1
  6. package/dist/agent/client.js +98 -39
  7. package/dist/agent/configurationWarnings.d.ts +1 -0
  8. package/dist/agent/configurationWarnings.js +21 -0
  9. package/dist/agent/decisionReceipt.d.ts +4 -1
  10. package/dist/agent/decisionReceipt.js +27 -3
  11. package/dist/agent/decisionValidator.js +4 -0
  12. package/dist/agent/engine.d.ts +1 -1
  13. package/dist/agent/engine.js +1 -1
  14. package/dist/agent/entryPredicates.d.ts +10 -0
  15. package/dist/agent/entryPredicates.js +67 -0
  16. package/dist/agent/gate.js +14 -11
  17. package/dist/agent/opportunityReporter.d.ts +16 -0
  18. package/dist/agent/opportunityReporter.js +38 -0
  19. package/dist/agent/prompt.js +5 -0
  20. package/dist/agent/providers.d.ts +2 -1
  21. package/dist/agent/providers.js +109 -19
  22. package/dist/agent/reconcileObservation.d.ts +2 -0
  23. package/dist/agent/reconcileObservation.js +33 -0
  24. package/dist/agent/runner.js +32 -74
  25. package/dist/agent/skill.js +6 -0
  26. package/dist/agent/skillValidator.js +3 -0
  27. package/dist/agent/state.js +17 -2
  28. package/dist/agent/strictLint.js +1 -0
  29. package/dist/agent/templates.js +34 -30
  30. package/dist/agent/types.d.ts +1 -0
  31. package/dist/agent/util.js +3 -3
  32. package/dist/client.js +3 -2
  33. package/dist/http.d.ts +7 -1
  34. package/dist/http.js +36 -16
  35. package/dist/httpCompletion.d.ts +33 -0
  36. package/dist/httpCompletion.js +220 -0
  37. package/dist/retryAfter.d.ts +1 -0
  38. package/dist/retryAfter.js +16 -0
  39. package/dist/tools.js +19 -1
  40. package/package.json +13 -2
@@ -4,8 +4,10 @@
4
4
  // from an agent file). 429 backs off on Retry-After; 401/403/409/422 are
5
5
  // FAIL-CLOSED cycle outcomes (returned, not retried). fetch + sleep are
6
6
  // injectable so tests run with no network and no real waits.
7
- import { sleep as realSleep } from "./util.js";
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ import { retryAfterSeconds } from "../retryAfter.js";
8
9
  export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
10
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
9
11
  function traceHeaders(trace) {
10
12
  const h = {};
11
13
  if (!trace)
@@ -30,6 +32,8 @@ export class CoinRithmClient {
30
32
  fetchFn;
31
33
  sleepFn;
32
34
  maxRetries;
35
+ requestTimeoutMs;
36
+ signal;
33
37
  extraHeaders;
34
38
  // Every 429 seen this session (read or write, retried or not) — feeds the
35
39
  // rate-limit-pressure kill-switch, which a write-only counter would miss.
@@ -38,8 +42,15 @@ export class CoinRithmClient {
38
42
  this.apiKey = cfg.apiKey;
39
43
  this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
40
44
  this.fetchFn = cfg.fetchFn ?? fetch;
41
- this.sleepFn = cfg.sleepFn ?? realSleep;
45
+ this.sleepFn = cfg.sleepFn;
42
46
  this.maxRetries = cfg.maxRetries ?? 3;
47
+ this.requestTimeoutMs = cfg.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
48
+ if (!Number.isSafeInteger(this.requestTimeoutMs) ||
49
+ this.requestTimeoutMs < 1 ||
50
+ this.requestTimeoutMs > 2_147_483_647) {
51
+ throw new Error("requestTimeoutMs must be an integer between 1 and 2147483647");
52
+ }
53
+ this.signal = cfg.signal;
43
54
  this.extraHeaders = cfg.extraHeaders;
44
55
  }
45
56
  async request(method, path, opts = {}) {
@@ -59,53 +70,101 @@ export class CoinRithmClient {
59
70
  };
60
71
  if (opts.body !== undefined)
61
72
  headers["Content-Type"] = "application/json";
62
- for (let attempt = 0;; attempt++) {
63
- let res;
64
- try {
65
- res = await this.fetchFn(url.toString(), {
73
+ const controller = new AbortController();
74
+ let timedOut = false;
75
+ const cancel = () => controller.abort(new Error("API request cancelled"));
76
+ const timer = setTimeout(() => {
77
+ timedOut = true;
78
+ controller.abort(new Error("API request deadline exceeded"));
79
+ }, this.requestTimeoutMs);
80
+ this.signal?.addEventListener("abort", cancel, { once: true });
81
+ if (this.signal?.aborted)
82
+ cancel();
83
+ let rejectAborted;
84
+ const aborted = new Promise((_, reject) => {
85
+ rejectAborted = () => reject(controller.signal.reason);
86
+ controller.signal.addEventListener("abort", rejectAborted, {
87
+ once: true,
88
+ });
89
+ if (controller.signal.aborted)
90
+ rejectAborted();
91
+ });
92
+ const perform = async () => {
93
+ for (let attempt = 0;; attempt++) {
94
+ controller.signal.throwIfAborted();
95
+ const res = await this.fetchFn(url.toString(), {
66
96
  method,
67
97
  headers,
68
98
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
99
+ signal: controller.signal,
69
100
  });
70
- }
71
- catch (err) {
72
- return {
73
- ok: false,
74
- status: 0,
75
- data: {
76
- error: "network_error",
77
- message: err instanceof Error ? err.message : String(err),
78
- },
79
- };
80
- }
81
- const retryAfter = Number(res.headers.get("retry-after"));
82
- if (res.status === 429)
83
- this.rateLimitHits += 1;
84
- if (res.status === 429 && attempt < this.maxRetries) {
85
- await this.sleepFn((Number.isFinite(retryAfter) ? retryAfter : 5) * 1000);
86
- continue;
87
- }
88
- const text = await res.text();
89
- let data = text;
90
- if (text) {
91
- try {
92
- data = JSON.parse(text);
101
+ controller.signal.throwIfAborted();
102
+ const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
103
+ if (res.status === 429)
104
+ this.rateLimitHits += 1;
105
+ if (res.status === 429 && attempt < this.maxRetries) {
106
+ // Release this response before waiting so retries do not retain sockets.
107
+ void res.body?.cancel().catch(() => { });
108
+ const delayMs = (retryAfter ?? 5) * 1000;
109
+ // Never shorten a provider's Retry-After or overflow a Node timer.
110
+ if (delayMs >= this.requestTimeoutMs)
111
+ await aborted;
112
+ else if (this.sleepFn)
113
+ await this.sleepFn(delayMs);
114
+ else
115
+ await sleep(delayMs, undefined, { signal: controller.signal });
116
+ continue;
93
117
  }
94
- catch {
95
- /* leave as text */
118
+ const text = await res.text();
119
+ controller.signal.throwIfAborted();
120
+ let data = text;
121
+ if (text) {
122
+ try {
123
+ data = JSON.parse(text);
124
+ }
125
+ catch {
126
+ /* leave as text */
127
+ }
96
128
  }
129
+ return {
130
+ ok: res.ok,
131
+ status: res.status,
132
+ data,
133
+ retryAfterSeconds: res.status === 429 ? retryAfter : undefined,
134
+ rateLimitRemaining: Number(res.headers.get("ratelimit-remaining")) || undefined,
135
+ ledgerEventId: res.headers.get("x-coinrithm-ledger-event-id"),
136
+ };
97
137
  }
138
+ };
139
+ try {
140
+ // The race also bounds injected transports that do not honor AbortSignal.
141
+ return await Promise.race([perform(), aborted]);
142
+ }
143
+ catch (err) {
98
144
  return {
99
- ok: res.ok,
100
- status: res.status,
101
- data,
102
- retryAfterSeconds: res.status === 429 && Number.isFinite(retryAfter)
103
- ? retryAfter
104
- : undefined,
105
- rateLimitRemaining: Number(res.headers.get("ratelimit-remaining")) || undefined,
106
- ledgerEventId: res.headers.get("x-coinrithm-ledger-event-id"),
145
+ ok: false,
146
+ status: 0,
147
+ data: {
148
+ error: timedOut
149
+ ? "request_timeout"
150
+ : controller.signal.aborted
151
+ ? "request_aborted"
152
+ : "network_error",
153
+ message: timedOut
154
+ ? `API request exceeded ${this.requestTimeoutMs}ms deadline`
155
+ : controller.signal.aborted
156
+ ? "API request cancelled"
157
+ : err instanceof Error
158
+ ? err.message
159
+ : String(err),
160
+ },
107
161
  };
108
162
  }
163
+ finally {
164
+ clearTimeout(timer);
165
+ this.signal?.removeEventListener("abort", cancel);
166
+ controller.signal.removeEventListener("abort", rejectAborted);
167
+ }
109
168
  }
110
169
  // ── reads ──────────────────────────────────────────────────────────────────
111
170
  me(trace) {
@@ -0,0 +1 @@
1
+ export declare function configurationWarnings(raw: Record<string, unknown>): string[];
@@ -0,0 +1,21 @@
1
+ // Advisory only: these fields remain loadable for backwards compatibility.
2
+ export function configurationWarnings(raw) {
3
+ const warnings = [];
4
+ if (Array.isArray(raw.capabilities) &&
5
+ raw.capabilities.includes("websearch")) {
6
+ warnings.push("capabilities.websearch is reserved: the runner does not perform web searches");
7
+ }
8
+ if (raw.abstention && typeof raw.abstention === "object") {
9
+ for (const key of [
10
+ "onStaleData",
11
+ "onWeakSignal",
12
+ "onMissingQuote",
13
+ "onInsufficientBalance",
14
+ ]) {
15
+ if (Object.prototype.hasOwnProperty.call(raw.abstention, key)) {
16
+ warnings.push(`abstention.${key} is inactive: changing it does not change execution; freshness, quote and balance checks remain mandatory`);
17
+ }
18
+ }
19
+ }
20
+ return warnings;
21
+ }
@@ -3,9 +3,12 @@ import { type DailyRiskBudget } from "./prompt.js";
3
3
  export declare const DECISION_INPUT_MAX_BYTES: number;
4
4
  export type DecisionInputPhase = "before_observation" | "observed" | "decision_input";
5
5
  type Scalar = string | number | boolean | null;
6
- type Row = Record<string, Scalar | string[] | Record<string, Scalar | string[]>>;
6
+ interface Row {
7
+ [key: string]: Scalar | string[] | Row;
8
+ }
7
9
  export interface DecisionInputRecord {
8
10
  version: "coinrithm.decision-input.v1";
11
+ projectionVersion?: "coinrithm.decision-input-projection.v2";
9
12
  visibility: "private";
10
13
  completeness: "partial";
11
14
  phase: DecisionInputPhase;
@@ -49,6 +49,7 @@ export function buildDecisionInputRecord(input) {
49
49
  const budget = buildDailyRiskBudget(input.spec, input.state);
50
50
  const record = {
51
51
  version: "coinrithm.decision-input.v1",
52
+ projectionVersion: "coinrithm.decision-input-projection.v2",
52
53
  visibility: "private",
53
54
  completeness: "partial",
54
55
  phase: input.phase,
@@ -149,6 +150,12 @@ export function buildDecisionInputRecord(input) {
149
150
  ema20AboveEma50: bool(obj(r.indicators).ema20AboveEma50),
150
151
  brokeRecentHigh: bool(obj(r.indicators).brokeRecentHigh),
151
152
  brokeRecentLow: bool(obj(r.indicators).brokeRecentLow),
153
+ bollinger: numeric(obj(obj(r.indicators).bollinger), [
154
+ "upper",
155
+ "mid",
156
+ "lower",
157
+ ]),
158
+ recent20: numeric(obj(obj(r.indicators).recent20), ["high", "low"]),
152
159
  },
153
160
  fundamentals: numeric(obj(r.fundamentals), [
154
161
  "marketCapRank",
@@ -156,11 +163,18 @@ export function buildDecisionInputRecord(input) {
156
163
  "volume24hUsd",
157
164
  ]),
158
165
  }));
166
+ // Context-only movers are not executable candidates. Preserve their order
167
+ // and numeric facts without copying provider names or arbitrary free text.
168
+ add("universeMovers", obs.universeMovers, (r) => ({
169
+ symbol: id(r.symbol, 20),
170
+ ...numeric(r, ["change24hPct", "priceUsd"]),
171
+ }));
159
172
  add("futuresPositions", obs.openPositions, (r) => ({
160
173
  id: num(r.id),
161
174
  symbol: id(r.symbol, 20),
162
175
  coinId: id(r.coinId, 32),
163
176
  side: code(r.side, ["long", "short"]),
177
+ openedAt: sourceTimestamp(r.openedAt) ?? null,
164
178
  ...numeric(r, [
165
179
  "leverage",
166
180
  "marginMusd",
@@ -238,7 +252,6 @@ export function buildDecisionInputRecord(input) {
238
252
  ["news", obs.news],
239
253
  ["pmResolutions", obs.pmResolutions],
240
254
  ["newClosedTrades", obs.newClosedTrades],
241
- ["universeMovers", obs.universeMovers],
242
255
  ["journal", input.state.journal],
243
256
  ]) {
244
257
  record.counts[name] = {
@@ -247,7 +260,7 @@ export function buildDecisionInputRecord(input) {
247
260
  omitted: arr(values).length,
248
261
  };
249
262
  }
250
- record.omissions.push("unlisted_fields_and_nested_indicators_excluded");
263
+ record.omissions.push("unlisted_fields_excluded");
251
264
  if (Object.values(record.counts).some((v) => v.omitted > 0))
252
265
  record.omissions.push("list_rows_omitted");
253
266
  // Leave room for fixed outcome/error metadata. Drop the largest remaining
@@ -303,6 +316,7 @@ const OMISSIONS = [
303
316
  "config_fingerprint_unavailable",
304
317
  "observation_not_available",
305
318
  "unlisted_fields_and_nested_indicators_excluded",
319
+ "unlisted_fields_excluded",
306
320
  "list_rows_omitted",
307
321
  "byte_budget_exceeded",
308
322
  "evidence_capture_failed",
@@ -327,6 +341,7 @@ const LIST_KEYS = {
327
341
  "symbol",
328
342
  "coinId",
329
343
  "side",
344
+ "openedAt",
330
345
  "leverage",
331
346
  "marginMusd",
332
347
  "unrealizedPnlMusd",
@@ -361,6 +376,7 @@ const LIST_KEYS = {
361
376
  "decisionSupport",
362
377
  ],
363
378
  signals: ["symbol", "kind", "bias", "strength", "held"],
379
+ universeMovers: ["symbol", "change24hPct", "priceUsd"],
364
380
  };
365
381
  const NESTED_KEYS = {
366
382
  freshness: ["status", "ageSeconds", "asOf", "basis"],
@@ -391,7 +407,11 @@ const NESTED_KEYS = {
391
407
  "ema20AboveEma50",
392
408
  "brokeRecentHigh",
393
409
  "brokeRecentLow",
410
+ "bollinger",
411
+ "recent20",
394
412
  ],
413
+ bollinger: ["upper", "mid", "lower"],
414
+ recent20: ["high", "low"],
395
415
  fundamentals: ["marketCapRank", "marketCapUsd", "volume24hUsd"],
396
416
  };
397
417
  function keysOnly(value, keys) {
@@ -408,7 +428,7 @@ function validRow(value, keys) {
408
428
  return true;
409
429
  if (NESTED_KEYS[key])
410
430
  return validRow(v, NESTED_KEYS[key]);
411
- if (key === "asOf" || key === "assessedAt")
431
+ if (key === "asOf" || key === "assessedAt" || key === "openedAt")
412
432
  return sourceTimestamp(v) === v;
413
433
  if (key === "basis")
414
434
  return code(v, FRESHNESS_BASES) !== null;
@@ -489,6 +509,7 @@ export function sanitizeDecisionInputRecord(value) {
489
509
  const r = JSON.parse(encoded);
490
510
  if (!keysOnly(r, [
491
511
  "version",
512
+ "projectionVersion",
492
513
  "visibility",
493
514
  "completeness",
494
515
  "phase",
@@ -506,6 +527,9 @@ export function sanitizeDecisionInputRecord(value) {
506
527
  "omissions",
507
528
  ]))
508
529
  return undefined;
530
+ if (r.projectionVersion !== undefined &&
531
+ r.projectionVersion !== "coinrithm.decision-input-projection.v2")
532
+ return undefined;
509
533
  if (r.version !== "coinrithm.decision-input.v1" ||
510
534
  r.visibility !== "private" ||
511
535
  r.completeness !== "partial")
@@ -3,6 +3,7 @@
3
3
  // come from the spec (not the observation or the model), a prompt-injection in
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
+ import { checkEntryPredicates } from "./entryPredicates.js";
6
7
  const SERVER_MAX_LEVERAGE = 20;
7
8
  // Minimum edge, in probability POINTS, between the model's own forecast for
8
9
  // the outcome it is backing and what that outcome currently costs. Live
@@ -51,6 +52,9 @@ export function validateAction(action, ctx) {
51
52
  return fail("no_poll_before_write", "must successfully poll /trades before writing");
52
53
  }
53
54
  const increasesRisk = isRiskIncreasingAction(action);
55
+ const entryCondition = checkEntryPredicates(action, spec, observation);
56
+ if (!entryCondition.valid)
57
+ return entryCondition;
54
58
  if (increasesRisk &&
55
59
  ctx.riskIncreasesThisCycle >= spec.limits.maxWritesPerCycle) {
56
60
  return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
@@ -8,7 +8,7 @@ export { CoinRithmClient } from "./client.js";
8
8
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
9
9
  export { resolveAgent } from "./resolve.js";
10
10
  export { validateSkill, type SkillValidationMode } from "./skillValidator.js";
11
- export { newState, rollDay } from "./state.js";
11
+ export { newState, rollDay, loadState, saveState } from "./state.js";
12
12
  export { makeRunId } from "./runEvidence.js";
13
13
  export { parseCadenceMs } from "./util.js";
14
14
  export { BENCHMARK_AGENTS, BENCHMARK_STRATEGIES, decideMechanical, isBenchmarkStrategy, type BenchmarkStrategy, type BenchmarkAgentDefinition, } from "./mechanical.js";
@@ -18,7 +18,7 @@ export { CoinRithmClient } from "./client.js";
18
18
  export { loadAgent, buildSpec } from "./skill.js";
19
19
  export { resolveAgent } from "./resolve.js";
20
20
  export { validateSkill } from "./skillValidator.js";
21
- export { newState, rollDay } from "./state.js";
21
+ export { newState, rollDay, loadState, saveState } from "./state.js";
22
22
  export { makeRunId } from "./runEvidence.js";
23
23
  export { parseCadenceMs } from "./util.js";
24
24
  // Mechanical BENCHMARK baseline agents (sol #7): the deterministic, non-LLM
@@ -0,0 +1,10 @@
1
+ import type { AgentSpec, Observation, ProposedAction, ValidationResult } from "./types.js";
2
+ export interface EntryPredicate {
3
+ side: "long" | "short";
4
+ metric: "change1h" | "change24h";
5
+ operator: "gte" | "lte";
6
+ threshold: number;
7
+ maxAgeSeconds: number;
8
+ }
9
+ export declare function entryPredicateIssues(value: unknown): ValidationResult[];
10
+ export declare function checkEntryPredicates(action: ProposedAction, spec: AgentSpec, observation: Observation): ValidationResult;
@@ -0,0 +1,67 @@
1
+ import { fail, ok } from "./types.js";
2
+ export function entryPredicateIssues(value) {
3
+ if (!Array.isArray(value) || value.length === 0 || value.length > 16)
4
+ return [
5
+ fail("entry_predicate_config", "risk.entryPredicates must contain 1–16 conditions"),
6
+ ];
7
+ const keys = ["side", "metric", "operator", "threshold", "maxAgeSeconds"];
8
+ const issues = [];
9
+ for (const [i, item] of value.entries()) {
10
+ const p = item;
11
+ if (!p ||
12
+ typeof p !== "object" ||
13
+ Array.isArray(p) ||
14
+ Object.keys(p).some((key) => !keys.includes(key)) ||
15
+ !["long", "short"].includes(p.side) ||
16
+ !["change1h", "change24h"].includes(p.metric) ||
17
+ !["gte", "lte"].includes(p.operator) ||
18
+ typeof p.threshold !== "number" ||
19
+ !Number.isFinite(p.threshold) ||
20
+ typeof p.maxAgeSeconds !== "number" ||
21
+ !Number.isFinite(p.maxAgeSeconds) ||
22
+ p.maxAgeSeconds <= 0) {
23
+ issues.push(fail("entry_predicate_config", `risk.entryPredicates[${i}] has an invalid condition`));
24
+ }
25
+ }
26
+ return issues;
27
+ }
28
+ export function checkEntryPredicates(action, spec, observation) {
29
+ const rules = spec.risk.entryPredicates;
30
+ if (rules === undefined)
31
+ return ok();
32
+ if (action.type !== "futures_open" &&
33
+ !(action.type === "spot_order" && action.side === "buy"))
34
+ return ok();
35
+ const invalid = entryPredicateIssues(rules);
36
+ if (invalid.length)
37
+ return invalid[0];
38
+ const side = action.type === "futures_open" ? action.side : "long";
39
+ const applicable = rules.filter((rule) => rule.side === side);
40
+ if (!applicable.length)
41
+ return ok();
42
+ const entry = observation.watch.find((item) => item.symbol.toUpperCase() === action.symbol.toUpperCase());
43
+ const observedAt = Date.parse(observation.asOf);
44
+ const age = entry?.freshness?.ageSeconds;
45
+ for (const rule of applicable) {
46
+ const value = entry?.[rule.metric];
47
+ // Missing evidence never becomes a passing predicate. Include time since
48
+ // observation so a long model call cannot freeze the freshness clock.
49
+ if (entry?.freshness?.status !== "fresh" ||
50
+ typeof age !== "number" ||
51
+ !Number.isFinite(age) ||
52
+ age < 0 ||
53
+ !Number.isFinite(observedAt) ||
54
+ age + Math.max(0, (Date.now() - observedAt) / 1000) >
55
+ rule.maxAgeSeconds ||
56
+ typeof value !== "number" ||
57
+ !Number.isFinite(value)) {
58
+ return fail("entry_predicate_evidence", `fresh ${rule.metric} evidence required for ${action.symbol}`);
59
+ }
60
+ if (!(rule.operator === "gte"
61
+ ? value >= rule.threshold
62
+ : value <= rule.threshold)) {
63
+ return fail("entry_predicate_false", `${action.symbol} ${rule.metric}=${value} does not satisfy ${rule.operator} ${rule.threshold}`);
64
+ }
65
+ }
66
+ return ok();
67
+ }
@@ -58,27 +58,27 @@ export function evaluateGate(observation, state, policy, nowMs) {
58
58
  // evaluate PREDICTION MARKETS — they carry edge even when crypto prices are flat,
59
59
  // so an agent on a quiet tape shouldn't go dark on PM. At most once per cooldown
60
60
  // (gated on the last LLM call, which any fire resets), so it's not every cycle.
61
+ let pmPeriodic = false;
61
62
  if (codeList.length === 0) {
62
63
  const pmAvailable = observation.pmMarkets.length > 0;
63
64
  const sinceLastCall = state.lastLlmCallAt == null ? Infinity : nowMs - state.lastLlmCallAt;
64
65
  if (pmAvailable &&
65
66
  policy.pmEvalCooldownMinutes > 0 &&
66
67
  sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
68
+ pmPeriodic = true;
69
+ codeList.push("PM_PERIODIC");
70
+ }
71
+ else {
67
72
  return {
68
- fire: true,
69
- codes: ["PM_PERIODIC"],
70
- reason: "PM periodic eval (quiet price tape)",
73
+ fire: false,
74
+ codes: [],
75
+ reason: "no trigger (flat tape, no open position)",
71
76
  };
72
77
  }
73
- return {
74
- fire: false,
75
- codes: [],
76
- reason: "no trigger (flat tape, no open position)",
77
- };
78
78
  }
79
79
  // A real trigger exists. Open positions are NEVER starved by budget/debounce
80
80
  // (managing a live position is always allowed); the caps below only throttle
81
- // fresh entry-only cycles so a chop-storm of entry setups can't burn the budget.
81
+ // fresh entry-only cycles, including periodic PM evaluations.
82
82
  if (!hasPosition) {
83
83
  if (policy.maxLlmCallsPerHour > 0) {
84
84
  const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
@@ -90,7 +90,8 @@ export function evaluateGate(observation, state, policy, nowMs) {
90
90
  };
91
91
  }
92
92
  }
93
- if (policy.debounceMinutes > 0) {
93
+ // PM already passed its own cooldown; debounce only crypto-entry triggers.
94
+ if (!pmPeriodic && policy.debounceMinutes > 0) {
94
95
  const fp = [...codeList].sort().join(",");
95
96
  if (state.lastTriggerFingerprint === fp &&
96
97
  state.lastLlmCallAt != null &&
@@ -106,7 +107,9 @@ export function evaluateGate(observation, state, policy, nowMs) {
106
107
  return {
107
108
  fire: true,
108
109
  codes: codeList,
109
- reason: `triggers: ${codeList.join(",")}`,
110
+ reason: pmPeriodic
111
+ ? "PM periodic eval (quiet price tape)"
112
+ : `triggers: ${codeList.join(",")}`,
110
113
  };
111
114
  }
112
115
  // Record that this cycle spent an LLM call — feeds the budget + debounce next
@@ -0,0 +1,16 @@
1
+ import type { CoinRithmClient, ProvenanceReport } from "./client.js";
2
+ import type { AgentSpec, AgentTrace, PostedOpportunity } from "./types.js";
3
+ export declare function createOpportunityReporter({ client, spec, live, enabled, runId, decisionId, provenance, baseTrace, log, }: {
4
+ client: CoinRithmClient;
5
+ spec: AgentSpec;
6
+ live: boolean;
7
+ enabled: boolean;
8
+ runId: string;
9
+ decisionId: string;
10
+ provenance: ProvenanceReport;
11
+ baseTrace: AgentTrace;
12
+ log: (line: string) => void;
13
+ }): {
14
+ post: (o: PostedOpportunity) => Promise<void>;
15
+ readonly posted: PostedOpportunity | undefined;
16
+ };
@@ -0,0 +1,38 @@
1
+ export function createOpportunityReporter({ client, spec, live, enabled, runId, decisionId, provenance, baseTrace, log, }) {
2
+ let opportunityPosted = false;
3
+ let postedOpportunity;
4
+ const post = async (o) => {
5
+ if (!enabled || !live || opportunityPosted)
6
+ return;
7
+ opportunityPosted = true;
8
+ postedOpportunity = o;
9
+ try {
10
+ await client.reportPmOpportunity({
11
+ kind: o.kind,
12
+ source: o.source,
13
+ slug: o.slug,
14
+ outcomeExternalMarketId: o.outcomeExternalMarketId,
15
+ forecastProbability: o.forecastProbability,
16
+ marketProbability: o.marketProbability,
17
+ reasonCode: o.reasonCode,
18
+ cohort: {
19
+ universeSize: o.universeSize,
20
+ horizon: spec.objective?.horizon,
21
+ },
22
+ decisionId,
23
+ runId,
24
+ provenance,
25
+ }, baseTrace);
26
+ log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
27
+ }
28
+ catch (err) {
29
+ log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ };
32
+ return {
33
+ post,
34
+ get posted() {
35
+ return postedOpportunity;
36
+ },
37
+ };
38
+ }
@@ -117,6 +117,11 @@ opts = {}) {
117
117
  : '- DIRECTION: LONG ONLY — every futures_open MUST be side:"long". A short is REJECTED by the runner no matter how strong the setup looks.',
118
118
  ]
119
119
  : []),
120
+ ...(r.entryPredicates
121
+ ? [
122
+ `- Binding crypto entry conditions (all conditions matching the entry side must pass on fresh observed data): ${JSON.stringify(r.entryPredicates)}. Missing evidence is a rejection. These conditions never authorize exceeding another cap.`,
123
+ ]
124
+ : []),
120
125
  // With universe_scan, the validator's gate is WATCH-membership (manual
121
126
  // watchlist ∪ this cycle's discovered entries) — saying "ONLY these" here
122
127
  // while the universe-scan section below calls discovered movers tradable
@@ -20,7 +20,7 @@ export interface DecideRouteMeta {
20
20
  profile: "fast" | "strong" | "configured";
21
21
  effectiveProvider?: string;
22
22
  effectiveModel?: string;
23
- reason: "configured" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
23
+ reason: "configured" | "configured_direct" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
24
24
  attempts: DecideRouteAttempt[];
25
25
  }
26
26
  export type DecideResult = {
@@ -36,6 +36,7 @@ export type DecideResult = {
36
36
  error: string;
37
37
  status?: number;
38
38
  retryAfterMs?: number;
39
+ retryableHttpFailure?: true;
39
40
  deferred?: boolean;
40
41
  route?: DecideRouteMeta;
41
42
  };