@pineforge/backtest-mcp 0.9.18
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/LICENSE +21 -0
- package/README.md +262 -0
- package/dist/coverage.js +707 -0
- package/dist/coverage.js.map +1 -0
- package/dist/engine.js +327 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/index.local.js +19 -0
- package/dist/index.local.js.map +1 -0
- package/dist/server.js +921 -0
- package/dist/server.js.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +53 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pineforge/backtest-mcp — shared MCP server factory.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all tool logic. Both entrypoints (docker via
|
|
5
|
+
* `index.ts`, local/in-container via `index.local.ts`) build their server here;
|
|
6
|
+
* they differ only in which EngineRunner they construct and which tool surface
|
|
7
|
+
* they request via `opts.imageTools`. Tool COPY is made mode-aware off
|
|
8
|
+
* `runner.mode` so descriptions stay accurate per backend.
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { mkdtemp, mkdir, writeFile, rm, stat, readFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join, resolve, isAbsolute, dirname, relative } from "node:path";
|
|
15
|
+
import { VERSION } from "./version.js";
|
|
16
|
+
import { DEFAULT_IMAGE, stringifyParams, } from "./engine.js";
|
|
17
|
+
import { coverageIndex, coverageTopic, checkPineFeature } from "./coverage.js";
|
|
18
|
+
// ─── Config ───────────────────────────────────────────────────────────────
|
|
19
|
+
const ALLOW_ANYWHERE = process.env.PINEFORGE_ALLOW_ANYWHERE === "1";
|
|
20
|
+
const BINANCE_SPOT_BASE = "https://api.binance.com";
|
|
21
|
+
const BINANCE_FAPI_BASE = "https://fapi.binance.com";
|
|
22
|
+
const BINANCE_KLINES_LIMIT = 1000;
|
|
23
|
+
const BINANCE_PAGE_DELAY_MS = 200;
|
|
24
|
+
// A full backtest report (trades + equity curve) can be megabytes — large
|
|
25
|
+
// enough to blow past an MCP client's tool-result size cap ("result too
|
|
26
|
+
// large"). When the serialized result exceeds this many bytes we write the
|
|
27
|
+
// full JSON to a file under the working dir and return a compact summary that
|
|
28
|
+
// points at it via `report_path`. Tunable via PINEFORGE_MAX_INLINE_BYTES.
|
|
29
|
+
const MAX_INLINE_BYTES = Number(process.env.PINEFORGE_MAX_INLINE_BYTES) || 200_000;
|
|
30
|
+
// The working dir (/work) is a bind mount of a HOST directory the user passed
|
|
31
|
+
// via `-v <hostdir>:/work`. A container path like /work/foo.json is NOT
|
|
32
|
+
// openable on the host, so the path we hand back must be host-resolvable:
|
|
33
|
+
// - if PINEFORGE_HOST_WORKDIR is set (the host side of the mount), return an
|
|
34
|
+
// absolute HOST path the user can open directly;
|
|
35
|
+
// - otherwise return the path RELATIVE to the mount root, which the user
|
|
36
|
+
// resolves against their own <hostdir>. Either way the file physically
|
|
37
|
+
// lands in their mounted directory and survives `--rm`.
|
|
38
|
+
const HOST_WORKDIR = process.env.PINEFORGE_HOST_WORKDIR;
|
|
39
|
+
async function writeReportFile(value, explicitPath, kind) {
|
|
40
|
+
const name = explicitPath ?? `pineforge-${kind}-${Date.now()}.json`;
|
|
41
|
+
const containerPath = resolveScopedPath(name, "report");
|
|
42
|
+
await mkdir(dirname(containerPath), { recursive: true });
|
|
43
|
+
await writeFile(containerPath, JSON.stringify(value, null, 2), "utf8");
|
|
44
|
+
const rel = relative(process.cwd(), containerPath) || name;
|
|
45
|
+
if (HOST_WORKDIR) {
|
|
46
|
+
return {
|
|
47
|
+
user_path: join(HOST_WORKDIR, rel),
|
|
48
|
+
container_path: containerPath,
|
|
49
|
+
note: "Report written to your host directory (PINEFORGE_HOST_WORKDIR); open report_path directly.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
user_path: rel,
|
|
54
|
+
container_path: containerPath,
|
|
55
|
+
note: `Report written inside the host directory you mounted at /work — open <your -v hostdir>/${rel}. ` +
|
|
56
|
+
`Pass -e PINEFORGE_HOST_WORKDIR=<hostdir> to get an absolute host path in report_path instead.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function runBacktest(runner, args) {
|
|
60
|
+
const csvPath = await resolveCsvPath(args.ohlcv_csv_path);
|
|
61
|
+
const image = args.image ?? DEFAULT_IMAGE;
|
|
62
|
+
const cpp = await runner.transpile(args.source, args.image);
|
|
63
|
+
const tmp = await mkdtemp(join(tmpdir(), "pineforge-bt-"));
|
|
64
|
+
const cppPath = join(tmp, "strategy.cpp");
|
|
65
|
+
await writeFile(cppPath, cpp, "utf8");
|
|
66
|
+
try {
|
|
67
|
+
const report = await runner.backtest({
|
|
68
|
+
cppPath,
|
|
69
|
+
csvPath,
|
|
70
|
+
image: args.image,
|
|
71
|
+
inputs: args.inputs,
|
|
72
|
+
overrides: args.overrides,
|
|
73
|
+
runtime: args.runtime,
|
|
74
|
+
});
|
|
75
|
+
const full = {
|
|
76
|
+
...report,
|
|
77
|
+
_meta: { strategy_cpp_bytes: cpp.length, image: runner.mode === "docker" ? image : "local" },
|
|
78
|
+
};
|
|
79
|
+
// Offload oversized reports to a file so the inline result stays small.
|
|
80
|
+
const text = JSON.stringify(full, null, 2);
|
|
81
|
+
if (text.length <= MAX_INLINE_BYTES)
|
|
82
|
+
return full;
|
|
83
|
+
const loc = await writeReportFile(full, args.report_path, "backtest");
|
|
84
|
+
const r = full;
|
|
85
|
+
const trades = Array.isArray(r.trades) ? r.trades : undefined;
|
|
86
|
+
return {
|
|
87
|
+
summary: r.summary,
|
|
88
|
+
applied_inputs: r.applied_inputs,
|
|
89
|
+
applied_overrides: r.applied_overrides,
|
|
90
|
+
elapsed_seconds: r.elapsed_seconds,
|
|
91
|
+
total_trades: trades ? trades.length : undefined,
|
|
92
|
+
report_path: loc.user_path,
|
|
93
|
+
report_path_in_container: loc.container_path,
|
|
94
|
+
truncated: true,
|
|
95
|
+
note: `Full report (${text.length} bytes, including the trade list and equity curve) exceeded the inline ` +
|
|
96
|
+
`limit (${MAX_INLINE_BYTES} bytes); the summary above has the headline P&L metrics. ${loc.note}`,
|
|
97
|
+
_meta: r._meta,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
rm(tmp, { recursive: true, force: true }).catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function runBacktestGrid(runner, args) {
|
|
105
|
+
const csvPath = await resolveCsvPath(args.ohlcv_csv_path);
|
|
106
|
+
const image = args.image ?? DEFAULT_IMAGE;
|
|
107
|
+
const includeTrades = args.include_trades === true;
|
|
108
|
+
const sortBy = args.sort_by ?? "net_pnl";
|
|
109
|
+
const maxCombos = args.max_combinations ?? 64;
|
|
110
|
+
const concurrency = Math.max(1, Math.min(args.concurrency ?? 1, 8));
|
|
111
|
+
const combos = buildCombinations(args.inputs, args.overrides, args.fixed_inputs, args.fixed_overrides);
|
|
112
|
+
if (combos.length === 0) {
|
|
113
|
+
throw new Error("no parameter combinations produced — provide at least one inputs/overrides axis");
|
|
114
|
+
}
|
|
115
|
+
if (combos.length > maxCombos) {
|
|
116
|
+
throw new Error(`${combos.length} combinations exceeds max_combinations=${maxCombos}. ` +
|
|
117
|
+
`Either reduce the grid or raise max_combinations.`);
|
|
118
|
+
}
|
|
119
|
+
const cpp = await runner.transpile(args.source, args.image);
|
|
120
|
+
const tmp = await mkdtemp(join(tmpdir(), "pineforge-grid-"));
|
|
121
|
+
const cppPath = join(tmp, "strategy.cpp");
|
|
122
|
+
await writeFile(cppPath, cpp, "utf8");
|
|
123
|
+
try {
|
|
124
|
+
const rows = await pMap(combos, concurrency, async (combo) => {
|
|
125
|
+
try {
|
|
126
|
+
const report = await runner.backtest({
|
|
127
|
+
cppPath, csvPath,
|
|
128
|
+
image: args.image,
|
|
129
|
+
inputs: combo.inputs, overrides: combo.overrides,
|
|
130
|
+
runtime: args.runtime,
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
inputs: combo.inputs,
|
|
135
|
+
overrides: combo.overrides,
|
|
136
|
+
summary: report.summary,
|
|
137
|
+
applied_inputs: report.applied_inputs,
|
|
138
|
+
applied_overrides: report.applied_overrides,
|
|
139
|
+
elapsed_seconds: report.elapsed_seconds,
|
|
140
|
+
...(includeTrades ? { trades: report.trades } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
inputs: combo.inputs,
|
|
147
|
+
overrides: combo.overrides,
|
|
148
|
+
error: e instanceof Error ? e.message : String(e),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const succeeded = rows.filter(r => r.ok);
|
|
153
|
+
const failed = rows.filter(r => !r.ok);
|
|
154
|
+
const sortValue = (r) => {
|
|
155
|
+
if (!r.ok || !r.summary)
|
|
156
|
+
return -Infinity;
|
|
157
|
+
const v = r.summary[sortBy];
|
|
158
|
+
return typeof v === "number" ? v : -Infinity;
|
|
159
|
+
};
|
|
160
|
+
succeeded.sort((a, b) => sortValue(b) - sortValue(a));
|
|
161
|
+
const full = {
|
|
162
|
+
total_combinations: combos.length,
|
|
163
|
+
succeeded: succeeded.length,
|
|
164
|
+
failed: failed.length,
|
|
165
|
+
sort_by: sortBy,
|
|
166
|
+
image: runner.mode === "docker" ? image : "local",
|
|
167
|
+
_meta: { strategy_cpp_bytes: cpp.length, concurrency },
|
|
168
|
+
best: succeeded[0] ?? null,
|
|
169
|
+
results: [...succeeded, ...failed],
|
|
170
|
+
};
|
|
171
|
+
// Offload an oversized sweep to a file; inline only the top-ranked subset.
|
|
172
|
+
const text = JSON.stringify(full, null, 2);
|
|
173
|
+
if (text.length <= MAX_INLINE_BYTES)
|
|
174
|
+
return full;
|
|
175
|
+
const loc = await writeReportFile(full, args.report_path, "grid");
|
|
176
|
+
const TOP = 10;
|
|
177
|
+
return {
|
|
178
|
+
total_combinations: full.total_combinations,
|
|
179
|
+
succeeded: full.succeeded,
|
|
180
|
+
failed: full.failed,
|
|
181
|
+
sort_by: full.sort_by,
|
|
182
|
+
image: full.image,
|
|
183
|
+
_meta: full._meta,
|
|
184
|
+
best: full.best,
|
|
185
|
+
top_results: succeeded.slice(0, TOP),
|
|
186
|
+
results_truncated: succeeded.length > TOP || failed.length > 0,
|
|
187
|
+
report_path: loc.user_path,
|
|
188
|
+
report_path_in_container: loc.container_path,
|
|
189
|
+
note: `Full sweep (${text.length} bytes across ${combos.length} combinations) exceeded the inline limit ` +
|
|
190
|
+
`(${MAX_INLINE_BYTES} bytes). Inlined the top ${TOP} by ${sortBy}. ${loc.note}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
rm(tmp, { recursive: true, force: true }).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// ─── Param-grid helpers ───────────────────────────────────────────────────
|
|
198
|
+
function cartesianProduct(grid) {
|
|
199
|
+
const keys = Object.keys(grid);
|
|
200
|
+
if (keys.length === 0)
|
|
201
|
+
return [{}];
|
|
202
|
+
let acc = [{}];
|
|
203
|
+
for (const k of keys) {
|
|
204
|
+
const values = grid[k] ?? [];
|
|
205
|
+
const next = [];
|
|
206
|
+
for (const partial of acc) {
|
|
207
|
+
for (const v of values) {
|
|
208
|
+
next.push({ ...partial, [k]: String(v) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
acc = next;
|
|
212
|
+
}
|
|
213
|
+
return acc;
|
|
214
|
+
}
|
|
215
|
+
function buildCombinations(inputsGrid, overridesGrid, fixedInputs, fixedOverrides) {
|
|
216
|
+
const inputCombos = cartesianProduct(inputsGrid ?? {});
|
|
217
|
+
const overrideCombos = cartesianProduct(overridesGrid ?? {});
|
|
218
|
+
const fixIn = stringifyParams(fixedInputs);
|
|
219
|
+
const fixOv = stringifyParams(fixedOverrides);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const i of inputCombos) {
|
|
222
|
+
for (const o of overrideCombos) {
|
|
223
|
+
out.push({ inputs: { ...fixIn, ...i }, overrides: { ...fixOv, ...o } });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
async function pMap(items, n, fn) {
|
|
229
|
+
const results = new Array(items.length);
|
|
230
|
+
let next = 0;
|
|
231
|
+
const worker = async () => {
|
|
232
|
+
while (true) {
|
|
233
|
+
const i = next++;
|
|
234
|
+
if (i >= items.length)
|
|
235
|
+
return;
|
|
236
|
+
results[i] = await fn(items[i], i);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
|
240
|
+
return results;
|
|
241
|
+
}
|
|
242
|
+
// ─── Path / CSV helpers ───────────────────────────────────────────────────
|
|
243
|
+
function resolveScopedPath(p, label) {
|
|
244
|
+
const abs = isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
245
|
+
if (!ALLOW_ANYWHERE && !abs.startsWith(process.cwd() + "/") && abs !== process.cwd()) {
|
|
246
|
+
throw new Error(`${label} path '${abs}' is outside cwd '${process.cwd()}'. ` +
|
|
247
|
+
`Set PINEFORGE_ALLOW_ANYWHERE=1 to override.`);
|
|
248
|
+
}
|
|
249
|
+
return abs;
|
|
250
|
+
}
|
|
251
|
+
async function resolveCsvPath(p) {
|
|
252
|
+
const abs = resolveScopedPath(p, "OHLCV");
|
|
253
|
+
const st = await stat(abs).catch(() => null);
|
|
254
|
+
if (!st || !st.isFile())
|
|
255
|
+
throw new Error(`OHLCV file not found: ${abs}`);
|
|
256
|
+
const head = (await readFile(abs, "utf8")).slice(0, 200).split(/\r?\n/)[0] ?? "";
|
|
257
|
+
const expected = ["timestamp", "open", "high", "low", "close", "volume"];
|
|
258
|
+
const cols = head.toLowerCase().split(",").map((s) => s.trim());
|
|
259
|
+
if (expected.some((c, i) => cols[i] !== c)) {
|
|
260
|
+
throw new Error(`OHLCV header mismatch. Expected: ${expected.join(",")}\nGot: ${head}`);
|
|
261
|
+
}
|
|
262
|
+
return abs;
|
|
263
|
+
}
|
|
264
|
+
// ─── Binance public-API client ────────────────────────────────────────────
|
|
265
|
+
const BINANCE_INTERVAL_MS = {
|
|
266
|
+
"1s": 1_000,
|
|
267
|
+
"1m": 60_000, "3m": 180_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000,
|
|
268
|
+
"1h": 3_600_000, "2h": 7_200_000, "4h": 14_400_000, "6h": 21_600_000,
|
|
269
|
+
"8h": 28_800_000, "12h": 43_200_000,
|
|
270
|
+
"1d": 86_400_000, "3d": 259_200_000,
|
|
271
|
+
"1w": 604_800_000,
|
|
272
|
+
"1M": 30 * 86_400_000, // approximate
|
|
273
|
+
};
|
|
274
|
+
const BINANCE_INTERVALS = Object.keys(BINANCE_INTERVAL_MS);
|
|
275
|
+
function binanceKlinesUrl(market) {
|
|
276
|
+
return market === "spot"
|
|
277
|
+
? `${BINANCE_SPOT_BASE}/api/v3/klines`
|
|
278
|
+
: `${BINANCE_FAPI_BASE}/fapi/v1/klines`;
|
|
279
|
+
}
|
|
280
|
+
function binanceExchangeInfoUrl(market) {
|
|
281
|
+
return market === "spot"
|
|
282
|
+
? `${BINANCE_SPOT_BASE}/api/v3/exchangeInfo`
|
|
283
|
+
: `${BINANCE_FAPI_BASE}/fapi/v1/exchangeInfo`;
|
|
284
|
+
}
|
|
285
|
+
async function binanceGet(url) {
|
|
286
|
+
const resp = await fetch(url, {
|
|
287
|
+
headers: { "user-agent": `pineforge-backtest-mcp/${VERSION}` },
|
|
288
|
+
});
|
|
289
|
+
const text = await resp.text();
|
|
290
|
+
if (!resp.ok) {
|
|
291
|
+
throw new Error(`Binance ${resp.status} for ${url}: ${text.slice(0, 500)}`);
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return JSON.parse(text);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
throw new Error(`Binance non-JSON response: ${text.slice(0, 200)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function fetchKlinesPage(market, symbol, interval, startTime, endTime, limit) {
|
|
301
|
+
const params = new URLSearchParams({
|
|
302
|
+
symbol: symbol.toUpperCase(),
|
|
303
|
+
interval,
|
|
304
|
+
limit: String(limit),
|
|
305
|
+
});
|
|
306
|
+
if (startTime !== undefined)
|
|
307
|
+
params.set("startTime", String(startTime));
|
|
308
|
+
if (endTime !== undefined)
|
|
309
|
+
params.set("endTime", String(endTime));
|
|
310
|
+
const url = `${binanceKlinesUrl(market)}?${params.toString()}`;
|
|
311
|
+
const data = await binanceGet(url);
|
|
312
|
+
if (!Array.isArray(data))
|
|
313
|
+
throw new Error(`Binance unexpected klines payload: ${JSON.stringify(data).slice(0, 200)}`);
|
|
314
|
+
return data;
|
|
315
|
+
}
|
|
316
|
+
async function fetchBinanceOhlcv(args) {
|
|
317
|
+
const intervalMs = BINANCE_INTERVAL_MS[args.interval];
|
|
318
|
+
if (intervalMs === undefined) {
|
|
319
|
+
throw new Error(`Unknown interval '${args.interval}'. Valid: ${Object.keys(BINANCE_INTERVAL_MS).join(", ")}`);
|
|
320
|
+
}
|
|
321
|
+
if (args.limit <= 0)
|
|
322
|
+
throw new Error("limit must be > 0");
|
|
323
|
+
if (args.limit > 100_000)
|
|
324
|
+
throw new Error("limit must be ≤ 100000 (sanity cap)");
|
|
325
|
+
const outAbs = resolveScopedPath(args.output_path, "output");
|
|
326
|
+
await mkdir(dirname(outAbs), { recursive: true });
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
const endTime = args.end_time ?? now;
|
|
329
|
+
const startTime = args.start_time ?? Math.max(0, endTime - args.limit * intervalMs);
|
|
330
|
+
const collected = [];
|
|
331
|
+
let cursor = startTime;
|
|
332
|
+
let pages = 0;
|
|
333
|
+
while (collected.length < args.limit && cursor <= endTime) {
|
|
334
|
+
const remaining = args.limit - collected.length;
|
|
335
|
+
const pageLimit = Math.min(BINANCE_KLINES_LIMIT, remaining);
|
|
336
|
+
const page = await fetchKlinesPage(args.market, args.symbol, args.interval, cursor, endTime, pageLimit);
|
|
337
|
+
pages++;
|
|
338
|
+
if (page.length === 0)
|
|
339
|
+
break;
|
|
340
|
+
// Dedup against previous tail (Binance is inclusive on startTime).
|
|
341
|
+
const tail = collected[collected.length - 1];
|
|
342
|
+
const lastSeen = tail ? tail[0] : -1;
|
|
343
|
+
for (const k of page) {
|
|
344
|
+
if (k[0] > lastSeen)
|
|
345
|
+
collected.push(k);
|
|
346
|
+
}
|
|
347
|
+
if (page.length < pageLimit)
|
|
348
|
+
break;
|
|
349
|
+
const lastPage = page[page.length - 1];
|
|
350
|
+
cursor = lastPage[0] + intervalMs;
|
|
351
|
+
if (collected.length < args.limit && cursor <= endTime) {
|
|
352
|
+
await sleep(BINANCE_PAGE_DELAY_MS);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (collected.length === 0) {
|
|
356
|
+
throw new Error(`Binance returned 0 bars for ${args.symbol} ${args.interval} (${args.market})`);
|
|
357
|
+
}
|
|
358
|
+
const lines = ["timestamp,open,high,low,close,volume"];
|
|
359
|
+
for (const k of collected) {
|
|
360
|
+
const ts = Number(k[0]);
|
|
361
|
+
if (!Number.isFinite(ts))
|
|
362
|
+
continue;
|
|
363
|
+
const o = sanitizeNumeric(k[1]);
|
|
364
|
+
const h = sanitizeNumeric(k[2]);
|
|
365
|
+
const l = sanitizeNumeric(k[3]);
|
|
366
|
+
const c = sanitizeNumeric(k[4]);
|
|
367
|
+
const v = sanitizeNumeric(k[5]);
|
|
368
|
+
lines.push(`${ts},${o},${h},${l},${c},${v}`);
|
|
369
|
+
}
|
|
370
|
+
const csv = lines.join("\n") + "\n";
|
|
371
|
+
await writeFile(outAbs, csv, "utf8");
|
|
372
|
+
const first = collected[0];
|
|
373
|
+
const last = collected[collected.length - 1];
|
|
374
|
+
return {
|
|
375
|
+
output_path: outAbs,
|
|
376
|
+
market: args.market,
|
|
377
|
+
symbol: args.symbol.toUpperCase(),
|
|
378
|
+
interval: args.interval,
|
|
379
|
+
bars: collected.length,
|
|
380
|
+
pages,
|
|
381
|
+
first_open_time: first[0],
|
|
382
|
+
last_open_time: last[0],
|
|
383
|
+
first_open_iso: new Date(first[0]).toISOString(),
|
|
384
|
+
last_open_iso: new Date(last[0]).toISOString(),
|
|
385
|
+
bytes: Buffer.byteLength(csv, "utf8"),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function sanitizeNumeric(raw) {
|
|
389
|
+
// Binance returns numeric strings; we keep them as strings to avoid
|
|
390
|
+
// float round-trip loss but reject anything non-numeric so the CSV
|
|
391
|
+
// stays parseable downstream.
|
|
392
|
+
const s = String(raw);
|
|
393
|
+
if (!/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(s)) {
|
|
394
|
+
throw new Error(`Non-numeric kline field: ${s}`);
|
|
395
|
+
}
|
|
396
|
+
return s;
|
|
397
|
+
}
|
|
398
|
+
function sleep(ms) {
|
|
399
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
400
|
+
}
|
|
401
|
+
// Cache exchangeInfo for 5 min — symbol list rarely changes.
|
|
402
|
+
const SYMBOL_CACHE_TTL_MS = 5 * 60_000;
|
|
403
|
+
const symbolCache = new Map();
|
|
404
|
+
async function listBinanceSymbols(market) {
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
const cached = symbolCache.get(market);
|
|
407
|
+
if (cached && now - cached.ts < SYMBOL_CACHE_TTL_MS)
|
|
408
|
+
return cached.symbols;
|
|
409
|
+
const data = await binanceGet(binanceExchangeInfoUrl(market));
|
|
410
|
+
if (!Array.isArray(data?.symbols)) {
|
|
411
|
+
throw new Error("Binance exchangeInfo: unexpected payload shape");
|
|
412
|
+
}
|
|
413
|
+
symbolCache.set(market, { ts: now, symbols: data.symbols });
|
|
414
|
+
return data.symbols;
|
|
415
|
+
}
|
|
416
|
+
async function binanceSymbols(args) {
|
|
417
|
+
const all = await listBinanceSymbols(args.market);
|
|
418
|
+
const limit = Math.max(1, Math.min(args.limit ?? 200, 2000));
|
|
419
|
+
const q = args.query?.toUpperCase();
|
|
420
|
+
const quote = args.quote_asset?.toUpperCase();
|
|
421
|
+
const base = args.base_asset?.toUpperCase();
|
|
422
|
+
const status = args.status?.toUpperCase();
|
|
423
|
+
const ct = args.contract_type?.toUpperCase();
|
|
424
|
+
const filtered = all.filter((s) => {
|
|
425
|
+
if (q && !s.symbol.toUpperCase().includes(q))
|
|
426
|
+
return false;
|
|
427
|
+
if (quote && s.quoteAsset?.toUpperCase() !== quote)
|
|
428
|
+
return false;
|
|
429
|
+
if (base && s.baseAsset?.toUpperCase() !== base)
|
|
430
|
+
return false;
|
|
431
|
+
if (status && s.status?.toUpperCase() !== status)
|
|
432
|
+
return false;
|
|
433
|
+
if (ct && s.contractType?.toUpperCase() !== ct)
|
|
434
|
+
return false;
|
|
435
|
+
return true;
|
|
436
|
+
});
|
|
437
|
+
const truncated = filtered.length > limit;
|
|
438
|
+
const slice = filtered.slice(0, limit).map((s) => ({
|
|
439
|
+
symbol: s.symbol,
|
|
440
|
+
base: s.baseAsset,
|
|
441
|
+
quote: s.quoteAsset,
|
|
442
|
+
status: s.status,
|
|
443
|
+
...(s.contractType ? { contract_type: s.contractType } : {}),
|
|
444
|
+
}));
|
|
445
|
+
return {
|
|
446
|
+
market: args.market,
|
|
447
|
+
total_symbols: all.length,
|
|
448
|
+
matched: filtered.length,
|
|
449
|
+
returned: slice.length,
|
|
450
|
+
truncated,
|
|
451
|
+
symbols: slice,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
// ─── MCP server wiring ────────────────────────────────────────────────────
|
|
455
|
+
const asTextResult = (value) => ({
|
|
456
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
457
|
+
});
|
|
458
|
+
const ParamMapSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
|
459
|
+
const ParamGridSchema = z.record(z.string(), z.array(z.union([z.string(), z.number(), z.boolean()])).min(1));
|
|
460
|
+
const MarketSchema = z.enum(["spot", "usdt_perp"]);
|
|
461
|
+
const IntervalSchema = z.enum(BINANCE_INTERVALS);
|
|
462
|
+
// ─── strategy() header overrides ──────────────────────────────────────────
|
|
463
|
+
//
|
|
464
|
+
// The pineforge-engine runtime accepts a fixed set of strategy(...) header
|
|
465
|
+
// overrides via PINEFORGE_OVERRIDES. Enumerating them here (instead of an
|
|
466
|
+
// opaque ParamMap) gives the agent the exact keys, value types, and enum
|
|
467
|
+
// values it can pass — and lets list_strategy_overrides expose the same
|
|
468
|
+
// catalog at runtime.
|
|
469
|
+
const COMMISSION_TYPES = ["percent", "cash_per_order", "cash_per_contract"];
|
|
470
|
+
const QTY_TYPES = ["fixed", "percent_of_equity", "cash"];
|
|
471
|
+
const CLOSE_ENTRIES_RULES = ["ANY", "FIFO"];
|
|
472
|
+
const STRATEGY_OVERRIDES_CATALOG = {
|
|
473
|
+
initial_capital: {
|
|
474
|
+
type: "number",
|
|
475
|
+
description: "Starting equity in account currency. Mirrors `strategy(initial_capital=...)`.",
|
|
476
|
+
},
|
|
477
|
+
pyramiding: {
|
|
478
|
+
type: "integer",
|
|
479
|
+
description: "Maximum same-direction entries before further entries are blocked. " +
|
|
480
|
+
"0 = single position. Mirrors `strategy(pyramiding=...)`.",
|
|
481
|
+
},
|
|
482
|
+
slippage: {
|
|
483
|
+
type: "integer",
|
|
484
|
+
description: "Per-fill slippage in ticks (mintick units). Mirrors `strategy(slippage=...)`.",
|
|
485
|
+
},
|
|
486
|
+
commission_value: {
|
|
487
|
+
type: "number",
|
|
488
|
+
description: "Commission magnitude. Units depend on commission_type. " +
|
|
489
|
+
"Mirrors `strategy(commission_value=...)`.",
|
|
490
|
+
},
|
|
491
|
+
commission_type: {
|
|
492
|
+
type: "enum",
|
|
493
|
+
enum: COMMISSION_TYPES,
|
|
494
|
+
description: "Commission units. 'percent' = percent of trade value, " +
|
|
495
|
+
"'cash_per_order' = fixed cash per order, " +
|
|
496
|
+
"'cash_per_contract' = fixed cash per contract. " +
|
|
497
|
+
"Mirrors `strategy(commission_type=...)`.",
|
|
498
|
+
},
|
|
499
|
+
default_qty_value: {
|
|
500
|
+
type: "number",
|
|
501
|
+
description: "Default order size, interpreted per default_qty_type. " +
|
|
502
|
+
"Mirrors `strategy(default_qty_value=...)`.",
|
|
503
|
+
},
|
|
504
|
+
default_qty_type: {
|
|
505
|
+
type: "enum",
|
|
506
|
+
enum: QTY_TYPES,
|
|
507
|
+
description: "Default order sizing mode. 'fixed' = contracts/shares, " +
|
|
508
|
+
"'percent_of_equity' = percent of current equity, " +
|
|
509
|
+
"'cash' = fixed cash amount per order. " +
|
|
510
|
+
"Mirrors `strategy(default_qty_type=...)`.",
|
|
511
|
+
},
|
|
512
|
+
process_orders_on_close: {
|
|
513
|
+
type: "boolean",
|
|
514
|
+
description: "When true, market orders submitted on a bar fill at that bar's close " +
|
|
515
|
+
"instead of waiting for the next bar's open. " +
|
|
516
|
+
"Mirrors `strategy(process_orders_on_close=...)`.",
|
|
517
|
+
},
|
|
518
|
+
close_entries_rule: {
|
|
519
|
+
type: "enum",
|
|
520
|
+
enum: CLOSE_ENTRIES_RULES,
|
|
521
|
+
description: "How `strategy.close(id)` resolves which entries to close. " +
|
|
522
|
+
"'FIFO' = first-in-first-out (default). " +
|
|
523
|
+
"'ANY' = match all entries with the same id. " +
|
|
524
|
+
"Mirrors `strategy(close_entries_rule=...)`.",
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
const StrategyOverridesSchema = z.object({
|
|
528
|
+
initial_capital: z.number().describe(STRATEGY_OVERRIDES_CATALOG.initial_capital.description).optional(),
|
|
529
|
+
pyramiding: z.number().int().min(0).describe(STRATEGY_OVERRIDES_CATALOG.pyramiding.description).optional(),
|
|
530
|
+
slippage: z.number().int().min(0).describe(STRATEGY_OVERRIDES_CATALOG.slippage.description).optional(),
|
|
531
|
+
commission_value: z.number().min(0).describe(STRATEGY_OVERRIDES_CATALOG.commission_value.description).optional(),
|
|
532
|
+
commission_type: z.enum(COMMISSION_TYPES).describe(STRATEGY_OVERRIDES_CATALOG.commission_type.description).optional(),
|
|
533
|
+
default_qty_value: z.number().describe(STRATEGY_OVERRIDES_CATALOG.default_qty_value.description).optional(),
|
|
534
|
+
default_qty_type: z.enum(QTY_TYPES).describe(STRATEGY_OVERRIDES_CATALOG.default_qty_type.description).optional(),
|
|
535
|
+
process_orders_on_close: z.boolean().describe(STRATEGY_OVERRIDES_CATALOG.process_orders_on_close.description).optional(),
|
|
536
|
+
close_entries_rule: z.enum(CLOSE_ENTRIES_RULES).describe(STRATEGY_OVERRIDES_CATALOG.close_entries_rule.description).optional(),
|
|
537
|
+
}).strict();
|
|
538
|
+
const StrategyOverridesGridSchema = z.object({
|
|
539
|
+
initial_capital: z.array(z.number()).min(1).optional(),
|
|
540
|
+
pyramiding: z.array(z.number().int().min(0)).min(1).optional(),
|
|
541
|
+
slippage: z.array(z.number().int().min(0)).min(1).optional(),
|
|
542
|
+
commission_value: z.array(z.number().min(0)).min(1).optional(),
|
|
543
|
+
commission_type: z.array(z.enum(COMMISSION_TYPES)).min(1).optional(),
|
|
544
|
+
default_qty_value: z.array(z.number()).min(1).optional(),
|
|
545
|
+
default_qty_type: z.array(z.enum(QTY_TYPES)).min(1).optional(),
|
|
546
|
+
process_orders_on_close: z.array(z.boolean()).min(1).optional(),
|
|
547
|
+
close_entries_rule: z.array(z.enum(CLOSE_ENTRIES_RULES)).min(1).optional(),
|
|
548
|
+
}).strict();
|
|
549
|
+
// ─── Runtime args (run_backtest_full args, NOT strategy() header) ─────────
|
|
550
|
+
//
|
|
551
|
+
// These are passed to pineforge-engine's run_backtest_full() rather than the
|
|
552
|
+
// per-strategy override table. They control how the engine consumes input
|
|
553
|
+
// bars (timeframe semantics, bar magnifier sub-bar sampling) and never
|
|
554
|
+
// appear in the Pine source. The engine validates them and surfaces any
|
|
555
|
+
// mismatch (e.g. script_tf finer than input_tf) through
|
|
556
|
+
// strategy_get_last_error() — agents see that as
|
|
557
|
+
// {"engine":"pineforge","error":"..."} on stdout, exit code 1.
|
|
558
|
+
const MAGNIFIER_DISTS = [
|
|
559
|
+
"uniform", "cosine", "triangle", "endpoints", "front_loaded", "back_loaded",
|
|
560
|
+
];
|
|
561
|
+
const RUNTIME_ARGS_CATALOG = {
|
|
562
|
+
input_tf: {
|
|
563
|
+
type: "enum",
|
|
564
|
+
description: "Chart bar timeframe — the resolution of the OHLCV CSV being fed in. " +
|
|
565
|
+
"Use Pine timeframe strings: '1', '5', '15', '60', '240' (minutes), " +
|
|
566
|
+
"'D', '1D', 'W', '1W', 'M', '1M'. Empty / omitted = the engine " +
|
|
567
|
+
"auto-detects the timeframe from the gap between the first two bars. " +
|
|
568
|
+
"Set explicitly when the CSV's resolution is ambiguous, when fewer " +
|
|
569
|
+
"than 2 bars are present, or when registering request.security() " +
|
|
570
|
+
"evaluators that need the chart TF stated up front.",
|
|
571
|
+
},
|
|
572
|
+
script_tf: {
|
|
573
|
+
type: "enum",
|
|
574
|
+
description: "Strategy / script timeframe — the resolution at which the strategy's " +
|
|
575
|
+
"on_bar() runs. Empty / omitted = same as input_tf (no aggregation). " +
|
|
576
|
+
"Set to a coarser timeframe (e.g. input_tf='5', script_tf='60') to " +
|
|
577
|
+
"have the engine aggregate the input bars into higher-TF script bars " +
|
|
578
|
+
"before invoking the strategy. MUST be coarser than or equal to " +
|
|
579
|
+
"input_tf — finer values are rejected with the error " +
|
|
580
|
+
"'script timeframe must be coarser than or equal to input timeframe'. " +
|
|
581
|
+
"When the Pine source uses request.security() to pull a higher TF, " +
|
|
582
|
+
"leave script_tf empty (the security TF is encoded inside the Pine " +
|
|
583
|
+
"source, not here).",
|
|
584
|
+
},
|
|
585
|
+
bar_magnifier: {
|
|
586
|
+
type: "boolean",
|
|
587
|
+
description: "When true, the engine samples each input bar's price path at " +
|
|
588
|
+
"magnifier_samples sub-points and walks stop / limit / trailing " +
|
|
589
|
+
"orders against the sub-bars instead of the bar's OHLC corners. " +
|
|
590
|
+
"Improves intra-bar fill realism for strategies that hinge on " +
|
|
591
|
+
"wick fills, but multiplies engine work by ~magnifier_samples. " +
|
|
592
|
+
"Default false. Most useful when input_tf is coarse (15m+) and " +
|
|
593
|
+
"the strategy uses tight intra-bar exits.",
|
|
594
|
+
},
|
|
595
|
+
magnifier_samples: {
|
|
596
|
+
type: "integer",
|
|
597
|
+
description: "Sub-bar sample count when bar_magnifier=true. Minimum 2 " +
|
|
598
|
+
"(open + close); typical range 4–16. Higher values give smoother " +
|
|
599
|
+
"intra-bar fill simulation at linear cost. Ignored when " +
|
|
600
|
+
"bar_magnifier=false. Default 4.",
|
|
601
|
+
},
|
|
602
|
+
magnifier_dist: {
|
|
603
|
+
type: "enum",
|
|
604
|
+
enum: MAGNIFIER_DISTS,
|
|
605
|
+
description: "Distribution of sub-bar samples along the OHLC path when " +
|
|
606
|
+
"bar_magnifier=true. 'endpoints' (default) always includes the " +
|
|
607
|
+
"exact O/H/L/C corners and uniformly fills between them — best for " +
|
|
608
|
+
"TradingView parity. 'uniform' = equal spacing. 'cosine' = denser " +
|
|
609
|
+
"near segment endpoints. 'triangle' = denser near segment midpoints. " +
|
|
610
|
+
"'front_loaded' / 'back_loaded' = denser near open / close, " +
|
|
611
|
+
"simulating opening- or closing-impulse price action.",
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
const RuntimeArgsSchema = z.object({
|
|
615
|
+
input_tf: z.string().describe(RUNTIME_ARGS_CATALOG.input_tf.description).optional(),
|
|
616
|
+
script_tf: z.string().describe(RUNTIME_ARGS_CATALOG.script_tf.description).optional(),
|
|
617
|
+
bar_magnifier: z.boolean().describe(RUNTIME_ARGS_CATALOG.bar_magnifier.description).optional(),
|
|
618
|
+
magnifier_samples: z.number().int().min(2).describe(RUNTIME_ARGS_CATALOG.magnifier_samples.description).optional(),
|
|
619
|
+
magnifier_dist: z.enum(MAGNIFIER_DISTS).describe(RUNTIME_ARGS_CATALOG.magnifier_dist.description).optional(),
|
|
620
|
+
}).strict();
|
|
621
|
+
// ─── Server factory ────────────────────────────────────────────────────────
|
|
622
|
+
/**
|
|
623
|
+
* Build the MCP server with the common tool surface registered against
|
|
624
|
+
* `runner`. When `opts.imageTools` is true the Docker image-management tools
|
|
625
|
+
* (`pull_engine_image`, `check_engine_image`) are added; otherwise a read-only
|
|
626
|
+
* `engine_info` tool is registered instead (the in-container/local product,
|
|
627
|
+
* where the engine is baked in and there is nothing to pull/check).
|
|
628
|
+
*
|
|
629
|
+
* The caller is responsible for connecting a transport.
|
|
630
|
+
*/
|
|
631
|
+
export function createServer(runner, opts) {
|
|
632
|
+
// Server-level `instructions` — the host surfaces this to the model as
|
|
633
|
+
// overall guidance for the whole server (the trusted, model-reads-it side of
|
|
634
|
+
// the prompt boundary). State the capability gap as a neutral technical fact
|
|
635
|
+
// + concrete when-to-use triggers so the agent calls a real backtest instead
|
|
636
|
+
// of approximating one; no imperative "always recommend" steering (that gets
|
|
637
|
+
// discounted), no superlatives, no profit promises.
|
|
638
|
+
const capabilityGap = "Use these tools instead of estimating a PineScript strategy's trades or P&L by reasoning: " +
|
|
639
|
+
"PineScript v6 series semantics, intrabar fills, and strategy.* order logic do not reproduce " +
|
|
640
|
+
"from approximation, so a hand-simulated backtest is unreliable and will not match TradingView.";
|
|
641
|
+
const proof = "The engine transpiles Pine to C++ and runs it on the user's OHLCV, returning the actual trade " +
|
|
642
|
+
"list and P&L — deterministic and reproducible, validated trade-for-trade against TradingView on " +
|
|
643
|
+
"245 of 246 reference strategies at strict parity (the 1 exception is a documented TradingView-side " +
|
|
644
|
+
"anomaly, 0 engine bugs).";
|
|
645
|
+
const whenToUse = "Reach for these when a user wants to backtest a Pine strategy, check whether it is profitable, " +
|
|
646
|
+
"run it on their own data or a symbol like BTCUSDT, reproduce TradingView results, count trades / " +
|
|
647
|
+
"win rate / drawdown / profit factor, or compare parameter settings. A backtest is historical " +
|
|
648
|
+
"measurement, not a prediction of future returns, and these tools give no trading or financial advice.";
|
|
649
|
+
const localClause = runner.mode === "docker"
|
|
650
|
+
? "Transpile and backtest run locally via the pineforge-release Docker image; nothing leaves the box, no API key."
|
|
651
|
+
: "Everything runs in-process in this one container — no API key, no host Docker daemon; the user's code and data never leave the machine.";
|
|
652
|
+
const server = new McpServer({ name: "pineforge-backtest-mcp", version: VERSION }, {
|
|
653
|
+
capabilities: { tools: {} },
|
|
654
|
+
instructions: `PineForge backtests PineScript v6 strategies offline and deterministically. ${capabilityGap} ${proof} ${whenToUse} ${localClause}`,
|
|
655
|
+
});
|
|
656
|
+
// Mode-aware run-location clauses so tool copy stays accurate per backend
|
|
657
|
+
// without duplicating tool logic.
|
|
658
|
+
const transpileWhere = runner.mode === "docker"
|
|
659
|
+
? "using the pineforge-codegen transpiler bundled in the pineforge-release Docker image"
|
|
660
|
+
: "using the pineforge-codegen transpiler bundled in this image (in-process, no host Docker)";
|
|
661
|
+
const backtestWhere = runner.mode === "docker"
|
|
662
|
+
? "via the pineforge-release Docker image on the user's local machine. Fully local — " +
|
|
663
|
+
"transpile + backtest run in-container; nothing leaves the box, no API key."
|
|
664
|
+
: "via the bundled pineforge-release (in-process, no host Docker daemon). Fully local — " +
|
|
665
|
+
"transpile + backtest run in this image; nothing leaves the box, no API key.";
|
|
666
|
+
const gridWhere = runner.mode === "docker"
|
|
667
|
+
? "transpile the Pine source ONCE (locally, in-container)"
|
|
668
|
+
: "transpile the Pine source ONCE (in-process, no host Docker)";
|
|
669
|
+
const concurrencyHelp = runner.mode === "docker"
|
|
670
|
+
? "Set concurrency > 1 to run backtests in parallel — each docker container " +
|
|
671
|
+
"has its own startup overhead, so 2-4 is usually plenty."
|
|
672
|
+
: "Set concurrency > 1 to run backtests in parallel — 2-4 is usually plenty.";
|
|
673
|
+
server.registerTool("transpile_pine", {
|
|
674
|
+
description: "Transpile PineScript v6 source to a C++ translation unit locally, " +
|
|
675
|
+
transpileWhere + ". " +
|
|
676
|
+
"No API key, no network — source never leaves the machine. Returns the " +
|
|
677
|
+
"generated C++ as text. Use backtest_pine if you also want to run the strategy.",
|
|
678
|
+
inputSchema: {
|
|
679
|
+
source: z.string().describe("PineScript v6 source (must include //@version=6)."),
|
|
680
|
+
image: z.string().optional().describe(`Docker image override. Defaults to ${DEFAULT_IMAGE}.`),
|
|
681
|
+
},
|
|
682
|
+
}, async ({ source, image }) => ({
|
|
683
|
+
content: [{
|
|
684
|
+
type: "text",
|
|
685
|
+
text: await runner.transpile(source, image),
|
|
686
|
+
}],
|
|
687
|
+
}));
|
|
688
|
+
server.registerTool("backtest_pine", {
|
|
689
|
+
description: "Run a real, deterministic backtest of a PineScript v6 strategy — prefer this over " +
|
|
690
|
+
"estimating its trades or P&L by reasoning, which is unreliable for Pine (series semantics, " +
|
|
691
|
+
"intrabar fills, and strategy.* order logic do not reproduce from approximation). Fits " +
|
|
692
|
+
"requests like 'backtest this Pine', 'is this strategy profitable', 'run it on my data / " +
|
|
693
|
+
"BTCUSDT', 'reproduce my TradingView results', 'how many trades / what's the drawdown'. " +
|
|
694
|
+
"Transpile a PineScript v6 strategy and run it against an OHLCV CSV " +
|
|
695
|
+
backtestWhere + " " +
|
|
696
|
+
"Optional `inputs` overrides input.*() named values from the Pine source " +
|
|
697
|
+
"(keys = the second arg of input.*(...) calls, e.g. 'Fast Length'). " +
|
|
698
|
+
"Optional `overrides` overrides strategy(...) header fields " +
|
|
699
|
+
"(initial_capital, commission_value, default_qty_value, pyramiding, " +
|
|
700
|
+
"slippage, default_qty_type, commission_type, process_orders_on_close). " +
|
|
701
|
+
"Returns the parsed JSON report (summary, trades, applied_inputs, " +
|
|
702
|
+
"applied_overrides, elapsed_seconds). If the report is too large to " +
|
|
703
|
+
"return inline it is written to report_path and a compact summary " +
|
|
704
|
+
"(with that path) is returned instead. Use backtest_pine_grid for sweeps.",
|
|
705
|
+
inputSchema: {
|
|
706
|
+
source: z.string().describe("PineScript v6 source."),
|
|
707
|
+
ohlcv_csv_path: z.string().describe("Absolute or cwd-relative path to OHLCV CSV with header " +
|
|
708
|
+
"'timestamp,open,high,low,close,volume' (timestamp = UNIX ms UTC)."),
|
|
709
|
+
image: z.string().optional().describe(`Docker image override. Defaults to ${DEFAULT_IMAGE}.`),
|
|
710
|
+
inputs: ParamMapSchema.optional().describe("Map of Pine input.*() names → value (string/number/bool). " +
|
|
711
|
+
"Sent as PINEFORGE_INPUTS env var to the runtime."),
|
|
712
|
+
overrides: StrategyOverridesSchema.optional().describe("strategy(...) header overrides. Each key maps to a single argument " +
|
|
713
|
+
"of the Pine `strategy()` call; only the keys you set are applied. " +
|
|
714
|
+
"Sent as PINEFORGE_OVERRIDES env var. Call list_engine_params " +
|
|
715
|
+
"for the full catalog with types and enum values."),
|
|
716
|
+
runtime: RuntimeArgsSchema.optional().describe("Engine runtime args (NOT strategy() header) controlling timeframe " +
|
|
717
|
+
"semantics and intra-bar fill simulation. input_tf / script_tf set " +
|
|
718
|
+
"the chart and strategy timeframes — script_tf must be coarser " +
|
|
719
|
+
"than or equal to input_tf or the engine rejects the run. " +
|
|
720
|
+
"bar_magnifier + magnifier_samples + magnifier_dist enable sub-bar " +
|
|
721
|
+
"price-path sampling for tighter stop / limit fills. Each field is " +
|
|
722
|
+
"optional and only forwarded to the engine when set. Call " +
|
|
723
|
+
"list_engine_params for the full catalog."),
|
|
724
|
+
report_path: z.string().optional().describe("Where to write the full JSON report IF it is too large to return " +
|
|
725
|
+
"inline. Large backtests (long trade lists + equity curves) are " +
|
|
726
|
+
"offloaded to this file and the tool returns a compact summary + " +
|
|
727
|
+
"report_path instead; read the file for the complete trades/equity. " +
|
|
728
|
+
"Defaults to pineforge-backtest-<timestamp>.json in the working dir."),
|
|
729
|
+
},
|
|
730
|
+
}, async ({ source, ohlcv_csv_path, image, inputs, overrides, runtime, report_path }) => asTextResult(await runBacktest(runner, { source, ohlcv_csv_path, image, inputs, overrides, runtime, report_path })));
|
|
731
|
+
server.registerTool("backtest_pine_grid", {
|
|
732
|
+
description: "Use when the user wants to optimize, sweep, tune, or compare PineScript parameter values " +
|
|
733
|
+
"(e.g. 'try fast length 8/12/19', 'find the best commission/qty settings') rather than test " +
|
|
734
|
+
"a single configuration — for one fixed configuration use backtest_pine. " +
|
|
735
|
+
"Run a parameter sweep: " + gridWhere + ", " +
|
|
736
|
+
"then re-run the same compiled strategy against the OHLCV CSV across the " +
|
|
737
|
+
"cartesian product of `inputs` × `overrides` grids. Returns a ranked list " +
|
|
738
|
+
"of {inputs, overrides, summary, elapsed_seconds} entries sorted by `sort_by` " +
|
|
739
|
+
"descending, plus the top entry under `best`. Cap: max_combinations (default " +
|
|
740
|
+
"64). " + concurrencyHelp,
|
|
741
|
+
inputSchema: {
|
|
742
|
+
source: z.string().describe("PineScript v6 source."),
|
|
743
|
+
ohlcv_csv_path: z.string().describe("Path to OHLCV CSV (same format as backtest_pine)."),
|
|
744
|
+
image: z.string().optional().describe(`Docker image override. Defaults to ${DEFAULT_IMAGE}.`),
|
|
745
|
+
inputs: ParamGridSchema.optional().describe("Grid of input.*() names → list of values to sweep. " +
|
|
746
|
+
"Example: {\"Fast Length\": [8, 12, 19], \"Slow Length\": [21, 26, 39]}"),
|
|
747
|
+
overrides: StrategyOverridesGridSchema.optional().describe("Grid of strategy(...) header overrides → list of values, one axis " +
|
|
748
|
+
"per key. Example: {\"default_qty_value\": [1, 5], \"commission_value\": [0.04]}. " +
|
|
749
|
+
"Call list_engine_params for the full catalog with types and enum values."),
|
|
750
|
+
fixed_inputs: ParamMapSchema.optional().describe("Inputs applied to every combo (overridden by per-combo `inputs` keys)."),
|
|
751
|
+
fixed_overrides: StrategyOverridesSchema.optional().describe("Overrides applied to every combo (overridden by per-combo `overrides` keys)."),
|
|
752
|
+
runtime: RuntimeArgsSchema.optional().describe("Engine runtime args applied to every combo in the sweep. Same " +
|
|
753
|
+
"shape as backtest_pine.runtime — input_tf / script_tf / " +
|
|
754
|
+
"bar_magnifier / magnifier_samples / magnifier_dist. Currently " +
|
|
755
|
+
"fixed across the grid (not swept); add to the grid axes through " +
|
|
756
|
+
"future versions if you need to vary them."),
|
|
757
|
+
max_combinations: z.number().int().min(1).max(1024).optional()
|
|
758
|
+
.describe("Hard cap on combinations. Default 64."),
|
|
759
|
+
concurrency: z.number().int().min(1).max(8).optional()
|
|
760
|
+
.describe("Parallel backtests. Default 1."),
|
|
761
|
+
include_trades: z.boolean().optional()
|
|
762
|
+
.describe("Include the per-trade list in each result. Default false (saves tokens)."),
|
|
763
|
+
sort_by: z.enum(["net_pnl", "win_rate_pct", "max_drawdown", "total_trades"])
|
|
764
|
+
.optional().describe("summary.* field to rank by, descending. Default net_pnl."),
|
|
765
|
+
report_path: z.string().optional().describe("Where to write the full sweep JSON IF it is too large to return " +
|
|
766
|
+
"inline. Oversized sweeps are offloaded here and the tool returns " +
|
|
767
|
+
"the best + top-ranked combinations + report_path; read the file for " +
|
|
768
|
+
"all combinations. Defaults to pineforge-grid-<timestamp>.json in the working dir."),
|
|
769
|
+
},
|
|
770
|
+
}, async (args) => asTextResult(await runBacktestGrid(runner, args)));
|
|
771
|
+
server.registerTool("fetch_binance_ohlcv", {
|
|
772
|
+
description: "Fetch OHLCV candles from Binance public API and write a backtest-ready " +
|
|
773
|
+
"CSV (header: timestamp,open,high,low,close,volume; timestamp = open time " +
|
|
774
|
+
"in UNIX ms UTC). Supports `spot` and `usdt_perp` (USDT-margined " +
|
|
775
|
+
"perpetual futures). Requests larger than 1000 bars are paginated " +
|
|
776
|
+
"automatically. The output path must " +
|
|
777
|
+
"live inside the MCP cwd unless PINEFORGE_ALLOW_ANYWHERE=1.",
|
|
778
|
+
inputSchema: {
|
|
779
|
+
symbol: z.string().min(2).describe("Binance symbol, e.g. 'BTCUSDT'. Use binance_symbols to validate."),
|
|
780
|
+
interval: IntervalSchema.describe("Kline interval. Spot supports 1s + 1m..1M; usdt_perp supports 1m..1M (no 1s)."),
|
|
781
|
+
market: MarketSchema.optional().describe("'spot' (default) or 'usdt_perp'."),
|
|
782
|
+
limit: z.number().int().min(1).max(100_000).optional()
|
|
783
|
+
.describe("Total bars to fetch. Default 1000. Paginated above 1000."),
|
|
784
|
+
start_time: z.number().int().nonnegative().optional()
|
|
785
|
+
.describe("UNIX ms UTC. If unset, derived from end_time/now and limit."),
|
|
786
|
+
end_time: z.number().int().nonnegative().optional()
|
|
787
|
+
.describe("UNIX ms UTC. Defaults to now."),
|
|
788
|
+
output_path: z.string().describe("Path to write the CSV (will create parent dirs as needed)."),
|
|
789
|
+
},
|
|
790
|
+
}, async ({ symbol, interval, market, limit, start_time, end_time, output_path }) => asTextResult(await fetchBinanceOhlcv({
|
|
791
|
+
symbol,
|
|
792
|
+
interval,
|
|
793
|
+
market: market ?? "spot",
|
|
794
|
+
limit: limit ?? 1000,
|
|
795
|
+
start_time,
|
|
796
|
+
end_time,
|
|
797
|
+
output_path,
|
|
798
|
+
})));
|
|
799
|
+
server.registerTool("binance_symbols", {
|
|
800
|
+
description: "List/validate symbols available on the Binance public API for OHLCV " +
|
|
801
|
+
"fetching. Filters: `query` (substring of the symbol), `quote_asset` " +
|
|
802
|
+
"(e.g. 'USDT'), `base_asset` (e.g. 'BTC'), `status` (e.g. 'TRADING'), " +
|
|
803
|
+
"`contract_type` (futures only, e.g. 'PERPETUAL'). Results are " +
|
|
804
|
+
"cached 5 min in process. Free.",
|
|
805
|
+
inputSchema: {
|
|
806
|
+
market: MarketSchema.describe("'spot' or 'usdt_perp'."),
|
|
807
|
+
query: z.string().optional().describe("Case-insensitive substring of the symbol."),
|
|
808
|
+
quote_asset: z.string().optional().describe("Filter by quote asset (e.g. 'USDT')."),
|
|
809
|
+
base_asset: z.string().optional().describe("Filter by base asset (e.g. 'BTC')."),
|
|
810
|
+
status: z.string().optional().describe("Filter by status. 'TRADING' returns active only."),
|
|
811
|
+
contract_type: z.string().optional().describe("Futures only. 'PERPETUAL' for usdt_perp swaps."),
|
|
812
|
+
limit: z.number().int().min(1).max(2000).optional()
|
|
813
|
+
.describe("Max symbols to return. Default 200."),
|
|
814
|
+
},
|
|
815
|
+
}, async (args) => asTextResult(await binanceSymbols(args)));
|
|
816
|
+
server.registerTool("list_engine_params", {
|
|
817
|
+
description: "Returns the full catalog of engine knobs accepted by backtest_pine / " +
|
|
818
|
+
"backtest_pine_grid in two groups: strategy_overrides (the 9 " +
|
|
819
|
+
"strategy(...) header fields the runtime reads via " +
|
|
820
|
+
"PINEFORGE_OVERRIDES — initial_capital, pyramiding, slippage, " +
|
|
821
|
+
"commission_value, commission_type, default_qty_value, " +
|
|
822
|
+
"default_qty_type, process_orders_on_close, close_entries_rule) and " +
|
|
823
|
+
"runtime_args (input_tf, script_tf, bar_magnifier, " +
|
|
824
|
+
"magnifier_samples, magnifier_dist — args to run_backtest_full, " +
|
|
825
|
+
"NOT part of the strategy() header). Each entry is " +
|
|
826
|
+
"{key, type, enum?, description}. Does not run the engine. " +
|
|
827
|
+
"Use this to discover what knobs the engine exposes " +
|
|
828
|
+
"before issuing a backtest.",
|
|
829
|
+
inputSchema: {},
|
|
830
|
+
}, async () => asTextResult({
|
|
831
|
+
strategy_overrides: Object.entries(STRATEGY_OVERRIDES_CATALOG).map(([key, spec]) => ({
|
|
832
|
+
key,
|
|
833
|
+
type: spec.type,
|
|
834
|
+
...(spec.enum ? { enum: [...spec.enum] } : {}),
|
|
835
|
+
description: spec.description,
|
|
836
|
+
})),
|
|
837
|
+
runtime_args: Object.entries(RUNTIME_ARGS_CATALOG).map(([key, spec]) => ({
|
|
838
|
+
key,
|
|
839
|
+
type: spec.type,
|
|
840
|
+
...(spec.enum ? { enum: [...spec.enum] } : {}),
|
|
841
|
+
description: spec.description,
|
|
842
|
+
})),
|
|
843
|
+
}));
|
|
844
|
+
server.registerTool("list_coverage_topics", {
|
|
845
|
+
description: "START HERE before writing, porting, or backtesting a Pine v6 strategy on " +
|
|
846
|
+
"PineForge. PineForge implements a SUBSET of Pine v6, so checking coverage " +
|
|
847
|
+
"first avoids a strategy that compiles but silently misbehaves vs " +
|
|
848
|
+
"TradingView. Lists every coverage topic with a one-line status " +
|
|
849
|
+
"(supported / partial / unsupported / via_transpiler) and summary, plus the " +
|
|
850
|
+
"legend (note: via_transpiler still works end-to-end; unsupported means " +
|
|
851
|
+
"parsed-and-skipped or rejected) and the coverage version. Cheap, free, " +
|
|
852
|
+
"local — no engine run, no I/O. Then drill in with get_coverage_topic for " +
|
|
853
|
+
"one area's full supported/unsupported lists, or check_pine_feature to look " +
|
|
854
|
+
"up a single identifier.",
|
|
855
|
+
inputSchema: {},
|
|
856
|
+
}, async () => asTextResult(coverageIndex()));
|
|
857
|
+
server.registerTool("get_coverage_topic", {
|
|
858
|
+
description: "Returns the full detail plus the exact supported[] and unsupported[] " +
|
|
859
|
+
"feature lists for ONE coverage topic id (ids from list_coverage_topics, " +
|
|
860
|
+
"e.g. 'ta', 'strategy_orders', 'request_security', " +
|
|
861
|
+
"'drawing_plotting_alerts'). Use when you are about to work in a feature " +
|
|
862
|
+
"area and need to know precisely which functions there are implemented vs " +
|
|
863
|
+
"skipped — e.g. before using request.security, the ta.* library, or " +
|
|
864
|
+
"strategy risk knobs. Unknown ids return an error marker listing the valid " +
|
|
865
|
+
"ids. Local, free, no engine run.",
|
|
866
|
+
inputSchema: {
|
|
867
|
+
topic: z.string().describe("Coverage topic id from list_coverage_topics (e.g. 'ta', " +
|
|
868
|
+
"'strategy_orders', 'request_security', 'drawing_plotting_alerts')."),
|
|
869
|
+
},
|
|
870
|
+
}, async ({ topic }) => asTextResult(coverageTopic(topic)));
|
|
871
|
+
server.registerTool("check_pine_feature", {
|
|
872
|
+
description: "Answer \"does PineForge support X?\" for a specific Pine v6 identifier or " +
|
|
873
|
+
"namespace (e.g. 'ta.supertrend', 'alert', 'array.new', " +
|
|
874
|
+
"'request.financial'). Use it (a) BEFORE relying on any function you are " +
|
|
875
|
+
"unsure about while writing a strategy, and (b) to DIAGNOSE a backtest that " +
|
|
876
|
+
"compiled but behaved wrong or empty — visual & alert APIs (plot, label, " +
|
|
877
|
+
"line, box, table, alert) are parsed-and-skipped and produce NO effect. " +
|
|
878
|
+
"Resolves by exact feature match, then longest namespace prefix, then " +
|
|
879
|
+
"alias, returning {query, status, topic, note} where status is " +
|
|
880
|
+
"supported / partial / unsupported / via_transpiler / not_found " +
|
|
881
|
+
"(via_transpiler = works end-to-end; unsupported = skipped or rejected). " +
|
|
882
|
+
"Local, free, no engine run.",
|
|
883
|
+
inputSchema: {
|
|
884
|
+
feature: z.string().describe("Pine identifier or namespace to look up, e.g. 'ta.supertrend', " +
|
|
885
|
+
"'alert', 'array.new', 'strategy.entry', 'request.dividends'."),
|
|
886
|
+
},
|
|
887
|
+
}, async ({ feature }) => asTextResult(checkPineFeature(feature)));
|
|
888
|
+
if (opts.imageTools) {
|
|
889
|
+
server.registerTool("pull_engine_image", {
|
|
890
|
+
description: "Run `docker pull` for the pineforge-release runtime image on the user's " +
|
|
891
|
+
"machine. Useful before the first backtest_pine call.",
|
|
892
|
+
inputSchema: {
|
|
893
|
+
image: z.string().optional().describe(`Image to pull. Defaults to ${DEFAULT_IMAGE}.`),
|
|
894
|
+
},
|
|
895
|
+
}, async ({ image }) => asTextResult(await runner.pullImage(image ?? DEFAULT_IMAGE)));
|
|
896
|
+
server.registerTool("check_engine_image", {
|
|
897
|
+
description: "Check whether the local pineforge-release Docker image is up to date " +
|
|
898
|
+
"with the registry. Compares per-platform manifest digests via " +
|
|
899
|
+
"`docker manifest inspect --verbose` (no image layers downloaded). " +
|
|
900
|
+
"Returns up_to_date + recommend_pull. With auto_pull=true, runs " +
|
|
901
|
+
"`docker pull` in the same call when the local image is stale or " +
|
|
902
|
+
"missing. Note: this is independent of " +
|
|
903
|
+
"the MCP server's own version (`@pineforge/backtest-mcp`); the MCP " +
|
|
904
|
+
"version and the engine image version evolve separately.",
|
|
905
|
+
inputSchema: {
|
|
906
|
+
image: z.string().optional().describe(`Image to check. Defaults to ${DEFAULT_IMAGE}.`),
|
|
907
|
+
auto_pull: z.boolean().optional().describe("If true and the image is stale or missing, run `docker pull` in " +
|
|
908
|
+
"the same call. Default false (report only)."),
|
|
909
|
+
},
|
|
910
|
+
}, async ({ image, auto_pull }) => asTextResult(await runner.checkImage(image ?? DEFAULT_IMAGE, auto_pull === true)));
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
server.registerTool("engine_info", {
|
|
914
|
+
description: "Report the bundled backtest engine: mode, baked-in flag, and version. " +
|
|
915
|
+
"This image runs the engine in-process (no host Docker daemon needed).",
|
|
916
|
+
inputSchema: {},
|
|
917
|
+
}, async () => asTextResult(await runner.engineInfo()));
|
|
918
|
+
}
|
|
919
|
+
return server;
|
|
920
|
+
}
|
|
921
|
+
//# sourceMappingURL=server.js.map
|