sim-cli 0.7.0

CLI tool for running and comparing Solana simulator backtests
Documentation
//! JSON and console rendering of a [`CompareReport`].

use std::collections::HashMap;

use console::style;
use eyre::Result;

use super::{
    CompareArgs, CompareSection,
    model::{
        CompareOutput, CompareReport, CompareSummary, ImprovementEntry, LogDiffEntry, PnlDiff,
        RegressionEntry,
    },
    symbols::resolve_symbol,
};

const DEFAULT_LIMIT: usize = 10;

pub(super) fn print_json(
    args: &CompareArgs,
    mut report: CompareReport,
    symbols: &HashMap<String, String>,
) -> Result<()> {
    // Resolve tickers into the owned report; only the balance sections
    // serialize them, but the in-place fill is cheaper than cloning the diffs.
    report
        .pnl_diffs
        .iter_mut()
        .flat_map(|d| d.tokens.iter_mut())
        .for_each(|t| t.symbol = resolve_symbol(&t.mint, symbols).to_string());

    let value = match args.section {
        Some(CompareSection::Regressions) => serde_json::to_value(&report.regressions)?,
        Some(CompareSection::Improvements) => serde_json::to_value(&report.improvements)?,
        Some(CompareSection::Logs) => serde_json::to_value(&report.log_diffs)?,
        Some(CompareSection::Balances) => serde_json::to_value(&report.pnl_diffs)?,
        None => {
            let summary = CompareSummary {
                regressions: report.regressions.len(),
                improvements: report.improvements.len(),
                log_diffs: report.log_diffs.len(),
                missing: report.missing.len(),
                balance_diffs: report.pnl_diffs.len(),
            };
            serde_json::to_value(CompareOutput {
                baseline: args.baseline.display().to_string(),
                experiment: args.experiment.display().to_string(),
                baseline_transactions: report.baseline_transactions,
                experiment_transactions: report.experiment_transactions,
                common_transactions: report.common,
                balance_diffs: report.pnl_diffs,
                regressions: report.regressions,
                improvements: report.improvements,
                log_diffs: report.log_diffs,
                missing: report.missing,
                summary,
            })?
        }
    };

    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}

pub(super) fn print_text(
    args: &CompareArgs,
    report: &CompareReport,
    symbols: &HashMap<String, String>,
) {
    println!(
        "Baseline ({}) versus Experiment ({})",
        args.baseline.display(),
        args.experiment.display(),
    );
    println!("Baseline transactions: {}", report.baseline_transactions);
    println!(
        "Experiment transactions: {}",
        report.experiment_transactions
    );
    println!("Common transactions: {}\n", report.common);

    match args.section {
        Some(CompareSection::Regressions) => {
            print_regressions(&report.regressions, None);
        }
        Some(CompareSection::Improvements) => {
            print_improvements(&report.improvements, None);
        }
        Some(CompareSection::Logs) => {
            print_log_diffs(&report.log_diffs, None);
        }
        Some(CompareSection::Balances) => {
            print_pnl_diffs(&report.pnl_diffs, None, symbols);
        }
        None => {
            if report.regressions.is_empty()
                && report.improvements.is_empty()
                && report.log_diffs.is_empty()
                && report.missing.is_empty()
                && report.pnl_diffs.is_empty()
            {
                println!("{} No differences found", style("").green());
            }

            print_regressions(&report.regressions, Some(DEFAULT_LIMIT));
            print_improvements(&report.improvements, Some(DEFAULT_LIMIT));
            print_log_diffs(&report.log_diffs, Some(DEFAULT_LIMIT));
            print_missing(&report.missing, Some(DEFAULT_LIMIT));
            print_pnl_diffs(&report.pnl_diffs, Some(DEFAULT_LIMIT), symbols);

            println!(
                "Summary: {} regressions, {} improvements, {} log diffs, {} missing, {} balance diffs",
                report.regressions.len(),
                report.improvements.len(),
                report.log_diffs.len(),
                report.missing.len(),
                report.pnl_diffs.len(),
            );
        }
    }
}

