1use 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#[derive(Debug, Clone)]
13pub struct ScenarioResult {
14 pub name: String,
16 pub cycles: usize,
18 pub total_trades: usize,
20 pub initial_value: Decimal,
22 pub final_value: Decimal,
24 pub return_percent: Decimal,
26 pub cycle_results: Vec<RebalanceResult>,
28 pub passed: bool,
30 pub failure_reason: Option<String>,
32}
33
34impl ScenarioResult {
35 #[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 pub fn fail(&mut self, reason: impl Into<String>) {
53 self.passed = false;
54 self.failure_reason = Some(reason.into());
55 }
56
57 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#[derive(Debug)]
68pub struct Scenario {
69 pub name: String,
71 pub initial_cash: Decimal,
73 pub initial_positions: Vec<(String, Decimal, Decimal)>, pub instruments: Vec<(String, String, Decimal, u32)>, pub target_allocation: DesiredAllocation,
79 pub balancer_config: BalancerConfig,
81 pub price_models: Vec<(String, PriceModel)>,
83 pub ticks: usize,
85 pub rebalance_interval: usize,
87 pub assertions: Vec<ScenarioAssertion>,
89}
90
91pub enum ScenarioAssertion {
93 MinValue(Decimal),
95 MaxValue(Decimal),
97 MinReturn(Decimal),
99 MaxTrades(usize),
101 AllocationWithinTolerance(Decimal),
103 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 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, Self::Custom(_, check) => check(result),
145 }
146 }
147
148 #[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
162pub struct ScenarioBuilder {
164 scenario: Scenario,
165}
166
167impl ScenarioBuilder {
168 #[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 #[must_use]
189 pub const fn with_cash(mut self, cash: Decimal) -> Self {
190 self.scenario.initial_cash = cash;
191 self
192 }
193
194 #[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 #[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 #[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 #[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 #[must_use]
239 pub const fn with_ticks(mut self, ticks: usize) -> Self {
240 self.scenario.ticks = ticks;
241 self
242 }
243
244 #[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 #[must_use]
253 pub fn dry_run(mut self) -> Self {
254 self.scenario.balancer_config.dry_run = true;
255 self
256 }
257
258 #[must_use]
260 pub fn assert(mut self, assertion: ScenarioAssertion) -> Self {
261 self.scenario.assertions.push(assertion);
262 self
263 }
264
265 #[must_use]
267 pub fn assert_min_value(self, value: Decimal) -> Self {
268 self.assert(ScenarioAssertion::MinValue(value))
269 }
270
271 #[must_use]
273 pub fn assert_min_return(self, percent: Decimal) -> Self {
274 self.assert(ScenarioAssertion::MinReturn(percent))
275 }
276
277 #[must_use]
279 pub fn assert_max_trades(self, max: usize) -> Self {
280 self.assert(ScenarioAssertion::MaxTrades(max))
281 }
282
283 #[must_use]
285 pub fn build(self) -> Scenario {
286 self.scenario
287 }
288}
289
290impl Scenario {
291 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 let exchange = SimulatedExchange::new(currency);
299
300 for (symbol, name, price, lot_size) in &self.instruments {
302 exchange.add_instrument(symbol, name, *price, *lot_size);
303 }
304
305 {
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 exchange.create_account(account_id, self.initial_cash);
316
317 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 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 let exchange = Arc::new(exchange);
348 let mut engine = BalancerEngine::new(Arc::clone(&exchange), self.balancer_config.clone());
349
350 for tick in 0..self.ticks {
352 exchange.tick();
354
355 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 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 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)) .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)) .with_target("A", dec!(40)) .with_target("B", dec!(40)) .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); }
459}