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 },
128 });
129 }
130 }
131 markers
132 }
133
134 fn load_dashboard_inner(
135 &self,
136 query: DashboardQuery,
137 selected_report_override: Option<crate::backtest_app::runner::BacktestReport>,
138 ) -> Result<DashboardSnapshot, StorageError> {
139 let db_path = RecorderCoordination::new(query.base_dir.clone()).db_path(query.mode);
140 init_schema_for_path(&db_path)?;
141 let recorder_metrics = metrics_for_path(&db_path)?;
142 let available_symbols = load_recorded_symbols_for_path(&db_path, 256)?;
143 let symbol = resolve_symbol(&query.symbol, &available_symbols);
144 let dataset_summary =
145 load_dataset_summary(&db_path, query.mode, &symbol, query.from, query.to)?;
146 let market_series = load_market_series(&db_path, &symbol, query.from, query.to)?;
147 let recent_runs = load_backtest_run_summaries(&db_path, query.run_limit)?;
148 let selected_run_id = query.selected_run_id.or_else(|| {
149 recent_runs
150 .iter()
151 .find(|row| row.instrument == symbol)
152 .map(|row| row.run_id)
153 });
154 let selected_report = match selected_report_override {
155 Some(report) => Some(report),
156 None => match selected_run_id {
157 Some(run_id) => load_backtest_report(&db_path, Some(run_id))?,
158 None => None,
159 },
160 };
161
162 Ok(DashboardSnapshot {
163 mode: query.mode,
164 base_dir: query.base_dir,
165 db_path,
166 symbol,
167 from: query.from,
168 to: query.to,
169 available_symbols,
170 recorder_metrics,
171 dataset_summary,
172 market_series,
173 recent_runs,
174 selected_report,
175 selected_run_id,
176 })
177 }
178}
179
180fn load_dataset_summary(
181 db_path: &Path,
182 mode: BinanceMode,
183 symbol: &str,
184 from: chrono::NaiveDate,
185 to: chrono::NaiveDate,
186) -> Result<BacktestDatasetSummary, StorageError> {
187 if symbol.is_empty() {
188 return Ok(BacktestDatasetSummary {
189 mode,
190 symbol: String::new(),
191 symbol_found: false,
192 from: from.to_string(),
193 to: to.to_string(),
194 liquidation_events: 0,
195 book_ticker_events: 0,
196 agg_trade_events: 0,
197 derived_kline_1s_bars: 0,
198 });
199 }
200 backtest_summary_for_path(db_path, mode, symbol, from, to)
201}
202
203fn load_market_series(
204 db_path: &Path,
205 symbol: &str,
206 from: chrono::NaiveDate,
207 to: chrono::NaiveDate,
208) -> Result<MarketSeries, StorageError> {
209 if symbol.is_empty() {
210 return Ok(MarketSeries {
211 symbol: String::new(),
212 liquidations: Vec::new(),
213 book_tickers: Vec::new(),
214 klines: Vec::new(),
215 kline_interval: None,
216 });
217 }
218 let derived_klines = load_derived_kline_rows_for_path(db_path, symbol, from, to)?;
219 let (klines, kline_interval) = if derived_klines.is_empty() {
220 match load_raw_kline_rows_for_path(db_path, symbol, from, to)? {
221 Some((interval, rows)) => (rows, Some(interval)),
222 None => (Vec::new(), None),
223 }
224 } else {
225 (derived_klines, Some("1s".to_string()))
226 };
227 Ok(MarketSeries {
228 symbol: symbol.to_string(),
229 liquidations: load_liquidation_events_for_path(db_path, symbol, from, to)?,
230 book_tickers: load_book_ticker_rows_for_path(db_path, symbol, from, to)?,
231 klines,
232 kline_interval,
233 })
234}
235
236fn resolve_symbol(selected: &str, available_symbols: &[String]) -> String {
237 if !selected.trim().is_empty() {
238 return selected.trim().to_ascii_uppercase();
239 }
240 available_symbols.first().cloned().unwrap_or_default()
241}
242
243#[cfg(test)]
244mod tests {
245 use std::path::PathBuf;
246
247 use super::*;
248 use crate::app::bootstrap::BinanceMode;
249 use crate::dataset::schema::init_schema_for_path;
250 use chrono::{TimeZone, Utc};
251 use duckdb::Connection;
252
253 #[test]
254 fn equity_curve_accumulates_realized_trade_pnl() {
255 let trades = vec![
256 BacktestTrade {
257 trade_id: 1,
258 trigger_time: Utc.timestamp_millis_opt(1_000).single().expect("timestamp"),
259 entry_time: Utc.timestamp_millis_opt(2_000).single().expect("timestamp"),
260 entry_price: 100.0,
261 stop_price: 101.0,
262 take_profit_price: 98.0,
263 qty: 1.0,
264 exit_time: Some(Utc.timestamp_millis_opt(3_000).single().expect("timestamp")),
265 exit_price: Some(98.0),
266 exit_reason: Some(BacktestExitReason::TakeProfit),
267 gross_pnl: Some(2.0),
268 fees: Some(0.2),
269 net_pnl: Some(1.8),
270 },
271 BacktestTrade {
272 trade_id: 2,
273 trigger_time: Utc.timestamp_millis_opt(4_000).single().expect("timestamp"),
274 entry_time: Utc.timestamp_millis_opt(5_000).single().expect("timestamp"),
275 entry_price: 99.0,
276 stop_price: 100.0,
277 take_profit_price: 97.0,
278 qty: 1.0,
279 exit_time: Some(Utc.timestamp_millis_opt(6_000).single().expect("timestamp")),
280 exit_price: Some(100.0),
281 exit_reason: Some(BacktestExitReason::StopLoss),
282 gross_pnl: Some(-1.0),
283 fees: Some(0.2),
284 net_pnl: Some(-1.2),
285 },
286 ];
287
288 let points = VisualizationService::equity_curve(10_000.0, &trades);
289
290 assert_eq!(points.len(), 2);
291 assert!((points[0].equity - 10_001.8).abs() < 1e-9);
292 assert!((points[1].equity - 10_000.6).abs() < 1e-9);
293 }
294
295 #[test]
296 fn resolve_symbol_prefers_selected_value() {
297 let symbol = resolve_symbol("ethusdt", &["BTCUSDT".to_string()]);
298
299 assert_eq!(symbol, "ETHUSDT");
300 }
301
302 #[test]
303 fn empty_symbol_summary_uses_requested_range() {
304 let from = chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid date");
305 let to = chrono::NaiveDate::from_ymd_opt(2026, 3, 14).expect("valid date");
306 let summary = load_dataset_summary(
307 &PathBuf::from("/tmp/missing.duckdb"),
308 BinanceMode::Demo,
309 "",
310 from,
311 to,
312 )
313 .expect("summary");
314
315 assert_eq!(summary.mode, BinanceMode::Demo);
316 assert_eq!(summary.from, "2026-03-13");
317 assert_eq!(summary.to, "2026-03-14");
318 assert_eq!(summary.symbol, "");
319 }
320
321 #[test]
322 fn load_dashboard_falls_back_to_raw_klines_when_derived_klines_are_absent() {
323 let mut base_dir = std::env::temp_dir();
324 base_dir.push(format!(
325 "sandbox_quant_gui_raw_kline_fallback_{}_{}",
326 std::process::id(),
327 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
328 ));
329 std::fs::create_dir_all(&base_dir).expect("create temp dir");
330 let db_path = base_dir.join("market-v2-demo.duckdb");
331 init_schema_for_path(&db_path).expect("init schema");
332 let connection = Connection::open(&db_path).expect("open db");
333 connection
334 .execute(
335 "INSERT INTO raw_klines (
336 kline_id, mode, product, symbol, interval, open_time, close_time,
337 open, high, low, close, volume, quote_volume, trade_count, raw_payload
338 ) VALUES (
339 1, 'demo', 'um', 'BTCUSDT', '1m',
340 CAST('2026-03-13 00:00:00' AS TIMESTAMP),
341 CAST('2026-03-13 00:00:59' AS TIMESTAMP),
342 100.0, 101.0, 99.5, 100.5, 10.0, 1005.0, 5, '{}'
343 )",
344 [],
345 )
346 .expect("insert raw kline");
347
348 let service = VisualizationService;
349 let snapshot = service
350 .load_dashboard(DashboardQuery {
351 mode: BinanceMode::Demo,
352 base_dir: base_dir.clone(),
353 symbol: "BTCUSDT".to_string(),
354 from: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
355 to: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
356 selected_run_id: None,
357 run_limit: 10,
358 })
359 .expect("load dashboard");
360
361 assert_eq!(snapshot.market_series.kline_interval.as_deref(), Some("1m"));
362 assert_eq!(snapshot.market_series.klines.len(), 1);
363
364 std::fs::remove_file(db_path).ok();
365 std::fs::remove_dir_all(base_dir).ok();
366 }
367}