Skip to main content

simulator_client/
pnl.rs

1use simulator_api::{AgentParams, AgentStatsReport};
2use solana_address::Address;
3use spl_associated_token_account_interface::program::id as ata_program_id;
4use spl_token_2022_interface::inline_spl_token::id as token_program_id;
5use tracing::warn;
6
7/// Print a PnL summary for each agent, comparing final on-chain balances against
8/// the seed amounts.  Optionally includes opportunity/success-rate stats when
9/// `agent_stats` is provided.
10pub async fn report_agent_pnl(
11    rpc_url: &str,
12    agents_params: &[AgentParams],
13    agent_stats: Option<&[AgentStatsReport]>,
14) {
15    if rpc_url.is_empty() {
16        return;
17    }
18    let http = reqwest::Client::new();
19
20    println!();
21    println!("=== Agent PnL Summary ===");
22
23    for (i, params) in agents_params.iter().enumerate() {
24        let Some(ref wallet_str) = params.wallet else {
25            continue;
26        };
27        let Ok(wallet) = wallet_str.parse::<Address>() else {
28            continue;
29        };
30
31        let seed_lamports = params.seed_sol_lamports.unwrap_or(1_000_000_000);
32        let final_sol = rpc_get_balance(&http, rpc_url, &wallet).await;
33        let sol_pnl = final_sol as i64 - seed_lamports as i64;
34
35        println!("Agent {i} ({wallet_str}):");
36        println!(
37            "  SOL PnL:  {:+.9} ({:+} lamports)",
38            sol_pnl as f64 / 1e9,
39            sol_pnl
40        );
41
42        for (mint_str, seeded_amount) in &params.seed_token_accounts {
43            let Ok(mint) = mint_str.parse::<Address>() else {
44                continue;
45            };
46            let (ata, _) = Address::find_program_address(
47                &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()],
48                &ata_program_id(),
49            );
50            let final_balance = rpc_get_token_balance(&http, rpc_url, &ata).await;
51            let pnl = final_balance as i64 - *seeded_amount as i64;
52            let short_mint = &mint_str[..8.min(mint_str.len())];
53            println!("  {short_mint}.. PnL: {pnl:+} base units");
54        }
55
56        if let Some(stats) = agent_stats.and_then(|s| s.get(i)) {
57            println!(
58                "  Opportunities: {} found, {} skipped, {} no routes",
59                stats.opportunities_found, stats.opportunities_skipped, stats.no_routes,
60            );
61            println!("  Txs produced: {}", stats.txs_produced);
62
63            let total_txs = stats.txs_submitted + stats.txs_failed;
64            if total_txs > 0 {
65                let tx_success_rate = stats.txs_submitted as f64 / total_txs as f64 * 100.0;
66                println!(
67                    "  Tx execution: {} submitted, {} failed ({:.1}% success rate)",
68                    stats.txs_submitted, stats.txs_failed, tx_success_rate,
69                );
70            }
71
72            if stats.txs_simulation_rejected > 0 || stats.txs_simulation_failed > 0 {
73                println!(
74                    "  Preflight: {} rejected (unprofitable), {} sim errors",
75                    stats.txs_simulation_rejected, stats.txs_simulation_failed,
76                );
77            }
78
79            if !stats.expected_gain_by_mint.is_empty() {
80                println!("  Expected PnL (from quotes):");
81                for (mint, gain) in &stats.expected_gain_by_mint {
82                    let short_mint = &mint[..8.min(mint.len())];
83                    println!("    {short_mint}..: {gain:+} base units");
84                }
85            }
86        }
87    }
88
89    println!("=========================");
90}
91
92async fn rpc_get_balance(http: &reqwest::Client, rpc_url: &str, address: &Address) -> u64 {
93    let Ok(resp) = http
94        .post(rpc_url)
95        .json(&serde_json::json!({
96            "jsonrpc": "2.0", "id": 1,
97            "method": "getBalance",
98            "params": [address.to_string()]
99        }))
100        .send()
101        .await
102    else {
103        warn!("getBalance request failed for {address}");
104        return 0;
105    };
106    let Ok(json) = resp.json::<serde_json::Value>().await else {
107        warn!("getBalance response not JSON for {address}");
108        return 0;
109    };
110    json["result"]["value"].as_u64().unwrap_or(0)
111}
112
113async fn rpc_get_token_balance(http: &reqwest::Client, rpc_url: &str, ata: &Address) -> u64 {
114    let Ok(resp) = http
115        .post(rpc_url)
116        .json(&serde_json::json!({
117            "jsonrpc": "2.0", "id": 1,
118            "method": "getTokenAccountBalance",
119            "params": [ata.to_string()]
120        }))
121        .send()
122        .await
123    else {
124        warn!("getTokenAccountBalance request failed for {ata}");
125        return 0;
126    };
127    let Ok(json) = resp.json::<serde_json::Value>().await else {
128        warn!("getTokenAccountBalance response not JSON for {ata}");
129        return 0;
130    };
131    json["result"]["value"]["amount"]
132        .as_str()
133        .and_then(|s| s.parse().ok())
134        .unwrap_or(0)
135}