tradelab 0.1.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,120 @@
1
+ import { minutesET } from "../utils/time.js";
2
+
3
+ export function applyFill(
4
+ price,
5
+ side,
6
+ { slippageBps = 0, feeBps = 0, kind = "market" } = {}
7
+ ) {
8
+ let effectiveSlippageBps = slippageBps;
9
+ if (kind === "limit") effectiveSlippageBps *= 0.25;
10
+ if (kind === "stop") effectiveSlippageBps *= 1.25;
11
+
12
+ const slippage = (effectiveSlippageBps / 10000) * price;
13
+ const filledPrice = side === "long" ? price + slippage : price - slippage;
14
+ const feePerUnit = (feeBps / 10000) * Math.abs(filledPrice);
15
+ return { price: filledPrice, fee: feePerUnit };
16
+ }
17
+
18
+ export function clampStop(marketPrice, proposedStop, side, oco) {
19
+ const epsilon = (oco?.clampEpsBps ?? 0.25) / 10000;
20
+ const epsilonAbs = marketPrice * epsilon;
21
+ return side === "long"
22
+ ? Math.min(proposedStop, marketPrice - epsilonAbs)
23
+ : Math.max(proposedStop, marketPrice + epsilonAbs);
24
+ }
25
+
26
+ export function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
27
+ if (!bar || limitPrice === undefined || limitPrice === null) return false;
28
+ if (mode === "close") {
29
+ return side === "long"
30
+ ? bar.close <= limitPrice
31
+ : bar.close >= limitPrice;
32
+ }
33
+ return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
34
+ }
35
+
36
+ export function ocoExitCheck({
37
+ side,
38
+ stop,
39
+ tp,
40
+ bar,
41
+ mode = "intrabar",
42
+ tieBreak = "pessimistic",
43
+ }) {
44
+ if (mode === "close") {
45
+ const close = bar.close;
46
+ if (side === "long") {
47
+ if (close <= stop) return { hit: "SL", px: stop };
48
+ if (close >= tp) return { hit: "TP", px: tp };
49
+ } else {
50
+ if (close >= stop) return { hit: "SL", px: stop };
51
+ if (close <= tp) return { hit: "TP", px: tp };
52
+ }
53
+ return { hit: null, px: null };
54
+ }
55
+
56
+ const hitStop = side === "long" ? bar.low <= stop : bar.high >= stop;
57
+ const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
58
+
59
+ if (hitStop && hitTarget) {
60
+ return tieBreak === "optimistic"
61
+ ? { hit: "TP", px: tp }
62
+ : { hit: "SL", px: stop };
63
+ }
64
+
65
+ if (hitStop) return { hit: "SL", px: stop };
66
+ if (hitTarget) return { hit: "TP", px: tp };
67
+ return { hit: null, px: null };
68
+ }
69
+
70
+ export function isEODBar(timeMs) {
71
+ return minutesET(timeMs) >= 16 * 60;
72
+ }
73
+
74
+ export function roundStep(value, step = 0.001) {
75
+ return Math.floor(value / step) * step;
76
+ }
77
+
78
+ export function estimateBarMs(candles) {
79
+ if (candles.length >= 2) {
80
+ const deltas = [];
81
+ for (let index = 1; index < Math.min(candles.length, 500); index += 1) {
82
+ const delta = candles[index].time - candles[index - 1].time;
83
+ if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
84
+ }
85
+
86
+ if (deltas.length) {
87
+ deltas.sort((a, b) => a - b);
88
+ const middle = Math.floor(deltas.length / 2);
89
+ const median =
90
+ deltas.length % 2
91
+ ? deltas[middle]
92
+ : (deltas[middle - 1] + deltas[middle]) / 2;
93
+ return Math.max(60e3, Math.min(median, 60 * 60e3));
94
+ }
95
+ }
96
+ return 5 * 60 * 1000;
97
+ }
98
+
99
+ export function dayKeyUTC(timeMs) {
100
+ const date = new Date(timeMs);
101
+ return [
102
+ date.getUTCFullYear(),
103
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
104
+ String(date.getUTCDate()).padStart(2, "0"),
105
+ ].join("-");
106
+ }
107
+
108
+ export function dayKeyET(timeMs) {
109
+ const date = new Date(timeMs);
110
+ const minutes = minutesET(timeMs);
111
+ const hoursET = Math.floor(minutes / 60);
112
+ const minutesETDay = minutes % 60;
113
+
114
+ const anchor = new Date(
115
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
116
+ );
117
+ const pseudoEtTime =
118
+ anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
119
+ return dayKeyUTC(pseudoEtTime);
120
+ }
package/src/index.js ADDED
@@ -0,0 +1,43 @@
1
+ export { backtest } from "./engine/backtest.js";
2
+
3
+ export { buildMetrics } from "./metrics/buildMetrics.js";
4
+ export {
5
+ backtestHistorical,
6
+ cachedCandlesPath,
7
+ candleStats,
8
+ fetchHistorical,
9
+ fetchLatestCandle,
10
+ getHistoricalCandles,
11
+ loadCandlesFromCache,
12
+ loadCandlesFromCSV,
13
+ mergeCandles,
14
+ normalizeCandles,
15
+ saveCandlesToCache,
16
+ } from "./data/index.js";
17
+
18
+ export {
19
+ renderHtmlReport,
20
+ exportHtmlReport,
21
+ } from "./reporting/renderHtmlReport.js";
22
+ export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
23
+ export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
24
+
25
+ export {
26
+ ema,
27
+ atr,
28
+ swingHigh,
29
+ swingLow,
30
+ detectFVG,
31
+ lastSwing,
32
+ structureState,
33
+ bpsOf,
34
+ pct,
35
+ } from "./utils/indicators.js";
36
+ export { calculatePositionSize } from "./utils/positionSizing.js";
37
+ export {
38
+ offsetET,
39
+ minutesET,
40
+ isSession,
41
+ parseWindowsCSV,
42
+ inWindowsET,
43
+ } from "./utils/time.js";
@@ -0,0 +1,306 @@
1
+ function sum(values) {
2
+ return values.reduce((total, value) => total + value, 0);
3
+ }
4
+
5
+ function mean(values) {
6
+ return values.length ? sum(values) / values.length : 0;
7
+ }
8
+
9
+ function stddev(values) {
10
+ if (values.length <= 1) return 0;
11
+ const avg = mean(values);
12
+ return Math.sqrt(mean(values.map((value) => (value - avg) ** 2)));
13
+ }
14
+
15
+ function sortino(values) {
16
+ const losses = values.filter((value) => value < 0);
17
+ const downsideDeviation = stddev(losses.length ? losses : [0]);
18
+ const avg = mean(values);
19
+ return downsideDeviation === 0
20
+ ? avg > 0
21
+ ? Infinity
22
+ : 0
23
+ : avg / downsideDeviation;
24
+ }
25
+
26
+ function dayKeyUTC(timeMs) {
27
+ const date = new Date(timeMs);
28
+ return [
29
+ date.getUTCFullYear(),
30
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
31
+ String(date.getUTCDate()).padStart(2, "0"),
32
+ ].join("-");
33
+ }
34
+
35
+ function tradeRMultiple(trade) {
36
+ const initialRisk = trade._initRisk || 0;
37
+ if (initialRisk <= 0) return 0;
38
+ const entry = trade.entryFill ?? trade.entry;
39
+ const perUnit =
40
+ trade.side === "long"
41
+ ? trade.exit.price - entry
42
+ : entry - trade.exit.price;
43
+ return perUnit / initialRisk;
44
+ }
45
+
46
+ function streaks(labels) {
47
+ let wins = 0;
48
+ let losses = 0;
49
+ let maxWins = 0;
50
+ let maxLosses = 0;
51
+
52
+ for (const label of labels) {
53
+ if (label === "win") {
54
+ wins += 1;
55
+ losses = 0;
56
+ if (wins > maxWins) maxWins = wins;
57
+ continue;
58
+ }
59
+
60
+ if (label === "loss") {
61
+ losses += 1;
62
+ wins = 0;
63
+ if (losses > maxLosses) maxLosses = losses;
64
+ continue;
65
+ }
66
+
67
+ wins = 0;
68
+ losses = 0;
69
+ }
70
+
71
+ return { maxWin: maxWins, maxLoss: maxLosses };
72
+ }
73
+
74
+ function buildEquitySeriesFromLegs({ legs, equityStart }) {
75
+ const firstTime = legs.length ? legs[0].exit.time : Date.now();
76
+ const series = [{ time: firstTime, equity: equityStart }];
77
+ let equity = equityStart;
78
+
79
+ for (const leg of legs) {
80
+ equity += leg.exit.pnl;
81
+ series.push({ time: leg.exit.time, equity });
82
+ }
83
+
84
+ return series;
85
+ }
86
+
87
+ function dailyReturns(eqSeries) {
88
+ if (!eqSeries?.length) return [];
89
+
90
+ const byDay = new Map();
91
+ for (const point of eqSeries) {
92
+ const day = dayKeyUTC(point.time);
93
+ const record = byDay.get(day) || {
94
+ open: point.equity,
95
+ close: point.equity,
96
+ first: point.time,
97
+ last: point.time,
98
+ };
99
+
100
+ if (point.time < record.first) {
101
+ record.first = point.time;
102
+ record.open = point.equity;
103
+ }
104
+
105
+ if (point.time >= record.last) {
106
+ record.last = point.time;
107
+ record.close = point.equity;
108
+ }
109
+
110
+ byDay.set(day, record);
111
+ }
112
+
113
+ const returns = [];
114
+ for (const { open, close } of byDay.values()) {
115
+ if (open > 0 && Number.isFinite(open) && Number.isFinite(close)) {
116
+ returns.push((close - open) / open);
117
+ }
118
+ }
119
+
120
+ return returns;
121
+ }
122
+
123
+ function percentile(values, percentileRank) {
124
+ if (!values.length) return 0;
125
+ const sorted = [...values].sort((a, b) => a - b);
126
+ const index = Math.floor((sorted.length - 1) * percentileRank);
127
+ return sorted[index];
128
+ }
129
+
130
+ export function buildMetrics({
131
+ closed,
132
+ equityStart,
133
+ equityFinal,
134
+ candles,
135
+ estBarMs,
136
+ eqSeries,
137
+ }) {
138
+ const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
139
+ const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
140
+ const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
141
+
142
+ const tradeRs = completedTrades.map(tradeRMultiple);
143
+ const totalR = sum(tradeRs);
144
+ const avgR = mean(tradeRs);
145
+
146
+ const labels = completedTrades.map((trade) =>
147
+ trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
148
+ );
149
+ const { maxWin, maxLoss } = streaks(labels);
150
+
151
+ const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
152
+ const expectancy = mean(tradePnls);
153
+ const tradeReturns = completedTrades.map(
154
+ (trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
155
+ );
156
+ const tradeReturnStd = stddev(tradeReturns);
157
+ const sharpePerTrade =
158
+ tradeReturnStd === 0
159
+ ? tradeReturns.length
160
+ ? Infinity
161
+ : 0
162
+ : mean(tradeReturns) / tradeReturnStd;
163
+ const sortinoPerTrade = sortino(tradeReturns);
164
+
165
+ const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
166
+ const grossLossPositions = Math.abs(
167
+ sum(losingTrades.map((trade) => trade.exit.pnl))
168
+ );
169
+ const profitFactorPositions =
170
+ grossLossPositions === 0
171
+ ? grossProfitPositions > 0
172
+ ? Infinity
173
+ : 0
174
+ : grossProfitPositions / grossLossPositions;
175
+
176
+ const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
177
+ const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
178
+ const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
179
+ const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
180
+ const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
181
+ const profitFactorLegs =
182
+ grossLossLegs === 0
183
+ ? grossProfitLegs > 0
184
+ ? Infinity
185
+ : 0
186
+ : grossProfitLegs / grossLossLegs;
187
+
188
+ let peakEquity = equityStart;
189
+ let currentEquity = equityStart;
190
+ let maxDrawdown = 0;
191
+
192
+ for (const leg of legs) {
193
+ currentEquity += leg.exit.pnl;
194
+ if (currentEquity > peakEquity) peakEquity = currentEquity;
195
+ const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
196
+ if (drawdown > maxDrawdown) maxDrawdown = drawdown;
197
+ }
198
+
199
+ const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
200
+ const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
201
+ const calmar = maxDrawdown === 0 ? (returnPct > 0 ? Infinity : 0) : returnPct / maxDrawdown;
202
+
203
+ const totalBars = Math.max(1, candles.length);
204
+ const openBars = completedTrades.reduce((total, trade) => {
205
+ const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
206
+ return total + barsHeld;
207
+ }, 0);
208
+ const exposurePct = openBars / totalBars;
209
+
210
+ const holdDurationsMinutes = completedTrades.map(
211
+ (trade) => (trade.exit.time - trade.openTime) / (1000 * 60)
212
+ );
213
+ const avgHoldMin = mean(holdDurationsMinutes);
214
+
215
+ const equitySeries =
216
+ eqSeries && eqSeries.length
217
+ ? eqSeries
218
+ : buildEquitySeriesFromLegs({ legs, equityStart });
219
+ const dailyReturnsSeries = dailyReturns(equitySeries);
220
+ const dailyStd = stddev(dailyReturnsSeries);
221
+ const sharpeDaily =
222
+ dailyStd === 0
223
+ ? dailyReturnsSeries.length
224
+ ? Infinity
225
+ : 0
226
+ : mean(dailyReturnsSeries) / dailyStd;
227
+ const sortinoDaily = sortino(dailyReturnsSeries);
228
+ const dailyWinRate = dailyReturnsSeries.length
229
+ ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length
230
+ : 0;
231
+
232
+ const longTrades = completedTrades.filter((trade) => trade.side === "long");
233
+ const shortTrades = completedTrades.filter((trade) => trade.side === "short");
234
+ const longRs = longTrades.map(tradeRMultiple);
235
+ const shortRs = shortTrades.map(tradeRMultiple);
236
+ const longPnls = longTrades.map((trade) => trade.exit.pnl);
237
+ const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
238
+
239
+ const rDistribution = {
240
+ p10: percentile(tradeRs, 0.1),
241
+ p25: percentile(tradeRs, 0.25),
242
+ p50: percentile(tradeRs, 0.5),
243
+ p75: percentile(tradeRs, 0.75),
244
+ p90: percentile(tradeRs, 0.9),
245
+ };
246
+
247
+ const holdDistribution = {
248
+ p10: percentile(holdDurationsMinutes, 0.1),
249
+ p25: percentile(holdDurationsMinutes, 0.25),
250
+ p50: percentile(holdDurationsMinutes, 0.5),
251
+ p75: percentile(holdDurationsMinutes, 0.75),
252
+ p90: percentile(holdDurationsMinutes, 0.9),
253
+ };
254
+
255
+ return {
256
+ trades: completedTrades.length,
257
+ winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
258
+ profitFactor: profitFactorPositions,
259
+ expectancy,
260
+ totalR,
261
+ avgR,
262
+ sharpePerTrade,
263
+ sortinoPerTrade,
264
+ maxDrawdownPct: maxDrawdown,
265
+ calmar,
266
+ maxConsecWins: maxWin,
267
+ maxConsecLosses: maxLoss,
268
+ avgHoldMin,
269
+ exposurePct,
270
+ totalPnL: realizedPnL,
271
+ returnPct,
272
+ finalEquity: equityFinal,
273
+ startEquity: equityStart,
274
+ profitFactor_pos: profitFactorPositions,
275
+ profitFactor_leg: profitFactorLegs,
276
+ winRate_pos: completedTrades.length
277
+ ? winningTrades.length / completedTrades.length
278
+ : 0,
279
+ winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
280
+ sharpeDaily,
281
+ sortinoDaily,
282
+ long: {
283
+ trades: longTrades.length,
284
+ winRate: longTrades.length
285
+ ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length
286
+ : 0,
287
+ avgPnL: mean(longPnls),
288
+ avgR: mean(longRs),
289
+ },
290
+ short: {
291
+ trades: shortTrades.length,
292
+ winRate: shortTrades.length
293
+ ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length
294
+ : 0,
295
+ avgPnL: mean(shortPnls),
296
+ avgR: mean(shortRs),
297
+ },
298
+ rDist: rDistribution,
299
+ holdDistMin: holdDistribution,
300
+ daily: {
301
+ count: dailyReturnsSeries.length,
302
+ winRate: dailyWinRate,
303
+ avgReturn: mean(dailyReturnsSeries),
304
+ },
305
+ };
306
+ }
@@ -0,0 +1,53 @@
1
+ import { exportHtmlReport } from "./renderHtmlReport.js";
2
+ import { exportTradesCsv } from "./exportTradesCsv.js";
3
+
4
+ export function exportBacktestArtifacts({
5
+ result,
6
+ symbol = result?.symbol,
7
+ interval = result?.interval ?? "tf",
8
+ range = result?.range ?? "range",
9
+ outDir = "output",
10
+ exportCsv = true,
11
+ exportHtml = true,
12
+ csvSource = "positions",
13
+ plotlyCdnUrl,
14
+ } = {}) {
15
+ if (!result) {
16
+ throw new Error("exportBacktestArtifacts() requires a backtest result");
17
+ }
18
+
19
+ const outputs = {
20
+ csv: null,
21
+ html: null,
22
+ };
23
+
24
+ const csvTrades =
25
+ csvSource === "trades"
26
+ ? result.trades
27
+ : result.positions ?? result.trades;
28
+
29
+ if (exportCsv) {
30
+ outputs.csv = exportTradesCsv(csvTrades, {
31
+ symbol,
32
+ interval,
33
+ range,
34
+ outDir,
35
+ });
36
+ }
37
+
38
+ if (exportHtml) {
39
+ outputs.html = exportHtmlReport({
40
+ symbol,
41
+ interval,
42
+ range,
43
+ metrics: result.metrics,
44
+ eqSeries: result.eqSeries,
45
+ replay: result.replay,
46
+ positions: result.positions ?? [],
47
+ outDir,
48
+ plotlyCdnUrl,
49
+ });
50
+ }
51
+
52
+ return outputs;
53
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ function safeSegment(value) {
5
+ return String(value).replace(/[^-_.A-Za-z0-9]/g, "_");
6
+ }
7
+
8
+ function tradeRMultiple(trade) {
9
+ const initialRisk = trade._initRisk || 0;
10
+ if (initialRisk <= 0) return 0;
11
+ const entry = trade.entryFill ?? trade.entry;
12
+ const perUnit =
13
+ trade.side === "long"
14
+ ? trade.exit.price - entry
15
+ : entry - trade.exit.price;
16
+ return perUnit / initialRisk;
17
+ }
18
+
19
+ export function exportTradesCsv(
20
+ closedTrades,
21
+ { symbol = "UNKNOWN", interval = "tf", range = "range", outDir = "output" } = {}
22
+ ) {
23
+ if (!closedTrades?.length) return null;
24
+
25
+ const rows = [
26
+ [
27
+ "time_open",
28
+ "time_close",
29
+ "side",
30
+ "entry",
31
+ "stop",
32
+ "takeProfit",
33
+ "exit",
34
+ "reason",
35
+ "size",
36
+ "pnl",
37
+ "R",
38
+ "mfeR",
39
+ "maeR",
40
+ "adds",
41
+ "entryATR",
42
+ "exitATR",
43
+ ].join(","),
44
+ ...closedTrades.map((trade) =>
45
+ [
46
+ new Date(trade.openTime).toISOString(),
47
+ new Date(trade.exit.time).toISOString(),
48
+ trade.side,
49
+ Number(trade.entry).toFixed(6),
50
+ Number(trade.stop).toFixed(6),
51
+ Number(trade.takeProfit).toFixed(6),
52
+ Number(trade.exit.price).toFixed(6),
53
+ trade.exit.reason,
54
+ trade.size,
55
+ trade.exit.pnl.toFixed(2),
56
+ tradeRMultiple(trade).toFixed(3),
57
+ (trade.mfeR ?? 0).toFixed(3),
58
+ (trade.maeR ?? 0).toFixed(3),
59
+ trade.adds ?? 0,
60
+ trade.entryATR !== undefined ? Number(trade.entryATR).toFixed(6) : "",
61
+ trade.exit.exitATR !== undefined
62
+ ? Number(trade.exit.exitATR).toFixed(6)
63
+ : "",
64
+ ].join(",")
65
+ ),
66
+ ].join("\n");
67
+
68
+ fs.mkdirSync(outDir, { recursive: true });
69
+ const filename = `trades-${safeSegment(symbol)}-${safeSegment(interval)}-${safeSegment(range)}.csv`;
70
+ const outputPath = path.join(outDir, filename);
71
+ fs.writeFileSync(outputPath, rows, "utf8");
72
+ return outputPath;
73
+ }