use rust_decimal::Decimal;
use std::sync::Arc;
use super::{PriceModel, SimulatedExchange};
use crate::balancer::{BalancerConfig, BalancerEngine, RebalanceResult};
use crate::domain::{DesiredAllocation, Money, Position};
use crate::exchange::ExchangeProvider;
#[derive(Debug, Clone)]
pub struct ScenarioResult {
pub name: String,
pub cycles: usize,
pub total_trades: usize,
pub initial_value: Decimal,
pub final_value: Decimal,
pub return_percent: Decimal,
pub cycle_results: Vec<RebalanceResult>,
pub passed: bool,
pub failure_reason: Option<String>,
}
impl ScenarioResult {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
cycles: 0,
total_trades: 0,
initial_value: Decimal::ZERO,
final_value: Decimal::ZERO,
return_percent: Decimal::ZERO,
cycle_results: Vec::new(),
passed: true,
failure_reason: None,
}
}
pub fn fail(&mut self, reason: impl Into<String>) {
self.passed = false;
self.failure_reason = Some(reason.into());
}
pub fn calculate_return(&mut self) {
if !self.initial_value.is_zero() {
self.return_percent =
(self.final_value - self.initial_value) / self.initial_value * Decimal::from(100);
}
}
}
#[derive(Debug)]
pub struct Scenario {
pub name: String,
pub initial_cash: Decimal,
pub initial_positions: Vec<(String, Decimal, Decimal)>, pub instruments: Vec<(String, String, Decimal, u32)>, pub target_allocation: DesiredAllocation,
pub balancer_config: BalancerConfig,
pub price_models: Vec<(String, PriceModel)>,
pub ticks: usize,
pub rebalance_interval: usize,
pub assertions: Vec<ScenarioAssertion>,
}
pub enum ScenarioAssertion {
MinValue(Decimal),
MaxValue(Decimal),
MinReturn(Decimal),
MaxTrades(usize),
AllocationWithinTolerance(Decimal),
Custom(String, Arc<dyn Fn(&ScenarioResult) -> bool + Send + Sync>),
}
impl std::fmt::Debug for ScenarioAssertion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MinValue(v) => f.debug_tuple("MinValue").field(v).finish(),
Self::MaxValue(v) => f.debug_tuple("MaxValue").field(v).finish(),
Self::MinReturn(v) => f.debug_tuple("MinReturn").field(v).finish(),
Self::MaxTrades(v) => f.debug_tuple("MaxTrades").field(v).finish(),
Self::AllocationWithinTolerance(v) => {
f.debug_tuple("AllocationWithinTolerance").field(v).finish()
}
Self::Custom(desc, _) => f.debug_tuple("Custom").field(desc).finish(),
}
}
}
impl Clone for ScenarioAssertion {
fn clone(&self) -> Self {
match self {
Self::MinValue(v) => Self::MinValue(*v),
Self::MaxValue(v) => Self::MaxValue(*v),
Self::MinReturn(v) => Self::MinReturn(*v),
Self::MaxTrades(v) => Self::MaxTrades(*v),
Self::AllocationWithinTolerance(v) => Self::AllocationWithinTolerance(*v),
Self::Custom(desc, f) => Self::Custom(desc.clone(), Arc::clone(f)),
}
}
}
impl ScenarioAssertion {
pub fn check(&self, result: &ScenarioResult) -> bool {
match self {
Self::MinValue(min) => result.final_value >= *min,
Self::MaxValue(max) => result.final_value <= *max,
Self::MinReturn(min) => result.return_percent >= *min,
Self::MaxTrades(max) => result.total_trades <= *max,
Self::AllocationWithinTolerance(_) => true, Self::Custom(_, check) => check(result),
}
}
#[must_use]
pub fn description(&self) -> String {
match self {
Self::MinValue(v) => format!("Portfolio value >= {v}"),
Self::MaxValue(v) => format!("Portfolio value <= {v}"),
Self::MinReturn(r) => format!("Return >= {r}%"),
Self::MaxTrades(n) => format!("Total trades <= {n}"),
Self::AllocationWithinTolerance(t) => format!("Allocation within {t}% of target"),
Self::Custom(desc, _) => desc.clone(),
}
}
}
pub struct ScenarioBuilder {
scenario: Scenario,
}
impl ScenarioBuilder {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
scenario: Scenario {
name: name.into(),
initial_cash: Decimal::new(10000, 0),
initial_positions: Vec::new(),
instruments: Vec::new(),
target_allocation: DesiredAllocation::new(),
balancer_config: BalancerConfig::default(),
price_models: Vec::new(),
ticks: 100,
rebalance_interval: 10,
assertions: Vec::new(),
},
}
}
#[must_use]
pub const fn with_cash(mut self, cash: Decimal) -> Self {
self.scenario.initial_cash = cash;
self
}
#[must_use]
pub fn with_instrument(
mut self,
symbol: impl Into<String>,
name: impl Into<String>,
price: Decimal,
lot_size: u32,
) -> Self {
self.scenario
.instruments
.push((symbol.into(), name.into(), price, lot_size));
self
}
#[must_use]
pub fn with_position(
mut self,
symbol: impl Into<String>,
quantity: Decimal,
price: Decimal,
) -> Self {
self.scenario
.initial_positions
.push((symbol.into(), quantity, price));
self
}
#[must_use]
pub fn with_target(mut self, symbol: impl Into<String>, percent: Decimal) -> Self {
self.scenario.target_allocation.set(symbol, percent);
self
}
#[must_use]
pub fn with_price_model(mut self, symbol: impl Into<String>, model: PriceModel) -> Self {
self.scenario.price_models.push((symbol.into(), model));
self
}
#[must_use]
pub const fn with_ticks(mut self, ticks: usize) -> Self {
self.scenario.ticks = ticks;
self
}
#[must_use]
pub const fn with_rebalance_interval(mut self, interval: usize) -> Self {
self.scenario.rebalance_interval = interval;
self
}
#[must_use]
pub fn dry_run(mut self) -> Self {
self.scenario.balancer_config.dry_run = true;
self
}
#[must_use]
pub fn assert(mut self, assertion: ScenarioAssertion) -> Self {
self.scenario.assertions.push(assertion);
self
}
#[must_use]
pub fn assert_min_value(self, value: Decimal) -> Self {
self.assert(ScenarioAssertion::MinValue(value))
}
#[must_use]
pub fn assert_min_return(self, percent: Decimal) -> Self {
self.assert(ScenarioAssertion::MinReturn(percent))
}
#[must_use]
pub fn assert_max_trades(self, max: usize) -> Self {
self.assert(ScenarioAssertion::MaxTrades(max))
}
#[must_use]
pub fn build(self) -> Scenario {
self.scenario
}
}
impl Scenario {
pub async fn run(&self) -> ScenarioResult {
let mut result = ScenarioResult::new(&self.name);
let account_id = "scenario_account";
let currency = "USD";
let exchange = SimulatedExchange::new(currency);
for (symbol, name, price, lot_size) in &self.instruments {
exchange.add_instrument(symbol, name, *price, *lot_size);
}
{
let market = exchange.market();
let mut market_guard = market.write().unwrap();
for (symbol, model) in &self.price_models {
market_guard.set_price_model(symbol, *model);
}
}
exchange.create_account(account_id, self.initial_cash);
for (symbol, quantity, price) in &self.initial_positions {
let instrument = exchange
.market()
.read()
.unwrap()
.get_instrument(symbol)
.cloned();
if let Some(inst) = instrument {
let position = Position::builder(symbol, "SIMULATOR")
.quantity(*quantity)
.lot_size(inst.lot_size)
.current_price(Money::new(*price, currency))
.average_cost(Money::new(*price, currency))
.build();
exchange.add_position(account_id, position);
}
}
let initial_wallet = match exchange.get_wallet(account_id).await {
Ok(w) => w,
Err(e) => {
result.fail(format!("Failed to get initial wallet: {e}"));
return result;
}
};
result.initial_value = initial_wallet.total_value().amount();
let exchange = Arc::new(exchange);
let mut engine = BalancerEngine::new(Arc::clone(&exchange), self.balancer_config.clone());
for tick in 0..self.ticks {
exchange.tick();
if tick > 0 && tick % self.rebalance_interval == 0 {
match engine.rebalance(account_id, &self.target_allocation).await {
Ok(rebalance_result) => {
result.total_trades += rebalance_result.executed.len();
result.cycle_results.push(rebalance_result);
result.cycles += 1;
}
Err(e) => {
result.fail(format!("Rebalance failed at tick {tick}: {e}"));
return result;
}
}
}
}
let final_wallet = match exchange.get_wallet(account_id).await {
Ok(w) => w,
Err(e) => {
result.fail(format!("Failed to get final wallet: {e}"));
return result;
}
};
result.final_value = final_wallet.total_value().amount();
result.calculate_return();
for assertion in &self.assertions {
if !assertion.check(&result) {
result.fail(format!("Assertion failed: {}", assertion.description()));
return result;
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[tokio::test]
async fn test_simple_scenario() {
let scenario = ScenarioBuilder::new("Simple rebalance test")
.with_cash(dec!(10000))
.with_instrument("A", "Stock A", dec!(100), 1)
.with_instrument("B", "Stock B", dec!(100), 1)
.with_target("A", dec!(50))
.with_target("B", dec!(50))
.with_ticks(20)
.with_rebalance_interval(5)
.assert_min_value(dec!(9000)) .build();
let result = scenario.run().await;
assert!(
result.passed,
"Scenario failed: {:?}",
result.failure_reason
);
assert!(result.cycles > 0);
}
#[tokio::test]
async fn test_scenario_with_initial_positions() {
let scenario = ScenarioBuilder::new("Rebalance with existing positions")
.with_cash(dec!(5000))
.with_instrument("A", "Stock A", dec!(100), 1)
.with_instrument("B", "Stock B", dec!(100), 1)
.with_position("A", dec!(30), dec!(100)) .with_target("A", dec!(40)) .with_target("B", dec!(40)) .with_ticks(10)
.with_rebalance_interval(5)
.build();
let result = scenario.run().await;
assert!(
result.passed,
"Scenario failed: {:?}",
result.failure_reason
);
}
#[tokio::test]
async fn test_dry_run_scenario() {
let scenario = ScenarioBuilder::new("Dry run test")
.with_cash(dec!(10000))
.with_instrument("A", "Stock A", dec!(100), 1)
.with_target("A", dec!(50))
.with_ticks(20)
.with_rebalance_interval(5)
.dry_run()
.build();
let result = scenario.run().await;
assert!(result.passed);
assert_eq!(result.total_trades, 0); }
}