sim-cli 0.6.0

CLI tool for running and comparing Solana simulator backtests
use std::{
    collections::{BTreeMap, HashMap, HashSet},
    path::PathBuf,
};

use chrono::{DateTime, Utc};
use clap::Parser;
use console::style;
use eyre::Result;

use crate::output::load_simulation_output;

#[derive(Parser, Debug)]
pub struct SummarizeArgs {
    /// Simulation output file to summarize
    pub file: PathBuf,
    /// Comma-separated list of wallet addresses to trace balances across all transactions
    #[arg(long, value_delimiter = ',')]
    pub accounts: Vec<String>,
}

// Opening and closing balance for one token/SOL across the full simulation run.
struct BalanceTrace {
    opening: u64,
    closing: u64,
    decimals: u8,
}

pub fn summarize(args: SummarizeArgs) -> Result<()> {
    let mut output = load_simulation_output(&args.file)?;
    // Opening/closing balance walk assumes (slot, signature) ordering — restore
    // it explicitly since NDJSON output is in arrival order.
    output
        .transactions
        .sort_by(|a, b| a.slot.cmp(&b.slot).then(a.signature.cmp(&b.signature)));

    let m = &output.metadata;
    println!("File         : {}", args.file.display());
    println!("Slot range   : {}{}", m.start_slot, m.end_slot);
    if !m.program_ids.is_empty() {
        println!("Program IDs  : {}", m.program_ids.join(", "));
    }
    if !m.program_so.is_empty() {
        println!("Program.so   : {}", m.program_so.join(", "));
    }
    println!("Session(s)   : {}", m.session_ids.len());
    let ran_at = DateTime::<Utc>::from_timestamp(m.ran_at_unix_secs as i64, 0)
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
        .unwrap_or_else(|| format!("{} (unix)", m.ran_at_unix_secs));
    println!("Ran at       : {ran_at}");
    println!();

    let s = &output.summary;
    println!(
        "{} Transactions : {}  ({} successes, {} failures)",
        style("i").blue(),
        s.total_transactions,
        s.successes,
        s.failures,
    );

    if args.accounts.is_empty() {
        return Ok(());
    }

    let tracked: HashSet<&str> = args.accounts.iter().map(|s| s.as_str()).collect();

    // Per wallet: "SOL" or mint -> BalanceTrace
    // Transactions are already in slot order, so the first pre we see is the opening
    // balance and we keep overwriting closing with the latest post.
    let mut traces: HashMap<&str, BTreeMap<String, BalanceTrace>> =
        tracked.iter().map(|a| (*a, BTreeMap::new())).collect();

    for tx in &output.transactions {
        for sc in &tx.sol_changes {
            if let Some(wallet) = tracked.get(sc.pubkey.as_str()) {
                let entry = traces
                    .get_mut(wallet)
                    .unwrap()
                    .entry("SOL".to_string())
                    .or_insert(BalanceTrace {
                        opening: sc.pre_lamports,
                        closing: sc.post_lamports,
                        decimals: 9,
                    });
                entry.closing = sc.post_lamports;
            }
        }

        for tc in &tx.token_changes {
            if let Some(wallet) = tracked.get(tc.owner.as_str()) {
                let entry = traces
                    .get_mut(wallet)
                    .unwrap()
                    .entry(tc.mint.clone())
                    .or_insert(BalanceTrace {
                        opening: tc.pre_amount,
                        closing: tc.post_amount,
                        decimals: tc.decimals,
                    });
                entry.closing = tc.post_amount;
            }
        }
    }

    println!();
    for account in &args.accounts {
        println!("Account: {account}");
        let wallet_traces = &traces[account.as_str()];
        if wallet_traces.is_empty() {
            println!("  (no activity found)");
        }
        for (label, t) in wallet_traces {
            let dec = t.decimals as usize;
            let div = 10u64.pow(t.decimals as u32) as f64;
            let open = t.opening as f64 / div;
            let close = t.closing as f64 / div;
            let delta = close - open;
            println!("  {label:<50}  {open:.dec$} → {close:.dec$}  (Δ {delta:+.dec$})");
        }
        println!();
    }

    Ok(())
}