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
package/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0] - 2026-06-26
9
+
10
+ ### Added
11
+
12
+ - **Metrics correctness and annualization**
13
+ - `sharpeAnnualized` and `sortinoAnnualized` fields in `buildMetrics` output, scaled by `sqrt(periodsPerYear)`.
14
+ - `annualizationPeriods` field exposing the computed periods-per-year used for scaling.
15
+ - All ratio metrics (`profitFactor`, `sharpe`, `calmar`, etc.) now clamped to a finite sentinel via `clampFinite`; metrics JSON never emits `Infinity` or `NaN`.
16
+ - Benchmark statistics (`alpha`, `beta`, `correlation`, `informationRatio`, `trackingError`) available in `metrics.benchmark` when `benchmarkReturns` is passed to `buildMetrics`.
17
+ - New top-level exports: `clampFinite`, `BIG_NUMBER`, `periodsPerYear`, `benchmarkStats`.
18
+
19
+ - **`tradelab/ta` indicator namespace** (new subpath export)
20
+ - Oscillators: `rsi`, `macd`, `stochastic`.
21
+ - Channels: `bollinger`, `donchian`, `keltner`.
22
+ - Trend: `supertrend`, `vwap`.
23
+ - Re-exports from core: `ema`, `atr`, `swingHigh`, `swingLow`, `detectFVG`, `lastSwing`, `structureState`.
24
+ - All indicators return full-length arrays aligned to input (warmup positions are `undefined`).
25
+
26
+ - **Async and agent signals**
27
+ - `backtestAsync(options)` — async sibling of `backtest()` where `signal()` may return a `Promise`; accepts `signalBudgetMs` to race each bar's signal against a timeout.
28
+ - `LlmSignal` class — wraps an async `resolve(context)` function with per-bar caching, a configurable `budgetMs` timeout, a no-lookahead candle proxy, and a `log` array of every bar decision.
29
+ - `backtestTicks()` now accepts a `seed` option for reproducible probabilistic limit fills.
30
+ - `LiveEngine` awaits async signals, so `LlmSignal` can be used directly in live/paper execution.
31
+
32
+ - **`tradelab/mcp` MCP server** (new subpath export)
33
+ - `tradelab-mcp` binary — starts an MCP stdio server exposing tradelab tools to any MCP-capable agent (Claude Desktop, Cursor, etc.).
34
+ - Server exposes tools: `list_strategies`, `fetch_candles`, `run_backtest`, `walk_forward`.
35
+ - Name-addressable strategy registry: `listStrategies()`, `getStrategy(name)`, `registerStrategy(name, def)` (exported from `tradelab`).
36
+
37
+ - **Research and overfitting toolkit** (`research` namespace, re-exported from `tradelab`)
38
+ - `research.monteCarlo(returns, options)` — equity curve Monte Carlo simulation.
39
+ - `research.deflatedSharpe(sharpe, options)` — deflated Sharpe ratio (DSR) adjustment.
40
+ - `research.sweepHaircut(results, options)` — apply DSR haircut across a parameter sweep.
41
+ - `research.probabilityOfBacktestOverfitting(folds, options)` — CSCV/PBO overfitting probability.
42
+ - `research.combinatorialPurgedSplits(candles, options)` — combinatorial purged cross-validation (CPCV) split generator.
43
+
44
+ - **Parallel parameter optimization**
45
+ - `optimize(options)` — worker-thread pool that runs a parameter sweep in parallel; accepts `candles`, `signalModulePath`, `parameterSets`, `concurrency`, and `scoreBy`; returns `{ results, leaderboard, best }`.
46
+ - `grid(spec)` — helper that expands a `{ param: [v1, v2] }` spec into an array of parameter-set objects.
47
+
48
+ - **Carry and funding cost model**
49
+ - `costs.carry` — annualized borrow/margin cost (`longAnnualBps`, `shortAnnualBps`); deducted proportionally over hold time.
50
+ - `costs.funding` — perpetual futures funding (`rateBps`, `intervalMs`, `anchorMs`); positive rates charge longs and credit shorts.
51
+ - Closed positions include `exit.financing` (total financing cost already deducted from `exit.pnl`).
52
+
53
+ - **Live dashboard**
54
+ - `createDashboardServer(options)` exported from `tradelab/live` — zero-dependency SSE server using Node `node:http`.
55
+ - `--dashboard` and `--dashboardPort` CLI flags for `tradelab paper` and `tradelab live`.
56
+
57
+ - **Dependencies**: `@modelcontextprotocol/sdk` and `zod` added as runtime dependencies.
58
+
59
+ ### Fixed
60
+
61
+ - Metrics JSON no longer emits `Infinity` or `NaN`; all ratio metrics are clamped to `BIG_NUMBER` (1e9) or zero.
62
+ - Non-annualized Sharpe was not directly comparable across different timeframes; `sharpeAnnualized` provides a consistent cross-timeframe measure.
63
+
64
+ ## [1.0.1]
65
+
66
+ Prior release. See git history for details.
package/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
 
