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                    },
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}