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