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