@coinrithm/mcp-trading 0.7.15 → 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.
- package/CHANGELOG.md +127 -8
- package/README.md +18 -3
- package/dist/agent/bench/bench.d.ts +59 -0
- package/dist/agent/bench/bench.js +319 -0
- package/dist/agent/bench/cassette.d.ts +97 -0
- package/dist/agent/bench/cassette.js +181 -0
- package/dist/agent/bench/costs.d.ts +28 -0
- package/dist/agent/bench/costs.js +60 -0
- package/dist/agent/bench/labelBuilder.d.ts +123 -0
- package/dist/agent/bench/labelBuilder.js +282 -0
- package/dist/agent/bench/labels.d.ts +75 -0
- package/dist/agent/bench/labels.js +259 -0
- package/dist/agent/bench/recordingClient.d.ts +51 -0
- package/dist/agent/bench/recordingClient.js +193 -0
- package/dist/agent/bench/replayClient.d.ts +41 -0
- package/dist/agent/bench/replayClient.js +262 -0
- package/dist/agent/bench/report.d.ts +115 -0
- package/dist/agent/bench/report.js +435 -0
- package/dist/agent/cli.d.ts +25 -0
- package/dist/agent/cli.js +300 -2
- package/dist/agent/client.d.ts +1 -0
- package/dist/agent/client.js +6 -0
- package/dist/agent/configurationWarnings.js +11 -0
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +85 -3
- package/dist/agent/decisionValidator.d.ts +30 -0
- package/dist/agent/decisionValidator.js +147 -2
- package/dist/agent/engine.d.ts +3 -3
- package/dist/agent/engine.js +3 -3
- package/dist/agent/observe.d.ts +27 -2
- package/dist/agent/observe.js +529 -89
- package/dist/agent/prompt.d.ts +4 -1
- package/dist/agent/prompt.js +98 -7
- package/dist/agent/promptTables.d.ts +1 -0
- package/dist/agent/promptTables.js +33 -0
- package/dist/agent/providerCapabilities.d.ts +6 -0
- package/dist/agent/providerCapabilities.js +23 -0
- package/dist/agent/providers.d.ts +25 -7
- package/dist/agent/providers.js +188 -48
- package/dist/agent/resolve.js +3 -0
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.js +59 -15
- package/dist/agent/setups.d.ts +2 -1
- package/dist/agent/setups.js +17 -16
- package/dist/agent/signals.d.ts +33 -0
- package/dist/agent/signals.js +89 -0
- package/dist/agent/skill.js +37 -0
- package/dist/agent/skillValidator.js +33 -0
- package/dist/agent/strictLint.js +29 -0
- package/dist/agent/types.d.ts +149 -12
- package/dist/agent/universe.d.ts +78 -0
- package/dist/agent/universe.js +215 -0
- package/package.json +3 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Bench cassettes: the recorded READ inputs of one agent cycle.
|
|
2
|
+
//
|
|
3
|
+
// A cassette is the source of truth the bench replays. It holds every read
|
|
4
|
+
// response the real production runner made in one dry-run cycle (status, body,
|
|
5
|
+
// keyed by a canonical request key), the clock it was recorded at, and the
|
|
6
|
+
// recorded agent's spec (needed to replay the built-in baselines on the same
|
|
7
|
+
// inputs). It never holds request headers, so no API key reaches a cassette.
|
|
8
|
+
// It DOES hold the recording account's paper reads (wallet, positions), so
|
|
9
|
+
// keep a corpus as private as the account it was recorded from.
|
|
10
|
+
//
|
|
11
|
+
// Why not replay the stored `decision_input_record`: that record declares
|
|
12
|
+
// itself partial (prompts, news and journal excluded, byte budget) and carries
|
|
13
|
+
// no replay guarantee. Inputs are therefore recorded going forward, in full.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { DEFAULT_TRIGGER_POLICY } from "../types.js";
|
|
18
|
+
import { sortDeep } from "../util.js";
|
|
19
|
+
import { parseLabelFile } from "./labels.js";
|
|
20
|
+
export const CASSETTE_SCHEMA = "coinrithm.bench.cassette.v1";
|
|
21
|
+
/**
|
|
22
|
+
* Canonical request identity used for both recording and replay: upper-cased
|
|
23
|
+
* method, the path relative to the API base path, and the query parameters
|
|
24
|
+
* sorted by name. The host is deliberately not part of the key, so a cassette
|
|
25
|
+
* recorded against one base URL replays against the bench's fake one.
|
|
26
|
+
*/
|
|
27
|
+
export function canonicalRequest(method, url, basePath = "") {
|
|
28
|
+
const u = new URL(url);
|
|
29
|
+
const base = basePath.replace(/\/+$/, "");
|
|
30
|
+
let path = u.pathname;
|
|
31
|
+
if (base && (path === base || path.startsWith(`${base}/`)))
|
|
32
|
+
path = path.slice(base.length) || "/";
|
|
33
|
+
const params = new URLSearchParams(u.search);
|
|
34
|
+
params.sort();
|
|
35
|
+
const query = {};
|
|
36
|
+
params.forEach((value, name) => {
|
|
37
|
+
query[name] = value;
|
|
38
|
+
});
|
|
39
|
+
const qs = params.toString();
|
|
40
|
+
const m = method.toUpperCase();
|
|
41
|
+
return { key: `${m} ${path}${qs ? `?${qs}` : ""}`, method: m, path, query };
|
|
42
|
+
}
|
|
43
|
+
/** The path prefix of an API base URL ("" for a host-root base). */
|
|
44
|
+
export function basePathOf(baseUrl) {
|
|
45
|
+
return new URL(baseUrl).pathname.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
export function sha256Hex(text) {
|
|
48
|
+
return createHash("sha256").update(text).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
/** Sorted-key JSON, the same canonicalization the scorecard hash relies on. */
|
|
51
|
+
export function canonicalJson(value) {
|
|
52
|
+
return JSON.stringify(sortDeep(value));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The spec a recording pass runs with: the agent's own spec with its gate
|
|
56
|
+
* forced to fire, so the post-gate reads (whale context) are always recorded
|
|
57
|
+
* and a variant whose gate fires on replay finds them in the cassette.
|
|
58
|
+
*/
|
|
59
|
+
export function recordingSpec(spec) {
|
|
60
|
+
return {
|
|
61
|
+
...spec,
|
|
62
|
+
triggerPolicy: {
|
|
63
|
+
...(spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY),
|
|
64
|
+
mode: "always",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The market-implied baseline: the recorded agent's spec and caps with the
|
|
70
|
+
* deterministic mechanical "market-implied" strategy as its brain (mechanical
|
|
71
|
+
* .ts). runBench separately preserves the recorded agent's observation
|
|
72
|
+
* policy, so replacing the provider never changes the PM opportunity set.
|
|
73
|
+
*/
|
|
74
|
+
export function marketBaselineSpec(spec) {
|
|
75
|
+
return mechanicalBaselineSpec(spec, "market-implied");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The same recorded spec with one of the mechanical benchmark strategies as
|
|
79
|
+
* its brain. base-rate and random pick the SAME market from the SAME
|
|
80
|
+
* observation as market-implied (mechanical.ts); all use the original
|
|
81
|
+
* recording's PM board and never fetch a separate baseline universe.
|
|
82
|
+
*/
|
|
83
|
+
export function mechanicalBaselineSpec(spec, strategy) {
|
|
84
|
+
return {
|
|
85
|
+
...recordingSpec(spec),
|
|
86
|
+
model: { provider: "mechanical", name: strategy },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function stampOf(asOf) {
|
|
90
|
+
const stamp = asOf
|
|
91
|
+
.replace(/[^0-9A-Za-z]+/g, "-")
|
|
92
|
+
.slice(0, 40)
|
|
93
|
+
.replace(/^-+|-+$/g, "");
|
|
94
|
+
return stamp || "cassette";
|
|
95
|
+
}
|
|
96
|
+
/** Filesystem-safe, content-addressed cassette id: asOf stamp + body hash. */
|
|
97
|
+
export function cassetteId(asOf, contentHash) {
|
|
98
|
+
return `${stampOf(asOf)}-${contentHash.slice(0, 12)}`;
|
|
99
|
+
}
|
|
100
|
+
export function writeCassette(dir, cassette) {
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
const file = join(dir, `${cassette.id}.json`);
|
|
103
|
+
writeFileSync(file, `${JSON.stringify(cassette, null, 2)}\n`, "utf8");
|
|
104
|
+
return file;
|
|
105
|
+
}
|
|
106
|
+
/** Validate a parsed cassette. Throws on a malformed one (fail closed). */
|
|
107
|
+
export function parseCassette(value, source) {
|
|
108
|
+
const c = (value ?? {});
|
|
109
|
+
const problems = [];
|
|
110
|
+
if (c.schema !== CASSETTE_SCHEMA)
|
|
111
|
+
problems.push("schema");
|
|
112
|
+
if (typeof c.id !== "string" || !/^[0-9A-Za-z-]{1,80}$/.test(c.id))
|
|
113
|
+
problems.push("id");
|
|
114
|
+
if (typeof c.asOf !== "string" || !Number.isFinite(Date.parse(c.asOf)))
|
|
115
|
+
problems.push("asOf");
|
|
116
|
+
if (typeof c.clockMs !== "number" || !Number.isFinite(c.clockMs))
|
|
117
|
+
problems.push("clockMs");
|
|
118
|
+
if (!c.spec || typeof c.spec !== "object" || !Array.isArray(c.spec.venues))
|
|
119
|
+
problems.push("spec");
|
|
120
|
+
if (!Array.isArray(c.responses) ||
|
|
121
|
+
c.responses.some((r) => !r ||
|
|
122
|
+
typeof r.key !== "string" ||
|
|
123
|
+
typeof r.path !== "string" ||
|
|
124
|
+
typeof r.status !== "number"))
|
|
125
|
+
problems.push("responses");
|
|
126
|
+
if (problems.length > 0)
|
|
127
|
+
throw new Error(`cassette ${source} is invalid (${problems.join(", ")}); re-record it`);
|
|
128
|
+
return {
|
|
129
|
+
...c,
|
|
130
|
+
marketBaselineRecorded: c.marketBaselineRecorded === true,
|
|
131
|
+
refusedRequests: Array.isArray(c.refusedRequests) ? c.refusedRequests : [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Read every cassette at the top level of `dir` plus the optional
|
|
136
|
+
* `labels/<cassetteId>.json` files. A JSON file with the cassette schema that
|
|
137
|
+
* fails validation, or a malformed label file, stops the bench: a silently
|
|
138
|
+
* skipped input would bias the comparison.
|
|
139
|
+
*/
|
|
140
|
+
export function readCorpus(dir) {
|
|
141
|
+
const cassettes = [];
|
|
142
|
+
const ignored = [];
|
|
143
|
+
for (const name of readdirSync(dir).sort()) {
|
|
144
|
+
if (!name.endsWith(".json"))
|
|
145
|
+
continue;
|
|
146
|
+
const file = join(dir, name);
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error(`corpus file ${name} is not valid JSON; repair or explicitly remove it before comparing variants`);
|
|
153
|
+
}
|
|
154
|
+
if (parsed?.schema !== CASSETTE_SCHEMA) {
|
|
155
|
+
ignored.push(name);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
cassettes.push(parseCassette(parsed, name));
|
|
159
|
+
}
|
|
160
|
+
const ids = new Set();
|
|
161
|
+
for (const c of cassettes) {
|
|
162
|
+
if (ids.has(c.id))
|
|
163
|
+
throw new Error(`duplicate cassette id ${c.id}`);
|
|
164
|
+
ids.add(c.id);
|
|
165
|
+
}
|
|
166
|
+
const labels = {};
|
|
167
|
+
for (const c of cassettes) {
|
|
168
|
+
const file = join(dir, "labels", `${c.id}.json`);
|
|
169
|
+
if (!existsSync(file))
|
|
170
|
+
continue;
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
throw new Error(`label file labels/${c.id}.json is not valid JSON: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
labels[c.id] = parseLabelFile(parsed, `labels/${c.id}.json`);
|
|
179
|
+
}
|
|
180
|
+
return { cassettes, labels, ignored };
|
|
181
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Fee fraction of the price paid at a 50% PM price (openapi.yaml: ~1.8%). */
|
|
2
|
+
export declare const PM_SYNTHETIC_FEE_RATE_AT_MID = 0.018;
|
|
3
|
+
/** Flat futures taker fee (bps of notional) used for synthesized quotes. */
|
|
4
|
+
export declare const FUTURES_SYNTHETIC_FEE_BPS = 10;
|
|
5
|
+
/** Flat spot taker fee (bps of notional) used for synthesized quotes. */
|
|
6
|
+
export declare const SPOT_SYNTHETIC_FEE_BPS = 10;
|
|
7
|
+
/**
|
|
8
|
+
* Synthetic PM fee in probability POINTS for an outcome priced at
|
|
9
|
+
* `probabilityPoints` (0..100). Zero at the extremes, largest at 50.
|
|
10
|
+
*/
|
|
11
|
+
export declare function pmSyntheticFeePoints(probabilityPoints: number): number;
|
|
12
|
+
export interface SyntheticPmQuote {
|
|
13
|
+
/** Raw chosen-outcome probability in points: the value the floor checks. */
|
|
14
|
+
entryProbability: number;
|
|
15
|
+
feePoints: number;
|
|
16
|
+
/** Fee-inclusive break-even cost in points (entry + fee). */
|
|
17
|
+
costPoints: number;
|
|
18
|
+
stakeMusd: number;
|
|
19
|
+
/** Net shares: stake / (cost / 100). One share pays 1 mUSD on a win. */
|
|
20
|
+
sharesEstimate: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The PM quote the bench serves for a recorded outcome probability. Returns
|
|
24
|
+
* undefined for a probability the paper engine could not fill (0, 100, or not
|
|
25
|
+
* a finite number in range) or a non-positive stake, so the caller reports an
|
|
26
|
+
* ineligible quote instead of dividing by zero.
|
|
27
|
+
*/
|
|
28
|
+
export declare function synthesizePmQuote(probabilityPoints: number, stakeMusd: number): SyntheticPmQuote | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Synthetic execution costs for the bench's replayed quotes.
|
|
2
|
+
//
|
|
3
|
+
// A quote depends on the action the agent proposes (which outcome, which stake,
|
|
4
|
+
// which leverage), so it can never be recorded ahead of time: a cassette only
|
|
5
|
+
// holds the READ inputs of a cycle. On replay the bench therefore synthesizes
|
|
6
|
+
// every quote from recorded evidence (the discover probability of the chosen PM
|
|
7
|
+
// outcome, the recorded market price of a coin) plus the cost model below. Every
|
|
8
|
+
// synthesized quote is counted in the report; it is a fidelity limit, not a
|
|
9
|
+
// silent substitute for the paper engine.
|
|
10
|
+
//
|
|
11
|
+
// What the cost model covers and what it leaves out, stated plainly:
|
|
12
|
+
// - PM: the published fee SHAPE only. openapi.yaml ("Paper Execution Realism
|
|
13
|
+
// v1") documents a Polymarket-shaped taker fee of about 1.8% near a 50%
|
|
14
|
+
// price and about 0 at the extremes. That is modelled as
|
|
15
|
+
// PM_SYNTHETIC_FEE_RATE_AT_MID * 4p(1-p) of the price paid. The ask spread
|
|
16
|
+
// and the size-scaled slippage the paper engine adds are NOT modelled, so a
|
|
17
|
+
// synthesized PM cost is optimistic by an unmeasured amount.
|
|
18
|
+
// - Futures and spot: a flat taker fee on notional. The paper engine discloses
|
|
19
|
+
// its own feeBps per quote, which a cassette cannot contain, so the bench
|
|
20
|
+
// uses the runner's own conservative pre-quote estimate
|
|
21
|
+
// (CAPITAL_FEE_BUFFER_BPS). Funding, spread and slippage are not modelled.
|
|
22
|
+
import { CAPITAL_FEE_BUFFER_BPS } from "../capitalSizing.js";
|
|
23
|
+
/** Fee fraction of the price paid at a 50% PM price (openapi.yaml: ~1.8%). */
|
|
24
|
+
export const PM_SYNTHETIC_FEE_RATE_AT_MID = 0.018;
|
|
25
|
+
/** Flat futures taker fee (bps of notional) used for synthesized quotes. */
|
|
26
|
+
export const FUTURES_SYNTHETIC_FEE_BPS = CAPITAL_FEE_BUFFER_BPS;
|
|
27
|
+
/** Flat spot taker fee (bps of notional) used for synthesized quotes. */
|
|
28
|
+
export const SPOT_SYNTHETIC_FEE_BPS = CAPITAL_FEE_BUFFER_BPS;
|
|
29
|
+
/**
|
|
30
|
+
* Synthetic PM fee in probability POINTS for an outcome priced at
|
|
31
|
+
* `probabilityPoints` (0..100). Zero at the extremes, largest at 50.
|
|
32
|
+
*/
|
|
33
|
+
export function pmSyntheticFeePoints(probabilityPoints) {
|
|
34
|
+
const p = probabilityPoints / 100;
|
|
35
|
+
return probabilityPoints * PM_SYNTHETIC_FEE_RATE_AT_MID * 4 * p * (1 - p);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The PM quote the bench serves for a recorded outcome probability. Returns
|
|
39
|
+
* undefined for a probability the paper engine could not fill (0, 100, or not
|
|
40
|
+
* a finite number in range) or a non-positive stake, so the caller reports an
|
|
41
|
+
* ineligible quote instead of dividing by zero.
|
|
42
|
+
*/
|
|
43
|
+
export function synthesizePmQuote(probabilityPoints, stakeMusd) {
|
|
44
|
+
if (!Number.isFinite(probabilityPoints) ||
|
|
45
|
+
probabilityPoints <= 0 ||
|
|
46
|
+
probabilityPoints >= 100 ||
|
|
47
|
+
!Number.isFinite(stakeMusd) ||
|
|
48
|
+
stakeMusd <= 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const feePoints = pmSyntheticFeePoints(probabilityPoints);
|
|
52
|
+
const costPoints = probabilityPoints + feePoints;
|
|
53
|
+
return {
|
|
54
|
+
entryProbability: probabilityPoints,
|
|
55
|
+
feePoints,
|
|
56
|
+
costPoints,
|
|
57
|
+
stakeMusd,
|
|
58
|
+
sharesEstimate: stakeMusd / (costPoints / 100),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ApiResult } from "../types.js";
|
|
2
|
+
import { Cassette } from "./cassette.js";
|
|
3
|
+
import { LabelFile, PriceBar } from "./labels.js";
|
|
4
|
+
export declare const LABEL_RANGES: readonly [{
|
|
5
|
+
readonly range: "1D";
|
|
6
|
+
readonly spanSeconds: 86400;
|
|
7
|
+
readonly barSeconds: 300;
|
|
8
|
+
}, {
|
|
9
|
+
readonly range: "1W";
|
|
10
|
+
readonly spanSeconds: number;
|
|
11
|
+
readonly barSeconds: 900;
|
|
12
|
+
}, {
|
|
13
|
+
readonly range: "1M";
|
|
14
|
+
readonly spanSeconds: number;
|
|
15
|
+
readonly barSeconds: 3600;
|
|
16
|
+
}, {
|
|
17
|
+
readonly range: "3M";
|
|
18
|
+
readonly spanSeconds: number;
|
|
19
|
+
readonly barSeconds: 14400;
|
|
20
|
+
}];
|
|
21
|
+
export declare const DEFAULT_LABEL_HORIZON_HOURS = 24;
|
|
22
|
+
export type PriceLabelPlan = {
|
|
23
|
+
status: "ready";
|
|
24
|
+
range: string;
|
|
25
|
+
barSeconds: number;
|
|
26
|
+
fromSec: number;
|
|
27
|
+
toSec: number;
|
|
28
|
+
} | {
|
|
29
|
+
status: "not_yet";
|
|
30
|
+
labelableAfter: string;
|
|
31
|
+
} | {
|
|
32
|
+
status: "too_old";
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Which candle range can label [asOf, asOf + horizon] right now. The horizon
|
|
36
|
+
* must have fully elapsed, and the range window (ending now) must still hold
|
|
37
|
+
* the bar that contains asOf.
|
|
38
|
+
*/
|
|
39
|
+
export declare function planPriceLabels(asOf: string, horizonHours: number, nowMs: number): PriceLabelPlan;
|
|
40
|
+
/** The coins a cassette observed: its recorded GET /api/agent/market/:id reads. */
|
|
41
|
+
export declare function cassetteCoins(cassette: Pick<Cassette, "responses">): Array<{
|
|
42
|
+
coinId: string;
|
|
43
|
+
symbol: string;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Bars from a candles response that can matter for [fromSec, toSec]: the bar
|
|
47
|
+
* containing asOf onward (the scorer itself ignores every bar at or before
|
|
48
|
+
* asOf) through the last bar that CLOSES by toSec, finite OHLC only, oldest
|
|
49
|
+
* first. A bar straddling toSec (it may hold prices after the horizon) or
|
|
50
|
+
* still open when labelling runs is never stored.
|
|
51
|
+
*/
|
|
52
|
+
export declare function priceBarsFor(data: unknown, fromSec: number, toSec: number, barSeconds: number): PriceBar[];
|
|
53
|
+
export type CassetteLabelResult = {
|
|
54
|
+
id: string;
|
|
55
|
+
status: "not_yet";
|
|
56
|
+
labelableAfter: string;
|
|
57
|
+
} | {
|
|
58
|
+
id: string;
|
|
59
|
+
status: "too_old";
|
|
60
|
+
} | {
|
|
61
|
+
id: string;
|
|
62
|
+
status: "kept";
|
|
63
|
+
} | {
|
|
64
|
+
id: string;
|
|
65
|
+
status: "horizon_mismatch";
|
|
66
|
+
existingHorizonHours: number | null;
|
|
67
|
+
} | {
|
|
68
|
+
id: string;
|
|
69
|
+
status: "no_coins";
|
|
70
|
+
} | {
|
|
71
|
+
id: string;
|
|
72
|
+
status: "built";
|
|
73
|
+
file: LabelFile;
|
|
74
|
+
range: string;
|
|
75
|
+
symbols: string[];
|
|
76
|
+
missing: string[];
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Price labels for each cassette. `fetchCandles` is the agent API candles
|
|
80
|
+
* read; one fetch per coin and range serves every cassette. An existing label
|
|
81
|
+
* file keeps its PM and funding labels; its prices are replaced only with
|
|
82
|
+
* `overwrite`. A symbol whose read fails or returns no bar in the window is
|
|
83
|
+
* reported missing and stays unlabelled.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildPriceLabels(cassettes: ReadonlyArray<Pick<Cassette, "id" | "asOf" | "responses">>, opts: {
|
|
86
|
+
fetchCandles: (coinId: string, range: string) => Promise<ApiResult>;
|
|
87
|
+
horizonHours?: number;
|
|
88
|
+
nowMs: number;
|
|
89
|
+
existing?: Record<string, LabelFile>;
|
|
90
|
+
overwrite?: boolean;
|
|
91
|
+
}): Promise<CassetteLabelResult[]>;
|
|
92
|
+
/** The PM events a cassette discovered (its recorded /pm/discover reads). */
|
|
93
|
+
export declare function cassettePmEvents(cassette: Pick<Cassette, "responses">): Array<{
|
|
94
|
+
source: string;
|
|
95
|
+
slug: string;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Pure: settled labels from one public event-detail body, keyed
|
|
99
|
+
* `source/slug/outcomeExternalMarketId`. Empty unless the platform's own
|
|
100
|
+
* verdict says the event settles and the outcome has a provider result.
|
|
101
|
+
*/
|
|
102
|
+
export declare function pmVerdictLabels(source: string, slug: string, body: unknown): Record<string, {
|
|
103
|
+
settled: 0 | 1;
|
|
104
|
+
}>;
|
|
105
|
+
export type CassettePmResult = {
|
|
106
|
+
id: string;
|
|
107
|
+
events: number;
|
|
108
|
+
labelled: number;
|
|
109
|
+
failed: number;
|
|
110
|
+
pm: Record<string, {
|
|
111
|
+
settled: 0 | 1;
|
|
112
|
+
}>;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Settled PM labels per cassette from the public event verdict. One read per
|
|
116
|
+
* event serves every cassette; a failed or unusable read leaves that event
|
|
117
|
+
* unlabelled and is counted. Existing PM labels are kept unless `overwrite`.
|
|
118
|
+
*/
|
|
119
|
+
export declare function buildPmLabels(cassettes: ReadonlyArray<Pick<Cassette, "id" | "responses">>, opts: {
|
|
120
|
+
fetchEvent: (source: string, slug: string) => Promise<ApiResult>;
|
|
121
|
+
existing?: Record<string, LabelFile>;
|
|
122
|
+
overwrite?: boolean;
|
|
123
|
+
}): Promise<CassettePmResult[]>;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Outcome labels for bench cassettes, built from data published AFTER each
|
|
2
|
+
// cassette's asOf (C2 of the item-3 bench plan). Reads only; never writes to
|
|
3
|
+
// the API.
|
|
4
|
+
//
|
|
5
|
+
// Prices: OHLC bars from GET /api/agent/market/:coinId/candles, the same
|
|
6
|
+
// loader and shape as the public /api/coins/:slug/candles (probed live
|
|
7
|
+
// 2026-10-07: range=1D -> 288 five-minute bars {t, o, h, l, c, v}, t = bar
|
|
8
|
+
// open in unix seconds). Candle storage keeps fine bars only briefly (about
|
|
9
|
+
// 24 h of 5-minute bars, 7 days of 15-minute, 30 days of hourly), so the bar
|
|
10
|
+
// size follows the cassette's age: the finest range whose window still covers
|
|
11
|
+
// asOf. A label file has one bar size (barSeconds).
|
|
12
|
+
//
|
|
13
|
+
// PM settlement comes from the platform's own public event verdict (see the
|
|
14
|
+
// PM section below), never a rule rebuilt here; anything short of a settled
|
|
15
|
+
// provider result stays "unlabelled".
|
|
16
|
+
export const LABEL_RANGES = [
|
|
17
|
+
{ range: "1D", spanSeconds: 86_400, barSeconds: 300 },
|
|
18
|
+
{ range: "1W", spanSeconds: 7 * 86_400, barSeconds: 900 },
|
|
19
|
+
{ range: "1M", spanSeconds: 30 * 86_400, barSeconds: 3_600 },
|
|
20
|
+
{ range: "3M", spanSeconds: 90 * 86_400, barSeconds: 14_400 },
|
|
21
|
+
];
|
|
22
|
+
export const DEFAULT_LABEL_HORIZON_HOURS = 24;
|
|
23
|
+
/**
|
|
24
|
+
* Which candle range can label [asOf, asOf + horizon] right now. The horizon
|
|
25
|
+
* must have fully elapsed, and the range window (ending now) must still hold
|
|
26
|
+
* the bar that contains asOf.
|
|
27
|
+
*/
|
|
28
|
+
export function planPriceLabels(asOf, horizonHours, nowMs) {
|
|
29
|
+
const asOfMs = Date.parse(asOf);
|
|
30
|
+
if (!Number.isFinite(asOfMs))
|
|
31
|
+
throw new Error(`invalid asOf "${asOf}"`);
|
|
32
|
+
if (!Number.isFinite(horizonHours) || horizonHours <= 0)
|
|
33
|
+
throw new Error("horizon hours must be a positive number");
|
|
34
|
+
const fromSec = Math.floor(asOfMs / 1000);
|
|
35
|
+
const toSec = fromSec + Math.round(horizonHours * 3600);
|
|
36
|
+
const nowSec = Math.floor(nowMs / 1000);
|
|
37
|
+
if (toSec > nowSec)
|
|
38
|
+
return {
|
|
39
|
+
status: "not_yet",
|
|
40
|
+
labelableAfter: new Date(toSec * 1000).toISOString(),
|
|
41
|
+
};
|
|
42
|
+
const age = nowSec - fromSec;
|
|
43
|
+
const pick = LABEL_RANGES.find((r) => age + r.barSeconds <= r.spanSeconds);
|
|
44
|
+
return pick
|
|
45
|
+
? {
|
|
46
|
+
status: "ready",
|
|
47
|
+
range: pick.range,
|
|
48
|
+
barSeconds: pick.barSeconds,
|
|
49
|
+
fromSec,
|
|
50
|
+
toSec,
|
|
51
|
+
}
|
|
52
|
+
: { status: "too_old" };
|
|
53
|
+
}
|
|
54
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
55
|
+
/** The coins a cassette observed: its recorded GET /api/agent/market/:id reads. */
|
|
56
|
+
export function cassetteCoins(cassette) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
for (const r of cassette.responses) {
|
|
59
|
+
const m = /^\/api\/agent\/market\/([^/]+)$/.exec(r.path);
|
|
60
|
+
if (!m || r.method !== "GET" || !r.ok)
|
|
61
|
+
continue;
|
|
62
|
+
const coin = isObject(r.data) ? r.data.coin : undefined;
|
|
63
|
+
const symbol = isObject(coin) ? coin.symbol : undefined;
|
|
64
|
+
if (typeof symbol === "string" && symbol.trim())
|
|
65
|
+
out.set(decodeURIComponent(m[1]), symbol.trim().toUpperCase());
|
|
66
|
+
}
|
|
67
|
+
return [...out].map(([coinId, symbol]) => ({ coinId, symbol }));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Bars from a candles response that can matter for [fromSec, toSec]: the bar
|
|
71
|
+
* containing asOf onward (the scorer itself ignores every bar at or before
|
|
72
|
+
* asOf) through the last bar that CLOSES by toSec, finite OHLC only, oldest
|
|
73
|
+
* first. A bar straddling toSec (it may hold prices after the horizon) or
|
|
74
|
+
* still open when labelling runs is never stored.
|
|
75
|
+
*/
|
|
76
|
+
export function priceBarsFor(data, fromSec, toSec, barSeconds) {
|
|
77
|
+
const rows = isObject(data) && Array.isArray(data.candles) ? data.candles : [];
|
|
78
|
+
const bars = [];
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (!isObject(row))
|
|
81
|
+
continue;
|
|
82
|
+
const { t, h, l, c } = row;
|
|
83
|
+
if ([t, h, l, c].every((v) => typeof v === "number" && Number.isFinite(v)) &&
|
|
84
|
+
t > fromSec - barSeconds &&
|
|
85
|
+
t + barSeconds <= toSec)
|
|
86
|
+
bars.push({
|
|
87
|
+
t: t,
|
|
88
|
+
h: h,
|
|
89
|
+
l: l,
|
|
90
|
+
c: c,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return bars.sort((a, b) => a.t - b.t);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Price labels for each cassette. `fetchCandles` is the agent API candles
|
|
97
|
+
* read; one fetch per coin and range serves every cassette. An existing label
|
|
98
|
+
* file keeps its PM and funding labels; its prices are replaced only with
|
|
99
|
+
* `overwrite`. A symbol whose read fails or returns no bar in the window is
|
|
100
|
+
* reported missing and stays unlabelled.
|
|
101
|
+
*/
|
|
102
|
+
export async function buildPriceLabels(cassettes, opts) {
|
|
103
|
+
const horizonHours = opts.horizonHours ?? DEFAULT_LABEL_HORIZON_HOURS;
|
|
104
|
+
const cache = new Map();
|
|
105
|
+
const read = (coinId, range) => {
|
|
106
|
+
const key = `${coinId}|${range}`;
|
|
107
|
+
let hit = cache.get(key);
|
|
108
|
+
if (!hit) {
|
|
109
|
+
hit = opts
|
|
110
|
+
.fetchCandles(coinId, range)
|
|
111
|
+
.catch(() => ({ ok: false, status: 0, data: null }));
|
|
112
|
+
cache.set(key, hit);
|
|
113
|
+
}
|
|
114
|
+
return hit;
|
|
115
|
+
};
|
|
116
|
+
const results = [];
|
|
117
|
+
for (const cassette of cassettes) {
|
|
118
|
+
const existing = opts.existing?.[cassette.id];
|
|
119
|
+
if (existing?.prices && !opts.overwrite) {
|
|
120
|
+
// An existing file labelled for another horizon is never reported as
|
|
121
|
+
// done for this one: rebuilding it needs --overwrite.
|
|
122
|
+
results.push(existing.horizonHours === horizonHours
|
|
123
|
+
? { id: cassette.id, status: "kept" }
|
|
124
|
+
: {
|
|
125
|
+
id: cassette.id,
|
|
126
|
+
status: "horizon_mismatch",
|
|
127
|
+
existingHorizonHours: existing.horizonHours ?? null,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const plan = planPriceLabels(cassette.asOf, horizonHours, opts.nowMs);
|
|
132
|
+
if (plan.status !== "ready") {
|
|
133
|
+
results.push({ id: cassette.id, ...plan });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const coins = cassetteCoins(cassette);
|
|
137
|
+
if (coins.length === 0) {
|
|
138
|
+
results.push({ id: cassette.id, status: "no_coins" });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const prices = {};
|
|
142
|
+
const missing = [];
|
|
143
|
+
for (const { coinId, symbol } of coins) {
|
|
144
|
+
const res = await read(coinId, plan.range);
|
|
145
|
+
const bars = res.ok
|
|
146
|
+
? priceBarsFor(res.data, plan.fromSec, plan.toSec, plan.barSeconds)
|
|
147
|
+
: [];
|
|
148
|
+
if (bars.length > 0)
|
|
149
|
+
prices[symbol] = bars;
|
|
150
|
+
else
|
|
151
|
+
missing.push(symbol);
|
|
152
|
+
}
|
|
153
|
+
const { prices: _old, barSeconds: _bar, horizonHours: _h, ...kept } = existing ?? {};
|
|
154
|
+
results.push({
|
|
155
|
+
id: cassette.id,
|
|
156
|
+
status: "built",
|
|
157
|
+
range: plan.range,
|
|
158
|
+
symbols: Object.keys(prices).sort(),
|
|
159
|
+
missing: missing.sort(),
|
|
160
|
+
file: {
|
|
161
|
+
...kept,
|
|
162
|
+
prices,
|
|
163
|
+
barSeconds: plan.barSeconds,
|
|
164
|
+
horizonHours,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
// ── PM settlement labels (root 57074) ──────────────────────────────────────
|
|
171
|
+
// From the EXISTING public event read GET /api/prediction-markets/events/
|
|
172
|
+
// :source/:slug, never an invented verdict: an outcome is labelled only when
|
|
173
|
+
// the event's settlementTrust says settlementEligible AND limboVerdict
|
|
174
|
+
// "settle", and the outcome's own lifecycle is a provider result (isResult,
|
|
175
|
+
// basis "provider", result won|lost). Probed live 2026-10-07: a Kalshi ladder
|
|
176
|
+
// (1 won / 74 lost, eligible, settle) and a ForecastEx event whose winner is
|
|
177
|
+
// known but whose time is unverified (eligible false, limbo "void"), which
|
|
178
|
+
// stays unlabelled. A pm_open buys its named outcome, so settled = won.
|
|
179
|
+
/** The PM events a cassette discovered (its recorded /pm/discover reads). */
|
|
180
|
+
export function cassettePmEvents(cassette) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
for (const r of cassette.responses) {
|
|
183
|
+
if (r.path !== "/api/agent/pm/discover" || r.method !== "GET" || !r.ok)
|
|
184
|
+
continue;
|
|
185
|
+
const rows = isObject(r.data) && Array.isArray(r.data.data) ? r.data.data : [];
|
|
186
|
+
for (const ev of rows) {
|
|
187
|
+
if (!isObject(ev))
|
|
188
|
+
continue;
|
|
189
|
+
const { source, slug } = ev;
|
|
190
|
+
if (typeof source !== "string" || typeof slug !== "string")
|
|
191
|
+
continue;
|
|
192
|
+
const s = source.trim().toLowerCase();
|
|
193
|
+
const g = slug.trim().toLowerCase();
|
|
194
|
+
if (s && g)
|
|
195
|
+
out.set(`${s}/${g}`, { source: s, slug: g });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...out.values()];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Pure: settled labels from one public event-detail body, keyed
|
|
202
|
+
* `source/slug/outcomeExternalMarketId`. Empty unless the platform's own
|
|
203
|
+
* verdict says the event settles and the outcome has a provider result.
|
|
204
|
+
*/
|
|
205
|
+
export function pmVerdictLabels(source, slug, body) {
|
|
206
|
+
const out = {};
|
|
207
|
+
if (!isObject(body))
|
|
208
|
+
return out;
|
|
209
|
+
const resolution = isObject(body.resolution) ? body.resolution : undefined;
|
|
210
|
+
const trust = resolution && isObject(resolution.settlementTrust)
|
|
211
|
+
? resolution.settlementTrust
|
|
212
|
+
: undefined;
|
|
213
|
+
if (!trust ||
|
|
214
|
+
trust.settlementEligible !== true ||
|
|
215
|
+
trust.limboVerdict !== "settle")
|
|
216
|
+
return out;
|
|
217
|
+
const event = isObject(body.event) ? body.event : undefined;
|
|
218
|
+
const outcomes = event && Array.isArray(event.outcomes) ? event.outcomes : [];
|
|
219
|
+
for (const o of outcomes) {
|
|
220
|
+
if (!isObject(o) || typeof o.externalMarketId !== "string")
|
|
221
|
+
continue;
|
|
222
|
+
const lc = isObject(o.lifecycle) ? o.lifecycle : undefined;
|
|
223
|
+
if (!lc ||
|
|
224
|
+
lc.isResult !== true ||
|
|
225
|
+
lc.basis !== "provider" ||
|
|
226
|
+
(lc.result !== "won" && lc.result !== "lost"))
|
|
227
|
+
continue;
|
|
228
|
+
out[`${source}/${slug}/${o.externalMarketId}`] = {
|
|
229
|
+
settled: lc.result === "won" ? 1 : 0,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Settled PM labels per cassette from the public event verdict. One read per
|
|
236
|
+
* event serves every cassette; a failed or unusable read leaves that event
|
|
237
|
+
* unlabelled and is counted. Existing PM labels are kept unless `overwrite`.
|
|
238
|
+
*/
|
|
239
|
+
export async function buildPmLabels(cassettes, opts) {
|
|
240
|
+
const cache = new Map();
|
|
241
|
+
const read = (source, slug) => {
|
|
242
|
+
const key = `${source}/${slug}`;
|
|
243
|
+
let hit = cache.get(key);
|
|
244
|
+
if (!hit) {
|
|
245
|
+
hit = opts
|
|
246
|
+
.fetchEvent(source, slug)
|
|
247
|
+
.catch(() => ({ ok: false, status: 0, data: null }));
|
|
248
|
+
cache.set(key, hit);
|
|
249
|
+
}
|
|
250
|
+
return hit;
|
|
251
|
+
};
|
|
252
|
+
const results = [];
|
|
253
|
+
for (const cassette of cassettes) {
|
|
254
|
+
const events = cassettePmEvents(cassette);
|
|
255
|
+
const pm = opts.overwrite
|
|
256
|
+
? {}
|
|
257
|
+
: { ...(opts.existing?.[cassette.id]?.pm ?? {}) };
|
|
258
|
+
let failed = 0;
|
|
259
|
+
let labelled = 0;
|
|
260
|
+
for (const { source, slug } of events) {
|
|
261
|
+
const res = await read(source, slug);
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
failed += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
for (const [key, label] of Object.entries(pmVerdictLabels(source, slug, res.data))) {
|
|
267
|
+
if (!opts.overwrite && pm[key] !== undefined)
|
|
268
|
+
continue;
|
|
269
|
+
pm[key] = label;
|
|
270
|
+
labelled += 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
results.push({
|
|
274
|
+
id: cassette.id,
|
|
275
|
+
events: events.length,
|
|
276
|
+
labelled,
|
|
277
|
+
failed,
|
|
278
|
+
pm,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|