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