Skip to main content

trader_bot/simulator/
scenario.rs

1//! Scenario-based testing for trading strategies.
2
3use rust_decimal::Decimal;
4use std::sync::Arc;
5
6use super::{PriceModel, SimulatedExchange};
7use crate::balancer::{BalancerConfig, BalancerEngine, RebalanceResult};
8use crate::domain::{DesiredAllocation, Money, Position};
9use crate::exchange::ExchangeProvider;
10
11/// Result of running a scenario.
12#[derive(Debug, Clone)]
13pub struct ScenarioResult {
14    /// Name of the scenario.
15    pub name: String,
16    /// Number of rebalancing cycles executed.
17    pub cycles: usize,
18    /// Total number of trades executed.
19    pub total_trades: usize,
20    /// Initial portfolio value.
21    pub initial_value: Decimal,
22    /// Final portfolio value.
23    pub final_value: Decimal,
24    /// Return percentage.
25    pub return_percent: Decimal,
26    /// Results from each rebalancing cycle.
27    pub cycle_results: Vec<RebalanceResult>,
28    /// Whether the scenario passed.
29    pub passed: bool,
30    /// Failure reason if not passed.
31    pub failure_reason: Option<String>,
32}
33
34impl ScenarioResult {
35    /// Creates a new scenario result.
36    #[must_use]
37    pub fn new(name: impl Into<String>) -> Self {
38        Self {
39            name: name.into(),
40            cycles: 0,
41            total_trades: 0,
42            initial_value: Decimal::ZERO,
43            final_value: Decimal::ZERO,
44            return_percent: Decimal::ZERO,
45            cycle_results: Vec::new(),
46            passed: true,
47            failure_reason: None,
48        }
49    }
50
51    /// Marks the scenario as failed.
52    pub fn fail(&mut self, reason: impl Into<String>) {
53        self.passed = false;
54        self.failure_reason = Some(reason.into());
55    }
56
57    /// Calculates the return percentage.
58    pub fn calculate_return(&mut self) {
59        if !self.initial_value.is_zero() {
60            self.return_percent =
61                (self.final_value - self.initial_value) / self.initial_value * Decimal::from(100);
62        }
63    }
64}
65
66/// A test scenario definition.
67#[derive(Debug)]
68pub struct Scenario {
69    /// Scenario name.
70    pub name: String,
71    /// Initial cash balance.
72    pub initial_cash: Decimal,
73    /// Initial positions.
74    pub initial_positions: Vec<(String, Decimal, Decimal)>, // (symbol, quantity, price)
75    /// Instruments to add.
76    pub instruments: Vec<(String, String, Decimal, u32)>, // (symbol, name, price, lot_size)
77    /// Target allocation.
78    pub target_allocation: DesiredAllocation,
79    /// Balancer configuration.
80    pub balancer_config: BalancerConfig,
81    /// Price models for instruments.
82    pub price_models: Vec<(String, PriceModel)>,
83    /// Number of ticks to simulate.
84    pub ticks: usize,
85    /// Rebalance every N ticks.
86    pub rebalance_interval: usize,
87    /// Expected conditions to verify.
88    pub assertions: Vec<ScenarioAssertion>,
89}
90
91/// Assertion to verify after running a scenario.
92pub enum ScenarioAssertion {
93    /// Portfolio value should be at least this amount.
94    MinValue(Decimal),
95    /// Portfolio value should be at most this amount.
96    MaxValue(Decimal),
97    /// Return should be at least this percentage.
98    MinReturn(Decimal),
99    /// Total trades should be at most this number.
100    MaxTrades(usize),
101    /// Final allocation should be within tolerance of target.
102    AllocationWithinTolerance(Decimal),
103    /// Custom assertion with description and check function.
104    Custom(String, Arc<dyn Fn(&ScenarioResult) -> bool + Send + Sync>),
105}
106
107impl std::fmt::Debug for ScenarioAssertion {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::MinValue(v) => f.debug_tuple("MinValue").field(v).finish(),
111            Self::MaxValue(v) => f.debug_tuple("MaxValue").field(v).finish(),
112            Self::MinReturn(v) => f.debug_tuple("MinReturn").field(v).finish(),
113            Self::MaxTrades(v) => f.debug_tuple("MaxTrades").field(v).finish(),
114            Self::AllocationWithinTolerance(v) => {
115                f.debug_tuple("AllocationWithinTolerance").field(v).finish()
116            }
117            Self::Custom(desc, _) => f.debug_tuple("Custom").field(desc).finish(),
118        }
119    }
120}
121
122impl Clone for ScenarioAssertion {
123    fn clone(&self) -> Self {
124        match self {
125            Self::MinValue(v) => Self::MinValue(*v),
126            Self::MaxValue(v) => Self::MaxValue(*v),
127            Self::MinReturn(v) => Self::MinReturn(*v),
128            Self::MaxTrades(v) => Self::MaxTrades(*v),
129            Self::AllocationWithinTolerance(v) => Self::AllocationWithinTolerance(*v),
130            Self::Custom(desc, f) => Self::Custom(desc.clone(), Arc::clone(f)),
131        }
132    }
133}
134
135impl ScenarioAssertion {
136    /// Checks if the assertion passes.
137    pub fn check(&self, result: &ScenarioResult) -> bool {
138        match self {
139            Self::MinValue(min) => result.final_value >= *min,
140            Self::MaxValue(max) => result.final_value <= *max,
141            Self::MinReturn(min) => result.return_percent >= *min,
142            Self::MaxTrades(max) => result.total_trades <= *max,
143            Self::AllocationWithinTolerance(_) => true, // Checked separately
144            Self::Custom(_, check) => check(result),
145        }
146    }
147
148    /// Returns a description of the assertion.
149    #[must_use]
150    pub fn description(&self) -> String {
151        match self {
152            Self::MinValue(v) => format!("Portfolio value >= {v}"),
153            Self::MaxValue(v) => format!("Portfolio value <= {v}"),
154            Self::MinReturn(r) => format!("Return >= {r}%"),
155            Self::MaxTrades(n) => format!("Total trades <= {n}"),
156            Self::AllocationWithinTolerance(t) => format!("Allocation within {t}% of target"),
157            Self::Custom(desc, _) => desc.clone(),
158        }
159    }
160}
161
162/// Builder for creating scenarios.
163pub struct ScenarioBuilder {
164    scenario: Scenario,
165}
166
167impl ScenarioBuilder {
168    /// Creates a new scenario builder.
169    #[must_use]
170    pub fn new(name: impl Into<String>) -> Self {
171        Self {
172            scenario: Scenario {
173                name: name.into(),
174                initial_cash: Decimal::new(10000, 0),
175                initial_positions: Vec::new(),
176                instruments: Vec::new(),
177                target_allocation: DesiredAllocation::new(),
178                balancer_config: BalancerConfig::default(),
179                price_models: Vec::new(),
180                ticks: 100,
181                rebalance_interval: 10,
182                assertions: Vec::new(),
183            },
184        }
185    }
186
187    /// Sets the initial cash balance.
188    #[must_use]
189    pub const fn with_cash(mut self, cash: Decimal) -> Self {
190        self.scenario.initial_cash = cash;
191        self
192    }
193
194    /// Adds an instrument.
195    #[must_use]
196    pub fn with_instrument(
197        mut self,
198        symbol: impl Into<String>,
199        name: impl Into<String>,
200        price: Decimal,
201        lot_size: u32,
202    ) -> Self {
203        self.scenario
204            .instruments
205            .push((symbol.into(), name.into(), price, lot_size));
206        self
207    }
208
209    /// Adds an initial position.
210    #[must_use]
211    pub fn with_position(
212        mut self,
213        symbol: impl Into<String>,
214        quantity: Decimal,
215        price: Decimal,
216    ) -> Self {
217        self.scenario
218            .initial_positions
219            .push((symbol.into(), quantity, price));
220        self
221    }
222
223    /// Sets a target allocation percentage.
224    #[must_use]
225    pub fn with_target(mut self, symbol: impl Into<String>, percent: Decimal) -> Self {
226        self.scenario.target_allocation.set(symbol, percent);
227        self
228    }
229
230    /// Sets the price model for an instrument.
231    #[must_use]
232    pub fn with_price_model(mut self, symbol: impl Into<String>, model: PriceModel) -> Self {
233        self.scenario.price_models.push((symbol.into(), model));
234        self
235    }
236
237    /// Sets the number of ticks to simulate.
238    #[must_use]
239    pub const fn with_ticks(mut self, ticks: usize) -> Self {
240        self.scenario.ticks = ticks;
241        self
242    }
243
244    /// Sets the rebalance interval.
245    #[must_use]
246    pub const fn with_rebalance_interval(mut self, interval: usize) -> Self {
247        self.scenario.rebalance_interval = interval;
248        self
249    }
250
251    /// Sets the balancer to dry run mode.
252    #[must_use]
253    pub fn dry_run(mut self) -> Self {
254        self.scenario.balancer_config.dry_run = true;
255        self
256    }
257
258    /// Adds an assertion.
259    #[must_use]
260    pub fn assert(mut self, assertion: ScenarioAssertion) -> Self {
261        self.scenario.assertions.push(assertion);
262        self
263    }
264
265    /// Asserts minimum portfolio value.
266    #[must_use]
267    pub fn assert_min_value(self, value: Decimal) -> Self {
268        self.assert(ScenarioAssertion::MinValue(value))
269    }
270
271    /// Asserts minimum return percentage.
272    #[must_use]
273    pub fn assert_min_return(self, percent: Decimal) -> Self {
274        self.assert(ScenarioAssertion::MinReturn(percent))
275    }
276
277    /// Asserts maximum number of trades.
278    #[must_use]
279    pub fn assert_max_trades(self, max: usize) -> Self {
280        self.assert(ScenarioAssertion::MaxTrades(max))
281    }
282
283    /// Builds the scenario.
284    #[must_use]
285    pub fn build(self) -> Scenario {
286        self.scenario
287    }
288}
289
290impl Scenario {
291    /// Runs the scenario and returns the result.
292    pub async fn run(&self) -> ScenarioResult {
293        let mut result = ScenarioResult::new(&self.name);
294        let account_id = "scenario_account";
295        let currency = "USD";
296
297        // Set up exchange
298        let exchange = SimulatedExchange::new(currency);
299
300        // Add instruments
301        for (symbol, name, price, lot_size) in &self.instruments {
302            exchange.add_instrument(symbol, name, *price, *lot_size);
303        }
304
305        // Set price models
306        {
307            let market = exchange.market();
308            let mut market_guard = market.write().unwrap();
309            for (symbol, model) in &self.price_models {
310                market_guard.set_price_model(symbol, *model);
311            }
312        }
313
314        // Create account
315        exchange.create_account(account_id, self.initial_cash);
316
317        // Add initial positions
318        for (symbol, quantity, price) in &self.initial_positions {
319            let instrument = exchange
320                .market()
321                .read()
322                .unwrap()
323                .get_instrument(symbol)
324                .cloned();
325            if let Some(inst) = instrument {
326                let position = Position::builder(symbol, "SIMULATOR")
327                    .quantity(*quantity)
328                    .lot_size(inst.lot_size)
329                    .current_price(Money::new(*price, currency))
330                    .average_cost(Money::new(*price, currency))
331                    .build();
332                exchange.add_position(account_id, position);
333            }
334        }
335
336        // Get initial value
337        let initial_wallet = match exchange.get_wallet(account_id).await {
338            Ok(w) => w,
339            Err(e) => {
340                result.fail(format!("Failed to get initial wallet: {e}"));
341                return result;
342            }
343        };
344        result.initial_value = initial_wallet.total_value().amount();
345
346        // Create balancer
347        let exchange = Arc::new(exchange);
348        let mut engine = BalancerEngine::new(Arc::clone(&exchange), self.balancer_config.clone());
349
350        // Run simulation
351        for tick in 0..self.ticks {
352            // Update market prices
353            exchange.tick();
354
355            // Rebalance at intervals
356            if tick > 0 && tick % self.rebalance_interval == 0 {
357                match engine.rebalance(account_id, &self.target_allocation).await {
358                    Ok(rebalance_result) => {
359                        result.total_trades += rebalance_result.executed.len();
360                        result.cycle_results.push(rebalance_result);
361                        result.cycles += 1;
362                    }
363                    Err(e) => {
364                        result.fail(format!("Rebalance failed at tick {tick}: {e}"));
365                        return result;
366                    }
367                }
368            }
369        }
370
371        // Get final value
372        let final_wallet = match exchange.get_wallet(account_id).await {
373            Ok(w) => w,
374            Err(e) => {
375                result.fail(format!("Failed to get final wallet: {e}"));
376                return result;
377            }
378        };
379        result.final_value = final_wallet.total_value().amount();
380        result.calculate_return();
381
382        // Check assertions
383        for assertion in &self.assertions {
384            if !assertion.check(&result) {
385                result.fail(format!("Assertion failed: {}", assertion.description()));
386                return result;
387            }
388        }
389
390        result
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use rust_decimal_macros::dec;
398
399    #[tokio::test]
400    async fn test_simple_scenario() {
401        let scenario = ScenarioBuilder::new("Simple rebalance test")
402            .with_cash(dec!(10000))
403            .with_instrument("A", "Stock A", dec!(100), 1)
404            .with_instrument("B", "Stock B", dec!(100), 1)
405            .with_target("A", dec!(50))
406            .with_target("B", dec!(50))
407            .with_ticks(20)
408            .with_rebalance_interval(5)
409            .assert_min_value(dec!(9000)) // Allow for some trading costs
410            .build();
411
412        let result = scenario.run().await;
413        assert!(
414            result.passed,
415            "Scenario failed: {:?}",
416            result.failure_reason
417        );
418        assert!(result.cycles > 0);
419    }
420
421    #[tokio::test]
422    async fn test_scenario_with_initial_positions() {
423        let scenario = ScenarioBuilder::new("Rebalance with existing positions")
424            .with_cash(dec!(5000))
425            .with_instrument("A", "Stock A", dec!(100), 1)
426            .with_instrument("B", "Stock B", dec!(100), 1)
427            .with_position("A", dec!(30), dec!(100)) // 30 shares @ 100 = 3000
428            .with_target("A", dec!(40)) // Want 40%
429            .with_target("B", dec!(40)) // Want 40%
430            // Total = 5000 cash + 3000 A = 8000
431            // 40% of 8000 = 3200 per asset
432            .with_ticks(10)
433            .with_rebalance_interval(5)
434            .build();
435
436        let result = scenario.run().await;
437        assert!(
438            result.passed,
439            "Scenario failed: {:?}",
440            result.failure_reason
441        );
442    }
443
444    #[tokio::test]
445    async fn test_dry_run_scenario() {
446        let scenario = ScenarioBuilder::new("Dry run test")
447            .with_cash(dec!(10000))
448            .with_instrument("A", "Stock A", dec!(100), 1)
449            .with_target("A", dec!(50))
450            .with_ticks(20)
451            .with_rebalance_interval(5)
452            .dry_run()
453            .build();
454
455        let result = scenario.run().await;
456        assert!(result.passed);
457        assert_eq!(result.total_trades, 0); // No actual trades in dry run
458    }
459}