tesser_cli/
analyze.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use csv::Writer;
7use rust_decimal::Decimal;
8use serde::Serialize;
9use tesser_data::analytics::{
10    analyze_execution, ExecutionAnalysisRequest, ExecutionReport, ExecutionStats,
11};
12
13/// Runs the execution analysis workflow and renders a textual report.
14pub fn run_execution(request: ExecutionAnalysisRequest, export_csv: Option<&Path>) -> Result<()> {
15    let report = analyze_execution(&request)?;
16    if let Some(path) = export_csv {
17        export_report(&report, path)?;
18    }
19    render_report(&report);
20    Ok(())
21}
22
23fn render_report(report: &ExecutionReport) {
24    println!("=== Execution Analysis ===");
25    println!(
26        "Period        : {} -> {}",
27        format_period(report.period_start),
28        format_period(report.period_end)
29    );
30    println!(
31        "Orders        : {} analyzed ({} skipped)",
32        report.totals.order_count, report.skipped_orders
33    );
34    println!("Fills         : {}", report.totals.fill_count);
35    println!(
36        "Arrival Cover : {} ({})",
37        report.totals.orders_with_arrival,
38        arrival_percent(&report.totals)
39    );
40    if report.totals.order_count == 0 {
41        println!("No executions found for the requested window.");
42        return;
43    }
44
45    println!(
46        "Volume        : {}",
47        format_decimal(&report.totals.filled_quantity, 4)
48    );
49    println!(
50        "Notional      : {}",
51        format_decimal(&report.totals.notional, 2)
52    );
53    println!(
54        "Fees          : {}",
55        format_decimal(&report.totals.total_fees, 6)
56    );
57    println!(
58        "Shortfall     : {}",
59        format_decimal(&report.totals.implementation_shortfall, 6)
60    );
61    println!(
62        "Avg Slippage  : {} bps",
63        format_optional(report.totals.avg_slippage_bps, 2)
64    );
65
66    if report.per_algo.is_empty() {
67        return;
68    }
69
70    println!("\nAlgo breakdown:");
71    println!(
72        "{:<12} {:>8} {:>12} {:>14} {:>12} {:>14} {:>14}",
73        "Algo", "Orders", "Quantity", "Notional", "Fees", "Shortfall", "Slippage(bps)"
74    );
75    for stats in &report.per_algo {
76        println!(
77            "{:<12} {:>8} {:>12} {:>14} {:>12} {:>14} {:>14}",
78            stats.label,
79            stats.order_count,
80            format_decimal(&stats.filled_quantity, 4),
81            format_decimal(&stats.notional, 2),
82            format_decimal(&stats.total_fees, 6),
83            format_decimal(&stats.implementation_shortfall, 6),
84            format_optional(stats.avg_slippage_bps, 2)
85        );
86    }
87}
88
89fn format_period(ts: Option<DateTime<Utc>>) -> String {
90    ts.map(|value| value.to_rfc3339())
91        .unwrap_or_else(|| "-".to_string())
92}
93
94fn format_decimal(value: &Decimal, scale: u32) -> String {
95    if value.is_zero() {
96        return "0".to_string();
97    }
98    value.round_dp(scale).normalize().to_string()
99}
100
101fn format_optional(value: Option<Decimal>, scale: u32) -> String {
102    value
103        .map(|v| format_decimal(&v, scale))
104        .unwrap_or_else(|| "-".to_string())
105}
106
107fn arrival_percent(stats: &ExecutionStats) -> String {
108    if stats.order_count == 0 {
109        return "-".to_string();
110    }
111    let pct = stats.orders_with_arrival as f64 / stats.order_count as f64 * 100.0;
112    format!("{pct:.0}%")
113}
114
115fn decimal_raw(value: &Decimal) -> String {
116    if value.is_zero() {
117        return "0".to_string();
118    }
119    value.normalize().to_string()
120}
121
122fn optional_decimal_raw(value: Option<Decimal>) -> String {
123    value.map(|v| decimal_raw(&v)).unwrap_or_default()
124}
125
126fn export_report(report: &ExecutionReport, output: &Path) -> Result<()> {
127    if let Some(parent) = output.parent() {
128        fs::create_dir_all(parent)
129            .with_context(|| format!("failed to create directory {}", parent.display()))?;
130    }
131    let mut writer = Writer::from_path(output)
132        .with_context(|| format!("failed to create {}", output.display()))?;
133    let rows = std::iter::once(&report.totals).chain(report.per_algo.iter());
134    for stats in rows {
135        let row = ExecutionCsvRow {
136            label: &stats.label,
137            order_count: stats.order_count,
138            fill_count: stats.fill_count,
139            orders_with_arrival: stats.orders_with_arrival,
140            filled_quantity: decimal_raw(&stats.filled_quantity),
141            notional: decimal_raw(&stats.notional),
142            total_fees: decimal_raw(&stats.total_fees),
143            implementation_shortfall: decimal_raw(&stats.implementation_shortfall),
144            avg_slippage_bps: optional_decimal_raw(stats.avg_slippage_bps),
145        };
146        writer.serialize(row)?;
147    }
148    writer.flush()?;
149    Ok(())
150}
151
152#[derive(Serialize)]
153struct ExecutionCsvRow<'a> {
154    label: &'a str,
155    order_count: usize,
156    fill_count: usize,
157    orders_with_arrival: usize,
158    filled_quantity: String,
159    notional: String,
160    total_fees: String,
161    implementation_shortfall: String,
162    avg_slippage_bps: String,
163}