percli 0.2.0

Offline CLI simulator for the Percolator risk engine
use anyhow::{bail, Context, Result};
use percli_core::{EngineSnapshot, NamedEngine, POS_SCALE};

use crate::scenario::types::*;

pub struct RunOptions {
    pub check_conservation: bool,
    pub step_by_step: bool,
    pub verbose: bool,
}

pub struct RunResult {
    pub final_snapshot: EngineSnapshot,
    pub step_results: Vec<StepResult>,
}

pub struct StepResult {
    pub step_num: usize,
    pub description: String,
    pub outcome: StepOutcome,
    pub snapshot: Option<EngineSnapshot>,
    pub delta: Option<DeltaSnapshot>,
}

pub enum StepOutcome {
    Ok,
    Warning(String),
    QueryResult(EngineSnapshot),
    AssertPassed,
    AssertFailed(String),
}

pub struct DeltaSnapshot {
    pub vault_delta: i128,
    pub insurance_delta: i128,
    pub account_deltas: Vec<AccountDelta>,
}

pub struct AccountDelta {
    pub name: String,
    pub capital_delta: i128,
    pub equity_maint_delta: i128,
}

pub fn run_scenario(scenario: &Scenario, opts: &RunOptions) -> Result<RunResult> {
    let params = scenario.params.to_risk_params();
    let mut engine = NamedEngine::new(
        params,
        scenario.market.initial_slot,
        scenario.market.initial_oracle_price,
    );

    let mut step_results = Vec::new();

    for (i, step) in scenario.steps.iter().enumerate() {
        let step_num = i + 1;

        let before = if opts.verbose {
            Some(engine.snapshot())
        } else {
            None
        };

        let mut result = execute_step(&mut engine, step, step_num)?;

        if opts.check_conservation {
            let snap = engine.snapshot();
            if !snap.conservation {
                bail!("Conservation check FAILED after step {step_num}");
            }
        }

        if opts.step_by_step {
            result.snapshot = Some(engine.snapshot());
        }

        if let Some(before) = before {
            let after = engine.snapshot();
            result.delta = Some(compute_delta(&before, &after));
        }

        step_results.push(result);
    }

    let final_snapshot = engine.snapshot();
    Ok(RunResult {
        final_snapshot,
        step_results,
    })
}

