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}