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
package/src/data/csv.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
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 resolveDate(value, customDateParser) {
|
|
9
|
+
if (value === undefined || value === null || value === "") {
|
|
10
|
+
throw new Error("Missing date value");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof customDateParser === "function") {
|
|
14
|
+
const parsed = customDateParser(value);
|
|
15
|
+
const time = parsed instanceof Date ? parsed.getTime() : Number(parsed);
|
|
16
|
+
if (Number.isFinite(time)) return time;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (value instanceof Date) {
|
|
20
|
+
const time = value.getTime();
|
|
21
|
+
if (Number.isFinite(time)) return time;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const raw = String(value).trim().replace(/^['"]|['"]$/g, "");
|
|
25
|
+
const numeric = Number(raw);
|
|
26
|
+
if (Number.isFinite(numeric)) {
|
|
27
|
+
return numeric < 1e11 ? numeric * 1000 : numeric;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parsed = Date.parse(raw);
|
|
31
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
32
|
+
|
|
33
|
+
const mt = raw.match(/^(\d{4})\.(\d{2})\.(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
34
|
+
if (mt) {
|
|
35
|
+
const [, year, month, day, hour, minute, second = "0"] = mt;
|
|
36
|
+
return new Date(
|
|
37
|
+
Number(year),
|
|
38
|
+
Number(month) - 1,
|
|
39
|
+
Number(day),
|
|
40
|
+
Number(hour),
|
|
41
|
+
Number(minute),
|
|
42
|
+
Number(second)
|
|
43
|
+
).getTime();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new Error(`Cannot parse date: ${raw}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCsvLine(line, delimiter) {
|
|
50
|
+
const out = [];
|
|
51
|
+
let current = "";
|
|
52
|
+
let inQuotes = false;
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
55
|
+
const char = line[index];
|
|
56
|
+
if (char === '"') {
|
|
57
|
+
if (inQuotes && line[index + 1] === '"') {
|
|
58
|
+
current += '"';
|
|
59
|
+
index += 1;
|
|
60
|
+
} else {
|
|
61
|
+
inQuotes = !inQuotes;
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!inQuotes && char === delimiter) {
|
|
67
|
+
out.push(current.trim());
|
|
68
|
+
current = "";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
current += char;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
out.push(current.trim());
|
|
76
|
+
return out.map((value) => value.replace(/^['"]|['"]$/g, ""));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildHeaderIndex(headers) {
|
|
80
|
+
const map = new Map();
|
|
81
|
+
headers.forEach((header, index) => {
|
|
82
|
+
map.set(header.trim().toLowerCase(), index);
|
|
83
|
+
});
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveColumn(column, headerIndex, aliases = []) {
|
|
88
|
+
if (typeof column === "number" && column >= 0) return column;
|
|
89
|
+
|
|
90
|
+
const candidates = [column, ...aliases]
|
|
91
|
+
.filter((value) => value !== undefined && value !== null)
|
|
92
|
+
.map((value) => String(value).trim().toLowerCase());
|
|
93
|
+
|
|
94
|
+
for (const candidate of candidates) {
|
|
95
|
+
if (headerIndex.has(candidate)) return headerIndex.get(candidate);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeDateBoundary(value, fallback) {
|
|
102
|
+
if (!value) return fallback;
|
|
103
|
+
if (value instanceof Date) return value.getTime();
|
|
104
|
+
const parsed = Date.parse(String(value));
|
|
105
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeCandles(candles) {
|
|
109
|
+
if (!Array.isArray(candles)) return [];
|
|
110
|
+
|
|
111
|
+
const normalized = candles
|
|
112
|
+
.map((bar) => {
|
|
113
|
+
try {
|
|
114
|
+
const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
|
|
115
|
+
const open = Number(bar?.open ?? bar?.o);
|
|
116
|
+
const high = Number(bar?.high ?? bar?.h);
|
|
117
|
+
const low = Number(bar?.low ?? bar?.l);
|
|
118
|
+
const close = Number(bar?.close ?? bar?.c);
|
|
119
|
+
const volume = Number(bar?.volume ?? bar?.v ?? 0);
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
!Number.isFinite(time) ||
|
|
123
|
+
!Number.isFinite(open) ||
|
|
124
|
+
!Number.isFinite(high) ||
|
|
125
|
+
!Number.isFinite(low) ||
|
|
126
|
+
!Number.isFinite(close)
|
|
127
|
+
) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
time,
|
|
133
|
+
open,
|
|
134
|
+
high: Math.max(high, open, close),
|
|
135
|
+
low: Math.min(low, open, close),
|
|
136
|
+
close,
|
|
137
|
+
volume: Number.isFinite(volume) ? volume : 0,
|
|
138
|
+
};
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.sort((left, right) => left.time - right.time);
|
|
145
|
+
|
|
146
|
+
const deduped = [];
|
|
147
|
+
let lastTime = null;
|
|
148
|
+
for (const candle of normalized) {
|
|
149
|
+
if (candle.time === lastTime) continue;
|
|
150
|
+
deduped.push(candle);
|
|
151
|
+
lastTime = candle.time;
|
|
152
|
+
}
|
|
153
|
+
return deduped;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function loadCandlesFromCSV(filePath, options = {}) {
|
|
157
|
+
const {
|
|
158
|
+
delimiter = ",",
|
|
159
|
+
skipRows = 0,
|
|
160
|
+
hasHeader = true,
|
|
161
|
+
timeCol = "time",
|
|
162
|
+
openCol = "open",
|
|
163
|
+
highCol = "high",
|
|
164
|
+
lowCol = "low",
|
|
165
|
+
closeCol = "close",
|
|
166
|
+
volumeCol = "volume",
|
|
167
|
+
startDate,
|
|
168
|
+
endDate,
|
|
169
|
+
customDateParser,
|
|
170
|
+
} = options;
|
|
171
|
+
|
|
172
|
+
if (!fs.existsSync(filePath)) {
|
|
173
|
+
throw new Error(`CSV file not found: ${filePath}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
177
|
+
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
178
|
+
if (lines.length <= skipRows) {
|
|
179
|
+
throw new Error(`CSV file is empty: ${filePath}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const headerRow = hasHeader ? parseCsvLine(lines[skipRows], delimiter) : [];
|
|
183
|
+
const headerIndex = buildHeaderIndex(headerRow);
|
|
184
|
+
const startRow = hasHeader ? skipRows + 1 : skipRows;
|
|
185
|
+
|
|
186
|
+
const timeIdx = resolveColumn(timeCol, headerIndex, [
|
|
187
|
+
"date",
|
|
188
|
+
"datetime",
|
|
189
|
+
"timestamp",
|
|
190
|
+
"ts",
|
|
191
|
+
"open time",
|
|
192
|
+
"opentime",
|
|
193
|
+
]);
|
|
194
|
+
const openIdx = resolveColumn(openCol, headerIndex, ["o"]);
|
|
195
|
+
const highIdx = resolveColumn(highCol, headerIndex, ["h"]);
|
|
196
|
+
const lowIdx = resolveColumn(lowCol, headerIndex, ["l"]);
|
|
197
|
+
const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
|
|
198
|
+
const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
timeIdx < 0 ||
|
|
202
|
+
openIdx < 0 ||
|
|
203
|
+
highIdx < 0 ||
|
|
204
|
+
lowIdx < 0 ||
|
|
205
|
+
closeIdx < 0
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Could not resolve required CSV columns in ${path.basename(filePath)}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const minTime = normalizeDateBoundary(startDate, -Infinity);
|
|
213
|
+
const maxTime = normalizeDateBoundary(endDate, Infinity);
|
|
214
|
+
const candles = [];
|
|
215
|
+
|
|
216
|
+
for (let row = startRow; row < lines.length; row += 1) {
|
|
217
|
+
const cols = parseCsvLine(lines[row], delimiter);
|
|
218
|
+
try {
|
|
219
|
+
const time = resolveDate(cols[timeIdx], customDateParser);
|
|
220
|
+
if (time < minTime || time > maxTime) continue;
|
|
221
|
+
|
|
222
|
+
const open = Number(cols[openIdx]);
|
|
223
|
+
const high = Number(cols[highIdx]);
|
|
224
|
+
const low = Number(cols[lowIdx]);
|
|
225
|
+
const close = Number(cols[closeIdx]);
|
|
226
|
+
const volume = volumeIdx >= 0 ? Number(cols[volumeIdx]) : 0;
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
!Number.isFinite(open) ||
|
|
230
|
+
!Number.isFinite(high) ||
|
|
231
|
+
!Number.isFinite(low) ||
|
|
232
|
+
!Number.isFinite(close)
|
|
233
|
+
) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
candles.push({
|
|
238
|
+
time,
|
|
239
|
+
open,
|
|
240
|
+
high: Math.max(high, open, close),
|
|
241
|
+
low: Math.min(low, open, close),
|
|
242
|
+
close,
|
|
243
|
+
volume: Number.isFinite(volume) ? volume : 0,
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return normalizeCandles(candles);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function mergeCandles(...arrays) {
|
|
254
|
+
return normalizeCandles(arrays.flat());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function candleStats(candles) {
|
|
258
|
+
if (!candles?.length) return null;
|
|
259
|
+
|
|
260
|
+
const normalized = normalizeCandles(candles);
|
|
261
|
+
const first = normalized[0];
|
|
262
|
+
const last = normalized[normalized.length - 1];
|
|
263
|
+
const gaps = [];
|
|
264
|
+
let minLow = Infinity;
|
|
265
|
+
let maxHigh = -Infinity;
|
|
266
|
+
|
|
267
|
+
for (const candle of normalized) {
|
|
268
|
+
if (candle.low < minLow) minLow = candle.low;
|
|
269
|
+
if (candle.high > maxHigh) maxHigh = candle.high;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (let index = 1; index < Math.min(normalized.length, 500); index += 1) {
|
|
273
|
+
const delta = normalized[index].time - normalized[index - 1].time;
|
|
274
|
+
if (delta > 0) gaps.push(delta);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
gaps.sort((left, right) => left - right);
|
|
278
|
+
const medianGapMs = gaps[Math.floor(gaps.length / 2)] || 0;
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
count: normalized.length,
|
|
282
|
+
firstTime: new Date(first.time).toISOString(),
|
|
283
|
+
lastTime: new Date(last.time).toISOString(),
|
|
284
|
+
durationDays: (last.time - first.time) / (1000 * 60 * 60 * 24),
|
|
285
|
+
estimatedIntervalMin: Math.round(medianGapMs / 60000),
|
|
286
|
+
priceRange: {
|
|
287
|
+
low: minLow,
|
|
288
|
+
high: maxHigh,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function saveCandlesToCache(
|
|
294
|
+
candles,
|
|
295
|
+
{ symbol = "UNKNOWN", interval = "tf", period = "range", outDir = "output/data", source } = {}
|
|
296
|
+
) {
|
|
297
|
+
const outputPath = path.join(
|
|
298
|
+
outDir,
|
|
299
|
+
`candles-${safeSegment(symbol)}-${safeSegment(interval)}-${safeSegment(period)}.json`
|
|
300
|
+
);
|
|
301
|
+
const normalized = normalizeCandles(candles);
|
|
302
|
+
|
|
303
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
304
|
+
fs.writeFileSync(
|
|
305
|
+
outputPath,
|
|
306
|
+
JSON.stringify(
|
|
307
|
+
{
|
|
308
|
+
symbol,
|
|
309
|
+
interval,
|
|
310
|
+
period,
|
|
311
|
+
source: source ?? null,
|
|
312
|
+
count: normalized.length,
|
|
313
|
+
asOf: new Date().toISOString(),
|
|
314
|
+
candles: normalized,
|
|
315
|
+
},
|
|
316
|
+
null,
|
|
317
|
+
2
|
|
318
|
+
),
|
|
319
|
+
"utf8"
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return outputPath;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function cachedCandlesPath(symbol, interval, period, outDir = "output/data") {
|
|
326
|
+
const fileName = `candles-${safeSegment(symbol)}-${safeSegment(interval)}-${safeSegment(period)}.json`;
|
|
327
|
+
return path.join(outDir, fileName);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function loadCandlesFromCache(symbol, interval, period, outDir = "output/data") {
|
|
331
|
+
const filePath = cachedCandlesPath(symbol, interval, period, outDir);
|
|
332
|
+
if (!fs.existsSync(filePath)) return null;
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
336
|
+
return Array.isArray(parsed?.candles) ? normalizeCandles(parsed.candles) : null;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { backtest as runBacktest } from "../engine/backtest.js";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
cachedCandlesPath,
|
|
6
|
+
candleStats,
|
|
7
|
+
loadCandlesFromCache,
|
|
8
|
+
loadCandlesFromCSV,
|
|
9
|
+
mergeCandles,
|
|
10
|
+
normalizeCandles,
|
|
11
|
+
saveCandlesToCache,
|
|
12
|
+
} from "./csv.js";
|
|
13
|
+
import { fetchHistorical, fetchLatestCandle } from "./yahoo.js";
|
|
14
|
+
|
|
15
|
+
function normalizeCacheDir(cacheDir) {
|
|
16
|
+
return cacheDir || path.join(process.cwd(), "output", "data");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function derivePeriodFromRange(startDate, endDate) {
|
|
20
|
+
if (!startDate || !endDate) return "custom";
|
|
21
|
+
const start = new Date(startDate).getTime();
|
|
22
|
+
const end = new Date(endDate).getTime();
|
|
23
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return "custom";
|
|
24
|
+
|
|
25
|
+
const days = Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
|
|
26
|
+
return `${days}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getHistoricalCandles(options = {}) {
|
|
30
|
+
const {
|
|
31
|
+
source: requestedSource = "auto",
|
|
32
|
+
symbol,
|
|
33
|
+
interval = "1d",
|
|
34
|
+
period,
|
|
35
|
+
cache = true,
|
|
36
|
+
refresh = false,
|
|
37
|
+
cacheDir,
|
|
38
|
+
csv,
|
|
39
|
+
csvPath,
|
|
40
|
+
...rest
|
|
41
|
+
} = options;
|
|
42
|
+
|
|
43
|
+
const effectiveCacheDir = normalizeCacheDir(cacheDir);
|
|
44
|
+
const source =
|
|
45
|
+
requestedSource === "auto"
|
|
46
|
+
? csvPath || csv?.filePath || csv?.path
|
|
47
|
+
? "csv"
|
|
48
|
+
: "yahoo"
|
|
49
|
+
: requestedSource;
|
|
50
|
+
|
|
51
|
+
if (source === "csv") {
|
|
52
|
+
const filePath = csvPath || csv?.filePath || csv?.path;
|
|
53
|
+
if (!filePath) {
|
|
54
|
+
throw new Error('CSV source requires "csvPath" or "csv.filePath"');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const candles = loadCandlesFromCSV(filePath, csv || rest);
|
|
58
|
+
if (cache && symbol) {
|
|
59
|
+
saveCandlesToCache(candles, {
|
|
60
|
+
symbol,
|
|
61
|
+
interval,
|
|
62
|
+
period:
|
|
63
|
+
period ??
|
|
64
|
+
derivePeriodFromRange(csv?.startDate ?? rest.startDate, csv?.endDate ?? rest.endDate),
|
|
65
|
+
outDir: effectiveCacheDir,
|
|
66
|
+
source: "csv",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return candles;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (source !== "yahoo") {
|
|
73
|
+
throw new Error(`Unsupported data source "${source}"`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!symbol) {
|
|
77
|
+
throw new Error('Yahoo source requires "symbol"');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolvedPeriod = period ?? "1y";
|
|
81
|
+
|
|
82
|
+
if (cache && !refresh) {
|
|
83
|
+
const cached = loadCandlesFromCache(symbol, interval, resolvedPeriod, effectiveCacheDir);
|
|
84
|
+
if (cached?.length) return cached;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const candles = await fetchHistorical(symbol, interval, resolvedPeriod, rest);
|
|
88
|
+
if (cache) {
|
|
89
|
+
saveCandlesToCache(candles, {
|
|
90
|
+
symbol,
|
|
91
|
+
interval,
|
|
92
|
+
period: resolvedPeriod,
|
|
93
|
+
outDir: effectiveCacheDir,
|
|
94
|
+
source: "yahoo",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return candles;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function backtestHistorical({
|
|
101
|
+
backtestOptions = {},
|
|
102
|
+
data,
|
|
103
|
+
...legacy
|
|
104
|
+
} = {}) {
|
|
105
|
+
const candles = await getHistoricalCandles(data || legacy);
|
|
106
|
+
return runBacktest({
|
|
107
|
+
candles,
|
|
108
|
+
symbol: data?.symbol ?? legacy.symbol,
|
|
109
|
+
interval: data?.interval ?? legacy.interval,
|
|
110
|
+
range: data?.period ?? legacy.period ?? "custom",
|
|
111
|
+
...backtestOptions,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
cachedCandlesPath,
|
|
117
|
+
candleStats,
|
|
118
|
+
fetchHistorical,
|
|
119
|
+
fetchLatestCandle,
|
|
120
|
+
loadCandlesFromCache,
|
|
121
|
+
loadCandlesFromCSV,
|
|
122
|
+
mergeCandles,
|
|
123
|
+
normalizeCandles,
|
|
124
|
+
saveCandlesToCache,
|
|
125
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const DAY_SEC = 24 * 60 * 60;
|
|
5
|
+
const requestQueue = {
|
|
6
|
+
lastRequestAt: 0,
|
|
7
|
+
minDelayMs: 400,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function nowSec() {
|
|
11
|
+
return Math.floor(Date.now() / 1000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function msToSec(value) {
|
|
15
|
+
return Math.floor(value / 1000);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isIntraday(interval) {
|
|
19
|
+
return /(m|h)$/i.test(String(interval));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeInterval(interval) {
|
|
23
|
+
return String(interval || "1d").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parsePeriodToMs(period) {
|
|
27
|
+
if (typeof period === "number" && Number.isFinite(period)) return period;
|
|
28
|
+
|
|
29
|
+
const raw = String(period || "60d")
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase();
|
|
32
|
+
const normalized = raw
|
|
33
|
+
.replace(/months?$/, "mo")
|
|
34
|
+
.replace(/^(\d+)mon$/, "$1mo")
|
|
35
|
+
.replace(/^(\d+)mos$/, "$1mo");
|
|
36
|
+
const match = normalized.match(/^(\d+)(mo|m|h|d|w|y)$/i);
|
|
37
|
+
|
|
38
|
+
if (!match) {
|
|
39
|
+
throw new Error(`Invalid period "${period}". Use values like "5d", "60d", "1y".`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const amount = Number(match[1]);
|
|
43
|
+
const unit = match[2].toLowerCase();
|
|
44
|
+
switch (unit) {
|
|
45
|
+
case "mo":
|
|
46
|
+
return Math.round(amount * 30.4375 * DAY_MS);
|
|
47
|
+
case "m":
|
|
48
|
+
return amount * 60 * 1000;
|
|
49
|
+
case "h":
|
|
50
|
+
return amount * 60 * 60 * 1000;
|
|
51
|
+
case "d":
|
|
52
|
+
return amount * DAY_MS;
|
|
53
|
+
case "w":
|
|
54
|
+
return amount * 7 * DAY_MS;
|
|
55
|
+
case "y":
|
|
56
|
+
return Math.round(amount * 365.25 * DAY_MS);
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(`Unsupported period unit "${unit}"`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function maxDaysForInterval(interval) {
|
|
63
|
+
const value = normalizeInterval(interval);
|
|
64
|
+
if (!isIntraday(value)) return 365 * 10;
|
|
65
|
+
|
|
66
|
+
if (/^\d+m$/i.test(value)) {
|
|
67
|
+
const minutes = Number(value.slice(0, -1));
|
|
68
|
+
if (minutes <= 2) return 7;
|
|
69
|
+
if (minutes <= 30) return 60;
|
|
70
|
+
if (minutes <= 60) return 730;
|
|
71
|
+
return 365;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (/^\d+h$/i.test(value)) return 730;
|
|
75
|
+
return 60;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizeBars(candles) {
|
|
79
|
+
const deduped = new Map();
|
|
80
|
+
for (const candle of candles) {
|
|
81
|
+
if (
|
|
82
|
+
!Number.isFinite(candle?.time) ||
|
|
83
|
+
!Number.isFinite(candle?.open) ||
|
|
84
|
+
!Number.isFinite(candle?.high) ||
|
|
85
|
+
!Number.isFinite(candle?.low) ||
|
|
86
|
+
!Number.isFinite(candle?.close)
|
|
87
|
+
) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
deduped.set(candle.time, {
|
|
92
|
+
time: candle.time,
|
|
93
|
+
open: candle.open,
|
|
94
|
+
high: Math.max(candle.high, candle.open, candle.close),
|
|
95
|
+
low: Math.min(candle.low, candle.open, candle.close),
|
|
96
|
+
close: candle.close,
|
|
97
|
+
volume: Number.isFinite(candle.volume) ? candle.volume : 0,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return [...deduped.values()].sort((left, right) => left.time - right.time);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function rateLimitedFetch(url, options = {}) {
|
|
105
|
+
const elapsed = Date.now() - requestQueue.lastRequestAt;
|
|
106
|
+
if (elapsed < requestQueue.minDelayMs) {
|
|
107
|
+
await sleep(requestQueue.minDelayMs - elapsed);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
requestQueue.lastRequestAt = Date.now();
|
|
111
|
+
return fetch(url, {
|
|
112
|
+
...options,
|
|
113
|
+
headers: {
|
|
114
|
+
"User-Agent":
|
|
115
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
116
|
+
...options.headers,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchYahooChart(symbol, { period1, period2, interval, includePrePost = false }) {
|
|
122
|
+
const url = new URL(
|
|
123
|
+
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`
|
|
124
|
+
);
|
|
125
|
+
url.searchParams.set("period1", String(Math.floor(period1)));
|
|
126
|
+
url.searchParams.set("period2", String(Math.floor(period2)));
|
|
127
|
+
url.searchParams.set("interval", normalizeInterval(interval));
|
|
128
|
+
url.searchParams.set("includePrePost", includePrePost ? "true" : "false");
|
|
129
|
+
url.searchParams.set("events", "div,splits");
|
|
130
|
+
|
|
131
|
+
const response = await rateLimitedFetch(url.toString());
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
throw new Error(`Yahoo API error ${response.status}: ${text}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const payload = await response.json();
|
|
138
|
+
if (payload.chart?.error) {
|
|
139
|
+
throw new Error(payload.chart.error.description || "Yahoo chart error");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = payload.chart?.result?.[0];
|
|
143
|
+
if (!result) return [];
|
|
144
|
+
|
|
145
|
+
const timestamps = result.timestamp || [];
|
|
146
|
+
const quote = result.indicators?.quote?.[0] || {};
|
|
147
|
+
const open = quote.open || [];
|
|
148
|
+
const high = quote.high || [];
|
|
149
|
+
const low = quote.low || [];
|
|
150
|
+
const close = quote.close || [];
|
|
151
|
+
const volume = quote.volume || [];
|
|
152
|
+
|
|
153
|
+
const candles = [];
|
|
154
|
+
for (let index = 0; index < timestamps.length; index += 1) {
|
|
155
|
+
if (
|
|
156
|
+
open[index] == null ||
|
|
157
|
+
high[index] == null ||
|
|
158
|
+
low[index] == null ||
|
|
159
|
+
close[index] == null
|
|
160
|
+
) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
candles.push({
|
|
165
|
+
time: timestamps[index] * 1000,
|
|
166
|
+
open: open[index],
|
|
167
|
+
high: high[index],
|
|
168
|
+
low: low[index],
|
|
169
|
+
close: close[index],
|
|
170
|
+
volume: volume[index] ?? 0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return candles;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function fetchYahooChartWithRetry(symbol, params, maxRetries = 5) {
|
|
178
|
+
let lastError = null;
|
|
179
|
+
|
|
180
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
181
|
+
try {
|
|
182
|
+
return await fetchYahooChart(symbol, params);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
lastError = error;
|
|
185
|
+
const message = String(error?.message || error);
|
|
186
|
+
const isRateLimited = /too many requests|rate limit|429/i.test(message);
|
|
187
|
+
const isRetryable = isRateLimited || /timeout|fetch failed|network/i.test(message);
|
|
188
|
+
|
|
189
|
+
if (!isRetryable || attempt === maxRetries - 1) break;
|
|
190
|
+
|
|
191
|
+
const delay = isRateLimited
|
|
192
|
+
? Math.min(30_000, 2_000 * 2 ** attempt)
|
|
193
|
+
: Math.min(10_000, 750 * (attempt + 1));
|
|
194
|
+
await sleep(delay);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw lastError ?? new Error(`Failed to fetch Yahoo data for ${symbol}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
202
|
+
const normalizedInterval = normalizeInterval(interval);
|
|
203
|
+
const spanMs = parsePeriodToMs(period);
|
|
204
|
+
const maxSpanMs = maxDaysForInterval(normalizedInterval) * DAY_MS;
|
|
205
|
+
const includePrePost = Boolean(options.includePrePost);
|
|
206
|
+
|
|
207
|
+
if (spanMs <= maxSpanMs) {
|
|
208
|
+
const endSec = nowSec();
|
|
209
|
+
const startSec = Math.max(0, endSec - msToSec(spanMs));
|
|
210
|
+
const candles = await fetchYahooChartWithRetry(symbol, {
|
|
211
|
+
period1: startSec,
|
|
212
|
+
period2: endSec,
|
|
213
|
+
interval: normalizedInterval,
|
|
214
|
+
includePrePost,
|
|
215
|
+
});
|
|
216
|
+
return sanitizeBars(candles);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const chunks = [];
|
|
220
|
+
let remainingMs = spanMs;
|
|
221
|
+
let chunkEndMs = Date.now();
|
|
222
|
+
|
|
223
|
+
while (remainingMs > 0) {
|
|
224
|
+
const takeMs = Math.min(remainingMs, maxSpanMs);
|
|
225
|
+
const chunkStartMs = chunkEndMs - takeMs;
|
|
226
|
+
const candles = await fetchYahooChartWithRetry(symbol, {
|
|
227
|
+
period1: msToSec(chunkStartMs),
|
|
228
|
+
period2: msToSec(chunkEndMs),
|
|
229
|
+
interval: normalizedInterval,
|
|
230
|
+
includePrePost,
|
|
231
|
+
});
|
|
232
|
+
chunks.push(...candles);
|
|
233
|
+
chunkEndMs = chunkStartMs - 1000;
|
|
234
|
+
remainingMs -= takeMs;
|
|
235
|
+
|
|
236
|
+
if (chunks.length > 2_000_000) break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return sanitizeBars(chunks);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function fetchLatestCandle(symbol, interval = "1m", options = {}) {
|
|
243
|
+
const bars = await fetchHistorical(symbol, interval, "5d", options);
|
|
244
|
+
return bars[bars.length - 1] ?? null;
|
|
245
|
+
}
|