From 1f63505cb9f6f5ec2665af01b53f84a65259d37a Mon Sep 17 00:00:00 2001 From: Devid HW Date: Sun, 19 Apr 2026 02:13:33 +0700 Subject: [PATCH] Make analytics granular - individual tools + selective analyze_report **Granular Analytics Tools (8 new tools):** - analyze_monthly_pnl - Monthly profit/loss breakdown - analyze_drawdown_events - Drawdown events from balance curve - analyze_top_losses - Top N worst losses with grid depth - analyze_loss_sequences - Consecutive loss streaks analysis - analyze_position_pairs - Entry/exit position pairs - analyze_direction_bias - Long vs Short performance stats - analyze_streaks - Win/loss streaks with dates - analyze_concurrent_peak - Peak concurrent open positions **Updated analyze_report:** - Now supports selective analytics via 'analytics' array parameter - Runs all analytics by default (backward compatible) - Optional top_losses_limit parameter - Returns list of analytics that were run **Changes:** - analytics/analyze.rs: Made all analysis methods public - tools/definitions.rs: Added 8 new tool definitions + updated analyze_report schema - tools/handlers/mod.rs: Added dispatch for new tools - tools/handlers/analysis.rs: Added helper load_report_data() + 8 granular handlers + selective analyze_report logic --- src/analytics/analyze.rs | 16 +-- src/tools/definitions.rs | 124 +++++++++++++++++++- src/tools/handlers/analysis.rs | 208 +++++++++++++++++++++++++++++++-- src/tools/handlers/mod.rs | 8 ++ 4 files changed, 339 insertions(+), 17 deletions(-) diff --git a/src/analytics/analyze.rs b/src/analytics/analyze.rs index 633f4e1..21284f1 100644 --- a/src/analytics/analyze.rs +++ b/src/analytics/analyze.rs @@ -34,7 +34,7 @@ impl DealAnalyzer { } } - fn monthly_pnl(&self, deals: &[Deal]) -> Vec { + pub fn monthly_pnl(&self, deals: &[Deal]) -> Vec { let mut monthly: HashMap = HashMap::new(); for deal in deals { @@ -71,7 +71,7 @@ impl DealAnalyzer { result } - fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec { + pub fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec { let mut balance_curve = Vec::new(); let mut peak_balance: f64 = 0.0; let mut initial_balance: Option = None; @@ -178,7 +178,7 @@ impl DealAnalyzer { event } - fn top_losses(&self, deals: &[Deal], n: usize) -> Vec { + pub fn top_losses(&self, deals: &[Deal], n: usize) -> Vec { let mut losses: Vec = deals .iter() .filter(|d| d.profit < 0.0) @@ -196,7 +196,7 @@ impl DealAnalyzer { losses } - fn loss_sequences(&self, deals: &[Deal]) -> Vec { + pub fn loss_sequences(&self, deals: &[Deal]) -> Vec { let closed: Vec<&Deal> = deals .iter() .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) @@ -241,7 +241,7 @@ impl DealAnalyzer { sequences } - fn position_pairs(&self, deals: &[Deal]) -> Vec { + pub fn position_pairs(&self, deals: &[Deal]) -> Vec { let mut open_pos: HashMap = HashMap::new(); let mut pairs = Vec::new(); @@ -278,7 +278,7 @@ impl DealAnalyzer { pairs } - fn direction_bias(&self, deals: &[Deal]) -> HashMap { + pub fn direction_bias(&self, deals: &[Deal]) -> HashMap { let mut stats: HashMap = HashMap::new(); stats.insert("buy".to_string(), (0, 0, 0.0)); stats.insert("sell".to_string(), (0, 0, 0.0)); @@ -314,7 +314,7 @@ impl DealAnalyzer { .collect() } - fn streak_analysis(&self, deals: &[Deal]) -> StreakAnalysis { + pub fn streak_analysis(&self, deals: &[Deal]) -> StreakAnalysis { let closed: Vec<&Deal> = deals .iter() .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) @@ -372,7 +372,7 @@ impl DealAnalyzer { } } - fn concurrent_peak(&self, deals: &[Deal]) -> ConcurrentPeak { + pub fn concurrent_peak(&self, deals: &[Deal]) -> ConcurrentPeak { let mut events: Vec<(DateTime, i32, &Deal)> = Vec::new(); for deal in deals { diff --git a/src/tools/definitions.rs b/src/tools/definitions.rs index 1bde7b8..c6b73b3 100644 --- a/src/tools/definitions.rs +++ b/src/tools/definitions.rs @@ -6,6 +6,14 @@ pub fn get_tools_list() -> Value { tool_run_optimization(), tool_get_optimization_results(), tool_analyze_report(), + tool_analyze_monthly_pnl(), + tool_analyze_drawdown_events(), + tool_analyze_top_losses(), + tool_analyze_loss_sequences(), + tool_analyze_position_pairs(), + tool_analyze_direction_bias(), + tool_analyze_streaks(), + tool_analyze_concurrent_peak(), tool_compare_baseline(), tool_compile_ea(), tool_verify_setup(), @@ -109,7 +117,121 @@ fn tool_get_optimization_results() -> Value { fn tool_analyze_report() -> Value { json!({ "name": "analyze_report", - "description": "Read and summarize a completed backtest report", + "description": "Run comprehensive analytics on a backtest report. By default runs all analytics. Use 'analytics' array to run specific ones: monthly_pnl, drawdown_events, top_losses, loss_sequences, position_pairs, direction_bias, streak_analysis, concurrent_peak", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" }, + "analytics": { + "type": "array", + "description": "Optional: specific analytics to run. If omitted, runs all.", + "items": { + "type": "string", + "enum": ["monthly_pnl", "drawdown_events", "top_losses", "loss_sequences", "position_pairs", "direction_bias", "streak_analysis", "concurrent_peak"] + } + }, + "top_losses_limit": { "type": "integer", "description": "Number of top losses to return (default: 10)" } + } + } + }) +} + +fn tool_analyze_monthly_pnl() -> Value { + json!({ + "name": "analyze_monthly_pnl", + "description": "Analyze monthly profit/loss breakdown from a backtest report", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" } + } + } + }) +} + +fn tool_analyze_drawdown_events() -> Value { + json!({ + "name": "analyze_drawdown_events", + "description": "Analyze drawdown events reconstructed from balance curve", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +fn tool_analyze_top_losses() -> Value { + json!({ + "name": "analyze_top_losses", + "description": "Get top N worst losses with grid depth analysis", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" }, + "limit": { "type": "integer", "description": "Number of losses to return (default: 10)", "default": 10 } + } + } + }) +} + +fn tool_analyze_loss_sequences() -> Value { + json!({ + "name": "analyze_loss_sequences", + "description": "Analyze consecutive loss streaks and their impact", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +fn tool_analyze_position_pairs() -> Value { + json!({ + "name": "analyze_position_pairs", + "description": "Analyze entry/exit position pairs and their performance", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +fn tool_analyze_direction_bias() -> Value { + json!({ + "name": "analyze_direction_bias", + "description": "Analyze long vs short performance bias", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +fn tool_analyze_streaks() -> Value { + json!({ + "name": "analyze_streaks", + "description": "Analyze win/loss streaks with dates and current streak", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +fn tool_analyze_concurrent_peak() -> Value { + json!({ + "name": "analyze_concurrent_peak", + "description": "Find peak number of concurrent open positions", "inputSchema": { "type": "object", "properties": { diff --git a/src/tools/handlers/analysis.rs b/src/tools/handlers/analysis.rs index cd93041..730769e 100644 --- a/src/tools/handlers/analysis.rs +++ b/src/tools/handlers/analysis.rs @@ -1,16 +1,14 @@ use anyhow::Result; use serde_json::{json, Value}; +use std::collections::HashSet; use std::fs; use std::path::Path; use crate::analytics::DealAnalyzer; use crate::models::deals::Deal; use crate::models::metrics::Metrics; -pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result { - let report_dir = args.get("report_dir") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - +/// Helper to load deals and metrics from report directory +fn load_report_data(report_dir: &str) -> Result<(Vec, Metrics)> { let deals_csv = Path::new(report_dir).join("deals.csv"); let metrics_json = Path::new(report_dir).join("metrics.json"); @@ -27,11 +25,56 @@ pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, metrics) = load_report_data(report_dir)?; let analyzer = DealAnalyzer::new(); - let result = analyzer.analyze(&deals, &metrics); + + // Check if specific analytics requested + let requested: Option> = args.get("analytics") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect()); + + let top_losses_limit = args.get("top_losses_limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + + let all = requested.is_none(); + let req = |name: &str| all || requested.as_ref().map(|s| s.contains(name)).unwrap_or(false); + + // Build selective result + let mut result = json!({}); + + if req("monthly_pnl") || all { + result["monthly"] = json!(analyzer.monthly_pnl(&deals)); + } + if req("drawdown_events") || all { + result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); + } + if req("top_losses") || all { + result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); + } + if req("loss_sequences") || all { + result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); + } + if req("position_pairs") || all { + result["position_pairs"] = json!(analyzer.position_pairs(&deals)); + } + if req("direction_bias") || all { + result["direction_bias"] = json!(analyzer.direction_bias(&deals)); + } + if req("streak_analysis") || all { + result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); + } + if req("concurrent_peak") || all { + result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); + } let analysis_path = Path::new(report_dir).join("analysis.json"); fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?; @@ -40,6 +83,7 @@ pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result>()).unwrap_or_else(|| vec!["all".to_string()]), "summary": result, }).to_string() }], "isError": false @@ -113,5 +157,153 @@ pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.monthly_pnl(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "monthly_pnl": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, metrics) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.reconstruct_dd_events(&deals, &metrics); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "drawdown_events": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.top_losses(&deals, limit); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "limit": limit, + "top_losses": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.loss_sequences(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "loss_sequences": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.position_pairs(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "position_pairs": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.direction_bias(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "direction_bias": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.streak_analysis(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "streak_analysis": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.concurrent_peak(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "concurrent_peak": result, + }).to_string() }], + "isError": false + })) +} + // Import Config for analysis module use crate::models::Config; diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index 4fd16e3..c1de5d7 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -49,6 +49,14 @@ impl ToolHandler { // Analysis handlers "analyze_report" => analysis::handle_analyze_report(&self.config, args).await, + "analyze_monthly_pnl" => analysis::handle_analyze_monthly_pnl(&self.config, args).await, + "analyze_drawdown_events" => analysis::handle_analyze_drawdown_events(&self.config, args).await, + "analyze_top_losses" => analysis::handle_analyze_top_losses(&self.config, args).await, + "analyze_loss_sequences" => analysis::handle_analyze_loss_sequences(&self.config, args).await, + "analyze_position_pairs" => analysis::handle_analyze_position_pairs(&self.config, args).await, + "analyze_direction_bias" => analysis::handle_analyze_direction_bias(&self.config, args).await, + "analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await, + "analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await, "compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await, // Set file handlers