percli 0.1.0

Offline CLI simulator for the Percolator risk engine
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Table, Cell, Attribute};
use percli_core::{EngineSnapshot, POS_SCALE};

pub fn print_snapshot(snap: &EngineSnapshot) {
    print_vault_table(snap);
    println!();
    if !snap.accounts.is_empty() {
        print_accounts_table(snap);
    }
}

fn print_vault_table(snap: &EngineSnapshot) {
    let h = snap.haircut_ratio_f64();
    let h_str = if h >= 1.0 {
        "1.000 (no stress)".to_string()
    } else {
        format!("{h:.4} (stressed)")
    };

    let conservation_str = if snap.conservation { "PASS" } else { "FAIL" };

    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL)
        .apply_modifier(UTF8_ROUND_CORNERS)
        .set_header(vec![
            Cell::new("Vault Summary").add_attribute(Attribute::Bold),
            Cell::new(""),
        ]);

    table.add_row(vec!["Vault Balance", &format_u128(snap.vault)]);
    table.add_row(vec!["Insurance Fund", &format_u128(snap.insurance_fund)]);
    table.add_row(vec!["Haircut Ratio (h)", &h_str]);
    table.add_row(vec![
        "Conservation",
        conservation_str,
    ]);
    table.add_row(vec!["Oracle Price", &snap.last_oracle_price.to_string()]);
    table.add_row(vec!["Slot", &snap.current_slot.to_string()]);
    table.add_row(vec![
        "OI Long (q)",
        &format_pos_q(snap.oi_long_q as i128),
    ]);
    table.add_row(vec![
        "OI Short (q)",
        &format_pos_q(snap.oi_short_q as i128),
    ]);
    table.add_row(vec!["Side Mode Long", &snap.side_mode_long]);
    table.add_row(vec!["Side Mode Short", &snap.side_mode_short]);

    if snap.lifetime_liquidations > 0 {
        table.add_row(vec![
            "Lifetime Liquidations",
            &snap.lifetime_liquidations.to_string(),
        ]);
    }

    println!("{table}");
}

fn print_accounts_table(snap: &EngineSnapshot) {
    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL)
        .apply_modifier(UTF8_ROUND_CORNERS)
        .set_header(vec![
            Cell::new("Account").add_attribute(Attribute::Bold),
            Cell::new("Capital"),
            Cell::new("PnL"),
            Cell::new("Reserved"),
            Cell::new("Equity(M)"),
            Cell::new("Equity(I)"),
            Cell::new("Pos(eff)"),
            Cell::new("Notional"),
            Cell::new("MM"),
            Cell::new("IM"),
        ]);

    for acct in &snap.accounts {
        let mm_str = if acct.effective_position_q == 0 {
            "-".to_string()
        } else if acct.above_maintenance_margin {
            "OK".to_string()
        } else {
            "BELOW".to_string()
        };

        let im_str = if acct.effective_position_q == 0 {
            "-".to_string()
        } else if acct.above_initial_margin {
            "OK".to_string()
        } else {
            "BELOW".to_string()
        };

        table.add_row(vec![
            acct.name.clone(),
            format_u128(acct.capital),
            format_i128(acct.pnl),
            format_u128(acct.reserved_pnl),
            format_i128(acct.equity_maint),
            format_i128(acct.equity_init),
            format_pos_q(acct.effective_position_q),
            format_u128(acct.notional),
            mm_str,
            im_str,
        ]);
    }

    println!("{table}");
}

fn format_u128(val: u128) -> String {
    let s = val.to_string();
    add_thousands_sep(&s)
}

fn format_i128(val: i128) -> String {
    if val < 0 {
        let s = (-val).to_string();
        format!("-{}", add_thousands_sep(&s))
    } else {
        let s = val.to_string();
        add_thousands_sep(&s)
    }
}

fn format_pos_q(q: i128) -> String {
    let whole = q / POS_SCALE as i128;
    let frac = (q % POS_SCALE as i128).unsigned_abs();
    if frac == 0 {
        format!("{whole}.0")
    } else {
        format!("{whole}.{frac:06}")
    }
}

fn add_thousands_sep(s: &str) -> String {
    let bytes: Vec<char> = s.chars().collect();
    let mut result = String::new();
    for (i, c) in bytes.iter().enumerate() {
        if i > 0 && (bytes.len() - i) % 3 == 0 {
            result.push(',');
        }
        result.push(*c);
    }
    result
}