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.
- package/README.md +20 -7
- package/bin/tradelab.js +384 -0
- package/dist/cjs/data.cjs +59 -27
- package/dist/cjs/index.cjs +291 -29
- package/package.json +5 -1
- package/src/engine/backtest.js +29 -20
- package/src/engine/execution.js +44 -7
- package/src/engine/portfolio.js +160 -0
- package/src/engine/walkForward.js +126 -0
- package/src/index.js +2 -0
- package/types/index.d.ts +58 -0
package/dist/cjs/index.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
799
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 +=
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/engine/backtest.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 +=
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
743
|
+
exitFeeTotal,
|
|
735
744
|
time: bar.time,
|
|
736
745
|
reason: hit,
|
|
737
746
|
});
|