percli 0.1.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 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 enum StepOutcome {
    Ok,
    Warning(String),
    QueryResult(EngineSnapshot),
    AssertPassed,
    AssertFailed(String),
}

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 result = execute_step(&mut engine, step, step_num, opts)?;

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

        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,
    _opts: &RunOptions,
) -> 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,
            })
        }

        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,
            })
        }

        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,
            })
        }

        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"))?;

            // Check for accounts below maintenance margin
            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,
            })
        }

        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,
            })
        }

        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,
            })
        }

        Step::SetOracle { oracle_price, comment } => {
            let desc = format_desc("set_oracle", comment, &oracle_price.to_string());
            engine.set_oracle(*oracle_price);
            Ok(StepResult {
                step_num,
                description: desc,
                outcome: StepOutcome::Ok,
                _snapshot: 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,
            })
        }

        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,
            })
        }

        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),
            })
        }

        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,
            })
        }
    }
}

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