1use std::collections::VecDeque;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, TimeZone, Utc};
5
6use crate::app::bootstrap::BinanceMode;
7use crate::dataset::query::{
8 backtest_summary_for_path, load_book_ticker_rows_for_path, load_liquidation_events_for_path,
9};
10use crate::dataset::types::{BacktestDatasetSummary, BookTickerRow, LiquidationEventRow};
11use crate::error::storage_error::StorageError;
12use crate::strategy::model::StrategyTemplate;
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct BacktestConfig {
16 pub starting_equity: f64,
17 pub risk_pct: f64,
18 pub win_rate_assumption: f64,
19 pub r_multiple: f64,
20 pub max_entry_slippage_pct: f64,
21 pub stop_distance_pct: f64,
22 pub min_cluster_notional: f64,
23 pub cluster_lookback_secs: i64,
24 pub failed_hold_timeout_secs: i64,
25 pub breakdown_confirm_bps: f64,
26 pub cooldown_secs: i64,
27 pub taker_fee_rate: f64,
28 pub stop_slippage_pct: f64,
29 pub tp_slippage_pct: f64,
30}
31
32impl Default for BacktestConfig {
33 fn default() -> Self {
34 Self {
35 starting_equity: 10_000.0,
36 risk_pct: 0.005,
37 win_rate_assumption: 0.8,
38 r_multiple: 1.5,
39 max_entry_slippage_pct: 0.001,
40 stop_distance_pct: 0.012,
41 min_cluster_notional: 1.0,
42 cluster_lookback_secs: 60,
43 failed_hold_timeout_secs: 30,
44 breakdown_confirm_bps: 5.0,
45 cooldown_secs: 30,
46 taker_fee_rate: 0.0005,
47 stop_slippage_pct: 0.0008,
48 tp_slippage_pct: 0.0003,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BacktestExitReason {
55 TakeProfit,
56 StopLoss,
57 OpenAtEnd,
58 SignalExit,
59}
60
61impl BacktestExitReason {
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 Self::TakeProfit => "take_profit",
65 Self::StopLoss => "stop_loss",
66 Self::OpenAtEnd => "open_at_end",
67 Self::SignalExit => "signal_exit",
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct BacktestTrade {
74 pub trade_id: usize,
75 pub trigger_time: DateTime<Utc>,
76 pub entry_time: DateTime<Utc>,
77 pub entry_price: f64,
78 pub stop_price: f64,
79 pub take_profit_price: f64,
80 pub qty: f64,
81 pub exit_time: Option<DateTime<Utc>>,
82 pub exit_price: Option<f64>,
83 pub exit_reason: Option<BacktestExitReason>,
84 pub gross_pnl: Option<f64>,
85 pub fees: Option<f64>,
86 pub net_pnl: Option<f64>,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub struct BacktestReport {
91 pub run_id: Option<i64>,
92 pub template: StrategyTemplate,
93 pub instrument: String,
94 pub mode: BinanceMode,
95 pub from: chrono::NaiveDate,
96 pub to: chrono::NaiveDate,
97 pub db_path: PathBuf,
98 pub dataset: BacktestDatasetSummary,
99 pub config: BacktestConfig,
100 pub trigger_count: usize,
101 pub trades: Vec<BacktestTrade>,
102 pub wins: usize,
103 pub losses: usize,
104 pub open_trades: usize,
105 pub skipped_triggers: usize,
106 pub starting_equity: f64,
107 pub ending_equity: f64,
108 pub net_pnl: f64,
109 pub observed_win_rate: f64,
110 pub average_net_pnl: f64,
111 pub configured_expected_value: f64,
112}
113
114#[derive(Debug, Clone)]
115struct PendingCluster {
116 formed_at_ms: i64,
117 zone_low: f64,
118}
119
120#[derive(Debug, Clone)]
121struct OpenTrade {
122 trade_id: usize,
123 trigger_time_ms: i64,
124 entry_time_ms: i64,
125 entry_price: f64,
126 stop_price: f64,
127 take_profit_price: f64,
128 qty: f64,
129 entry_fee: f64,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum ReplayEventKind {
134 Liquidation(usize),
135 BookTicker(usize),
136}
137
138pub fn run_backtest_for_path(
139 db_path: &Path,
140 mode: BinanceMode,
141 template: StrategyTemplate,
142 instrument: &str,
143 from: chrono::NaiveDate,
144 to: chrono::NaiveDate,
145 config: BacktestConfig,
146) -> Result<BacktestReport, StorageError> {
147 let dataset = backtest_summary_for_path(db_path, mode, instrument, from, to)?;
148 let liquidation_events = load_liquidation_events_for_path(db_path, instrument, from, to)?;
149 let book_tickers = load_book_ticker_rows_for_path(db_path, instrument, from, to)?;
150 Ok(run_backtest_on_events(
151 template,
152 instrument.to_string(),
153 mode,
154 from,
155 to,
156 db_path.to_path_buf(),
157 dataset,
158 liquidation_events,
159 book_tickers,
160 config,
161 ))
162}
163
164fn run_backtest_on_events(
165 template: StrategyTemplate,
166 instrument: String,
167 mode: BinanceMode,
168 from: chrono::NaiveDate,
169 to: chrono::NaiveDate,
170 db_path: PathBuf,
171 dataset: BacktestDatasetSummary,
172 liquidation_events: Vec<LiquidationEventRow>,
173 book_tickers: Vec<BookTickerRow>,
174 config: BacktestConfig,
175) -> BacktestReport {
176 let mut replay = liquidation_events
177 .iter()
178 .enumerate()
179 .map(|(index, event)| (event.event_time_ms, ReplayEventKind::Liquidation(index)))
180 .chain(
181 book_tickers
182 .iter()
183 .enumerate()
184 .map(|(index, tick)| (tick.event_time_ms, ReplayEventKind::BookTicker(index))),
185 )
186 .collect::<Vec<_>>();
187 replay.sort_by_key(|(time_ms, kind)| {
188 (
189 *time_ms,
190 match kind {
191 ReplayEventKind::Liquidation(_) => 0u8,
192 ReplayEventKind::BookTicker(_) => 1u8,
193 },
194 )
195 });
196
197 let mut liquidation_window = VecDeque::<LiquidationEventRow>::new();
198 let mut pending_cluster: Option<PendingCluster> = None;
199 let mut open_trade: Option<OpenTrade> = None;
200 let mut completed_trades = Vec::new();
201 let mut trigger_count = 0usize;
202 let mut skipped_triggers = 0usize;
203 let mut next_allowed_entry_ms = 0i64;
204 let mut equity = config.starting_equity;
205
206 for (event_time_ms, kind) in replay {
207 match kind {
208 ReplayEventKind::Liquidation(index) => {
209 let event = &liquidation_events[index];
210 if event.force_side != "BUY" {
211 continue;
212 }
213 liquidation_window.push_back(event.clone());
214 while liquidation_window.front().is_some_and(|front| {
215 front.event_time_ms < event_time_ms - config.cluster_lookback_secs * 1_000
216 }) {
217 let _ = liquidation_window.pop_front();
218 }
219
220 let total_notional = liquidation_window
221 .iter()
222 .map(|item| item.notional)
223 .sum::<f64>();
224 if open_trade.is_none()
225 && event_time_ms >= next_allowed_entry_ms
226 && total_notional >= config.min_cluster_notional
227 {
228 let zone_low = liquidation_window
229 .iter()
230 .map(|item| item.price)
231 .fold(f64::INFINITY, f64::min);
232 pending_cluster = Some(PendingCluster {
233 formed_at_ms: event_time_ms,
234 zone_low,
235 });
236 }
237 }
238 ReplayEventKind::BookTicker(index) => {
239 let tick = &book_tickers[index];
240 if let Some(trade) = open_trade.as_ref() {
241 if tick.ask >= trade.stop_price {
242 let exit_price = trade.stop_price * (1.0 + config.stop_slippage_pct);
243 let gross_pnl = (trade.entry_price - exit_price) * trade.qty;
244 let exit_fee = exit_price * trade.qty * config.taker_fee_rate;
245 let fees = trade.entry_fee + exit_fee;
246 let net_pnl = gross_pnl - fees;
247 equity += net_pnl;
248 completed_trades.push(BacktestTrade {
249 trade_id: trade.trade_id,
250 trigger_time: timestamp_utc(trade.trigger_time_ms),
251 entry_time: timestamp_utc(trade.entry_time_ms),
252 entry_price: trade.entry_price,
253 stop_price: trade.stop_price,
254 take_profit_price: trade.take_profit_price,
255 qty: trade.qty,
256 exit_time: Some(timestamp_utc(tick.event_time_ms)),
257 exit_price: Some(exit_price),
258 exit_reason: Some(BacktestExitReason::StopLoss),
259 gross_pnl: Some(gross_pnl),
260 fees: Some(fees),
261 net_pnl: Some(net_pnl),
262 });
263 open_trade = None;
264 next_allowed_entry_ms = tick.event_time_ms + config.cooldown_secs * 1_000;
265 continue;
266 }
267 if tick.ask <= trade.take_profit_price {
268 let exit_price = trade.take_profit_price * (1.0 + config.tp_slippage_pct);
269 let gross_pnl = (trade.entry_price - exit_price) * trade.qty;
270 let exit_fee = exit_price * trade.qty * config.taker_fee_rate;
271 let fees = trade.entry_fee + exit_fee;
272 let net_pnl = gross_pnl - fees;
273 equity += net_pnl;
274 completed_trades.push(BacktestTrade {
275 trade_id: trade.trade_id,
276 trigger_time: timestamp_utc(trade.trigger_time_ms),
277 entry_time: timestamp_utc(trade.entry_time_ms),
278 entry_price: trade.entry_price,
279 stop_price: trade.stop_price,
280 take_profit_price: trade.take_profit_price,
281 qty: trade.qty,
282 exit_time: Some(timestamp_utc(tick.event_time_ms)),
283 exit_price: Some(exit_price),
284 exit_reason: Some(BacktestExitReason::TakeProfit),
285 gross_pnl: Some(gross_pnl),
286 fees: Some(fees),
287 net_pnl: Some(net_pnl),
288 });
289 open_trade = None;
290 next_allowed_entry_ms = tick.event_time_ms + config.cooldown_secs * 1_000;
291 continue;
292 }
293 }
294
295 let Some(cluster) = pending_cluster.clone() else {
296 continue;
297 };
298 if open_trade.is_some() || tick.event_time_ms < next_allowed_entry_ms {
299 continue;
300 }
301 if tick.event_time_ms
302 > cluster.formed_at_ms + config.failed_hold_timeout_secs * 1_000
303 {
304 pending_cluster = None;
305 continue;
306 }
307 let breakdown_price =
308 cluster.zone_low * (1.0 - config.breakdown_confirm_bps / 10_000.0);
309 if tick.bid > breakdown_price {
310 continue;
311 }
312 if equity <= 0.0 {
313 skipped_triggers += 1;
314 pending_cluster = None;
315 continue;
316 }
317
318 trigger_count += 1;
319 let entry_price = tick.bid * (1.0 - config.max_entry_slippage_pct * 0.5);
320 let risk_amount = equity * config.risk_pct;
321 let qty = risk_amount / (entry_price * config.stop_distance_pct);
322 if !(qty.is_finite() && qty > 0.0) {
323 skipped_triggers += 1;
324 pending_cluster = None;
325 continue;
326 }
327 let entry_fee = entry_price * qty * config.taker_fee_rate;
328 let stop_price = entry_price * (1.0 + config.stop_distance_pct);
329 let take_profit_price =
330 entry_price * (1.0 - config.stop_distance_pct * config.r_multiple);
331 open_trade = Some(OpenTrade {
332 trade_id: completed_trades.len() + usize::from(open_trade.is_some()) + 1,
333 trigger_time_ms: cluster.formed_at_ms,
334 entry_time_ms: tick.event_time_ms,
335 entry_price,
336 stop_price,
337 take_profit_price,
338 qty,
339 entry_fee,
340 });
341 pending_cluster = None;
342 }
343 }
344 }
345
346 let mut trades = completed_trades.clone();
347 if let Some(trade) = open_trade {
348 let last_tick = book_tickers
349 .iter()
350 .rev()
351 .find(|tick| tick.event_time_ms >= trade.entry_time_ms);
352 let (exit_time, exit_price, gross_pnl, fees, net_pnl) = if let Some(tick) = last_tick {
353 let exit_price = tick.ask;
354 let gross_pnl = (trade.entry_price - exit_price) * trade.qty;
355 let exit_fee = exit_price * trade.qty * config.taker_fee_rate;
356 let fees = trade.entry_fee + exit_fee;
357 let net_pnl = gross_pnl - fees;
358 equity += net_pnl;
359 (
360 Some(timestamp_utc(tick.event_time_ms)),
361 Some(exit_price),
362 Some(gross_pnl),
363 Some(fees),
364 Some(net_pnl),
365 )
366 } else {
367 (None, None, None, Some(trade.entry_fee), None)
368 };
369 trades.push(BacktestTrade {
370 trade_id: trade.trade_id,
371 trigger_time: timestamp_utc(trade.trigger_time_ms),
372 entry_time: timestamp_utc(trade.entry_time_ms),
373 entry_price: trade.entry_price,
374 stop_price: trade.stop_price,
375 take_profit_price: trade.take_profit_price,
376 qty: trade.qty,
377 exit_time,
378 exit_price,
379 exit_reason: Some(BacktestExitReason::OpenAtEnd),
380 gross_pnl,
381 fees,
382 net_pnl,
383 });
384 }
385
386 let wins = completed_trades
387 .iter()
388 .filter(|trade| trade.exit_reason == Some(BacktestExitReason::TakeProfit))
389 .count();
390 let losses = completed_trades
391 .iter()
392 .filter(|trade| trade.exit_reason == Some(BacktestExitReason::StopLoss))
393 .count();
394 let net_pnl = trades.iter().filter_map(|trade| trade.net_pnl).sum::<f64>();
395 let realized_trade_count = trades
396 .iter()
397 .filter(|trade| trade.net_pnl.is_some())
398 .count();
399 let average_net_pnl = if realized_trade_count == 0 {
400 0.0
401 } else {
402 net_pnl / realized_trade_count as f64
403 };
404 let observed_win_rate = if completed_trades.is_empty() {
405 0.0
406 } else {
407 wins as f64 / completed_trades.len() as f64
408 };
409 let average_win = average_net_of(&completed_trades, BacktestExitReason::TakeProfit);
410 let average_loss = average_net_of(&completed_trades, BacktestExitReason::StopLoss).abs();
411 let configured_expected_value = config.win_rate_assumption * average_win
412 - (1.0 - config.win_rate_assumption) * average_loss;
413
414 BacktestReport {
415 run_id: None,
416 template,
417 instrument,
418 mode,
419 from,
420 to,
421 db_path,
422 dataset,
423 config: config.clone(),
424 trigger_count,
425 trades,
426 wins,
427 losses,
428 open_trades: 0,
429 skipped_triggers,
430 starting_equity: config.starting_equity,
431 ending_equity: equity,
432 net_pnl,
433 observed_win_rate,
434 average_net_pnl,
435 configured_expected_value,
436 }
437}
438
439fn average_net_of(trades: &[BacktestTrade], reason: BacktestExitReason) -> f64 {
440 let values = trades
441 .iter()
442 .filter(|trade| trade.exit_reason == Some(reason.clone()))
443 .filter_map(|trade| trade.net_pnl)
444 .collect::<Vec<_>>();
445 if values.is_empty() {
446 0.0
447 } else {
448 values.iter().sum::<f64>() / values.len() as f64
449 }
450}
451
452fn timestamp_utc(event_time_ms: i64) -> DateTime<Utc> {
453 Utc.timestamp_millis_opt(event_time_ms)
454 .single()
455 .unwrap_or_else(Utc::now)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn liquidation_breakdown_backtest_records_take_profit_trade() {
464 let report = run_backtest_on_events(
465 StrategyTemplate::LiquidationBreakdownShort,
466 "BTCUSDT".to_string(),
467 BinanceMode::Demo,
468 chrono::NaiveDate::from_ymd_opt(2026, 3, 13).unwrap(),
469 chrono::NaiveDate::from_ymd_opt(2026, 3, 13).unwrap(),
470 PathBuf::from("/tmp/test.duckdb"),
471 BacktestDatasetSummary {
472 mode: BinanceMode::Demo,
473 symbol: "BTCUSDT".to_string(),
474 symbol_found: true,
475 from: "2026-03-13".to_string(),
476 to: "2026-03-13".to_string(),
477 liquidation_events: 1,
478 book_ticker_events: 3,
479 agg_trade_events: 0,
480 derived_kline_1s_bars: 0,
481 },
482 vec![LiquidationEventRow {
483 event_time_ms: 1_000,
484 force_side: "BUY".to_string(),
485 price: 100.0,
486 qty: 100.0,
487 notional: 10_000.0,
488 }],
489 vec![
490 BookTickerRow {
491 event_time_ms: 2_000,
492 bid: 99.9,
493 ask: 100.0,
494 },
495 BookTickerRow {
496 event_time_ms: 3_000,
497 bid: 98.0,
498 ask: 98.0,
499 },
500 ],
501 BacktestConfig::default(),
502 );
503
504 assert_eq!(report.wins, 1);
505 assert_eq!(report.losses, 0);
506 assert!(report.net_pnl > 0.0);
507 }
508
509 #[test]
510 fn liquidation_breakdown_backtest_records_stop_loss_trade() {
511 let report = run_backtest_on_events(
512 StrategyTemplate::LiquidationBreakdownShort,
513 "BTCUSDT".to_string(),
514 BinanceMode::Demo,
515 chrono::NaiveDate::from_ymd_opt(2026, 3, 13).unwrap(),
516 chrono::NaiveDate::from_ymd_opt(2026, 3, 13).unwrap(),
517 PathBuf::from("/tmp/test.duckdb"),
518 BacktestDatasetSummary {
519 mode: BinanceMode::Demo,
520 symbol: "BTCUSDT".to_string(),
521 symbol_found: true,
522 from: "2026-03-13".to_string(),
523 to: "2026-03-13".to_string(),
524 liquidation_events: 1,
525 book_ticker_events: 3,
526 agg_trade_events: 0,
527 derived_kline_1s_bars: 0,
528 },
529 vec![LiquidationEventRow {
530 event_time_ms: 1_000,
531 force_side: "BUY".to_string(),
532 price: 100.0,
533 qty: 100.0,
534 notional: 10_000.0,
535 }],
536 vec![
537 BookTickerRow {
538 event_time_ms: 2_000,
539 bid: 99.9,
540 ask: 100.0,
541 },
542 BookTickerRow {
543 event_time_ms: 3_000,
544 bid: 101.2,
545 ask: 101.2,
546 },
547 ],
548 BacktestConfig::default(),
549 );
550
551 assert_eq!(report.wins, 0);
552 assert_eq!(report.losses, 1);
553 assert!(report.net_pnl < 0.0);
554 }
555}