18
18
  Use it for backtests, portfolio and walk-forward validation, and live or paper execution through broker adapters while keeping the same `signal()` contract.
19
19
 
20
+ AI-agent users: see [docs/mcp.md](docs/mcp.md) to run the research loop via MCP.
21
+
20
22
  ```bash
21
23
  npm install tradelab
22
24
  ```
@@ -29,6 +31,7 @@ npm install tradelab
29
31
  - [Quick start](#quick-start)
30
32
  - [Loading historical data](#loading-historical-data)
31
33
  - [Core concepts](#core-concepts)
34
+ - [AI agents / MCP](#ai-agents--mcp)
32
35
  - [Portfolio mode](#portfolio-mode)
33
36
  - [Walk-forward optimization](#walk-forward-optimization)
34
37
  - [Tick backtests](#tick-backtests)
@@ -43,16 +46,21 @@ npm install tradelab
43
46
 
44
47
  ## What it includes
45
48
 
46
- | Area | What you get |
47
- | ------------------ | ---------------------------------------------------------------------------------------- |
48
- | **Engine** | Candle and tick backtests with position sizing, exits, replay capture, and cost models |
49
- | **Portfolio** | Multi-system shared-capital simulation with live capital locking and daily loss halts |
50
- | **Walk-forward** | Rolling and anchored train/test validation with parameter search and stability summaries |
51
- | **Live execution** | Live and paper engines with broker adapters, state persistence, and orchestration |
52
- | **Data** | Yahoo Finance downloads, CSV import, and local cache helpers |
53
- | **Costs** | Slippage, spread, and commission modeling |
54
- | **Exports** | HTML reports, metrics JSON, and trade CSV |
55
- | **Dev experience** | TypeScript definitions, ESM/CJS support, CLI for quick runs |
49
+ | Area | What you get |
50
+ | -------------------------- | -------------------------------------------------------------------------------------------------- |
51
+ | **Engine** | Candle and tick backtests with position sizing, exits, replay capture, and cost models |
52
+ | **Async / AI signals** | Promise-returning signals, `LlmSignal` caching/budgets, and live async signal support |
53
+ | **Indicators (`ta`)** | RSI, MACD, stochastic, Bollinger, Donchian, Keltner, Supertrend, VWAP, EMA, ATR, swing/FVG helpers |
54
+ | **Optimization** | `optimize()` worker-pool parameter sweep and `grid()` spec helper |
55
+ | **Portfolio** | Multi-system shared-capital simulation with live capital locking and daily loss halts |
56
+ | **Walk-forward** | Rolling and anchored train/test validation with parameter search and stability summaries |
57
+ | **Research / overfitting** | Monte Carlo, deflated Sharpe, sweep haircut, PBO (CSCV), and CPCV combinatorial purging |
58
+ | **AI / MCP server** | `tradelab-mcp` stdio server — run the research loop from Claude Desktop, Cursor, or any MCP agent |
59
+ | **Live execution** | Live and paper engines with broker adapters, state persistence, orchestration, and SSE dashboard |
60
+ | **Data** | Yahoo Finance downloads, CSV import, and local cache helpers |
61
+ | **Costs** | Slippage, spread, commission, annualized carry/borrow, and perpetual futures funding |
62
+ | **Exports** | HTML reports, metrics JSON, and trade CSV |
63
+ | **Dev experience** | TypeScript definitions, ESM/CJS support, CLI for quick runs |
56
64
 
57
65
  ---
58
66
 
@@ -98,6 +106,14 @@ After the run, check `result.metrics` for the headline numbers and `result.posit
98
106
 
99
107
  ---
100
108
 
109
+ ## AI agents / MCP
110
+
111
+ `tradelab-mcp` lets MCP-capable agents run the research loop through tools: list strategies, fetch candles, run backtests, and walk-forward validate parameter grids.
112
+
113
+ See [docs/mcp.md](docs/mcp.md) for setup and the Claude Desktop config.
114
+
115
+ ---
116
+
101
117
  ## Loading historical data
102
118
 
103
119
  Most users can start with `getHistoricalCandles()`. It abstracts over Yahoo Finance and CSV, handles caching, and normalizes the output so it feeds straight into `backtest()`.
@@ -254,12 +270,35 @@ import { backtestTicks } from "tradelab";
254
270
  const result = backtestTicks({
255
271
  ticks,
256
272
  queueFillProbability: 0.35,
273
+ seed: "research-run-1",
257
274
  signal,
258
275
  });
259
276
  ```
260
277
 
261
278
  Market entries fill on the next tick, limit orders can fill at the touch with configurable queue probability, and stop exits use the existing cost model with stop-specific slippage if you provide it in `costs.slippageByKind.stop`.
262
279
 
280
+ Use `seed` to make probabilistic limit fills reproducible across repeated runs with the same data and options.
281
+
282
+ ## Async and LLM signals
283
+
284
+ Use `backtestAsync()` when your signal returns a promise. `LlmSignal` wraps async model or agent calls with a per-bar budget, one-decision-per-bar cache, no-lookahead candle view, and a decision log.
285
+
286
+ ```js
287
+ import { backtestAsync, LlmSignal } from "tradelab";
288
+
289
+ const llm = new LlmSignal({
290
+ budgetMs: 2000,
291
+ async resolve({ candles, bar }) {
292
+ const closes = candles.map((c) => c.close);
293
+ return closes.at(-1) > closes.at(-5) ? { side: "long", stop: bar.close * 0.98, rr: 2 } : null;
294
+ },
295
+ });
296
+
297
+ const result = await backtestAsync({ candles, signal: llm.signal, signalBudgetMs: 3000 });
298
+ ```
299
+
300
+ `LiveEngine` awaits async signals too, so the same `llm.signal` can move from research into paper or live execution.
301
+
263
302
  ---
264
303
 
265
304
  ## Live trading
@@ -314,6 +353,15 @@ const result = backtest({
314
353
  commissionPerUnit: 0,
315
354
  commissionPerOrder: 1,
316
355
  minCommission: 1,
356
+ carry: {
357
+ longAnnualBps: 500,
358
+ shortAnnualBps: 800,
359
+ },
360
+ funding: {
361
+ rateBps: 10,
362
+ intervalMs: 8 * 60 * 60 * 1000,
363
+ anchorMs: 0,
364
+ },
317
365
  },
318
366
  });
319
367
  ```
@@ -322,6 +370,9 @@ const result = backtest({
322
370
  - Spread is modeled as half-spread paid on entry and exit
323
371
  - Commission can be percentage-based, per-unit, per-order, or mixed
324
372
  - `minCommission` floors the fee per fill
373
+ - `carry` models annualized overnight financing or borrow costs
374
+ - `funding` models per-interval perpetual futures funding; positive rates charge longs and credit shorts
375
+ - Closed trades include `exit.financing`, already deducted from `exit.pnl`
325
376
 
326
377
  > Leaving costs at zero is the most common cause of inflated backtests. Set them from the start.
327
378
 
@@ -396,6 +447,7 @@ You can also point `--strategy` at a local module that exports `default(args)`,
396
447
  ```bash
397
448
  node examples/emaCross.js
398
449
  node examples/yahooEmaCross.js SPY 1d 1y
450
+ node examples/llmSignal.js
399
451
  ```
400
452
 
401
453
  The examples are a good place to start if you want something runnable before wiring the package into your own strategy code.
@@ -408,18 +460,27 @@ The examples are a good place to start if you want something runnable before wir
408
460
 
409
461
  ```js
410
462
  import { backtest, getHistoricalCandles, ema } from "tradelab";
463
+ import { backtestAsync, LlmSignal, optimize, grid } from "tradelab";
464
+ import { research } from "tradelab"; // monteCarlo, deflatedSharpe, probabilityOfBacktestOverfitting, ...
411
465
  import { fetchHistorical } from "tradelab/data";
412
- import { LiveEngine, PaperEngine } from "tradelab/live";
466
+ import { LiveEngine, PaperEngine, createDashboardServer } from "tradelab/live";
467
+ import { rsi, macd, bollinger, vwap, supertrend } from "tradelab/ta";
468
+ import { createServer, startStdioServer } from "tradelab/mcp";
413
469
  ```
414
470
 
415
471
  ### CommonJS
416
472
 
417
473
  ```js
418
474
  const { backtest, getHistoricalCandles, ema } = require("tradelab");
475
+ const { backtestAsync, LlmSignal, optimize, grid } = require("tradelab");
476
+ const { research } = require("tradelab");
419
477
  const { fetchHistorical } = require("tradelab/data");
420
- const { LiveEngine, PaperEngine } = require("tradelab/live");
478
+ const { LiveEngine, PaperEngine, createDashboardServer } = require("tradelab/live");
479
+ const { rsi, macd, bollinger, vwap, supertrend } = require("tradelab/ta");
421
480
  ```
422
481
 
482
+ > `tradelab/mcp` is ESM-only (the MCP SDK is ESM-only). Use the `tradelab-mcp` binary or import it from an ESM context.
483
+
423
484
  ---
424
485
 
425
486
  ## Documentation
@@ -429,6 +490,8 @@ const { LiveEngine, PaperEngine } = require("tradelab/live");
429
490
  | [Backtest engine](docs/backtest-engine.md) | Signal contract, all options, result shape, portfolio mode, walk-forward |
430
491
  | [Data, reporting, and CLI](docs/data-reporting-cli.md) | Data loading, cache behavior, exports, CLI reference |
431
492
  | [Live trading](docs/live-trading.md) | Live engine, broker adapters, paper mode, orchestration, and state persistence |
493
+ | [MCP server](docs/mcp.md) | Run the research loop from any MCP-capable agent |
494
+ | [Research & overfitting](docs/research.md) | Monte Carlo, deflated Sharpe, PBO, CPCV, sweep haircut |
432
495
  | [Strategy examples](docs/examples.md) | Mean reversion, breakout, sentiment, LLM, and portfolio strategy patterns |
433
496
  | [API reference](docs/api-reference.md) | Compact index of every public export |
434
497
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { startStdioServer } from "../src/mcp/server.js";
3
+
4
+ startStdioServer().catch((error) => {
5
+ console.error("tradelab-mcp failed to start:", error);
6
+ process.exit(1);
7
+ });
package/bin/tradelab.js CHANGED
@@ -116,6 +116,22 @@ function createBrokerAdapter(args, overrides = {}) {
116
116
  throw new Error(`Unsupported broker "${brokerName}"`);
117
117
  }
118
118
 
119
+ async function maybeStartDashboard(args, source) {
120
+ if (!toBoolean(args.dashboard, false)) return null;
121
+ const { createDashboardServer } = await import("../src/live/dashboard/server.js");
122
+ const dashboard = createDashboardServer({
123
+ source,
124
+ port: toNumber(args.dashboardPort, 4317),
125
+ });
126
+ const url = await dashboard.start();
127
+ console.log(`dashboard: ${url}`);
128
+ return dashboard;
129
+ }
130
+
131
+ async function closeDashboard(dashboard) {
132
+ if (dashboard) await dashboard.close();
133
+ }
134
+
119
135
  function brokerConfigFromArgs(args, overrides = {}) {
120
136
  return {
121
137
  apiKey: overrides.apiKey ?? args.apiKey,
@@ -514,6 +530,7 @@ async function commandLive(args, overrides = {}) {
514
530
  maxDailyLossPct: toNumber(fileConfig.maxDailyLossPct ?? args.maxDailyLossPct, 0),
515
531
  equity: toNumber(fileConfig.equity ?? args.equity, 10_000),
516
532
  });
533
+ const dashboard = await maybeStartDashboard(args, orchestrator);
517
534
  await broker.connect(brokerConfig);
518
535
  await orchestrator.start();
519
536
 
@@ -526,6 +543,15 @@ async function commandLive(args, overrides = {}) {
526
543
 
527
544
  if (!watch) {
528
545
  await orchestrator.stop();
546
+ await closeDashboard(dashboard);
547
+ } else if (dashboard) {
548
+ const shutdown = async () => {
549
+ await orchestrator.stop();
550
+ await closeDashboard(dashboard);
551
+ process.exit(0);
552
+ };
553
+ process.once("SIGINT", shutdown);
554
+ process.once("SIGTERM", shutdown);
529
555
  }
530
556
  return;
531
557
  }
@@ -551,6 +577,7 @@ async function commandLive(args, overrides = {}) {
551
577
  storage,
552
578
  brokerConfig,
553
579
  });
580
+ const dashboard = await maybeStartDashboard(args, engine);
554
581
 
555
582
  await engine.start();
556
583
 
@@ -563,11 +590,13 @@ async function commandLive(args, overrides = {}) {
563
590
 
564
591
  if (!watch) {
565
592
  await engine.stop();
593
+ await closeDashboard(dashboard);
566
594
  return;
567
595
  }
568
596
 
569
597
  const shutdown = async () => {
570
598
  await engine.stop();
599
+ await closeDashboard(dashboard);
571
600
  process.exit(0);
572
601
  };
573
602
  process.once("SIGINT", shutdown);
package/dist/cjs/data.cjs CHANGED
@@ -119,22 +119,93 @@ function calculatePositionSize({
119
119
  return quantity >= minQty ? quantity : 0;
120
120
  }
121
121
 
122
+ // src/metrics/finite.js
123
+ var BIG_NUMBER = 1e9;
124
+ function clampFinite(value, fallback = 0) {
125
+ if (value === Infinity) return BIG_NUMBER;
126
+ if (value === -Infinity) return -BIG_NUMBER;
127
+ if (typeof value === "number" && Number.isFinite(value)) return value;
128
+ return fallback;
129
+ }
130
+
131
+ // src/metrics/annualize.js
132
+ var TRADING_DAYS = 252;
133
+ var RTH_HOURS = 6.5;
134
+ var MS_PER_YEAR = 365 * 24 * 60 * 60 * 1e3;
135
+ var INTERVAL_PERIODS = {
136
+ "1m": TRADING_DAYS * RTH_HOURS * 60,
137
+ "2m": TRADING_DAYS * RTH_HOURS * 30,
138
+ "5m": TRADING_DAYS * RTH_HOURS * 12,
139
+ "15m": TRADING_DAYS * RTH_HOURS * 4,
140
+ "30m": TRADING_DAYS * RTH_HOURS * 2,
141
+ "1h": TRADING_DAYS * RTH_HOURS,
142
+ "60m": TRADING_DAYS * RTH_HOURS,
143
+ "1d": TRADING_DAYS,
144
+ "1wk": 52,
145
+ "1mo": 12
146
+ };
147
+ function periodsPerYear(interval, estBarMs) {
148
+ if (interval && INTERVAL_PERIODS[interval]) return INTERVAL_PERIODS[interval];
149
+ if (Number.isFinite(estBarMs) && estBarMs > 0) {
150
+ return Math.round(MS_PER_YEAR / estBarMs);
151
+ }
152
+ return TRADING_DAYS;
153
+ }
154
+
155
+ // src/metrics/benchmark.js
156
+ function mean(xs) {
157
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
158
+ }
159
+ function benchmarkStats(strategyReturns, benchmarkReturns) {
160
+ const nullStats = {
161
+ alpha: null,
162
+ beta: null,
163
+ correlation: null,
164
+ informationRatio: null,
165
+ trackingError: null
166
+ };
167
+ if (!Array.isArray(strategyReturns) || !Array.isArray(benchmarkReturns) || strategyReturns.length === 0 || strategyReturns.length !== benchmarkReturns.length) {
168
+ return nullStats;
169
+ }
170
+ const meanStrat = mean(strategyReturns);
171
+ const meanBench = mean(benchmarkReturns);
172
+ let covar = 0;
173
+ let varBench = 0;
174
+ let varStrat = 0;
175
+ for (let i = 0; i < strategyReturns.length; i += 1) {
176
+ const ds = strategyReturns[i] - meanStrat;
177
+ const db = benchmarkReturns[i] - meanBench;
178
+ covar += ds * db;
179
+ varBench += db * db;
180
+ varStrat += ds * ds;
181
+ }
182
+ const beta = varBench === 0 ? 0 : covar / varBench;
183
+ const alpha = meanStrat - beta * meanBench;
184
+ const denom = Math.sqrt(varStrat * varBench);
185
+ const correlation = denom === 0 ? 0 : covar / denom;
186
+ const active = strategyReturns.map((r, i) => r - benchmarkReturns[i]);
187
+ const meanActive = mean(active);
188
+ const trackingError = Math.sqrt(mean(active.map((a) => (a - meanActive) ** 2)));
189
+ const informationRatio = trackingError === 0 ? 0 : meanActive / trackingError;
190
+ return { alpha, beta, correlation, informationRatio, trackingError };
191
+ }
192
+
122
193
  // src/metrics/buildMetrics.js
123
194
  function sum(values) {
124
195
  return values.reduce((total, value) => total + value, 0);
125
196
  }
126
- function mean(values) {
197
+ function mean2(values) {
127
198
  return values.length ? sum(values) / values.length : 0;
128
199
  }
129
200
  function stddev(values) {
130
201
  if (values.length <= 1) return 0;
131
- const avg = mean(values);
132
- return Math.sqrt(mean(values.map((value) => (value - avg) ** 2)));
202
+ const avg = mean2(values);
203
+ return Math.sqrt(mean2(values.map((value) => (value - avg) ** 2)));
133
204
  }
134
205
  function sortino(values) {
135
206
  const losses = values.filter((value) => value < 0);
136
207
  const downsideDeviation = stddev(losses.length ? losses : [0]);
137
- const avg = mean(values);
208
+ const avg = mean2(values);
138
209
  return downsideDeviation === 0 ? avg > 0 ? Infinity : 0 : avg / downsideDeviation;
139
210
  }
140
211
  function dayKeyUTC(timeMs) {
@@ -220,14 +291,22 @@ function percentile(values, percentileRank) {
220
291
  const index = Math.floor((sorted.length - 1) * percentileRank);
221
292
  return sorted[index];
222
293
  }
223
- var PROFIT_FACTOR_CAP = 1e6;
224
294
  function finiteProfitFactor(grossProfit, grossLoss) {
225
295
  if (grossLoss === 0) {
226
- return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
296
+ return grossProfit > 0 ? BIG_NUMBER : 0;
227
297
  }
228
298
  return grossProfit / grossLoss;
229
299
  }
230
- function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
300
+ function buildMetrics({
301
+ closed,
302
+ equityStart,
303
+ equityFinal,
304
+ candles,
305
+ estBarMs,
306
+ eqSeries,
307
+ interval,
308
+ benchmarkReturns
309
+ }) {
231
310
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
232
311
  const completedTrades = [];
233
312
  const tradeRs = [];
@@ -297,11 +376,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
297
376
  if (pnl > 0) shortTradeWins += 1;
298
377
  }
299
378
  }
300
- const avgR = mean(tradeRs);
379
+ const avgR = mean2(tradeRs);
301
380
  const { maxWin, maxLoss } = streaks(labels);
302
- const expectancy = mean(tradePnls);
381
+ const expectancy = mean2(tradePnls);
303
382
  const tradeReturnStd = stddev(tradeReturns);
304
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
383
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean2(tradeReturns) / tradeReturnStd;
305
384
  const sortinoPerTrade = sortino(tradeReturns);
306
385
  const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
307
386
  const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
@@ -309,11 +388,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
309
388
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
310
389
  const totalBars = Math.max(1, candles.length);
311
390
  const exposurePct = openBars / totalBars;
312
- const avgHoldMin = mean(holdDurationsMinutes);
391
+ const avgHoldMin = mean2(holdDurationsMinutes);
313
392
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
314
393
  const dailyReturnsSeries = dailyReturns(equitySeries);
315
394
  const dailyStd = stddev(dailyReturnsSeries);
316
- const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
395
+ const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean2(dailyReturnsSeries) / dailyStd;
317
396
  const sortinoDaily = sortino(dailyReturnsSeries);
318
397
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
319
398
  const rDistribution = {
@@ -335,28 +414,36 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
335
414
  trades: longTradesCount,
336
415
  winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
337
416
  avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
338
- avgR: mean(longRs)
417
+ avgR: mean2(longRs)
339
418
  },
340
419
  short: {
341
420
  trades: shortTradesCount,
342
421
  winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
343
422
  avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
344
- avgR: mean(shortRs)
423
+ avgR: mean2(shortRs)
345
424
  }
346
425
  };
426
+ const periods = periodsPerYear(interval, estBarMs);
427
+ const sqrtPeriods = Math.sqrt(periods);
428
+ const sharpeAnnualized = clampFinite(clampFinite(sharpeDaily) * sqrtPeriods);
429
+ const sortinoAnnualized = clampFinite(clampFinite(sortinoDaily) * sqrtPeriods);
430
+ const benchmark = benchmarkStats(dailyReturnsSeries, benchmarkReturns ?? []);
347
431
  return {
348
432
  trades: completedTrades.length,
349
433
  winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
350
- profitFactor: profitFactorPositions,
434
+ profitFactor: clampFinite(profitFactorPositions),
351
435
  expectancy,
352
436
  totalR,
353
437
  avgR,
354
- sharpe: sharpeDaily,
355
- sharpePerTrade,
356
- sortinoPerTrade,
438
+ sharpe: clampFinite(sharpeDaily),
439
+ sharpeAnnualized,
440
+ sortinoAnnualized,
441
+ sharpePerTrade: clampFinite(sharpePerTrade),
442
+ sortinoPerTrade: clampFinite(sortinoPerTrade),
443
+ annualizationPeriods: periods,
357
444
  maxDrawdown,
358
445
  maxDrawdownPct: maxDrawdown,
359
- calmar,
446
+ calmar: clampFinite(calmar),
360
447
  maxConsecWins: maxWin,
361
448
  maxConsecLosses: maxLoss,
362
449
  avgHold: avgHoldMin,
@@ -366,12 +453,13 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
366
453
  returnPct,
367
454
  finalEquity: equityFinal,
368
455
  startEquity: equityStart,
369
- profitFactor_pos: profitFactorPositions,
370
- profitFactor_leg: profitFactorLegs,
456
+ profitFactor_pos: clampFinite(profitFactorPositions),
457
+ profitFactor_leg: clampFinite(profitFactorLegs),
371
458
  winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
372
459
  winRate_leg: legs.length ? winningLegCount / legs.length : 0,
373
- sharpeDaily,
374
- sortinoDaily,
460
+ sharpeDaily: clampFinite(sharpeDaily),
461
+ sortinoDaily: clampFinite(sortinoDaily),
462
+ benchmark,
375
463
  sideBreakdown,
376
464
  long: sideBreakdown.long,
377
465
  short: sideBreakdown.short,
@@ -380,7 +468,7 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
380
468
  daily: {
381
469
  count: dailyReturnsSeries.length,
382
470
  winRate: dailyWinRate,
383
- avgReturn: mean(dailyReturnsSeries)
471
+ avgReturn: mean2(dailyReturnsSeries)
384
472
  }
385
473
  };
386
474
  }
@@ -800,6 +888,30 @@ function dayKeyET(timeMs) {
800
888
  const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1e3 + minutesETDay * 60 * 1e3;
801
889
  return dayKeyUTC2(pseudoEtTime);
802
890
  }
891
+ var MS_PER_YEAR2 = 365 * 24 * 60 * 60 * 1e3;
892
+ function fundingEvents(fromMs, toMs, intervalMs, anchorMs = 0) {
893
+ if (!(intervalMs > 0) || toMs <= fromMs) return 0;
894
+ const firstK = Math.floor((fromMs - anchorMs) / intervalMs) + 1;
895
+ const lastK = Math.floor((toMs - anchorMs) / intervalMs);
896
+ return Math.max(0, lastK - firstK + 1);
897
+ }
898
+ function financingCost({ side, notional, fromMs, toMs, costs }) {
899
+ const model = costs || {};
900
+ const absNotional = Math.abs(notional);
901
+ let cost = 0;
902
+ if (model.carry) {
903
+ const annualBps = side === "long" ? model.carry.longAnnualBps ?? 0 : model.carry.shortAnnualBps ?? 0;
904
+ const years = Math.max(0, toMs - fromMs) / MS_PER_YEAR2;
905
+ cost += absNotional * (annualBps / 1e4) * years;
906
+ }
907
+ const funding = model.funding;
908
+ if (funding && funding.intervalMs > 0 && Number.isFinite(funding.rateBps)) {
909
+ const count = fundingEvents(fromMs, toMs, funding.intervalMs, funding.anchorMs ?? 0);
910
+ const perEvent = absNotional * (funding.rateBps / 1e4);
911
+ cost += (side === "long" ? 1 : -1) * perEvent * count;
912
+ }
913
+ return cost;
914
+ }
803
915
 
804
916
  // src/engine/backtest.js
805
917
  function asNumber(value) {
@@ -931,6 +1043,7 @@ function mergeOptions(options) {
931
1043
  maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
932
1044
  collectEqSeries: options.collectEqSeries ?? true,
933
1045
  collectReplay: options.collectReplay ?? true,
1046
+ benchmarkReturns: Array.isArray(options.benchmarkReturns) ? options.benchmarkReturns : null,
934
1047
  strict: options.strict ?? false
935
1048
  };
936
1049
  }
@@ -1053,7 +1166,14 @@ function backtest(rawOptions) {
1053
1166
  const entryFill = openPos.entryFill;
1054
1167
  const grossPnl = (exitPx - entryFill) * direction * qty;
1055
1168
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
1056
- const pnl = grossPnl - entryFeePortion - exitFeeTotal;
1169
+ const financing = financingCost({
1170
+ side: openPos.side,
1171
+ notional: entryFill * qty,
1172
+ fromMs: openPos.openTime,
1173
+ toMs: time,
1174
+ costs
1175
+ });
1176
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
1057
1177
  currentEquity += pnl;
1058
1178
  dayPnl += pnl;
1059
1179
  if (wantEqSeries) {
@@ -1081,6 +1201,7 @@ function backtest(rawOptions) {
1081
1201
  time,
1082
1202
  reason,
1083
1203
  pnl,
1204
+ financing,
1084
1205
  exitATR: openPos._lastATR ?? void 0
1085
1206
  },
1086
1207
  mfeR: openPos._mfeR ?? 0,
@@ -1483,7 +1604,9 @@ function backtest(rawOptions) {
1483
1604
  equityFinal: currentEquity,
1484
1605
  candles,
1485
1606
  estBarMs: estimatedBarMs,
1486
- eqSeries
1607
+ eqSeries,
1608
+ interval: options.interval,
1609
+ benchmarkReturns: options.benchmarkReturns
1487
1610
  });
1488
1611
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
1489
1612
  const lastPrice = asNumber(candles[candles.length - 1]?.close);