fn execute_step(engine: &mut NamedEngine, step: &Step, step_num: usize) -> Result<StepResult> {
    match step {
        Step::Deposit {
            account,
            amount,
            comment,
        } => {
            let desc = format_desc("deposit", comment, &format!("{account} {amount}"));
            engine
                .deposit(account, *amount as u128)
                .with_context(|| format!("step {step_num}: deposit {account} {amount}"))?;
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::Withdraw {
            account,
            amount,
            comment,
        } => {
            let desc = format_desc("withdraw", comment, &format!("{account} {amount}"));
            engine
                .withdraw(account, *amount as u128)
                .with_context(|| format!("step {step_num}: withdraw {account} {amount}"))?;
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::Trade {
            long,
            short,
            size,
            price,
            comment,
        } => {
            let size_q = *size as i128 * POS_SCALE as i128;
            let desc = format_desc(
                "trade",
                comment,
                &format!("{long} long / {short} short x {size} @ {price}"),
            );
            engine
                .trade(long, short, size_q, *price)
                .with_context(|| format!("step {step_num}: trade {long}/{short}"))?;
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::Crank {
            oracle_price,
            slot,
            comment,
        } => {
            let desc = format_desc(
                "crank",
                comment,
                &format!("oracle={oracle_price} slot={slot}"),
            );
            engine
                .crank(*oracle_price, *slot)
                .with_context(|| format!("step {step_num}: crank"))?;

            let snap = engine.snapshot();
            let mut warnings = Vec::new();
            for acct in &snap.accounts {
                if !acct.above_maintenance_margin && acct.effective_position_q != 0 {
                    warnings.push(format!("{} below maintenance margin", acct.name));
                }
            }

            let outcome = if warnings.is_empty() {
                StepOutcome::Ok
            } else {
                StepOutcome::Warning(warnings.join("; "))
            };

            Ok(StepResult {
                step_num,
                description: desc,
                outcome,
                snapshot: None,
                delta: None,
            })
        }

        Step::Liquidate { account, comment } => {
            let desc = format_desc("liquidate", comment, account);
            let did_liq = engine
                .liquidate(account)
                .with_context(|| format!("step {step_num}: liquidate {account}"))?;
            let outcome = if did_liq {
                StepOutcome::Ok
            } else {
                StepOutcome::Warning(format!("{account} was not liquidatable"))
            };
            Ok(StepResult {
                step_num,
                description: desc,
                outcome,
                snapshot: None,
                delta: None,
            })
        }

        Step::Settle { account, comment } => {
            let desc = format_desc("settle", comment, account);
            engine
                .settle(account)
                .with_context(|| format!("step {step_num}: settle {account}"))?;
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::SetOracle {
            oracle_price,
            comment,
        } => {
            let desc = format_desc("set_oracle", comment, &oracle_price.to_string());
            engine
                .set_oracle(*oracle_price)
                .with_context(|| format!("step {step_num}: set_oracle {oracle_price}"))?;
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::SetSlot { slot, comment } => {
            let desc = format_desc("set_slot", comment, &slot.to_string());
            engine.set_slot(*slot);
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::SetFundingRate { rate, comment } => {
            let desc = format_desc("set_funding_rate", comment, &rate.to_string());
            engine.set_funding_rate(*rate);
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                snapshot: None,
                delta: None,
            })
        }

        Step::Query { metric, comment } => {
            let snap = engine.snapshot();
            let metric_str = format!("{metric:?}");
            let desc = format_desc("query", comment, &metric_str);
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::QueryResult(snap.clone()),
                snapshot: Some(snap),
                delta: None,
            })
        }

        Step::Assert { condition, comment } => {
            let snap = engine.snapshot();
            let cond_str = format!("{condition:?}");
            let desc = format_desc("assert", comment, &cond_str);
            let outcome = check_assert(&snap, condition);
            Ok(StepResult {
                step_num,
                description: desc,
                outcome,
                snapshot: None,
                delta: None,
            })
        }
    }
}

fn check_assert(snap: &EngineSnapshot, condition: &AssertCondition) -> StepOutcome {
    match condition {
        AssertCondition::Conservation => {
            if snap.conservation {
                StepOutcome::AssertPassed
            } else {
                StepOutcome::AssertFailed("conservation check failed".to_string())
            }
        }
        AssertCondition::HaircutBelow { threshold } => {
            let h = snap.haircut_ratio_f64();
            if h < *threshold {
                StepOutcome::AssertPassed
            } else {
                StepOutcome::AssertFailed(format!("haircut {h:.4} >= {threshold}"))
            }
        }
        AssertCondition::AboveMaintenanceMargin { account } => {
            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
                if acct.above_maintenance_margin {
                    StepOutcome::AssertPassed
                } else {
                    StepOutcome::AssertFailed(format!("{account} below maintenance margin"))
                }
            } else {
                StepOutcome::AssertFailed(format!("account {account} not found"))
            }
        }
        AssertCondition::BelowMaintenanceMargin { account } => {
            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
                if !acct.above_maintenance_margin {
                    StepOutcome::AssertPassed
                } else {
                    StepOutcome::AssertFailed(format!("{account} still above maintenance margin"))
                }
            } else {
                StepOutcome::AssertFailed(format!("account {account} not found"))
            }
        }
    }
}

fn compute_delta(before: &EngineSnapshot, after: &EngineSnapshot) -> DeltaSnapshot {
    let vault_delta = after.vault as i128 - before.vault as i128;
    let insurance_delta = after.insurance_fund as i128 - before.insurance_fund as i128;

    let mut account_deltas = Vec::new();
    for acct_after in &after.accounts {
        let before_acct = before.accounts.iter().find(|a| a.name == acct_after.name);
        let (cap_before, eq_before) = before_acct
            .map(|a| (a.capital as i128, a.equity_maint))
            .unwrap_or((0, 0));

        let cap_delta = acct_after.capital as i128 - cap_before;
        let eq_delta = acct_after.equity_maint - eq_before;

        if cap_delta != 0 || eq_delta != 0 {
            account_deltas.push(AccountDelta {
                name: acct_after.name.clone(),
                capital_delta: cap_delta,
                equity_maint_delta: eq_delta,
            });
        }
    }

    DeltaSnapshot {
        vault_delta,
        insurance_delta,
        account_deltas,
    }
}

fn format_desc(action: &str, comment: &Option<String>, detail: &str) -> String {
    match comment {
        Some(c) => format!("{action} {detail}{c}"),
        None => format!("{action} {detail}"),
    }
}