sandbox-quant 1.0.8

Exchange-truth trading core for Binance Spot and Futures
Documentation
use std::path::Path;

use crate::app::bootstrap::BinanceMode;
use crate::backtest_app::runner::{run_backtest_for_path, BacktestExitReason, BacktestTrade};
use crate::dataset::query::{
    backtest_summary_for_path, latest_market_data_day_for_path, load_backtest_report,
    load_backtest_run_summaries, load_book_ticker_rows_for_path, load_derived_kline_rows_for_path,
    load_liquidation_events_for_path, load_raw_kline_rows_for_path, load_recorded_symbols_for_path,
    metrics_for_path, persist_backtest_report,
};
use crate::dataset::schema::init_schema_for_path;
use crate::dataset::types::BacktestDatasetSummary;
use crate::error::storage_error::StorageError;
use crate::record::coordination::RecorderCoordination;
use crate::visualization::types::{
    BacktestRunRequest, DashboardQuery, DashboardSnapshot, EquityPoint, MarketSeries, PricePoint,
    SignalKind, SignalMarker,
};

#[derive(Debug, Default, Clone, Copy)]
pub struct VisualizationService;

impl VisualizationService {
    pub fn load_dashboard(&self, query: DashboardQuery) -> Result<DashboardSnapshot, StorageError> {
        self.load_dashboard_inner(query, None)
    }

    pub fn run_backtest(
        &self,
        request: BacktestRunRequest,
    ) -> Result<DashboardSnapshot, StorageError> {
        let db_path = RecorderCoordination::new(request.base_dir.clone()).db_path(request.mode);
        init_schema_for_path(&db_path)?;
        let report = run_backtest_for_path(
            &db_path,
            request.mode,
            request.template,
            &request.symbol,
            request.from,
            request.to,
            request.config,
        )?;
        let run_id = persist_backtest_report(&db_path, &report)?;
        self.load_dashboard_inner(
            DashboardQuery {
                mode: request.mode,
                base_dir: request.base_dir,
                symbol: request.symbol,
                from: request.from,
                to: request.to,
                selected_run_id: Some(run_id),
                run_limit: request.run_limit,
            },
            None,
        )
    }

    pub fn latest_market_data_day(
        &self,
        mode: BinanceMode,
        base_dir: std::path::PathBuf,
        symbol: &str,
    ) -> Result<Option<chrono::NaiveDate>, StorageError> {
        let db_path = RecorderCoordination::new(base_dir).db_path(mode);
        init_schema_for_path(&db_path)?;
        latest_market_data_day_for_path(&db_path, symbol)
    }

    pub fn price_points(series: &MarketSeries) -> Vec<PricePoint> {
        if !series.klines.is_empty() {
            return series
                .klines
                .iter()
                .map(|row| PricePoint {
                    time_ms: row.close_time_ms,
                    price: row.close,
                })
                .collect();
        }
        series
            .book_tickers
            .iter()
            .map(|row| PricePoint {
                time_ms: row.event_time_ms,
                price: (row.bid + row.ask) * 0.5,
            })
            .collect()
    }

    pub fn equity_curve(starting_equity: f64, trades: &[BacktestTrade]) -> Vec<EquityPoint> {
        let mut equity = starting_equity;
        let mut points = Vec::new();
        for trade in trades {
            if let (Some(exit_time), Some(net_pnl)) = (trade.exit_time, trade.net_pnl) {
                equity += net_pnl;
                points.push(EquityPoint {
                    time_ms: exit_time.timestamp_millis(),
                    equity,
                });
            }
        }
        points
    }

    pub fn signal_markers(trades: &[BacktestTrade]) -> Vec<SignalMarker> {
        let mut markers = Vec::new();
        for trade in trades {
            markers.push(SignalMarker {
                time_ms: trade.entry_time.timestamp_millis(),
                price: trade.entry_price,
                label: format!("entry #{}", trade.trade_id),
                kind: SignalKind::Entry,
            });
            if let (Some(exit_time), Some(exit_price), Some(exit_reason)) = (
                trade.exit_time,
                trade.exit_price,
                trade.exit_reason.as_ref(),
            ) {
                markers.push(SignalMarker {
                    time_ms: exit_time.timestamp_millis(),
                    price: exit_price,
                    label: format!("exit #{}", trade.trade_id),
                    kind: match exit_reason {
                        BacktestExitReason::TakeProfit => SignalKind::TakeProfit,
                        BacktestExitReason::StopLoss => SignalKind::StopLoss,
                        BacktestExitReason::OpenAtEnd => SignalKind::OpenAtEnd,
                        BacktestExitReason::SignalExit => SignalKind::SignalExit,
                    },
                });
            }
        }
        markers
    }

