tradelab 0.4.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 +38 -17
- package/bin/tradelab.js +68 -23
- package/dist/cjs/data.cjs +78 -53
- package/dist/cjs/index.cjs +1518 -211
- package/docs/README.md +6 -1
- package/docs/api-reference.md +7 -2
- package/docs/backtest-engine.md +40 -10
- package/docs/data-reporting-cli.md +7 -3
- package/docs/examples.md +281 -0
- package/package.json +1 -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
|
@@ -142,24 +142,88 @@ export function buildMetrics({
|
|
|
142
142
|
estBarMs,
|
|
143
143
|
eqSeries,
|
|
144
144
|
}) {
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
145
|
+
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
146
|
+
const completedTrades = [];
|
|
147
|
+
const tradeRs = [];
|
|
148
|
+
const tradePnls = [];
|
|
149
|
+
const tradeReturns = [];
|
|
150
|
+
const holdDurationsMinutes = [];
|
|
151
|
+
const labels = [];
|
|
152
|
+
const longRs = [];
|
|
153
|
+
const shortRs = [];
|
|
154
|
+
let totalR = 0;
|
|
155
|
+
let realizedPnL = 0;
|
|
156
|
+
let winningTradeCount = 0;
|
|
157
|
+
let grossProfitPositions = 0;
|
|
158
|
+
let grossLossPositions = 0;
|
|
159
|
+
let grossProfitLegs = 0;
|
|
160
|
+
let grossLossLegs = 0;
|
|
161
|
+
let winningLegCount = 0;
|
|
162
|
+
let openBars = 0;
|
|
163
|
+
let longTradesCount = 0;
|
|
164
|
+
let longTradeWins = 0;
|
|
165
|
+
let longPnLSum = 0;
|
|
166
|
+
let shortTradesCount = 0;
|
|
167
|
+
let shortTradeWins = 0;
|
|
168
|
+
let shortPnLSum = 0;
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
let peakEquity = equityStart;
|
|
171
|
+
let currentEquity = equityStart;
|
|
172
|
+
let maxDrawdown = 0;
|
|
152
173
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
for (const trade of legs) {
|
|
175
|
+
const pnl = trade.exit.pnl;
|
|
176
|
+
realizedPnL += pnl;
|
|
177
|
+
|
|
178
|
+
if (pnl > 0) {
|
|
179
|
+
grossProfitLegs += pnl;
|
|
180
|
+
winningLegCount += 1;
|
|
181
|
+
} else if (pnl < 0) {
|
|
182
|
+
grossLossLegs += Math.abs(pnl);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
currentEquity += pnl;
|
|
186
|
+
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
187
|
+
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
188
|
+
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
189
|
+
|
|
190
|
+
if (trade.exit.reason === "SCALE") continue;
|
|
191
|
+
|
|
192
|
+
completedTrades.push(trade);
|
|
193
|
+
tradePnls.push(pnl);
|
|
194
|
+
tradeReturns.push(pnl / Math.max(1e-12, equityStart));
|
|
195
|
+
const tradeR = tradeRMultiple(trade);
|
|
196
|
+
tradeRs.push(tradeR);
|
|
197
|
+
totalR += tradeR;
|
|
198
|
+
labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
|
|
199
|
+
|
|
200
|
+
const holdMinutes = (trade.exit.time - trade.openTime) / (1000 * 60);
|
|
201
|
+
holdDurationsMinutes.push(holdMinutes);
|
|
202
|
+
openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
203
|
+
|
|
204
|
+
if (pnl > 0) {
|
|
205
|
+
winningTradeCount += 1;
|
|
206
|
+
grossProfitPositions += pnl;
|
|
207
|
+
} else if (pnl < 0) {
|
|
208
|
+
grossLossPositions += Math.abs(pnl);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (trade.side === "long") {
|
|
212
|
+
longTradesCount += 1;
|
|
213
|
+
longPnLSum += pnl;
|
|
214
|
+
longRs.push(tradeR);
|
|
215
|
+
if (pnl > 0) longTradeWins += 1;
|
|
216
|
+
} else if (trade.side === "short") {
|
|
217
|
+
shortTradesCount += 1;
|
|
218
|
+
shortPnLSum += pnl;
|
|
219
|
+
shortRs.push(tradeR);
|
|
220
|
+
if (pnl > 0) shortTradeWins += 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
157
223
|
|
|
158
|
-
const
|
|
224
|
+
const avgR = mean(tradeRs);
|
|
225
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
159
226
|
const expectancy = mean(tradePnls);
|
|
160
|
-
const tradeReturns = completedTrades.map(
|
|
161
|
-
(trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
|
|
162
|
-
);
|
|
163
227
|
const tradeReturnStd = stddev(tradeReturns);
|
|
164
228
|
const sharpePerTrade =
|
|
165
229
|
tradeReturnStd === 0
|
|
@@ -169,54 +233,23 @@ export function buildMetrics({
|
|
|
169
233
|
: mean(tradeReturns) / tradeReturnStd;
|
|
170
234
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
171
235
|
|
|
172
|
-
const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
|
|
173
|
-
const grossLossPositions = Math.abs(
|
|
174
|
-
sum(losingTrades.map((trade) => trade.exit.pnl))
|
|
175
|
-
);
|
|
176
236
|
const profitFactorPositions =
|
|
177
237
|
grossLossPositions === 0
|
|
178
238
|
? grossProfitPositions > 0
|
|
179
239
|
? Infinity
|
|
180
240
|
: 0
|
|
181
241
|
: grossProfitPositions / grossLossPositions;
|
|
182
|
-
|
|
183
|
-
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
184
|
-
const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
|
|
185
|
-
const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
|
|
186
|
-
const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
|
|
187
|
-
const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
|
|
188
242
|
const profitFactorLegs =
|
|
189
243
|
grossLossLegs === 0
|
|
190
244
|
? grossProfitLegs > 0
|
|
191
245
|
? Infinity
|
|
192
246
|
: 0
|
|
193
247
|
: grossProfitLegs / grossLossLegs;
|
|
194
|
-
|
|
195
|
-
let peakEquity = equityStart;
|
|
196
|
-
let currentEquity = equityStart;
|
|
197
|
-
let maxDrawdown = 0;
|
|
198
|
-
|
|
199
|
-
for (const leg of legs) {
|
|
200
|
-
currentEquity += leg.exit.pnl;
|
|
201
|
-
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
202
|
-
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
203
|
-
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
|
|
207
248
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
208
249
|
const calmar = maxDrawdown === 0 ? (returnPct > 0 ? Infinity : 0) : returnPct / maxDrawdown;
|
|
209
250
|
|
|
210
251
|
const totalBars = Math.max(1, candles.length);
|
|
211
|
-
const openBars = completedTrades.reduce((total, trade) => {
|
|
212
|
-
const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
213
|
-
return total + barsHeld;
|
|
214
|
-
}, 0);
|
|
215
252
|
const exposurePct = openBars / totalBars;
|
|
216
|
-
|
|
217
|
-
const holdDurationsMinutes = completedTrades.map(
|
|
218
|
-
(trade) => (trade.exit.time - trade.openTime) / (1000 * 60)
|
|
219
|
-
);
|
|
220
253
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
221
254
|
|
|
222
255
|
const equitySeries =
|
|
@@ -236,13 +269,6 @@ export function buildMetrics({
|
|
|
236
269
|
? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length
|
|
237
270
|
: 0;
|
|
238
271
|
|
|
239
|
-
const longTrades = completedTrades.filter((trade) => trade.side === "long");
|
|
240
|
-
const shortTrades = completedTrades.filter((trade) => trade.side === "short");
|
|
241
|
-
const longRs = longTrades.map(tradeRMultiple);
|
|
242
|
-
const shortRs = shortTrades.map(tradeRMultiple);
|
|
243
|
-
const longPnls = longTrades.map((trade) => trade.exit.pnl);
|
|
244
|
-
const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
|
|
245
|
-
|
|
246
272
|
const rDistribution = {
|
|
247
273
|
p10: percentile(tradeRs, 0.1),
|
|
248
274
|
p25: percentile(tradeRs, 0.25),
|
|
@@ -261,26 +287,26 @@ export function buildMetrics({
|
|
|
261
287
|
|
|
262
288
|
const sideBreakdown = {
|
|
263
289
|
long: {
|
|
264
|
-
trades:
|
|
265
|
-
winRate:
|
|
266
|
-
?
|
|
290
|
+
trades: longTradesCount,
|
|
291
|
+
winRate: longTradesCount
|
|
292
|
+
? longTradeWins / longTradesCount
|
|
267
293
|
: 0,
|
|
268
|
-
avgPnL:
|
|
294
|
+
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
269
295
|
avgR: mean(longRs),
|
|
270
296
|
},
|
|
271
297
|
short: {
|
|
272
|
-
trades:
|
|
273
|
-
winRate:
|
|
274
|
-
?
|
|
298
|
+
trades: shortTradesCount,
|
|
299
|
+
winRate: shortTradesCount
|
|
300
|
+
? shortTradeWins / shortTradesCount
|
|
275
301
|
: 0,
|
|
276
|
-
avgPnL:
|
|
302
|
+
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
277
303
|
avgR: mean(shortRs),
|
|
278
304
|
},
|
|
279
305
|
};
|
|
280
306
|
|
|
281
307
|
return {
|
|
282
308
|
trades: completedTrades.length,
|
|
283
|
-
winRate: completedTrades.length ?
|
|
309
|
+
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
284
310
|
profitFactor: profitFactorPositions,
|
|
285
311
|
expectancy,
|
|
286
312
|
totalR,
|
|
@@ -303,9 +329,9 @@ export function buildMetrics({
|
|
|
303
329
|
profitFactor_pos: profitFactorPositions,
|
|
304
330
|
profitFactor_leg: profitFactorLegs,
|
|
305
331
|
winRate_pos: completedTrades.length
|
|
306
|
-
?
|
|
332
|
+
? winningTradeCount / completedTrades.length
|
|
307
333
|
: 0,
|
|
308
|
-
winRate_leg: legs.length ?
|
|
334
|
+
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
309
335
|
sharpeDaily,
|
|
310
336
|
sortinoDaily,
|
|
311
337
|
sideBreakdown,
|
package/types/index.d.ts
CHANGED
|
@@ -10,6 +10,20 @@ export interface Candle {
|
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface Tick {
|
|
14
|
+
time: number;
|
|
15
|
+
price?: number;
|
|
16
|
+
last?: number;
|
|
17
|
+
bid?: number;
|
|
18
|
+
ask?: number;
|
|
19
|
+
high?: number;
|
|
20
|
+
low?: number;
|
|
21
|
+
close?: number;
|
|
22
|
+
size?: number;
|
|
23
|
+
volume?: number;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
/** Realized equity snapshot captured during a backtest. */
|
|
14
28
|
export interface EquityPoint {
|
|
15
29
|
/** Bar timestamp in Unix milliseconds. */
|
|
@@ -18,6 +32,10 @@ export interface EquityPoint {
|
|
|
18
32
|
timestamp: number;
|
|
19
33
|
/** Realized account equity at this point in the run. */
|
|
20
34
|
equity: number;
|
|
35
|
+
/** Capital currently locked by open positions, when available. */
|
|
36
|
+
lockedCapital?: number;
|
|
37
|
+
/** Capital currently available for new positions, when available. */
|
|
38
|
+
availableCapital?: number;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
/** Lightweight chart frame for replay/export consumers. */
|
|
@@ -32,6 +50,8 @@ export interface ReplayFrame {
|
|
|
32
50
|
posSide: Side | null;
|
|
33
51
|
/** Active position size at the frame time. */
|
|
34
52
|
posSize: number;
|
|
53
|
+
lockedCapital?: number;
|
|
54
|
+
availableCapital?: number;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/** Replay event emitted for entries, exits, adds, and scale-outs. */
|
|
@@ -301,6 +321,29 @@ export interface BacktestOptions {
|
|
|
301
321
|
strict?: boolean;
|
|
302
322
|
}
|
|
303
323
|
|
|
324
|
+
export interface BacktestTickOptions {
|
|
325
|
+
ticks: Tick[];
|
|
326
|
+
symbol?: string;
|
|
327
|
+
equity?: number;
|
|
328
|
+
riskPct?: number;
|
|
329
|
+
signal: SignalFunction;
|
|
330
|
+
interval?: string;
|
|
331
|
+
range?: string;
|
|
332
|
+
slippageBps?: number;
|
|
333
|
+
feeBps?: number;
|
|
334
|
+
costs?: ExecutionCostOptions;
|
|
335
|
+
finalTP_R?: number;
|
|
336
|
+
maxDailyLossPct?: number;
|
|
337
|
+
dailyMaxTrades?: number;
|
|
338
|
+
qtyStep?: number;
|
|
339
|
+
minQty?: number;
|
|
340
|
+
maxLeverage?: number;
|
|
341
|
+
collectEqSeries?: boolean;
|
|
342
|
+
collectReplay?: boolean;
|
|
343
|
+
queueFillProbability?: number;
|
|
344
|
+
oco?: OCOOptions;
|
|
345
|
+
}
|
|
346
|
+
|
|
304
347
|
/** Full result payload returned by `backtest()`. */
|
|
305
348
|
export interface BacktestResult {
|
|
306
349
|
symbol?: string;
|
|
@@ -320,12 +363,16 @@ export interface BacktestResult {
|
|
|
320
363
|
|
|
321
364
|
export interface PortfolioSystem extends Omit<BacktestOptions, "equity"> {
|
|
322
365
|
weight?: number;
|
|
366
|
+
maxAllocation?: number;
|
|
367
|
+
maxAllocationPct?: number;
|
|
323
368
|
}
|
|
324
369
|
|
|
325
370
|
export interface PortfolioSystemResult {
|
|
326
371
|
symbol: string;
|
|
327
372
|
weight: number;
|
|
328
373
|
equity: number;
|
|
374
|
+
allocationCapPct?: number;
|
|
375
|
+
allocationCap?: number;
|
|
329
376
|
result: BacktestResult;
|
|
330
377
|
}
|
|
331
378
|
|
|
@@ -340,12 +387,38 @@ export interface WalkForwardWindow {
|
|
|
340
387
|
trainScore: number;
|
|
341
388
|
trainMetrics: BacktestMetrics;
|
|
342
389
|
testMetrics: BacktestMetrics;
|
|
390
|
+
oosTrades: number;
|
|
391
|
+
profitable: boolean;
|
|
392
|
+
stabilityScore: number;
|
|
343
393
|
result: BacktestResult;
|
|
344
394
|
}
|
|
345
395
|
|
|
396
|
+
export interface WalkForwardBestParamsSummary {
|
|
397
|
+
adjacentRepeatRate: number;
|
|
398
|
+
uniqueWinnerCount: number;
|
|
399
|
+
dominant: {
|
|
400
|
+
params: Record<string, unknown>;
|
|
401
|
+
wins: number;
|
|
402
|
+
profitableWindows: number;
|
|
403
|
+
oosTrades: number;
|
|
404
|
+
} | null;
|
|
405
|
+
leaderboard: Array<{
|
|
406
|
+
params: Record<string, unknown>;
|
|
407
|
+
wins: number;
|
|
408
|
+
profitableWindows: number;
|
|
409
|
+
oosTrades: number;
|
|
410
|
+
}>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export interface WalkForwardBestParams extends Array<Record<string, unknown>> {
|
|
414
|
+
winners: Array<Record<string, unknown>>;
|
|
415
|
+
stability: WalkForwardBestParamsSummary;
|
|
416
|
+
}
|
|
417
|
+
|
|
346
418
|
export interface WalkForwardResult extends BacktestResult {
|
|
347
419
|
windows: WalkForwardWindow[];
|
|
348
|
-
bestParams:
|
|
420
|
+
bestParams: WalkForwardBestParams;
|
|
421
|
+
bestParamsSummary: WalkForwardBestParamsSummary;
|
|
349
422
|
}
|
|
350
423
|
|
|
351
424
|
export interface CsvLoadOptions {
|
|
@@ -459,12 +532,14 @@ export interface ArtifactPaths {
|
|
|
459
532
|
* chart-friendly replay frames/events in `replay`.
|
|
460
533
|
*/
|
|
461
534
|
export function backtest(options: BacktestOptions): BacktestResult;
|
|
535
|
+
export function backtestTicks(options: BacktestTickOptions): BacktestResult;
|
|
462
536
|
export function backtestPortfolio(options: {
|
|
463
537
|
systems: PortfolioSystem[];
|
|
464
538
|
equity?: number;
|
|
465
539
|
allocation?: "equal" | "weight";
|
|
466
540
|
collectEqSeries?: boolean;
|
|
467
541
|
collectReplay?: boolean;
|
|
542
|
+
maxDailyLossPct?: number;
|
|
468
543
|
}): PortfolioBacktestResult;
|
|
469
544
|
export function walkForwardOptimize(options: {
|
|
470
545
|
candles: Candle[];
|
|
@@ -473,6 +548,7 @@ export function walkForwardOptimize(options: {
|
|
|
473
548
|
trainBars: number;
|
|
474
549
|
testBars: number;
|
|
475
550
|
stepBars?: number;
|
|
551
|
+
mode?: "rolling" | "anchored";
|
|
476
552
|
scoreBy?: keyof BacktestMetrics;
|
|
477
553
|
backtestOptions?: Omit<BacktestOptions, "candles" | "signal">;
|
|
478
554
|
}): WalkForwardResult;
|