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
This commit is contained in:
@@ -34,7 +34,7 @@ impl DealAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
fn monthly_pnl(&self, deals: &[Deal]) -> Vec<MonthlyPnl> {
|
||||
pub fn monthly_pnl(&self, deals: &[Deal]) -> Vec<MonthlyPnl> {
|
||||
let mut monthly: HashMap<String, (f64, i32)> = HashMap::new();
|
||||
|
||||
for deal in deals {
|
||||
@@ -71,7 +71,7 @@ impl DealAnalyzer {
|
||||
result
|
||||
}
|
||||
|
||||
fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec<DrawdownEvent> {
|
||||
pub fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec<DrawdownEvent> {
|
||||
let mut balance_curve = Vec::new();
|
||||
let mut peak_balance: f64 = 0.0;
|
||||
let mut initial_balance: Option<f64> = None;
|
||||
@@ -178,7 +178,7 @@ impl DealAnalyzer {
|
||||
event
|
||||
}
|
||||
|
||||
fn top_losses(&self, deals: &[Deal], n: usize) -> Vec<LossEntry> {
|
||||
pub fn top_losses(&self, deals: &[Deal], n: usize) -> Vec<LossEntry> {
|
||||
let mut losses: Vec<LossEntry> = deals
|
||||
.iter()
|
||||
.filter(|d| d.profit < 0.0)
|
||||
@@ -196,7 +196,7 @@ impl DealAnalyzer {
|
||||
losses
|
||||
}
|
||||
|
||||
fn loss_sequences(&self, deals: &[Deal]) -> Vec<LossSequence> {
|
||||
pub fn loss_sequences(&self, deals: &[Deal]) -> Vec<LossSequence> {
|
||||
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<PositionPair> {
|
||||
pub fn position_pairs(&self, deals: &[Deal]) -> Vec<PositionPair> {
|
||||
let mut open_pos: HashMap<String, &Deal> = HashMap::new();
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
@@ -278,7 +278,7 @@ impl DealAnalyzer {
|
||||
pairs
|
||||
}
|
||||
|
||||
fn direction_bias(&self, deals: &[Deal]) -> HashMap<String, DirectionStats> {
|
||||
pub fn direction_bias(&self, deals: &[Deal]) -> HashMap<String, DirectionStats> {
|
||||
let mut stats: HashMap<String, (i32, i32, f64)> = 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<chrono::Utc>, i32, &Deal)> = Vec::new();
|
||||
|
||||
for deal in deals {
|
||||
|
||||
+123
-1
@@ -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": {
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Deal>, 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<Val
|
||||
Metrics::default()
|
||||
};
|
||||
|
||||
let _strategy = args.get("strategy").and_then(|v| v.as_str()).unwrap_or("grid");
|
||||
let _deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
Ok((deals, metrics))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<HashSet<String>> = 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<Val
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).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<V
|
||||
}))
|
||||
}
|
||||
|
||||
// === Granular Analytics Handlers ===
|
||||
|
||||
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user