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.
- package/README.md +273 -121
- package/bin/tradelab.js +384 -0
- package/dist/cjs/data.cjs +59 -27
- package/dist/cjs/index.cjs +291 -29
- package/docs/README.md +61 -0
- package/docs/api-reference.md +70 -0
- package/docs/backtest-engine.md +363 -0
- package/docs/data-reporting-cli.md +254 -0
- package/package.json +6 -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/bin/tradelab.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
663
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 +=
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1303
|
+
exitFeeTotal,
|
|
1272
1304
|
time: bar.time,
|
|
1273
1305
|
reason: hit
|
|
1274
1306
|
});
|