sim-cli 0.9.0

CLI tool for running and comparing Solana simulator backtests
Documentation
use std::time::Duration;

use clap::Parser;
use eyre::Result;
use sim_cli::ApiKeyArg;
use simulator_api::{BundleBuildRequest, BundleBuildStatus, BundleBuildStatusResponse};
use simulator_client::BacktestClient;

#[derive(Parser, Debug)]
pub struct BuildBundleArgs {
    /// First slot of the range to build (inclusive).
    pub start_slot: u64,
    /// Last slot of the range to build (inclusive).
    pub end_slot: u64,

    #[command(flatten)]
    pub api_key: ApiKeyArg,

    /// Slots per bundle. Omit to use the pipeline default of 50,000 slots.
    #[arg(long)]
    pub bundle_size: Option<u64>,

    /// Dedupe key: resubmitting the same key returns the existing build instead
    /// of triggering (or re-charging for) a new one.
    #[arg(long)]
    pub idempotency_key: Option<String>,

    /// Poll until the build reaches a terminal state instead of returning on submit.
    #[arg(long)]
    pub wait: bool,

    /// Seconds between status polls when `--wait` is set.
    #[arg(long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
    pub poll_secs: u64,

    /// Print the (final) status response as JSON.
    #[arg(long)]
    pub json: bool,
}

pub async fn build_bundle(args: BuildBundleArgs, url: String) -> Result<()> {
    let client = BacktestClient::builder()
        .url(url)
        .api_key(args.api_key.get())
        .build();
    let mut resp = client
        .build_bundle(&BundleBuildRequest {
            start_slot: args.start_slot,
            end_slot: args.end_slot,
            bundle_size: args.bundle_size,
            idempotency_key: args.idempotency_key,
        })
        .await?;
    if !args.json {
        println!("{}", format_status("issued", &resp));
    }

    if args.wait {
        while matches!(resp.status, BundleBuildStatus::InProgress) {
            tokio::time::sleep(Duration::from_secs(args.poll_secs)).await;
            resp = client.get_bundle_status(&resp.request_id).await?;
            if !args.json {
                println!("{}", format_status("status", &resp));
            }
        }
    }

    if args.json {
        println!("{}", serde_json::to_string_pretty(&resp)?);
    }
    Ok(())
}

#[derive(Parser, Debug)]
pub struct BuildStatusArgs {
    /// Request id returned by `build-bundle`.
    pub request_id: String,

    #[command(flatten)]
    pub api_key: ApiKeyArg,

    /// Print the status response as JSON.
    #[arg(long)]
    pub json: bool,
}

pub async fn get_status(args: BuildStatusArgs, url: String) -> Result<()> {
    let client = BacktestClient::builder()
        .url(url)
        .api_key(args.api_key.get())
        .build();
    let resp = client.get_bundle_status(&args.request_id).await?;
    if args.json {
        println!("{}", serde_json::to_string_pretty(&resp)?);
    } else {
        println!("{}", format_status("status", &resp));
    }
    Ok(())
}

fn format_status(label: &str, resp: &BundleBuildStatusResponse) -> String {
    let size = resp
        .bundle_size
        .map_or_else(|| "default".to_string(), |s| s.to_string());
    // Best-effort ETA, only while the build is still running.
    let eta = match resp.status {
        BundleBuildStatus::InProgress => format!(
            " eta {}",
            format_duration(estimated_build_secs(resp.start_slot, resp.end_slot))
        ),
        _ => String::new(),
    };
    format!(
        "{label}: {} [{}] slots {}..={} size={size}{eta}",
        resp.request_id,
        resp.status.as_str(),
        resp.start_slot,
        resp.end_slot,
    )
}

/// Best-effort estimate of total build time for a slot range, from the
/// pipeline's rough phase costs: snapshot processing (~30m) + incremental build
/// (~0.6s/slot) + bundle build (~0.01s/slot). Coarse and slot-count-based (not a
/// live countdown), so `--wait` users get a sense of scale rather than a precise
/// remaining time.
fn estimated_build_secs(start_slot: u64, end_slot: u64) -> u64 {
    let slots = end_slot.saturating_sub(start_slot) as f64;
    let snapshot_secs = 30 * 60;
    snapshot_secs + (slots * 0.6) as u64 + (slots * 0.01) as u64
}

fn format_duration(secs: u64) -> String {
    let (hours, minutes) = (secs / 3600, (secs % 3600) / 60);
    if hours > 0 {
        format!("~{hours}h{minutes:02}m")
    } else {
        format!("~{minutes}m")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_positional_range_and_flags() {
        let args = BuildBundleArgs::try_parse_from([
            "build-bundle",
            "100",
            "200",
            "--api-key",
            "test-key",
            "--bundle-size",
            "500",
            "--wait",
        ])
        .unwrap();
        assert_eq!((args.start_slot, args.end_slot), (100, 200));
        assert_eq!(args.bundle_size, Some(500));
        assert!(args.wait);
    }

    #[test]
    fn poll_secs_rejects_zero() {
        assert!(
            BuildBundleArgs::try_parse_from([
                "build-bundle",
                "1",
                "2",
                "--api-key",
                "k",
                "--wait",
                "--poll-secs",
                "0",
            ])
            .is_err()
        );
    }

    #[test]
    fn formats_status_with_default_size() {
        let resp = BundleBuildStatusResponse {
            request_id: "req-1".to_string(),
            start_slot: 1,
            end_slot: 2,
            bundle_size: None,
            status: BundleBuildStatus::InProgress,
        };
        let line = format_status("issued", &resp);
        assert!(line.contains("req-1"));
        assert!(line.contains("in_progress"));
        assert!(line.contains("size=default"));
    }
}