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.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. 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
- equityStart,
140
- equityFinal,
141
- candles,
142
- estBarMs,
143
- eqSeries,
144
- }) {
145
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
146
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
147
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
148
-
149
- const tradeRs = completedTrades.map(tradeRMultiple);
150
- const totalR = sum(tradeRs);
151
- const avgR = mean(tradeRs);
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
- const labels = completedTrades.map((trade) =>
154
- trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
155
- );
156
- const { maxWin, maxLoss } = streaks(labels);
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 tradePnls = completedTrades.map((trade) => trade.exit.pnl);
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 grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
173
- const grossLossPositions = Math.abs(
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: longTrades.length,
265
- winRate: longTrades.length
266
- ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length
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: shortTrades.length,
273
- winRate: shortTrades.length
274
- ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length
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 ? winningTrades.length / completedTrades.length : 0,
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
- ? winningTrades.length / completedTrades.length
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
- for (const root of candidateRoots()) {
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(`Could not locate template asset: ${relativePath}`);
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,
@@ -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;
@@ -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>{{CSS}}</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>{{SUMMARY_ROWS}}</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>{{BREAKDOWN_ROWS}}</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>{{POSITION_ROWS}}</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">{{REPORT_DATA_JSON}}</script>
108
+ <script id="report-data" type="application/json">
109
+ {{REPORT_DATA_JSON}}
110
+ </script>
103
111
  <script src="{{PLOTLY_CDN_URL}}"></script>
104
- <script>{{REPORT_JS}}</script>
112
+ <script>
113
+ {
114
+ {
115
+ REPORT_JS;
116
+ }
117
+ }
118
+ </script>
105
119
  </body>
106
120
  </html>