tradelab 0.2.0 → 0.3.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.
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  atr: () => atr,
33
33
  backtest: () => backtest,
34
34
  backtestHistorical: () => backtestHistorical,
35
+ backtestPortfolio: () => backtestPortfolio,
35
36
  bpsOf: () => bpsOf,
36
37
  buildMetrics: () => buildMetrics,
37
38
  cachedCandlesPath: () => cachedCandlesPath,
@@ -61,7 +62,8 @@ __export(index_exports, {
61
62
  saveCandlesToCache: () => saveCandlesToCache,
62
63
  structureState: () => structureState,
63
64
  swingHigh: () => swingHigh,
64
- swingLow: () => swingLow
65
+ swingLow: () => swingLow,
66
+ walkForwardOptimize: () => walkForwardOptimize
65
67
  });
66
68
  module.exports = __toCommonJS(index_exports);
67
69
 
@@ -789,14 +791,37 @@ function inWindowsET(timeMs, windows) {
789
791
  }
790
792
 
791
793
  // src/engine/execution.js
792
- function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market" } = {}) {
794
+ function resolveSlippageBps(kind, slippageBps, slippageByKind) {
795
+ if (Number.isFinite(slippageByKind?.[kind])) {
796
+ return slippageByKind[kind];
797
+ }
793
798
  let effectiveSlippageBps = slippageBps;
794
799
  if (kind === "limit") effectiveSlippageBps *= 0.25;
795
800
  if (kind === "stop") effectiveSlippageBps *= 1.25;
796
- const slippage = effectiveSlippageBps / 1e4 * price;
801
+ return effectiveSlippageBps;
802
+ }
803
+ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}) {
804
+ const model = costs || {};
805
+ const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
806
+ const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
807
+ const effectiveSlippageBps = resolveSlippageBps(
808
+ kind,
809
+ modelSlippageBps,
810
+ model.slippageByKind
811
+ );
812
+ const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
813
+ const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
797
814
  const filledPrice = side === "long" ? price + slippage : price - slippage;
798
- const feePerUnit = feeBps / 1e4 * Math.abs(filledPrice);
799
- return { price: filledPrice, fee: feePerUnit };
815
+ const variableFeePerUnit = (modelFeeBps || 0) / 1e4 * Math.abs(filledPrice) + (Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
816
+ const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
817
+ const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
818
+ const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
819
+ const feeTotal = Math.max(
820
+ Number.isFinite(model.minCommission) ? model.minCommission : 0,
821
+ grossFeeTotal
822
+ );
823
+ const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
824
+ return { price: filledPrice, fee: feePerUnit, feeTotal };
800
825
  }
801
826
  function clampStop(marketPrice, proposedStop, side, oco) {
802
827
  const epsilon = (oco?.clampEpsBps ?? 0.25) / 1e4;
@@ -918,6 +943,7 @@ function mergeOptions(options) {
918
943
  warmupBars: options.warmupBars ?? 200,
919
944
  slippageBps: options.slippageBps ?? 1,
920
945
  feeBps: options.feeBps ?? 0,
946
+ costs: options.costs ?? null,
921
947
  scaleOutAtR: options.scaleOutAtR ?? 1,
922
948
  scaleOutFrac: options.scaleOutFrac ?? 0.5,
923
949
  finalTP_R: options.finalTP_R ?? 3,
@@ -1018,6 +1044,7 @@ function backtest(rawOptions) {
1018
1044
  signal,
1019
1045
  slippageBps,
1020
1046
  feeBps,
1047
+ costs,
1021
1048
  scaleOutAtR,
1022
1049
  scaleOutFrac,
1023
1050
  finalTP_R,
@@ -1087,12 +1114,11 @@ function backtest(rawOptions) {
1087
1114
  });
1088
1115
  }
1089
1116
  }
1090
- function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
1117
+ function closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
1091
1118
  const direction = openPos.side === "long" ? 1 : -1;
1092
1119
  const entryFill = openPos.entryFill;
1093
1120
  const grossPnl = (exitPx - entryFill) * direction * qty;
1094
1121
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
1095
- const exitFeeTotal = exitFeePerUnit * qty;
1096
1122
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
1097
1123
  currentEquity += pnl;
1098
1124
  dayPnl += pnl;
@@ -1145,16 +1171,18 @@ function backtest(rawOptions) {
1145
1171
  function forceExit(reason, bar) {
1146
1172
  if (!open) return;
1147
1173
  const exitSide = open.side === "long" ? "short" : "long";
1148
- const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
1174
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
1149
1175
  slippageBps,
1150
1176
  feeBps,
1151
- kind: "market"
1177
+ kind: "market",
1178
+ qty: open.size,
1179
+ costs
1152
1180
  });
1153
1181
  closeLeg({
1154
1182
  openPos: open,
1155
1183
  qty: open.size,
1156
1184
  exitPx: filled,
1157
- exitFeePerUnit: exitFee,
1185
+ exitFeeTotal,
1158
1186
  time: bar.time,
1159
1187
  reason
1160
1188
  });
@@ -1195,13 +1223,15 @@ function backtest(rawOptions) {
1195
1223
  });
