tradelab 0.1.2 → 0.2.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 +38 -15
- package/dist/cjs/data.cjs +1718 -0
- package/dist/cjs/index.cjs +2294 -0
- package/package.json +28 -6
- package/src/data/yahoo.js +40 -17
- package/src/engine/backtest.js +46 -4
- package/src/index.js +1 -0
- package/src/metrics/buildMetrics.js +32 -16
- package/src/reporting/exportBacktestArtifacts.js +13 -0
- package/src/reporting/exportMetricsJson.js +24 -0
- package/src/reporting/renderHtmlReport.js +26 -9
- package/types/data.d.ts +13 -0
- package/types/index.d.ts +512 -0
|
@@ -0,0 +1,1718 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// src/data/index.js
|
|
30
|
+
var index_exports = {};
|
|
31
|
+
__export(index_exports, {
|
|
32
|
+
backtestHistorical: () => backtestHistorical,
|
|
33
|
+
cachedCandlesPath: () => cachedCandlesPath,
|
|
34
|
+
candleStats: () => candleStats,
|
|
35
|
+
fetchHistorical: () => fetchHistorical,
|
|
36
|
+
fetchLatestCandle: () => fetchLatestCandle,
|
|
37
|
+
getHistoricalCandles: () => getHistoricalCandles,
|
|
38
|
+
loadCandlesFromCSV: () => loadCandlesFromCSV,
|
|
39
|
+
loadCandlesFromCache: () => loadCandlesFromCache,
|
|
40
|
+
mergeCandles: () => mergeCandles,
|
|
41
|
+
normalizeCandles: () => normalizeCandles,
|
|
42
|
+
saveCandlesToCache: () => saveCandlesToCache
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(index_exports);
|
|
45
|
+
var import_path2 = __toESM(require("path"), 1);
|
|
46
|
+
|
|
47
|
+
// src/utils/indicators.js
|
|
48
|
+
function atr(bars, period = 14) {
|
|
49
|
+
if (!bars?.length || period <= 0) return [];
|
|
50
|
+
const trueRanges = new Array(bars.length);
|
|
51
|
+
for (let index = 0; index < bars.length; index += 1) {
|
|
52
|
+
if (index === 0) {
|
|
53
|
+
trueRanges[index] = bars[index].high - bars[index].low;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const high = bars[index].high;
|
|
57
|
+
const low = bars[index].low;
|
|
58
|
+
const previousClose = bars[index - 1].close;
|
|
59
|
+
trueRanges[index] = Math.max(
|
|
60
|
+
high - low,
|
|
61
|
+
Math.abs(high - previousClose),
|
|
62
|
+
Math.abs(low - previousClose)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const output = new Array(trueRanges.length);
|
|
66
|
+
let previousAtr;
|
|
67
|
+
for (let index = 0; index < trueRanges.length; index += 1) {
|
|
68
|
+
if (index < period) {
|
|
69
|
+
output[index] = void 0;
|
|
70
|
+
if (index === period - 1) {
|
|
71
|
+
let seed = 0;
|
|
72
|
+
for (let cursor = 0; cursor < period; cursor += 1) {
|
|
73
|
+
seed += trueRanges[cursor];
|
|
74
|
+
}
|
|
75
|
+
previousAtr = seed / period;
|
|
76
|
+
output[index] = previousAtr;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
previousAtr = (previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
81
|
+
output[index] = previousAtr;
|
|
82
|
+
}
|
|
83
|
+
return output;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/utils/positionSizing.js
|
|
87
|
+
function roundStep(value, step) {
|
|
88
|
+
return Math.floor(value / step) * step;
|
|
89
|
+
}
|
|
90
|
+
function calculatePositionSize({
|
|
91
|
+
equity,
|
|
92
|
+
entry,
|
|
93
|
+
stop,
|
|
94
|
+
riskFraction = 0.01,
|
|
95
|
+
qtyStep = 1e-3,
|
|
96
|
+
minQty = 1e-3,
|
|
97
|
+
maxLeverage = 2
|
|
98
|
+
}) {
|
|
99
|
+
const riskPerUnit = Math.abs(entry - stop);
|
|
100
|
+
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
101
|
+
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
102
|
+
let quantity = maxRiskDollars / riskPerUnit;
|
|
103
|
+
const leverageCapQty = equity * maxLeverage / Math.max(1e-12, Math.abs(entry));
|
|
104
|
+
quantity = Math.min(quantity, leverageCapQty);
|
|
105
|
+
quantity = roundStep(quantity, qtyStep);
|
|
106
|
+
return quantity >= minQty ? quantity : 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/metrics/buildMetrics.js
|
|
110
|
+
function sum(values) {
|
|
111
|
+
return values.reduce((total, value) => total + value, 0);
|
|
112
|
+
}
|
|
113
|
+
function mean(values) {
|
|
114
|
+
return values.length ? sum(values) / values.length : 0;
|
|
115
|
+
}
|
|
116
|
+
function stddev(values) {
|
|
117
|
+
if (values.length <= 1) return 0;
|
|
118
|
+
const avg = mean(values);
|
|
119
|
+
return Math.sqrt(mean(values.map((value) => (value - avg) ** 2)));
|
|
120
|
+
}
|
|
121
|
+
function sortino(values) {
|
|
122
|
+
const losses = values.filter((value) => value < 0);
|
|
123
|
+
const downsideDeviation = stddev(losses.length ? losses : [0]);
|
|
124
|
+
const avg = mean(values);
|
|
125
|
+
return downsideDeviation === 0 ? avg > 0 ? Infinity : 0 : avg / downsideDeviation;
|
|
126
|
+
}
|
|
127
|
+
function dayKeyUTC(timeMs) {
|
|
128
|
+
const date = new Date(timeMs);
|
|
129
|
+
return [
|
|
130
|
+
date.getUTCFullYear(),
|
|
131
|
+
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
132
|
+
String(date.getUTCDate()).padStart(2, "0")
|
|
133
|
+
].join("-");
|
|
134
|
+
}
|
|
135
|
+
function tradeRMultiple(trade) {
|
|
136
|
+
const initialRisk = trade._initRisk || 0;
|
|
137
|
+
if (initialRisk <= 0) return 0;
|
|
138
|
+
const entry = trade.entryFill ?? trade.entry;
|
|
139
|
+
const perUnit = trade.side === "long" ? trade.exit.price - entry : entry - trade.exit.price;
|
|
140
|
+
return perUnit / initialRisk;
|
|
141
|
+
}
|
|
142
|
+
function streaks(labels) {
|
|
143
|
+
let wins = 0;
|
|
144
|
+
let losses = 0;
|
|
145
|
+
let maxWins = 0;
|
|
146
|
+
let maxLosses = 0;
|
|
147
|
+
for (const label of labels) {
|
|
148
|
+
if (label === "win") {
|
|
149
|
+
wins += 1;
|
|
150
|
+
losses = 0;
|
|
151
|
+
if (wins > maxWins) maxWins = wins;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (label === "loss") {
|
|
155
|
+
losses += 1;
|
|
156
|
+
wins = 0;
|
|
157
|
+
if (losses > maxLosses) maxLosses = losses;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
wins = 0;
|
|
161
|
+
losses = 0;
|
|
162
|
+
}
|
|
163
|
+
return { maxWin: maxWins, maxLoss: maxLosses };
|
|
164
|
+
}
|
|
165
|
+
function buildEquitySeriesFromLegs({ legs, equityStart }) {
|
|
166
|
+
const firstTime = legs.length ? legs[0].exit.time : Date.now();
|
|
167
|
+
const series = [{ time: firstTime, equity: equityStart }];
|
|
168
|
+
let equity = equityStart;
|
|
169
|
+
for (const leg of legs) {
|
|
170
|
+
equity += leg.exit.pnl;
|
|
171
|
+
series.push({ time: leg.exit.time, equity });
|
|
172
|
+
}
|
|
173
|
+
return series;
|
|
174
|
+
}
|
|
175
|
+
function dailyReturns(eqSeries) {
|
|
176
|
+
if (!eqSeries?.length) return [];
|
|
177
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const point of eqSeries) {
|
|
179
|
+
const day = dayKeyUTC(point.time);
|
|
180
|
+
const record = byDay.get(day) || {
|
|
181
|
+
open: point.equity,
|
|
182
|
+
close: point.equity,
|
|
183
|
+
first: point.time,
|
|
184
|
+
last: point.time
|
|
185
|
+
};
|
|
186
|
+
if (point.time < record.first) {
|
|
187
|
+
record.first = point.time;
|
|
188
|
+
record.open = point.equity;
|
|
189
|
+
}
|
|
190
|
+
if (point.time >= record.last) {
|
|
191
|
+
record.last = point.time;
|
|
192
|
+
record.close = point.equity;
|
|
193
|
+
}
|
|
194
|
+
byDay.set(day, record);
|
|
195
|
+
}
|
|
196
|
+
const returns = [];
|
|
197
|
+
for (const { open, close } of byDay.values()) {
|
|
198
|
+
if (open > 0 && Number.isFinite(open) && Number.isFinite(close)) {
|
|
199
|
+
returns.push((close - open) / open);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return returns;
|
|
203
|
+
}
|
|
204
|
+
function percentile(values, percentileRank) {
|
|
205
|
+
if (!values.length) return 0;
|
|
206
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
207
|
+
const index = Math.floor((sorted.length - 1) * percentileRank);
|
|
208
|
+
return sorted[index];
|
|
209
|
+
}
|
|
210
|
+
function buildMetrics({
|
|
211
|
+
closed,
|
|
212
|
+
equityStart,
|
|
213
|
+
equityFinal,
|
|
214
|
+
candles,
|
|
215
|
+
estBarMs,
|
|
216
|
+
eqSeries
|
|
217
|
+
}) {
|
|
218
|
+
const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
219
|
+
const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
|
|
220
|
+
const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
|
|
221
|
+
const tradeRs = completedTrades.map(tradeRMultiple);
|
|
222
|
+
const totalR = sum(tradeRs);
|
|
223
|
+
const avgR = mean(tradeRs);
|
|
224
|
+
const labels = completedTrades.map(
|
|
225
|
+
(trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
|
|
226
|
+
);
|
|
227
|
+
const { maxWin, maxLoss } = streaks(labels);
|
|
228
|
+
const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
|
|
229
|
+
const expectancy = mean(tradePnls);
|
|
230
|
+
const tradeReturns = completedTrades.map(
|
|
231
|
+
(trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
|
|
232
|
+
);
|
|
233
|
+
const tradeReturnStd = stddev(tradeReturns);
|
|
234
|
+
const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
|
|
235
|
+
const sortinoPerTrade = sortino(tradeReturns);
|
|
236
|
+
const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
|
|
237
|
+
const grossLossPositions = Math.abs(
|
|
238
|
+
sum(losingTrades.map((trade) => trade.exit.pnl))
|
|
239
|
+
);
|
|
240
|
+
const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
|
|
241
|
+
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
242
|
+
const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
|
|
243
|
+
const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
|
|
244
|
+
const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
|
|
245
|
+
const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
|
|
246
|
+
const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
|
|
247
|
+
let peakEquity = equityStart;
|
|
248
|
+
let currentEquity = equityStart;
|
|
249
|
+
let maxDrawdown = 0;
|
|
250
|
+
for (const leg of legs) {
|
|
251
|
+
currentEquity += leg.exit.pnl;
|
|
252
|
+
if (currentEquity > peakEquity) peakEquity = currentEquity;
|
|
253
|
+
const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
|
|
254
|
+
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
|
255
|
+
}
|
|
256
|
+
const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
|
|
257
|
+
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
258
|
+
const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
|
|
259
|
+
const totalBars = Math.max(1, candles.length);
|
|
260
|
+
const openBars = completedTrades.reduce((total, trade) => {
|
|
261
|
+
const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
|
|
262
|
+
return total + barsHeld;
|
|
263
|
+
}, 0);
|
|
264
|
+
const exposurePct = openBars / totalBars;
|
|
265
|
+
const holdDurationsMinutes = completedTrades.map(
|
|
266
|
+
(trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
|
|
267
|
+
);
|
|
268
|
+
const avgHoldMin = mean(holdDurationsMinutes);
|
|
269
|
+
const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
270
|
+
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
271
|
+
const dailyStd = stddev(dailyReturnsSeries);
|
|
272
|
+
const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
|
|
273
|
+
const sortinoDaily = sortino(dailyReturnsSeries);
|
|
274
|
+
const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
|
|
275
|
+
const longTrades = completedTrades.filter((trade) => trade.side === "long");
|
|
276
|
+
const shortTrades = completedTrades.filter((trade) => trade.side === "short");
|
|
277
|
+
const longRs = longTrades.map(tradeRMultiple);
|
|
278
|
+
const shortRs = shortTrades.map(tradeRMultiple);
|
|
279
|
+
const longPnls = longTrades.map((trade) => trade.exit.pnl);
|
|
280
|
+
const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
|
|
281
|
+
const rDistribution = {
|
|
282
|
+
p10: percentile(tradeRs, 0.1),
|
|
283
|
+
p25: percentile(tradeRs, 0.25),
|
|
284
|
+
p50: percentile(tradeRs, 0.5),
|
|
285
|
+
p75: percentile(tradeRs, 0.75),
|
|
286
|
+
p90: percentile(tradeRs, 0.9)
|
|
287
|
+
};
|
|
288
|
+
const holdDistribution = {
|
|
289
|
+
p10: percentile(holdDurationsMinutes, 0.1),
|
|
290
|
+
p25: percentile(holdDurationsMinutes, 0.25),
|
|
291
|
+
p50: percentile(holdDurationsMinutes, 0.5),
|
|
292
|
+
p75: percentile(holdDurationsMinutes, 0.75),
|
|
293
|
+
p90: percentile(holdDurationsMinutes, 0.9)
|
|
294
|
+
};
|
|
295
|
+
const sideBreakdown = {
|
|
296
|
+
long: {
|
|
297
|
+
trades: longTrades.length,
|
|
298
|
+
winRate: longTrades.length ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length : 0,
|
|
299
|
+
avgPnL: mean(longPnls),
|
|
300
|
+
avgR: mean(longRs)
|
|
301
|
+
},
|
|
302
|
+
short: {
|
|
303
|
+
trades: shortTrades.length,
|
|
304
|
+
winRate: shortTrades.length ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length : 0,
|
|
305
|
+
avgPnL: mean(shortPnls),
|
|
306
|
+
avgR: mean(shortRs)
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
trades: completedTrades.length,
|
|
311
|
+
winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
|
|
312
|
+
profitFactor: profitFactorPositions,
|
|
313
|
+
expectancy,
|
|
314
|
+
totalR,
|
|
315
|
+
avgR,
|
|
316
|
+
sharpe: sharpeDaily,
|
|
317
|
+
sharpePerTrade,
|
|
318
|
+
sortinoPerTrade,
|
|
319
|
+
maxDrawdown,
|
|
320
|
+
maxDrawdownPct: maxDrawdown,
|
|
321
|
+
calmar,
|
|
322
|
+
maxConsecWins: maxWin,
|
|
323
|
+
maxConsecLosses: maxLoss,
|
|
324
|
+
avgHold: avgHoldMin,
|
|
325
|
+
avgHoldMin,
|
|
326
|
+
exposurePct,
|
|
327
|
+
totalPnL: realizedPnL,
|
|
328
|
+
returnPct,
|
|
329
|
+
finalEquity: equityFinal,
|
|
330
|
+
startEquity: equityStart,
|
|
331
|
+
profitFactor_pos: profitFactorPositions,
|
|
332
|
+
profitFactor_leg: profitFactorLegs,
|
|
333
|
+
winRate_pos: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
|
|
334
|
+
winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
|
|
335
|
+
sharpeDaily,
|
|
336
|
+
sortinoDaily,
|
|
337
|
+
sideBreakdown,
|
|
338
|
+
long: sideBreakdown.long,
|
|
339
|
+
short: sideBreakdown.short,
|
|
340
|
+
rDist: rDistribution,
|
|
341
|
+
holdDistMin: holdDistribution,
|
|
342
|
+
daily: {
|
|
343
|
+
count: dailyReturnsSeries.length,
|
|
344
|
+
winRate: dailyWinRate,
|
|
345
|
+
avgReturn: mean(dailyReturnsSeries)
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/data/csv.js
|
|
351
|
+
var import_fs = __toESM(require("fs"), 1);
|
|
352
|
+
var import_path = __toESM(require("path"), 1);
|
|
353
|
+
function safeSegment(value) {
|
|
354
|
+
return String(value).replace(/[^-_.A-Za-z0-9]/g, "_");
|
|
355
|
+
}
|
|
356
|
+
function resolveDate(value, customDateParser) {
|
|
357
|
+
if (value === void 0 || value === null || value === "") {
|
|
358
|
+
throw new Error("Missing date value");
|
|
359
|
+
}
|
|
360
|
+
if (typeof customDateParser === "function") {
|
|
361
|
+
const parsed2 = customDateParser(value);
|
|
362
|
+
const time = parsed2 instanceof Date ? parsed2.getTime() : Number(parsed2);
|
|
363
|
+
if (Number.isFinite(time)) return time;
|
|
364
|
+
}
|
|
365
|
+
if (value instanceof Date) {
|
|
366
|
+
const time = value.getTime();
|
|
367
|
+
if (Number.isFinite(time)) return time;
|
|
368
|
+
}
|
|
369
|
+
const raw = String(value).trim().replace(/^['"]|['"]$/g, "");
|
|
370
|
+
const numeric = Number(raw);
|
|
371
|
+
if (Number.isFinite(numeric)) {
|
|
372
|
+
return numeric < 1e11 ? numeric * 1e3 : numeric;
|
|
373
|
+
}
|
|
374
|
+
const parsed = Date.parse(raw);
|
|
375
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
376
|
+
const mt = raw.match(/^(\d{4})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
377
|
+
if (mt) {
|
|
378
|
+
const [, year, month, day, hour, minute, second = "0"] = mt;
|
|
379
|
+
return new Date(
|
|
380
|
+
Number(year),
|
|
381
|
+
Number(month) - 1,
|
|
382
|
+
Number(day),
|
|
383
|
+
Number(hour),
|
|
384
|
+
Number(minute),
|
|
385
|
+
Number(second)
|
|
386
|
+
).getTime();
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Cannot parse date: ${raw}`);
|
|
389
|
+
}
|
|
390
|
+
function parseCsvLine(line, delimiter) {
|
|
391
|
+
const out = [];
|
|
392
|
+
let current = "";
|
|
393
|
+
let inQuotes = false;
|
|
394
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
395
|
+
const char = line[index];
|
|
396
|
+
if (char === '"') {
|
|
397
|
+
if (inQuotes && line[index + 1] === '"') {
|
|
398
|
+
current += '"';
|
|
399
|
+
index += 1;
|
|
400
|
+
} else {
|
|
401
|
+
inQuotes = !inQuotes;
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!inQuotes && char === delimiter) {
|
|
406
|
+
out.push(current.trim());
|
|
407
|
+
current = "";
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
current += char;
|
|
411
|
+
}
|
|
412
|
+
out.push(current.trim());
|
|
413
|
+
return out.map((value) => value.replace(/^['"]|['"]$/g, ""));
|
|
414
|
+
}
|
|
415
|
+
function buildHeaderIndex(headers) {
|
|
416
|
+
const map = /* @__PURE__ */ new Map();
|
|
417
|
+
headers.forEach((header, index) => {
|
|
418
|
+
map.set(header.trim().toLowerCase(), index);
|
|
419
|
+
});
|
|
420
|
+
return map;
|
|
421
|
+
}
|
|
422
|
+
function resolveColumn(column, headerIndex, aliases = []) {
|
|
423
|
+
if (typeof column === "number" && column >= 0) return column;
|
|
424
|
+
const candidates = [column, ...aliases].filter((value) => value !== void 0 && value !== null).map((value) => String(value).trim().toLowerCase());
|
|
425
|
+
for (const candidate of candidates) {
|
|
426
|
+
if (headerIndex.has(candidate)) return headerIndex.get(candidate);
|
|
427
|
+
}
|
|
428
|
+
return -1;
|
|
429
|
+
}
|
|
430
|
+
function normalizeDateBoundary(value, fallback) {
|
|
431
|
+
if (!value) return fallback;
|
|
432
|
+
if (value instanceof Date) return value.getTime();
|
|
433
|
+
const parsed = Date.parse(String(value));
|
|
434
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
435
|
+
}
|
|
436
|
+
function normalizeCandles(candles) {
|
|
437
|
+
if (!Array.isArray(candles)) return [];
|
|
438
|
+
const normalized = candles.map((bar) => {
|
|
439
|
+
try {
|
|
440
|
+
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
441
|
+
const open = Number(bar?.open ?? bar?.o);
|
|
442
|
+
const high = Number(bar?.high ?? bar?.h);
|
|
443
|
+
const low = Number(bar?.low ?? bar?.l);
|
|
444
|
+
const close = Number(bar?.close ?? bar?.c);
|
|
445
|
+
const volume = Number(bar?.volume ?? bar?.v ?? 0);
|
|
446
|
+
if (!Number.isFinite(time) || !Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
time,
|
|
451
|
+
open,
|
|
452
|
+
high: Math.max(high, open, close),
|
|
453
|
+
low: Math.min(low, open, close),
|
|
454
|
+
close,
|
|
455
|
+
volume: Number.isFinite(volume) ? volume : 0
|
|
456
|
+
};
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}).filter(Boolean).sort((left, right) => left.time - right.time);
|
|
461
|
+
const deduped = [];
|
|
462
|
+
let lastTime = null;
|
|
463
|
+
for (const candle of normalized) {
|
|
464
|
+
if (candle.time === lastTime) continue;
|
|
465
|
+
deduped.push(candle);
|
|
466
|
+
lastTime = candle.time;
|
|
467
|
+
}
|
|
468
|
+
return deduped;
|
|
469
|
+
}
|
|
470
|
+
function loadCandlesFromCSV(filePath, options = {}) {
|
|
471
|
+
const {
|
|
472
|
+
delimiter = ",",
|
|
473
|
+
skipRows = 0,
|
|
474
|
+
hasHeader = true,
|
|
475
|
+
timeCol = "time",
|
|
476
|
+
openCol = "open",
|
|
477
|
+
highCol = "high",
|
|
478
|
+
lowCol = "low",
|
|
479
|
+
closeCol = "close",
|
|
480
|
+
volumeCol = "volume",
|
|
481
|
+
startDate,
|
|
482
|
+
endDate,
|
|
483
|
+
customDateParser
|
|
484
|
+
} = options;
|
|
485
|
+
if (!import_fs.default.existsSync(filePath)) {
|
|
486
|
+
throw new Error(`CSV file not found: ${filePath}`);
|
|
487
|
+
}
|
|
488
|
+
const content = import_fs.default.readFileSync(filePath, "utf8");
|
|
489
|
+
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
490
|
+
if (lines.length <= skipRows) {
|
|
491
|
+
throw new Error(`CSV file is empty: ${filePath}`);
|
|
492
|
+
}
|
|
493
|
+
const headerRow = hasHeader ? parseCsvLine(lines[skipRows], delimiter) : [];
|
|
494
|
+
const headerIndex = buildHeaderIndex(headerRow);
|
|
495
|
+
const startRow = hasHeader ? skipRows + 1 : skipRows;
|
|
496
|
+
const timeIdx = resolveColumn(timeCol, headerIndex, [
|
|
497
|
+
"date",
|
|
498
|
+
"datetime",
|
|
499
|
+
"timestamp",
|
|
500
|
+
"ts",
|
|
501
|
+
"open time",
|
|
502
|
+
"opentime"
|
|
503
|
+
]);
|
|
504
|
+
const openIdx = resolveColumn(openCol, headerIndex, ["o"]);
|
|
505
|
+
const highIdx = resolveColumn(highCol, headerIndex, ["h"]);
|
|
506
|
+
const lowIdx = resolveColumn(lowCol, headerIndex, ["l"]);
|
|
507
|
+
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
508
|
+
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
509
|
+
if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
515
|
+
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
516
|
+
const candles = [];
|
|
517
|
+
for (let row = startRow; row < lines.length; row += 1) {
|
|
518
|
+
const cols = parseCsvLine(lines[row], delimiter);
|
|
519
|
+
try {
|
|
520
|
+
const time = resolveDate(cols[timeIdx], customDateParser);
|
|
521
|
+
if (time < minTime || time > maxTime) continue;
|
|
522
|
+
const open = Number(cols[openIdx]);
|
|
523
|
+
const high = Number(cols[highIdx]);
|
|
524
|
+
const low = Number(cols[lowIdx]);
|
|
525
|
+
const close = Number(cols[closeIdx]);
|
|
526
|
+
const volume = volumeIdx >= 0 ? Number(cols[volumeIdx]) : 0;
|
|
527
|
+
if (!Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
candles.push({
|
|
531
|
+
time,
|
|
532
|
+
open,
|
|
533
|
+
high: Math.max(high, open, close),
|
|
534
|
+
low: Math.min(low, open, close),
|
|
535
|
+
close,
|
|
536
|
+
volume: Number.isFinite(volume) ? volume : 0
|
|
537
|
+
});
|
|
538
|
+
} catch {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return normalizeCandles(candles);
|
|
543
|
+
}
|
|
544
|
+
function mergeCandles(...arrays) {
|
|
545
|
+
return normalizeCandles(arrays.flat());
|
|
546
|
+
}
|
|
547
|
+
function candleStats(candles) {
|
|
548
|
+
if (!candles?.length) return null;
|
|
549
|
+
const normalized = normalizeCandles(candles);
|
|
550
|
+
const first = normalized[0];
|
|
551
|
+
const last = normalized[normalized.length - 1];
|
|
552
|
+
const gaps = [];
|
|
553
|
+
let minLow = Infinity;
|
|
554
|
+
let maxHigh = -Infinity;
|
|
555
|
+
for (const candle of normalized) {
|
|
556
|
+
if (candle.low < minLow) minLow = candle.low;
|
|
557
|
+
if (candle.high > maxHigh) maxHigh = candle.high;
|
|
558
|
+
}
|
|
559
|
+
for (let index = 1; index < Math.min(normalized.length, 500); index += 1) {
|
|
560
|
+
const delta = normalized[index].time - normalized[index - 1].time;
|
|
561
|
+
if (delta > 0) gaps.push(delta);
|
|
562
|
+
}
|
|
563
|
+
gaps.sort((left, right) => left - right);
|
|
564
|
+
const medianGapMs = gaps[Math.floor(gaps.length / 2)] || 0;
|
|
565
|
+
return {
|
|
566
|
+
count: normalized.length,
|
|
567
|
+
firstTime: new Date(first.time).toISOString(),
|
|
568
|
+
lastTime: new Date(last.time).toISOString(),
|
|
569
|
+
durationDays: (last.time - first.time) / (1e3 * 60 * 60 * 24),
|
|
570
|
+
estimatedIntervalMin: Math.round(medianGapMs / 6e4),
|
|
571
|
+
priceRange: {
|
|
572
|
+
low: minLow,
|
|
573
|
+
high: maxHigh
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function saveCandlesToCache(candles, { symbol = "UNKNOWN", interval = "tf", period = "range", outDir = "output/data", source } = {}) {
|
|
578
|
+
const outputPath = import_path.default.join(
|
|
579
|
+
outDir,
|
|
580
|
+
`candles-${safeSegment(symbol)}-${safeSegment(interval)}-${safeSegment(period)}.json`
|
|
581
|
+
);
|
|
582
|
+
const normalized = normalizeCandles(candles);
|
|
583
|
+
import_fs.default.mkdirSync(outDir, { recursive: true });
|
|
584
|
+
import_fs.default.writeFileSync(
|
|
585
|
+
outputPath,
|
|
586
|
+
JSON.stringify(
|
|
587
|
+
{
|
|
588
|
+
symbol,
|
|
589
|
+
interval,
|
|
590
|
+
period,
|
|
591
|
+
source: source ?? null,
|
|
592
|
+
count: normalized.length,
|
|
593
|
+
asOf: (/* @__PURE__ */ new Date()).toISOString(),
|
|
594
|
+
candles: normalized
|
|
595
|
+
},
|
|
596
|
+
null,
|
|
597
|
+
2
|
|
598
|
+
),
|
|
599
|
+
"utf8"
|
|
600
|
+
);
|
|
601
|
+
return outputPath;
|
|
602
|
+
}
|
|
603
|
+
function cachedCandlesPath(symbol, interval, period, outDir = "output/data") {
|
|
604
|
+
const fileName = `candles-${safeSegment(symbol)}-${safeSegment(interval)}-${safeSegment(period)}.json`;
|
|
605
|
+
return import_path.default.join(outDir, fileName);
|
|
606
|
+
}
|
|
607
|
+
function loadCandlesFromCache(symbol, interval, period, outDir = "output/data") {
|
|
608
|
+
const filePath = cachedCandlesPath(symbol, interval, period, outDir);
|
|
609
|
+
if (!import_fs.default.existsSync(filePath)) return null;
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(import_fs.default.readFileSync(filePath, "utf8"));
|
|
612
|
+
return Array.isArray(parsed?.candles) ? normalizeCandles(parsed.candles) : null;
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/utils/time.js
|
|
619
|
+
function usDstBoundsUTC(year) {
|
|
620
|
+
let marchCursor = new Date(Date.UTC(year, 2, 1, 7, 0, 0));
|
|
621
|
+
let sundaysSeen = 0;
|
|
622
|
+
while (marchCursor.getUTCMonth() === 2) {
|
|
623
|
+
if (marchCursor.getUTCDay() === 0) sundaysSeen += 1;
|
|
624
|
+
if (sundaysSeen === 2) break;
|
|
625
|
+
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
|
|
626
|
+
}
|
|
627
|
+
const dstStart = new Date(
|
|
628
|
+
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
629
|
+
);
|
|
630
|
+
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
631
|
+
while (novemberCursor.getUTCDay() !== 0) {
|
|
632
|
+
novemberCursor = new Date(
|
|
633
|
+
novemberCursor.getTime() + 24 * 60 * 60 * 1e3
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const dstEnd = new Date(
|
|
637
|
+
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
638
|
+
);
|
|
639
|
+
return { dstStart, dstEnd };
|
|
640
|
+
}
|
|
641
|
+
function isUsEasternDST(timeMs) {
|
|
642
|
+
const date = new Date(timeMs);
|
|
643
|
+
const { dstStart, dstEnd } = usDstBoundsUTC(date.getUTCFullYear());
|
|
644
|
+
return date >= dstStart && date < dstEnd;
|
|
645
|
+
}
|
|
646
|
+
function offsetET(timeMs) {
|
|
647
|
+
return isUsEasternDST(timeMs) ? 4 : 5;
|
|
648
|
+
}
|
|
649
|
+
function minutesET(timeMs) {
|
|
650
|
+
const date = new Date(timeMs);
|
|
651
|
+
const offset = offsetET(timeMs);
|
|
652
|
+
return (date.getUTCHours() - offset + 24) % 24 * 60 + date.getUTCMinutes();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/engine/execution.js
|
|
656
|
+
function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market" } = {}) {
|
|
657
|
+
let effectiveSlippageBps = slippageBps;
|
|
658
|
+
if (kind === "limit") effectiveSlippageBps *= 0.25;
|
|
659
|
+
if (kind === "stop") effectiveSlippageBps *= 1.25;
|
|
660
|
+
const slippage = effectiveSlippageBps / 1e4 * price;
|
|
661
|
+
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
662
|
+
const feePerUnit = feeBps / 1e4 * Math.abs(filledPrice);
|
|
663
|
+
return { price: filledPrice, fee: feePerUnit };
|
|
664
|
+
}
|
|
665
|
+
function clampStop(marketPrice, proposedStop, side, oco) {
|
|
666
|
+
const epsilon = (oco?.clampEpsBps ?? 0.25) / 1e4;
|
|
667
|
+
const epsilonAbs = marketPrice * epsilon;
|
|
668
|
+
return side === "long" ? Math.min(proposedStop, marketPrice - epsilonAbs) : Math.max(proposedStop, marketPrice + epsilonAbs);
|
|
669
|
+
}
|
|
670
|
+
function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
671
|
+
if (!bar || limitPrice === void 0 || limitPrice === null) return false;
|
|
672
|
+
if (mode === "close") {
|
|
673
|
+
return side === "long" ? bar.close <= limitPrice : bar.close >= limitPrice;
|
|
674
|
+
}
|
|
675
|
+
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
676
|
+
}
|
|
677
|
+
function ocoExitCheck({
|
|
678
|
+
side,
|
|
679
|
+
stop,
|
|
680
|
+
tp,
|
|
681
|
+
bar,
|
|
682
|
+
mode = "intrabar",
|
|
683
|
+
tieBreak = "pessimistic"
|
|
684
|
+
}) {
|
|
685
|
+
if (mode === "close") {
|
|
686
|
+
const close = bar.close;
|
|
687
|
+
if (side === "long") {
|
|
688
|
+
if (close <= stop) return { hit: "SL", px: stop };
|
|
689
|
+
if (close >= tp) return { hit: "TP", px: tp };
|
|
690
|
+
} else {
|
|
691
|
+
if (close >= stop) return { hit: "SL", px: stop };
|
|
692
|
+
if (close <= tp) return { hit: "TP", px: tp };
|
|
693
|
+
}
|
|
694
|
+
return { hit: null, px: null };
|
|
695
|
+
}
|
|
696
|
+
const hitStop = side === "long" ? bar.low <= stop : bar.high >= stop;
|
|
697
|
+
const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
|
|
698
|
+
if (hitStop && hitTarget) {
|
|
699
|
+
return tieBreak === "optimistic" ? { hit: "TP", px: tp } : { hit: "SL", px: stop };
|
|
700
|
+
}
|
|
701
|
+
if (hitStop) return { hit: "SL", px: stop };
|
|
702
|
+
if (hitTarget) return { hit: "TP", px: tp };
|
|
703
|
+
return { hit: null, px: null };
|
|
704
|
+
}
|
|
705
|
+
function isEODBar(timeMs) {
|
|
706
|
+
return minutesET(timeMs) >= 16 * 60;
|
|
707
|
+
}
|
|
708
|
+
function roundStep2(value, step = 1e-3) {
|
|
709
|
+
return Math.floor(value / step) * step;
|
|
710
|
+
}
|
|
711
|
+
function estimateBarMs(candles) {
|
|
712
|
+
if (candles.length >= 2) {
|
|
713
|
+
const deltas = [];
|
|
714
|
+
for (let index = 1; index < Math.min(candles.length, 500); index += 1) {
|
|
715
|
+
const delta = candles[index].time - candles[index - 1].time;
|
|
716
|
+
if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
|
|
717
|
+
}
|
|
718
|
+
if (deltas.length) {
|
|
719
|
+
deltas.sort((a, b) => a - b);
|
|
720
|
+
const middle = Math.floor(deltas.length / 2);
|
|
721
|
+
const median = deltas.length % 2 ? deltas[middle] : (deltas[middle - 1] + deltas[middle]) / 2;
|
|
722
|
+
return Math.max(6e4, Math.min(median, 60 * 6e4));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return 5 * 60 * 1e3;
|
|
726
|
+
}
|
|
727
|
+
function dayKeyUTC2(timeMs) {
|
|
728
|
+
const date = new Date(timeMs);
|
|
729
|
+
return [
|
|
730
|
+
date.getUTCFullYear(),
|
|
731
|
+
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
732
|
+
String(date.getUTCDate()).padStart(2, "0")
|
|
733
|
+
].join("-");
|
|
734
|
+
}
|
|
735
|
+
function dayKeyET(timeMs) {
|
|
736
|
+
const date = new Date(timeMs);
|
|
737
|
+
const minutes = minutesET(timeMs);
|
|
738
|
+
const hoursET = Math.floor(minutes / 60);
|
|
739
|
+
const minutesETDay = minutes % 60;
|
|
740
|
+
const anchor = new Date(
|
|
741
|
+
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
|
|
742
|
+
);
|
|
743
|
+
const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1e3 + minutesETDay * 60 * 1e3;
|
|
744
|
+
return dayKeyUTC2(pseudoEtTime);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/engine/backtest.js
|
|
748
|
+
function asNumber(value) {
|
|
749
|
+
const numeric = Number(value);
|
|
750
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
751
|
+
}
|
|
752
|
+
function equityPoint(time, equity) {
|
|
753
|
+
return { time, timestamp: time, equity };
|
|
754
|
+
}
|
|
755
|
+
function isArrayIndexKey(property) {
|
|
756
|
+
if (typeof property !== "string") return false;
|
|
757
|
+
const numeric = Number(property);
|
|
758
|
+
return Number.isInteger(numeric) && numeric >= 0;
|
|
759
|
+
}
|
|
760
|
+
function strictHistoryView(candles, currentIndex) {
|
|
761
|
+
return new Proxy(candles, {
|
|
762
|
+
get(target, property, receiver) {
|
|
763
|
+
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
return Reflect.get(target, property, receiver);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
function mergeOptions(options) {
|
|
773
|
+
const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
|
|
774
|
+
return {
|
|
775
|
+
candles: normalizeCandles(options.candles ?? []),
|
|
776
|
+
symbol: options.symbol ?? "UNKNOWN",
|
|
777
|
+
equity: options.equity ?? 1e4,
|
|
778
|
+
riskPct: normalizedRiskPct ?? 1,
|
|
779
|
+
signal: options.signal,
|
|
780
|
+
interval: options.interval,
|
|
781
|
+
range: options.range,
|
|
782
|
+
warmupBars: options.warmupBars ?? 200,
|
|
783
|
+
slippageBps: options.slippageBps ?? 1,
|
|
784
|
+
feeBps: options.feeBps ?? 0,
|
|
785
|
+
scaleOutAtR: options.scaleOutAtR ?? 1,
|
|
786
|
+
scaleOutFrac: options.scaleOutFrac ?? 0.5,
|
|
787
|
+
finalTP_R: options.finalTP_R ?? 3,
|
|
788
|
+
maxDailyLossPct: options.maxDailyLossPct ?? 2,
|
|
789
|
+
atrTrailMult: options.atrTrailMult ?? 0,
|
|
790
|
+
atrTrailPeriod: options.atrTrailPeriod ?? 14,
|
|
791
|
+
oco: {
|
|
792
|
+
mode: "intrabar",
|
|
793
|
+
tieBreak: "pessimistic",
|
|
794
|
+
clampStops: true,
|
|
795
|
+
clampEpsBps: 0.25,
|
|
796
|
+
...options.oco || {}
|
|
797
|
+
},
|
|
798
|
+
triggerMode: options.triggerMode,
|
|
799
|
+
flattenAtClose: options.flattenAtClose ?? true,
|
|
800
|
+
dailyMaxTrades: options.dailyMaxTrades ?? 0,
|
|
801
|
+
postLossCooldownBars: options.postLossCooldownBars ?? 0,
|
|
802
|
+
mfeTrail: {
|
|
803
|
+
enabled: false,
|
|
804
|
+
armR: 1,
|
|
805
|
+
givebackR: 0.5,
|
|
806
|
+
...options.mfeTrail || {}
|
|
807
|
+
},
|
|
808
|
+
pyramiding: {
|
|
809
|
+
enabled: false,
|
|
810
|
+
addAtR: 1,
|
|
811
|
+
addFrac: 0.25,
|
|
812
|
+
maxAdds: 1,
|
|
813
|
+
onlyAfterBreakEven: true,
|
|
814
|
+
...options.pyramiding || {}
|
|
815
|
+
},
|
|
816
|
+
volScale: {
|
|
817
|
+
enabled: false,
|
|
818
|
+
atrPeriod: options.atrTrailPeriod ?? 14,
|
|
819
|
+
cutIfAtrX: 1.3,
|
|
820
|
+
cutFrac: 0.33,
|
|
821
|
+
noCutAboveR: 1.5,
|
|
822
|
+
...options.volScale || {}
|
|
823
|
+
},
|
|
824
|
+
qtyStep: options.qtyStep ?? 1e-3,
|
|
825
|
+
minQty: options.minQty ?? 1e-3,
|
|
826
|
+
maxLeverage: options.maxLeverage ?? 2,
|
|
827
|
+
entryChase: {
|
|
828
|
+
enabled: true,
|
|
829
|
+
afterBars: 2,
|
|
830
|
+
maxSlipR: 0.2,
|
|
831
|
+
convertOnExpiry: false,
|
|
832
|
+
...options.entryChase || {}
|
|
833
|
+
},
|
|
834
|
+
reanchorStopOnFill: options.reanchorStopOnFill ?? true,
|
|
835
|
+
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
836
|
+
collectEqSeries: options.collectEqSeries ?? true,
|
|
837
|
+
collectReplay: options.collectReplay ?? true,
|
|
838
|
+
strict: options.strict ?? false
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function normalizeSide(value) {
|
|
842
|
+
if (value === "long" || value === "buy") return "long";
|
|
843
|
+
if (value === "short" || value === "sell") return "short";
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
function normalizeSignal(signal, bar, fallbackR) {
|
|
847
|
+
if (!signal) return null;
|
|
848
|
+
const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
|
|
849
|
+
if (!side) return null;
|
|
850
|
+
const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
|
|
851
|
+
const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
|
|
852
|
+
if (entry === null || stop === null) return null;
|
|
853
|
+
const risk = Math.abs(entry - stop);
|
|
854
|
+
if (!(risk > 0)) return null;
|
|
855
|
+
let takeProfit = asNumber(signal.takeProfit ?? signal.target ?? signal.tp);
|
|
856
|
+
const rrHint = asNumber(signal._rr ?? signal.rr);
|
|
857
|
+
const targetR = rrHint ?? fallbackR;
|
|
858
|
+
if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
|
|
859
|
+
takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
|
|
860
|
+
}
|
|
861
|
+
if (takeProfit === null) return null;
|
|
862
|
+
return {
|
|
863
|
+
...signal,
|
|
864
|
+
side,
|
|
865
|
+
entry,
|
|
866
|
+
stop,
|
|
867
|
+
takeProfit,
|
|
868
|
+
qty: asNumber(signal.qty ?? signal.size),
|
|
869
|
+
riskPct: asNumber(signal.riskPct),
|
|
870
|
+
riskFraction: asNumber(signal.riskFraction),
|
|
871
|
+
_rr: rrHint ?? signal._rr,
|
|
872
|
+
_initRisk: asNumber(signal._initRisk) ?? signal._initRisk
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function backtest(rawOptions) {
|
|
876
|
+
const options = mergeOptions(rawOptions || {});
|
|
877
|
+
const {
|
|
878
|
+
candles,
|
|
879
|
+
symbol,
|
|
880
|
+
equity,
|
|
881
|
+
riskPct,
|
|
882
|
+
signal,
|
|
883
|
+
slippageBps,
|
|
884
|
+
feeBps,
|
|
885
|
+
scaleOutAtR,
|
|
886
|
+
scaleOutFrac,
|
|
887
|
+
finalTP_R,
|
|
888
|
+
maxDailyLossPct,
|
|
889
|
+
atrTrailMult,
|
|
890
|
+
atrTrailPeriod,
|
|
891
|
+
oco,
|
|
892
|
+
triggerMode,
|
|
893
|
+
flattenAtClose,
|
|
894
|
+
dailyMaxTrades,
|
|
895
|
+
postLossCooldownBars,
|
|
896
|
+
mfeTrail,
|
|
897
|
+
pyramiding,
|
|
898
|
+
volScale,
|
|
899
|
+
qtyStep,
|
|
900
|
+
minQty,
|
|
901
|
+
maxLeverage,
|
|
902
|
+
entryChase,
|
|
903
|
+
reanchorStopOnFill,
|
|
904
|
+
maxSlipROnFill,
|
|
905
|
+
collectEqSeries,
|
|
906
|
+
collectReplay,
|
|
907
|
+
warmupBars,
|
|
908
|
+
strict
|
|
909
|
+
} = options;
|
|
910
|
+
if (!Array.isArray(candles) || candles.length === 0) {
|
|
911
|
+
throw new Error("backtest() requires a non-empty candles array");
|
|
912
|
+
}
|
|
913
|
+
if (typeof signal !== "function") {
|
|
914
|
+
throw new Error("backtest() requires a signal function");
|
|
915
|
+
}
|
|
916
|
+
const closed = [];
|
|
917
|
+
let currentEquity = equity;
|
|
918
|
+
let open = null;
|
|
919
|
+
let cooldown = 0;
|
|
920
|
+
let pending = null;
|
|
921
|
+
let currentDay = null;
|
|
922
|
+
let dayPnl = 0;
|
|
923
|
+
let dayTrades = 0;
|
|
924
|
+
let dayEquityStart = equity;
|
|
925
|
+
const wantReplay = Boolean(collectReplay);
|
|
926
|
+
const wantEqSeries = Boolean(collectEqSeries);
|
|
927
|
+
const estimatedBarMs = estimateBarMs(candles);
|
|
928
|
+
const atrSourcePeriod = volScale.enabled ? volScale.atrPeriod : atrTrailPeriod;
|
|
929
|
+
const needAtr = atrTrailMult > 0 || volScale.enabled;
|
|
930
|
+
const atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
931
|
+
const eqSeries = wantEqSeries ? [equityPoint(candles[0].time, currentEquity)] : [];
|
|
932
|
+
const replayFrames = wantReplay ? [] : [];
|
|
933
|
+
const replayEvents = wantReplay ? [] : [];
|
|
934
|
+
let tradeIdCounter = 0;
|
|
935
|
+
const useVolScale = Boolean(volScale.enabled);
|
|
936
|
+
const useAtrTrail = atrTrailMult > 0;
|
|
937
|
+
const useMfeTrail = Boolean(mfeTrail.enabled);
|
|
938
|
+
const usePyramiding = Boolean(pyramiding.enabled);
|
|
939
|
+
const trigger = triggerMode || oco.mode || "intrabar";
|
|
940
|
+
function recordFrame(bar) {
|
|
941
|
+
if (wantEqSeries) {
|
|
942
|
+
eqSeries.push(equityPoint(bar.time, currentEquity));
|
|
943
|
+
}
|
|
944
|
+
if (wantReplay) {
|
|
945
|
+
replayFrames.push({
|
|
946
|
+
t: new Date(bar.time).toISOString(),
|
|
947
|
+
price: bar.close,
|
|
948
|
+
equity: currentEquity,
|
|
949
|
+
posSide: open ? open.side : null,
|
|
950
|
+
posSize: open ? open.size : 0
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
|
|
955
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
956
|
+
const entryFill = openPos.entryFill;
|
|
957
|
+
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
958
|
+
const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
959
|
+
const exitFeeTotal = exitFeePerUnit * qty;
|
|
960
|
+
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
961
|
+
currentEquity += pnl;
|
|
962
|
+
dayPnl += pnl;
|
|
963
|
+
if (wantEqSeries) {
|
|
964
|
+
eqSeries.push(equityPoint(time, currentEquity));
|
|
965
|
+
}
|
|
966
|
+
const remaining = openPos.size - qty;
|
|
967
|
+
const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
|
|
968
|
+
if (wantReplay) {
|
|
969
|
+
replayEvents.push({
|
|
970
|
+
t: new Date(time).toISOString(),
|
|
971
|
+
price: exitPx,
|
|
972
|
+
type: eventType,
|
|
973
|
+
side: openPos.side,
|
|
974
|
+
size: qty,
|
|
975
|
+
tradeId: openPos.id,
|
|
976
|
+
reason,
|
|
977
|
+
pnl
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
const record = {
|
|
981
|
+
...openPos,
|
|
982
|
+
size: qty,
|
|
983
|
+
exit: {
|
|
984
|
+
price: exitPx,
|
|
985
|
+
time,
|
|
986
|
+
reason,
|
|
987
|
+
pnl,
|
|
988
|
+
exitATR: openPos._lastATR ?? void 0
|
|
989
|
+
},
|
|
990
|
+
mfeR: openPos._mfeR ?? 0,
|
|
991
|
+
maeR: openPos._maeR ?? 0,
|
|
992
|
+
adds: openPos._adds ?? 0
|
|
993
|
+
};
|
|
994
|
+
closed.push(record);
|
|
995
|
+
openPos.size -= qty;
|
|
996
|
+
openPos._realized = (openPos._realized || 0) + pnl;
|
|
997
|
+
return record;
|
|
998
|
+
}
|
|
999
|
+
function tightenStopToNetBreakeven(openPos, lastClose) {
|
|
1000
|
+
if (!openPos || openPos.size <= 0) return;
|
|
1001
|
+
const realized = openPos._realized || 0;
|
|
1002
|
+
if (realized <= 0) return;
|
|
1003
|
+
const direction = openPos.side === "long" ? 1 : -1;
|
|
1004
|
+
const breakevenDelta = Math.abs(realized / openPos.size);
|
|
1005
|
+
const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
|
|
1006
|
+
const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
|
|
1007
|
+
openPos.stop = oco.clampStops ? clampStop(lastClose, tightened, openPos.side, oco) : tightened;
|
|
1008
|
+
}
|
|
1009
|
+
function forceExit(reason, bar) {
|
|
1010
|
+
if (!open) return;
|
|
1011
|
+
const exitSide = open.side === "long" ? "short" : "long";
|
|
1012
|
+
const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
|
|
1013
|
+
slippageBps,
|
|
1014
|
+
feeBps,
|
|
1015
|
+
kind: "market"
|
|
1016
|
+
});
|
|
1017
|
+
closeLeg({
|
|
1018
|
+
openPos: open,
|
|
1019
|
+
qty: open.size,
|
|
1020
|
+
exitPx: filled,
|
|
1021
|
+
exitFeePerUnit: exitFee,
|
|
1022
|
+
time: bar.time,
|
|
1023
|
+
reason
|
|
1024
|
+
});
|
|
1025
|
+
cooldown = open._cooldownBars || 0;
|
|
1026
|
+
open = null;
|
|
1027
|
+
}
|
|
1028
|
+
function openFromPending(bar, index, entryPrice, fillKind = "limit") {
|
|
1029
|
+
if (!pending) return false;
|
|
1030
|
+
const plannedRisk = Math.max(
|
|
1031
|
+
1e-8,
|
|
1032
|
+
pending.plannedRiskAbs ?? Math.abs(pending.entry - pending.stop)
|
|
1033
|
+
);
|
|
1034
|
+
const slipR = Math.abs(entryPrice - pending.entry) / plannedRisk;
|
|
1035
|
+
if (slipR > maxSlipROnFill) return false;
|
|
1036
|
+
let stopPrice = pending.stop;
|
|
1037
|
+
if (reanchorStopOnFill) {
|
|
1038
|
+
const direction = pending.side === "long" ? 1 : -1;
|
|
1039
|
+
stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
|
|
1040
|
+
}
|
|
1041
|
+
let takeProfit = pending.tp;
|
|
1042
|
+
const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
|
|
1043
|
+
const rrHint = pending.meta?._rr;
|
|
1044
|
+
if (reanchorStopOnFill && Number.isFinite(rrHint)) {
|
|
1045
|
+
const plannedTarget = pending.side === "long" ? pending.entry + rrHint * plannedRisk : pending.entry - rrHint * plannedRisk;
|
|
1046
|
+
const closeEnough = Math.abs((pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
|
|
1047
|
+
if (closeEnough) {
|
|
1048
|
+
takeProfit = pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
const rawSize = pending.fixedQty ?? calculatePositionSize({
|
|
1052
|
+
equity: currentEquity,
|
|
1053
|
+
entry: entryPrice,
|
|
1054
|
+
stop: stopPrice,
|
|
1055
|
+
riskFraction: pending.riskFrac,
|
|
1056
|
+
qtyStep,
|
|
1057
|
+
minQty,
|
|
1058
|
+
maxLeverage
|
|
1059
|
+
});
|
|
1060
|
+
const size = roundStep2(rawSize, qtyStep);
|
|
1061
|
+
if (size < minQty) return false;
|
|
1062
|
+
const { price: entryFill, fee: entryFee } = applyFill(
|
|
1063
|
+
entryPrice,
|
|
1064
|
+
pending.side,
|
|
1065
|
+
{
|
|
1066
|
+
slippageBps,
|
|
1067
|
+
feeBps,
|
|
1068
|
+
kind: fillKind
|
|
1069
|
+
}
|
|
1070
|
+
);
|
|
1071
|
+
open = {
|
|
1072
|
+
symbol,
|
|
1073
|
+
...pending.meta,
|
|
1074
|
+
id: ++tradeIdCounter,
|
|
1075
|
+
side: pending.side,
|
|
1076
|
+
entry: entryPrice,
|
|
1077
|
+
stop: stopPrice,
|
|
1078
|
+
takeProfit,
|
|
1079
|
+
size,
|
|
1080
|
+
openTime: bar.time,
|
|
1081
|
+
entryFill,
|
|
1082
|
+
entryFeeTotal: entryFee * size,
|
|
1083
|
+
initSize: size,
|
|
1084
|
+
baseSize: size,
|
|
1085
|
+
_mfeR: 0,
|
|
1086
|
+
_maeR: 0,
|
|
1087
|
+
_adds: 0,
|
|
1088
|
+
_initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
|
|
1089
|
+
};
|
|
1090
|
+
if (atrValues && atrValues[index] !== void 0) {
|
|
1091
|
+
open.entryATR = atrValues[index];
|
|
1092
|
+
open._lastATR = atrValues[index];
|
|
1093
|
+
}
|
|
1094
|
+
dayTrades += 1;
|
|
1095
|
+
pending = null;
|
|
1096
|
+
if (wantReplay) {
|
|
1097
|
+
replayEvents.push({
|
|
1098
|
+
t: new Date(bar.time).toISOString(),
|
|
1099
|
+
price: entryFill,
|
|
1100
|
+
type: "entry",
|
|
1101
|
+
side: open.side,
|
|
1102
|
+
size,
|
|
1103
|
+
tradeId: open.id
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
const startIndex = Math.min(Math.max(1, warmupBars), candles.length);
|
|
1109
|
+
const history = candles.slice(0, startIndex);
|
|
1110
|
+
for (let index = startIndex; index < candles.length; index += 1) {
|
|
1111
|
+
const bar = candles[index];
|
|
1112
|
+
history.push(bar);
|
|
1113
|
+
const dayKey = flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
|
|
1114
|
+
if (currentDay === null || dayKey !== currentDay) {
|
|
1115
|
+
currentDay = dayKey;
|
|
1116
|
+
dayPnl = 0;
|
|
1117
|
+
dayTrades = 0;
|
|
1118
|
+
dayEquityStart = currentEquity;
|
|
1119
|
+
}
|
|
1120
|
+
if (open && open._maxBarsInTrade > 0) {
|
|
1121
|
+
const barsHeld = Math.max(
|
|
1122
|
+
1,
|
|
1123
|
+
Math.round((bar.time - open.openTime) / estimatedBarMs)
|
|
1124
|
+
);
|
|
1125
|
+
if (barsHeld >= open._maxBarsInTrade) {
|
|
1126
|
+
forceExit("TIME", bar);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (open && Number.isFinite(open._maxHoldMin) && open._maxHoldMin > 0) {
|
|
1130
|
+
const heldMinutes = (bar.time - open.openTime) / 6e4;
|
|
1131
|
+
if (heldMinutes >= open._maxHoldMin) {
|
|
1132
|
+
forceExit("TIME", bar);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (flattenAtClose && open && isEODBar(bar.time)) {
|
|
1136
|
+
forceExit("EOD", bar);
|
|
1137
|
+
}
|
|
1138
|
+
if (open) {
|
|
1139
|
+
const direction = open.side === "long" ? 1 : -1;
|
|
1140
|
+
const risk = open._initRisk || 1e-8;
|
|
1141
|
+
const highR = open.side === "long" ? (bar.high - open.entry) / risk : (open.entry - bar.low) / risk;
|
|
1142
|
+
const lowR = open.side === "long" ? (bar.low - open.entry) / risk : (open.entry - bar.high) / risk;
|
|
1143
|
+
const markR = direction === 1 ? (bar.close - open.entry) / risk : (open.entry - bar.close) / risk;
|
|
1144
|
+
if (atrValues && atrValues[index] !== void 0) {
|
|
1145
|
+
open._lastATR = atrValues[index];
|
|
1146
|
+
}
|
|
1147
|
+
open._mfeR = Math.max(open._mfeR ?? -Infinity, highR);
|
|
1148
|
+
open._maeR = Math.min(open._maeR ?? Infinity, lowR);
|
|
1149
|
+
if (open._breakevenAtR > 0 && highR >= open._breakevenAtR && !open._beArmed) {
|
|
1150
|
+
const tightened = open.side === "long" ? Math.max(open.stop, open.entry) : Math.min(open.stop, open.entry);
|
|
1151
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
1152
|
+
open._beArmed = true;
|
|
1153
|
+
}
|
|
1154
|
+
if (open._trailAfterR > 0 && highR >= open._trailAfterR) {
|
|
1155
|
+
const candidate = open.side === "long" ? bar.close - risk : bar.close + risk;
|
|
1156
|
+
const tightened = open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
1157
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
1158
|
+
}
|
|
1159
|
+
if (useMfeTrail && open._mfeR >= mfeTrail.armR) {
|
|
1160
|
+
const targetR = Math.max(0, open._mfeR - Math.max(0, mfeTrail.givebackR));
|
|
1161
|
+
const candidate = open.side === "long" ? open.entry + targetR * risk : open.entry - targetR * risk;
|
|
1162
|
+
const tightened = open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
1163
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
1164
|
+
}
|
|
1165
|
+
if (useAtrTrail && atrValues && atrValues[index] !== void 0) {
|
|
1166
|
+
const trailDistance = atrValues[index] * atrTrailMult;
|
|
1167
|
+
const candidate = open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
|
|
1168
|
+
const tightened = open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
|
|
1169
|
+
open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
|
|
1170
|
+
}
|
|
1171
|
+
if (useVolScale && open.entryATR && open.size > minQty && atrValues && atrValues[index] !== void 0) {
|
|
1172
|
+
const ratio = atrValues[index] / Math.max(1e-12, open.entryATR);
|
|
1173
|
+
const shouldCut = ratio >= volScale.cutIfAtrX && markR < volScale.noCutAboveR && !open._volCutDone;
|
|
1174
|
+
if (shouldCut) {
|
|
1175
|
+
const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
|
|
1176
|
+
if (cutQty >= minQty && cutQty < open.size) {
|
|
1177
|
+
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1178
|
+
const { price: filled, fee: exitFee } = applyFill(
|
|
1179
|
+
bar.close,
|
|
1180
|
+
exitSide2,
|
|
1181
|
+
{ slippageBps, feeBps, kind: "market" }
|
|
1182
|
+
);
|
|
1183
|
+
closeLeg({
|
|
1184
|
+
openPos: open,
|
|
1185
|
+
qty: cutQty,
|
|
1186
|
+
exitPx: filled,
|
|
1187
|
+
exitFeePerUnit: exitFee,
|
|
1188
|
+
time: bar.time,
|
|
1189
|
+
reason: "SCALE"
|
|
1190
|
+
});
|
|
1191
|
+
tightenStopToNetBreakeven(open, bar.close);
|
|
1192
|
+
open._volCutDone = true;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
let addedThisBar = false;
|
|
1197
|
+
if (usePyramiding && (open._adds ?? 0) < pyramiding.maxAdds) {
|
|
1198
|
+
const addNumber = (open._adds || 0) + 1;
|
|
1199
|
+
const triggerR = pyramiding.addAtR * addNumber;
|
|
1200
|
+
const triggerPrice = open.side === "long" ? open.entry + triggerR * risk : open.entry - triggerR * risk;
|
|
1201
|
+
const breakEvenSatisfied = !pyramiding.onlyAfterBreakEven || open.side === "long" && open.stop >= open.entry || open.side === "short" && open.stop <= open.entry;
|
|
1202
|
+
const touched = open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
1203
|
+
if (breakEvenSatisfied && touched) {
|
|
1204
|
+
const baseSize = open.baseSize || open.initSize;
|
|
1205
|
+
const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
|
|
1206
|
+
if (addQty >= minQty) {
|
|
1207
|
+
const { price: addFill, fee: addFee } = applyFill(
|
|
1208
|
+
triggerPrice,
|
|
1209
|
+
open.side,
|
|
1210
|
+
{ slippageBps, feeBps, kind: "limit" }
|
|
1211
|
+
);
|
|
1212
|
+
const newSize = open.size + addQty;
|
|
1213
|
+
open.entryFeeTotal += addFee * addQty;
|
|
1214
|
+
open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
|
|
1215
|
+
open.size = newSize;
|
|
1216
|
+
open.initSize += addQty;
|
|
1217
|
+
if (!open.baseSize) open.baseSize = baseSize;
|
|
1218
|
+
open._adds = addNumber;
|
|
1219
|
+
addedThisBar = true;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (!addedThisBar && !open._scaled && scaleOutAtR > 0) {
|
|
1224
|
+
const triggerPrice = open.side === "long" ? open.entry + scaleOutAtR * risk : open.entry - scaleOutAtR * risk;
|
|
1225
|
+
const touched = open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
|
|
1226
|
+
if (touched) {
|
|
1227
|
+
const exitSide2 = open.side === "long" ? "short" : "long";
|
|
1228
|
+
const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide2, {
|
|
1229
|
+
slippageBps,
|
|
1230
|
+
feeBps,
|
|
1231
|
+
kind: "limit"
|
|
1232
|
+
});
|
|
1233
|
+
const qty = roundStep2(open.size * scaleOutFrac, qtyStep);
|
|
1234
|
+
if (qty >= minQty && qty < open.size) {
|
|
1235
|
+
closeLeg({
|
|
1236
|
+
openPos: open,
|
|
1237
|
+
qty,
|
|
1238
|
+
exitPx: filled,
|
|
1239
|
+
exitFeePerUnit: exitFee,
|
|
1240
|
+
time: bar.time,
|
|
1241
|
+
reason: "SCALE"
|
|
1242
|
+
});
|
|
1243
|
+
open._scaled = true;
|
|
1244
|
+
open.takeProfit = open.side === "long" ? open.entry + finalTP_R * risk : open.entry - finalTP_R * risk;
|
|
1245
|
+
tightenStopToNetBreakeven(open, bar.close);
|
|
1246
|
+
open._beArmed = true;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const exitSide = open.side === "long" ? "short" : "long";
|
|
1251
|
+
const { hit, px } = ocoExitCheck({
|
|
1252
|
+
side: open.side,
|
|
1253
|
+
stop: open.stop,
|
|
1254
|
+
tp: open.takeProfit,
|
|
1255
|
+
bar,
|
|
1256
|
+
mode: oco.mode,
|
|
1257
|
+
tieBreak: oco.tieBreak
|
|
1258
|
+
});
|
|
1259
|
+
if (hit) {
|
|
1260
|
+
const exitKind = hit === "TP" ? "limit" : "stop";
|
|
1261
|
+
const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
|
|
1262
|
+
slippageBps,
|
|
1263
|
+
feeBps,
|
|
1264
|
+
kind: exitKind
|
|
1265
|
+
});
|
|
1266
|
+
const localCooldown = open._cooldownBars || 0;
|
|
1267
|
+
closeLeg({
|
|
1268
|
+
openPos: open,
|
|
1269
|
+
qty: open.size,
|
|
1270
|
+
exitPx: filled,
|
|
1271
|
+
exitFeePerUnit: exitFee,
|
|
1272
|
+
time: bar.time,
|
|
1273
|
+
reason: hit
|
|
1274
|
+
});
|
|
1275
|
+
cooldown = (hit === "SL" ? Math.max(cooldown, postLossCooldownBars) : cooldown) || localCooldown;
|
|
1276
|
+
open = null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const maxLossDollars = maxDailyLossPct / 100 * dayEquityStart;
|
|
1280
|
+
const dailyLossHit = dayPnl <= -Math.abs(maxLossDollars);
|
|
1281
|
+
const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
|
|
1282
|
+
if (!open && pending) {
|
|
1283
|
+
if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
|
|
1284
|
+
if (entryChase.enabled && entryChase.convertOnExpiry) {
|
|
1285
|
+
const riskAtEdge = Math.abs(
|
|
1286
|
+
pending.meta._initRisk ?? pending.entry - pending.stop
|
|
1287
|
+
);
|
|
1288
|
+
const priceNow = bar.close;
|
|
1289
|
+
const direction = pending.side === "long" ? 1 : -1;
|
|
1290
|
+
const slippedR = Math.max(
|
|
1291
|
+
0,
|
|
1292
|
+
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1293
|
+
) / Math.max(1e-8, riskAtEdge);
|
|
1294
|
+
if (slippedR > maxSlipROnFill) {
|
|
1295
|
+
pending = null;
|
|
1296
|
+
} else if (!openFromPending(bar, index, priceNow, "market")) {
|
|
1297
|
+
pending = null;
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
pending = null;
|
|
1301
|
+
}
|
|
1302
|
+
} else if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1303
|
+
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
1304
|
+
pending = null;
|
|
1305
|
+
}
|
|
1306
|
+
} else if (entryChase.enabled) {
|
|
1307
|
+
const elapsedBars = index - (pending.startedAtIndex ?? index);
|
|
1308
|
+
const midpoint = pending.meta?._imb?.mid;
|
|
1309
|
+
if (!pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, entryChase.afterBars)) {
|
|
1310
|
+
pending.entry = midpoint;
|
|
1311
|
+
pending._chasedCE = true;
|
|
1312
|
+
}
|
|
1313
|
+
if (pending._chasedCE) {
|
|
1314
|
+
const riskRef = Math.abs(
|
|
1315
|
+
pending.meta?._initRisk ?? pending.entry - pending.stop
|
|
1316
|
+
);
|
|
1317
|
+
const priceNow = bar.close;
|
|
1318
|
+
const direction = pending.side === "long" ? 1 : -1;
|
|
1319
|
+
const slippedR = Math.max(
|
|
1320
|
+
0,
|
|
1321
|
+
direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
|
|
1322
|
+
) / Math.max(1e-8, riskRef);
|
|
1323
|
+
if (slippedR > maxSlipROnFill) {
|
|
1324
|
+
pending = null;
|
|
1325
|
+
} else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
|
|
1326
|
+
if (!openFromPending(bar, index, priceNow, "market")) {
|
|
1327
|
+
pending = null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (open || cooldown > 0) {
|
|
1334
|
+
if (cooldown > 0) cooldown -= 1;
|
|
1335
|
+
recordFrame(bar);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (dailyLossHit || dailyTradeCapHit) {
|
|
1339
|
+
pending = null;
|
|
1340
|
+
recordFrame(bar);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (!pending) {
|
|
1344
|
+
if (strict && history.length !== index + 1) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
`strict mode: signal() received ${history.length} candles at index ${index}`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
1350
|
+
const rawSignal = signal({
|
|
1351
|
+
candles: signalCandles,
|
|
1352
|
+
index,
|
|
1353
|
+
bar,
|
|
1354
|
+
equity: currentEquity,
|
|
1355
|
+
openPosition: open,
|
|
1356
|
+
pendingOrder: pending
|
|
1357
|
+
});
|
|
1358
|
+
const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
|
|
1359
|
+
if (nextSignal) {
|
|
1360
|
+
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100;
|
|
1361
|
+
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
1362
|
+
pending = {
|
|
1363
|
+
side: nextSignal.side,
|
|
1364
|
+
entry: nextSignal.entry,
|
|
1365
|
+
stop: nextSignal.stop,
|
|
1366
|
+
tp: nextSignal.takeProfit,
|
|
1367
|
+
riskFrac: signalRiskFraction,
|
|
1368
|
+
fixedQty: nextSignal.qty,
|
|
1369
|
+
expiresAt: index + Math.max(1, expiryBars),
|
|
1370
|
+
startedAtIndex: index,
|
|
1371
|
+
meta: nextSignal,
|
|
1372
|
+
plannedRiskAbs: Math.abs(
|
|
1373
|
+
nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
|
|
1374
|
+
)
|
|
1375
|
+
};
|
|
1376
|
+
if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
|
|
1377
|
+
if (!openFromPending(bar, index, pending.entry, "limit")) {
|
|
1378
|
+
pending = null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
recordFrame(bar);
|
|
1384
|
+
}
|
|
1385
|
+
const metrics = buildMetrics({
|
|
1386
|
+
closed,
|
|
1387
|
+
equityStart: equity,
|
|
1388
|
+
equityFinal: currentEquity,
|
|
1389
|
+
candles,
|
|
1390
|
+
estBarMs: estimatedBarMs,
|
|
1391
|
+
eqSeries
|
|
1392
|
+
});
|
|
1393
|
+
const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
|
|
1394
|
+
return {
|
|
1395
|
+
symbol: options.symbol,
|
|
1396
|
+
interval: options.interval,
|
|
1397
|
+
range: options.range,
|
|
1398
|
+
trades: closed,
|
|
1399
|
+
positions,
|
|
1400
|
+
metrics,
|
|
1401
|
+
eqSeries,
|
|
1402
|
+
replay: {
|
|
1403
|
+
frames: replayFrames,
|
|
1404
|
+
events: replayEvents
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/data/yahoo.js
|
|
1410
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1411
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1412
|
+
var DAY_SEC = 24 * 60 * 60;
|
|
1413
|
+
var requestQueue = {
|
|
1414
|
+
lastRequestAt: 0,
|
|
1415
|
+
minDelayMs: 400
|
|
1416
|
+
};
|
|
1417
|
+
function nowSec() {
|
|
1418
|
+
return Math.floor(Date.now() / 1e3);
|
|
1419
|
+
}
|
|
1420
|
+
function msToSec(value) {
|
|
1421
|
+
return Math.floor(value / 1e3);
|
|
1422
|
+
}
|
|
1423
|
+
function isIntraday(interval) {
|
|
1424
|
+
return /(m|h)$/i.test(String(interval));
|
|
1425
|
+
}
|
|
1426
|
+
function normalizeInterval(interval) {
|
|
1427
|
+
return String(interval || "1d").trim();
|
|
1428
|
+
}
|
|
1429
|
+
function parsePeriodToMs(period) {
|
|
1430
|
+
if (typeof period === "number" && Number.isFinite(period)) return period;
|
|
1431
|
+
const raw = String(period || "60d").trim().toLowerCase();
|
|
1432
|
+
const normalized = raw.replace(/months?$/, "mo").replace(/^(\d+)mon$/, "$1mo").replace(/^(\d+)mos$/, "$1mo");
|
|
1433
|
+
const match = normalized.match(/^(\d+)(mo|m|h|d|w|y)$/i);
|
|
1434
|
+
if (!match) {
|
|
1435
|
+
throw new Error(`Invalid period "${period}". Use values like "5d", "60d", "1y".`);
|
|
1436
|
+
}
|
|
1437
|
+
const amount = Number(match[1]);
|
|
1438
|
+
const unit = match[2].toLowerCase();
|
|
1439
|
+
switch (unit) {
|
|
1440
|
+
case "mo":
|
|
1441
|
+
return Math.round(amount * 30.4375 * DAY_MS);
|
|
1442
|
+
case "m":
|
|
1443
|
+
return amount * 60 * 1e3;
|
|
1444
|
+
case "h":
|
|
1445
|
+
return amount * 60 * 60 * 1e3;
|
|
1446
|
+
case "d":
|
|
1447
|
+
return amount * DAY_MS;
|
|
1448
|
+
case "w":
|
|
1449
|
+
return amount * 7 * DAY_MS;
|
|
1450
|
+
case "y":
|
|
1451
|
+
return Math.round(amount * 365.25 * DAY_MS);
|
|
1452
|
+
default:
|
|
1453
|
+
throw new Error(`Unsupported period unit "${unit}"`);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
function maxDaysForInterval(interval) {
|
|
1457
|
+
const value = normalizeInterval(interval);
|
|
1458
|
+
if (!isIntraday(value)) return 365 * 10;
|
|
1459
|
+
if (/^\d+m$/i.test(value)) {
|
|
1460
|
+
const minutes = Number(value.slice(0, -1));
|
|
1461
|
+
if (minutes <= 2) return 7;
|
|
1462
|
+
if (minutes <= 30) return 60;
|
|
1463
|
+
if (minutes <= 60) return 730;
|
|
1464
|
+
return 365;
|
|
1465
|
+
}
|
|
1466
|
+
if (/^\d+h$/i.test(value)) return 730;
|
|
1467
|
+
return 60;
|
|
1468
|
+
}
|
|
1469
|
+
function sanitizeBars(candles) {
|
|
1470
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
1471
|
+
for (const candle of candles) {
|
|
1472
|
+
if (!Number.isFinite(candle?.time) || !Number.isFinite(candle?.open) || !Number.isFinite(candle?.high) || !Number.isFinite(candle?.low) || !Number.isFinite(candle?.close)) {
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
deduped.set(candle.time, {
|
|
1476
|
+
time: candle.time,
|
|
1477
|
+
open: candle.open,
|
|
1478
|
+
high: Math.max(candle.high, candle.open, candle.close),
|
|
1479
|
+
low: Math.min(candle.low, candle.open, candle.close),
|
|
1480
|
+
close: candle.close,
|
|
1481
|
+
volume: Number.isFinite(candle.volume) ? candle.volume : 0
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
return [...deduped.values()].sort((left, right) => left.time - right.time);
|
|
1485
|
+
}
|
|
1486
|
+
async function rateLimitedFetch(url, options = {}) {
|
|
1487
|
+
const elapsed = Date.now() - requestQueue.lastRequestAt;
|
|
1488
|
+
if (elapsed < requestQueue.minDelayMs) {
|
|
1489
|
+
await sleep(requestQueue.minDelayMs - elapsed);
|
|
1490
|
+
}
|
|
1491
|
+
requestQueue.lastRequestAt = Date.now();
|
|
1492
|
+
return fetch(url, {
|
|
1493
|
+
...options,
|
|
1494
|
+
headers: {
|
|
1495
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
1496
|
+
...options.headers
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
async function fetchYahooChart(symbol, { period1, period2, interval, includePrePost = false }) {
|
|
1501
|
+
const url = new URL(
|
|
1502
|
+
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`
|
|
1503
|
+
);
|
|
1504
|
+
url.searchParams.set("period1", String(Math.floor(period1)));
|
|
1505
|
+
url.searchParams.set("period2", String(Math.floor(period2)));
|
|
1506
|
+
url.searchParams.set("interval", normalizeInterval(interval));
|
|
1507
|
+
url.searchParams.set("includePrePost", includePrePost ? "true" : "false");
|
|
1508
|
+
url.searchParams.set("events", "div,splits");
|
|
1509
|
+
const response = await rateLimitedFetch(url.toString());
|
|
1510
|
+
if (!response.ok) {
|
|
1511
|
+
const text = await response.text();
|
|
1512
|
+
throw new Error(`Yahoo API error ${response.status}: ${text}`);
|
|
1513
|
+
}
|
|
1514
|
+
const payload = await response.json();
|
|
1515
|
+
if (payload.chart?.error) {
|
|
1516
|
+
throw new Error(payload.chart.error.description || "Yahoo chart error");
|
|
1517
|
+
}
|
|
1518
|
+
const result = payload.chart?.result?.[0];
|
|
1519
|
+
if (!result) return [];
|
|
1520
|
+
const timestamps = result.timestamp || [];
|
|
1521
|
+
const quote = result.indicators?.quote?.[0] || {};
|
|
1522
|
+
const open = quote.open || [];
|
|
1523
|
+
const high = quote.high || [];
|
|
1524
|
+
const low = quote.low || [];
|
|
1525
|
+
const close = quote.close || [];
|
|
1526
|
+
const volume = quote.volume || [];
|
|
1527
|
+
const candles = [];
|
|
1528
|
+
for (let index = 0; index < timestamps.length; index += 1) {
|
|
1529
|
+
if (open[index] == null || high[index] == null || low[index] == null || close[index] == null) {
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
candles.push({
|
|
1533
|
+
time: timestamps[index] * 1e3,
|
|
1534
|
+
open: open[index],
|
|
1535
|
+
high: high[index],
|
|
1536
|
+
low: low[index],
|
|
1537
|
+
close: close[index],
|
|
1538
|
+
volume: volume[index] ?? 0
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
return candles;
|
|
1542
|
+
}
|
|
1543
|
+
function formatYahooFailureMessage(symbol, interval, period, error, attempts) {
|
|
1544
|
+
const detail = String(error?.message || error || "unknown error");
|
|
1545
|
+
return [
|
|
1546
|
+
`Unable to reach Yahoo Finance for ${symbol} ${interval} ${period} after ${attempts} attempts.`,
|
|
1547
|
+
`Last error: ${detail}`,
|
|
1548
|
+
'Try again later, or fall back to a local CSV/cache workflow with getHistoricalCandles({ source: "csv", ... }) or loadCandlesFromCache(...).'
|
|
1549
|
+
].join(" ");
|
|
1550
|
+
}
|
|
1551
|
+
async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3) {
|
|
1552
|
+
let lastError = null;
|
|
1553
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
1554
|
+
try {
|
|
1555
|
+
return await fetchYahooChart(symbol, params);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
lastError = error;
|
|
1558
|
+
const message = String(error?.message || error);
|
|
1559
|
+
const isRateLimited = /too many requests|rate limit|429/i.test(message);
|
|
1560
|
+
const isRetryable = isRateLimited || /timeout|fetch failed|network/i.test(message);
|
|
1561
|
+
if (!isRetryable || attempt === maxRetries - 1) break;
|
|
1562
|
+
const delay = Math.min(12e3, 500 * 2 ** attempt);
|
|
1563
|
+
await sleep(delay);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
throw new Error(
|
|
1567
|
+
formatYahooFailureMessage(
|
|
1568
|
+
symbol,
|
|
1569
|
+
params.interval,
|
|
1570
|
+
period,
|
|
1571
|
+
lastError,
|
|
1572
|
+
maxRetries
|
|
1573
|
+
)
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
1577
|
+
const normalizedInterval = normalizeInterval(interval);
|
|
1578
|
+
const spanMs = parsePeriodToMs(period);
|
|
1579
|
+
const maxSpanMs = maxDaysForInterval(normalizedInterval) * DAY_MS;
|
|
1580
|
+
const includePrePost = Boolean(options.includePrePost);
|
|
1581
|
+
if (spanMs <= maxSpanMs) {
|
|
1582
|
+
const endSec = nowSec();
|
|
1583
|
+
const startSec = Math.max(0, endSec - msToSec(spanMs));
|
|
1584
|
+
const candles = await fetchYahooChartWithRetry(
|
|
1585
|
+
symbol,
|
|
1586
|
+
{
|
|
1587
|
+
period1: startSec,
|
|
1588
|
+
period2: endSec,
|
|
1589
|
+
interval: normalizedInterval,
|
|
1590
|
+
includePrePost
|
|
1591
|
+
},
|
|
1592
|
+
period
|
|
1593
|
+
);
|
|
1594
|
+
return sanitizeBars(candles);
|
|
1595
|
+
}
|
|
1596
|
+
const chunks = [];
|
|
1597
|
+
let remainingMs = spanMs;
|
|
1598
|
+
let chunkEndMs = Date.now();
|
|
1599
|
+
while (remainingMs > 0) {
|
|
1600
|
+
const takeMs = Math.min(remainingMs, maxSpanMs);
|
|
1601
|
+
const chunkStartMs = chunkEndMs - takeMs;
|
|
1602
|
+
const candles = await fetchYahooChartWithRetry(
|
|
1603
|
+
symbol,
|
|
1604
|
+
{
|
|
1605
|
+
period1: msToSec(chunkStartMs),
|
|
1606
|
+
period2: msToSec(chunkEndMs),
|
|
1607
|
+
interval: normalizedInterval,
|
|
1608
|
+
includePrePost
|
|
1609
|
+
},
|
|
1610
|
+
period
|
|
1611
|
+
);
|
|
1612
|
+
chunks.push(...candles);
|
|
1613
|
+
chunkEndMs = chunkStartMs - 1e3;
|
|
1614
|
+
remainingMs -= takeMs;
|
|
1615
|
+
if (chunks.length > 2e6) break;
|
|
1616
|
+
}
|
|
1617
|
+
return sanitizeBars(chunks);
|
|
1618
|
+
}
|
|
1619
|
+
async function fetchLatestCandle(symbol, interval = "1m", options = {}) {
|
|
1620
|
+
const bars = await fetchHistorical(symbol, interval, "5d", options);
|
|
1621
|
+
return bars[bars.length - 1] ?? null;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// src/data/index.js
|
|
1625
|
+
function normalizeCacheDir(cacheDir) {
|
|
1626
|
+
return cacheDir || import_path2.default.join(process.cwd(), "output", "data");
|
|
1627
|
+
}
|
|
1628
|
+
function derivePeriodFromRange(startDate, endDate) {
|
|
1629
|
+
if (!startDate || !endDate) return "custom";
|
|
1630
|
+
const start = new Date(startDate).getTime();
|
|
1631
|
+
const end = new Date(endDate).getTime();
|
|
1632
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return "custom";
|
|
1633
|
+
const days = Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1e3)));
|
|
1634
|
+
return `${days}d`;
|
|
1635
|
+
}
|
|
1636
|
+
async function getHistoricalCandles(options = {}) {
|
|
1637
|
+
const {
|
|
1638
|
+
source: requestedSource = "auto",
|
|
1639
|
+
symbol,
|
|
1640
|
+
interval = "1d",
|
|
1641
|
+
period,
|
|
1642
|
+
cache = true,
|
|
1643
|
+
refresh = false,
|
|
1644
|
+
cacheDir,
|
|
1645
|
+
csv,
|
|
1646
|
+
csvPath,
|
|
1647
|
+
...rest
|
|
1648
|
+
} = options;
|
|
1649
|
+
const effectiveCacheDir = normalizeCacheDir(cacheDir);
|
|
1650
|
+
const source = requestedSource === "auto" ? csvPath || csv?.filePath || csv?.path ? "csv" : "yahoo" : requestedSource;
|
|
1651
|
+
if (source === "csv") {
|
|
1652
|
+
const filePath = csvPath || csv?.filePath || csv?.path;
|
|
1653
|
+
if (!filePath) {
|
|
1654
|
+
throw new Error('CSV source requires "csvPath" or "csv.filePath"');
|
|
1655
|
+
}
|
|
1656
|
+
const candles2 = loadCandlesFromCSV(filePath, csv || rest);
|
|
1657
|
+
if (cache && symbol) {
|
|
1658
|
+
saveCandlesToCache(candles2, {
|
|
1659
|
+
symbol,
|
|
1660
|
+
interval,
|
|
1661
|
+
period: period ?? derivePeriodFromRange(csv?.startDate ?? rest.startDate, csv?.endDate ?? rest.endDate),
|
|
1662
|
+
outDir: effectiveCacheDir,
|
|
1663
|
+
source: "csv"
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
return candles2;
|
|
1667
|
+
}
|
|
1668
|
+
if (source !== "yahoo") {
|
|
1669
|
+
throw new Error(`Unsupported data source "${source}"`);
|
|
1670
|
+
}
|
|
1671
|
+
if (!symbol) {
|
|
1672
|
+
throw new Error('Yahoo source requires "symbol"');
|
|
1673
|
+
}
|
|
1674
|
+
const resolvedPeriod = period ?? "1y";
|
|
1675
|
+
if (cache && !refresh) {
|
|
1676
|
+
const cached = loadCandlesFromCache(symbol, interval, resolvedPeriod, effectiveCacheDir);
|
|
1677
|
+
if (cached?.length) return cached;
|
|
1678
|
+
}
|
|
1679
|
+
const candles = await fetchHistorical(symbol, interval, resolvedPeriod, rest);
|
|
1680
|
+
if (cache) {
|
|
1681
|
+
saveCandlesToCache(candles, {
|
|
1682
|
+
symbol,
|
|
1683
|
+
interval,
|
|
1684
|
+
period: resolvedPeriod,
|
|
1685
|
+
outDir: effectiveCacheDir,
|
|
1686
|
+
source: "yahoo"
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
return candles;
|
|
1690
|
+
}
|
|
1691
|
+
async function backtestHistorical({
|
|
1692
|
+
backtestOptions = {},
|
|
1693
|
+
data,
|
|
1694
|
+
...legacy
|
|
1695
|
+
} = {}) {
|
|
1696
|
+
const candles = await getHistoricalCandles(data || legacy);
|
|
1697
|
+
return backtest({
|
|
1698
|
+
candles,
|
|
1699
|
+
symbol: data?.symbol ?? legacy.symbol,
|
|
1700
|
+
interval: data?.interval ?? legacy.interval,
|
|
1701
|
+
range: data?.period ?? legacy.period ?? "custom",
|
|
1702
|
+
...backtestOptions
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1706
|
+
0 && (module.exports = {
|
|
1707
|
+
backtestHistorical,
|
|
1708
|
+
cachedCandlesPath,
|
|
1709
|
+
candleStats,
|
|
1710
|
+
fetchHistorical,
|
|
1711
|
+
fetchLatestCandle,
|
|
1712
|
+
getHistoricalCandles,
|
|
1713
|
+
loadCandlesFromCSV,
|
|
1714
|
+
loadCandlesFromCache,
|
|
1715
|
+
mergeCandles,
|
|
1716
|
+
normalizeCandles,
|
|
1717
|
+
saveCandlesToCache
|
|
1718
|
+
});
|