Skip to content

Commit 0d607d1

Browse files
committed
feat: add Regime Trend research backtest tools
1 parent f51a0a3 commit 0d607d1

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { FOUR_HOURS_MS, CSV_HEADER } from "./dataset-tools.mjs";
2+
3+
export const DEVELOPMENT_START_MS = Date.parse("2019-01-01T00:00:00.000Z");
4+
export const DEVELOPMENT_END_EXCLUSIVE_MS = Date.parse("2023-01-01T00:00:00.000Z");
5+
export const VALIDATION_START_MS = DEVELOPMENT_END_EXCLUSIVE_MS;
6+
export const VALIDATION_END_EXCLUSIVE_MS = Date.parse("2025-01-01T00:00:00.000Z");
7+
8+
export const RESEARCH_PARTITIONS = Object.freeze([
9+
{
10+
id: "development",
11+
start: DEVELOPMENT_START_MS,
12+
endExclusive: DEVELOPMENT_END_EXCLUSIVE_MS
13+
},
14+
{
15+
id: "validation",
16+
start: VALIDATION_START_MS,
17+
endExclusive: VALIDATION_END_EXCLUSIVE_MS
18+
}
19+
]);
20+
21+
export function parseCsvCandles(csv, options = {}) {
22+
const endExclusive = options.endExclusive ?? Number.POSITIVE_INFINITY;
23+
const lines = csv.trim().split(/\r?\n/);
24+
if (lines.length < 2) throw new Error("CSV contains no candle rows");
25+
if (lines[0] !== CSV_HEADER) throw new Error(`Unexpected CSV header: ${lines[0]}`);
26+
27+
const candles = [];
28+
for (let index = 1; index < lines.length; index += 1) {
29+
const columns = lines[index].split(",");
30+
if (columns.length !== 7) throw new Error(`Invalid CSV row ${index + 1}`);
31+
const [openTime, open, high, low, close, volume] = columns.map(Number);
32+
if ([openTime, open, high, low, close, volume].some((value) => !Number.isFinite(value))) {
33+
throw new Error(`Non-finite CSV value at row ${index + 1}`);
34+
}
35+
if (openTime >= endExclusive) break;
36+
candles.push({ timestamp: openTime, open, high, low, close, volume });
37+
}
38+
return candles;
39+
}
40+
41+
export function splitContiguousCandles(candles, intervalMs = FOUR_HOURS_MS) {
42+
if (candles.length === 0) return [];
43+
const segments = [[candles[0]]];
44+
for (let index = 1; index < candles.length; index += 1) {
45+
const current = candles[index];
46+
const previous = candles[index - 1];
47+
if (current.timestamp - previous.timestamp === intervalMs) {
48+
segments.at(-1).push(current);
49+
} else {
50+
segments.push([current]);
51+
}
52+
}
53+
return segments;
54+
}
55+
56+
export function filterPartition(candles, partition) {
57+
return candles.filter(
58+
(candle) => candle.timestamp >= partition.start && candle.timestamp < partition.endExclusive
59+
);
60+
}
61+
62+
export function summarizeTrades(trades) {
63+
const ordered = [...trades].sort((left, right) => {
64+
if (left.exit_timestamp !== right.exit_timestamp) return left.exit_timestamp - right.exit_timestamp;
65+
return left.symbol.localeCompare(right.symbol);
66+
});
67+
68+
let cumulative = 0;
69+
let peak = 0;
70+
let maxDrawdown = 0;
71+
let grossProfit = 0;
72+
let grossLoss = 0;
73+
let wins = 0;
74+
75+
for (const trade of ordered) {
76+
cumulative += trade.net_pnl;
77+
peak = Math.max(peak, cumulative);
78+
maxDrawdown = Math.max(maxDrawdown, peak - cumulative);
79+
if (trade.net_pnl > 0) {
80+
grossProfit += trade.net_pnl;
81+
wins += 1;
82+
} else if (trade.net_pnl < 0) {
83+
grossLoss += Math.abs(trade.net_pnl);
84+
}
85+
}
86+
87+
const count = ordered.length;
88+
return {
89+
closed_trades: count,
90+
winning_trades: wins,
91+
losing_trades: count - wins,
92+
win_rate: count === 0 ? null : wins / count,
93+
total_net_pnl: cumulative,
94+
average_net_pnl: count === 0 ? null : cumulative / count,
95+
net_expectancy: count === 0 ? null : cumulative / count,
96+
gross_profit: grossProfit,
97+
gross_loss: grossLoss,
98+
profit_factor: grossLoss === 0 ? (grossProfit > 0 ? null : 0) : grossProfit / grossLoss,
99+
max_drawdown_normalized_units: maxDrawdown
100+
};
101+
}
102+
103+
export function buyAndHoldReturn(candles) {
104+
if (candles.length === 0) return null;
105+
return candles.at(-1).close / candles[0].open - 1;
106+
}
107+
108+
const LEDGER_FIELDS = [
109+
"strategy_id",
110+
"implementation_version",
111+
"dataset_hash",
112+
"symbol",
113+
"timeframe",
114+
"direction",
115+
"signal_timestamp",
116+
"entry_timestamp",
117+
"raw_entry_open",
118+
"entry_fill",
119+
"entry_atr",
120+
"initial_stop",
121+
"exit_timestamp",
122+
"raw_exit_reference",
123+
"exit_fill",
124+
"exit_reason",
125+
"quantity",
126+
"entry_fee",
127+
"exit_fee",
128+
"gross_pnl",
129+
"net_pnl",
130+
"net_return",
131+
"bars_held"
132+
];
133+
134+
export function tradeLedgerToCsv(trades) {
135+
const rows = trades.map((trade) => LEDGER_FIELDS.map((field) => trade[field]).join(","));
136+
return `${LEDGER_FIELDS.join(",")}\n${rows.join("\n")}${rows.length > 0 ? "\n" : ""}`;
137+
}

0 commit comments

Comments
 (0)