tradelab 0.3.0 → 0.5.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/README.md +290 -130
- package/bin/tradelab.js +68 -23
- package/dist/cjs/data.cjs +78 -53
- package/dist/cjs/index.cjs +1518 -211
- package/docs/README.md +66 -0
- package/docs/api-reference.md +75 -0
- package/docs/backtest-engine.md +393 -0
- package/docs/data-reporting-cli.md +258 -0
- package/docs/examples.md +281 -0
- package/package.json +2 -1
- package/src/engine/backtestTicks.js +429 -0
- package/src/engine/barSystemRunner.js +963 -0
- package/src/engine/portfolio.js +191 -68
- package/src/engine/walkForward.js +106 -10
- package/src/index.js +1 -0
- package/src/metrics/buildMetrics.js +89 -63
- package/types/index.d.ts +77 -1
package/src/engine/portfolio.js
CHANGED
|
@@ -1,57 +1,35 @@
|
|
|
1
1
|
import { buildMetrics } from "../metrics/buildMetrics.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { estimateBarMs, dayKeyET } from "./execution.js";
|
|
3
|
+
import { BarSystemRunner, defaultSystemCap } from "./barSystemRunner.js";
|
|
4
4
|
|
|
5
5
|
function asWeight(value) {
|
|
6
6
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!times.length) {
|
|
19
|
-
return [{ time: 0, timestamp: 0, equity: totalEquity }];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const states = systemRuns.map((run) => ({
|
|
23
|
-
points: run.result.eqSeries || [],
|
|
24
|
-
index: 0,
|
|
25
|
-
lastEquity: run.allocationEquity,
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
return times.map((time) => {
|
|
29
|
-
let equity = 0;
|
|
30
|
-
states.forEach((state) => {
|
|
31
|
-
while (
|
|
32
|
-
state.index < state.points.length &&
|
|
33
|
-
state.points[state.index].time <= time
|
|
34
|
-
) {
|
|
35
|
-
state.lastEquity = state.points[state.index].equity;
|
|
36
|
-
state.index += 1;
|
|
37
|
-
}
|
|
38
|
-
equity += state.lastEquity;
|
|
39
|
-
});
|
|
9
|
+
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
10
|
+
return {
|
|
11
|
+
time,
|
|
12
|
+
timestamp: time,
|
|
13
|
+
equity,
|
|
14
|
+
lockedCapital,
|
|
15
|
+
availableCapital,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
40
18
|
|
|
41
|
-
|
|
42
|
-
|
|
19
|
+
function stableSystemOrder(left, right) {
|
|
20
|
+
return left.index - right.index;
|
|
43
21
|
}
|
|
44
22
|
|
|
45
|
-
function combineReplay(
|
|
23
|
+
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
46
24
|
if (!collectReplay) {
|
|
47
25
|
return { frames: [], events: [] };
|
|
48
26
|
}
|
|
49
27
|
|
|
50
|
-
const events =
|
|
51
|
-
.flatMap((
|
|
52
|
-
(
|
|
28
|
+
const events = systemResults
|
|
29
|
+
.flatMap((entry) =>
|
|
30
|
+
(entry.result.replay?.events || []).map((event) => ({
|
|
53
31
|
...event,
|
|
54
|
-
symbol: event.symbol ||
|
|
32
|
+
symbol: event.symbol || entry.symbol,
|
|
55
33
|
}))
|
|
56
34
|
)
|
|
57
35
|
.sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
@@ -62,17 +40,82 @@ function combineReplay(systemRuns, eqSeries, collectReplay) {
|
|
|
62
40
|
equity: point.equity,
|
|
63
41
|
posSide: null,
|
|
64
42
|
posSize: 0,
|
|
43
|
+
lockedCapital: point.lockedCapital,
|
|
44
|
+
availableCapital: point.availableCapital,
|
|
65
45
|
}));
|
|
66
46
|
|
|
67
47
|
return { frames, events };
|
|
68
48
|
}
|
|
69
49
|
|
|
50
|
+
function portfolioState(runners, initialEquity) {
|
|
51
|
+
let markedEquity = initialEquity;
|
|
52
|
+
let lockedCapital = 0;
|
|
53
|
+
|
|
54
|
+
for (const { runner, initialReferenceEquity } of runners) {
|
|
55
|
+
markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
|
|
56
|
+
lockedCapital += runner.getLockedCapital();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
markedEquity,
|
|
61
|
+
lockedCapital,
|
|
62
|
+
availableCapital: markedEquity - lockedCapital,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findNextTimeAndActive(runners) {
|
|
67
|
+
let nextTime = Infinity;
|
|
68
|
+
const active = [];
|
|
69
|
+
|
|
70
|
+
for (const entry of runners) {
|
|
71
|
+
const time = entry.runner.peekTime();
|
|
72
|
+
if (time < nextTime) {
|
|
73
|
+
nextTime = time;
|
|
74
|
+
active.length = 0;
|
|
75
|
+
active.push(entry);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (time === nextTime) {
|
|
79
|
+
active.push(entry);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { nextTime, active };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function initialPortfolioTime(runners) {
|
|
87
|
+
let time = Infinity;
|
|
88
|
+
for (const { runner } of runners) {
|
|
89
|
+
const next = runner.candles[0]?.time ?? Infinity;
|
|
90
|
+
if (next < time) time = next;
|
|
91
|
+
}
|
|
92
|
+
return Number.isFinite(time) ? time : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveSystemCap(systemEntry, totalEquity) {
|
|
96
|
+
return defaultSystemCap(
|
|
97
|
+
Math.max(0, totalEquity),
|
|
98
|
+
systemEntry.defaultCapPct,
|
|
99
|
+
systemEntry.system.maxAllocation,
|
|
100
|
+
systemEntry.system.maxAllocationPct
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function forceExitAll(runners, time) {
|
|
105
|
+
for (const { runner } of runners) {
|
|
106
|
+
if (!runner.open) continue;
|
|
107
|
+
const price = runner.getMarkPrice();
|
|
108
|
+
if (!Number.isFinite(price)) continue;
|
|
109
|
+
runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
70
113
|
/**
|
|
71
|
-
* Run multiple
|
|
114
|
+
* Run multiple systems against a shared capital pool.
|
|
72
115
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
116
|
+
* Existing allocation weights are preserved as default per-system capital caps,
|
|
117
|
+
* but capital is only locked when a fill actually occurs. Systems therefore
|
|
118
|
+
* compete for the same remaining capital at fill time.
|
|
76
119
|
*/
|
|
77
120
|
export function backtestPortfolio({
|
|
78
121
|
systems = [],
|
|
@@ -80,6 +123,7 @@ export function backtestPortfolio({
|
|
|
80
123
|
allocation = "equal",
|
|
81
124
|
collectEqSeries = true,
|
|
82
125
|
collectReplay = false,
|
|
126
|
+
maxDailyLossPct = 0,
|
|
83
127
|
} = {}) {
|
|
84
128
|
if (!Array.isArray(systems) || systems.length === 0) {
|
|
85
129
|
throw new Error("backtestPortfolio() requires a non-empty systems array");
|
|
@@ -89,30 +133,115 @@ export function backtestPortfolio({
|
|
|
89
133
|
allocation === "equal"
|
|
90
134
|
? systems.map(() => 1)
|
|
91
135
|
: systems.map((system) => asWeight(system.weight || 0));
|
|
92
|
-
const totalWeight = weights.reduce((
|
|
136
|
+
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
93
137
|
|
|
94
138
|
if (!(totalWeight > 0)) {
|
|
95
139
|
throw new Error("backtestPortfolio() requires positive allocation weights");
|
|
96
140
|
}
|
|
97
141
|
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
...system,
|
|
102
|
-
equity: allocationEquity,
|
|
103
|
-
collectEqSeries,
|
|
104
|
-
collectReplay,
|
|
105
|
-
});
|
|
106
|
-
|
|
142
|
+
const runners = systems.map((system, index) => {
|
|
143
|
+
const defaultCapPct = weights[index] / totalWeight;
|
|
144
|
+
const initialReferenceEquity = equity * defaultCapPct;
|
|
107
145
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
146
|
+
index,
|
|
147
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
148
|
+
system,
|
|
149
|
+
defaultCapPct,
|
|
150
|
+
initialReferenceEquity,
|
|
151
|
+
runner: new BarSystemRunner({
|
|
152
|
+
...system,
|
|
153
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
154
|
+
equity: initialReferenceEquity,
|
|
155
|
+
collectEqSeries,
|
|
156
|
+
collectReplay,
|
|
157
|
+
}),
|
|
112
158
|
};
|
|
113
159
|
});
|
|
114
160
|
|
|
115
|
-
const
|
|
161
|
+
const eqSeries = collectEqSeries ? [] : [];
|
|
162
|
+
let state = portfolioState(runners, equity);
|
|
163
|
+
if (collectEqSeries) {
|
|
164
|
+
eqSeries.push(
|
|
165
|
+
buildPortfolioPoint(
|
|
166
|
+
initialPortfolioTime(runners),
|
|
167
|
+
state.markedEquity,
|
|
168
|
+
state.lockedCapital,
|
|
169
|
+
state.availableCapital
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let currentDay = null;
|
|
175
|
+
let dayStartEquity = equity;
|
|
176
|
+
let portfolioHalted = false;
|
|
177
|
+
|
|
178
|
+
while (true) {
|
|
179
|
+
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
180
|
+
if (!Number.isFinite(nextTime)) break;
|
|
181
|
+
active.sort(stableSystemOrder);
|
|
182
|
+
|
|
183
|
+
const dayKey = dayKeyET(nextTime);
|
|
184
|
+
if (currentDay === null || dayKey !== currentDay) {
|
|
185
|
+
currentDay = dayKey;
|
|
186
|
+
state = portfolioState(runners, equity);
|
|
187
|
+
dayStartEquity = state.markedEquity;
|
|
188
|
+
portfolioHalted = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const systemEntry of active) {
|
|
192
|
+
state = portfolioState(runners, equity);
|
|
193
|
+
const totalEquity = state.markedEquity;
|
|
194
|
+
const availableCapital = Math.max(0, state.availableCapital);
|
|
195
|
+
const systemLocked = systemEntry.runner.getLockedCapital();
|
|
196
|
+
const systemCap = resolveSystemCap(systemEntry, totalEquity);
|
|
197
|
+
const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
|
|
198
|
+
|
|
199
|
+
systemEntry.runner.step({
|
|
200
|
+
signalEquity: totalEquity,
|
|
201
|
+
canTrade: !portfolioHalted,
|
|
202
|
+
resolveEntrySize({ desiredSize, entryPrice }) {
|
|
203
|
+
const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
|
|
204
|
+
const byAvailable = (availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
205
|
+
const bySystemCap = (systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
206
|
+
return Math.min(desiredSize, byAvailable, bySystemCap);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
state = portfolioState(runners, equity);
|
|
211
|
+
if (
|
|
212
|
+
!portfolioHalted &&
|
|
213
|
+
maxDailyLossPct > 0 &&
|
|
214
|
+
state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)
|
|
215
|
+
) {
|
|
216
|
+
portfolioHalted = true;
|
|
217
|
+
for (const { runner } of runners) runner.cancelPending();
|
|
218
|
+
forceExitAll(runners, nextTime);
|
|
219
|
+
state = portfolioState(runners, equity);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (collectEqSeries) {
|
|
224
|
+
eqSeries.push(
|
|
225
|
+
buildPortfolioPoint(
|
|
226
|
+
nextTime,
|
|
227
|
+
state.markedEquity,
|
|
228
|
+
state.lockedCapital,
|
|
229
|
+
state.availableCapital
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const systemResults = runners.map((entry) => ({
|
|
236
|
+
symbol: entry.symbol,
|
|
237
|
+
weight: entry.defaultCapPct,
|
|
238
|
+
equity: entry.initialReferenceEquity,
|
|
239
|
+
allocationCapPct: entry.defaultCapPct,
|
|
240
|
+
allocationCap: resolveSystemCap(entry, equity),
|
|
241
|
+
result: entry.runner.buildResult(),
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
const trades = systemResults
|
|
116
245
|
.flatMap((run) =>
|
|
117
246
|
run.result.trades.map((trade) => ({
|
|
118
247
|
...trade,
|
|
@@ -120,7 +249,7 @@ export function backtestPortfolio({
|
|
|
120
249
|
}))
|
|
121
250
|
)
|
|
122
251
|
.sort((left, right) => left.exit.time - right.exit.time);
|
|
123
|
-
const positions =
|
|
252
|
+
const positions = systemResults
|
|
124
253
|
.flatMap((run) =>
|
|
125
254
|
run.result.positions.map((trade) => ({
|
|
126
255
|
...trade,
|
|
@@ -128,8 +257,7 @@ export function backtestPortfolio({
|
|
|
128
257
|
}))
|
|
129
258
|
)
|
|
130
259
|
.sort((left, right) => left.exit.time - right.exit.time);
|
|
131
|
-
const
|
|
132
|
-
const replay = combineReplay(systemRuns, eqSeries, collectReplay);
|
|
260
|
+
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
133
261
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
134
262
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
135
263
|
const metrics = buildMetrics({
|
|
@@ -150,11 +278,6 @@ export function backtestPortfolio({
|
|
|
150
278
|
metrics,
|
|
151
279
|
eqSeries,
|
|
152
280
|
replay,
|
|
153
|
-
systems:
|
|
154
|
-
symbol: run.symbol,
|
|
155
|
-
weight: run.weight / totalWeight,
|
|
156
|
-
equity: run.allocationEquity,
|
|
157
|
-
result: run.result,
|
|
158
|
-
})),
|
|
281
|
+
systems: systemResults,
|
|
159
282
|
};
|
|
160
283
|
}
|
|
@@ -19,6 +19,73 @@ function stitchEquitySeries(target, source) {
|
|
|
19
19
|
target.push(...nextPoints);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function canonicalParams(params) {
|
|
23
|
+
const entries = Object.entries(params || {}).sort(([left], [right]) =>
|
|
24
|
+
left.localeCompare(right)
|
|
25
|
+
);
|
|
26
|
+
return JSON.stringify(Object.fromEntries(entries));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
|
|
30
|
+
const ranges = [];
|
|
31
|
+
for (
|
|
32
|
+
let start = 0;
|
|
33
|
+
start + trainBars + testBars <= length;
|
|
34
|
+
start += stepBars
|
|
35
|
+
) {
|
|
36
|
+
const trainStart = mode === "anchored" ? 0 : start;
|
|
37
|
+
const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
|
|
38
|
+
const testStart = trainEnd;
|
|
39
|
+
const testEnd = testStart + testBars;
|
|
40
|
+
if (testEnd > length) break;
|
|
41
|
+
ranges.push({ trainStart, trainEnd, testStart, testEnd });
|
|
42
|
+
}
|
|
43
|
+
return ranges;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeBestParams(windows) {
|
|
47
|
+
const summaryBySignature = new Map();
|
|
48
|
+
let adjacentRepeats = 0;
|
|
49
|
+
|
|
50
|
+
windows.forEach((window, index) => {
|
|
51
|
+
const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
|
|
52
|
+
const current = summaryBySignature.get(signature) || {
|
|
53
|
+
params: window.bestParams,
|
|
54
|
+
wins: 0,
|
|
55
|
+
profitableWindows: 0,
|
|
56
|
+
oosTrades: 0,
|
|
57
|
+
};
|
|
58
|
+
current.wins += 1;
|
|
59
|
+
current.profitableWindows += window.profitable ? 1 : 0;
|
|
60
|
+
current.oosTrades += window.oosTrades;
|
|
61
|
+
summaryBySignature.set(signature, current);
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
index > 0 &&
|
|
65
|
+
(windows[index - 1].bestParamsSignature ??
|
|
66
|
+
canonicalParams(windows[index - 1].bestParams)) === signature
|
|
67
|
+
) {
|
|
68
|
+
adjacentRepeats += 1;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
|
|
73
|
+
if (right.wins !== left.wins) return right.wins - left.wins;
|
|
74
|
+
return right.profitableWindows - left.profitableWindows;
|
|
75
|
+
});
|
|
76
|
+
const adjacentPairs = Math.max(0, windows.length - 1);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
winners: windows.map((window) => window.bestParams),
|
|
80
|
+
stability: {
|
|
81
|
+
adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
|
|
82
|
+
uniqueWinnerCount: summaryBySignature.size,
|
|
83
|
+
dominant: byFrequency[0] || null,
|
|
84
|
+
leaderboard: byFrequency,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
22
89
|
/**
|
|
23
90
|
* Run rolling walk-forward optimization over a single candle series.
|
|
24
91
|
*
|
|
@@ -32,6 +99,7 @@ export function walkForwardOptimize({
|
|
|
32
99
|
trainBars,
|
|
33
100
|
testBars,
|
|
34
101
|
stepBars = testBars,
|
|
102
|
+
mode = "rolling",
|
|
35
103
|
scoreBy = "profitFactor",
|
|
36
104
|
backtestOptions = {},
|
|
37
105
|
} = {}) {
|
|
@@ -47,25 +115,31 @@ export function walkForwardOptimize({
|
|
|
47
115
|
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
48
116
|
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
49
117
|
}
|
|
118
|
+
if (mode !== "rolling" && mode !== "anchored") {
|
|
119
|
+
throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
|
|
120
|
+
}
|
|
50
121
|
|
|
51
122
|
const windows = [];
|
|
52
123
|
const allTrades = [];
|
|
53
124
|
const allPositions = [];
|
|
54
125
|
const eqSeries = [];
|
|
55
126
|
let rollingEquity = backtestOptions.equity ?? 10_000;
|
|
127
|
+
const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
|
|
128
|
+
const trainBacktestOptions = {
|
|
129
|
+
...backtestOptions,
|
|
130
|
+
collectEqSeries: false,
|
|
131
|
+
collectReplay: false,
|
|
132
|
+
};
|
|
133
|
+
const testBacktestOptions = { ...backtestOptions };
|
|
56
134
|
|
|
57
|
-
for (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
start += stepBars
|
|
61
|
-
) {
|
|
62
|
-
const trainSlice = candles.slice(start, start + trainBars);
|
|
63
|
-
const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
|
|
135
|
+
for (const range of ranges) {
|
|
136
|
+
const trainSlice = candles.slice(range.trainStart, range.trainEnd);
|
|
137
|
+
const testSlice = candles.slice(range.testStart, range.testEnd);
|
|
64
138
|
|
|
65
139
|
let best = null;
|
|
66
140
|
for (const params of parameterSets) {
|
|
67
141
|
const trainResult = backtest({
|
|
68
|
-
...
|
|
142
|
+
...trainBacktestOptions,
|
|
69
143
|
candles: trainSlice,
|
|
70
144
|
equity: rollingEquity,
|
|
71
145
|
signal: signalFactory(params),
|
|
@@ -77,11 +151,12 @@ export function walkForwardOptimize({
|
|
|
77
151
|
}
|
|
78
152
|
|
|
79
153
|
const testResult = backtest({
|
|
80
|
-
...
|
|
154
|
+
...testBacktestOptions,
|
|
81
155
|
candles: testSlice,
|
|
82
156
|
equity: rollingEquity,
|
|
83
157
|
signal: signalFactory(best.params),
|
|
84
158
|
});
|
|
159
|
+
const bestParamsSignature = canonicalParams(best.params);
|
|
85
160
|
|
|
86
161
|
rollingEquity = testResult.metrics.finalEquity;
|
|
87
162
|
allTrades.push(...testResult.trades);
|
|
@@ -101,10 +176,29 @@ export function walkForwardOptimize({
|
|
|
101
176
|
trainScore: best.score,
|
|
102
177
|
trainMetrics: best.metrics,
|
|
103
178
|
testMetrics: testResult.metrics,
|
|
179
|
+
oosTrades: testResult.metrics.trades,
|
|
180
|
+
profitable: testResult.metrics.totalPnL > 0,
|
|
181
|
+
stabilityScore: 0,
|
|
182
|
+
bestParamsSignature,
|
|
104
183
|
result: testResult,
|
|
105
184
|
});
|
|
106
185
|
}
|
|
107
186
|
|
|
187
|
+
for (let index = 0; index < windows.length; index += 1) {
|
|
188
|
+
const currentSignature = windows[index].bestParamsSignature;
|
|
189
|
+
const adjacent = [];
|
|
190
|
+
if (index > 0) {
|
|
191
|
+
adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
192
|
+
}
|
|
193
|
+
if (index + 1 < windows.length) {
|
|
194
|
+
adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
|
|
195
|
+
}
|
|
196
|
+
windows[index].stabilityScore = adjacent.length
|
|
197
|
+
? adjacent.reduce((total, value) => total + value, 0) / adjacent.length
|
|
198
|
+
: 1;
|
|
199
|
+
delete windows[index].bestParamsSignature;
|
|
200
|
+
}
|
|
201
|
+
|
|
108
202
|
const metrics = buildMetrics({
|
|
109
203
|
closed: allTrades,
|
|
110
204
|
equityStart: backtestOptions.equity ?? 10_000,
|
|
@@ -113,6 +207,7 @@ export function walkForwardOptimize({
|
|
|
113
207
|
estBarMs: estimateBarMs(candles),
|
|
114
208
|
eqSeries,
|
|
115
209
|
});
|
|
210
|
+
const bestParamsSummary = summarizeBestParams(windows);
|
|
116
211
|
|
|
117
212
|
return {
|
|
118
213
|
windows,
|
|
@@ -121,6 +216,7 @@ export function walkForwardOptimize({
|
|
|
121
216
|
metrics,
|
|
122
217
|
eqSeries,
|
|
123
218
|
replay: { frames: [], events: [] },
|
|
124
|
-
bestParams: windows.map((window) => window.bestParams),
|
|
219
|
+
bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
|
|
220
|
+
bestParamsSummary: bestParamsSummary.stability,
|
|
125
221
|
};
|
|
126
222
|
}
|