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.
@@ -142,24 +142,88 @@ export function buildMetrics({
142
142
  estBarMs,
143
143
  eqSeries,
144
144
  }) {
145
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
146
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
147
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
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
- const tradeRs = completedTrades.map(tradeRMultiple);
150
- const totalR = sum(tradeRs);
151
- const avgR = mean(tradeRs);
170
+ let peakEquity = equityStart;
171
+ let currentEquity = equityStart;
172
+ let maxDrawdown = 0;
152
173
 
153
- const labels = completedTrades.map((trade) =>
154
- trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
155
- );
156
- const { maxWin, maxLoss } = streaks(labels);
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 tradePnls = completedTrades.map((trade) => trade.exit.pnl);
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: longTrades.length,
265
- winRate: longTrades.length
266
- ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length
290
+ trades: longTradesCount,
291
+ winRate: longTradesCount
292
+ ? longTradeWins / longTradesCount
267
293
  : 0,
268
- avgPnL: mean(longPnls),
294
+ avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
269
295
  avgR: mean(longRs),
270
296
  },
271
297
  short: {
272
- trades: shortTrades.length,
273
- winRate: shortTrades.length
274
- ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length
298
+ trades: shortTradesCount,
299
+ winRate: shortTradesCount
300
+ ? shortTradeWins / shortTradesCount
275
301
  : 0,
276
- avgPnL: mean(shortPnls),
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 ? winningTrades.length / completedTrades.length : 0,
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
- ? winningTrades.length / completedTrades.length
332
+ ? winningTradeCount / completedTrades.length
307
333
  : 0,
308
- winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
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: Array<Record<string, unknown>>;
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;