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.
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/examples/emaCross.js +108 -0
- package/examples/yahooEmaCross.js +88 -0
- package/package.json +42 -0
- package/scripts/import-csv.js +69 -0
- package/scripts/prefetch.js +52 -0
- package/src/data/csv.js +340 -0
- package/src/data/index.js +125 -0
- package/src/data/yahoo.js +245 -0
- package/src/engine/backtest.js +852 -0
- package/src/engine/execution.js +120 -0
- package/src/index.js +43 -0
- package/src/metrics/buildMetrics.js +306 -0
- package/src/reporting/exportBacktestArtifacts.js +53 -0
- package/src/reporting/exportTradesCsv.js +73 -0
- package/src/reporting/renderHtmlReport.js +310 -0
- package/src/utils/indicators.js +138 -0
- package/src/utils/positionSizing.js +26 -0
- package/src/utils/time.js +92 -0
- package/templates/report.css +213 -0
- package/templates/report.html +106 -0
- package/templates/report.js +120 -0
|
@@ -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
|
+
}
|