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
This commit is contained in:
Devid HW
2026-04-19 01:56:41 +07:00
parent 13e821bc5d
commit e94ec8153d
6 changed files with 159 additions and 0 deletions
Generated
+7
View File
@@ -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",
+1
View File
@@ -27,3 +27,4 @@ encoding_rs = "0.8"
tempfile = "3.0"
roxmltree = "0.21.1"
rusqlite = { version = "0.31", features = ["bundled"] }
base64 = "0.22"
+51
View File
@@ -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<Option<ReportEntry>> {
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<String> = 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)
}
}
+14
View File
@@ -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",
+1
View File
@@ -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,
+85
View File
@@ -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<Value> {
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<Value> {
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(30) as usize;