simulator-client 0.7.0

Async WebSocket client for the Solana simulator backtest API
Documentation
use simulator_api::{AgentParams, AgentStatsReport};
use solana_address::Address;
use spl_associated_token_account_interface::program::id as ata_program_id;
use spl_token_2022_interface::inline_spl_token::id as token_program_id;
use tracing::warn;

/// Print a PnL summary for each agent, comparing final on-chain balances against
/// the seed amounts.  Optionally includes opportunity/success-rate stats when
/// `agent_stats` is provided.
pub async fn report_agent_pnl(
    rpc_url: &str,
    agents_params: &[AgentParams],
    agent_stats: Option<&[AgentStatsReport]>,
) {
    if rpc_url.is_empty() {
        return;
    }
    let http = reqwest::Client::new();

    println!();
    println!("=== Agent PnL Summary ===");

    for (i, params) in agents_params.iter().enumerate() {
        let Some(ref wallet_str) = params.wallet else {
            continue;
        };
        let Ok(wallet) = wallet_str.parse::<Address>() else {
            continue;
        };

        let seed_lamports = params.seed_sol_lamports.unwrap_or(1_000_000_000);
        let final_sol = rpc_get_balance(&http, rpc_url, &wallet).await;
        let sol_pnl = final_sol as i64 - seed_lamports as i64;

        println!("Agent {i} ({wallet_str}):");
        println!(
            "  SOL PnL:  {:+.9} ({:+} lamports)",
            sol_pnl as f64 / 1e9,
            sol_pnl
        );

        for (mint_str, seeded_amount) in &params.seed_token_accounts {
            let Ok(mint) = mint_str.parse::<Address>() else {
                continue;
            };
            let (ata, _) = Address::find_program_address(
                &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()],
                &ata_program_id(),
            );
            let final_balance = rpc_get_token_balance(&http, rpc_url, &ata).await;
            let pnl = final_balance as i64 - *seeded_amount as i64;
            let short_mint = &mint_str[..8.min(mint_str.len())];
            println!("  {short_mint}.. PnL: {pnl:+} base units");
        }

        if let Some(stats) = agent_stats.and_then(|s| s.get(i)) {
            println!(
                "  Opportunities: {} found, {} skipped, {} no routes",
                stats.opportunities_found, stats.opportunities_skipped, stats.no_routes,
            );
            println!("  Txs produced: {}", stats.txs_produced);

            let total_txs = stats.txs_submitted + stats.txs_failed;
            if total_txs > 0 {
                let tx_success_rate = stats.txs_submitted as f64 / total_txs as f64 * 100.0;
                println!(
                    "  Tx execution: {} submitted, {} failed ({:.1}% success rate)",
                    stats.txs_submitted, stats.txs_failed, tx_success_rate,
                );
            }

            if stats.txs_simulation_rejected > 0 || stats.txs_simulation_failed > 0 {
                println!(
                    "  Preflight: {} rejected (unprofitable), {} sim errors",
                    stats.txs_simulation_rejected, stats.txs_simulation_failed,
                );
            }

            if !stats.expected_gain_by_mint.is_empty() {
                println!("  Expected PnL (from quotes):");
                for (mint, gain) in &stats.expected_gain_by_mint {
                    let short_mint = &mint[..8.min(mint.len())];
                    println!("    {short_mint}..: {gain:+} base units");
                }
            }
        }
    }

    println!("=========================");
}

async fn rpc_get_balance(http: &reqwest::Client, rpc_url: &str, address: &Address) -> u64 {
    let Ok(resp) = http
        .post(rpc_url)
        .json(&serde_json::json!({
            "jsonrpc": "2.0", "id": 1,
            "method": "getBalance",
            "params": [address.to_string()]
        }))
        .send()
        .await
    else {
        warn!("getBalance request failed for {address}");
        return 0;
    };
    let Ok(json) = resp.json::<serde_json::Value>().await else {
        warn!("getBalance response not JSON for {address}");
        return 0;
    };
    json["result"]["value"].as_u64().unwrap_or(0)
}

async fn rpc_get_token_balance(http: &reqwest::Client, rpc_url: &str, ata: &Address) -> u64 {
    let Ok(resp) = http
        .post(rpc_url)
        .json(&serde_json::json!({
            "jsonrpc": "2.0", "id": 1,
            "method": "getTokenAccountBalance",
            "params": [ata.to_string()]
        }))
        .send()
        .await
    else {
        warn!("getTokenAccountBalance request failed for {ata}");
        return 0;
    };
    let Ok(json) = resp.json::<serde_json::Value>().await else {
        warn!("getTokenAccountBalance response not JSON for {ata}");
        return 0;
    };
    json["result"]["value"]["amount"]
        .as_str()
        .and_then(|s| s.parse().ok())
        .unwrap_or(0)
}