Skip to main content

percli_core/scenario/
runner.rs

1use anyhow::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3
4use crate::{EngineSnapshot, NamedEngine, POS_SCALE};
5
6use super::types::*;
7
8pub struct RunOptions {
9    pub check_conservation: bool,
10    pub step_by_step: bool,
11    pub verbose: bool,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RunResult {
16    pub final_snapshot: EngineSnapshot,
17    pub step_results: Vec<StepResult>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct StepResult {
22    pub step_num: usize,
23    pub description: String,
24    pub outcome: StepOutcome,
25    pub snapshot: Option<EngineSnapshot>,
26    pub delta: Option<DeltaSnapshot>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum StepOutcome {
31    Ok,
32    Warning(String),
33    QueryResult(EngineSnapshot),
34    AssertPassed,
35    AssertFailed(String),
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DeltaSnapshot {
40    pub vault_delta: i128,
41    pub insurance_delta: i128,
42    pub account_deltas: Vec<AccountDelta>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AccountDelta {
47    pub name: String,
48    pub capital_delta: i128,
49    pub equity_maint_delta: i128,
50}
51
52pub fn run_scenario(scenario: &Scenario, opts: &RunOptions) -> Result<RunResult> {
53    let params = scenario.params.to_risk_params();
54    let mut engine = NamedEngine::new(
55        params,
56        scenario.market.initial_slot,
57        scenario.market.initial_oracle_price,
58    );
59
60    let mut step_results = Vec::new();
61
62    for (i, step) in scenario.steps.iter().enumerate() {
63        let step_num = i + 1;
64
65        let before = if opts.verbose {
66            Some(engine.snapshot())
67        } else {
68            None
69        };
70
71        let mut result = execute_step(&mut engine, step, step_num)?;
72
73        if opts.check_conservation {
74            let snap = engine.snapshot();
75            if !snap.conservation {
76                bail!("Conservation check FAILED after step {step_num}");
77            }
78        }
79
80        if opts.step_by_step {
81            result.snapshot = Some(engine.snapshot());
82        }
83
84        if let Some(before) = before {
85            let after = engine.snapshot();
86            result.delta = Some(compute_delta(&before, &after));
87        }
88
89        step_results.push(result);
90    }
91
92    let final_snapshot = engine.snapshot();
93    Ok(RunResult {
94        final_snapshot,
95        step_results,
96    })
97}
98
99fn execute_step(engine: &mut NamedEngine, step: &Step, step_num: usize) -> Result<StepResult> {
100    match step {
101        Step::Deposit {
102            account,
103            amount,
104            comment,
105        } => {
106            let desc = format_desc("deposit", comment, &format!("{account} {amount}"));
107            engine
108                .deposit(account, *amount as u128)
109                .with_context(|| format!("step {step_num}: deposit {account} {amount}"))?;
110            Ok(StepResult {
111                step_num,
112                description: desc,
113                outcome: StepOutcome::Ok,
114                snapshot: None,
115                delta: None,
116            })
117        }
118
119        Step::Withdraw {
120            account,
121            amount,
122            comment,
123        } => {
124            let desc = format_desc("withdraw", comment, &format!("{account} {amount}"));
125            engine
126                .withdraw(account, *amount as u128)
127                .with_context(|| format!("step {step_num}: withdraw {account} {amount}"))?;
128            Ok(StepResult {
129                step_num,
130                description: desc,
131                outcome: StepOutcome::Ok,
132                snapshot: None,
133                delta: None,
134            })
135        }
136
137        Step::Trade {
138            long,
139            short,
140            size,
141            price,
142            comment,
143        } => {
144            let size_q = (*size as i128)
145                .checked_mul(POS_SCALE as i128)
146                .ok_or_else(|| {
147                    anyhow::anyhow!(
148                        "step {step_num}: size {size} overflows when scaled by POS_SCALE"
149                    )
150                })?;
151            let desc = format_desc(
152                "trade",
153                comment,
154                &format!("{long} long / {short} short x {size} @ {price}"),
155            );
156            engine
157                .trade(long, short, size_q, *price)
158                .with_context(|| format!("step {step_num}: trade {long}/{short}"))?;
159            Ok(StepResult {
160                step_num,
161                description: desc,
162                outcome: StepOutcome::Ok,
163                snapshot: None,
164                delta: None,
165            })
166        }
167
168        Step::Crank {
169            oracle_price,
170            slot,
171            comment,
172        } => {
173            let desc = format_desc(
174                "crank",
175                comment,
176                &format!("oracle={oracle_price} slot={slot}"),
177            );
178            engine
179                .crank(*oracle_price, *slot)
180                .with_context(|| format!("step {step_num}: crank"))?;
181
182            let snap = engine.snapshot();
183            let mut warnings = Vec::new();
184            for acct in &snap.accounts {
185                if !acct.above_maintenance_margin && acct.effective_position_q != 0 {
186                    warnings.push(format!("{} below maintenance margin", acct.name));
187                }
188            }
189
190            let outcome = if warnings.is_empty() {
191                StepOutcome::Ok
192            } else {
193                StepOutcome::Warning(warnings.join("; "))
194            };
195
196            Ok(StepResult {
197                step_num,
198                description: desc,
199                outcome,
200                snapshot: None,
201                delta: None,
202            })
203        }
204
205        Step::Liquidate { account, comment } => {
206            let desc = format_desc("liquidate", comment, account);
207            let did_liq = engine
208                .liquidate(account)
209                .with_context(|| format!("step {step_num}: liquidate {account}"))?;
210            let outcome = if did_liq {
211                StepOutcome::Ok
212            } else {
213                StepOutcome::Warning(format!("{account} was not liquidatable"))
214            };
215            Ok(StepResult {
216                step_num,
217                description: desc,
218                outcome,
219                snapshot: None,
220                delta: None,
221            })
222        }
223
224        Step::Settle { account, comment } => {
225            let desc = format_desc("settle", comment, account);
226            engine
227                .settle(account)
228                .with_context(|| format!("step {step_num}: settle {account}"))?;
229            Ok(StepResult {
230                step_num,
231                description: desc,
232                outcome: StepOutcome::Ok,
233                snapshot: None,
234                delta: None,
235            })
236        }
237
238        Step::SetOracle {
239            oracle_price,
240            comment,
241        } => {
242            let desc = format_desc("set_oracle", comment, &oracle_price.to_string());
243            engine
244                .set_oracle(*oracle_price)
245                .with_context(|| format!("step {step_num}: set_oracle {oracle_price}"))?;
246            Ok(StepResult {
247                step_num,
248                description: desc,
249                outcome: StepOutcome::Ok,
250                snapshot: None,
251                delta: None,
252            })
253        }
254
255        Step::SetSlot { slot, comment } => {
256            let desc = format_desc("set_slot", comment, &slot.to_string());
257            engine.set_slot(*slot);
258            Ok(StepResult {
259                step_num,
260                description: desc,
261                outcome: StepOutcome::Ok,
262                snapshot: None,
263                delta: None,
264            })
265        }
266
267        Step::SetFundingRate { rate, comment } => {
268            let desc = format_desc("set_funding_rate", comment, &rate.to_string());
269            engine.set_funding_rate(*rate);
270            Ok(StepResult {
271                step_num,
272                description: desc,
273                outcome: StepOutcome::Ok,
274                snapshot: None,
275                delta: None,
276            })
277        }
278
279        Step::Query { metric, comment } => {
280            let snap = engine.snapshot();
281            let metric_str = format!("{metric:?}");
282            let desc = format_desc("query", comment, &metric_str);
283            Ok(StepResult {
284                step_num,
285                description: desc,
286                outcome: StepOutcome::QueryResult(snap.clone()),
287                snapshot: Some(snap),
288                delta: None,
289            })
290        }
291
292        Step::Assert { condition, comment } => {
293            let snap = engine.snapshot();
294            let cond_str = format!("{condition:?}");
295            let desc = format_desc("assert", comment, &cond_str);
296            let outcome = check_assert(&snap, condition);
297            Ok(StepResult {
298                step_num,
299                description: desc,
300                outcome,
301                snapshot: None,
302                delta: None,
303            })
304        }
305    }
306}
307
308fn check_assert(snap: &EngineSnapshot, condition: &AssertCondition) -> StepOutcome {
309    match condition {
310        AssertCondition::Conservation => {
311            if snap.conservation {
312                StepOutcome::AssertPassed
313            } else {
314                StepOutcome::AssertFailed("conservation check failed".to_string())
315            }
316        }
317        AssertCondition::HaircutBelow { threshold } => {
318            let h = snap.haircut_ratio_f64();
319            if h < *threshold {
320                StepOutcome::AssertPassed
321            } else {
322                StepOutcome::AssertFailed(format!("haircut {h:.4} >= {threshold}"))
323            }
324        }
325        AssertCondition::AboveMaintenanceMargin { account } => {
326            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
327                if acct.above_maintenance_margin {
328                    StepOutcome::AssertPassed
329                } else {
330                    StepOutcome::AssertFailed(format!("{account} below maintenance margin"))
331                }
332            } else {
333                StepOutcome::AssertFailed(format!("account {account} not found"))
334            }
335        }
336        AssertCondition::BelowMaintenanceMargin { account } => {
337            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
338                if !acct.above_maintenance_margin {
339                    StepOutcome::AssertPassed
340                } else {
341                    StepOutcome::AssertFailed(format!("{account} still above maintenance margin"))
342                }
343            } else {
344                StepOutcome::AssertFailed(format!("account {account} not found"))
345            }
346        }
347        AssertCondition::AboveInitialMargin { account } => {
348            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
349                if acct.above_initial_margin {
350                    StepOutcome::AssertPassed
351                } else {
352                    StepOutcome::AssertFailed(format!("{account} below initial margin"))
353                }
354            } else {
355                StepOutcome::AssertFailed(format!("account {account} not found"))
356            }
357        }
358        AssertCondition::BelowInitialMargin { account } => {
359            if let Some(acct) = snap.accounts.iter().find(|a| a.name == *account) {
360                if !acct.above_initial_margin {
361                    StepOutcome::AssertPassed
362                } else {
363                    StepOutcome::AssertFailed(format!("{account} still above initial margin"))
364                }
365            } else {
366                StepOutcome::AssertFailed(format!("account {account} not found"))
367            }
368        }
369        AssertCondition::VaultAbove { amount } => {
370            if snap.vault > *amount {
371                StepOutcome::AssertPassed
372            } else {
373                StepOutcome::AssertFailed(format!("vault {} is not above {amount}", snap.vault))
374            }
375        }
376        AssertCondition::VaultBelow { amount } => {
377            if snap.vault < *amount {
378                StepOutcome::AssertPassed
379            } else {
380                StepOutcome::AssertFailed(format!("vault {} is not below {amount}", snap.vault))
381            }
382        }
383    }
384}
385
386fn compute_delta(before: &EngineSnapshot, after: &EngineSnapshot) -> DeltaSnapshot {
387    let vault_delta = after.vault as i128 - before.vault as i128;
388    let insurance_delta = after.insurance_fund as i128 - before.insurance_fund as i128;
389
390    let mut account_deltas = Vec::new();
391    for acct_after in &after.accounts {
392        let before_acct = before.accounts.iter().find(|a| a.name == acct_after.name);
393        let (cap_before, eq_before) = before_acct
394            .map(|a| (a.capital as i128, a.equity_maint))
395            .unwrap_or((0, 0));
396
397        let cap_delta = acct_after.capital as i128 - cap_before;
398        let eq_delta = acct_after.equity_maint - eq_before;
399
400        if cap_delta != 0 || eq_delta != 0 {
401            account_deltas.push(AccountDelta {
402                name: acct_after.name.clone(),
403                capital_delta: cap_delta,
404                equity_maint_delta: eq_delta,
405            });
406        }
407    }
408
409    DeltaSnapshot {
410        vault_delta,
411        insurance_delta,
412        account_deltas,
413    }
414}
415
416fn format_desc(action: &str, comment: &Option<String>, detail: &str) -> String {
417    match comment {
418        Some(c) => format!("{action} {detail} — {c}"),
419        None => format!("{action} {detail}"),
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn basic_scenario(steps: Vec<Step>) -> Scenario {
428        Scenario {
429            meta: Meta::default(),
430            params: crate::ParamsConfig::default(),
431            market: MarketConfig::default(),
432            steps,
433        }
434    }
435
436    fn default_opts() -> RunOptions {
437        RunOptions {
438            check_conservation: true,
439            step_by_step: false,
440            verbose: false,
441        }
442    }
443
444    #[test]
445    fn trade_size_overflow_is_checked() {
446        // i64::MAX * POS_SCALE fits in i128, so verify checked_mul doesn't panic
447        // and the engine properly handles it (rejects on margin/size grounds)
448        let scenario = basic_scenario(vec![
449            Step::Deposit {
450                account: "a".into(),
451                amount: 1_000_000,
452                comment: None,
453            },
454            Step::Deposit {
455                account: "b".into(),
456                amount: 1_000_000,
457                comment: None,
458            },
459            Step::Crank {
460                oracle_price: 1000,
461                slot: 1,
462                comment: None,
463            },
464            Step::Trade {
465                long: "a".into(),
466                short: "b".into(),
467                size: i64::MAX,
468                price: 1000,
469                comment: None,
470            },
471        ]);
472        // Should fail (either overflow or engine rejection) but not panic
473        assert!(run_scenario(&scenario, &default_opts()).is_err());
474    }
475
476    #[test]
477    fn assert_conservation_pass() {
478        let scenario = basic_scenario(vec![
479            Step::Deposit {
480                account: "a".into(),
481                amount: 1000,
482                comment: None,
483            },
484            Step::Assert {
485                condition: AssertCondition::Conservation,
486                comment: None,
487            },
488        ]);
489        let result = run_scenario(&scenario, &default_opts()).unwrap();
490        assert!(matches!(
491            result.step_results[1].outcome,
492            StepOutcome::AssertPassed
493        ));
494    }
495
496    #[test]
497    fn assert_vault_above_pass() {
498        let scenario = basic_scenario(vec![
499            Step::Deposit {
500                account: "a".into(),
501                amount: 5000,
502                comment: None,
503            },
504            Step::Assert {
505                condition: AssertCondition::VaultAbove { amount: 1000 },
506                comment: None,
507            },
508        ]);
509        let result = run_scenario(&scenario, &default_opts()).unwrap();
510        assert!(matches!(
511            result.step_results[1].outcome,
512            StepOutcome::AssertPassed
513        ));
514    }
515
516    #[test]
517    fn assert_vault_above_fail() {
518        let scenario = basic_scenario(vec![
519            Step::Deposit {
520                account: "a".into(),
521                amount: 500,
522                comment: None,
523            },
524            Step::Assert {
525                condition: AssertCondition::VaultAbove { amount: 1000 },
526                comment: None,
527            },
528        ]);
529        let result = run_scenario(&scenario, &default_opts()).unwrap();
530        assert!(matches!(
531            result.step_results[1].outcome,
532            StepOutcome::AssertFailed(_)
533        ));
534    }
535
536    #[test]
537    fn assert_vault_below_pass() {
538        let scenario = basic_scenario(vec![
539            Step::Deposit {
540                account: "a".into(),
541                amount: 500,
542                comment: None,
543            },
544            Step::Assert {
545                condition: AssertCondition::VaultBelow { amount: 1000 },
546                comment: None,
547            },
548        ]);
549        let result = run_scenario(&scenario, &default_opts()).unwrap();
550        assert!(matches!(
551            result.step_results[1].outcome,
552            StepOutcome::AssertPassed
553        ));
554    }
555
556    #[test]
557    fn assert_vault_below_fail() {
558        let scenario = basic_scenario(vec![
559            Step::Deposit {
560                account: "a".into(),
561                amount: 5000,
562                comment: None,
563            },
564            Step::Assert {
565                condition: AssertCondition::VaultBelow { amount: 1000 },
566                comment: None,
567            },
568        ]);
569        let result = run_scenario(&scenario, &default_opts()).unwrap();
570        assert!(matches!(
571            result.step_results[1].outcome,
572            StepOutcome::AssertFailed(_)
573        ));
574    }
575
576    #[test]
577    fn assert_above_initial_margin_pass() {
578        let scenario = basic_scenario(vec![
579            Step::Deposit {
580                account: "a".into(),
581                amount: 100_000,
582                comment: None,
583            },
584            Step::Deposit {
585                account: "b".into(),
586                amount: 100_000,
587                comment: None,
588            },
589            Step::Crank {
590                oracle_price: 1000,
591                slot: 1,
592                comment: None,
593            },
594            Step::Trade {
595                long: "a".into(),
596                short: "b".into(),
597                size: 10,
598                price: 1000,
599                comment: None,
600            },
601            Step::Assert {
602                condition: AssertCondition::AboveInitialMargin {
603                    account: "a".into(),
604                },
605                comment: None,
606            },
607        ]);
608        let result = run_scenario(&scenario, &default_opts()).unwrap();
609        assert!(matches!(
610            result.step_results[4].outcome,
611            StepOutcome::AssertPassed
612        ));
613    }
614
615    #[test]
616    fn assert_below_initial_margin_unknown_account() {
617        let scenario = basic_scenario(vec![Step::Assert {
618            condition: AssertCondition::BelowInitialMargin {
619                account: "ghost".into(),
620            },
621            comment: None,
622        }]);
623        let result = run_scenario(&scenario, &default_opts()).unwrap();
624        assert!(matches!(
625            result.step_results[0].outcome,
626            StepOutcome::AssertFailed(_)
627        ));
628    }
629
630    #[test]
631    fn assert_above_maintenance_margin_unknown_account() {
632        let scenario = basic_scenario(vec![Step::Assert {
633            condition: AssertCondition::AboveMaintenanceMargin {
634                account: "ghost".into(),
635            },
636            comment: None,
637        }]);
638        let result = run_scenario(&scenario, &default_opts()).unwrap();
639        assert!(matches!(
640            result.step_results[0].outcome,
641            StepOutcome::AssertFailed(_)
642        ));
643    }
644
645    #[test]
646    fn verbose_produces_deltas() {
647        let scenario = basic_scenario(vec![
648            Step::Deposit {
649                account: "a".into(),
650                amount: 1000,
651                comment: None,
652            },
653            Step::Deposit {
654                account: "a".into(),
655                amount: 2000,
656                comment: None,
657            },
658        ]);
659        let opts = RunOptions {
660            check_conservation: true,
661            step_by_step: false,
662            verbose: true,
663        };
664        let result = run_scenario(&scenario, &opts).unwrap();
665        assert!(result.step_results[0].delta.is_some());
666        let delta = result.step_results[1].delta.as_ref().unwrap();
667        assert_eq!(delta.vault_delta, 2000);
668    }
669
670    #[test]
671    fn step_by_step_produces_snapshots() {
672        let scenario = basic_scenario(vec![Step::Deposit {
673            account: "a".into(),
674            amount: 1000,
675            comment: None,
676        }]);
677        let opts = RunOptions {
678            check_conservation: true,
679            step_by_step: true,
680            verbose: false,
681        };
682        let result = run_scenario(&scenario, &opts).unwrap();
683        assert!(result.step_results[0].snapshot.is_some());
684    }
685}