tradelab 1.0.1 → 1.2.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 (66) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +188 -328
  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 +1917 -1005
  7. package/dist/cjs/live.cjs +536 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/README.md +32 -66
  10. package/docs/api-reference.md +283 -112
  11. package/docs/backtest-engine.md +210 -252
  12. package/docs/data-reporting-cli.md +114 -156
  13. package/docs/examples.md +6 -6
  14. package/docs/live-trading.md +263 -92
  15. package/docs/mcp.md +285 -0
  16. package/docs/research.md +157 -0
  17. package/examples/liveDashboard.js +33 -0
  18. package/examples/llmSignal.js +33 -0
  19. package/examples/mcpLiveTrading.js +77 -0
  20. package/examples/optimize.js +25 -0
  21. package/package.json +26 -4
  22. package/src/engine/asyncSignal.js +28 -0
  23. package/src/engine/backtest.js +13 -1
  24. package/src/engine/backtestAsync.js +27 -0
  25. package/src/engine/backtestTicks.js +13 -2
  26. package/src/engine/barSystemRunner.js +96 -41
  27. package/src/engine/execution.js +39 -0
  28. package/src/engine/grid.js +15 -0
  29. package/src/engine/llmSignal.js +84 -0
  30. package/src/engine/optimize.js +110 -0
  31. package/src/engine/optimizeWorker.js +67 -0
  32. package/src/engine/portfolio.js +4 -1
  33. package/src/engine/walkForward.js +1 -0
  34. package/src/index.js +9 -0
  35. package/src/live/dashboard/server.js +179 -0
  36. package/src/live/engine/liveEngine.js +2 -2
  37. package/src/live/engine/paperEngine.js +5 -0
  38. package/src/live/index.js +3 -0
  39. package/src/live/session.js +402 -0
  40. package/src/mcp/liveTools.js +179 -0
  41. package/src/mcp/schemas.js +167 -0
  42. package/src/mcp/server.js +35 -0
  43. package/src/mcp/tools.js +265 -0
  44. package/src/metrics/annualize.js +32 -0
  45. package/src/metrics/benchmark.js +55 -0
  46. package/src/metrics/buildMetrics.js +34 -13
  47. package/src/metrics/finite.js +17 -0
  48. package/src/research/combinations.js +18 -0
  49. package/src/research/cpcv.js +47 -0
  50. package/src/research/deflatedSharpe.js +35 -0
  51. package/src/research/index.js +6 -0
  52. package/src/research/monteCarlo.js +88 -0
  53. package/src/research/pbo.js +69 -0
  54. package/src/research/stats.js +78 -0
  55. package/src/strategies/builtins.js +96 -0
  56. package/src/strategies/index.js +30 -0
  57. package/src/ta/channels.js +67 -0
  58. package/src/ta/index.js +16 -0
  59. package/src/ta/oscillators.js +70 -0
  60. package/src/ta/trend.js +78 -0
  61. package/src/utils/random.js +33 -0
  62. package/templates/dashboard.html +661 -0
  63. package/types/index.d.ts +179 -0
  64. package/types/live.d.ts +114 -0
  65. package/types/mcp.d.ts +17 -0
  66. package/types/ta.d.ts +45 -0
@@ -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
+ }
@@ -1,3 +1,7 @@
1
+ import { clampFinite, BIG_NUMBER } from "./finite.js";
2
+ import { periodsPerYear } from "./annualize.js";
3
+ import { benchmarkStats } from "./benchmark.js";
4
+
1
5
  function sum(values) {
2
6
  return values.reduce((total, value) => total + value, 0);
3
7
  }
@@ -120,11 +124,9 @@ function percentile(values, percentileRank) {
120
124
  return sorted[index];
121
125
  }
122
126
 
123
- const PROFIT_FACTOR_CAP = 1_000_000;
124
-
125
127
  function finiteProfitFactor(grossProfit, grossLoss) {
126
128
  if (grossLoss === 0) {
127
- return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
129
+ return grossProfit > 0 ? BIG_NUMBER : 0;
128
130
  }
129
131
  return grossProfit / grossLoss;
130
132
  }
