tradelab 0.5.0 → 1.0.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 +89 -41
- package/bin/tradelab.js +276 -30
- package/dist/cjs/data.cjs +134 -104
- package/dist/cjs/index.cjs +378 -177
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +21 -9
- package/docs/api-reference.md +87 -29
- package/docs/backtest-engine.md +37 -53
- package/docs/data-reporting-cli.md +60 -34
- package/docs/examples.md +6 -12
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +89 -37
- package/src/engine/barSystemRunner.js +182 -118
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +54 -6
- package/src/engine/walkForward.js +37 -14
- package/src/index.js +2 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +18 -41
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +21 -3
- package/types/live.d.ts +382 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { StorageProvider } from "./interface.js";
|
|
6
|
+
|
|
7
|
+
function sanitizeNamespace(namespace) {
|
|
8
|
+
return String(namespace || "default").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function ensureDir(dirPath) {
|
|
12
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readJsonFile(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error && error.code === "ENOENT") return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeJsonAtomic(filePath, payload) {
|
|
26
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random()
|
|
27
|
+
.toString(16)
|
|
28
|
+
.slice(2)}.tmp`;
|
|
29
|
+
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
|
30
|
+
await fsp.rename(tmpPath, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function appendJsonLine(filePath, payload) {
|
|
34
|
+
await ensureDir(path.dirname(filePath));
|
|
35
|
+
await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJsonLines(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
41
|
+
return raw
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trim())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((line) => JSON.parse(line));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error && error.code === "ENOENT") return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Zero-dependency JSON state storage.
|
|
54
|
+
*/
|
|
55
|
+
export class JsonFileStorage extends StorageProvider {
|
|
56
|
+
constructor({ baseDir = path.resolve(process.cwd(), "output/live-state") } = {}) {
|
|
57
|
+
super();
|
|
58
|
+
this.baseDir = baseDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
namespaceDir(namespace) {
|
|
62
|
+
return path.join(this.baseDir, sanitizeNamespace(namespace));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
statePath(namespace) {
|
|
66
|
+
return path.join(this.namespaceDir(namespace), "state.json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tradesPath(namespace) {
|
|
70
|
+
return path.join(this.namespaceDir(namespace), "trades.jsonl");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
equityPath(namespace) {
|
|
74
|
+
return path.join(this.namespaceDir(namespace), "equity.jsonl");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async load(namespace) {
|
|
78
|
+
return readJsonFile(this.statePath(namespace));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async save(namespace, state) {
|
|
82
|
+
const dir = this.namespaceDir(namespace);
|
|
83
|
+
await ensureDir(dir);
|
|
84
|
+
await writeJsonAtomic(this.statePath(namespace), state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async appendTrade(namespace, trade) {
|
|
88
|
+
await appendJsonLine(this.tradesPath(namespace), trade);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async appendEquityPoint(namespace, point) {
|
|
92
|
+
await appendJsonLine(this.equityPath(namespace), point);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async loadTrades(namespace) {
|
|
96
|
+
return readJsonLines(this.tradesPath(namespace));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async loadEquityCurve(namespace) {
|
|
100
|
+
return readJsonLines(this.equityPath(namespace));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async clear(namespace) {
|
|
104
|
+
const dir = this.namespaceDir(namespace);
|
|
105
|
+
if (!fs.existsSync(dir)) return;
|
|
106
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createJsonFileStorage(options) {
|
|
111
|
+
return new JsonFileStorage(options);
|
|
112
|
+
}
|
|
@@ -16,11 +16,7 @@ function sortino(values) {
|
|
|
16
16
|
const losses = values.filter((value) => value < 0);
|
|
17
17
|
const downsideDeviation = stddev(losses.length ? losses : [0]);
|
|
18
18
|
const avg = mean(values);
|
|
19
|
-
return downsideDeviation === 0
|
|
20
|
-
? avg > 0
|
|
21
|
-
? Infinity
|
|
22
|
-
: 0
|
|
23
|
-
: avg / downsideDeviation;
|
|
19
|
+
return downsideDeviation === 0 ? (avg > 0 ? Infinity : 0) : avg / downsideDeviation;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
function dayKeyUTC(timeMs) {
|
|
@@ -36,10 +32,7 @@ function tradeRMultiple(trade) {
|
|
|
36
32
|
const initialRisk = trade._initRisk || 0;
|
|
37
33
|
if (initialRisk <= 0) return 0;
|
|
38
34
|
const entry = trade.entryFill ?? trade.entry;
|
|
39
|
-
const perUnit =
|
|
40
|
-
trade.side === "long"
|
|
41
|
-
? trade.exit.price - entry
|
|
42
|
-
: entry - trade.exit.price;
|
|
35
|
+
const perUnit = trade.side === "long" ? trade.exit.price - entry : entry - trade.exit.price;
|
|
43
36
|
return perUnit / initialRisk;
|
|
44
37
|
}
|
|
45
38
|
|
|
@@ -127,6 +120,15 @@ function percentile(values, percentileRank) {
|
|
|
127
120
|
return sorted[index];
|
|
128
121
|
}
|
|
129
122
|
|
|
123
|
+
const PROFIT_FACTOR_CAP = 1_000_000;
|
|
124
|
+
|
|
125
|
+
function finiteProfitFactor(grossProfit, grossLoss) {
|
|
126
|
+
if (grossLoss === 0) {
|
|
127
|
+
return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
|
|
128
|
+
}
|
|
129
|
+
return grossProfit / grossLoss;
|
|
130
|
+
}
|
|
131
|
+
|
|
130
132
|
/**
|
|
131
133
|
* Build aggregate backtest metrics for completed positions and realized trade legs.
|
|
132
134
|
*
|
|
@@ -134,14 +136,7 @@ function percentile(values, percentileRank) {
|
|
|
134
136
|
* `profitFactor`, `winRate`, `expectancy`, `maxDrawdown`, `sharpe`, `avgHold`,
|
|
135
137
|
* and `sideBreakdown`, while preserving the more specific legacy fields.
|
|
136
138
|
*/
|
|
137
|
-
export function buildMetrics({
|
|
138
|
-
closed,
|
|
139
|
-
equityStart,
|
|
140
|
-
equityFinal,
|
|
141
|
-
candles,
|
|
142
|
-
estBarMs,
|
|
143
|
-
eqSeries,
|
|
144
|
-
}) {
|
|
139
|
+
export function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
|
|
145
140
|
const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
|
|
146
141
|
const completedTrades = [];
|
|
147
142
|
const tradeRs = [];
|
|
@@ -233,18 +228,8 @@ export function buildMetrics({
|
|
|
233
228
|
: mean(tradeReturns) / tradeReturnStd;
|
|
234
229
|
const sortinoPerTrade = sortino(tradeReturns);
|
|
235
230
|
|
|
236
|
-
const profitFactorPositions =
|
|
237
|
-
|
|
238
|
-
? grossProfitPositions > 0
|
|
239
|
-
? Infinity
|
|
240
|
-
: 0
|
|
241
|
-
: grossProfitPositions / grossLossPositions;
|
|
242
|
-
const profitFactorLegs =
|
|
243
|
-
grossLossLegs === 0
|
|
244
|
-
? grossProfitLegs > 0
|
|
245
|
-
? Infinity
|
|
246
|
-
: 0
|
|
247
|
-
: grossProfitLegs / grossLossLegs;
|
|
231
|
+
const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
|
|
232
|
+
const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
|
|
248
233
|
const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
|
|
249
234
|
const calmar = maxDrawdown === 0 ? (returnPct > 0 ? Infinity : 0) : returnPct / maxDrawdown;
|
|
250
235
|
|
|
@@ -253,9 +238,7 @@ export function buildMetrics({
|
|
|
253
238
|
const avgHoldMin = mean(holdDurationsMinutes);
|
|
254
239
|
|
|
255
240
|
const equitySeries =
|
|
256
|
-
eqSeries && eqSeries.length
|
|
257
|
-
? eqSeries
|
|
258
|
-
: buildEquitySeriesFromLegs({ legs, equityStart });
|
|
241
|
+
eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
|
|
259
242
|
const dailyReturnsSeries = dailyReturns(equitySeries);
|
|
260
243
|
const dailyStd = stddev(dailyReturnsSeries);
|
|
261
244
|
const sharpeDaily =
|
|
@@ -288,17 +271,13 @@ export function buildMetrics({
|
|
|
288
271
|
const sideBreakdown = {
|
|
289
272
|
long: {
|
|
290
273
|
trades: longTradesCount,
|
|
291
|
-
winRate: longTradesCount
|
|
292
|
-
? longTradeWins / longTradesCount
|
|
293
|
-
: 0,
|
|
274
|
+
winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
|
|
294
275
|
avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
|
|
295
276
|
avgR: mean(longRs),
|
|
296
277
|
},
|
|
297
278
|
short: {
|
|
298
279
|
trades: shortTradesCount,
|
|
299
|
-
winRate: shortTradesCount
|
|
300
|
-
? shortTradeWins / shortTradesCount
|
|
301
|
-
: 0,
|
|
280
|
+
winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
|
|
302
281
|
avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
|
|
303
282
|
avgR: mean(shortRs),
|
|
304
283
|
},
|
|
@@ -328,9 +307,7 @@ export function buildMetrics({
|
|
|
328
307
|
startEquity: equityStart,
|
|
329
308
|
profitFactor_pos: profitFactorPositions,
|
|
330
309
|
profitFactor_leg: profitFactorLegs,
|
|
331
|
-
winRate_pos: completedTrades.length
|
|
332
|
-
? winningTradeCount / completedTrades.length
|
|
333
|
-
: 0,
|
|
310
|
+
winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
|
|
334
311
|
winRate_leg: legs.length ? winningLegCount / legs.length : 0,
|
|
335
312
|
sharpeDaily,
|
|
336
313
|
sortinoDaily,
|
|
@@ -24,10 +24,7 @@ export function exportBacktestArtifacts({
|
|
|
24
24
|
metrics: null,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
const csvTrades =
|
|
28
|
-
csvSource === "trades"
|
|
29
|
-
? result.trades
|
|
30
|
-
: result.positions ?? result.trades;
|
|
27
|
+
const csvTrades = csvSource === "trades" ? result.trades : (result.positions ?? result.trades);
|
|
31
28
|
|
|
32
29
|
if (exportCsv) {
|
|
33
30
|
outputs.csv = exportTradesCsv(csvTrades, {
|
|
@@ -9,10 +9,7 @@ function tradeRMultiple(trade) {
|
|
|
9
9
|
const initialRisk = trade._initRisk || 0;
|
|
10
10
|
if (initialRisk <= 0) return 0;
|
|
11
11
|
const entry = trade.entryFill ?? trade.entry;
|
|
12
|
-
const perUnit =
|
|
13
|
-
trade.side === "long"
|
|
14
|
-
? trade.exit.price - entry
|
|
15
|
-
: entry - trade.exit.price;
|
|
12
|
+
const perUnit = trade.side === "long" ? trade.exit.price - entry : entry - trade.exit.price;
|
|
16
13
|
return perUnit / initialRisk;
|
|
17
14
|
}
|
|
18
15
|
|
|
@@ -58,9 +55,7 @@ export function exportTradesCsv(
|
|
|
58
55
|
(trade.maeR ?? 0).toFixed(3),
|
|
59
56
|
trade.adds ?? 0,
|
|
60
57
|
trade.entryATR !== undefined ? Number(trade.entryATR).toFixed(6) : "",
|
|
61
|
-
trade.exit.exitATR !== undefined
|
|
62
|
-
? Number(trade.exit.exitATR).toFixed(6)
|
|
63
|
-
: "",
|
|
58
|
+
trade.exit.exitATR !== undefined ? Number(trade.exit.exitATR).toFixed(6) : "",
|
|
64
59
|
].join(",")
|
|
65
60
|
),
|
|
66
61
|
].join("\n");
|
|
@@ -19,7 +19,8 @@ function candidateRoots() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function readTemplate(relativePath) {
|
|
22
|
-
|
|
22
|
+
const roots = candidateRoots();
|
|
23
|
+
for (const root of roots) {
|
|
23
24
|
const absolutePath = path.join(root, relativePath);
|
|
24
25
|
if (!fs.existsSync(absolutePath)) continue;
|
|
25
26
|
|
|
@@ -29,7 +30,9 @@ function readTemplate(relativePath) {
|
|
|
29
30
|
return templateCache.get(absolutePath);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
throw new Error(
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Could not locate template asset: ${relativePath} (searched ${roots.length} roots starting from ${roots[0]})`
|
|
35
|
+
);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function fmt(value, digits = 2) {
|
|
@@ -135,9 +138,7 @@ function renderPositionRows(positions) {
|
|
|
135
138
|
<td>${escapeHtml(fmt(exit.price, 4))}</td>
|
|
136
139
|
<td>${escapeHtml(exit.reason ?? "—")}</td>
|
|
137
140
|
<td>${escapeHtml(fmt(exit.pnl, 2))}</td>
|
|
138
|
-
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
|
|
139
|
-
fmt(trade.maeR ?? 0, 2)
|
|
140
|
-
)}</td>
|
|
141
|
+
<td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
|
|
141
142
|
</tr>
|
|
142
143
|
`;
|
|
143
144
|
})
|
|
@@ -259,10 +260,7 @@ export function renderHtmlReport({
|
|
|
259
260
|
["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
|
|
260
261
|
[
|
|
261
262
|
"Hold p50 / p90",
|
|
262
|
-
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
|
|
263
|
-
metrics.holdDistMin?.p90 ?? 0,
|
|
264
|
-
1
|
|
265
|
-
)} min`,
|
|
263
|
+
`${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`,
|
|
266
264
|
],
|
|
267
265
|
]);
|
|
268
266
|
|
|
@@ -306,10 +304,7 @@ export function exportHtmlReport({
|
|
|
306
304
|
const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
307
305
|
const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
308
306
|
const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
309
|
-
const outputPath = path.join(
|
|
310
|
-
outDir,
|
|
311
|
-
`report-${safeSymbol}-${safeInterval}-${safeRange}.html`
|
|
312
|
-
);
|
|
307
|
+
const outputPath = path.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
|
|
313
308
|
|
|
314
309
|
const html = renderHtmlReport({
|
|
315
310
|
symbol,
|
package/src/utils/indicators.js
CHANGED
|
@@ -126,8 +126,7 @@ export function atr(bars, period = 14) {
|
|
|
126
126
|
continue;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
previousAtr =
|
|
130
|
-
(previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
129
|
+
previousAtr = (previousAtr * (period - 1) + trueRanges[index]) / period;
|
|
131
130
|
output[index] = previousAtr;
|
|
132
131
|
}
|
|
133
132
|
|
|
@@ -2,6 +2,16 @@ function roundStep(value, step) {
|
|
|
2
2
|
return Math.floor(value / step) * step;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
let warnedNonPositiveEquity = false;
|
|
6
|
+
|
|
7
|
+
function warnNonPositiveEquity(equity) {
|
|
8
|
+
if (warnedNonPositiveEquity) return;
|
|
9
|
+
warnedNonPositiveEquity = true;
|
|
10
|
+
console.warn(
|
|
11
|
+
`[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export function calculatePositionSize({
|
|
6
16
|
equity,
|
|
7
17
|
entry,
|
|
@@ -11,14 +21,18 @@ export function calculatePositionSize({
|
|
|
11
21
|
minQty = 0.001,
|
|
12
22
|
maxLeverage = 2,
|
|
13
23
|
}) {
|
|
24
|
+
if (!Number.isFinite(equity) || equity <= 0) {
|
|
25
|
+
warnNonPositiveEquity(equity);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
const riskPerUnit = Math.abs(entry - stop);
|
|
15
30
|
if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
|
|
16
31
|
|
|
17
32
|
const maxRiskDollars = Math.max(0, equity * riskFraction);
|
|
18
33
|
let quantity = maxRiskDollars / riskPerUnit;
|
|
19
34
|
|
|
20
|
-
const leverageCapQty =
|
|
21
|
-
(equity * maxLeverage) / Math.max(1e-12, Math.abs(entry));
|
|
35
|
+
const leverageCapQty = (equity * maxLeverage) / Math.max(1e-12, Math.abs(entry));
|
|
22
36
|
quantity = Math.min(quantity, leverageCapQty);
|
|
23
37
|
quantity = roundStep(quantity, qtyStep);
|
|
24
38
|
|
package/src/utils/time.js
CHANGED
|
@@ -8,20 +8,14 @@ function usDstBoundsUTC(year) {
|
|
|
8
8
|
marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1000);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const dstStart = new Date(
|
|
12
|
-
Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
|
|
13
|
-
);
|
|
11
|
+
const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
|
|
14
12
|
|
|
15
13
|
let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
|
|
16
14
|
while (novemberCursor.getUTCDay() !== 0) {
|
|
17
|
-
novemberCursor = new Date(
|
|
18
|
-
novemberCursor.getTime() + 24 * 60 * 60 * 1000
|
|
19
|
-
);
|
|
15
|
+
novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1000);
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
const dstEnd = new Date(
|
|
23
|
-
Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
|
|
24
|
-
);
|
|
18
|
+
const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
|
|
25
19
|
|
|
26
20
|
return { dstStart, dstEnd };
|
|
27
21
|
}
|
|
@@ -58,9 +52,7 @@ export function isSession(timeMs, session = "NYSE") {
|
|
|
58
52
|
if (session === "FUT") {
|
|
59
53
|
const maintenanceStart = 17 * 60;
|
|
60
54
|
const maintenanceEnd = 18 * 60;
|
|
61
|
-
return !(
|
|
62
|
-
minutes >= maintenanceStart && minutes < maintenanceEnd
|
|
63
|
-
);
|
|
55
|
+
return !(minutes >= maintenanceStart && minutes < maintenanceEnd);
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
const open = 9 * 60 + 30;
|
package/templates/report.html
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>{{TITLE}} backtest</title>
|
|
7
|
-
<style>
|
|
7
|
+
<style>
|
|
8
|
+
{{CSS}}
|
|
9
|
+
</style>
|
|
8
10
|
</head>
|
|
9
11
|
<body>
|
|
10
12
|
<main class="report-shell">
|
|
@@ -17,9 +19,7 @@
|
|
|
17
19
|
<div class="hero-pill">{{HERO_PILL}}</div>
|
|
18
20
|
</header>
|
|
19
21
|
|
|
20
|
-
<section class="metric-grid">
|
|
21
|
-
{{METRIC_CARDS}}
|
|
22
|
-
</section>
|
|
22
|
+
<section class="metric-grid">{{METRIC_CARDS}}</section>
|
|
23
23
|
|
|
24
24
|
<section class="panel-grid">
|
|
25
25
|
<article class="panel panel--wide">
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
<p>Headline stats for completed positions.</p>
|
|
37
37
|
</div>
|
|
38
38
|
<table class="data-table">
|
|
39
|
-
<tbody>
|
|
39
|
+
<tbody>
|
|
40
|
+
{{SUMMARY_ROWS}}
|
|
41
|
+
</tbody>
|
|
40
42
|
</table>
|
|
41
43
|
</article>
|
|
42
44
|
|
|
@@ -70,7 +72,9 @@
|
|
|
70
72
|
<p>Long/short split and distribution snapshots.</p>
|
|
71
73
|
</div>
|
|
72
74
|
<table class="data-table">
|
|
73
|
-
<tbody>
|
|
75
|
+
<tbody>
|
|
76
|
+
{{BREAKDOWN_ROWS}}
|
|
77
|
+
</tbody>
|
|
74
78
|
</table>
|
|
75
79
|
</article>
|
|
76
80
|
|
|
@@ -92,15 +96,25 @@
|
|
|
92
96
|
<th>MFE / MAE</th>
|
|
93
97
|
</tr>
|
|
94
98
|
</thead>
|
|
95
|
-
<tbody>
|
|
99
|
+
<tbody>
|
|
100
|
+
{{POSITION_ROWS}}
|
|
101
|
+
</tbody>
|
|
96
102
|
</table>
|
|
97
103
|
</div>
|
|
98
104
|
</article>
|
|
99
105
|
</section>
|
|
100
106
|
</main>
|
|
101
107
|
|
|
102
|
-
<script id="report-data" type="application/json">
|
|
108
|
+
<script id="report-data" type="application/json">
|
|
109
|
+
{{REPORT_DATA_JSON}}
|
|
110
|
+
</script>
|
|
103
111
|
<script src="{{PLOTLY_CDN_URL}}"></script>
|
|
104
|
-
<script>
|
|
112
|
+
<script>
|
|
113
|
+
{
|
|
114
|
+
{
|
|
115
|
+
REPORT_JS;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</script>
|
|
105
119
|
</body>
|
|
106
120
|
</html>
|
package/templates/report.js
CHANGED
|
@@ -34,87 +34,101 @@
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
plot(
|
|
37
|
+
plot(
|
|
38
|
+
"equity-chart",
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
x: data.eqSeries.map((point) => point.t),
|
|
42
|
+
y: data.eqSeries.map((point) => point.equity),
|
|
43
|
+
type: "scatter",
|
|
44
|
+
mode: "lines",
|
|
45
|
+
name: "Equity",
|
|
46
|
+
line: { color: "#64e0c1", width: 2.5 },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
38
49
|
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
mode: "lines",
|
|
43
|
-
name: "Equity",
|
|
44
|
-
line: { color: "#64e0c1", width: 2.5 },
|
|
45
|
-
},
|
|
46
|
-
], {
|
|
47
|
-
yaxis: { title: "Equity", gridcolor: "rgba(159,176,201,0.12)" },
|
|
48
|
-
});
|
|
50
|
+
yaxis: { title: "Equity", gridcolor: "rgba(159,176,201,0.12)" },
|
|
51
|
+
}
|
|
52
|
+
);
|
|
49
53
|
|
|
50
|
-
plot(
|
|
54
|
+
plot(
|
|
55
|
+
"drawdown-chart",
|
|
56
|
+
[
|
|
57
|
+
{
|
|
58
|
+
x: data.drawdown.map((point) => point.t),
|
|
59
|
+
y: data.drawdown.map((point) => point.value),
|
|
60
|
+
type: "scatter",
|
|
61
|
+
mode: "lines",
|
|
62
|
+
name: "Drawdown",
|
|
63
|
+
line: { color: "#fb7185", width: 2.2 },
|
|
64
|
+
fill: "tozeroy",
|
|
65
|
+
fillcolor: "rgba(251,113,133,0.12)",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
51
68
|
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fillcolor: "rgba(251,113,133,0.12)",
|
|
60
|
-
},
|
|
61
|
-
], {
|
|
62
|
-
yaxis: {
|
|
63
|
-
title: "Drawdown",
|
|
64
|
-
tickformat: ",.0%",
|
|
65
|
-
gridcolor: "rgba(159,176,201,0.12)",
|
|
66
|
-
},
|
|
67
|
-
});
|
|
69
|
+
yaxis: {
|
|
70
|
+
title: "Drawdown",
|
|
71
|
+
tickformat: ",.0%",
|
|
72
|
+
gridcolor: "rgba(159,176,201,0.12)",
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
);
|
|
68
76
|
|
|
69
77
|
if (Array.isArray(data.dailyPnl) && data.dailyPnl.length) {
|
|
70
|
-
plot(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
plot(
|
|
79
|
+
"daily-chart",
|
|
80
|
+
[
|
|
81
|
+
{
|
|
82
|
+
x: data.dailyPnl.map((point) => point.date),
|
|
83
|
+
y: data.dailyPnl.map((point) => point.pnl),
|
|
84
|
+
type: "bar",
|
|
85
|
+
name: "Daily PnL",
|
|
86
|
+
marker: {
|
|
87
|
+
color: data.dailyPnl.map((point) => (point.pnl >= 0 ? "#4ade80" : "#fb7185")),
|
|
88
|
+
},
|
|
80
89
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
],
|
|
91
|
+
{
|
|
92
|
+
yaxis: { title: "PnL", gridcolor: "rgba(159,176,201,0.12)" },
|
|
93
|
+
}
|
|
94
|
+
);
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
if (data.hasReplay && Array.isArray(data.replay.frames)) {
|
|
88
98
|
const entries = data.replay.events.filter((event) => event.type === "entry");
|
|
89
99
|
const exits = data.replay.events.filter((event) => event.type !== "entry");
|
|
90
100
|
|
|
91
|
-
plot(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
plot(
|
|
102
|
+
"replay-chart",
|
|
103
|
+
[
|
|
104
|
+
{
|
|
105
|
+
x: data.replay.frames.map((frame) => frame.t),
|
|
106
|
+
y: data.replay.frames.map((frame) => frame.price),
|
|
107
|
+
type: "scatter",
|
|
108
|
+
mode: "lines",
|
|
109
|
+
name: "Price",
|
|
110
|
+
line: { color: "#93c5fd", width: 2 },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
x: entries.map((event) => event.t),
|
|
114
|
+
y: entries.map((event) => event.price),
|
|
115
|
+
type: "scatter",
|
|
116
|
+
mode: "markers",
|
|
117
|
+
name: "Entries",
|
|
118
|
+
marker: { color: "#4ade80", size: 9, symbol: "triangle-up" },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
x: exits.map((event) => event.t),
|
|
122
|
+
y: exits.map((event) => event.price),
|
|
123
|
+
type: "scatter",
|
|
124
|
+
mode: "markers",
|
|
125
|
+
name: "Exits",
|
|
126
|
+
marker: { color: "#fb7185", size: 9, symbol: "triangle-down" },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
108
129
|
{
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
mode: "markers",
|
|
113
|
-
name: "Exits",
|
|
114
|
-
marker: { color: "#fb7185", size: 9, symbol: "triangle-down" },
|
|
115
|
-
},
|
|
116
|
-
], {
|
|
117
|
-
yaxis: { title: "Price", gridcolor: "rgba(159,176,201,0.12)" },
|
|
118
|
-
});
|
|
130
|
+
yaxis: { title: "Price", gridcolor: "rgba(159,176,201,0.12)" },
|
|
131
|
+
}
|
|
132
|
+
);
|
|
119
133
|
}
|
|
120
134
|
})();
|