1196
1224
  const size = roundStep2(rawSize, qtyStep);
1197
1225
  if (size < minQty) return false;
1198
- const { price: entryFill, fee: entryFee } = applyFill(
1226
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1199
1227
  entryPrice,
1200
1228
  pending.side,
1201
1229
  {
1202
1230
  slippageBps,
1203
1231
  feeBps,
1204
- kind: fillKind
1232
+ kind: fillKind,
1233
+ qty: size,
1234
+ costs
1205
1235
  }
1206
1236
  );
1207
1237
  open = {
@@ -1215,7 +1245,7 @@ function backtest(rawOptions) {
1215
1245
  size,
1216
1246
  openTime: bar.time,
1217
1247
  entryFill,
1218
- entryFeeTotal: entryFee * size,
1248
+ entryFeeTotal,
1219
1249
  initSize: size,
1220
1250
  baseSize: size,
1221
1251
  _mfeR: 0,
@@ -1311,16 +1341,16 @@ function backtest(rawOptions) {
1311
1341
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1312
1342
  if (cutQty >= minQty && cutQty < open.size) {
1313
1343
  const exitSide2 = open.side === "long" ? "short" : "long";
1314
- const { price: filled, fee: exitFee } = applyFill(
1344
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1315
1345
  bar.close,
1316
1346
  exitSide2,
1317
- { slippageBps, feeBps, kind: "market" }
1347
+ { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1318
1348
  );
1319
1349
  closeLeg({
1320
1350
  openPos: open,
1321
1351
  qty: cutQty,
1322
1352
  exitPx: filled,
1323
- exitFeePerUnit: exitFee,
1353
+ exitFeeTotal,
1324
1354
  time: bar.time,
1325
1355
  reason: "SCALE"
1326
1356
  });
@@ -1340,13 +1370,13 @@ function backtest(rawOptions) {
1340
1370
  const baseSize = open.baseSize || open.initSize;
1341
1371
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1342
1372
  if (addQty >= minQty) {
1343
- const { price: addFill, fee: addFee } = applyFill(
1373
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1344
1374
  triggerPrice,
1345
1375
  open.side,
1346
- { slippageBps, feeBps, kind: "limit" }
1376
+ { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1347
1377
  );
1348
1378
  const newSize = open.size + addQty;
1349
- open.entryFeeTotal += addFee * addQty;
1379
+ open.entryFeeTotal += addFeeTotal;
1350
1380
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
1351
1381
  open.size = newSize;
1352
1382
  open.initSize += addQty;
@@ -1361,18 +1391,20 @@ function backtest(rawOptions) {
1361
1391
  const touched = open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
1362
1392
  if (touched) {
1363
1393
  const exitSide2 = open.side === "long" ? "short" : "long";
1364
- const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide2, {
1365
- slippageBps,
1366
- feeBps,
1367
- kind: "limit"
1368
- });
1369
1394
  const qty = roundStep2(open.size * scaleOutFrac, qtyStep);
1370
1395
  if (qty >= minQty && qty < open.size) {
1396
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
1397
+ slippageBps,
1398
+ feeBps,
1399
+ kind: "limit",
1400
+ qty,
1401
+ costs
1402
+ });
1371
1403
  closeLeg({
1372
1404
  openPos: open,
1373
1405
  qty,
1374
1406
  exitPx: filled,
1375
- exitFeePerUnit: exitFee,
1407
+ exitFeeTotal,
1376
1408
  time: bar.time,
1377
1409
  reason: "SCALE"
1378
1410
  });
@@ -1394,17 +1426,19 @@ function backtest(rawOptions) {
1394
1426
  });
1395
1427
  if (hit) {
1396
1428
  const exitKind = hit === "TP" ? "limit" : "stop";
1397
- const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
1429
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
1398
1430
  slippageBps,
1399
1431
  feeBps,
1400
- kind: exitKind
1432
+ kind: exitKind,
1433
+ qty: open.size,
1434
+ costs
1401
1435
  });
1402
1436
  const localCooldown = open._cooldownBars || 0;
1403
1437
  closeLeg({
1404
1438
  openPos: open,
1405
1439
  qty: open.size,
1406
1440
  exitPx: filled,
1407
- exitFeePerUnit: exitFee,
1441
+ exitFeeTotal,
1408
1442
  time: bar.time,
1409
1443
  reason: hit
1410
1444
  });
@@ -1542,6 +1576,232 @@ function backtest(rawOptions) {
1542
1576
  };
1543
1577
  }
1544
1578
 
1579
+ // src/engine/portfolio.js
1580
+ function asWeight(value) {
1581
+ return Number.isFinite(value) && value > 0 ? value : 0;
1582
+ }
1583
+ function combineEquitySeries(systemRuns, totalEquity) {
1584
+ const timeline = /* @__PURE__ */ new Set();
1585
+ for (const run of systemRuns) {
1586
+ for (const point of run.result.eqSeries || []) {
1587
+ timeline.add(point.time);
1588
+ }
1589
+ }
1590
+ const times = [...timeline].sort((left, right) => left - right);
1591
+ if (!times.length) {
1592
+ return [{ time: 0, timestamp: 0, equity: totalEquity }];
1593
+ }
1594
+ const states = systemRuns.map((run) => ({
1595
+ points: run.result.eqSeries || [],
1596
+ index: 0,
1597
+ lastEquity: run.allocationEquity
1598
+ }));
1599
+ return times.map((time) => {
1600
+ let equity = 0;
1601
+ states.forEach((state) => {
1602
+ while (state.index < state.points.length && state.points[state.index].time <= time) {
1603
+ state.lastEquity = state.points[state.index].equity;
1604
+ state.index += 1;
1605
+ }
1606
+ equity += state.lastEquity;
1607
+ });
1608
+ return { time, timestamp: time, equity };
1609
+ });
1610
+ }
1611
+ function combineReplay(systemRuns, eqSeries, collectReplay) {
1612
+ if (!collectReplay) {
1613
+ return { frames: [], events: [] };
1614
+ }
1615
+ const events = systemRuns.flatMap(
1616
+ (run) => (run.result.replay?.events || []).map((event) => ({
1617
+ ...event,
1618
+ symbol: event.symbol || run.symbol
1619
+ }))
1620
+ ).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
1621
+ const frames = eqSeries.map((point) => ({
1622
+ t: new Date(point.time).toISOString(),
1623
+ price: 0,
1624
+ equity: point.equity,
1625
+ posSide: null,
1626
+ posSize: 0
1627
+ }));
1628
+ return { frames, events };
1629
+ }
1630
+ function backtestPortfolio({
1631
+ systems = [],
1632
+ equity = 1e4,
1633
+ allocation = "equal",
1634
+ collectEqSeries = true,
1635
+ collectReplay = false
1636
+ } = {}) {
1637
+ if (!Array.isArray(systems) || systems.length === 0) {
1638
+ throw new Error("backtestPortfolio() requires a non-empty systems array");
1639
+ }
1640
+ const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
1641
+ const totalWeight = weights.reduce((sum2, weight) => sum2 + weight, 0);
1642
+ if (!(totalWeight > 0)) {
1643
+ throw new Error("backtestPortfolio() requires positive allocation weights");
1644
+ }
1645
+ const systemRuns = systems.map((system, index) => {
1646
+ const allocationEquity = equity * (weights[index] / totalWeight);
1647
+ const result = backtest({
1648
+ ...system,
1649
+ equity: allocationEquity,
1650
+ collectEqSeries,
1651
+ collectReplay
1652
+ });
1653
+ return {
1654
+ symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
1655
+ weight: weights[index],
1656
+ allocationEquity,
1657
+ result
1658
+ };
1659
+ });
1660
+ const trades = systemRuns.flatMap(
1661
+ (run) => run.result.trades.map((trade) => ({
1662
+ ...trade,
1663
+ symbol: trade.symbol || run.symbol
1664
+ }))
1665
+ ).sort((left, right) => left.exit.time - right.exit.time);
1666
+ const positions = systemRuns.flatMap(
1667
+ (run) => run.result.positions.map((trade) => ({
1668
+ ...trade,
1669
+ symbol: trade.symbol || run.symbol
1670
+ }))
1671
+ ).sort((left, right) => left.exit.time - right.exit.time);
1672
+ const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
1673
+ const replay = combineReplay(systemRuns, eqSeries, collectReplay);
1674
+ const allCandles = systems.flatMap((system) => system.candles || []);
1675
+ const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
1676
+ const metrics = buildMetrics({
1677
+ closed: trades,
1678
+ equityStart: equity,
1679
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
1680
+ candles: orderedCandles,
1681
+ estBarMs: estimateBarMs(orderedCandles),
1682
+ eqSeries
1683
+ });
1684
+ return {
1685
+ symbol: "PORTFOLIO",
1686
+ interval: void 0,
1687
+ range: void 0,
1688
+ trades,
1689
+ positions,
1690
+ metrics,
1691
+ eqSeries,
1692
+ replay,
1693
+ systems: systemRuns.map((run) => ({
1694
+ symbol: run.symbol,
1695
+ weight: run.weight / totalWeight,
1696
+ equity: run.allocationEquity,
1697
+ result: run.result
1698
+ }))
1699
+ };
1700
+ }
1701
+
1702
+ // src/engine/walkForward.js
1703
+ function scoreOf(metrics, scoreBy) {
1704
+ const value = metrics?.[scoreBy];
1705
+ return Number.isFinite(value) ? value : -Infinity;
1706
+ }
1707
+ function stitchEquitySeries(target, source) {
1708
+ if (!source?.length) return;
1709
+ if (!target.length) {
1710
+ target.push(...source);
1711
+ return;
1712
+ }
1713
+ const lastTime = target[target.length - 1].time;
1714
+ const nextPoints = source.filter((point) => point.time > lastTime);
1715
+ target.push(...nextPoints);
1716
+ }
1717
+ function walkForwardOptimize({
1718
+ candles = [],
1719
+ signalFactory,
1720
+ parameterSets = [],
1721
+ trainBars,
1722
+ testBars,
1723
+ stepBars = testBars,
1724
+ scoreBy = "profitFactor",
1725
+ backtestOptions = {}
1726
+ } = {}) {
1727
+ if (!Array.isArray(candles) || candles.length === 0) {
1728
+ throw new Error("walkForwardOptimize() requires a non-empty candles array");
1729
+ }
1730
+ if (typeof signalFactory !== "function") {
1731
+ throw new Error("walkForwardOptimize() requires a signalFactory function");
1732
+ }
1733
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
1734
+ throw new Error("walkForwardOptimize() requires parameterSets");
1735
+ }
1736
+ if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
1737
+ throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
1738
+ }
1739
+ const windows = [];
1740
+ const allTrades = [];
1741
+ const allPositions = [];
1742
+ const eqSeries = [];
1743
+ let rollingEquity = backtestOptions.equity ?? 1e4;
1744
+ for (let start = 0; start + trainBars + testBars <= candles.length; start += stepBars) {
1745
+ const trainSlice = candles.slice(start, start + trainBars);
1746
+ const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
1747
+ let best = null;
1748
+ for (const params of parameterSets) {
1749
+ const trainResult = backtest({
1750
+ ...backtestOptions,
1751
+ candles: trainSlice,
1752
+ equity: rollingEquity,
1753
+ signal: signalFactory(params)
1754
+ });
1755
+ const score = scoreOf(trainResult.metrics, scoreBy);
1756
+ if (!best || score > best.score) {
1757
+ best = { params, score, metrics: trainResult.metrics };
1758
+ }
1759
+ }
1760
+ const testResult = backtest({
1761
+ ...backtestOptions,
1762
+ candles: testSlice,
1763
+ equity: rollingEquity,
1764
+ signal: signalFactory(best.params)
1765
+ });
1766
+ rollingEquity = testResult.metrics.finalEquity;
1767
+ allTrades.push(...testResult.trades);
1768
+ allPositions.push(...testResult.positions);
1769
+ stitchEquitySeries(eqSeries, testResult.eqSeries);
1770
+ windows.push({
1771
+ train: {
1772
+ start: trainSlice[0]?.time ?? null,
1773
+ end: trainSlice[trainSlice.length - 1]?.time ?? null
1774
+ },
1775
+ test: {
1776
+ start: testSlice[0]?.time ?? null,
1777
+ end: testSlice[testSlice.length - 1]?.time ?? null
1778
+ },
1779
+ bestParams: best.params,
1780
+ trainScore: best.score,
1781
+ trainMetrics: best.metrics,
1782
+ testMetrics: testResult.metrics,
1783
+ result: testResult
1784
+ });
1785
+ }
1786
+ const metrics = buildMetrics({
1787
+ closed: allTrades,
1788
+ equityStart: backtestOptions.equity ?? 1e4,
1789
+ equityFinal: rollingEquity,
1790
+ candles,
1791
+ estBarMs: estimateBarMs(candles),
1792
+ eqSeries
1793
+ });
1794
+ return {
1795
+ windows,
1796
+ trades: allTrades,
1797
+ positions: allPositions,
1798
+ metrics,
1799
+ eqSeries,
1800
+ replay: { frames: [], events: [] },
1801
+ bestParams: windows.map((window) => window.bestParams)
1802
+ };
1803
+ }
1804
+
1545
1805
  // src/data/index.js
1546
1806
  var import_path2 = __toESM(require("path"), 1);
1547
1807
 
@@ -2261,6 +2521,7 @@ function exportBacktestArtifacts({
2261
2521
  atr,
2262
2522
  backtest,
2263
2523
  backtestHistorical,
2524
+ backtestPortfolio,
2264
2525
  bpsOf,
2265
2526
  buildMetrics,
2266
2527
  cachedCandlesPath,
@@ -2290,5 +2551,6 @@ function exportBacktestArtifacts({
2290
2551
  saveCandlesToCache,
2291
2552
  structureState,
2292
2553
  swingHigh,
2293
- swingLow
2554
+ swingLow,
2555
+ walkForwardOptimize
2294
2556
  });
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "tradelab",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
7
7
  "module": "./src/index.js",
8
8
  "types": "./types/index.d.ts",
9
+ "bin": {
10
+ "tradelab": "./bin/tradelab.js"
11
+ },
9
12
  "license": "MIT",
10
13
  "repository": {
11
14
  "type": "git",
@@ -33,6 +36,7 @@
33
36
  "./package.json": "./package.json"
34
37
  },
35
38
  "files": [
39
+ "bin",
36
40
  "dist",
37
41
  "src",
38
42
  "types",
@@ -58,6 +58,7 @@ function mergeOptions(options) {
58
58
  warmupBars: options.warmupBars ?? 200,
59
59
  slippageBps: options.slippageBps ?? 1,
60
60
  feeBps: options.feeBps ?? 0,
61
+ costs: options.costs ?? null,
61
62
  scaleOutAtR: options.scaleOutAtR ?? 1,
62
63
  scaleOutFrac: options.scaleOutFrac ?? 0.5,
63
64
  finalTP_R: options.finalTP_R ?? 3,
@@ -179,6 +180,7 @@ export function backtest(rawOptions) {
179
180
  signal,
180
181
  slippageBps,
181
182
  feeBps,
183
+ costs,
182
184
  scaleOutAtR,
183
185
  scaleOutFrac,
184
186
  finalTP_R,
@@ -258,13 +260,12 @@ export function backtest(rawOptions) {
258
260
  }
259
261
  }
260
262
 
261
- function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
263
+ function closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
262
264
  const direction = openPos.side === "long" ? 1 : -1;
263
265
  const entryFill = openPos.entryFill;
264
266
  const grossPnl = (exitPx - entryFill) * direction * qty;
265
267
  const entryFeePortion =
266
268
  (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
267
- const exitFeeTotal = exitFeePerUnit * qty;
268
269
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
269
270
 
270
271
  currentEquity += pnl;
@@ -348,17 +349,19 @@ export function backtest(rawOptions) {
348
349
  if (!open) return;
349
350
 
350
351
  const exitSide = open.side === "long" ? "short" : "long";
351
- const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
352
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
352
353
  slippageBps,
353
354
  feeBps,
354
355
  kind: "market",
356
+ qty: open.size,
357
+ costs,
355
358
  });
356
359
 
357
360
  closeLeg({
358
361
  openPos: open,
359
362
  qty: open.size,
360
363
  exitPx: filled,
361
- exitFeePerUnit: exitFee,
364
+ exitFeeTotal,
362
365
  time: bar.time,
363
366
  reason,
364
367
  });
@@ -421,13 +424,15 @@ export function backtest(rawOptions) {
421
424
  const size = roundStep(rawSize, qtyStep);
422
425
  if (size < minQty) return false;
423
426
 
424
- const { price: entryFill, fee: entryFee } = applyFill(
427
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
425
428
  entryPrice,
426
429
  pending.side,
427
430
  {
428
431
  slippageBps,
429
432
  feeBps,
430
433
  kind: fillKind,
434
+ qty: size,
435
+ costs,
431
436
  }
432
437
  );
433
438
 
@@ -442,7 +447,7 @@ export function backtest(rawOptions) {
442
447
  size,
443
448
  openTime: bar.time,
444
449
  entryFill,
445
- entryFeeTotal: entryFee * size,
450
+ entryFeeTotal,
446
451
  initSize: size,
447
452
  baseSize: size,
448
453
  _mfeR: 0,
@@ -605,16 +610,16 @@ export function backtest(rawOptions) {
605
610
  const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
606
611
  if (cutQty >= minQty && cutQty < open.size) {
607
612
  const exitSide = open.side === "long" ? "short" : "long";
608
- const { price: filled, fee: exitFee } = applyFill(
613
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(
609
614
  bar.close,
610
615
  exitSide,
611
- { slippageBps, feeBps, kind: "market" }
616
+ { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
612
617
  );
613
618
  closeLeg({
614
619
  openPos: open,
615
620
  qty: cutQty,
616
621
  exitPx: filled,
617
- exitFeePerUnit: exitFee,
622
+ exitFeeTotal,
618
623
  time: bar.time,
619
624
  reason: "SCALE",
620
625
  });
@@ -649,13 +654,13 @@ export function backtest(rawOptions) {
649
654
  const baseSize = open.baseSize || open.initSize;
650
655
  const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
651
656
  if (addQty >= minQty) {
652
- const { price: addFill, fee: addFee } = applyFill(
657
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
653
658
  triggerPrice,
654
659
  open.side,
655
- { slippageBps, feeBps, kind: "limit" }
660
+ { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
656
661
  );
657
662
  const newSize = open.size + addQty;
658
- open.entryFeeTotal += addFee * addQty;
663
+ open.entryFeeTotal += addFeeTotal;
659
664
  open.entryFill =
660
665
  (open.entryFill * open.size + addFill * addQty) / newSize;
661
666
  open.size = newSize;
@@ -683,18 +688,20 @@ export function backtest(rawOptions) {
683
688
 
684
689
  if (touched) {
685
690
  const exitSide = open.side === "long" ? "short" : "long";
686
- const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide, {
687
- slippageBps,
688
- feeBps,
689
- kind: "limit",
690
- });
691
691
  const qty = roundStep(open.size * scaleOutFrac, qtyStep);
692
692
  if (qty >= minQty && qty < open.size) {
693
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide, {
694
+ slippageBps,
695
+ feeBps,
696
+ kind: "limit",
697
+ qty,
698
+ costs,
699
+ });
693
700
  closeLeg({
694
701
  openPos: open,
695
702
  qty,
696
703
  exitPx: filled,
697
- exitFeePerUnit: exitFee,
704
+ exitFeeTotal,
698
705
  time: bar.time,
699
706
  reason: "SCALE",
700
707
  });
@@ -721,17 +728,19 @@ export function backtest(rawOptions) {
721
728
 
722
729
  if (hit) {
723
730
  const exitKind = hit === "TP" ? "limit" : "stop";
724
- const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
731
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
725
732
  slippageBps,
726
733
  feeBps,
727
734
  kind: exitKind,
735
+ qty: open.size,
736
+ costs,
728
737
  });
729
738
  const localCooldown = open._cooldownBars || 0;
730
739
  closeLeg({
731
740
  openPos: open,
732
741
  qty: open.size,
733
742
  exitPx: filled,
734
- exitFeePerUnit: exitFee,
743
+ exitFeeTotal,
735
744
  time: bar.time,
736
745
  reason: hit,
737
746
  });