    fn load_dashboard_inner(
        &self,
        query: DashboardQuery,
        selected_report_override: Option<crate::backtest_app::runner::BacktestReport>,
    ) -> Result<DashboardSnapshot, StorageError> {
        let db_path = RecorderCoordination::new(query.base_dir.clone()).db_path(query.mode);
        init_schema_for_path(&db_path)?;
        let recorder_metrics = metrics_for_path(&db_path)?;
        let available_symbols = load_recorded_symbols_for_path(&db_path, 256)?;
        let symbol = resolve_symbol(&query.symbol, &available_symbols);
        let dataset_summary =
            load_dataset_summary(&db_path, query.mode, &symbol, query.from, query.to)?;
        let market_series = load_market_series(&db_path, &symbol, query.from, query.to)?;
        let recent_runs = load_backtest_run_summaries(&db_path, query.run_limit)?;
        let selected_run_id = query.selected_run_id.or_else(|| {
            recent_runs
                .iter()
                .find(|row| row.instrument == symbol)
                .map(|row| row.run_id)
        });
        let selected_report = match selected_report_override {
            Some(report) => Some(report),
            None => match selected_run_id {
                Some(run_id) => load_backtest_report(&db_path, Some(run_id))?,
                None => None,
            },
        };

        Ok(DashboardSnapshot {
            mode: query.mode,
            base_dir: query.base_dir,
            db_path,
            symbol,
            from: query.from,
            to: query.to,
            available_symbols,
            recorder_metrics,
            dataset_summary,
            market_series,
            recent_runs,
            selected_report,
            selected_run_id,
        })
    }
}

fn load_dataset_summary(
    db_path: &Path,
    mode: BinanceMode,
    symbol: &str,
    from: chrono::NaiveDate,
    to: chrono::NaiveDate,
) -> Result<BacktestDatasetSummary, StorageError> {
    if symbol.is_empty() {
        return Ok(BacktestDatasetSummary {
            mode,
            symbol: String::new(),
            symbol_found: false,
            from: from.to_string(),
            to: to.to_string(),
            liquidation_events: 0,
            book_ticker_events: 0,
            agg_trade_events: 0,
            derived_kline_1s_bars: 0,
        });
    }
    backtest_summary_for_path(db_path, mode, symbol, from, to)
}

fn load_market_series(
    db_path: &Path,
    symbol: &str,
    from: chrono::NaiveDate,
    to: chrono::NaiveDate,
) -> Result<MarketSeries, StorageError> {
    if symbol.is_empty() {
        return Ok(MarketSeries {
            symbol: String::new(),
            liquidations: Vec::new(),
            book_tickers: Vec::new(),
            klines: Vec::new(),
            kline_interval: None,
        });
    }
    let derived_klines = load_derived_kline_rows_for_path(db_path, symbol, from, to)?;
    let (klines, kline_interval) = if derived_klines.is_empty() {
        match load_raw_kline_rows_for_path(db_path, symbol, from, to)? {
            Some((interval, rows)) => (rows, Some(interval)),
            None => (Vec::new(), None),
        }
    } else {
        (derived_klines, Some("1s".to_string()))
    };
    Ok(MarketSeries {
        symbol: symbol.to_string(),
        liquidations: load_liquidation_events_for_path(db_path, symbol, from, to)?,
        book_tickers: load_book_ticker_rows_for_path(db_path, symbol, from, to)?,
        klines,
        kline_interval,
    })
}

