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    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}