Skip to main content

sandbox_quant/ui/
backtest_output.rs

1use crate::backtest_app::runner::BacktestReport;
2use crate::dataset::types::BacktestRunSummaryRow;
3
4pub fn render_backtest_run(report: &BacktestReport) -> String {
5    let realized_trade_count = report
6        .trades
7        .iter()
8        .filter(|trade| trade.net_pnl.is_some())
9        .count();
10    let has_dataset_rows = report.dataset.liquidation_events > 0
11        || report.dataset.book_ticker_events > 0
12        || report.dataset.agg_trade_events > 0
13        || report.dataset.derived_kline_1s_bars > 0;
14    let mut lines = vec![
15        "backtest run".to_string(),
16        "[identity]".to_string(),
17        format!(
18            "run_id={}",
19            report
20                .run_id
21                .map(|value| value.to_string())
22                .unwrap_or_else(|| "n/a".to_string())
23        ),
24        format!("mode={}", report.mode.as_str()),
25        format!("template={}", report.template.slug()),
26        format!("instrument={}", report.instrument),
27        format!("from={}", report.from),
28        format!("to={}", report.to),
29        format!("db_path={}", report.db_path.display()),
30        "[dataset]".to_string(),
31        format!("liquidation_events={}", report.dataset.liquidation_events),
32        format!("book_ticker_events={}", report.dataset.book_ticker_events),
33        format!("agg_trade_events={}", report.dataset.agg_trade_events),
34        format!(
35            "derived_kline_1s_bars={}",
36            report.dataset.derived_kline_1s_bars
37        ),
38        format!(
39            "state={}",
40            if !report.dataset.symbol_found {
41                "symbol_not_found"
42            } else if !has_dataset_rows {
43                "empty_dataset"
44            } else if report.trades.is_empty() {
45                "no_trades"
46            } else {
47                "ok"
48            }
49        ),
50        "[results]".to_string(),
51        format!("trigger_count={}", report.trigger_count),
52        format!("closed_trades={}", realized_trade_count),
53        format!("open_trades={}", report.open_trades),
54        format!("wins={}", report.wins),
55        format!("losses={}", report.losses),
56        format!("skipped_triggers={}", report.skipped_triggers),
57        format!("starting_equity={:.2}", report.starting_equity),
58        format!("ending_equity={:.2}", report.ending_equity),
59        format!("net_pnl={:.2}", report.net_pnl),
60        format!("observed_win_rate={:.4}", report.observed_win_rate),
61        format!("average_net_pnl={:.2}", report.average_net_pnl),
62        format!(
63            "summary=state:{} trades:{}/{} pnl:{:.2} equity:{:.2}->{:.2}",
64            if !report.dataset.symbol_found {
65                "symbol_not_found"
66            } else if !has_dataset_rows {
67                "empty_dataset"
68            } else if report.trades.is_empty() {
69                "no_trades"
70            } else {
71                "ok"
72            },
73            realized_trade_count,
74            report.trigger_count,
75            report.net_pnl,
76            report.starting_equity,
77            report.ending_equity
78        ),
79        "[config]".to_string(),
80        format!(
81            "configured_expected_value={:.2}",
82            report.configured_expected_value
83        ),
84        format!("risk_pct={}", report.config.risk_pct),
85        format!("win_rate_assumption={}", report.config.win_rate_assumption),
86        format!("r_multiple={}", report.config.r_multiple),
87        format!(
88            "max_entry_slippage_pct={}",
89            report.config.max_entry_slippage_pct
90        ),
91        format!("stop_distance_pct={}", report.config.stop_distance_pct),
92    ];
93
94    if report.trades.is_empty() {
95        lines.push("[trades]".to_string());
96        lines.push("trades=none".to_string());
97    } else {
98        lines.push("[trades]".to_string());
99        for trade in report.trades.iter().take(5) {
100            lines.push(format!(
101                "trade id={} entry_time={} entry_price={:.4} stop={:.4} tp={:.4} exit_reason={} net_pnl={}",
102                trade.trade_id,
103                trade.entry_time.to_rfc3339(),
104                trade.entry_price,
105                trade.stop_price,
106                trade.take_profit_price,
107                trade
108                    .exit_reason
109                    .as_ref()
110                    .map(|reason| reason.as_str())
111                    .unwrap_or("open"),
112                trade
113                    .net_pnl
114                    .map(|value| format!("{value:.2}"))
115                    .unwrap_or_else(|| "open".to_string())
116            ));
117        }
118    }
119
120    lines.join("\n")
121}
122
123pub fn render_backtest_run_list(runs: &[BacktestRunSummaryRow]) -> String {
124    let mut lines = vec!["backtest runs".to_string(), format!("count={}", runs.len())];
125    if runs.is_empty() {
126        lines.push("runs=none".to_string());
127    } else {
128        lines.extend(runs.iter().map(|run| {
129            let state = summarize_run_state(run);
130            format!(
131                "run_id={} state={} created_at={} mode={} template={} instrument={} from={} to={} triggers={} closed_trades={} open_trades={} wins={} losses={} net_pnl={:.2} ending_equity={:.2}",
132                run.run_id,
133                state,
134                run.created_at,
135                run.mode.as_str(),
136                run.template,
137                run.instrument,
138                run.from,
139                run.to,
140                run.trigger_count,
141                run.closed_trades,
142                run.open_trades,
143                run.wins,
144                run.losses,
145                run.net_pnl,
146                run.ending_equity
147            )
148        }));
149    }
150    lines.join("\n")
151}
152
153fn summarize_run_state(run: &BacktestRunSummaryRow) -> &'static str {
154    if run.closed_trades > 0 {
155        "closed_trades"
156    } else if run.open_trades > 0 {
157        "open_trades"
158    } else if run.trigger_count > 0 {
159        "triggers_only"
160    } else {
161        "no_trades"
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use std::path::PathBuf;
168
169    use chrono::{NaiveDate, TimeZone, Utc};
170
171    use super::*;
172    use crate::app::bootstrap::BinanceMode;
173    use crate::backtest_app::runner::{
174        BacktestConfig, BacktestExitReason, BacktestReport, BacktestTrade,
175    };
176    use crate::dataset::types::BacktestDatasetSummary;
177    use crate::strategy::model::StrategyTemplate;
178
179    #[test]
180    fn render_backtest_run_marks_empty_dataset() {
181        let output = render_backtest_run(&sample_report(
182            Vec::new(),
183            BacktestDatasetSummary {
184                mode: BinanceMode::Demo,
185                symbol: "BTCUSDT".to_string(),
186                symbol_found: true,
187                from: "2026-03-13".to_string(),
188                to: "2026-03-13".to_string(),
189                liquidation_events: 0,
190                book_ticker_events: 0,
191                agg_trade_events: 0,
192                derived_kline_1s_bars: 0,
193            },
194        ));
195
196        assert!(output.contains("state=empty_dataset"));
197        assert!(output.contains("[dataset]"));
198        assert!(output.contains("[results]"));
199        assert!(output.contains("[trades]"));
200        assert!(output.contains("trades=none"));
201    }
202
203    #[test]
204    fn render_backtest_run_marks_no_trades_when_dataset_exists() {
205        let output = render_backtest_run(&sample_report(
206            Vec::new(),
207            BacktestDatasetSummary {
208                mode: BinanceMode::Demo,
209                symbol: "BTCUSDT".to_string(),
210                symbol_found: true,
211                from: "2026-03-13".to_string(),
212                to: "2026-03-13".to_string(),
213                liquidation_events: 1,
214                book_ticker_events: 10,
215                agg_trade_events: 0,
216                derived_kline_1s_bars: 5,
217            },
218        ));
219
220        assert!(output.contains("state=no_trades"));
221    }
222
223    #[test]
224    fn render_backtest_run_marks_ok_when_trades_exist() {
225        let output = render_backtest_run(&sample_report(
226            vec![BacktestTrade {
227                trade_id: 1,
228                trigger_time: Utc.timestamp_millis_opt(1_000).single().expect("timestamp"),
229                entry_time: Utc.timestamp_millis_opt(2_000).single().expect("timestamp"),
230                entry_price: 100.0,
231                stop_price: 101.0,
232                take_profit_price: 98.0,
233                qty: 1.0,
234                exit_time: Some(Utc.timestamp_millis_opt(3_000).single().expect("timestamp")),
235                exit_price: Some(98.0),
236                exit_reason: Some(BacktestExitReason::TakeProfit),
237                gross_pnl: Some(2.0),
238                fees: Some(0.2),
239                net_pnl: Some(1.8),
240            }],
241            BacktestDatasetSummary {
242                mode: BinanceMode::Demo,
243                symbol: "BTCUSDT".to_string(),
244                symbol_found: true,
245                from: "2026-03-13".to_string(),
246                to: "2026-03-13".to_string(),
247                liquidation_events: 1,
248                book_ticker_events: 10,
249                agg_trade_events: 0,
250                derived_kline_1s_bars: 5,
251            },
252        ));
253
254        assert!(output.contains("state=ok"));
255        assert!(output.contains("summary=state:ok"));
256        assert!(output.contains("trade id=1"));
257    }
258
259    #[test]
260    fn render_backtest_run_marks_symbol_not_found() {
261        let output = render_backtest_run(&sample_report(
262            Vec::new(),
263            BacktestDatasetSummary {
264                mode: BinanceMode::Demo,
265                symbol: "DOESNOTEXISTUSDT".to_string(),
266                symbol_found: false,
267                from: "2026-03-13".to_string(),
268                to: "2026-03-13".to_string(),
269                liquidation_events: 0,
270                book_ticker_events: 0,
271                agg_trade_events: 0,
272                derived_kline_1s_bars: 0,
273            },
274        ));
275
276        assert!(output.contains("state=symbol_not_found"));
277    }
278
279    fn sample_report(
280        trades: Vec<BacktestTrade>,
281        dataset: BacktestDatasetSummary,
282    ) -> BacktestReport {
283        BacktestReport {
284            run_id: Some(7),
285            template: StrategyTemplate::LiquidationBreakdownShort,
286            instrument: "BTCUSDT".to_string(),
287            mode: BinanceMode::Demo,
288            from: NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
289            to: NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
290            db_path: PathBuf::from("var/demo.duckdb"),
291            dataset,
292            config: BacktestConfig::default(),
293            trigger_count: trades.len(),
294            wins: trades
295                .iter()
296                .filter(|trade| trade.net_pnl.unwrap_or_default() > 0.0)
297                .count(),
298            losses: trades
299                .iter()
300                .filter(|trade| trade.net_pnl.unwrap_or_default() < 0.0)
301                .count(),
302            open_trades: trades
303                .iter()
304                .filter(|trade| trade.net_pnl.is_none())
305                .count(),
306            skipped_triggers: 0,
307            starting_equity: 10_000.0,
308            ending_equity: 10_001.8,
309            net_pnl: 1.8,
310            observed_win_rate: 1.0,
311            average_net_pnl: 1.8,
312            configured_expected_value: 1.0,
313            trades,
314        }
315    }
316
317    #[test]
318    fn render_backtest_run_list_includes_state_and_open_trade_counts() {
319        let output = render_backtest_run_list(&[
320            BacktestRunSummaryRow {
321                run_id: 1,
322                created_at: "2026-03-13 10:00:00".to_string(),
323                mode: BinanceMode::Demo,
324                template: "liquidation-breakdown-short".to_string(),
325                instrument: "BTCUSDT".to_string(),
326                from: "2026-03-13".to_string(),
327                to: "2026-03-13".to_string(),
328                trigger_count: 0,
329                closed_trades: 0,
330                open_trades: 0,
331                wins: 0,
332                losses: 0,
333                net_pnl: 0.0,
334                ending_equity: 10_000.0,
335            },
336            BacktestRunSummaryRow {
337                run_id: 2,
338                created_at: "2026-03-13 11:00:00".to_string(),
339                mode: BinanceMode::Demo,
340                template: "liquidation-breakdown-short".to_string(),
341                instrument: "ETHUSDT".to_string(),
342                from: "2026-03-13".to_string(),
343                to: "2026-03-13".to_string(),
344                trigger_count: 1,
345                closed_trades: 0,
346                open_trades: 1,
347                wins: 0,
348                losses: 0,
349                net_pnl: -5.0,
350                ending_equity: 9_995.0,
351            },
352        ]);
353
354        assert!(output.contains("run_id=1 state=no_trades"));
355        assert!(output.contains("run_id=2 state=open_trades"));
356        assert!(output.contains("open_trades=1"));
357    }
358}