From e94ec8153dfb7a637c4f3ec07062e1d539513281 Mon Sep 17 00:00:00 2001 From: Devid HW Date: Sun, 19 Apr 2026 01:56:41 +0700 Subject: [PATCH] Add get_latest_report tool with equity chart support **New Tool: get_latest_report** - Returns the most recent backtest report from the database - Includes full report details: metrics, paths, tags, notes, verdict - Optional include_chart parameter (default: true) - Returns equity chart as base64 PNG when available **Implementation:** - ReportDb.get_latest(): Query for most recent report by created_at DESC - handle_get_latest_report(): Fetches report and reads equity.png from charts_dir - Base64 encoding using base64 crate v0.22 - Graceful handling when chart not found (equity_chart_error field) **Files Changed:** - storage/database.rs: Added get_latest() method - tools/definitions.rs: Added tool_get_latest_report() definition - tools/handlers/mod.rs: Added dispatch for get_latest_report - tools/handlers/reports.rs: Implemented handler with base64 chart encoding - Cargo.toml: Added base64 = 0.22 dependency --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/storage/database.rs | 51 +++++++++++++++++++++ src/tools/definitions.rs | 14 ++++++ src/tools/handlers/mod.rs | 1 + src/tools/handlers/reports.rs | 85 +++++++++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ec40855..e40ca73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -478,6 +484,7 @@ name = "mt5-quant" version = "1.27.0" dependencies = [ "anyhow", + "base64", "chrono", "clap", "dirs", diff --git a/Cargo.toml b/Cargo.toml index d8565b6..a90d44a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ encoding_rs = "0.8" tempfile = "3.0" roxmltree = "0.21.1" rusqlite = { version = "0.31", features = ["bundled"] } +base64 = "0.22" diff --git a/src/storage/database.rs b/src/storage/database.rs index f4cb1d9..deee39c 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -323,4 +323,55 @@ impl ReportDb { 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) + } } diff --git a/src/tools/definitions.rs b/src/tools/definitions.rs index dd88f0d..1bde7b8 100644 --- a/src/tools/definitions.rs +++ b/src/tools/definitions.rs @@ -16,6 +16,7 @@ pub fn get_tools_list() -> Value { tool_get_backtest_status(), tool_get_optimization_status(), tool_prune_reports(), + tool_get_latest_report(), tool_list_reports(), tool_search_reports(), tool_tail_log(), @@ -256,6 +257,19 @@ fn tool_prune_reports() -> Value { }) } +fn tool_get_latest_report() -> Value { + json!({ + "name": "get_latest_report", + "description": "Get the latest backtest report with full details and equity chart image", + "inputSchema": { + "type": "object", + "properties": { + "include_chart": { "type": "boolean", "description": "Include equity chart as base64 PNG (default: true)", "default": true } + } + } + }) +} + fn tool_list_reports() -> Value { json!({ "name": "list_reports", diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index 6fe1f9e..4fd16e3 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -62,6 +62,7 @@ impl ToolHandler { "list_set_files" => setfiles::handle_list_set_files(&self.config).await, // Report handlers + "get_latest_report" => reports::handle_get_latest_report(&self.config, args).await, "list_reports" => reports::handle_list_reports(args).await, "search_reports" => reports::handle_search_reports(args).await, "prune_reports" => reports::handle_prune_reports(&self.config, args).await, diff --git a/src/tools/handlers/reports.rs b/src/tools/handlers/reports.rs index 8be942c..e2a0b75 100644 --- a/src/tools/handlers/reports.rs +++ b/src/tools/handlers/reports.rs @@ -1,10 +1,95 @@ use anyhow::Result; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde_json::{json, Value}; use std::fs; use std::path::Path; use crate::models::Config; use crate::storage::{ReportDb, ReportFilters}; +pub async fn handle_get_latest_report(_config: &Config, args: &Value) -> Result { + let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true); + + let db = ReportDb::new(&Config::db_path()); + if let Err(e) = db.init() { + return Ok(json!({ + "content": [{ "type": "text", "text": format!("DB error: {}", e) }], + "isError": true + })); + } + + match db.get_latest()? { + Some(entry) => { + let mut response = json!({ + "success": true, + "report": { + "id": entry.id, + "expert": entry.expert, + "symbol": entry.symbol, + "timeframe": entry.timeframe, + "from_date": entry.from_date, + "to_date": entry.to_date, + "created_at": entry.created_at, + "net_profit": entry.net_profit, + "profit_factor": entry.profit_factor, + "max_dd_pct": entry.max_dd_pct, + "sharpe_ratio": entry.sharpe_ratio, + "total_trades": entry.total_trades, + "win_rate_pct": entry.win_rate_pct, + "recovery_factor": entry.recovery_factor, + "deposit": entry.deposit, + "currency": entry.currency, + "leverage": entry.leverage, + "duration_seconds": entry.duration_seconds, + "set_file_original": entry.set_file_original, + "set_snapshot_path": entry.set_snapshot_path, + "report_dir": entry.report_dir, + "charts_dir": entry.charts_dir, + "tags": entry.tags, + "notes": entry.notes, + "verdict": entry.verdict, + } + }); + + // Include equity chart as base64 if requested and available + if include_chart { + if let Some(charts_dir) = &entry.charts_dir { + let chart_path = Path::new(charts_dir).join("equity.png"); + if chart_path.exists() { + match fs::read(&chart_path) { + Ok(bytes) => { + let base64 = BASE64.encode(&bytes); + response["report"]["equity_chart_base64"] = json!(base64); + response["report"]["equity_chart_format"] = json!("png"); + } + Err(e) => { + response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", e)); + } + } + } else { + response["report"]["equity_chart_error"] = json!("equity.png not found in charts_dir"); + } + } else { + response["report"]["equity_chart_error"] = json!("No charts_dir available for this report"); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": response.to_string() }], + "isError": false + })) + } + None => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": "No reports found in database" + }).to_string() }], + "isError": true + })) + } + } +} + pub async fn handle_list_reports(args: &Value) -> Result { let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(30) as usize;