@coinrithm/mcp-trading 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/README.md +9 -7
- package/dist/agent/act.d.ts +2 -2
- package/dist/agent/act.js +24 -3
- package/dist/agent/cli.js +59 -18
- package/dist/agent/client.d.ts +33 -0
- package/dist/agent/client.js +34 -7
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +26 -3
- package/dist/agent/decisionValidator.js +2 -1
- package/dist/agent/deploymentOverlay.js +25 -5
- package/dist/agent/engine.d.ts +2 -1
- package/dist/agent/engine.js +4 -1
- package/dist/agent/extract.js +3 -1
- package/dist/agent/gate.js +25 -5
- package/dist/agent/index.js +0 -1
- package/dist/agent/indicators.js +4 -2
- package/dist/agent/manifest.js +1 -1
- package/dist/agent/mechanical.d.ts +36 -0
- package/dist/agent/mechanical.js +286 -0
- package/dist/agent/observe.js +120 -52
- package/dist/agent/prompt.d.ts +3 -1
- package/dist/agent/prompt.js +17 -6
- package/dist/agent/providers.js +39 -4
- package/dist/agent/resolve.js +23 -6
- package/dist/agent/resolvePm.js +14 -3
- package/dist/agent/runEvidence.js +6 -2
- package/dist/agent/runner.d.ts +8 -2
- package/dist/agent/runner.js +363 -59
- package/dist/agent/scorecard.js +12 -4
- package/dist/agent/setups.js +57 -9
- package/dist/agent/skill.js +1 -1
- package/dist/agent/state.js +9 -4
- package/dist/agent/types.d.ts +17 -2
- package/dist/agent/types.js +2 -1
- package/dist/agent/util.js +11 -4
- package/dist/agent/version.d.ts +1 -1
- package/dist/agent/version.js +1 -1
- package/dist/client.d.ts +33 -0
- package/dist/client.js +12 -3
- package/dist/executionPolicy.d.ts +2 -0
- package/dist/executionPolicy.js +21 -0
- package/dist/http.js +10 -2
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +214 -29
- package/package.json +9 -1
package/dist/agent/gate.js
CHANGED
|
@@ -64,9 +64,17 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
64
64
|
if (pmAvailable &&
|
|
65
65
|
policy.pmEvalCooldownMinutes > 0 &&
|
|
66
66
|
sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
|
|
67
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
fire: true,
|
|
69
|
+
codes: ["PM_PERIODIC"],
|
|
70
|
+
reason: "PM periodic eval (quiet price tape)",
|
|
71
|
+
};
|
|
68
72
|
}
|
|
69
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
fire: false,
|
|
75
|
+
codes: [],
|
|
76
|
+
reason: "no trigger (flat tape, no open position)",
|
|
77
|
+
};
|
|
70
78
|
}
|
|
71
79
|
// A real trigger exists. Open positions are NEVER starved by budget/debounce
|
|
72
80
|
// (managing a live position is always allowed); the caps below only throttle
|
|
@@ -75,7 +83,11 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
75
83
|
if (policy.maxLlmCallsPerHour > 0) {
|
|
76
84
|
const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
|
|
77
85
|
if (recent.length >= policy.maxLlmCallsPerHour) {
|
|
78
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
fire: false,
|
|
88
|
+
codes: codeList,
|
|
89
|
+
reason: `hourly LLM budget ${policy.maxLlmCallsPerHour} reached`,
|
|
90
|
+
};
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
if (policy.debounceMinutes > 0) {
|
|
@@ -83,11 +95,19 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
83
95
|
if (state.lastTriggerFingerprint === fp &&
|
|
84
96
|
state.lastLlmCallAt != null &&
|
|
85
97
|
nowMs - state.lastLlmCallAt < policy.debounceMinutes * 60_000) {
|
|
86
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
fire: false,
|
|
100
|
+
codes: codeList,
|
|
101
|
+
reason: `debounced (same triggers within ${policy.debounceMinutes}m)`,
|
|
102
|
+
};
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
105
|
}
|
|
90
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
fire: true,
|
|
108
|
+
codes: codeList,
|
|
109
|
+
reason: `triggers: ${codeList.join(",")}`,
|
|
110
|
+
};
|
|
91
111
|
}
|
|
92
112
|
// Record that this cycle spent an LLM call — feeds the budget + debounce next
|
|
93
113
|
// cycle. Mutates state; the caller persists it.
|
package/dist/agent/index.js
CHANGED
package/dist/agent/indicators.js
CHANGED
|
@@ -133,14 +133,16 @@ export function computeIndicators(candles, opts = {}) {
|
|
|
133
133
|
const f = Math.pow(10, figs - Math.ceil(Math.log10(Math.abs(n))));
|
|
134
134
|
return Math.round(n * f) / f;
|
|
135
135
|
};
|
|
136
|
-
const sigN = (n, figs = 6) =>
|
|
136
|
+
const sigN = (n, figs = 6) => n == null ? null : sig(n, figs);
|
|
137
137
|
return {
|
|
138
138
|
asOfClose: sig(close),
|
|
139
139
|
rsi14: rsi14 == null ? null : Math.round(rsi14 * 10) / 10,
|
|
140
140
|
ema20: sigN(ema20),
|
|
141
141
|
ema50: sigN(ema50),
|
|
142
142
|
atr14: sigN(atr14),
|
|
143
|
-
bollinger: bb == null
|
|
143
|
+
bollinger: bb == null
|
|
144
|
+
? null
|
|
145
|
+
: { upper: sig(bb.upper), mid: sig(bb.mid), lower: sig(bb.lower) },
|
|
144
146
|
recent20: r20 == null ? null : { high: sig(r20.high), low: sig(r20.low) },
|
|
145
147
|
aboveEma20: ema20 == null ? null : close > ema20,
|
|
146
148
|
ema20AboveEma50: ema20 == null || ema50 == null ? null : ema20 > ema50,
|
package/dist/agent/manifest.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { sha256, stableStringify, toPosix } from "./util.js";
|
|
8
|
-
import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA } from "./version.js";
|
|
8
|
+
import { RESOLVER_VERSION, RUNNER_VERSION, MANIFEST_SCHEMA, } from "./version.js";
|
|
9
9
|
export function buildManifest(resolved, spec) {
|
|
10
10
|
// configHash binds the RESOLVED spec to the resolver + schema version, so a
|
|
11
11
|
// run reproduces only against the same compile (not just the same files).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AgentSpec, Decision, Observation, PmMarket } from "./types.js";
|
|
2
|
+
export declare const BENCHMARK_STRATEGIES: readonly ["market-implied", "base-rate", "random"];
|
|
3
|
+
export type BenchmarkStrategy = (typeof BENCHMARK_STRATEGIES)[number];
|
|
4
|
+
export declare function isBenchmarkStrategy(s: string): s is BenchmarkStrategy;
|
|
5
|
+
export declare const BASE_RATE_UNINFORMATIVE = 50;
|
|
6
|
+
export declare const BENCHMARK_STAKE_MUSD = 10;
|
|
7
|
+
export declare const RANDOM_FORECAST_MIN = 20;
|
|
8
|
+
export declare const RANDOM_FORECAST_MAX = 80;
|
|
9
|
+
export declare function marketKey(m: {
|
|
10
|
+
source: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
outcomeExternalMarketId: string;
|
|
13
|
+
}): string;
|
|
14
|
+
export declare function seededRandomForecast(seed: string): number;
|
|
15
|
+
export declare function pickBenchmarkMarket(markets: PmMarket[], heldKeys?: Set<string>): PmMarket | undefined;
|
|
16
|
+
export declare function benchmarkForecast(strategy: BenchmarkStrategy, market: PmMarket, dateKey: string): number | undefined;
|
|
17
|
+
export interface MechanicalDecideInput {
|
|
18
|
+
strategy: string;
|
|
19
|
+
observation: Observation;
|
|
20
|
+
dateKey?: string;
|
|
21
|
+
stakeMusd?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface MechanicalDecideResult {
|
|
24
|
+
decision: Decision;
|
|
25
|
+
log: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function decideMechanical(input: MechanicalDecideInput): MechanicalDecideResult;
|
|
28
|
+
export interface BenchmarkAgentDefinition {
|
|
29
|
+
handle: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
strategy: BenchmarkStrategy;
|
|
32
|
+
cadenceSeconds: number;
|
|
33
|
+
spec: AgentSpec;
|
|
34
|
+
prose: string;
|
|
35
|
+
}
|
|
36
|
+
export declare const BENCHMARK_AGENTS: BenchmarkAgentDefinition[];
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Mechanical BENCHMARK baseline agents — the living reference line the Arena
|
|
2
|
+
// measures skill against (sol-audit #7 baselines).
|
|
3
|
+
//
|
|
4
|
+
// These are NOT LLM agents. There is no model, no prompt, and no inference cost.
|
|
5
|
+
// Each cycle the runner short-circuits the provider and computes a decision
|
|
6
|
+
// deterministically from the observation, so a benchmark's forecast is fully
|
|
7
|
+
// reproducible from (market, date) alone. Three strategies, ALL mechanical:
|
|
8
|
+
//
|
|
9
|
+
// • market-implied — submits a forecast EXACTLY equal to the market's own
|
|
10
|
+
// probability at decision time. This is the definition of the baseline the
|
|
11
|
+
// forecast-skill scorecard measures against, and the ONLY agent for which an
|
|
12
|
+
// echo of the market price is correct BY DESIGN. Its description says
|
|
13
|
+
// BENCHMARK so the runner's anti-echo log is never read as a defect.
|
|
14
|
+
// • base-rate — submits an uninformative 50 for every market. We do NOT
|
|
15
|
+
// invent per-category historical base rates: the observation carries no
|
|
16
|
+
// calibrated category prior, so the honest baseline is the uninformative
|
|
17
|
+
// prior. If a cheap calibrated base rate is ever surfaced in the
|
|
18
|
+
// observation, swap it in here (documented, never fabricated).
|
|
19
|
+
// • random — submits a deterministic pseudo-random forecast in [20,80]
|
|
20
|
+
// seeded from (market key, UTC date), so a run is reproducible and a "null"
|
|
21
|
+
// forecaster's noise floor is a fair, stable comparison.
|
|
22
|
+
//
|
|
23
|
+
// The market PICK rule is identical across all three (highest-volume eligible
|
|
24
|
+
// market that carries a usable probability and is not already held), so the
|
|
25
|
+
// three benchmarks bet the SAME markets and differ ONLY in the forecast — which
|
|
26
|
+
// is exactly what a clean baseline comparison needs.
|
|
27
|
+
import { SPEC_VERSION, } from "./types.js";
|
|
28
|
+
import { dayKey } from "./util.js";
|
|
29
|
+
// ───────────────────────── Strategy vocabulary ──────────────────────────────
|
|
30
|
+
export const BENCHMARK_STRATEGIES = [
|
|
31
|
+
"market-implied",
|
|
32
|
+
"base-rate",
|
|
33
|
+
"random",
|
|
34
|
+
];
|
|
35
|
+
export function isBenchmarkStrategy(s) {
|
|
36
|
+
return BENCHMARK_STRATEGIES.includes(s);
|
|
37
|
+
}
|
|
38
|
+
// The uninformative prior the base-rate benchmark submits. Documented, NOT an
|
|
39
|
+
// invented per-category historical rate: the observation carries no calibrated
|
|
40
|
+
// category prior to read, so 50 (maximum entropy for a binary) is the honest,
|
|
41
|
+
// non-fabricated baseline. See the module header.
|
|
42
|
+
export const BASE_RATE_UNINFORMATIVE = 50;
|
|
43
|
+
// Tiny fixed stake (mUSD). Equal to the server's PM minimum so a benchmark bets
|
|
44
|
+
// the smallest honest ticket — it exists to record forecasts, not to size risk.
|
|
45
|
+
export const BENCHMARK_STAKE_MUSD = 10;
|
|
46
|
+
// The random/null benchmark's forecast range (inclusive), kept away from the
|
|
47
|
+
// [1,99] extremes so a "no-information" forecaster never masquerades as confident.
|
|
48
|
+
export const RANDOM_FORECAST_MIN = 20;
|
|
49
|
+
export const RANDOM_FORECAST_MAX = 80;
|
|
50
|
+
// ───────────────────────── Deterministic helpers ────────────────────────────
|
|
51
|
+
// A market's stable identity: the canonical triple, lower-cased. Doubles as the
|
|
52
|
+
// dedupe key against held positions and the seed component for the random RNG.
|
|
53
|
+
export function marketKey(m) {
|
|
54
|
+
return `${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`;
|
|
55
|
+
}
|
|
56
|
+
// FNV-1a 32-bit string hash — small, dependency-free, and deterministic across
|
|
57
|
+
// platforms. Used only to derive the reproducible random-benchmark forecast.
|
|
58
|
+
function fnv1a32(s) {
|
|
59
|
+
let h = 0x811c9dc5;
|
|
60
|
+
for (let i = 0; i < s.length; i++) {
|
|
61
|
+
h ^= s.charCodeAt(i);
|
|
62
|
+
// h *= 16777619, kept in 32-bit unsigned space via Math.imul.
|
|
63
|
+
h = Math.imul(h, 0x01000193);
|
|
64
|
+
}
|
|
65
|
+
return h >>> 0;
|
|
66
|
+
}
|
|
67
|
+
// Deterministic pseudo-random forecast in [RANDOM_FORECAST_MIN,
|
|
68
|
+
// RANDOM_FORECAST_MAX], one-decimal, seeded from (marketKey, dateKey). Same seed
|
|
69
|
+
// ⇒ same value, so a re-run of the same cycle reproduces exactly.
|
|
70
|
+
export function seededRandomForecast(seed) {
|
|
71
|
+
const span = RANDOM_FORECAST_MAX - RANDOM_FORECAST_MIN;
|
|
72
|
+
// 0..1 from the hash, then map into the span at one-decimal precision.
|
|
73
|
+
const unit = fnv1a32(seed) / 0xffffffff;
|
|
74
|
+
const raw = RANDOM_FORECAST_MIN + unit * span;
|
|
75
|
+
return Math.round(raw * 10) / 10;
|
|
76
|
+
}
|
|
77
|
+
// Clamp any probability-percentage to the backend's exclusive (0,100) rail as a
|
|
78
|
+
// one-decimal value in [1,99] — the same rail the runner enforces on model
|
|
79
|
+
// forecasts. Non-finite input returns undefined.
|
|
80
|
+
function clampForecast(pct) {
|
|
81
|
+
if (!Number.isFinite(pct))
|
|
82
|
+
return undefined;
|
|
83
|
+
const clamped = Math.min(99, Math.max(1, pct));
|
|
84
|
+
return Math.round(clamped * 10) / 10;
|
|
85
|
+
}
|
|
86
|
+
// A market is a usable benchmark candidate iff it carries a real probability
|
|
87
|
+
// (needed for the market-implied echo) AND a canonical triple. Keeping the SAME
|
|
88
|
+
// gate for all three strategies is what makes them bet identical markets.
|
|
89
|
+
function hasUsableProbability(m) {
|
|
90
|
+
return (typeof m.probability === "number" &&
|
|
91
|
+
Number.isFinite(m.probability) &&
|
|
92
|
+
!!m.source &&
|
|
93
|
+
!!m.slug &&
|
|
94
|
+
!!m.outcomeExternalMarketId);
|
|
95
|
+
}
|
|
96
|
+
// Deterministic pick: among eligible (usable-probability), not-already-held
|
|
97
|
+
// candidates, the highest-volume market wins; ties break on the market key
|
|
98
|
+
// ascending so the choice is fully reproducible. `volumeUsd` absent ⇒ treated as
|
|
99
|
+
// 0, so an older backend that omits volume falls back to a pure key-ordered pick
|
|
100
|
+
// (still deterministic). observe() already excludes held markets and eligibility
|
|
101
|
+
// -false outcomes, so this is a belt-and-suspenders re-filter.
|
|
102
|
+
export function pickBenchmarkMarket(markets, heldKeys = new Set()) {
|
|
103
|
+
const candidates = markets
|
|
104
|
+
.filter(hasUsableProbability)
|
|
105
|
+
.filter((m) => !heldKeys.has(marketKey(m)));
|
|
106
|
+
if (candidates.length === 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
return candidates.reduce((best, m) => {
|
|
109
|
+
const bv = best.volumeUsd ?? 0;
|
|
110
|
+
const mv = m.volumeUsd ?? 0;
|
|
111
|
+
if (mv !== bv)
|
|
112
|
+
return mv > bv ? m : best;
|
|
113
|
+
return marketKey(m) < marketKey(best) ? m : best;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// The per-strategy forecast (1..99) for a chosen market on a given UTC date.
|
|
117
|
+
// Returns undefined only if a market-implied echo can't be sized (non-finite
|
|
118
|
+
// probability) — the caller then skips rather than fabricating a value.
|
|
119
|
+
export function benchmarkForecast(strategy, market, dateKey) {
|
|
120
|
+
switch (strategy) {
|
|
121
|
+
case "market-implied":
|
|
122
|
+
// Echo the market's own probability (0..1 ⇒ percentage). BY DESIGN — this
|
|
123
|
+
// agent IS the market-implied baseline definition.
|
|
124
|
+
return clampForecast(Math.round((market.probability ?? NaN) * 100));
|
|
125
|
+
case "base-rate":
|
|
126
|
+
return BASE_RATE_UNINFORMATIVE;
|
|
127
|
+
case "random":
|
|
128
|
+
return clampForecast(seededRandomForecast(`${marketKey(market)}|${dateKey}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const heldKeysOf = (positions) => new Set(positions
|
|
132
|
+
.filter((p) => (p.status ?? "open") === "open")
|
|
133
|
+
.map((p) => marketKey({
|
|
134
|
+
source: p.source ?? "",
|
|
135
|
+
slug: p.slug ?? "",
|
|
136
|
+
outcomeExternalMarketId: p.outcomeExternalMarketId ?? "",
|
|
137
|
+
})));
|
|
138
|
+
// Compute one benchmark cycle's decision directly from the observation — no
|
|
139
|
+
// model call. Skips (never throws) when the strategy is unknown or no eligible
|
|
140
|
+
// market is available; otherwise emits a single pm_open carrying the strategy's
|
|
141
|
+
// forecast. Confidence is fixed at 1 (a benchmark never abstains on confidence).
|
|
142
|
+
export function decideMechanical(input) {
|
|
143
|
+
const log = [];
|
|
144
|
+
const strategy = input.strategy;
|
|
145
|
+
if (!isBenchmarkStrategy(strategy)) {
|
|
146
|
+
log.push(`mechanical: unknown benchmark strategy "${strategy}" — skipping`);
|
|
147
|
+
return {
|
|
148
|
+
decision: { decision: "skip", reason: "unknown_strategy", actions: [] },
|
|
149
|
+
log,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const dateKey = input.dateKey ?? dayKey();
|
|
153
|
+
const stakeMusd = input.stakeMusd ?? BENCHMARK_STAKE_MUSD;
|
|
154
|
+
const held = heldKeysOf(input.observation.pmPositions);
|
|
155
|
+
const market = pickBenchmarkMarket(input.observation.pmMarkets, held);
|
|
156
|
+
if (!market) {
|
|
157
|
+
log.push(`mechanical(${strategy}): no eligible PM market to benchmark this cycle — skipping`);
|
|
158
|
+
return {
|
|
159
|
+
decision: { decision: "skip", reason: "no_eligible_market", actions: [] },
|
|
160
|
+
log,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const forecast = benchmarkForecast(strategy, market, dateKey);
|
|
164
|
+
if (forecast == null) {
|
|
165
|
+
log.push(`mechanical(${strategy}): could not size a forecast for ${market.slug} — skipping`);
|
|
166
|
+
return {
|
|
167
|
+
decision: { decision: "skip", reason: "unsizable_forecast", actions: [] },
|
|
168
|
+
log,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const marketPct = typeof market.probability === "number"
|
|
172
|
+
? Math.round(market.probability * 100)
|
|
173
|
+
: undefined;
|
|
174
|
+
const rationale = strategy === "market-implied"
|
|
175
|
+
? `BENCHMARK market-implied: forecast ${forecast}% = market probability${marketPct != null ? ` ${marketPct}%` : ""} on ${market.source}/${market.slug} (echo is the baseline definition, not a defect).`
|
|
176
|
+
: strategy === "base-rate"
|
|
177
|
+
? `BENCHMARK base-rate: uninformative ${forecast}% prior on ${market.source}/${market.slug} (no invented per-category base rate).`
|
|
178
|
+
: `BENCHMARK random: seeded pseudo-random ${forecast}% on ${market.source}/${market.slug} (reproducible from market+date).`;
|
|
179
|
+
const action = {
|
|
180
|
+
type: "pm_open",
|
|
181
|
+
ref: market.ref,
|
|
182
|
+
source: market.source,
|
|
183
|
+
slug: market.slug,
|
|
184
|
+
outcomeExternalMarketId: market.outcomeExternalMarketId,
|
|
185
|
+
stakeMusd,
|
|
186
|
+
confidence: 1,
|
|
187
|
+
forecastProbability: forecast,
|
|
188
|
+
rationaleSummary: rationale,
|
|
189
|
+
};
|
|
190
|
+
log.push(`mechanical(${strategy}): bet ${stakeMusd}mUSD on ${market.source}/${market.slug} @ forecast ${forecast}% (market ${marketPct ?? "?"}%, vol ${market.volumeUsd ?? 0})`);
|
|
191
|
+
return {
|
|
192
|
+
decision: {
|
|
193
|
+
decision: "act",
|
|
194
|
+
confidence: 1,
|
|
195
|
+
rationale,
|
|
196
|
+
actions: [action],
|
|
197
|
+
},
|
|
198
|
+
log,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// A complete, valid AgentSpec for a mechanical benchmark. The strategy travels
|
|
202
|
+
// in model.name (provider "mechanical"); the runner reads it there. Caps are set
|
|
203
|
+
// so a benchmark NEVER self-disables (all kill-switches off — it is a permanent
|
|
204
|
+
// reference line) and never abstains on confidence (minConfidence 0). PM-only,
|
|
205
|
+
// tiny fixed stake, always-fire trigger policy (mechanical is free, so there is
|
|
206
|
+
// no reason to gate a cycle).
|
|
207
|
+
function benchmarkSpec(strategy, cadenceSeconds) {
|
|
208
|
+
const label = LABELS[strategy];
|
|
209
|
+
return {
|
|
210
|
+
name: `bench-${strategy}`,
|
|
211
|
+
description: `BENCHMARK (${label}) — a mechanical, non-LLM baseline reference agent. ${DESCRIPTIONS[strategy]} It is NOT a skill agent; it exists so the Arena can show real agents beating (or not) a fixed, deterministic baseline. Paper-only, zero inference cost.`,
|
|
212
|
+
spec: SPEC_VERSION,
|
|
213
|
+
trigger: { cadence: `${Math.round(cadenceSeconds / 60)}m` },
|
|
214
|
+
model: { provider: "mechanical", name: strategy },
|
|
215
|
+
venues: ["pm"],
|
|
216
|
+
risk: {
|
|
217
|
+
maxLeverage: 1,
|
|
218
|
+
perTradeMarginMusd: BENCHMARK_STAKE_MUSD, // per-trade stake cap = the tiny fixed stake
|
|
219
|
+
maxConcurrentPositions: 1000, // PM opens aren't capped by this; kept generous
|
|
220
|
+
requireStopLoss: false,
|
|
221
|
+
// Discovery needs a query coin; PM-only agents still seed discover from the
|
|
222
|
+
// watchlist. Majors give the broadest, most-liquid crypto board to benchmark.
|
|
223
|
+
watchlist: ["BTC", "ETH", "SOL"],
|
|
224
|
+
},
|
|
225
|
+
limits: {
|
|
226
|
+
maxTradesPerDay: 0, // 0 = unlimited: a benchmark records as many markets as it sees
|
|
227
|
+
maxWritesPerCycle: 1, // one benchmarked market per cycle
|
|
228
|
+
maxDailyLossMusd: 0, // disabled — a reference line never risk-stops
|
|
229
|
+
maxOpenMarginMusd: 100000,
|
|
230
|
+
},
|
|
231
|
+
abstention: {
|
|
232
|
+
onStaleData: false,
|
|
233
|
+
onWeakSignal: false,
|
|
234
|
+
onMissingQuote: false,
|
|
235
|
+
onInsufficientBalance: false,
|
|
236
|
+
minConfidence: 0, // a benchmark never abstains on confidence
|
|
237
|
+
},
|
|
238
|
+
sync: { requirePollBeforeWrite: false },
|
|
239
|
+
killSwitch: {
|
|
240
|
+
maxDrawdownMusd: 0,
|
|
241
|
+
maxConsecutiveRejects: 0,
|
|
242
|
+
maxConsecutiveModelFailures: 0,
|
|
243
|
+
onRateLimitPressure: false,
|
|
244
|
+
},
|
|
245
|
+
objective: {
|
|
246
|
+
primary: "calibration",
|
|
247
|
+
secondary: ["benchmark", strategy],
|
|
248
|
+
horizon: "all",
|
|
249
|
+
},
|
|
250
|
+
capabilities: [],
|
|
251
|
+
triggerPolicy: {
|
|
252
|
+
mode: "always", // mechanical = free; always evaluate, never gate a cycle
|
|
253
|
+
skipLlmWhenNoTrigger: false,
|
|
254
|
+
alwaysManageOpenPositions: true,
|
|
255
|
+
maxLlmCallsPerHour: 0,
|
|
256
|
+
debounceMinutes: 0,
|
|
257
|
+
pmEvalCooldownMinutes: 0,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const LABELS = {
|
|
262
|
+
"market-implied": "market-implied",
|
|
263
|
+
"base-rate": "base-rate",
|
|
264
|
+
random: "random/null",
|
|
265
|
+
};
|
|
266
|
+
const DESCRIPTIONS = {
|
|
267
|
+
"market-implied": "Each cycle it picks the highest-volume eligible market and submits a forecast EXACTLY equal to the market's own probability — the market-implied baseline every skill claim is measured against.",
|
|
268
|
+
"base-rate": "It submits an uninformative 50% prior on every market (no fabricated per-category base rate).",
|
|
269
|
+
random: "It submits a deterministic pseudo-random forecast in [20,80] seeded from the market and date, giving a reproducible no-information noise floor.",
|
|
270
|
+
};
|
|
271
|
+
// Human-readable prose stored on the row. NEVER fed to a model (mechanical agents
|
|
272
|
+
// don't reason) — it exists so the Arena/terminal can describe the agent honestly.
|
|
273
|
+
function benchmarkProse(strategy) {
|
|
274
|
+
return `# Benchmark: ${LABELS[strategy]}\n\nThis is a MECHANICAL BENCHMARK baseline, not a skill agent. ${DESCRIPTIONS[strategy]}\n\nIt calls no language model, has zero inference cost, and is fully deterministic and reproducible. It exists purely as a public reference line: the Arena compares real agents' calibration against these baselines. Paper-only.`;
|
|
275
|
+
}
|
|
276
|
+
// Default cadence: hourly. Frequent enough to accumulate a steady benchmark
|
|
277
|
+
// record, slow enough that the three benchmarks don't churn the discovered board.
|
|
278
|
+
const DEFAULT_BENCHMARK_CADENCE_SECONDS = 3600;
|
|
279
|
+
export const BENCHMARK_AGENTS = BENCHMARK_STRATEGIES.map((strategy) => ({
|
|
280
|
+
handle: `bench-${strategy}`,
|
|
281
|
+
displayName: `Benchmark: ${LABELS[strategy]}`,
|
|
282
|
+
strategy,
|
|
283
|
+
cadenceSeconds: DEFAULT_BENCHMARK_CADENCE_SECONDS,
|
|
284
|
+
spec: benchmarkSpec(strategy, DEFAULT_BENCHMARK_CADENCE_SECONDS),
|
|
285
|
+
prose: benchmarkProse(strategy),
|
|
286
|
+
}));
|
package/dist/agent/observe.js
CHANGED
|
@@ -54,6 +54,73 @@ function freshnessOf(block) {
|
|
|
54
54
|
const status = asStr(fr.status);
|
|
55
55
|
return status ? { status, ageSeconds: asNum(fr.ageSeconds) } : undefined;
|
|
56
56
|
}
|
|
57
|
+
// Does a market title reference the given watchlist coin? Matches on the PM coin
|
|
58
|
+
// NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
|
|
59
|
+
// is a phrase match so a q=Bitcoin result reliably carries "Bitcoin"/"BTC" in the
|
|
60
|
+
// title. Used both to decide whether the primary board already covers the coin the
|
|
61
|
+
// agent analysed and to keep the secondary (crypto-targeted) fetch on-topic.
|
|
62
|
+
function titleMentionsCoin(title, symbol) {
|
|
63
|
+
const t = (title ?? "").toLowerCase();
|
|
64
|
+
if (!t)
|
|
65
|
+
return false;
|
|
66
|
+
const name = (PM_COIN_NAMES[symbol] ?? symbol).toLowerCase();
|
|
67
|
+
const sym = symbol.toLowerCase();
|
|
68
|
+
return t.includes(name) || t.includes(sym);
|
|
69
|
+
}
|
|
70
|
+
// Expand one raw /api/agent/pm/discover payload into per-outcome PmMarket rows
|
|
71
|
+
// (WITHOUT a ref — refs are stamped once over the final merged+sliced list so they
|
|
72
|
+
// stay contiguous pm1..pmN). One row per quoteable outcome; drops outcomes the
|
|
73
|
+
// backend flagged not-openable (eligible === false) and markets the agent already
|
|
74
|
+
// holds (heldPmKeys). Shared by the primary board fetch and the crypto-targeted
|
|
75
|
+
// secondary fetch so both go through the exact same filters.
|
|
76
|
+
function expandPmMarkets(discData, heldPmKeys) {
|
|
77
|
+
const dd = asObj(discData);
|
|
78
|
+
return (asArr(dd.data ?? dd.markets ?? dd.results)
|
|
79
|
+
.map(asObj)
|
|
80
|
+
.flatMap((ev) => {
|
|
81
|
+
const source = (asStr(ev.source) ?? "").toLowerCase();
|
|
82
|
+
const slug = (asStr(ev.slug) ?? "").toLowerCase();
|
|
83
|
+
// Keep titles SHORT: the model only needs to recognise the market.
|
|
84
|
+
// Untrimmed titles, one per outcome across many events, ballooned the
|
|
85
|
+
// prompt to ~69k tokens (413s on small-context free models).
|
|
86
|
+
const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
|
|
87
|
+
const freshness = freshnessOf(ev); // freshness is event-level
|
|
88
|
+
// Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
|
|
89
|
+
// the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
|
|
90
|
+
// Same for every outcome of the event; undefined on an older backend.
|
|
91
|
+
const volumeUsd = asNum(ev.volume24h) ?? undefined;
|
|
92
|
+
// At most a few outcomes per event so a wide multi-outcome market
|
|
93
|
+
// (e.g. dozens of price buckets) can't explode the prompt. Drop
|
|
94
|
+
// outcomes the backend flagged NOT openable (eligible === false) so the
|
|
95
|
+
// model never bets a market that would fail the binary entry gate at
|
|
96
|
+
// quote. Back-compat: an older backend omits `eligible` (undefined) ->
|
|
97
|
+
// the outcome is kept (current behaviour).
|
|
98
|
+
const outcomes = asArr(ev.outcomes)
|
|
99
|
+
.map(asObj)
|
|
100
|
+
.filter((o) => o.eligible !== false)
|
|
101
|
+
.slice(0, 3);
|
|
102
|
+
// A market with no outcomes array still round-trips a flat fallback row.
|
|
103
|
+
const rows = outcomes.length > 0 ? outcomes : [ev];
|
|
104
|
+
return rows.map((o) => ({
|
|
105
|
+
source,
|
|
106
|
+
slug,
|
|
107
|
+
outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
|
|
108
|
+
// Carry the odds through: the model needs the outcome label + current
|
|
109
|
+
// probability to spot a mispriced market and bet it.
|
|
110
|
+
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
111
|
+
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
112
|
+
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
113
|
+
probability: ((p) => (p == null ? undefined : p > 1 ? p / 100 : p))(asNum(o.probability)),
|
|
114
|
+
title,
|
|
115
|
+
freshness,
|
|
116
|
+
volumeUsd,
|
|
117
|
+
}));
|
|
118
|
+
})
|
|
119
|
+
.filter((m) => m.source && m.slug && m.outcomeExternalMarketId)
|
|
120
|
+
// Drop already-held markets so the model only sees markets it can actually
|
|
121
|
+
// open — done BEFORE any slice so held positions don't consume candidate slots.
|
|
122
|
+
.filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
|
|
123
|
+
}
|
|
57
124
|
function emptyObservation(state, scopes = []) {
|
|
58
125
|
return {
|
|
59
126
|
asOf: state.cursor ?? new Date().toISOString(),
|
|
@@ -296,12 +363,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
296
363
|
.slice(0, 25);
|
|
297
364
|
}
|
|
298
365
|
if (pmDiscR.ok) {
|
|
299
|
-
const dd = asObj(pmDiscR.data);
|
|
300
366
|
// Anti-churn: exclude markets the agent ALREADY holds an open position in
|
|
301
367
|
// from the candidate list BEFORE it reaches the prompt — so the model never
|
|
302
368
|
// sees (and re-picks) a held market only to have the runner/server reject it
|
|
303
369
|
// as a duplicate, burning a whole cycle. Keyed source|slug|outcomeExternalMarketId
|
|
304
|
-
// (lower-cased to match the discover rows
|
|
370
|
+
// (lower-cased to match the discover rows). The runner preflight guard
|
|
305
371
|
// (duplicate_intent) + server dedup (duplicate_open) remain the backstops.
|
|
306
372
|
// Side-agnostic = no re-bet/hedge on a held outcome, matching the runner policy.
|
|
307
373
|
const heldPmKeys = new Set(pmPositions
|
|
@@ -309,57 +375,59 @@ export async function observe(client, spec, state, trace) {
|
|
|
309
375
|
.map((p) => `${(p.source ?? "").toLowerCase()}|${(p.slug ?? "").toLowerCase()}|${p.outcomeExternalMarketId ?? ""}`));
|
|
310
376
|
// Real /api/agent/pm/discover payload: { data: [event], pagination, meta }.
|
|
311
377
|
// Each EVENT carries source/slug/title/freshness at the top level and the
|
|
312
|
-
// quoteable id NESTED at outcomes[].externalMarketId —
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
378
|
+
// quoteable id NESTED at outcomes[].externalMarketId — expandPmMarkets turns
|
|
379
|
+
// that into one row per quoteable outcome (eligible + not-held filtered).
|
|
380
|
+
let mergedRows = expandPmMarkets(pmDiscR.data, heldPmKeys);
|
|
381
|
+
// ── Crypto-targeted secondary discover (pm_ref hallucination fix) ────────
|
|
382
|
+
// The prompt tells the model its SHARPEST PM edge is the crypto price view it
|
|
383
|
+
// JUST formed — but that is only actionable if the board actually LISTS a
|
|
384
|
+
// market for the coin it analysed. The primary board is keyed to ONE query
|
|
385
|
+
// (the top watchlist coin, with a Bitcoin fallback when that coin is thin),
|
|
386
|
+
// so an agent whose top coin got displaced by the Bitcoin fallback sees NO
|
|
387
|
+
// market for the coin it has a view on and an 8B model invents a pmN ref
|
|
388
|
+
// (→ pm_ref_unknown, wasted cycle). When the top ANALYSED coin (its sharpest
|
|
389
|
+
// edge) has no market in the primary board, fire ONE extra discover for that
|
|
390
|
+
// coin and MERGE it in — giving the model a real ref to bet instead of a
|
|
391
|
+
// hallucinated one. Budget: at most a single additional CoinRithm data-API
|
|
392
|
+
// read, and only on cycles where the top coin is actually missing; the shared
|
|
393
|
+
// free-tier model-call RateBudget (scheduler) is untouched — this is a read,
|
|
394
|
+
// not an LLM call, and the client already backs off on 429.
|
|
395
|
+
const analyzedCoins = watch
|
|
396
|
+
.filter((w) => w.coinId)
|
|
397
|
+
.map((w) => w.symbol.toUpperCase());
|
|
398
|
+
const topAnalyzed = analyzedCoins[0];
|
|
399
|
+
const primaryCoversTop = !topAnalyzed ||
|
|
400
|
+
mergedRows.some((m) => titleMentionsCoin(m.title, topAnalyzed));
|
|
401
|
+
if (topAnalyzed && !primaryCoversTop) {
|
|
402
|
+
const targetName = PM_COIN_NAMES[topAnalyzed] ?? topAnalyzed;
|
|
403
|
+
// limit 6 (not ~5): the eligible/held/dedupe filters shave the list, and we
|
|
404
|
+
// then cap the merged contribution to 4 targeted rows below.
|
|
405
|
+
const secR = await client.discoverPmMarkets({ q: targetName, limit: 6 }, trace);
|
|
406
|
+
if (secR.ok) {
|
|
407
|
+
// Dedupe the secondary rows against the primary list by source+slug (event
|
|
408
|
+
// key) so a market already on the board is never shown twice, and keep only
|
|
409
|
+
// rows that actually reference the targeted coin (a fuzzy backend match
|
|
410
|
+
// can't dilute the board with off-topic events).
|
|
411
|
+
const primaryEventKeys = new Set(mergedRows.map((m) => `${m.source}|${m.slug}`));
|
|
412
|
+
const secRows = expandPmMarkets(secR.data, heldPmKeys)
|
|
413
|
+
.filter((m) => titleMentionsCoin(m.title, topAnalyzed))
|
|
414
|
+
.filter((m) => !primaryEventKeys.has(`${m.source}|${m.slug}`))
|
|
415
|
+
.slice(0, 4);
|
|
416
|
+
// Reserve slots for the targeted rows so the 12-cap can't slice off the
|
|
417
|
+
// very markets the secondary fetch exists to surface. Primary rows keep
|
|
418
|
+
// priority; the targeted rows are appended.
|
|
419
|
+
if (secRows.length > 0) {
|
|
420
|
+
const primaryBudget = Math.max(0, 12 - secRows.length);
|
|
421
|
+
mergedRows = [...mergedRows.slice(0, primaryBudget), ...secRows];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Hard cap the PM block (a handful of fresh markets is plenty) and stamp a
|
|
426
|
+
// short, stable per-cycle ref (pm1…pmN) the model copies instead of the long
|
|
427
|
+
// outcomeExternalMarketId. Refs are assigned AFTER the merge + slice so they
|
|
428
|
+
// are a contiguous 1..N matching exactly what the prompt shows.
|
|
429
|
+
pmMarkets = mergedRows
|
|
359
430
|
.slice(0, 12)
|
|
360
|
-
// Stamp a short, stable per-cycle ref (pm1…pmN) the model copies instead of
|
|
361
|
-
// the long outcomeExternalMarketId. Assigned AFTER the slice so refs are a
|
|
362
|
-
// contiguous 1..N matching exactly what the prompt shows.
|
|
363
431
|
.map((m, i) => ({ ...m, ref: `pm${i + 1}` }));
|
|
364
432
|
}
|
|
365
433
|
}
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AgentSpec, Observation, PmResolution } from "./types.js";
|
|
2
2
|
export declare function formatPmResolutions(resolutions: PmResolution[]): string[];
|
|
3
|
-
export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string
|
|
3
|
+
export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string, opts?: {
|
|
4
|
+
includeForecast?: boolean;
|
|
5
|
+
}): string;
|
|
4
6
|
export declare function buildUserPrompt(obs: Observation, journal?: Array<{
|
|
5
7
|
at: string;
|
|
6
8
|
did: string;
|