@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.
- package/CHANGELOG.md +121 -0
- package/README.md +93 -69
- package/dist/agent/capitalSizing.js +17 -4
- package/dist/agent/cli.js +11 -1
- package/dist/agent/client.d.ts +8 -1
- package/dist/agent/client.js +98 -39
- package/dist/agent/configurationWarnings.d.ts +1 -0
- package/dist/agent/configurationWarnings.js +21 -0
- package/dist/agent/decisionReceipt.d.ts +4 -1
- package/dist/agent/decisionReceipt.js +27 -3
- package/dist/agent/decisionValidator.js +4 -0
- package/dist/agent/engine.d.ts +1 -1
- package/dist/agent/engine.js +1 -1
- package/dist/agent/entryPredicates.d.ts +10 -0
- package/dist/agent/entryPredicates.js +67 -0
- package/dist/agent/gate.js +14 -11
- package/dist/agent/opportunityReporter.d.ts +16 -0
- package/dist/agent/opportunityReporter.js +38 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/providers.d.ts +2 -1
- package/dist/agent/providers.js +109 -19
- package/dist/agent/reconcileObservation.d.ts +2 -0
- package/dist/agent/reconcileObservation.js +33 -0
- package/dist/agent/runner.js +32 -74
- package/dist/agent/skill.js +6 -0
- package/dist/agent/skillValidator.js +3 -0
- package/dist/agent/state.js +17 -2
- package/dist/agent/strictLint.js +1 -0
- package/dist/agent/templates.js +34 -30
- package/dist/agent/types.d.ts +1 -0
- package/dist/agent/util.js +3 -3
- package/dist/client.js +3 -2
- package/dist/http.d.ts +7 -1
- package/dist/http.js +36 -16
- package/dist/httpCompletion.d.ts +33 -0
- package/dist/httpCompletion.js +220 -0
- package/dist/retryAfter.d.ts +1 -0
- package/dist/retryAfter.js +16 -0
- package/dist/tools.js +19 -1
- package/package.json +13 -2
package/dist/agent/client.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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:
|
|
100
|
-
status:
|
|
101
|
-
data
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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("
|
|
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`);
|
package/dist/agent/engine.d.ts
CHANGED
|
@@ -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";
|
package/dist/agent/engine.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/agent/gate.js
CHANGED
|
@@ -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:
|
|
69
|
-
codes: [
|
|
70
|
-
reason: "
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/dist/agent/prompt.js
CHANGED
|
@@ -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
|
};
|