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.
Files changed (67) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +75 -12
  3. package/bin/tradelab-mcp.js +7 -0
  4. package/bin/tradelab.js +29 -0
  5. package/dist/cjs/data.cjs +149 -26
  6. package/dist/cjs/index.cjs +1893 -1003
  7. package/dist/cjs/live.cjs +134 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/api-reference.md +46 -0
  10. package/docs/backtest-engine.md +112 -0
  11. package/docs/live-trading.md +51 -0
  12. package/docs/mcp.md +64 -0
  13. package/docs/research.md +103 -0
  14. package/docs/superpowers/plans/2026-00-overview.md +101 -0
  15. package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
  16. package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
  17. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
  18. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
  19. package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
  20. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
  21. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
  22. package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
  23. package/docs/superpowers/plans/HANDOFF.md +88 -0
  24. package/examples/liveDashboard.js +33 -0
  25. package/examples/llmSignal.js +33 -0
  26. package/examples/optimize.js +25 -0
  27. package/package.json +16 -2
  28. package/src/engine/asyncSignal.js +28 -0
  29. package/src/engine/backtest.js +13 -1
  30. package/src/engine/backtestAsync.js +27 -0
  31. package/src/engine/backtestTicks.js +13 -2
  32. package/src/engine/barSystemRunner.js +96 -41
  33. package/src/engine/execution.js +39 -0
  34. package/src/engine/grid.js +15 -0
  35. package/src/engine/llmSignal.js +84 -0
  36. package/src/engine/optimize.js +86 -0
  37. package/src/engine/optimizeWorker.js +67 -0
  38. package/src/engine/walkForward.js +1 -0
  39. package/src/index.js +9 -0
  40. package/src/live/dashboard/server.js +120 -0
  41. package/src/live/engine/liveEngine.js +2 -2
  42. package/src/live/index.js +1 -0
  43. package/src/mcp/schemas.js +48 -0
  44. package/src/mcp/server.js +31 -0
  45. package/src/mcp/tools.js +142 -0
  46. package/src/metrics/annualize.js +32 -0
  47. package/src/metrics/benchmark.js +55 -0
  48. package/src/metrics/buildMetrics.js +34 -13
  49. package/src/metrics/finite.js +17 -0
  50. package/src/research/combinations.js +18 -0
  51. package/src/research/cpcv.js +47 -0
  52. package/src/research/deflatedSharpe.js +35 -0
  53. package/src/research/index.js +6 -0
  54. package/src/research/monteCarlo.js +88 -0
  55. package/src/research/pbo.js +69 -0
  56. package/src/research/stats.js +78 -0
  57. package/src/strategies/builtins.js +96 -0
  58. package/src/strategies/index.js +30 -0
  59. package/src/ta/channels.js +67 -0
  60. package/src/ta/index.js +16 -0
  61. package/src/ta/oscillators.js +70 -0
  62. package/src/ta/trend.js +78 -0
  63. package/src/utils/random.js +33 -0
  64. package/templates/dashboard.html +174 -0
  65. package/types/index.d.ts +154 -0
  66. package/types/live.d.ts +15 -0
  67. 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" });
@@ -225,6 +225,7 @@ export function walkForwardOptimize({
225
225
  candles,
226
226
  estBarMs: estimateBarMs(candles),
227
227
  eqSeries,
228
+ interval: backtestOptions.interval,
228
229
  });
229
230
  const bestParamsSummary = summarizeBestParams(windows);
230
231
 
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
- callSignalWithContext,
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 = callSignalWithContext({
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
+ }
@@ -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
+ }