Skip to main content

sandbox_quant/visualization/
service.rs

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}