tradelab 0.2.0 → 0.4.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.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+
5
+ import {
6
+ backtest,
7
+ backtestPortfolio,
8
+ ema,
9
+ exportBacktestArtifacts,
10
+ exportMetricsJSON,
11
+ getHistoricalCandles,
12
+ loadCandlesFromCSV,
13
+ saveCandlesToCache,
14
+ walkForwardOptimize,
15
+ } from "../src/index.js";
16
+
17
+ function parseArgs(argv) {
18
+ const args = { _: [] };
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const token = argv[index];
21
+ if (!token.startsWith("--")) {
22
+ args._.push(token);
23
+ continue;
24
+ }
25
+
26
+ const key = token.slice(2);
27
+ const next = argv[index + 1];
28
+ if (next && !next.startsWith("--")) {
29
+ args[key] = next;
30
+ index += 1;
31
+ } else {
32
+ args[key] = true;
33
+ }
34
+ }
35
+ return args;
36
+ }
37
+
38
+ function toNumber(value, fallback) {
39
+ const numeric = Number(value);
40
+ return Number.isFinite(numeric) ? numeric : fallback;
41
+ }
42
+
43
+ function toList(value, fallback) {
44
+ if (!value) return fallback;
45
+ return String(value)
46
+ .split(",")
47
+ .map((item) => Number(item.trim()))
48
+ .filter((item) => Number.isFinite(item));
49
+ }
50
+
51
+ function createEmaCrossSignal({
52
+ fast = 10,
53
+ slow = 30,
54
+ rr = 2,
55
+ stopLookback = 15,
56
+ } = {}) {
57
+ return ({ candles }) => {
58
+ if (candles.length < Math.max(fast, slow) + 2) return null;
59
+
60
+ const closes = candles.map((bar) => bar.close);
61
+ const fastLine = ema(closes, fast);
62
+ const slowLine = ema(closes, slow);
63
+ const last = closes.length - 1;
64
+
65
+ if (
66
+ fastLine[last - 1] <= slowLine[last - 1] &&
67
+ fastLine[last] > slowLine[last]
68
+ ) {
69
+ const entry = candles[last].close;
70
+ const stop = Math.min(
71
+ ...candles.slice(-stopLookback).map((bar) => bar.low)
72
+ );
73
+ if (entry > stop) {
74
+ return { side: "long", entry, stop, rr };
75
+ }
76
+ }
77
+
78
+ if (
79
+ fastLine[last - 1] >= slowLine[last - 1] &&
80
+ fastLine[last] < slowLine[last]
81
+ ) {
82
+ const entry = candles[last].close;
83
+ const stop = Math.max(
84
+ ...candles.slice(-stopLookback).map((bar) => bar.high)
85
+ );
86
+ if (entry < stop) {
87
+ return { side: "short", entry, stop, rr };
88
+ }
89
+ }
90
+
91
+ return null;
92
+ };
93
+ }
94
+
95
+ function createBuyHoldSignal({ holdBars = 5, stopPct = 0.05 } = {}) {
96
+ let entered = false;
97
+
98
+ return ({ bar }) => {
99
+ if (entered) return null;
100
+ entered = true;
101
+ return {
102
+ side: "long",
103
+ entry: bar.close,
104
+ stop: bar.close * (1 - stopPct),
105
+ rr: 100,
106
+ _maxBarsInTrade: holdBars,
107
+ };
108
+ };
109
+ }
110
+
111
+ async function loadStrategy(strategyArg, args) {
112
+ if (!strategyArg || strategyArg === "ema-cross") {
113
+ return createEmaCrossSignal({
114
+ fast: toNumber(args.fast, 10),
115
+ slow: toNumber(args.slow, 30),
116
+ rr: toNumber(args.rr, 2),
117
+ stopLookback: toNumber(args.stopLookback, 15),
118
+ });
119
+ }
120
+
121
+ if (strategyArg === "buy-hold") {
122
+ return createBuyHoldSignal({
123
+ holdBars: toNumber(args.holdBars, 5),
124
+ stopPct: toNumber(args.stopPct, 0.05),
125
+ });
126
+ }
127
+
128
+ const resolved = path.resolve(process.cwd(), strategyArg);
129
+ const module = await import(pathToFileURL(resolved).href);
130
+ if (typeof module.default === "function") return module.default(args);
131
+ if (typeof module.createSignal === "function") return module.createSignal(args);
132
+ if (typeof module.signal === "function") return module.signal;
133
+ throw new Error(`Strategy module "${strategyArg}" must export default, createSignal, or signal`);
134
+ }
135
+
136
+ function printHelp() {
137
+ console.log(`tradelab
138
+
139
+ Commands:
140
+ backtest Run a one-off backtest from Yahoo or CSV data
141
+ portfolio Run multiple CSV datasets as an equal-weight portfolio
142
+ walk-forward Run rolling train/test optimization with the built-in ema-cross strategy
143
+ prefetch Download Yahoo candles into the local cache
144
+ import-csv Normalize a CSV and save it into the local cache
145
+
146
+ Examples:
147
+ tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
148
+ tradelab backtest --source csv --csvPath ./data/btc.csv --strategy buy-hold --holdBars 3
149
+ tradelab walk-forward --source csv --csvPath ./data/spy.csv --trainBars 120 --testBars 40
150
+ `);
151
+ }
152
+
153
+ async function commandBacktest(args) {
154
+ const candles = await getHistoricalCandles({
155
+ source: args.source || (args.csvPath ? "csv" : "yahoo"),
156
+ symbol: args.symbol,
157
+ interval: args.interval || "1d",
158
+ period: args.period || "1y",
159
+ csvPath: args.csvPath,
160
+ cache: args.cache !== "false",
161
+ });
162
+ const signal = await loadStrategy(args.strategy, args);
163
+ const result = backtest({
164
+ candles,
165
+ symbol: args.symbol || "DATA",
166
+ interval: args.interval || "1d",
167
+ range: args.period || "custom",
168
+ equity: toNumber(args.equity, 10_000),
169
+ riskPct: toNumber(args.riskPct, 1),
170
+ warmupBars: toNumber(args.warmupBars, 20),
171
+ flattenAtClose: args.flattenAtClose === true || args.flattenAtClose === "true",
172
+ signal,
173
+ });
174
+
175
+ const outputs = exportBacktestArtifacts({
176
+ result,
177
+ outDir: args.outDir || "output",
178
+ });
179
+
180
+ console.log(
181
+ JSON.stringify(
182
+ {
183
+ symbol: result.symbol,
184
+ trades: result.metrics.trades,
185
+ winRate: result.metrics.winRate,
186
+ profitFactor: result.metrics.profitFactor,
187
+ finalEquity: result.metrics.finalEquity,
188
+ outputs,
189
+ },
190
+ null,
191
+ 2
192
+ )
193
+ );
194
+ }
195
+
196
+ function parsePortfolioInputs(args) {
197
+ const csvPaths = String(args.csvPaths || "")
198
+ .split(",")
199
+ .map((value) => value.trim())
200
+ .filter(Boolean);
201
+ const symbols = String(args.symbols || "")
202
+ .split(",")
203
+ .map((value) => value.trim())
204
+ .filter(Boolean);
205
+
206
+ return csvPaths.map((csvPath, index) => ({
207
+ symbol: symbols[index] || `asset-${index + 1}`,
208
+ candles: loadCandlesFromCSV(csvPath),
209
+ }));
210
+ }
211
+
212
+ async function commandPortfolio(args) {
213
+ const baseSystems = parsePortfolioInputs(args);
214
+ const systems = await Promise.all(
215
+ baseSystems.map(async (system) => ({
216
+ ...system,
217
+ signal: await loadStrategy(args.strategy || "buy-hold", args),
218
+ warmupBars: toNumber(args.warmupBars, 1),
219
+ flattenAtClose: false,
220
+ }))
221
+ );
222
+
223
+ const result = backtestPortfolio({
224
+ systems,
225
+ equity: toNumber(args.equity, 10_000),
226
+ collectReplay: false,
227
+ collectEqSeries: true,
228
+ });
229
+ const metricsPath = exportMetricsJSON({
230
+ result,
231
+ outDir: args.outDir || "output",
232
+ symbol: "PORTFOLIO",
233
+ interval: args.interval || "mixed",
234
+ range: args.period || "custom",
235
+ });
236
+
237
+ console.log(
238
+ JSON.stringify(
239
+ {
240
+ systems: result.systems.length,
241
+ positions: result.positions.length,
242
+ finalEquity: result.metrics.finalEquity,
243
+ metricsPath,
244
+ },
245
+ null,
246
+ 2
247
+ )
248
+ );
249
+ }
250
+
251
+ async function commandWalkForward(args) {
252
+ const candles = await getHistoricalCandles({
253
+ source: args.source || (args.csvPath ? "csv" : "yahoo"),
254
+ symbol: args.symbol,
255
+ interval: args.interval || "1d",
256
+ period: args.period || "1y",
257
+ csvPath: args.csvPath,
258
+ cache: args.cache !== "false",
259
+ });
260
+ const fasts = toList(args.fasts, [8, 10, 12]);
261
+ const slows = toList(args.slows, [20, 30, 40]);
262
+ const rrs = toList(args.rrs, [1.5, 2, 3]);
263
+ const parameterSets = [];
264
+
265
+ for (const fast of fasts) {
266
+ for (const slow of slows) {
267
+ if (fast >= slow) continue;
268
+ for (const rr of rrs) {
269
+ parameterSets.push({ fast, slow, rr });
270
+ }
271
+ }
272
+ }
273
+
274
+ const result = walkForwardOptimize({
275
+ candles,
276
+ parameterSets,
277
+ trainBars: toNumber(args.trainBars, 120),
278
+ testBars: toNumber(args.testBars, 40),
279
+ stepBars: toNumber(args.stepBars, toNumber(args.testBars, 40)),
280
+ scoreBy: args.scoreBy || "profitFactor",
281
+ backtestOptions: {
282
+ symbol: args.symbol || "DATA",
283
+ interval: args.interval || "1d",
284
+ range: args.period || "custom",
285
+ equity: toNumber(args.equity, 10_000),
286
+ riskPct: toNumber(args.riskPct, 1),
287
+ warmupBars: toNumber(args.warmupBars, 20),
288
+ },
289
+ signalFactory(params) {
290
+ return createEmaCrossSignal({
291
+ fast: params.fast,
292
+ slow: params.slow,
293
+ rr: params.rr,
294
+ stopLookback: toNumber(args.stopLookback, 15),
295
+ });
296
+ },
297
+ });
298
+
299
+ const metricsPath = exportMetricsJSON({
300
+ result,
301
+ outDir: args.outDir || "output",
302
+ symbol: args.symbol || "DATA",
303
+ interval: args.interval || "1d",
304
+ range: `${args.trainBars || 120}-${args.testBars || 40}`,
305
+ });
306
+
307
+ console.log(
308
+ JSON.stringify(
309
+ {
310
+ windows: result.windows.length,
311
+ positions: result.positions.length,
312
+ finalEquity: result.metrics.finalEquity,
313
+ metricsPath,
314
+ },
315
+ null,
316
+ 2
317
+ )
318
+ );
319
+ }
320
+
321
+ async function commandPrefetch(args) {
322
+ const candles = await getHistoricalCandles({
323
+ source: "yahoo",
324
+ symbol: args.symbol || "SPY",
325
+ interval: args.interval || "1d",
326
+ period: args.period || "1y",
327
+ cache: false,
328
+ });
329
+ const outputPath = saveCandlesToCache(candles, {
330
+ symbol: args.symbol || "SPY",
331
+ interval: args.interval || "1d",
332
+ period: args.period || "1y",
333
+ outDir: args.outDir || "output/data",
334
+ source: "yahoo",
335
+ });
336
+ console.log(`Saved ${candles.length} candles to ${outputPath}`);
337
+ }
338
+
339
+ async function commandImportCsv(args) {
340
+ const csvPath = args.csvPath || args._[1];
341
+ if (!csvPath) {
342
+ throw new Error("import-csv requires --csvPath or a positional CSV file path");
343
+ }
344
+
345
+ const candles = loadCandlesFromCSV(csvPath, {});
346
+ const outputPath = saveCandlesToCache(candles, {
347
+ symbol: args.symbol || "DATA",
348
+ interval: args.interval || "1d",
349
+ period: args.period || "custom",
350
+ outDir: args.outDir || "output/data",
351
+ source: "csv",
352
+ });
353
+ console.log(`Saved ${candles.length} candles to ${outputPath}`);
354
+ }
355
+
356
+ const commands = {
357
+ backtest: commandBacktest,
358
+ portfolio: commandPortfolio,
359
+ "walk-forward": commandWalkForward,
360
+ prefetch: commandPrefetch,
361
+ "import-csv": commandImportCsv,
362
+ };
363
+
364
+ async function main() {
365
+ const args = parseArgs(process.argv.slice(2));
366
+ const command = args._[0];
367
+
368
+ if (!command || command === "help" || args.help) {
369
+ printHelp();
370
+ return;
371
+ }
372
+
373
+ const handler = commands[command];
374
+ if (!handler) {
375
+ throw new Error(`Unknown command "${command}". Run "tradelab help".`);
376
+ }
377
+
378
+ await handler(args);
379
+ }
380
+
381
+ main().catch((error) => {
382
+ console.error(error.message);
383
+ process.exit(1);
384
+ });
package/dist/cjs/data.cjs CHANGED
@@ -653,14 +653,37 @@ function minutesET(timeMs) {
653
653
  }
