Skip to main content

sandbox_quant/backtest_app/
runner.rs

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}