fn resolve_symbol(selected: &str, available_symbols: &[String]) -> String {
    if !selected.trim().is_empty() {
        return selected.trim().to_ascii_uppercase();
    }
    available_symbols.first().cloned().unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;
    use crate::app::bootstrap::BinanceMode;
    use crate::dataset::schema::init_schema_for_path;
    use chrono::{TimeZone, Utc};
    use duckdb::Connection;

    #[test]
    fn equity_curve_accumulates_realized_trade_pnl() {
        let trades = vec![
            BacktestTrade {
                trade_id: 1,
                trigger_time: Utc.timestamp_millis_opt(1_000).single().expect("timestamp"),
                entry_time: Utc.timestamp_millis_opt(2_000).single().expect("timestamp"),
                entry_price: 100.0,
                stop_price: 101.0,
                take_profit_price: 98.0,
                qty: 1.0,
                exit_time: Some(Utc.timestamp_millis_opt(3_000).single().expect("timestamp")),
                exit_price: Some(98.0),
                exit_reason: Some(BacktestExitReason::TakeProfit),
                gross_pnl: Some(2.0),
                fees: Some(0.2),
                net_pnl: Some(1.8),
            },
            BacktestTrade {
                trade_id: 2,
                trigger_time: Utc.timestamp_millis_opt(4_000).single().expect("timestamp"),
                entry_time: Utc.timestamp_millis_opt(5_000).single().expect("timestamp"),
                entry_price: 99.0,
                stop_price: 100.0,
                take_profit_price: 97.0,
                qty: 1.0,
                exit_time: Some(Utc.timestamp_millis_opt(6_000).single().expect("timestamp")),
                exit_price: Some(100.0),
                exit_reason: Some(BacktestExitReason::StopLoss),
                gross_pnl: Some(-1.0),
                fees: Some(0.2),
                net_pnl: Some(-1.2),
            },
        ];

        let points = VisualizationService::equity_curve(10_000.0, &trades);

        assert_eq!(points.len(), 2);
        assert!((points[0].equity - 10_001.8).abs() < 1e-9);
        assert!((points[1].equity - 10_000.6).abs() < 1e-9);
    }

    #[test]
    fn resolve_symbol_prefers_selected_value() {
        let symbol = resolve_symbol("ethusdt", &["BTCUSDT".to_string()]);

        assert_eq!(symbol, "ETHUSDT");
    }

    #[test]
    fn empty_symbol_summary_uses_requested_range() {
        let from = chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid date");
        let to = chrono::NaiveDate::from_ymd_opt(2026, 3, 14).expect("valid date");
        let summary = load_dataset_summary(
            &PathBuf::from("/tmp/missing.duckdb"),
            BinanceMode::Demo,
            "",
            from,
            to,
        )
        .expect("summary");

        assert_eq!(summary.mode, BinanceMode::Demo);
        assert_eq!(summary.from, "2026-03-13");
        assert_eq!(summary.to, "2026-03-14");
        assert_eq!(summary.symbol, "");
    }

    #[test]
    fn load_dashboard_falls_back_to_raw_klines_when_derived_klines_are_absent() {
        let mut base_dir = std::env::temp_dir();
        base_dir.push(format!(
            "sandbox_quant_gui_raw_kline_fallback_{}_{}",
            std::process::id(),
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
        ));
        std::fs::create_dir_all(&base_dir).expect("create temp dir");
        let db_path = base_dir.join("market-v2-demo.duckdb");
        init_schema_for_path(&db_path).expect("init schema");
        let connection = Connection::open(&db_path).expect("open db");
        connection
            .execute(
                "INSERT INTO raw_klines (
                kline_id, mode, product, symbol, interval, open_time, close_time,
                open, high, low, close, volume, quote_volume, trade_count, raw_payload
             ) VALUES (
                1, 'demo', 'um', 'BTCUSDT', '1m',
                CAST('2026-03-13 00:00:00' AS TIMESTAMP),
                CAST('2026-03-13 00:00:59' AS TIMESTAMP),
                100.0, 101.0, 99.5, 100.5, 10.0, 1005.0, 5, '{}'
             )",
                [],
            )
            .expect("insert raw kline");

        let service = VisualizationService;
        let snapshot = service
            .load_dashboard(DashboardQuery {
                mode: BinanceMode::Demo,
                base_dir: base_dir.clone(),
                symbol: "BTCUSDT".to_string(),
                from: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
                to: chrono::NaiveDate::from_ymd_opt(2026, 3, 13).expect("date"),
                selected_run_id: None,
                run_limit: 10,
            })
            .expect("load dashboard");

        assert_eq!(snapshot.market_series.kline_interval.as_deref(), Some("1m"));
        assert_eq!(snapshot.market_series.klines.len(), 1);

        std::fs::remove_file(db_path).ok();
        std::fs::remove_dir_all(base_dir).ok();
    }
}