tradelab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/examples/emaCross.js +108 -0
- package/examples/yahooEmaCross.js +88 -0
- package/package.json +42 -0
- package/scripts/import-csv.js +69 -0
- package/scripts/prefetch.js +52 -0
- package/src/data/csv.js +340 -0
- package/src/data/index.js +125 -0
- package/src/data/yahoo.js +245 -0
- package/src/engine/backtest.js +852 -0
- package/src/engine/execution.js +120 -0
- package/src/index.js +43 -0
- package/src/metrics/buildMetrics.js +306 -0
- package/src/reporting/exportBacktestArtifacts.js +53 -0
- package/src/reporting/exportTradesCsv.js +73 -0
- package/src/reporting/renderHtmlReport.js +310 -0
- package/src/utils/indicators.js +138 -0
- package/src/utils/positionSizing.js +26 -0
- package/src/utils/time.js +92 -0
- package/templates/report.css +213 -0
- package/templates/report.html +106 -0
- package/templates/report.js +120 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const packageRoot = path.join(__dirname, "..", "..");
|
|
8
|
+
const templateCache = new Map();
|
|
9
|
+
|
|
10
|
+
function readTemplate(relativePath) {
|
|
11
|
+
const absolutePath = path.join(packageRoot, relativePath);
|
|
12
|
+
if (!templateCache.has(absolutePath)) {
|
|
13
|
+
templateCache.set(absolutePath, fs.readFileSync(absolutePath, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
return templateCache.get(absolutePath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmt(value, digits = 2) {
|
|
19
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
20
|
+
if (!Number.isFinite(value)) return value > 0 ? "Inf" : "0";
|
|
21
|
+
return Number(value).toFixed(digits);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fmtPct(value, digits = 2) {
|
|
25
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
26
|
+
if (!Number.isFinite(value)) return value > 0 ? "Inf" : "0";
|
|
27
|
+
return `${(Number(value) * 100).toFixed(digits)}%`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function escapeHtml(value) {
|
|
31
|
+
return String(value)
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function serializeJson(value) {
|
|
40
|
+
return JSON.stringify(value)
|
|
41
|
+
.replace(/</g, "\\u003c")
|
|
42
|
+
.replace(/<\/script/gi, "<\\\\/script");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderTemplate(template, replacements) {
|
|
46
|
+
return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => replacements[key] ?? "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function metricCards(metrics) {
|
|
50
|
+
const cards = [
|
|
51
|
+
{
|
|
52
|
+
label: "Net Return",
|
|
53
|
+
value: fmtPct(metrics.returnPct ?? 0, 2),
|
|
54
|
+
note: `PnL ${fmt(metrics.totalPnL ?? 0, 2)}`,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
label: "Win Rate",
|
|
58
|
+
value: fmtPct(metrics.winRate ?? 0, 1),
|
|
59
|
+
note: `${metrics.trades ?? 0} completed positions`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: "Profit Factor",
|
|
63
|
+
value: fmt(metrics.profitFactor ?? 0, 2),
|
|
64
|
+
note: `Avg R ${fmt(metrics.avgR ?? 0, 2)}`,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
label: "Drawdown",
|
|
68
|
+
value: fmtPct(metrics.maxDrawdownPct ?? 0, 2),
|
|
69
|
+
note: `Calmar ${fmt(metrics.calmar ?? 0, 2)}`,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
return cards
|
|
74
|
+
.map(
|
|
75
|
+
(card) => `
|
|
76
|
+
<article class="metric-card">
|
|
77
|
+
<div class="metric-card__label">${escapeHtml(card.label)}</div>
|
|
78
|
+
<div class="metric-card__value">${escapeHtml(card.value)}</div>
|
|
79
|
+
<div class="metric-card__note">${escapeHtml(card.note)}</div>
|
|
80
|
+
</article>
|
|
81
|
+
`
|
|
82
|
+
)
|
|
83
|
+
.join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderRows(rows, { empty = "No data available", colSpan = 2 } = {}) {
|
|
87
|
+
if (!rows.length) {
|
|
88
|
+
return `<tr><td class="table-empty" colspan="${colSpan}">${escapeHtml(empty)}</td></tr>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return rows
|
|
92
|
+
.map(
|
|
93
|
+
([label, value]) => `
|
|
94
|
+
<tr>
|
|
95
|
+
<th>${escapeHtml(label)}</th>
|
|
96
|
+
<td>${escapeHtml(value)}</td>
|
|
97
|
+
</tr>
|
|
98
|
+
`
|
|
99
|
+
)
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderPositionRows(positions) {
|
|
104
|
+
if (!positions?.length) {
|
|
105
|
+
return '<tr><td class="table-empty" colspan="7">No completed positions</td></tr>';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return positions
|
|
109
|
+
.slice(-25)
|
|
110
|
+
.reverse()
|
|
111
|
+
.map((trade) => {
|
|
112
|
+
const exit = trade.exit || {};
|
|
113
|
+
return `
|
|
114
|
+
<tr>
|
|
115
|
+
<td>${escapeHtml(new Date(trade.openTime).toISOString())}</td>
|
|
116
|
+
<td>${escapeHtml(trade.side)}</td>
|
|
117
|
+
<td>${escapeHtml(fmt(trade.entryFill ?? trade.entry, 4))}</td>
|
|
118
|
+
<td>${escapeHtml(fmt(exit.price, 4))}</td>
|
|
119
|
+
<td>${escapeHtml(exit.reason ?? "—")}</td>
|
|
120
|
+
<td>${escapeHtml(fmt(exit.pnl, 2))}</td>
|
|
121
|
+
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
|
|
122
|
+
fmt(trade.maeR ?? 0, 2)
|
|
123
|
+
)}</td>
|
|
124
|
+
</tr>
|
|
125
|
+
`;
|
|
126
|
+
})
|
|
127
|
+
.join("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildDailyPnl(eqSeries) {
|
|
131
|
+
if (!eqSeries?.length) return [];
|
|
132
|
+
|
|
133
|
+
const byDay = new Map();
|
|
134
|
+
for (const point of eqSeries) {
|
|
135
|
+
const date = new Date(point.time).toISOString().slice(0, 10);
|
|
136
|
+
const record = byDay.get(date) || {
|
|
137
|
+
date,
|
|
138
|
+
open: point.equity,
|
|
139
|
+
close: point.equity,
|
|
140
|
+
firstTime: point.time,
|
|
141
|
+
lastTime: point.time,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (point.time < record.firstTime) {
|
|
145
|
+
record.firstTime = point.time;
|
|
146
|
+
record.open = point.equity;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (point.time >= record.lastTime) {
|
|
150
|
+
record.lastTime = point.time;
|
|
151
|
+
record.close = point.equity;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
byDay.set(date, record);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [...byDay.values()]
|
|
158
|
+
.sort((left, right) => left.date.localeCompare(right.date))
|
|
159
|
+
.map((record) => ({
|
|
160
|
+
date: record.date,
|
|
161
|
+
pnl: record.close - record.open,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildReportPayload({ eqSeries, replay }) {
|
|
166
|
+
const normalizedEqSeries = eqSeries.map((point) => ({
|
|
167
|
+
t: new Date(point.time).toISOString(),
|
|
168
|
+
equity: point.equity,
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
let peak = normalizedEqSeries[0]?.equity ?? 0;
|
|
172
|
+
const drawdown = normalizedEqSeries.map((point) => {
|
|
173
|
+
peak = Math.max(peak, point.equity);
|
|
174
|
+
return {
|
|
175
|
+
t: point.t,
|
|
176
|
+
value: peak > 0 ? (point.equity - peak) / peak : 0,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const normalizedReplay = {
|
|
181
|
+
frames: Array.isArray(replay?.frames) ? replay.frames : [],
|
|
182
|
+
events: Array.isArray(replay?.events) ? replay.events : [],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
eqSeries: normalizedEqSeries,
|
|
187
|
+
drawdown,
|
|
188
|
+
dailyPnl: buildDailyPnl(eqSeries),
|
|
189
|
+
replay: normalizedReplay,
|
|
190
|
+
hasReplay: normalizedReplay.frames.length > 0,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderHtmlReport({
|
|
195
|
+
symbol,
|
|
196
|
+
interval,
|
|
197
|
+
range,
|
|
198
|
+
metrics,
|
|
199
|
+
eqSeries,
|
|
200
|
+
replay,
|
|
201
|
+
positions = [],
|
|
202
|
+
plotlyCdnUrl = "https://cdn.plot.ly/plotly-2.35.2.min.js",
|
|
203
|
+
}) {
|
|
204
|
+
if (!eqSeries?.length) {
|
|
205
|
+
throw new Error("renderHtmlReport() requires a populated eqSeries array");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const template = readTemplate("templates/report.html");
|
|
209
|
+
const css = readTemplate("templates/report.css");
|
|
210
|
+
const clientJs = readTemplate("templates/report.js");
|
|
211
|
+
|
|
212
|
+
const title = `${symbol} ${interval} (${range})`;
|
|
213
|
+
const payload = buildReportPayload({ eqSeries, replay });
|
|
214
|
+
const summaryRows = renderRows([
|
|
215
|
+
["Trades", String(metrics.trades ?? 0)],
|
|
216
|
+
["Win rate", fmtPct(metrics.winRate ?? 0, 1)],
|
|
217
|
+
["Profit factor", fmt(metrics.profitFactor ?? 0, 2)],
|
|
218
|
+
["Expectancy / trade", fmt(metrics.expectancy ?? 0, 2)],
|
|
219
|
+
["Total R", fmt(metrics.totalR ?? 0, 2)],
|
|
220
|
+
["Avg R / trade", fmt(metrics.avgR ?? 0, 2)],
|
|
221
|
+
["Max drawdown", fmtPct(metrics.maxDrawdownPct ?? 0, 2)],
|
|
222
|
+
["Exposure", fmtPct(metrics.exposurePct ?? 0, 1)],
|
|
223
|
+
["Avg hold (min)", fmt(metrics.avgHoldMin ?? 0, 1)],
|
|
224
|
+
["Daily Sharpe", fmt(metrics.sharpeDaily ?? 0, 2)],
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const breakdownRows = renderRows([
|
|
228
|
+
[
|
|
229
|
+
"Long",
|
|
230
|
+
`${metrics.long?.trades ?? 0} trades, ${fmtPct(
|
|
231
|
+
metrics.long?.winRate ?? 0,
|
|
232
|
+
1
|
|
233
|
+
)} win, avg R ${fmt(metrics.long?.avgR ?? 0, 2)}`,
|
|
234
|
+
],
|
|
235
|
+
[
|
|
236
|
+
"Short",
|
|
237
|
+
`${metrics.short?.trades ?? 0} trades, ${fmtPct(
|
|
238
|
+
metrics.short?.winRate ?? 0,
|
|
239
|
+
1
|
|
240
|
+
)} win, avg R ${fmt(metrics.short?.avgR ?? 0, 2)}`,
|
|
241
|
+
],
|
|
242
|
+
["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
|
|
243
|
+
[
|
|
244
|
+
"Hold p50 / p90",
|
|
245
|
+
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
|
|
246
|
+
metrics.holdDistMin?.p90 ?? 0,
|
|
247
|
+
1
|
|
248
|
+
)} min`,
|
|
249
|
+
],
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
return renderTemplate(template, {
|
|
253
|
+
TITLE: escapeHtml(title),
|
|
254
|
+
CSS: css,
|
|
255
|
+
REPORT_JS: clientJs,
|
|
256
|
+
PLOTLY_CDN_URL: escapeHtml(plotlyCdnUrl),
|
|
257
|
+
HERO_SUBTITLE: escapeHtml(
|
|
258
|
+
`Start ${fmt(metrics.startEquity ?? 0, 2)} • End ${fmt(metrics.finalEquity ?? 0, 2)}`
|
|
259
|
+
),
|
|
260
|
+
HERO_PILL: escapeHtml(
|
|
261
|
+
`Return ${fmtPct(metrics.returnPct ?? 0, 2)} • Max DD ${fmtPct(
|
|
262
|
+
metrics.maxDrawdownPct ?? 0,
|
|
263
|
+
2
|
|
264
|
+
)}`
|
|
265
|
+
),
|
|
266
|
+
METRIC_CARDS: metricCards(metrics),
|
|
267
|
+
SUMMARY_ROWS: summaryRows,
|
|
268
|
+
BREAKDOWN_ROWS: breakdownRows,
|
|
269
|
+
POSITION_ROWS: renderPositionRows(positions),
|
|
270
|
+
REPLAY_VISIBILITY: payload.hasReplay ? "" : "is-hidden",
|
|
271
|
+
REPORT_DATA_JSON: serializeJson(payload),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function exportHtmlReport({
|
|
276
|
+
symbol,
|
|
277
|
+
interval,
|
|
278
|
+
range,
|
|
279
|
+
metrics,
|
|
280
|
+
eqSeries,
|
|
281
|
+
replay,
|
|
282
|
+
positions,
|
|
283
|
+
outDir = "output",
|
|
284
|
+
plotlyCdnUrl,
|
|
285
|
+
}) {
|
|
286
|
+
if (!eqSeries?.length) return null;
|
|
287
|
+
|
|
288
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
289
|
+
const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
290
|
+
const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
291
|
+
const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
292
|
+
const outputPath = path.join(
|
|
293
|
+
outDir,
|
|
294
|
+
`report-${safeSymbol}-${safeInterval}-${safeRange}.html`
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const html = renderHtmlReport({
|
|
298
|
+
symbol,
|
|
299
|
+
interval,
|
|
300
|
+
range,
|
|
301
|
+
metrics,
|
|
302
|
+
eqSeries,
|
|
303
|
+
replay,
|
|
304
|
+
positions,
|
|
305
|
+
plotlyCdnUrl,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
fs.writeFileSync(outputPath, html, "utf8");
|
|
309
|
+
return outputPath;
|
|
310
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export function ema(values, period = 14) {
|
|
2
|
+
if (!values?.length) return [];
|
|
3
|
+
|
|
4
|
+
const lookback = Math.max(1, period | 0);
|
|
5
|
+
const output = new Array(values.length);
|
|
6
|
+
let warmupSum = 0;
|
|
7
|
+
|
|
8
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
9
|
+
const value = values[index];
|
|
10
|
+
|
|
11
|
+
if (!Number.isFinite(value)) {
|
|
12
|
+
output[index] = index === 0 ? 0 : output[index - 1];
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (index < lookback) {
|
|
17
|
+
warmupSum += value;
|
|
18
|
+
output[index] = index === lookback - 1 ? warmupSum / lookback : value;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const smoothing = 2 / (lookback + 1);
|
|
23
|
+
output[index] = value * smoothing + output[index - 1] * (1 - smoothing);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function swingHigh(bars, index, left = 2, right = 2) {
|
|
30
|
+
if (index < left || index + right >= bars.length) return false;
|
|
31
|
+
const high = bars[index].high;
|
|
32
|
+
for (let cursor = index - left; cursor <= index + right; cursor += 1) {
|
|
33
|
+
if (cursor !== index && bars[cursor].high >= high) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function swingLow(bars, index, left = 2, right = 2) {
|
|
39
|
+
if (index < left || index + right >= bars.length) return false;
|
|
40
|
+
const low = bars[index].low;
|
|
41
|
+
for (let cursor = index - left; cursor <= index + right; cursor += 1) {
|
|
42
|
+
if (cursor !== index && bars[cursor].low <= low) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function detectFVG(bars, index) {
|
|
48
|
+
if (index < 2) return null;
|
|
49
|
+
const first = bars[index - 2];
|
|
50
|
+
const third = bars[index];
|
|
51
|
+
|
|
52
|
+
if (first.high < third.low) {
|
|
53
|
+
return {
|
|
54
|
+
type: "bull",
|
|
55
|
+
top: first.high,
|
|
56
|
+
bottom: third.low,
|
|
57
|
+
mid: (first.high + third.low) / 2,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (first.low > third.high) {
|
|
62
|
+
return {
|
|
63
|
+
type: "bear",
|
|
64
|
+
top: third.high,
|
|
65
|
+
bottom: first.low,
|
|
66
|
+
mid: (third.high + first.low) / 2,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function lastSwing(bars, index, direction) {
|
|
74
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
75
|
+
if (direction === "up" && swingLow(bars, cursor)) {
|
|
76
|
+
return { idx: cursor, price: bars[cursor].low };
|
|
77
|
+
}
|
|
78
|
+
if (direction === "down" && swingHigh(bars, cursor)) {
|
|
79
|
+
return { idx: cursor, price: bars[cursor].high };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function structureState(bars, index) {
|
|
86
|
+
return {
|
|
87
|
+
lastLow: lastSwing(bars, index, "up"),
|
|
88
|
+
lastHigh: lastSwing(bars, index, "down"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function atr(bars, period = 14) {
|
|
93
|
+
if (!bars?.length || period <= 0) return [];
|
|
94
|
+
|
|
95
|
+
const trueRanges = new Array(bars.length);
|
|
96
|
+
for (let index = 0; index < bars.length; index += 1) {
|
|
97
|
+
if (index === 0) {
|
|
98
|
+
trueRanges[index] = bars[index].high - bars[index].low;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const high = bars[index].high;
|
|
103
|
+
const low = bars[index].low;
|
|
104
|
+
const previousClose = bars[index - 1].close;
|
|
105
|
+
trueRanges[index] = Math.max(
|
|
106
|
+
high - low,
|
|
107
|
+
Math.abs(high - previousClose),
|
|
108
|
+
Math.abs(low - previousClose)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const output = new Array(trueRanges.length);
|
|
113
|
+
let previousAtr;
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < trueRanges.length; index += 1) {
|
|
116
|
+
if (index < period) {
|
|
117
|
+
output[index] = undefined;
|
|
118
|
+
if (index === period - 1) {
|
|
119
|
+
let seed = 0;
|
|
120
|
+
for (let cursor = 0; cursor < period; cursor += 1) {
|
|
121
|
+
seed += trueRanges[cursor];
|
|
122
|
+
}
|
|
123
|
+
previousAtr = seed / period;
|
|
124
|
+
output[index] = previousAtr;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
previousAtr =
|
|
130
|
+
(previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
131
|
+
output[index] = previousAtr;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return output;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const bpsOf = (price, bps) => price * (bps / 10000);
|
|
138
|
+
export const pct = (a, b) => (a - b) / b;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function roundStep(value, step) {
|
|
2
|
+
return Math.floor(value / step) * step;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function calculatePositionSize({
|
|
6
|
+
equity,
|
|
7
|
+
entry,
|
|
8
|
+
stop,
|
|
9
|
+
riskFraction = 0.01,
|
|
10
|
+
qtyStep = 0.001,
|
|
11
|
+
minQty = 0.001,
|
|
12
|
+
maxLeverage = 2,
|
|
13
|
+
}) {
|
|
14
|
+
const riskPerUnit = Math.abs(entry - stop);
|
|
15
|
+
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
16
|
+
|
|
17
|
+
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
18
|
+
let quantity = maxRiskDollars / riskPerUnit;
|
|
19
|
+
|
|
20
|
+
const leverageCapQty =
|
|
21
|
+
(equity * maxLeverage) / Math.max(1e-12, Math.abs(entry));
|
|
22
|
+
quantity = Math.min(quantity, leverageCapQty);
|
|
23
|
+
quantity = roundStep(quantity, qtyStep);
|
|
24
|
+
|
|
25
|
+
return quantity >= minQty ? quantity : 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
function usDstBoundsUTC(year) {
|
|
2
|
+
let marchCursor = new Date(Date.UTC(year, 2, 1, 7, 0, 0));
|
|
3
|
+
let sundaysSeen = 0;
|
|
4
|
+
|
|
5
|
+
while (marchCursor.getUTCMonth() === 2) {
|
|
6
|
+
if (marchCursor.getUTCDay() === 0) sundaysSeen += 1;
|
|
7
|
+
if (sundaysSeen === 2) break;
|
|
8
|
+
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1000);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const dstStart = new Date(
|
|
12
|
+
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
16
|
+
while (novemberCursor.getUTCDay() !== 0) {
|
|
17
|
+
novemberCursor = new Date(
|
|
18
|
+
novemberCursor.getTime() + 24 * 60 * 60 * 1000
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dstEnd = new Date(
|
|
23
|
+
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return { dstStart, dstEnd };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isUsEasternDST(timeMs) {
|
|
30
|
+
const date = new Date(timeMs);
|
|
31
|
+
const { dstStart, dstEnd } = usDstBoundsUTC(date.getUTCFullYear());
|
|
32
|
+
return date >= dstStart && date < dstEnd;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function offsetET(timeMs) {
|
|
36
|
+
return isUsEasternDST(timeMs) ? 4 : 5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function minutesET(timeMs) {
|
|
40
|
+
const date = new Date(timeMs);
|
|
41
|
+
const offset = offsetET(timeMs);
|
|
42
|
+
return ((date.getUTCHours() - offset + 24) % 24) * 60 + date.getUTCMinutes();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSession(timeMs, session = "NYSE") {
|
|
46
|
+
const day = new Date(timeMs).getUTCDay();
|
|
47
|
+
if (day === 0 || day === 6) {
|
|
48
|
+
if (session === "FUT") {
|
|
49
|
+
const minutes = minutesET(timeMs);
|
|
50
|
+
return minutes >= 18 * 60 || minutes < 17 * 60;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const minutes = minutesET(timeMs);
|
|
56
|
+
if (session === "AUTO") return true;
|
|
57
|
+
|
|
58
|
+
if (session === "FUT") {
|
|
59
|
+
const maintenanceStart = 17 * 60;
|
|
60
|
+
const maintenanceEnd = 18 * 60;
|
|
61
|
+
return !(
|
|
62
|
+
minutes >= maintenanceStart && minutes < maintenanceEnd
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const open = 9 * 60 + 30;
|
|
67
|
+
const close = 16 * 60;
|
|
68
|
+
return minutes >= open && minutes <= close;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseWindowsCSV(csv) {
|
|
72
|
+
if (!csv) return null;
|
|
73
|
+
return csv
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((token) => token.trim())
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map((windowText) => {
|
|
78
|
+
const [start, end] = windowText.split("-").map((value) => value.trim());
|
|
79
|
+
const [startHour, startMinute] = start.split(":").map(Number);
|
|
80
|
+
const [endHour, endMinute] = end.split(":").map(Number);
|
|
81
|
+
return {
|
|
82
|
+
aMin: startHour * 60 + startMinute,
|
|
83
|
+
bMin: endHour * 60 + endMinute,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inWindowsET(timeMs, windows) {
|
|
89
|
+
if (!windows?.length) return true;
|
|
90
|
+
const minutes = minutesET(timeMs);
|
|
91
|
+
return windows.some((window) => minutes >= window.aMin && minutes <= window.bMin);
|
|
92
|
+
}
|