fn print_capped<T>(
    items: &[T],
    limit: Option<usize>,
    header: impl FnOnce(usize),
    render: impl Fn(&T),
    more_suffix: &str,
) {
    if items.is_empty() {
        return;
    }
    header(items.len());
    let shown = limit.map_or(items.len(), |l| l.min(items.len()));
    items[..shown].iter().for_each(render);
    if let Some(limit) = limit
        && items.len() > limit
    {
        println!("  … and {} more{more_suffix}", items.len() - limit);
    }
    println!();
}

fn print_regressions(regressions: &[RegressionEntry], limit: Option<usize>) {
    print_capped(
        regressions,
        limit,
        |count| {
            println!(
                "{} Regressions (success → failure): {count}",
                style("").red()
            )
        },
        |entry| {
            println!("  slot {}  {}", entry.slot, format(&entry.signature));
            if let Some(err) = &entry.error {
                println!("    error: {err}");
            }
        },
        " (use `sim compare regressions` to view all)",
    );
}

fn print_improvements(improvements: &[ImprovementEntry], limit: Option<usize>) {
    print_capped(
        improvements,
        limit,
        |count| {
            println!(
                "{} Improvements (failure → success): {count}",
                style("").green()
            )
        },
        |entry| {
            println!("  slot {}  {}", entry.slot, format(&entry.signature));
            if let Some(err) = &entry.was {
                println!("    was: {err}");
            }
        },
        " (use `sim compare improvements` to view all)",
    );
}

fn print_log_diffs(log_diffs: &[LogDiffEntry], limit: Option<usize>) {
    print_capped(
        log_diffs,
        limit,
        |count| println!("{} Log differences: {count}", style("~").yellow()),
        |entry| {
            println!(
                "  slot {}  {}  ({} baseline logs → {} modified logs)",
                entry.slot,
                format(&entry.signature),
                entry.baseline_log_count,
                entry.experiment_log_count,
            );
        },
        " (use `sim compare logs` to view all)",
    );
}

fn print_missing(missing: &[String], limit: Option<usize>) {
    print_capped(
        missing,
        limit,
        |count| println!("{} Missing from experiment: {count}", style("?").dim()),
        |signature| println!("  {}", format(signature)),
        "",
    );
}

/// Render the percent change from baseline to experiment delta, or `new` when
/// the baseline delta is zero (a balance change absent from the baseline).
pub(super) fn format_pct_change(baseline: i64, experiment: i64) -> String {
    if baseline == 0 {
        return "new".to_string();
    }
    format!(
        "{:+.4}%",
        (experiment - baseline) as f64 / baseline.unsigned_abs() as f64 * 100.0
    )
}

fn print_pnl_diffs(pnl_diffs: &[PnlDiff], limit: Option<usize>, symbols: &HashMap<String, String>) {
    print_capped(
        pnl_diffs,
        limit,
        |count| println!("{} Balance differences: {count}", style("$").cyan()),
        |diff| {
            println!("  slot {}  {}", diff.slot, format(&diff.signature));
            for d in &diff.sol {
                let pct = format_pct_change(d.baseline_delta, d.experiment_delta);
                println!(
                    "    SOL  {}  {:+.9}{:+.9}  ({pct})",
                    format(&d.pubkey),
                    d.baseline_delta as f64 / 1e9,
                    d.experiment_delta as f64 / 1e9,
                );
            }
            for d in &diff.tokens {
                let scale = 10f64.powi(d.decimals as i32);
                let prec = d.decimals as usize;
                let ticker = resolve_symbol(&d.mint, symbols);
                let pct = format_pct_change(d.baseline_delta, d.experiment_delta);
                println!(
                    "    {ticker}  {}  {:+.prec$} → {:+.prec$}  ({pct})",
                    format(&d.pubkey),
                    d.baseline_delta as f64 / scale,
                    d.experiment_delta as f64 / scale,
                    prec = prec,
                );
            }
            println!();
        },
        " (use `sim compare balances` to view all)",
    );
}

fn format(signature: &str) -> String {
    let n = signature.len();
    if n <= 16 {
        return signature.to_string();
    }
    format!("{}{}", &signature[..8], &signature[n - 8..])
}