@@ -136,7 +138,16 @@ function finiteProfitFactor(grossProfit, grossLoss) {
136
138
  * `profitFactor`, `winRate`, `expectancy`, `maxDrawdown`, `sharpe`, `avgHold`,
137
139
  * and `sideBreakdown`, while preserving the more specific legacy fields.
138
140
  */
139
- export function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
141
+ export function buildMetrics({
142
+ closed,
143
+ equityStart,
144
+ equityFinal,
145
+ candles,
146
+ estBarMs,
147
+ eqSeries,
148
+ interval,
149
+ benchmarkReturns,
150
+ }) {
140
151
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
141
152
  const completedTrades = [];
142
153
  const tradeRs = [];
@@ -283,19 +294,28 @@ export function buildMetrics({ closed, equityStart, equityFinal, candles, estBar
283
294
  },
284
295
  };
285
296
 
297
+ const periods = periodsPerYear(interval, estBarMs);
298
+ const sqrtPeriods = Math.sqrt(periods);
299
+ const sharpeAnnualized = clampFinite(clampFinite(sharpeDaily) * sqrtPeriods);
300
+ const sortinoAnnualized = clampFinite(clampFinite(sortinoDaily) * sqrtPeriods);
301
+ const benchmark = benchmarkStats(dailyReturnsSeries, benchmarkReturns ?? []);
302
+
286
303
  return {
287
304
  trades: completedTrades.length,
288
305
  winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
289
- profitFactor: profitFactorPositions,
306
+ profitFactor: clampFinite(profitFactorPositions),
290
307
  expectancy,
291
308
  totalR,
292
309
  avgR,
293
- sharpe: sharpeDaily,
294
- sharpePerTrade,
295
- sortinoPerTrade,
310
+ sharpe: clampFinite(sharpeDaily),
311
+ sharpeAnnualized,
312
+ sortinoAnnualized,
313
+ sharpePerTrade: clampFinite(sharpePerTrade),
314
+ sortinoPerTrade: clampFinite(sortinoPerTrade),
315
+ annualizationPeriods: periods,
296
316
  maxDrawdown: maxDrawdown,
297
317
  maxDrawdownPct: maxDrawdown,
298
- calmar,
318
+ calmar: clampFinite(calmar),
299
319
  maxConsecWins: maxWin,
300
320
  maxConsecLosses: maxLoss,
301
321
  avgHold: avgHoldMin,
@@ -305,12 +325,13 @@ export function buildMetrics({ closed, equityStart, equityFinal, candles, estBar
305
325
  returnPct,
306
326
  finalEquity: equityFinal,
307
327
  startEquity: equityStart,
308
- profitFactor_pos: profitFactorPositions,
309
- profitFactor_leg: profitFactorLegs,
328
+ profitFactor_pos: clampFinite(profitFactorPositions),
329
+ profitFactor_leg: clampFinite(profitFactorLegs),
310
330
  winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
311
331
  winRate_leg: legs.length ? winningLegCount / legs.length : 0,
312
- sharpeDaily,
313
- sortinoDaily,
332
+ sharpeDaily: clampFinite(sharpeDaily),
333
+ sortinoDaily: clampFinite(sortinoDaily),
334
+ benchmark,
314
335
  sideBreakdown,
315
336
  long: sideBreakdown.long,
316
337
  short: sideBreakdown.short,
@@ -0,0 +1,17 @@
1
+ // src/metrics/finite.js
2
+
3
+ // Sentinel for "effectively infinite" metric values (e.g. profit factor with zero
4
+ // losses). Large enough to be unmistakable, small enough to survive JSON.stringify
5
+ // and JSON.parse round-trips without becoming Infinity.
6
+ export const BIG_NUMBER = 1e9;
7
+
8
+ /**
9
+ * Coerce a metric to a finite, JSON-safe number.
10
+ * +/-Infinity clamp to +/-BIG_NUMBER. NaN/null/undefined become `fallback`.
11
+ */
12
+ export function clampFinite(value, fallback = 0) {
13
+ if (value === Infinity) return BIG_NUMBER;
14
+ if (value === -Infinity) return -BIG_NUMBER;
15
+ if (typeof value === "number" && Number.isFinite(value)) return value;
16
+ return fallback;
17
+ }
@@ -0,0 +1,18 @@
1
+ /** All k-sized index combinations of [0..n). Returns arrays of indices. */
2
+ export function combinations(n, k) {
3
+ const result = [];
4
+ const combo = [];
5
+ function recurse(start) {
6
+ if (combo.length === k) {
7
+ result.push([...combo]);
8
+ return;
9
+ }
10
+ for (let i = start; i < n; i += 1) {
11
+ combo.push(i);
12
+ recurse(i + 1);
13
+ combo.pop();
14
+ }
15
+ }
16
+ recurse(0);
17
+ return result;
18
+ }
@@ -0,0 +1,47 @@
1
+ import { combinations } from "./combinations.js";
2
+
3
+ /**
4
+ * Combinatorial Purged Cross-Validation index splits.
5
+ *
6
+ * Splits [0..nObservations) into `nGroups` contiguous blocks, then forms every
7
+ * combination choosing `nTestGroups` blocks as the test set. Training indices
8
+ * that fall within `embargo` observations of any test block are purged.
9
+ */
10
+ export function combinatorialPurgedSplits({
11
+ nObservations,
12
+ nGroups = 6,
13
+ nTestGroups = 2,
14
+ embargo = 0,
15
+ }) {
16
+ if (!(nObservations > 0)) throw new Error("nObservations must be positive");
17
+ if (nTestGroups >= nGroups) throw new Error("nTestGroups must be < nGroups");
18
+
19
+ const bounds = [];
20
+ for (let g = 0; g < nGroups; g += 1) {
21
+ bounds.push([
22
+ Math.floor((g * nObservations) / nGroups),
23
+ Math.floor(((g + 1) * nObservations) / nGroups),
24
+ ]);
25
+ }
26
+
27
+ const splits = [];
28
+ for (const testGroups of combinations(nGroups, nTestGroups)) {
29
+ const testSet = new Set();
30
+ const purgeZones = [];
31
+ for (const g of testGroups) {
32
+ const [start, end] = bounds[g];
33
+ for (let i = start; i < end; i += 1) testSet.add(i);
34
+ purgeZones.push([start - embargo, end + embargo]);
35
+ }
36
+ const inPurge = (i) => purgeZones.some(([lo, hi]) => i >= lo && i < hi);
37
+
38
+ const train = [];
39
+ const testIdx = [];
40
+ for (let i = 0; i < nObservations; i += 1) {
41
+ if (testSet.has(i)) testIdx.push(i);
42
+ else if (!inPurge(i)) train.push(i);
43
+ }
44
+ splits.push({ train, test: testIdx, testGroups });
45
+ }
46
+ return splits;
47
+ }
@@ -0,0 +1,35 @@
1
+ import { normalCdf, normalPpf } from "./stats.js";
2
+
3
+ const EULER_MASCHERONI = 0.5772156649015329;
4
+
5
+ /**
6
+ * Expected maximum Sharpe under the null (no skill), given `numTrials`
7
+ * independent strategy trials whose Sharpe estimates have std `sharpeStd`.
8
+ */
9
+ export function sweepHaircut({ numTrials, sharpeStd }) {
10
+ const N = Math.max(1, numTrials);
11
+ const a = normalPpf(1 - 1 / N);
12
+ const b = normalPpf(1 - 1 / (N * Math.E));
13
+ const expectedMaxSharpe = sharpeStd * ((1 - EULER_MASCHERONI) * a + EULER_MASCHERONI * b);
14
+ return { expectedMaxSharpe, numTrials: N };
15
+ }
16
+
17
+ /**
18
+ * Deflated Sharpe Ratio: probability the observed `sharpe` is genuinely > 0
19
+ * after accounting for strategy trials, non-normality, and finite sample size.
20
+ */
21
+ export function deflatedSharpe({
22
+ sharpe,
23
+ sampleSize,
24
+ numTrials = 1,
25
+ sharpeStd = 0,
26
+ skew = 0,
27
+ kurtosis = 3,
28
+ }) {
29
+ const sr0 = sweepHaircut({ numTrials, sharpeStd }).expectedMaxSharpe;
30
+ const denom = Math.sqrt(
31
+ Math.max(1e-12, 1 - skew * sharpe + ((kurtosis - 1) / 4) * sharpe * sharpe)
32
+ );
33
+ const z = ((sharpe - sr0) * Math.sqrt(Math.max(1, sampleSize - 1))) / denom;
34
+ return normalCdf(z);
35
+ }
@@ -0,0 +1,6 @@
1
+ export { monteCarlo } from "./monteCarlo.js";
2
+ export { deflatedSharpe, sweepHaircut } from "./deflatedSharpe.js";
3
+ export { probabilityOfBacktestOverfitting } from "./pbo.js";
4
+ export { combinatorialPurgedSplits } from "./cpcv.js";
5
+ export { combinations } from "./combinations.js";
6
+ export { normalCdf, normalPpf, moments } from "./stats.js";
@@ -0,0 +1,88 @@
1
+ import { makeRng, randInt } from "../utils/random.js";
2
+
3
+ function percentile(sorted, p) {
4
+ if (!sorted.length) return 0;
5
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p)));
6
+ return sorted[idx];
7
+ }
8
+
9
+ function maxDrawdownOf(equityPath) {
10
+ let peak = equityPath[0];
11
+ let maxDd = 0;
12
+ for (const e of equityPath) {
13
+ if (e > peak) peak = e;
14
+ const dd = peak > 0 ? (peak - e) / peak : 0;
15
+ if (dd > maxDd) maxDd = dd;
16
+ }
17
+ return maxDd;
18
+ }
19
+
20
+ /**
21
+ * Block-bootstrap the trade PnL sequence `iterations` times to produce a
22
+ * distribution of final equity and max drawdown. `blockSize > 1` resamples
23
+ * contiguous blocks to preserve short-run autocorrelation (streaks).
24
+ *
25
+ * Returns percentile bands { p5, p25, p50, p75, p95 } for finalEquity and
26
+ * maxDrawdown, plus pathBands (per-step p5/p50/p95 of the equity curve).
27
+ */
28
+ export function monteCarlo({
29
+ tradePnls,
30
+ equityStart = 10_000,
31
+ iterations = 1000,
32
+ blockSize = 1,
33
+ seed = "tradelab-mc",
34
+ }) {
35
+ if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
36
+ throw new Error("monteCarlo() requires a non-empty tradePnls array");
37
+ }
38
+ const rng = makeRng(seed);
39
+ const n = tradePnls.length;
40
+ const block = Math.max(1, Math.floor(blockSize));
41
+
42
+ const finals = [];
43
+ const drawdowns = [];
44
+ const pathSamples = Array.from({ length: n + 1 }, () => []);
45
+
46
+ for (let it = 0; it < iterations; it += 1) {
47
+ const path = [equityStart];
48
+ let equity = equityStart;
49
+ let filled = 0;
50
+ while (filled < n) {
51
+ const start = randInt(rng, n);
52
+ for (let k = 0; k < block && filled < n; k += 1) {
53
+ equity += tradePnls[(start + k) % n];
54
+ path.push(equity);
55
+ filled += 1;
56
+ }
57
+ }
58
+ for (let step = 0; step < path.length; step += 1) {
59
+ pathSamples[step].push(path[step]);
60
+ }
61
+ finals.push(equity);
62
+ drawdowns.push(maxDrawdownOf(path));
63
+ }
64
+
65
+ const sortedFinals = [...finals].sort((a, b) => a - b);
66
+ const sortedDd = [...drawdowns].sort((a, b) => a - b);
67
+ const pathBands = pathSamples.map((samples) => {
68
+ const s = [...samples].sort((a, b) => a - b);
69
+ return { p5: percentile(s, 0.05), p50: percentile(s, 0.5), p95: percentile(s, 0.95) };
70
+ });
71
+
72
+ const bands = (sorted) => ({
73
+ p5: percentile(sorted, 0.05),
74
+ p25: percentile(sorted, 0.25),
75
+ p50: percentile(sorted, 0.5),
76
+ p75: percentile(sorted, 0.75),
77
+ p95: percentile(sorted, 0.95),
78
+ });
79
+
80
+ return {
81
+ iterations,
82
+ blockSize: block,
83
+ finalEquity: bands(sortedFinals),
84
+ maxDrawdown: bands(sortedDd),
85
+ pathBands,
86
+ probProfit: finals.filter((f) => f > equityStart).length / iterations,
87
+ };
88
+ }
@@ -0,0 +1,69 @@
1
+ import { combinations } from "./combinations.js";
2
+
3
+ function sharpeOf(returns) {
4
+ const n = returns.length;
5
+ if (n < 2) return 0;
6
+ const mean = returns.reduce((a, b) => a + b, 0) / n;
7
+ let variance = 0;
8
+ for (const r of returns) variance += (r - mean) ** 2;
9
+ variance /= n - 1;
10
+ const std = Math.sqrt(variance);
11
+ if (std === 0) {
12
+ if (mean > 0) return Infinity;
13
+ if (mean < 0) return -Infinity;
14
+ return 0;
15
+ }
16
+ return mean / std;
17
+ }
18
+
19
+ /**
20
+ * Combinatorially-Symmetric Cross-Validation estimate of the Probability of
21
+ * Backtest Overfitting (Bailey, Borwein, López de Prado, Zhu 2017).
22
+ */
23
+ export function probabilityOfBacktestOverfitting(performanceMatrix, { groups = 16 } = {}) {
24
+ const nStrategies = performanceMatrix.length;
25
+ if (nStrategies < 2) throw new Error("PBO needs at least 2 strategies");
26
+ const nObs = performanceMatrix[0].length;
27
+ const S = Math.min(groups, nObs);
28
+ if (S % 2 !== 0) throw new Error("groups must be even");
29
+
30
+ const groupIdx = Array.from({ length: S }, () => []);
31
+ for (let i = 0; i < nObs; i += 1) groupIdx[Math.floor((i * S) / nObs)].push(i);
32
+
33
+ const isCombos = combinations(S, S / 2);
34
+ const logits = [];
35
+ let overfitCount = 0;
36
+
37
+ for (const isGroups of isCombos) {
38
+ const isSet = new Set(isGroups);
39
+ const isIndices = [];
40
+ const osIndices = [];
41
+ for (let g = 0; g < S; g += 1) {
42
+ (isSet.has(g) ? isIndices : osIndices).push(...groupIdx[g]);
43
+ }
44
+
45
+ const isScores = performanceMatrix.map((row) => sharpeOf(isIndices.map((i) => row[i])));
46
+ const osScores = performanceMatrix.map((row) => sharpeOf(osIndices.map((i) => row[i])));
47
+
48
+ let bestStrategy = 0;
49
+ for (let s = 1; s < nStrategies; s += 1) {
50
+ if (isScores[s] > isScores[bestStrategy]) bestStrategy = s;
51
+ }
52
+
53
+ const winnerOs = osScores[bestStrategy];
54
+ let rank = 1;
55
+ for (let s = 0; s < nStrategies; s += 1) {
56
+ if (s !== bestStrategy && osScores[s] < winnerOs) rank += 1;
57
+ }
58
+ const relativeRank = rank / (nStrategies + 1);
59
+ const logit = Math.log(relativeRank / (1 - relativeRank));
60
+ logits.push(logit);
61
+ if (relativeRank <= 0.5) overfitCount += 1;
62
+ }
63
+
64
+ return {
65
+ pbo: overfitCount / isCombos.length,
66
+ combos: isCombos.length,
67
+ medianLogit: [...logits].sort((a, b) => a - b)[Math.floor(logits.length / 2)],
68
+ };
69
+ }
@@ -0,0 +1,78 @@
1
+ /** Standard normal CDF via Abramowitz & Stegun 7.1.26 (error < 1.5e-7). */
2
+ export function normalCdf(x) {
3
+ const sign = x < 0 ? -1 : 1;
4
+ const ax = Math.abs(x) / Math.SQRT2;
5
+ const t = 1 / (1 + 0.3275911 * ax);
6
+ const y =
7
+ 1 -
8
+ ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
9
+ t *
10
+ Math.exp(-ax * ax);
11
+ return 0.5 * (1 + sign * y);
12
+ }
13
+
14
+ /** Inverse standard normal CDF (Acklam's algorithm). */
15
+ export function normalPpf(p) {
16
+ if (p <= 0) return -Infinity;
17
+ if (p >= 1) return Infinity;
18
+ const a = [
19
+ -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2,
20
+ -3.066479806614716e1, 2.506628277459239,
21
+ ];
22
+ const b = [
23
+ -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1,
24
+ -1.328068155288572e1,
25
+ ];
26
+ const c = [
27
+ -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734,
28
+ 4.374664141464968, 2.938163982698783,
29
+ ];
30
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
31
+ const plow = 0.02425;
32
+ const phigh = 1 - plow;
33
+ let q;
34
+ let r;
35
+ if (p < plow) {
36
+ q = Math.sqrt(-2 * Math.log(p));
37
+ return (
38
+ (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
39
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
40
+ );
41
+ }
42
+ if (p <= phigh) {
43
+ q = p - 0.5;
44
+ r = q * q;
45
+ return (
46
+ ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
47
+ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
48
+ );
49
+ }
50
+ q = Math.sqrt(-2 * Math.log(1 - p));
51
+ return (
52
+ -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
53
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
54
+ );
55
+ }
56
+
57
+ /** Sample skewness and excess-aware kurtosis (Pearson, kurtosis includes the +3). */
58
+ export function moments(values) {
59
+ const n = values.length;
60
+ if (n < 2) return { mean: values[0] ?? 0, std: 0, skew: 0, kurtosis: 3 };
61
+ const mean = values.reduce((a, b) => a + b, 0) / n;
62
+ let m2 = 0;
63
+ let m3 = 0;
64
+ let m4 = 0;
65
+ for (const v of values) {
66
+ const d = v - mean;
67
+ m2 += d * d;
68
+ m3 += d * d * d;
69
+ m4 += d * d * d * d;
70
+ }
71
+ m2 /= n;
72
+ m3 /= n;
73
+ m4 /= n;
74
+ const std = Math.sqrt(m2);
75
+ const skew = std === 0 ? 0 : m3 / std ** 3;
76
+ const kurtosis = m2 === 0 ? 3 : m4 / m2 ** 2;
77
+ return { mean, std, skew, kurtosis };
78
+ }
@@ -0,0 +1,96 @@
1
+ import { ema } from "../utils/indicators.js";
2
+ import { rsi } from "../ta/oscillators.js";
3
+ import { donchian } from "../ta/channels.js";
4
+
5
+ export const BUILTINS = {
6
+ "ema-cross": {
7
+ description: "Long when fast EMA crosses above slow EMA; stop at recent swing low.",
8
+ params: {
9
+ fast: { type: "number", default: 10, description: "fast EMA period" },
10
+ slow: { type: "number", default: 30, description: "slow EMA period" },
11
+ rr: { type: "number", default: 2, description: "reward:risk target" },
12
+ lookback: { type: "number", default: 15, description: "swing-low lookback for stop" },
13
+ },
14
+ factory({ fast = 10, slow = 30, rr = 2, lookback = 15 } = {}) {
15
+ return ({ candles, bar }) => {
16
+ if (candles.length < slow + 2) return null;
17
+ const closes = candles.map((c) => c.close);
18
+ const f = ema(closes, fast);
19
+ const s = ema(closes, slow);
20
+ const last = closes.length - 1;
21
+ if (f[last - 1] <= s[last - 1] && f[last] > s[last]) {
22
+ const stop = Math.min(...candles.slice(-lookback).map((c) => c.low));
23
+ if (stop >= bar.close) return null;
24
+ return { side: "long", entry: bar.close, stop, rr };
25
+ }
26
+ return null;
27
+ };
28
+ },
29
+ },
30
+
31
+ "rsi-reversion": {
32
+ description: "Long when RSI dips below `oversold`; stop a fixed pct below entry.",
33
+ params: {
34
+ period: { type: "number", default: 14, description: "RSI period" },
35
+ oversold: { type: "number", default: 30, description: "RSI entry threshold" },
36
+ stopPct: { type: "number", default: 2, description: "stop distance in percent" },
37
+ rr: { type: "number", default: 1.5, description: "reward:risk target" },
38
+ },
39
+ factory({ period = 14, oversold = 30, stopPct = 2, rr = 1.5 } = {}) {
40
+ return ({ candles, bar }) => {
41
+ if (candles.length < period + 2) return null;
42
+ const values = rsi(
43
+ candles.map((c) => c.close),
44
+ period
45
+ );
46
+ const r = values[values.length - 1];
47
+ if (r === undefined || r > oversold) return null;
48
+ return { side: "long", entry: bar.close, stop: bar.close * (1 - stopPct / 100), rr };
49
+ };
50
+ },
51
+ },
52
+
53
+ "donchian-breakout": {
54
+ description: "Long on a close above the prior Donchian upper channel.",
55
+ params: {
56
+ period: { type: "number", default: 20, description: "channel lookback" },
57
+ rr: { type: "number", default: 2, description: "reward:risk target" },
58
+ },
59
+ factory({ period = 20, rr = 2 } = {}) {
60
+ return ({ candles, bar }) => {
61
+ if (candles.length < period + 2) return null;
62
+ const ch = donchian(candles, period);
63
+ const i = candles.length - 1;
64
+ const priorUpper = ch.upper[i - 1];
65
+ const priorLower = ch.lower[i - 1];
66
+ if (priorUpper === undefined) return null;
67
+ if (bar.close > priorUpper) {
68
+ return { side: "long", entry: bar.close, stop: priorLower, rr };
69
+ }
70
+ return null;
71
+ };
72
+ },
73
+ },
74
+
75
+ "buy-hold": {
76
+ description: "Enter once at the first eligible bar and hold for `holdBars`.",
77
+ params: {
78
+ holdBars: { type: "number", default: 5, description: "bars to hold before exit" },
79
+ stopPct: { type: "number", default: 10, description: "protective stop distance in percent" },
80
+ },
81
+ factory({ holdBars = 5, stopPct = 10 } = {}) {
82
+ let entered = false;
83
+ return ({ bar }) => {
84
+ if (entered) return null;
85
+ entered = true;
86
+ return {
87
+ side: "long",
88
+ entry: bar.close,
89
+ stop: bar.close * (1 - stopPct / 100),
90
+ rr: 5,
91
+ _maxBarsInTrade: holdBars,
92
+ };
93
+ };
94
+ },
95
+ },
96
+ };
@@ -0,0 +1,30 @@
1
+ import { BUILTINS } from "./builtins.js";
2
+
3
+ const registry = new Map(Object.entries(BUILTINS));
4
+
5
+ /** Register a custom strategy at runtime. `def` is a BUILTINS-shaped object. */
6
+ export function registerStrategy(name, def) {
7
+ if (typeof def?.factory !== "function") {
8
+ throw new Error(`registerStrategy("${name}") requires a factory function`);
9
+ }
10
+ registry.set(name, def);
11
+ }
12
+
13
+ /** List all strategies as { name, description, params }. */
14
+ export function listStrategies() {
15
+ return [...registry.entries()].map(([name, def]) => ({
16
+ name,
17
+ description: def.description,
18
+ params: def.params,
19
+ }));
20
+ }
21
+
22
+ /** Get a strategy's signalFactory(params) => signal. Throws on unknown name. */
23
+ export function getStrategy(name) {
24
+ const def = registry.get(name);
25
+ if (!def) {
26
+ const available = [...registry.keys()].join(", ");
27
+ throw new Error(`Unknown strategy "${name}". Available: ${available}`);
28
+ }
29
+ return def.factory;
30
+ }