1use std::path::Path;
2
3use crate::app::bootstrap::BinanceMode;
4use crate::backtest_app::runner::{run_backtest_for_path, BacktestExitReason, BacktestTrade};
5use crate::dataset::query::{
6 backtest_summary_for_path, latest_market_data_day_for_path, load_backtest_report,
7 load_backtest_run_summaries, load_book_ticker_rows_for_path, load_derived_kline_rows_for_path,
8 load_liquidation_events_for_path, load_raw_kline_rows_for_path, load_recorded_symbols_for_path,
9 metrics_for_path, persist_backtest_report,
10};
11use crate::dataset::schema::init_schema_for_path;
12use crate::dataset::types::BacktestDatasetSummary;
13use crate::error::storage_error::StorageError;
14use crate::record::coordination::RecorderCoordination;
15use crate::visualization::types::{
16 BacktestRunRequest, DashboardQuery, DashboardSnapshot, EquityPoint, MarketSeries, PricePoint,
17 SignalKind, SignalMarker,
18};
19
20#[derive(Debug, Default, Clone, Copy)]
21pub struct VisualizationService;
22
23impl VisualizationService {
24 pub fn load_dashboard(&self, query: DashboardQuery) -> Result<DashboardSnapshot, StorageError> {
25 self.load_dashboard_inner(query, None)
26 }
27
28 pub fn run_backtest(
29 &self,
30 request: BacktestRunRequest,
31 ) -> Result<DashboardSnapshot, StorageError> {
32 let db_path = RecorderCoordination::new(request.base_dir.clone()).db_path(request.mode);
33 init_schema_for_path(&db_path)?;
34 let report = run_backtest_for_path(
35 &db_path,
36 request.mode,
37 request.template,
38 &request.symbol,
39 request.from,
40 request.to,
41 request.config,
42 )?;
43 let run_id = persist_backtest_report(&db_path, &report)?;
44 self.load_dashboard_inner(
45 DashboardQuery {
46 mode: request.mode,
47 base_dir: request.base_dir,
48 symbol: request.symbol,
49 from: request.from,
50 to: request.to,
51 selected_run_id: Some(run_id),
52 run_limit: request.run_limit,
53 },
54 None,
55 )
56 }
57
58 pub fn latest_market_data_day(
59 &self,
60 mode: BinanceMode,
61 base_dir: std::path::PathBuf,
62 symbol: &str,
63 ) -> Result<Option<chrono::NaiveDate>, StorageError> {
64 let db_path = RecorderCoordination::new(base_dir).db_path(mode);
65 init_schema_for_path(&db_path)?;
66 latest_market_data_day_for_path(&db_path, symbol)
67 }
68
69 pub fn price_points(series: &MarketSeries) -> Vec<PricePoint> {
70 if !series.klines.is_empty() {
71 return series
72 .klines
73 .iter()
74 .map(|row| PricePoint {
75 time_ms: row.close_time_ms,
76 price: row.close,
77 })
78 .collect();
79 }
80 series
81 .book_tickers
82 .iter()
83 .map(|row| PricePoint {
84 time_ms: row.event_time_ms,
85 price: (row.bid + row.ask) * 0.5,
86 })
87 .collect()
88 }
89
90 pub fn equity_curve(starting_equity: f64, trades: &[BacktestTrade]) -> Vec<EquityPoint> {
91 let mut equity = starting_equity;
92 let mut points = Vec::new();
93 for trade in trades {
94 if let (Some(exit_time), Some(net_pnl)) = (trade.exit_time, trade.net_pnl) {
95 equity += net_pnl;
96 points.push(EquityPoint {
97 time_ms: exit_time.timestamp_millis(),
98 equity,
99 });
100 }
101 }
102 points
103 }
104
105 pub fn signal_markers(trades: &[BacktestTrade]) -> Vec<SignalMarker> {
106 let mut markers = Vec::new();
107 for trade in trades {
108 markers.push(SignalMarker {
109 time_ms: trade.entry_time.timestamp_millis(),
110 price: trade.entry_price,
111 label: format!("entry #{}", trade.trade_id),
112 kind: SignalKind::Entry,
113 });
114 if let (Some(exit_time), Some(exit_price), Some(exit_reason)) = (
115 trade.exit_time,
116 trade.exit_price,
117 trade.exit_reason.as_ref(),
118 ) {
119 markers.push(SignalMarker {
120 time_ms: exit_time.timestamp_millis(),
121 price: exit_price,
122 label: format!("exit #{}", trade.trade_id),
123 kind: match exit_reason {
124 BacktestExitReason::TakeProfit => SignalKind::TakeProfit,
125 BacktestExitReason::StopLoss => SignalKind::StopLoss,
126 BacktestExitReason::OpenAtEnd => SignalKind::OpenAtEnd,
127 BacktestExitReason::SignalExit => SignalKind::SignalExit,
128 },
129 });
130 }
131 }
132 markers
133 }
134
135 fn load_dashboard_inner(
136 &self,
137 query: DashboardQuery,
138 selected_report_override: Option<crate::backtest_app::runner::BacktestReport>,
139 ) -> Result<DashboardSnapshot, StorageError> {
140 let db_path = RecorderCoordination::new(query.base_dir.clone()).db_path(query.mode);
141 init_schema_for_path(&db_path)?;
142 let recorder_metrics = metrics_for_path(&db_path)?;
143 let available_symbols = load_recorded_symbols_for_path(&db_path, 256)?;
144 let symbol = resolve_symbol(&query.symbol, &available_symbols);
145 let dataset_summary =
146 load_dataset_summary(&db_path, query.mode, &symbol, query.from, query.to)?;
147 let market_series = load_market_series(&db_path, &symbol, query.from, query.to)?;
148 let recent_runs = load_backtest_run_summaries(&db_path, query.run_limit)?;
149 let selected_run_id = query.selected_run_id.or_else(|| {
150 recent_runs
151 .iter()
152 .find(|row| row.instrument == symbol)
153 .map(|row| row.run_id)
154 });
155 let selected_report = match selected_report_override {
156 Some(report) => Some(report),
157 None => match selected_run_id {
158 Some(run_id) => load_backtest_report(&db_path, Some(run_id))?,
159 None => None,
160 },
161 };
162
163 Ok(DashboardSnapshot {
164 mode: query.mode,
165 base_dir: query.base_dir,
166 db_path,
167 symbol,
168 from: query.from,
169 to: query.to,
170 available_symbols,
171 recorder_metrics,
172 dataset_summary,
173 market_series,
174 recent_runs,
175 selected_report,
176 selected_run_id,
177 })
178 }
179}
180
181fn load_dataset_summary(
182 db_path: &Path,
183 mode: BinanceMode,
184 symbol: &str,
185 from: chrono::NaiveDate,
186 to: chrono::NaiveDate,
187) -> Result<BacktestDatasetSummary, StorageError> {
188 if symbol.is_empty() {
189 return Ok(BacktestDatasetSummary {
190 mode,
191 symbol: String::new(),
192 symbol_found: false,
193 from: from.to_string(),
194 to: to.to_string(),
195 liquidation_events: 0,
196 book_ticker_events: 0,
197 agg_trade_events: 0,
198 derived_kline_1s_bars: 0,
199 });
200 }
201 backtest_summary_for_path(db_path, mode, symbol, from, to)
202}
203
204fn load_market_series(
205 db_path: &Path,
206 symbol: &str,
207 from: chrono::NaiveDate,
208 to: chrono::NaiveDate,
209) -> Result<MarketSeries, StorageError> {
210 if symbol.is_empty() {
211 return Ok(MarketSeries {
212 symbol: String::new(),
213 liquidations: Vec::new(),
214 book_tickers: Vec::new(),
215 klines: Vec::new(),
216 kline_interval: None,
217 });
218 }
219 let derived_klines = load_derived_kline_rows_for_path(db_path, symbol, from, to)?;
220 let (klines, kline_interval) = if derived_klines.is_empty() {
221 match load_raw_kline_rows_for_path(db_path, symbol, from, to)? {
222 Some((interval, rows)) => (rows, Some(interval)),
223 None => (Vec::new(), None),
224 }
225 } else {
226 (derived_klines, Some("1s".to_string()))
227 };
228 Ok(MarketSeries {
229 symbol: symbol.to_string(),
230 liquidations: load_liquidation_events_for_path(db_path, symbol, from, to)?,
231 book_tickers: load_book_ticker_rows_for_path(db_path, symbol, from, to)?,
232 klines,
233 kline_interval,
234 })
235}
236
237fn resolve_symbol(selected: &str, available_symbols: &[String]) -> String {
238 if !selected.trim().is_empty() {
239 return selected.trim().to_ascii_uppercase();
240 }
241 available_symbols.first().cloned().unwrap_or_default()
242}
243
244#[cfg(test)]
245mod tests {
246 use std::path::PathBuf;
247
248 use super::*;
249 use crate::app::bootstrap::BinanceMode;
250 use crate::dataset::schema::init_schema_for_path;
251 use chrono::{TimeZone, Utc};
252 use duckdb::Connection;
253
254 #[test]
255 fn equity_curve_accumulates_realized_trade_pnl() {
256 let trades = vec![
257 BacktestTrade {
258 trade_id: 1,
259 trigger_time: Utc.timestamp_millis_opt(1_000).single().expect("timestamp"),
260 entry_time: Utc.timestamp_millis_opt(2_000).single().expect("timestamp"),
261 entry_price: 100.0,
262 stop_price: 101.0,
263 take_profit_price: 98.0,
264 qty: 1.0,
265 exit_time: Some(Utc.timestamp_millis_opt(3_000).single().expect("timestamp")),
266 exit_price: Some(98.0),
267 exit_reason: Some(BacktestExitReason::TakeProfit),
268 gross_pnl: Some(2.0),
269 fees: Some(0.2),
270 net_pnl: Some(1.8),
271 },
272 BacktestTrade {
273 trade_id: 2,
274 trigger_time: Utc.timestamp_millis_opt(4_000).single().expect("timestamp"),
275 entry_time: Utc.timestamp_millis_opt(5_000).single().expect("timestamp"),
276 entry_price: 99.0,
277 stop_price: 100.0,
278 take_profit_price: 97.0,
279 qty: 1.0,
280 exit_time: Some(Utc.timestamp_millis_opt(6_000).single().expect("timestamp")),
281 exit_price: Some(100.0),
282 exit_reason: Some(BacktestExitReason::StopLoss),
283 gross_pnl: Some(-1.0),
284 fees: Some(0.2),
285 net_pnl: Some(-1.2),
286 },
287 ];
288
289 let points = VisualizationService::equity_curve(10_000.0, &trades);
290
291 assert_eq!(points.len(), 2);
292 assert!((points[0].equity - 10_001.8).abs() < 1e-9);
293 assert!((points[1].equity - 10_000.6).abs() < 1e-9);
294 }
295
296 #[test]
297 fn resolve_symbol_prefers_selected_value() {
298 let symbol = resolve_symbol("ethusdt", &["BTCUSDT".to_string()]);
299
300 assert_eq!(symbol, "ETHUSDT");
301 }
302
303 #[test]
304 fn empty_symbol_summary_uses_requested_range() {
305 let from = chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid date");
306 let to = chrono::NaiveDate::from_ymd_opt(2026, 3, 14).expect("valid date");
307 let summary = load_dataset_summary(
308 &PathBuf::from("/tmp/missing.duckdb"),
309 BinanceMode::Demo,
310 "",
311 from,
312 to,
313 )
314 .expect("summary");
315
316 assert_eq!(summary.mode, BinanceMode::Demo);
317 assert_eq!(summary.from, "2026-03-13");
318 assert_eq!(summary.to, "2026-03-14");
319 assert_eq!(summary.symbol, "");
320 }
321
322 #[test]
323 fn load_dashboard_falls_back_to_raw_klines_when_derived_klines_are_absent() {
324 let mut base_dir = std::env::temp_dir();
325 base_dir.push(format!(
326 "sandbox_quant_gui_raw_kline_fallback_{}_{}",
327 std::process::id(),
328 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
329 ));
330 std::fs::create_dir_all(&base_dir).expect("create temp dir");
331 let db_path = base_dir.join("market-v2-demo.duckdb");
332 init_schema_for_path(&db_path).expect("init schema");
333 let connection = Connection::open(&db_path).expect("open db");
334 connection
335 .execute(
336 "INSERT INTO raw_klines (
337 kline_id, mode, product, symbol, interval, open_time, close_time,
338 open, high, low, close, volume, quote_volume, trade_count, raw_payload
339 ) VALUES (
340 1, 'demo', 'um', 'BTCUSDT', '1m',
341 CAST('2026-03-13 00:00:00' AS TIMESTAMP),
342 CAST('2026-03-13 00:00:59' AS TIMESTAMP),
343 100.0, 101.0, 99.5, 100.5, 10.0, 1005.0, 5, '{}'
344 )",
345 [],
346 )
347 .expect("insert raw kline");
348
349 let service = VisualizationService;
350 let snapshot = service
351 .load_dashboard(DashboardQuery {
352 mode: BinanceMode::Demo,
353 base_dir: base_dir.clone(),
354 symbol: "BTCUSDT".to_string(),
355 from: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
356 to: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
357 selected_run_id: None,
358 run_limit: 10,
359 })
360 .expect("load dashboard");
361
362 assert_eq!(snapshot.market_series.kline_interval.as_deref(), Some("1m"));
363 assert_eq!(snapshot.market_series.klines.len(), 1);
364
365 std::fs::remove_file(db_path).ok();
366 std::fs::remove_dir_all(base_dir).ok();
367 }
368}