tradelab 1.0.0 → 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
|
@@ -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 ?
|
|
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({
|
|
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
|
-
|
|
295
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/ta/channels.js
|
|
2
|
+
import { ema, atr } from "../utils/indicators.js";
|
|
3
|
+
|
|
4
|
+
function rollingMean(values, period, i) {
|
|
5
|
+
let sum = 0;
|
|
6
|
+
for (let j = i - period + 1; j <= i; j += 1) sum += values[j];
|
|
7
|
+
return sum / period;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bollinger Bands. `mult` standard deviations around the SMA middle band.
|
|
12
|
+
*/
|
|
13
|
+
export function bollinger(closes, period = 20, mult = 2) {
|
|
14
|
+
const middle = new Array(closes.length).fill(undefined);
|
|
15
|
+
const upper = new Array(closes.length).fill(undefined);
|
|
16
|
+
const lower = new Array(closes.length).fill(undefined);
|
|
17
|
+
for (let i = period - 1; i < closes.length; i += 1) {
|
|
18
|
+
const avg = rollingMean(closes, period, i);
|
|
19
|
+
let variance = 0;
|
|
20
|
+
for (let j = i - period + 1; j <= i; j += 1) variance += (closes[j] - avg) ** 2;
|
|
21
|
+
const sd = Math.sqrt(variance / period);
|
|
22
|
+
middle[i] = avg;
|
|
23
|
+
upper[i] = avg + mult * sd;
|
|
24
|
+
lower[i] = avg - mult * sd;
|
|
25
|
+
}
|
|
26
|
+
return { middle, upper, lower };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Donchian channel: rolling highest-high / lowest-low over `period` bars.
|
|
31
|
+
*/
|
|
32
|
+
export function donchian(bars, period = 20) {
|
|
33
|
+
const upper = new Array(bars.length).fill(undefined);
|
|
34
|
+
const lower = new Array(bars.length).fill(undefined);
|
|
35
|
+
const middle = new Array(bars.length).fill(undefined);
|
|
36
|
+
for (let i = period - 1; i < bars.length; i += 1) {
|
|
37
|
+
let hh = -Infinity;
|
|
38
|
+
let ll = Infinity;
|
|
39
|
+
for (let j = i - period + 1; j <= i; j += 1) {
|
|
40
|
+
if (bars[j].high > hh) hh = bars[j].high;
|
|
41
|
+
if (bars[j].low < ll) ll = bars[j].low;
|
|
42
|
+
}
|
|
43
|
+
upper[i] = hh;
|
|
44
|
+
lower[i] = ll;
|
|
45
|
+
middle[i] = (hh + ll) / 2;
|
|
46
|
+
}
|
|
47
|
+
return { upper, lower, middle };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keltner channel: EMA middle, +/- mult * ATR width.
|
|
52
|
+
*/
|
|
53
|
+
export function keltner(bars, emaPeriod = 20, atrPeriod = 14, mult = 2) {
|
|
54
|
+
const closes = bars.map((b) => b.close);
|
|
55
|
+
const mid = ema(closes, emaPeriod);
|
|
56
|
+
const range = atr(bars, atrPeriod);
|
|
57
|
+
const upper = new Array(bars.length).fill(undefined);
|
|
58
|
+
const lower = new Array(bars.length).fill(undefined);
|
|
59
|
+
const middle = new Array(bars.length).fill(undefined);
|
|
60
|
+
for (let i = 0; i < bars.length; i += 1) {
|
|
61
|
+
if (range[i] === undefined) continue;
|
|
62
|
+
middle[i] = mid[i];
|
|
63
|
+
upper[i] = mid[i] + mult * range[i];
|
|
64
|
+
lower[i] = mid[i] - mult * range[i];
|
|
65
|
+
}
|
|
66
|
+
return { upper, lower, middle };
|
|
67
|
+
}
|
package/src/ta/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/ta/index.js
|
|
2
|
+
export {
|
|
3
|
+
ema,
|
|
4
|
+
atr,
|
|
5
|
+
swingHigh,
|
|
6
|
+
swingLow,
|
|
7
|
+
detectFVG,
|
|
8
|
+
lastSwing,
|
|
9
|
+
structureState,
|
|
10
|
+
} from "../utils/indicators.js";
|
|
11
|
+
|
|
12
|
+
export { rsi, macd, stochastic } from "./oscillators.js";
|
|
13
|
+
|
|
14
|
+
export { bollinger, donchian, keltner } from "./channels.js";
|
|
15
|
+
|
|
16
|
+
export { supertrend, vwap } from "./trend.js";
|