tradelab 1.0.1 → 1.1.0
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 +66 -0
- package/README.md +75 -12
- package/bin/tradelab-mcp.js +7 -0
- package/bin/tradelab.js +29 -0
- package/dist/cjs/data.cjs +149 -26
- package/dist/cjs/index.cjs +1893 -1003
- package/dist/cjs/live.cjs +134 -25
- package/dist/cjs/ta.cjs +339 -0
- package/docs/api-reference.md +46 -0
- package/docs/backtest-engine.md +112 -0
- package/docs/live-trading.md +51 -0
- package/docs/mcp.md +64 -0
- package/docs/research.md +103 -0
- package/docs/superpowers/plans/2026-00-overview.md +101 -0
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
- package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
- package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
- package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
- package/docs/superpowers/plans/HANDOFF.md +88 -0
- package/examples/liveDashboard.js +33 -0
- package/examples/llmSignal.js +33 -0
- package/examples/optimize.js +25 -0
- package/package.json +16 -2
- package/src/engine/asyncSignal.js +28 -0
- package/src/engine/backtest.js +13 -1
- package/src/engine/backtestAsync.js +27 -0
- package/src/engine/backtestTicks.js +13 -2
- package/src/engine/barSystemRunner.js +96 -41
- package/src/engine/execution.js +39 -0
- package/src/engine/grid.js +15 -0
- package/src/engine/llmSignal.js +84 -0
- package/src/engine/optimize.js +86 -0
- package/src/engine/optimizeWorker.js +67 -0
- package/src/engine/walkForward.js +1 -0
- package/src/index.js +9 -0
- package/src/live/dashboard/server.js +120 -0
- package/src/live/engine/liveEngine.js +2 -2
- package/src/live/index.js +1 -0
- package/src/mcp/schemas.js +48 -0
- package/src/mcp/server.js +31 -0
- package/src/mcp/tools.js +142 -0
- package/src/metrics/annualize.js +32 -0
- package/src/metrics/benchmark.js +55 -0
- package/src/metrics/buildMetrics.js +34 -13
- package/src/metrics/finite.js +17 -0
- package/src/research/combinations.js +18 -0
- package/src/research/cpcv.js +47 -0
- package/src/research/deflatedSharpe.js +35 -0
- package/src/research/index.js +6 -0
- package/src/research/monteCarlo.js +88 -0
- package/src/research/pbo.js +69 -0
- package/src/research/stats.js +78 -0
- package/src/strategies/builtins.js +96 -0
- package/src/strategies/index.js +30 -0
- package/src/ta/channels.js +67 -0
- package/src/ta/index.js +16 -0
- package/src/ta/oscillators.js +70 -0
- package/src/ta/trend.js +78 -0
- package/src/utils/random.js +33 -0
- package/templates/dashboard.html +174 -0
- package/types/index.d.ts +154 -0
- package/types/live.d.ts +15 -0
- package/types/ta.d.ts +45 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
function defaultConcurrency() {
|
|
5
|
+
return Math.max(1, (os.cpus()?.length ?? 2) - 1);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function scoreValue(metrics, scoreBy) {
|
|
9
|
+
const v = metrics?.[scoreBy];
|
|
10
|
+
return Number.isFinite(v) ? v : -Infinity;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function optimize({
|
|
14
|
+
candles,
|
|
15
|
+
signalModulePath,
|
|
16
|
+
parameterSets,
|
|
17
|
+
interval,
|
|
18
|
+
backtestOptions = {},
|
|
19
|
+
concurrency,
|
|
20
|
+
scoreBy = "profitFactor",
|
|
21
|
+
}) {
|
|
22
|
+
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
23
|
+
return Promise.resolve({ results: [], leaderboard: [], best: null });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const poolSize = Math.min(concurrency || defaultConcurrency(), parameterSets.length);
|
|
28
|
+
const results = new Array(parameterSets.length);
|
|
29
|
+
const workers = [];
|
|
30
|
+
let nextIndex = 0;
|
|
31
|
+
let completed = 0;
|
|
32
|
+
let settled = false;
|
|
33
|
+
|
|
34
|
+
const finish = () => {
|
|
35
|
+
if (settled) return;
|
|
36
|
+
settled = true;
|
|
37
|
+
for (const w of workers) w.terminate();
|
|
38
|
+
const ranked = results
|
|
39
|
+
.filter((r) => r && r.metrics)
|
|
40
|
+
.sort((a, b) => scoreValue(b.metrics, scoreBy) - scoreValue(a.metrics, scoreBy));
|
|
41
|
+
resolve({ results, leaderboard: ranked, best: ranked[0] ?? null });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const fail = (error) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
for (const w of workers) w.terminate();
|
|
48
|
+
reject(error);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const dispatch = (worker) => {
|
|
52
|
+
if (nextIndex >= parameterSets.length) {
|
|
53
|
+
worker.postMessage({ type: "stop" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const index = nextIndex;
|
|
57
|
+
nextIndex += 1;
|
|
58
|
+
worker.postMessage({ type: "run", index, params: parameterSets[index] });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < poolSize; i += 1) {
|
|
62
|
+
const worker = new Worker(new URL("./optimizeWorker.js", import.meta.url), {
|
|
63
|
+
workerData: { candles, signalModulePath, interval, backtestOptions },
|
|
64
|
+
});
|
|
65
|
+
workers.push(worker);
|
|
66
|
+
|
|
67
|
+
worker.on("message", (msg) => {
|
|
68
|
+
if (msg.type === "ready") {
|
|
69
|
+
dispatch(worker);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === "result" || msg.type === "error") {
|
|
73
|
+
results[msg.index] =
|
|
74
|
+
msg.type === "result"
|
|
75
|
+
? { params: msg.params, metrics: msg.metrics }
|
|
76
|
+
: { params: msg.params, error: msg.error };
|
|
77
|
+
completed += 1;
|
|
78
|
+
if (completed === parameterSets.length) finish();
|
|
79
|
+
else dispatch(worker);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
worker.on("error", fail);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { workerData, parentPort } from "node:worker_threads";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { backtest } from "./backtest.js";
|
|
4
|
+
|
|
5
|
+
const { candles, signalModulePath, interval, backtestOptions } = workerData;
|
|
6
|
+
|
|
7
|
+
const mod = await import(pathToFileURL(signalModulePath).href);
|
|
8
|
+
const createSignal = mod.createSignal ?? mod.default;
|
|
9
|
+
if (typeof createSignal !== "function") {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`optimize: ${signalModulePath} must export createSignal(params) or a default factory`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pickMetrics(metrics) {
|
|
16
|
+
const keep = [
|
|
17
|
+
"trades",
|
|
18
|
+
"winRate",
|
|
19
|
+
"profitFactor",
|
|
20
|
+
"expectancy",
|
|
21
|
+
"totalR",
|
|
22
|
+
"avgR",
|
|
23
|
+
"sharpe",
|
|
24
|
+
"sharpeAnnualized",
|
|
25
|
+
"maxDrawdown",
|
|
26
|
+
"calmar",
|
|
27
|
+
"returnPct",
|
|
28
|
+
"totalPnL",
|
|
29
|
+
"finalEquity",
|
|
30
|
+
];
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const k of keep) out[k] = metrics[k];
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
parentPort.on("message", (msg) => {
|
|
37
|
+
if (msg.type === "stop") {
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
if (msg.type === "run") {
|
|
41
|
+
try {
|
|
42
|
+
const result = backtest({
|
|
43
|
+
candles,
|
|
44
|
+
interval,
|
|
45
|
+
signal: createSignal(msg.params),
|
|
46
|
+
collectReplay: false,
|
|
47
|
+
collectEqSeries: false,
|
|
48
|
+
...backtestOptions,
|
|
49
|
+
});
|
|
50
|
+
parentPort.postMessage({
|
|
51
|
+
type: "result",
|
|
52
|
+
index: msg.index,
|
|
53
|
+
params: msg.params,
|
|
54
|
+
metrics: pickMetrics(result.metrics),
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
parentPort.postMessage({
|
|
58
|
+
type: "error",
|
|
59
|
+
index: msg.index,
|
|
60
|
+
params: msg.params,
|
|
61
|
+
error: error instanceof Error ? error.message : String(error),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
parentPort.postMessage({ type: "ready" });
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
export { backtest } from "./engine/backtest.js";
|
|
2
|
+
export { backtestAsync } from "./engine/backtestAsync.js";
|
|
2
3
|
export { backtestTicks } from "./engine/backtestTicks.js";
|
|
3
4
|
export { backtestPortfolio } from "./engine/portfolio.js";
|
|
5
|
+
export { LlmSignal } from "./engine/llmSignal.js";
|
|
4
6
|
export { walkForwardOptimize } from "./engine/walkForward.js";
|
|
7
|
+
export { optimize } from "./engine/optimize.js";
|
|
8
|
+
export { grid } from "./engine/grid.js";
|
|
9
|
+
export { listStrategies, getStrategy, registerStrategy } from "./strategies/index.js";
|
|
10
|
+
export * as research from "./research/index.js";
|
|
5
11
|
|
|
6
12
|
export { buildMetrics } from "./metrics/buildMetrics.js";
|
|
13
|
+
export { benchmarkStats } from "./metrics/benchmark.js";
|
|
14
|
+
export { clampFinite, BIG_NUMBER } from "./metrics/finite.js";
|
|
15
|
+
export { periodsPerYear } from "./metrics/annualize.js";
|
|
7
16
|
export {
|
|
8
17
|
backtestHistorical,
|
|
9
18
|
cachedCandlesPath,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const FALLBACK_HTML = `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8" />
|
|
10
|
+
<title>tradelab live</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<h1>tradelab live</h1>
|
|
14
|
+
<pre id="state"></pre>
|
|
15
|
+
<script>
|
|
16
|
+
fetch("/state")
|
|
17
|
+
.then((res) => res.json())
|
|
18
|
+
.then((state) => {
|
|
19
|
+
document.getElementById("state").textContent = JSON.stringify(state, null, 2);
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>`;
|
|
24
|
+
|
|
25
|
+
function readDashboardHtml() {
|
|
26
|
+
if (import.meta.url) {
|
|
27
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const htmlPath = path.join(here, "..", "..", "..", "templates", "dashboard.html");
|
|
29
|
+
return readFileSync(htmlPath, "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(path.join(process.cwd(), "templates", "dashboard.html"), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
return FALLBACK_HTML;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Local realtime dashboard for a LiveEngine or LiveOrchestrator.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {{ eventBus: import("../events.js").EventBus, getStatus: Function }} opts.source
|
|
44
|
+
* @param {number} [opts.port=4317] 0 picks an ephemeral port for tests
|
|
45
|
+
* @param {number} [opts.maxBuffer=200] recent events replayed to new clients
|
|
46
|
+
* @returns {{ start: () => Promise<string>, close: () => Promise<void>, server: http.Server }}
|
|
47
|
+
*/
|
|
48
|
+
export function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
49
|
+
if (!source?.eventBus || typeof source.eventBus.onAny !== "function") {
|
|
50
|
+
throw new Error("dashboard source must expose an eventBus with onAny()");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const recent = [];
|
|
54
|
+
const clients = new Set();
|
|
55
|
+
|
|
56
|
+
const unsubscribe = source.eventBus.onAny(({ event, payload }) => {
|
|
57
|
+
const msg = { event, payload, t: Date.now() };
|
|
58
|
+
recent.push(msg);
|
|
59
|
+
if (recent.length > maxBuffer) recent.shift();
|
|
60
|
+
const frame = `data: ${JSON.stringify(msg)}\n\n`;
|
|
61
|
+
for (const res of clients) res.write(frame);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const server = http.createServer((req, res) => {
|
|
65
|
+
const url = (req.url || "/").split("?")[0];
|
|
66
|
+
|
|
67
|
+
if (url === "/") {
|
|
68
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
69
|
+
res.end(readDashboardHtml());
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (url === "/state") {
|
|
74
|
+
const status = typeof source.getStatus === "function" ? source.getStatus() : {};
|
|
75
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
76
|
+
res.end(JSON.stringify(status));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url === "/events") {
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
"Content-Type": "text/event-stream",
|
|
83
|
+
"Cache-Control": "no-cache",
|
|
84
|
+
Connection: "keep-alive",
|
|
85
|
+
});
|
|
86
|
+
res.flushHeaders();
|
|
87
|
+
for (const msg of recent) res.write(`data: ${JSON.stringify(msg)}\n\n`);
|
|
88
|
+
clients.add(res);
|
|
89
|
+
req.on("close", () => clients.delete(res));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
94
|
+
res.end("not found");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
start() {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
server.listen(port, () => {
|
|
101
|
+
const address = server.address();
|
|
102
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
103
|
+
resolve(`http://localhost:${actualPort}`);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
unsubscribe();
|
|
109
|
+
for (const res of clients) res.end();
|
|
110
|
+
clients.clear();
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
server.close((error) => {
|
|
113
|
+
if (error) reject(error);
|
|
114
|
+
else resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
server,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -2,7 +2,7 @@ import { calculatePositionSize } from "../../utils/positionSizing.js";
|
|
|
2
2
|
import { normalizeCandles } from "../../data/csv.js";
|
|
3
3
|
import { isEODBar, ocoExitCheck } from "../../engine/execution.js";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
callSignalWithContextAsync,
|
|
6
6
|
normalizeSignal,
|
|
7
7
|
snapshotOpenPosition,
|
|
8
8
|
} from "../../engine/barSystemRunner.js";
|
|
@@ -513,7 +513,7 @@ export class LiveEngine {
|
|
|
513
513
|
|
|
514
514
|
if (!this.openPosition && !this.pendingOrder) {
|
|
515
515
|
const context = this._signalContext(bar);
|
|
516
|
-
const rawSignal =
|
|
516
|
+
const rawSignal = await callSignalWithContextAsync({
|
|
517
517
|
signal: this.options.signal,
|
|
518
518
|
context,
|
|
519
519
|
index: context.index,
|
package/src/live/index.js
CHANGED
|
@@ -25,3 +25,4 @@ export { PaperEngine, createPaperEngine } from "./engine/paperEngine.js";
|
|
|
25
25
|
export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
|
|
26
26
|
|
|
27
27
|
export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
|
|
28
|
+
export { createDashboardServer } from "./dashboard/server.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const candle = z.object({
|
|
4
|
+
time: z.number(),
|
|
5
|
+
open: z.number().optional(),
|
|
6
|
+
high: z.number(),
|
|
7
|
+
low: z.number(),
|
|
8
|
+
close: z.number(),
|
|
9
|
+
volume: z.number().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const dataSpec = z
|
|
13
|
+
.object({
|
|
14
|
+
source: z.enum(["yahoo", "csv", "auto"]).optional(),
|
|
15
|
+
symbol: z.string().optional(),
|
|
16
|
+
interval: z.string().optional(),
|
|
17
|
+
period: z.string().optional(),
|
|
18
|
+
csvPath: z.string().optional(),
|
|
19
|
+
cache: z.boolean().optional(),
|
|
20
|
+
})
|
|
21
|
+
.passthrough();
|
|
22
|
+
|
|
23
|
+
export const schemas = {
|
|
24
|
+
list_strategies: {},
|
|
25
|
+
fetch_candles: dataSpec.shape,
|
|
26
|
+
run_backtest: {
|
|
27
|
+
candles: z.array(candle).optional(),
|
|
28
|
+
data: dataSpec.optional(),
|
|
29
|
+
symbol: z.string().optional(),
|
|
30
|
+
interval: z.string().optional(),
|
|
31
|
+
strategy: z.string(),
|
|
32
|
+
params: z.record(z.string(), z.any()).optional(),
|
|
33
|
+
backtestOptions: z.record(z.string(), z.any()).optional(),
|
|
34
|
+
},
|
|
35
|
+
walk_forward: {
|
|
36
|
+
candles: z.array(candle).optional(),
|
|
37
|
+
data: dataSpec.optional(),
|
|
38
|
+
interval: z.string().optional(),
|
|
39
|
+
strategy: z.string(),
|
|
40
|
+
trainBars: z.number(),
|
|
41
|
+
testBars: z.number(),
|
|
42
|
+
stepBars: z.number().optional(),
|
|
43
|
+
mode: z.enum(["rolling", "anchored"]).optional(),
|
|
44
|
+
scoreBy: z.string().optional(),
|
|
45
|
+
grid: z.record(z.string(), z.array(z.any())).optional(),
|
|
46
|
+
backtestOptions: z.record(z.string(), z.any()).optional(),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { mcpTools } from "./tools.js";
|
|
4
|
+
import { schemas } from "./schemas.js";
|
|
5
|
+
|
|
6
|
+
/** Build (but do not start) an McpServer with all tradelab tools registered. */
|
|
7
|
+
export function createServer() {
|
|
8
|
+
const server = new McpServer({ name: "tradelab", version: "1.1.0" });
|
|
9
|
+
|
|
10
|
+
for (const [name, def] of Object.entries(mcpTools)) {
|
|
11
|
+
server.tool(name, def.description, schemas[name] ?? {}, async (args) => {
|
|
12
|
+
try {
|
|
13
|
+
const result = await def.handler(args ?? {});
|
|
14
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${message}` }] };
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return server;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Start the server on stdio. Called by bin/tradelab-mcp.js. */
|
|
26
|
+
export async function startStdioServer() {
|
|
27
|
+
const server = createServer();
|
|
28
|
+
const transport = new StdioServerTransport();
|
|
29
|
+
await server.connect(transport);
|
|
30
|
+
return server;
|
|
31
|
+
}
|
package/src/mcp/tools.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { backtest } from "../engine/backtest.js";
|
|
2
|
+
import { walkForwardOptimize } from "../engine/walkForward.js";
|
|
3
|
+
import { getHistoricalCandles } from "../data/index.js";
|
|
4
|
+
import { getStrategy, listStrategies } from "../strategies/index.js";
|
|
5
|
+
|
|
6
|
+
function summarizeMetrics(metrics) {
|
|
7
|
+
const {
|
|
8
|
+
trades,
|
|
9
|
+
winRate,
|
|
10
|
+
profitFactor,
|
|
11
|
+
expectancy,
|
|
12
|
+
totalR,
|
|
13
|
+
avgR,
|
|
14
|
+
sharpe,
|
|
15
|
+
sharpeAnnualized,
|
|
16
|
+
sortinoAnnualized,
|
|
17
|
+
maxDrawdown,
|
|
18
|
+
calmar,
|
|
19
|
+
returnPct,
|
|
20
|
+
totalPnL,
|
|
21
|
+
finalEquity,
|
|
22
|
+
exposurePct,
|
|
23
|
+
sideBreakdown,
|
|
24
|
+
} = metrics;
|
|
25
|
+
return {
|
|
26
|
+
trades,
|
|
27
|
+
winRate,
|
|
28
|
+
profitFactor,
|
|
29
|
+
expectancy,
|
|
30
|
+
totalR,
|
|
31
|
+
avgR,
|
|
32
|
+
sharpe,
|
|
33
|
+
sharpeAnnualized,
|
|
34
|
+
sortinoAnnualized,
|
|
35
|
+
maxDrawdown,
|
|
36
|
+
calmar,
|
|
37
|
+
returnPct,
|
|
38
|
+
totalPnL,
|
|
39
|
+
finalEquity,
|
|
40
|
+
exposurePct,
|
|
41
|
+
sideBreakdown,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveCandles(args) {
|
|
46
|
+
if (Array.isArray(args.candles) && args.candles.length) return args.candles;
|
|
47
|
+
if (args.data) return getHistoricalCandles(args.data);
|
|
48
|
+
throw new Error("Provide either `candles` (array) or `data` (getHistoricalCandles spec).");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expandGrid(grid) {
|
|
52
|
+
const keys = Object.keys(grid || {});
|
|
53
|
+
if (!keys.length) return [{}];
|
|
54
|
+
return keys.reduce(
|
|
55
|
+
(acc, key) => acc.flatMap((base) => grid[key].map((v) => ({ ...base, [key]: v }))),
|
|
56
|
+
[{}]
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const mcpTools = {
|
|
61
|
+
list_strategies: {
|
|
62
|
+
description: "List built-in trading strategies with their tunable parameters.",
|
|
63
|
+
handler: async () => ({ strategies: listStrategies() }),
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
fetch_candles: {
|
|
67
|
+
description: "Download/caches OHLCV candles from Yahoo or CSV. Returns a compact summary.",
|
|
68
|
+
handler: async (args) => {
|
|
69
|
+
const candles = await getHistoricalCandles(args);
|
|
70
|
+
return {
|
|
71
|
+
count: candles.length,
|
|
72
|
+
first: candles[0] ?? null,
|
|
73
|
+
last: candles[candles.length - 1] ?? null,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
run_backtest: {
|
|
79
|
+
description:
|
|
80
|
+
"Run a single backtest using a named strategy + params. Returns a metrics summary and a small trade preview (no replay).",
|
|
81
|
+
handler: async (args) => {
|
|
82
|
+
const candles = await resolveCandles(args);
|
|
83
|
+
const factory = getStrategy(args.strategy);
|
|
84
|
+
const signal = factory(args.params || {});
|
|
85
|
+
const result = backtest({
|
|
86
|
+
candles,
|
|
87
|
+
symbol: args.symbol ?? "UNKNOWN",
|
|
88
|
+
interval: args.interval,
|
|
89
|
+
signal,
|
|
90
|
+
collectReplay: false,
|
|
91
|
+
...(args.backtestOptions || {}),
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
symbol: result.symbol,
|
|
95
|
+
interval: result.interval,
|
|
96
|
+
metrics: summarizeMetrics(result.metrics),
|
|
97
|
+
tradesPreview: result.positions.slice(0, 10).map((p) => ({
|
|
98
|
+
side: p.side,
|
|
99
|
+
entry: p.entryFill ?? p.entry,
|
|
100
|
+
exit: p.exit.price,
|
|
101
|
+
pnl: p.exit.pnl,
|
|
102
|
+
reason: p.exit.reason,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
walk_forward: {
|
|
109
|
+
description:
|
|
110
|
+
"Walk-forward optimize a named strategy over a parameter grid. Returns out-of-sample metrics and winner stability.",
|
|
111
|
+
handler: async (args) => {
|
|
112
|
+
const candles = await resolveCandles(args);
|
|
113
|
+
const factory = getStrategy(args.strategy);
|
|
114
|
+
const wf = walkForwardOptimize({
|
|
115
|
+
candles,
|
|
116
|
+
mode: args.mode ?? "rolling",
|
|
117
|
+
trainBars: args.trainBars,
|
|
118
|
+
testBars: args.testBars,
|
|
119
|
+
stepBars: args.stepBars ?? args.testBars,
|
|
120
|
+
scoreBy: args.scoreBy ?? "profitFactor",
|
|
121
|
+
parameterSets: expandGrid(args.grid),
|
|
122
|
+
signalFactory: (params) => factory(params),
|
|
123
|
+
backtestOptions: {
|
|
124
|
+
interval: args.interval,
|
|
125
|
+
collectReplay: false,
|
|
126
|
+
...(args.backtestOptions || {}),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
windows: wf.windows.length,
|
|
131
|
+
metrics: summarizeMetrics(wf.metrics),
|
|
132
|
+
stability: wf.bestParamsSummary,
|
|
133
|
+
windowSummaries: wf.windows.map((w) => ({
|
|
134
|
+
bestParams: w.bestParams,
|
|
135
|
+
oosTrades: w.oosTrades,
|
|
136
|
+
profitable: w.profitable,
|
|
137
|
+
stabilityScore: w.stabilityScore,
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/metrics/annualize.js
|
|
2
|
+
|
|
3
|
+
const TRADING_DAYS = 252;
|
|
4
|
+
const RTH_HOURS = 6.5; // US regular trading hours per day
|
|
5
|
+
const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
// Known intra/inter-day intervals => periods per trading year.
|
|
8
|
+
const INTERVAL_PERIODS = {
|
|
9
|
+
"1m": TRADING_DAYS * RTH_HOURS * 60,
|
|
10
|
+
"2m": TRADING_DAYS * RTH_HOURS * 30,
|
|
11
|
+
"5m": TRADING_DAYS * RTH_HOURS * 12,
|
|
12
|
+
"15m": TRADING_DAYS * RTH_HOURS * 4,
|
|
13
|
+
"30m": TRADING_DAYS * RTH_HOURS * 2,
|
|
14
|
+
"1h": TRADING_DAYS * RTH_HOURS,
|
|
15
|
+
"60m": TRADING_DAYS * RTH_HOURS,
|
|
16
|
+
"1d": TRADING_DAYS,
|
|
17
|
+
"1wk": 52,
|
|
18
|
+
"1mo": 12,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Number of bars in one year for the given interval. Used to annualize
|
|
23
|
+
* per-bar Sharpe/Sortino. Falls back to estBarMs (assuming a 24/7 clock)
|
|
24
|
+
* when the interval string is unknown, then to 252.
|
|
25
|
+
*/
|
|
26
|
+
export function periodsPerYear(interval, estBarMs) {
|
|
27
|
+
if (interval && INTERVAL_PERIODS[interval]) return INTERVAL_PERIODS[interval];
|
|
28
|
+
if (Number.isFinite(estBarMs) && estBarMs > 0) {
|
|
29
|
+
return Math.round(MS_PER_YEAR / estBarMs);
|
|
30
|
+
}
|
|
31
|
+
return TRADING_DAYS;
|
|
32
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/metrics/benchmark.js
|
|
2
|
+
|
|
3
|
+
function mean(xs) {
|
|
4
|
+
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ordinary least squares of strategy returns on benchmark returns.
|
|
9
|
+
* Returns { alpha, beta, correlation, informationRatio, trackingError }.
|
|
10
|
+
* `alpha` is per-period excess return (intercept). All null when inputs are
|
|
11
|
+
* empty or length-mismatched.
|
|
12
|
+
*/
|
|
13
|
+
export function benchmarkStats(strategyReturns, benchmarkReturns) {
|
|
14
|
+
const nullStats = {
|
|
15
|
+
alpha: null,
|
|
16
|
+
beta: null,
|
|
17
|
+
correlation: null,
|
|
18
|
+
informationRatio: null,
|
|
19
|
+
trackingError: null,
|
|
20
|
+
};
|
|
21
|
+
if (
|
|
22
|
+
!Array.isArray(strategyReturns) ||
|
|
23
|
+
!Array.isArray(benchmarkReturns) ||
|
|
24
|
+
strategyReturns.length === 0 ||
|
|
25
|
+
strategyReturns.length !== benchmarkReturns.length
|
|
26
|
+
) {
|
|
27
|
+
return nullStats;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const meanStrat = mean(strategyReturns);
|
|
31
|
+
const meanBench = mean(benchmarkReturns);
|
|
32
|
+
|
|
33
|
+
let covar = 0;
|
|
34
|
+
let varBench = 0;
|
|
35
|
+
let varStrat = 0;
|
|
36
|
+
for (let i = 0; i < strategyReturns.length; i += 1) {
|
|
37
|
+
const ds = strategyReturns[i] - meanStrat;
|
|
38
|
+
const db = benchmarkReturns[i] - meanBench;
|
|
39
|
+
covar += ds * db;
|
|
40
|
+
varBench += db * db;
|
|
41
|
+
varStrat += ds * ds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const beta = varBench === 0 ? 0 : covar / varBench;
|
|
45
|
+
const alpha = meanStrat - beta * meanBench;
|
|
46
|
+
const denom = Math.sqrt(varStrat * varBench);
|
|
47
|
+
const correlation = denom === 0 ? 0 : covar / denom;
|
|
48
|
+
|
|
49
|
+
const active = strategyReturns.map((r, i) => r - benchmarkReturns[i]);
|
|
50
|
+
const meanActive = mean(active);
|
|
51
|
+
const trackingError = Math.sqrt(mean(active.map((a) => (a - meanActive) ** 2)));
|
|
52
|
+
const informationRatio = trackingError === 0 ? 0 : meanActive / trackingError;
|
|
53
|
+
|
|
54
|
+
return { alpha, beta, correlation, informationRatio, trackingError };
|
|
55
|
+
}
|