use anyhow::Result; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct ReportEntry { pub id: String, pub expert: String, pub symbol: String, pub timeframe: String, pub model: i64, pub from_date: String, pub to_date: String, pub created_at: String, pub set_file_original: Option, pub set_snapshot_path: Option, pub report_dir: String, pub charts_dir: Option, pub net_profit: Option, pub profit_factor: Option, pub max_dd_pct: Option, pub sharpe_ratio: Option, pub total_trades: Option, pub win_rate_pct: Option, pub recovery_factor: Option, pub deposit: Option, pub currency: Option, pub leverage: Option, pub duration_seconds: Option, pub tags: Vec, pub notes: Option, pub verdict: Option, } #[derive(Debug, Default)] pub struct ReportFilters { pub expert: Option, pub symbol: Option, pub timeframe: Option, pub created_after: Option, pub min_profit: Option, pub max_dd: Option, pub verdict: Option, } pub struct ReportDb { db_path: PathBuf, } impl ReportDb { pub fn new(db_path: &Path) -> Self { Self { db_path: db_path.to_path_buf(), } } fn connect(&self) -> Result { let conn = Connection::open(&self.db_path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; Ok(conn) } pub fn init(&self) -> Result<()> { if let Some(parent) = self.db_path.parent() { std::fs::create_dir_all(parent)?; } let conn = self.connect()?; conn.execute_batch(" CREATE TABLE IF NOT EXISTS reports ( id TEXT PRIMARY KEY, expert TEXT NOT NULL, symbol TEXT NOT NULL, timeframe TEXT NOT NULL, model INTEGER NOT NULL DEFAULT 0, from_date TEXT NOT NULL DEFAULT '', to_date TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, set_file_original TEXT, set_snapshot_path TEXT, report_dir TEXT NOT NULL, charts_dir TEXT, net_profit REAL, profit_factor REAL, max_dd_pct REAL, sharpe_ratio REAL, total_trades INTEGER, win_rate_pct REAL, recovery_factor REAL, deposit REAL, currency TEXT, leverage INTEGER, duration_seconds INTEGER, tags TEXT DEFAULT '[]', notes TEXT, verdict TEXT ); CREATE INDEX IF NOT EXISTS idx_reports_expert ON reports(expert); CREATE INDEX IF NOT EXISTS idx_reports_symbol ON reports(symbol); CREATE INDEX IF NOT EXISTS idx_reports_created_at ON reports(created_at DESC); ")?; Ok(()) } pub fn insert(&self, entry: &ReportEntry) -> Result<()> { let conn = self.connect()?; let tags_json = serde_json::to_string(&entry.tags)?; conn.execute( "INSERT OR REPLACE INTO reports (id, expert, symbol, timeframe, model, from_date, to_date, created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, win_rate_pct, recovery_factor, deposit, currency, leverage, duration_seconds, tags, notes, verdict) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26)", params![ entry.id, entry.expert, entry.symbol, entry.timeframe, entry.model, entry.from_date, entry.to_date, entry.created_at, entry.set_file_original, entry.set_snapshot_path, entry.report_dir, entry.charts_dir, entry.net_profit, entry.profit_factor, entry.max_dd_pct, entry.sharpe_ratio, entry.total_trades, entry.win_rate_pct, entry.recovery_factor, entry.deposit, entry.currency, entry.leverage, entry.duration_seconds, tags_json, entry.notes, entry.verdict, ], )?; Ok(()) } pub fn list(&self, limit: usize, filters: &ReportFilters) -> Result> { let conn = self.connect()?; let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE 1=1" .to_string(); let mut filter_params: Vec = Vec::new(); if let Some(ea) = &filters.expert { sql.push_str(" AND expert LIKE ?"); filter_params.push(format!("%{}%", ea)); } if let Some(sym) = &filters.symbol { sql.push_str(" AND symbol = ?"); filter_params.push(sym.clone()); } if let Some(tf) = &filters.timeframe { sql.push_str(" AND timeframe = ?"); filter_params.push(tf.clone()); } if let Some(after) = &filters.created_after { sql.push_str(" AND created_at >= ?"); filter_params.push(after.clone()); } if let Some(verdict) = &filters.verdict { sql.push_str(" AND verdict = ?"); filter_params.push(verdict.clone()); } // Overfetch for in-memory numeric filters, then truncate let fetch_limit = if filters.min_profit.is_some() || filters.max_dd.is_some() { limit * 4 } else { limit }; sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", fetch_limit)); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt .query_map(rusqlite::params_from_iter(filter_params.iter()), |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .filter(|e| { if let Some(min_profit) = filters.min_profit { if e.net_profit.unwrap_or(f64::MIN) < min_profit { return false; } } if let Some(max_dd) = filters.max_dd { if e.max_dd_pct.unwrap_or(100.0) > max_dd { return false; } } true }) .take(limit) .collect(); Ok(entries) } pub fn annotate( &self, id: &str, notes: Option<&str>, tags: Option>, verdict: Option<&str>, ) -> Result { let conn = self.connect()?; let mut sets: Vec = Vec::new(); let mut param_vals: Vec = Vec::new(); if let Some(n) = notes { sets.push("notes = ?".to_string()); param_vals.push(n.to_string()); } if let Some(t) = tags { sets.push("tags = ?".to_string()); param_vals.push(serde_json::to_string(&t).unwrap_or_default()); } if let Some(v) = verdict { sets.push("verdict = ?".to_string()); param_vals.push(v.to_string()); } if sets.is_empty() { return Ok(false); } param_vals.push(id.to_string()); let sql = format!("UPDATE reports SET {} WHERE id = ?", sets.join(", ")); let changed = conn.execute(&sql, rusqlite::params_from_iter(param_vals.iter()))?; Ok(changed > 0) } /// Returns (id, report_dir, charts_dir) for the oldest entries beyond keep_last. pub fn list_purgeable( &self, keep_last: usize, ) -> Result)>> { let conn = self.connect()?; let count: usize = conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?; if count <= keep_last { return Ok(Vec::new()); } let to_delete = count - keep_last; let mut stmt = conn.prepare( "SELECT id, report_dir, charts_dir FROM reports ORDER BY created_at ASC LIMIT ?", )?; let rows: Vec<(String, String, Option)> = stmt .query_map(params![to_delete as i64], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) })? .filter_map(|r| r.ok()) .collect(); Ok(rows) } pub fn delete_entry(&self, id: &str) -> Result> { let conn = self.connect()?; let report_dir: Option = conn .query_row( "SELECT report_dir FROM reports WHERE id = ?", params![id], |row| row.get(0), ) .ok(); conn.execute("DELETE FROM reports WHERE id = ?", params![id])?; Ok(report_dir) } pub fn count(&self) -> Result { let conn = self.connect()?; let n: usize = conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?; Ok(n) } /// Get the latest report by created_at (most recent first) pub fn get_latest(&self) -> Result> { let conn = self.connect()?; let mut stmt = conn.prepare( "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports ORDER BY created_at DESC LIMIT 1" )?; let entry = stmt .query_map([], |row| { let tags_json: String = row.get(23)?; let tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .next(); Ok(entry) } /// Get a specific report by ID pub fn get_by_id(&self, id: &str) -> Result> { let conn = self.connect()?; let mut stmt = conn.prepare( "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE id = ?" )?; let entry = stmt .query_map([id], |row| { let tags_json: String = row.get(23)?; let tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .next(); Ok(entry) } /// Search reports by tags (at least one tag must match) pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result> { let conn = self.connect()?; let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE 1=1" .to_string(); // Build OR conditions for tags - use JSON1 extension for tag matching if !tags.is_empty() { let tag_conditions: Vec = tags.iter() .map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''"))) .collect(); sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR "))); } sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt .query_map([], |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Search reports by notes text (case-insensitive LIKE) pub fn search_by_notes(&self, query: &str, limit: usize) -> Result> { let conn = self.connect()?; let pattern = format!("%{}%", query.replace("'", "''")); let mut stmt = conn.prepare( &format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}", pattern, limit) )?; let entries: Vec = stmt .query_map([], |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Find reports by set file (original or snapshot) pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result> { let conn = self.connect()?; let pattern = format!("%{}%", set_file.replace("'", "''")); let mut stmt = conn.prepare( &format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \ ORDER BY created_at DESC LIMIT {}", pattern, pattern, limit) )?; let entries: Vec = stmt .query_map([], |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Search by backtest date range (from_date and to_date fields) pub fn search_by_date_range( &self, from_start: Option<&str>, from_end: Option<&str>, to_start: Option<&str>, to_end: Option<&str>, limit: usize, ) -> Result> { let conn = self.connect()?; let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE 1=1" .to_string(); let mut params: Vec = Vec::new(); if let Some(start) = from_start { sql.push_str(" AND from_date >= ?"); params.push(start.to_string()); } if let Some(end) = from_end { sql.push_str(" AND from_date <= ?"); params.push(end.to_string()); } if let Some(start) = to_start { sql.push_str(" AND to_date >= ?"); params.push(start.to_string()); } if let Some(end) = to_end { sql.push_str(" AND to_date <= ?"); params.push(end.to_string()); } sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Get comparable reports (same expert/symbol/timeframe for comparison) pub fn get_comparable( &self, expert: &str, symbol: &str, timeframe: &str, exclude_id: Option<&str>, limit: usize, ) -> Result> { let conn = self.connect()?; let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?" .to_string(); let mut params: Vec = vec![ expert.to_string(), symbol.to_string(), timeframe.to_string(), ]; if let Some(id) = exclude_id { sql.push_str(" AND id != ?"); params.push(id.to_string()); } sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Get reports sorted by a specific metric (for best/worst queries) pub fn get_sorted_by( &self, sort_column: &str, ascending: bool, limit: usize, filters: &ReportFilters, ) -> Result> { let conn = self.connect()?; // Validate sort column to prevent SQL injection let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", "sharpe_ratio", "recovery_factor", "total_trades", "created_at"]; if !valid_columns.contains(&sort_column) { return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column)); } let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE 1=1" .to_string(); let mut filter_params: Vec = Vec::new(); if let Some(ea) = &filters.expert { sql.push_str(" AND expert LIKE ?"); filter_params.push(format!("%{}%", ea)); } if let Some(sym) = &filters.symbol { sql.push_str(" AND symbol = ?"); filter_params.push(sym.clone()); } if let Some(tf) = &filters.timeframe { sql.push_str(" AND timeframe = ?"); filter_params.push(tf.clone()); } if let Some(verdict) = &filters.verdict { sql.push_str(" AND verdict = ?"); filter_params.push(verdict.clone()); } // For non-created_at columns, only include rows where that column is not NULL if sort_column != "created_at" { sql.push_str(&format!(" AND {} IS NOT NULL", sort_column)); } let order = if ascending { "ASC" } else { "DESC" }; sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit)); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt .query_map(rusqlite::params_from_iter(filter_params.iter()), |row| { let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); Ok(ReportEntry { id: row.get(0)?, expert: row.get(1)?, symbol: row.get(2)?, timeframe: row.get(3)?, model: row.get(4)?, from_date: row.get(5)?, to_date: row.get(6)?, created_at: row.get(7)?, set_file_original: row.get(8)?, set_snapshot_path: row.get(9)?, report_dir: row.get(10)?, charts_dir: row.get(11)?, net_profit: row.get(12)?, profit_factor: row.get(13)?, max_dd_pct: row.get(14)?, sharpe_ratio: row.get(15)?, total_trades: row.get(16)?, win_rate_pct: row.get(17)?, recovery_factor: row.get(18)?, deposit: row.get(19)?, currency: row.get(20)?, leverage: row.get(21)?, duration_seconds: row.get(22)?, tags, notes: row.get(24)?, verdict: row.get(25)?, }) })? .filter_map(|r| r.ok()) .collect(); Ok(entries) } /// Get aggregate statistics for reports pub fn get_stats(&self, filters: &ReportFilters) -> Result { let conn = self.connect()?; let mut sql = "SELECT \ COUNT(*), \ AVG(net_profit), \ AVG(profit_factor), \ AVG(max_dd_pct), \ AVG(win_rate_pct), \ AVG(sharpe_ratio), \ SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \ SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \ SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \ SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \ FROM reports WHERE 1=1" .to_string(); let mut filter_params: Vec = Vec::new(); if let Some(ea) = &filters.expert { sql.push_str(" AND expert LIKE ?"); filter_params.push(format!("%{}%", ea)); } if let Some(sym) = &filters.symbol { sql.push_str(" AND symbol = ?"); filter_params.push(sym.clone()); } if let Some(tf) = &filters.timeframe { sql.push_str(" AND timeframe = ?"); filter_params.push(tf.clone()); } if let Some(verdict) = &filters.verdict { sql.push_str(" AND verdict = ?"); filter_params.push(verdict.clone()); } let stats: ReportStats = conn.query_row( &sql, rusqlite::params_from_iter(filter_params.iter()), |row| { let total: i64 = row.get(0)?; let profitable: Option = row.get(6)?; let pass_count: Option = row.get(7)?; let fail_count: Option = row.get(8)?; let marginal_count: Option = row.get(9)?; Ok(ReportStats { total_count: total as usize, avg_net_profit: row.get(1)?, avg_profit_factor: row.get(2)?, avg_max_dd_pct: row.get(3)?, avg_win_rate_pct: row.get(4)?, avg_sharpe_ratio: row.get(5)?, profitable_count: profitable.unwrap_or(0) as usize, pass_verdict_count: pass_count.unwrap_or(0) as usize, fail_verdict_count: fail_count.unwrap_or(0) as usize, marginal_verdict_count: marginal_count.unwrap_or(0) as usize, }) }, )?; Ok(stats) } } #[derive(Debug, Serialize, Deserialize, Default)] pub struct ReportStats { pub total_count: usize, pub avg_net_profit: Option, pub avg_profit_factor: Option, pub avg_max_dd_pct: Option, pub avg_win_rate_pct: Option, pub avg_sharpe_ratio: Option, pub profitable_count: usize, pub pass_verdict_count: usize, pub fail_verdict_count: usize, pub marginal_verdict_count: usize, }