654
654
 
655
655
  // src/engine/execution.js
656
- function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market" } = {}) {
656
+ function resolveSlippageBps(kind, slippageBps, slippageByKind) {
657
+ if (Number.isFinite(slippageByKind?.[kind])) {
658
+ return slippageByKind[kind];
659
+ }
657
660
  let effectiveSlippageBps = slippageBps;
658
661
  if (kind === "limit") effectiveSlippageBps *= 0.25;
659
662
  if (kind === "stop") effectiveSlippageBps *= 1.25;
660
- const slippage = effectiveSlippageBps / 1e4 * price;
663
+ return effectiveSlippageBps;
664
+ }
665
+ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}) {
666
+ const model = costs || {};
667
+ const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
668
+ const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
669
+ const effectiveSlippageBps = resolveSlippageBps(
670
+ kind,
671
+ modelSlippageBps,
672
+ model.slippageByKind
673
+ );
674
+ const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
675
+ const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
661
676
  const filledPrice = side === "long" ? price + slippage : price - slippage;
662
- const feePerUnit = feeBps / 1e4 * Math.abs(filledPrice);
663
- return { price: filledPrice, fee: feePerUnit };
677
+ const variableFeePerUnit = (modelFeeBps || 0) / 1e4 * Math.abs(filledPrice) + (Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
678
+ const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
679
+ const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
680
+ const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
681
+ const feeTotal = Math.max(
682
+ Number.isFinite(model.minCommission) ? model.minCommission : 0,
683
+ grossFeeTotal
684
+ );
685
+ const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
686
+ return { price: filledPrice, fee: feePerUnit, feeTotal };
664
687
  }
665
688
  function clampStop(marketPrice, proposedStop, side, oco) {
666
689
  const epsilon = (oco?.clampEpsBps ?? 0.25) / 1e4;
@@ -782,6 +805,7 @@ function mergeOptions(options) {
782
805
  warmupBars: options.warmupBars ?? 200,
783
806
  slippageBps: options.slippageBps ?? 1,
784
807
  feeBps: options.feeBps ?? 0,
808
+ costs: options.costs ?? null,
785
809
  scaleOutAtR: options.scaleOutAtR ?? 1,
786
810
  scaleOutFrac: options.scaleOutFrac ?? 0.5,
787
811
  finalTP_R: options.finalTP_R ?? 3,
@@ -882,6 +906,7 @@ function backtest(rawOptions) {
882
906
  signal,
883
907
  slippageBps,
884
908
  feeBps,
909
+ costs,
885
910
  scaleOutAtR,
886
911
  scaleOutFrac,
887
912
  finalTP_R,
@@ -951,12 +976,11 @@ function backtest(rawOptions) {
951
976
  });
952
977
  }
953
978
  }
954
- function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
979
+ function closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
955
980
  const direction = openPos.side === "long" ? 1 : -1;
956
981
  const entryFill = openPos.entryFill;
957
982
  const grossPnl = (exitPx - entryFill) * direction * qty;
958
983
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
959
- const exitFeeTotal = exitFeePerUnit * qty;
960
984
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
961
985
  currentEquity += pnl;
962
986
  dayPnl += pnl;
@@ -1009,16 +1033,18 @@ function backtest(rawOptions) {
1009
1033
  function forceExit(reason, bar) {
1010
1034
  if (!open) return;
1011
1035
  const exitSide = open.side === "long" ? "short" : "long";
1012
- const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
1036
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
1013
1037
  slippageBps,
1014
1038
  feeBps,
1015
- kind: "market"
1039
+ kind: "market",
1040
+ qty: open.size,
1041
+ costs
1016
1042
  });
1017
1043
  closeLeg({
1018
1044
  openPos: open,
1019
1045
  qty: open.size,
1020
1046
  exitPx: filled,
1021
- exitFeePerUnit: exitFee,
1047
+ exitFeeTotal,
1022
1048
  time: bar.time,
1023
1049
  reason
1024
1050
  });
@@ -1059,13 +1085,15 @@ function backtest(rawOptions) {
1059
1085
  });
