tradelab 0.4.0 → 1.0.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 +121 -52
- package/bin/tradelab.js +340 -49
- package/dist/cjs/data.cjs +210 -155
- package/dist/cjs/index.cjs +1782 -274
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +26 -9
- package/docs/api-reference.md +89 -26
- package/docs/backtest-engine.md +74 -60
- package/docs/data-reporting-cli.md +66 -36
- package/docs/examples.md +275 -0
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +481 -0
- package/src/engine/barSystemRunner.js +1027 -0
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +237 -66
- package/src/engine/walkForward.js +132 -13
- package/src/index.js +3 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +103 -100
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +98 -4
- package/types/live.d.ts +382 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { StorageProvider } from "./interface.js";
|
|
6
|
+
|
|
7
|
+
function sanitizeNamespace(namespace) {
|
|
8
|
+
return String(namespace || "default").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function ensureDir(dirPath) {
|
|
12
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readJsonFile(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error && error.code === "ENOENT") return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeJsonAtomic(filePath, payload) {
|
|
26
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random()
|
|
27
|
+
.toString(16)
|
|
28
|
+
.slice(2)}.tmp`;
|
|
29
|
+
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
|
30
|
+
await fsp.rename(tmpPath, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function appendJsonLine(filePath, payload) {
|
|
34
|
+
await ensureDir(path.dirname(filePath));
|
|
35
|
+
await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJsonLines(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
41
|
+
return raw
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trim())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((line) => JSON.parse(line));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error && error.code === "ENOENT") return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Zero-dependency JSON state storage.
|
|
54
|
+
*/
|
|
55
|
+
export class JsonFileStorage extends StorageProvider {
|
|
56
|
+
constructor({ baseDir = path.resolve(process.cwd(), "output/live-state") } = {}) {
|
|
57
|
+
super();
|
|
58
|
+
this.baseDir = baseDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
namespaceDir(namespace) {
|
|
62
|
+
return path.join(this.baseDir, sanitizeNamespace(namespace));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
statePath(namespace) {
|
|
66
|
+
return path.join(this.namespaceDir(namespace), "state.json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tradesPath(namespace) {
|
|
70
|
+
return path.join(this.namespaceDir(namespace), "trades.jsonl");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
equityPath(namespace) {
|
|
74
|
+
return path.join(this.namespaceDir(namespace), "equity.jsonl");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async load(namespace) {
|
|
78
|
+
return readJsonFile(this.statePath(namespace));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async save(namespace, state) {
|
|
82
|
+
const dir = this.namespaceDir(namespace);
|
|
83
|
+
await ensureDir(dir);
|
|
84
|
+
await writeJsonAtomic(this.statePath(namespace), state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async appendTrade(namespace, trade) {
|
|
88
|
+
await appendJsonLine(this.tradesPath(namespace), trade);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async appendEquityPoint(namespace, point) {
|
|
92
|
+
await appendJsonLine(this.equityPath(namespace), point);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async loadTrades(namespace) {
|
|
96
|
+
return readJsonLines(this.tradesPath(namespace));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async loadEquityCurve(namespace) {
|
|
100
|
+
return readJsonLines(this.equityPath(namespace));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async clear(namespace) {
|
|
104
|
+
const dir = this.namespaceDir(namespace);
|
|
105
|
+
if (!fs.existsSync(dir)) return;
|
|
106
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createJsonFileStorage(options) {
|
|
111
|
+
return new JsonFileStorage(options);
|
|
112
|
+
}
|
|
@@ -16,11 +16,7 @@ function sortino(values) {
|
|
|
16
16
|
const losses = values.filter((value) => value < 0);
|
|
17
17
|
const downsideDeviation = stddev(losses.length ? losses : [0]);
|
|
18
18
|
const avg = mean(values);
|
|
19
|
-
return downsideDeviation === 0
|
|
20
|
-
? avg > 0
|
|
21
|
-
? Infinity
|
|
22
|
-
: 0
|
|
23
|
-
: avg / downsideDeviation;
|
|
19
|
+
return downsideDeviation === 0 ? (avg > 0 ? Infinity : 0) : avg / downsideDeviation;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
function dayKeyUTC(timeMs) {
|
|
@@ -36,10 +32,7 @@ function tradeRMultiple(trade) {
|
|
|
36
32
|
const initialRisk = trade._initRisk || 0;
|
|
37
33
|
if (initialRisk <= 0) return 0;
|
|
38
34
|
const entry = trade.entryFill ?? trade.entry;
|
|
39
|
-
const perUnit =
|
|
40
|
-
trade.side === "long"
|
|
41
|
-
? trade.exit.price - entry
|
|
42
|
-
: entry - trade.exit.price;
|
|
35
|
+
const perUnit = trade.side === "long" ? trade.exit.price - entry : entry - trade.exit.price;
|
|
43
36
|
return perUnit / initialRisk;
|
|
44
37
|
}
|
|
45
38
|
|
|
@@ -127,6 +120,15 @@ function percentile(values, percentileRank) {
|
|
|
127
120
|
return sorted[index];
|
|
128
121
|
}
|
|
129
122
|
|
|
123
|
+
const PROFIT_FACTOR_CAP = 1_000_000;
|
|
124
|
+
|
|
125
|
+
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
126
|
+
if (grossLoss === 0) {
|
|
127
|
+
return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
|
|
128
|
+
}
|
|
129
|
+
return grossProfit / grossLoss;
|
|
130
|
+
}
|
|
131
|
+
|
|
130
132
|
/**
|
|
131
133
|
* Build aggregate backtest metrics for completed positions and realized trade legs.
|
|
132
134
|
*
|
|
@@ -134,32 +136,89 @@ function percentile(values, percentileRank) {
|
|
|
134
136
|
* `profitFactor`, `winRate`, `expectancy`, `maxDrawdown`, `sharpe`, `avgHold`,
|
|
135
137
|
* and `sideBreakdown`, while preserving the more specific legacy fields.
|
|
136
138
|
*/
|
|
137
|
-
export function buildMetrics({
|
|
138
|
-
closed,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
139
|
+
export function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
|
|
140
|
+
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
141
|
+
const completedTrades = [];
|
|
142
|
+
const tradeRs = [];
|
|
143
|
+
const tradePnls = [];
|
|
144
|
+
const tradeReturns = [];
|
|
145
|
+
const holdDurationsMinutes = [];
|
|
146
|
+
const labels = [];
|
|
147
|
+
const longRs = [];
|
|
148
|
+
const shortRs = [];
|
|
149
|
+
let totalR = 0;
|
|
150
|
+
let realizedPnL = 0;
|
|
151
|
+
let winningTradeCount = 0;
|
|
152
|
+
let grossProfitPositions = 0;
|
|
153
|
+
let grossLossPositions = 0;
|
|
154
|
+
let grossProfitLegs = 0;
|
|
155
|
+
let grossLossLegs = 0;
|
|
156
|
+
let winningLegCount = 0;
|
|
157
|
+
let openBars = 0;
|
|
158
|
+
let longTradesCount = 0;
|
|
159
|
+
let longTradeWins = 0;
|
|
160
|
+
let longPnLSum = 0;
|
|
161
|
+
let shortTradesCount = 0;
|
|
162
|
+
let shortTradeWins = 0;
|
|
163
|
+
let shortPnLSum = 0;
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
let peakEquity = equityStart;
|
|
166
|
+
let currentEquity = equityStart;
|
|
167
|
+
let maxDrawdown = 0;
|
|
168
|
+
|
|
169
|
+
for (const trade of legs) {
|
|
170
|
+
const pnl = trade.exit.pnl;
|
|
171
|
+
realizedPnL += pnl;
|
|
172
|
+
|
|
173
|
+
if (pnl > 0) {
|
|
174
|
+
grossProfitLegs += pnl;
|
|
175
|
+
winningLegCount += 1;
|
|
176
|
+
} else if (pnl < 0) {
|
|
177
|
+
grossLossLegs += Math.abs(pnl);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
currentEquity += pnl;
|
|
181
|
+
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
182
|
+
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
183
|
+
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
184
|
+
|
|
185
|
+
if (trade.exit.reason === "SCALE") continue;
|
|
186
|
+
|
|
187
|
+
completedTrades.push(trade);
|
|
188
|
+
tradePnls.push(pnl);
|
|
189
|
+
tradeReturns.push(pnl / Math.max(1e-12, equityStart));
|
|
190
|
+
const tradeR = tradeRMultiple(trade);
|
|
191
|
+
tradeRs.push(tradeR);
|
|
192
|
+
totalR += tradeR;
|
|
193
|
+
labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
|
|
194
|
+
|
|
195
|
+
const holdMinutes = (trade.exit.time - trade.openTime) / (1000 * 60);
|
|
196
|
+
holdDurationsMinutes.push(holdMinutes);
|
|
197
|
+
openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
198
|
+
|
|
199
|
+
if (pnl > 0) {
|
|
200
|
+
winningTradeCount += 1;
|
|
201
|
+
grossProfitPositions += pnl;
|
|
202
|
+
} else if (pnl < 0) {
|
|
203
|
+
grossLossPositions += Math.abs(pnl);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (trade.side === "long") {
|
|
207
|
+
longTradesCount += 1;
|
|
208
|
+
longPnLSum += pnl;
|
|
209
|
+
longRs.push(tradeR);
|
|
210
|
+
if (pnl > 0) longTradeWins += 1;
|
|
211
|
+
} else if (trade.side === "short") {
|
|
212
|
+
shortTradesCount += 1;
|
|
213
|
+
shortPnLSum += pnl;
|
|
214
|
+
shortRs.push(tradeR);
|
|
215
|
+
if (pnl > 0) shortTradeWins += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
157
218
|
|
|
158
|
-
const
|
|
219
|
+
const avgR = mean(tradeRs);
|
|
220
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
159
221
|
const expectancy = mean(tradePnls);
|
|
160
|
-
const tradeReturns = completedTrades.map(
|
|
161
|
-
(trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
|
|
162
|
-
);
|
|
163
222
|
const tradeReturnStd = stddev(tradeReturns);
|
|
164
223
|
const sharpePerTrade =
|
|
165
224
|
tradeReturnStd === 0
|
|
@@ -169,60 +228,17 @@ export function buildMetrics({
|
|
|
169
228
|
: mean(tradeReturns) / tradeReturnStd;
|
|
170
229
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
171
230
|
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
sum(losingTrades.map((trade) => trade.exit.pnl))
|
|
175
|
-
);
|
|
176
|
-
const profitFactorPositions =
|
|
177
|
-
grossLossPositions === 0
|
|
178
|
-
? grossProfitPositions > 0
|
|
179
|
-
? Infinity
|
|
180
|
-
: 0
|
|
181
|
-
: grossProfitPositions / grossLossPositions;
|
|
182
|
-
|
|
183
|
-
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
184
|
-
const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
|
|
185
|
-
const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
|
|
186
|
-
const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
|
|
187
|
-
const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
|
|
188
|
-
const profitFactorLegs =
|
|
189
|
-
grossLossLegs === 0
|
|
190
|
-
? grossProfitLegs > 0
|
|
191
|
-
? Infinity
|
|
192
|
-
: 0
|
|
193
|
-
: grossProfitLegs / grossLossLegs;
|
|
194
|
-
|
|
195
|
-
let peakEquity = equityStart;
|
|
196
|
-
let currentEquity = equityStart;
|
|
197
|
-
let maxDrawdown = 0;
|
|
198
|
-
|
|
199
|
-
for (const leg of legs) {
|
|
200
|
-
currentEquity += leg.exit.pnl;
|
|
201
|
-
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
202
|
-
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
203
|
-
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
|
|
231
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
232
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
207
233
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
208
234
|
const calmar = maxDrawdown === 0 ? (returnPct > 0 ? Infinity : 0) : returnPct / maxDrawdown;
|
|
209
235
|
|
|
210
236
|
const totalBars = Math.max(1, candles.length);
|
|
211
|
-
const openBars = completedTrades.reduce((total, trade) => {
|
|
212
|
-
const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
213
|
-
return total + barsHeld;
|
|
214
|
-
}, 0);
|
|
215
237
|
const exposurePct = openBars / totalBars;
|
|
216
|
-
|
|
217
|
-
const holdDurationsMinutes = completedTrades.map(
|
|
218
|
-
(trade) => (trade.exit.time - trade.openTime) / (1000 * 60)
|
|
219
|
-
);
|
|
220
238
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
221
239
|
|
|
222
240
|
const equitySeries =
|
|
223
|
-
eqSeries && eqSeries.length
|
|
224
|
-
? eqSeries
|
|
225
|
-
: buildEquitySeriesFromLegs({ legs, equityStart });
|
|
241
|
+
eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
226
242
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
227
243
|
const dailyStd = stddev(dailyReturnsSeries);
|
|
228
244
|
const sharpeDaily =
|
|
@@ -236,13 +252,6 @@ export function buildMetrics({
|
|
|
236
252
|
? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length
|
|
237
253
|
: 0;
|
|
238
254
|
|
|
239
|
-
const longTrades = completedTrades.filter((trade) => trade.side === "long");
|
|
240
|
-
const shortTrades = completedTrades.filter((trade) => trade.side === "short");
|
|
241
|
-
const longRs = longTrades.map(tradeRMultiple);
|
|
242
|
-
const shortRs = shortTrades.map(tradeRMultiple);
|
|
243
|
-
const longPnls = longTrades.map((trade) => trade.exit.pnl);
|
|
244
|
-
const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
|
|
245
|
-
|
|
246
255
|
const rDistribution = {
|
|
247
256
|
p10: percentile(tradeRs, 0.1),
|
|
248
257
|
p25: percentile(tradeRs, 0.25),
|
|
@@ -261,26 +270,22 @@ export function buildMetrics({
|
|
|
261
270
|
|
|
262
271
|
const sideBreakdown = {
|
|
263
272
|
long: {
|
|
264
|
-
trades:
|
|
265
|
-
winRate:
|
|
266
|
-
|
|
267
|
-
: 0,
|
|
268
|
-
avgPnL: mean(longPnls),
|
|
273
|
+
trades: longTradesCount,
|
|
274
|
+
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
275
|
+
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
269
276
|
avgR: mean(longRs),
|
|
270
277
|
},
|
|
271
278
|
short: {
|
|
272
|
-
trades:
|
|
273
|
-
winRate:
|
|
274
|
-
|
|
275
|
-
: 0,
|
|
276
|
-
avgPnL: mean(shortPnls),
|
|
279
|
+
trades: shortTradesCount,
|
|
280
|
+
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
281
|
+
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
277
282
|
avgR: mean(shortRs),
|
|
278
283
|
},
|
|
279
284
|
};
|
|
280
285
|
|
|
281
286
|
return {
|
|
282
287
|
trades: completedTrades.length,
|
|
283
|
-
winRate: completedTrades.length ?
|
|
288
|
+
winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
284
289
|
profitFactor: profitFactorPositions,
|
|
285
290
|
expectancy,
|
|
286
291
|
totalR,
|
|
@@ -302,10 +307,8 @@ export function buildMetrics({
|
|
|
302
307
|
startEquity: equityStart,
|
|
303
308
|
profitFactor_pos: profitFactorPositions,
|
|
304
309
|
profitFactor_leg: profitFactorLegs,
|
|
305
|
-
winRate_pos: completedTrades.length
|
|
306
|
-
|
|
307
|
-
: 0,
|
|
308
|
-
winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
|
|
310
|
+
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
311
|
+
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
309
312
|
sharpeDaily,
|
|
310
313
|
sortinoDaily,
|
|
311
314
|
sideBreakdown,
|
|
@@ -24,10 +24,7 @@ export function exportBacktestArtifacts({
|
|
|
24
24
|
metrics: null,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
const csvTrades =
|
|
28
|
-
csvSource === "trades"
|
|
29
|
-
? result.trades
|
|
30
|
-
: result.positions ?? result.trades;
|
|
27
|
+
const csvTrades = csvSource === "trades" ? result.trades : (result.positions ?? result.trades);
|
|
31
28
|
|
|
32
29
|
if (exportCsv) {
|
|
33
30
|
outputs.csv = exportTradesCsv(csvTrades, {
|
|
@@ -9,10 +9,7 @@ function tradeRMultiple(trade) {
|
|
|
9
9
|
const initialRisk = trade._initRisk || 0;
|
|
10
10
|
if (initialRisk <= 0) return 0;
|
|
11
11
|
const entry = trade.entryFill ?? trade.entry;
|
|
12
|
-
const perUnit =
|
|
13
|
-
trade.side === "long"
|
|
14
|
-
? trade.exit.price - entry
|
|
15
|
-
: entry - trade.exit.price;
|
|
12
|
+
const perUnit = trade.side === "long" ? trade.exit.price - entry : entry - trade.exit.price;
|
|
16
13
|
return perUnit / initialRisk;
|
|
17
14
|
}
|
|
18
15
|
|
|
@@ -58,9 +55,7 @@ export function exportTradesCsv(
|
|
|
58
55
|
(trade.maeR ?? 0).toFixed(3),
|
|
59
56
|
trade.adds ?? 0,
|
|
60
57
|
trade.entryATR !== undefined ? Number(trade.entryATR).toFixed(6) : "",
|
|
61
|
-
trade.exit.exitATR !== undefined
|
|
62
|
-
? Number(trade.exit.exitATR).toFixed(6)
|
|
63
|
-
: "",
|
|
58
|
+
trade.exit.exitATR !== undefined ? Number(trade.exit.exitATR).toFixed(6) : "",
|
|
64
59
|
].join(",")
|
|
65
60
|
),
|
|
66
61
|
].join("\n");
|
|
@@ -19,7 +19,8 @@ function candidateRoots() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function readTemplate(relativePath) {
|
|
22
|
-
|
|
22
|
+
const roots = candidateRoots();
|
|
23
|
+
for (const root of roots) {
|
|
23
24
|
const absolutePath = path.join(root, relativePath);
|
|
24
25
|
if (!fs.existsSync(absolutePath)) continue;
|
|
25
26
|
|
|
@@ -29,7 +30,9 @@ function readTemplate(relativePath) {
|
|
|
29
30
|
return templateCache.get(absolutePath);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
throw new Error(
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Could not locate template asset: ${relativePath} (searched ${roots.length} roots starting from ${roots[0]})`
|
|
35
|
+
);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function fmt(value, digits = 2) {
|
|
@@ -135,9 +138,7 @@ function renderPositionRows(positions) {
|
|
|
135
138
|
<td>${escapeHtml(fmt(exit.price, 4))}</td>
|
|
136
139
|
<td>${escapeHtml(exit.reason ?? "—")}</td>
|
|
137
140
|
<td>${escapeHtml(fmt(exit.pnl, 2))}</td>
|
|
138
|
-
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
|
|
139
|
-
fmt(trade.maeR ?? 0, 2)
|
|
140
|
-
)}</td>
|
|
141
|
+
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
|
|
141
142
|
</tr>
|
|
142
143
|
`;
|
|
143
144
|
})
|
|
@@ -259,10 +260,7 @@ export function renderHtmlReport({
|
|
|
259
260
|
["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
|
|
260
261
|
[
|
|
261
262
|
"Hold p50 / p90",
|
|
262
|
-
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
|
|
263
|
-
metrics.holdDistMin?.p90 ?? 0,
|
|
264
|
-
1
|
|
265
|
-
)} min`,
|
|
263
|
+
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`,
|
|
266
264
|
],
|
|
267
265
|
]);
|
|
268
266
|
|
|
@@ -306,10 +304,7 @@ export function exportHtmlReport({
|
|
|
306
304
|
const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
307
305
|
const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
308
306
|
const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
309
|
-
const outputPath = path.join(
|
|
310
|
-
outDir,
|
|
311
|
-
`report-${safeSymbol}-${safeInterval}-${safeRange}.html`
|
|
312
|
-
);
|
|
307
|
+
const outputPath = path.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
|
|
313
308
|
|
|
314
309
|
const html = renderHtmlReport({
|
|
315
310
|
symbol,
|
package/src/utils/indicators.js
CHANGED
|
@@ -126,8 +126,7 @@ export function atr(bars, period = 14) {
|
|
|
126
126
|
continue;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
previousAtr =
|
|
130
|
-
(previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
129
|
+
previousAtr = (previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
131
130
|
output[index] = previousAtr;
|
|
132
131
|
}
|
|
133
132
|
|
|
@@ -2,6 +2,16 @@ function roundStep(value, step) {
|
|
|
2
2
|
return Math.floor(value / step) * step;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
let warnedNonPositiveEquity = false;
|
|
6
|
+
|
|
7
|
+
function warnNonPositiveEquity(equity) {
|
|
8
|
+
if (warnedNonPositiveEquity) return;
|
|
9
|
+
warnedNonPositiveEquity = true;
|
|
10
|
+
console.warn(
|
|
11
|
+
`[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export function calculatePositionSize({
|
|
6
16
|
equity,
|
|
7
17
|
entry,
|
|
@@ -11,14 +21,18 @@ export function calculatePositionSize({
|
|
|
11
21
|
minQty = 0.001,
|
|
12
22
|
maxLeverage = 2,
|
|
13
23
|
}) {
|
|
24
|
+
if (!Number.isFinite(equity) || equity <= 0) {
|
|
25
|
+
warnNonPositiveEquity(equity);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
const riskPerUnit = Math.abs(entry - stop);
|
|
15
30
|
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
16
31
|
|
|
17
32
|
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
18
33
|
let quantity = maxRiskDollars / riskPerUnit;
|
|
19
34
|
|
|
20
|
-
const leverageCapQty =
|
|
21
|
-
(equity * maxLeverage) / Math.max(1e-12, Math.abs(entry));
|
|
35
|
+
const leverageCapQty = (equity * maxLeverage) / Math.max(1e-12, Math.abs(entry));
|
|
22
36
|
quantity = Math.min(quantity, leverageCapQty);
|
|
23
37
|
quantity = roundStep(quantity, qtyStep);
|
|
24
38
|
|
package/src/utils/time.js
CHANGED
|
@@ -8,20 +8,14 @@ function usDstBoundsUTC(year) {
|
|
|
8
8
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1000);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const dstStart = new Date(
|
|
12
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
13
|
-
);
|
|
11
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
14
12
|
|
|
15
13
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
16
14
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
17
|
-
novemberCursor = new Date(
|
|
18
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1000
|
|
19
|
-
);
|
|
15
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1000);
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
const dstEnd = new Date(
|
|
23
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
24
|
-
);
|
|
18
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
25
19
|
|
|
26
20
|
return { dstStart, dstEnd };
|
|
27
21
|
}
|
|
@@ -58,9 +52,7 @@ export function isSession(timeMs, session = "NYSE") {
|
|
|
58
52
|
if (session === "FUT") {
|
|
59
53
|
const maintenanceStart = 17 * 60;
|
|
60
54
|
const maintenanceEnd = 18 * 60;
|
|
61
|
-
return !(
|
|
62
|
-
minutes >= maintenanceStart && minutes < maintenanceEnd
|
|
63
|
-
);
|
|
55
|
+
return !(minutes >= maintenanceStart && minutes < maintenanceEnd);
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
const open = 9 * 60 + 30;
|
package/templates/report.html
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>{{TITLE}} backtest</title>
|
|
7
|
-
<style>
|
|
7
|
+
<style>
|
|
8
|
+
{{CSS}}
|
|
9
|
+
</style>
|
|
8
10
|
</head>
|
|
9
11
|
<body>
|
|
10
12
|
<main class="report-shell">
|
|
@@ -17,9 +19,7 @@
|
|
|
17
19
|
<div class="hero-pill">{{HERO_PILL}}</div>
|
|
18
20
|
</header>
|
|
19
21
|
|
|
20
|
-
<section class="metric-grid">
|
|
21
|
-
{{METRIC_CARDS}}
|
|
22
|
-
</section>
|
|
22
|
+
<section class="metric-grid">{{METRIC_CARDS}}</section>
|
|
23
23
|
|
|
24
24
|
<section class="panel-grid">
|
|
25
25
|
<article class="panel panel--wide">
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
<p>Headline stats for completed positions.</p>
|
|
37
37
|
</div>
|
|
38
38
|
<table class="data-table">
|
|
39
|
-
<tbody>
|
|
39
|
+
<tbody>
|
|
40
|
+
{{SUMMARY_ROWS}}
|
|
41
|
+
</tbody>
|
|
40
42
|
</table>
|
|
41
43
|
</article>
|
|
42
44
|
|
|
@@ -70,7 +72,9 @@
|
|
|
70
72
|
<p>Long/short split and distribution snapshots.</p>
|
|
71
73
|
</div>
|
|
72
74
|
<table class="data-table">
|
|
73
|
-
<tbody>
|
|
75
|
+
<tbody>
|
|
76
|
+
{{BREAKDOWN_ROWS}}
|
|
77
|
+
</tbody>
|
|
74
78
|
</table>
|
|
75
79
|
</article>
|
|
76
80
|
|
|
@@ -92,15 +96,25 @@
|
|
|
92
96
|
<th>MFE / MAE</th>
|
|
93
97
|
</tr>
|
|
94
98
|
</thead>
|
|
95
|
-
<tbody>
|
|
99
|
+
<tbody>
|
|
100
|
+
{{POSITION_ROWS}}
|
|
101
|
+
</tbody>
|
|
96
102
|
</table>
|
|
97
103
|
</div>
|
|
98
104
|
</article>
|
|
99
105
|
</section>
|
|
100
106
|
</main>
|
|
101
107
|
|
|
102
|
-
<script id="report-data" type="application/json">
|
|
108
|
+
<script id="report-data" type="application/json">
|
|
109
|
+
{{REPORT_DATA_JSON}}
|
|
110
|
+
</script>
|
|
103
111
|
<script src="{{PLOTLY_CDN_URL}}"></script>
|
|
104
|
-
<script>
|
|
112
|
+
<script>
|
|
113
|
+
{
|
|
114
|
+
{
|
|
115
|
+
REPORT_JS;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</script>
|
|
105
119
|
</body>
|
|
106
120
|
</html>
|