1060
1086
  const size = roundStep2(rawSize, qtyStep);
1061
1087
  if (size < minQty) return false;
1062
- const { price: entryFill, fee: entryFee } = applyFill(
1088
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1063
1089
  entryPrice,
1064
1090
  pending.side,
1065
1091
  {
1066
1092
  slippageBps,
1067
1093
  feeBps,
1068
- kind: fillKind
1094
+ kind: fillKind,
1095
+ qty: size,
1096
+ costs
1069
1097
  }
1070
1098
  );
1071
1099
  open = {
@@ -1079,7 +1107,7 @@ function backtest(rawOptions) {
1079
1107
  size,
1080
1108
  openTime: bar.time,
1081
1109
  entryFill,
1082
- entryFeeTotal: entryFee * size,
1110
+ entryFeeTotal,
1083
1111
  initSize: size,
1084
1112
  baseSize: size,
1085
1113
  _mfeR: 0,
@@ -1175,16 +1203,16 @@ function backtest(rawOptions) {
1175
1203
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1176
1204
  if (cutQty >= minQty && cutQty < open.size) {
1177
1205
  const exitSide2 = open.side === "long" ? "short" : "long";
1178
- const { price: filled, fee: exitFee } = applyFill(
1206
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1179
1207
  bar.close,
1180
1208
  exitSide2,
1181
- { slippageBps, feeBps, kind: "market" }
1209
+ { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1182
1210
  );
1183
1211
  closeLeg({
1184
1212
  openPos: open,
1185
1213
  qty: cutQty,
1186
1214
  exitPx: filled,
1187
- exitFeePerUnit: exitFee,
1215
+ exitFeeTotal,
1188
1216
  time: bar.time,
1189
1217
  reason: "SCALE"
1190
1218
  });
@@ -1204,13 +1232,13 @@ function backtest(rawOptions) {
1204
1232
  const baseSize = open.baseSize || open.initSize;
1205
1233
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1206
1234
  if (addQty >= minQty) {
1207
- const { price: addFill, fee: addFee } = applyFill(
1235
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1208
1236
  triggerPrice,
1209
1237
  open.side,
1210
- { slippageBps, feeBps, kind: "limit" }
1238
+ { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1211
1239
  );
1212
1240
  const newSize = open.size + addQty;
1213
- open.entryFeeTotal += addFee * addQty;
1241
+ open.entryFeeTotal += addFeeTotal;
1214
1242
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
1215
1243
  open.size = newSize;
1216
1244
  open.initSize += addQty;
@@ -1225,18 +1253,20 @@ function backtest(rawOptions) {
1225
1253
  const touched = open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
1226
1254
  if (touched) {
1227
1255
  const exitSide2 = open.side === "long" ? "short" : "long";
1228
- const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide2, {
1229
- slippageBps,
1230
- feeBps,
1231
- kind: "limit"
1232
- });
1233
1256
  const qty = roundStep2(open.size * scaleOutFrac, qtyStep);
1234
1257
  if (qty >= minQty && qty < open.size) {
1258
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
1259
+ slippageBps,
1260
+ feeBps,
1261
+ kind: "limit",
1262
+ qty,
1263
+ costs
1264
+ });
1235
1265
  closeLeg({
1236
1266
  openPos: open,
1237
1267
  qty,
1238
1268
  exitPx: filled,
1239
- exitFeePerUnit: exitFee,
1269
+ exitFeeTotal,
1240
1270
  time: bar.time,
1241
1271
  reason: "SCALE"
1242
1272
  });
@@ -1258,17 +1288,19 @@ function backtest(rawOptions) {
1258
1288
  });
1259
1289
  if (hit) {
1260
1290
  const exitKind = hit === "TP" ? "limit" : "stop";
1261
- const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
1291
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
1262
1292
  slippageBps,
1263
1293
  feeBps,
1264
- kind: exitKind
1294
+ kind: exitKind,
1295
+ qty: open.size,
1296
+ costs
1265
1297
  });
1266
1298
  const localCooldown = open._cooldownBars || 0;
1267
1299
  closeLeg({
1268
1300
  openPos: open,
1269
1301
  qty: open.size,
1270
1302
  exitPx: filled,
1271
- exitFeePerUnit: exitFee,
1303
+ exitFeeTotal,
1272
1304
  time: bar.time,
1273
1305
  reason: hit
1274
1306
  });