use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::execution::ExecutionCosts;
use crate::portfolio::{CashLedger, PortfolioSnapshot, PositionSnapshot};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StressScenario {
pub price_shock_pct: f64,
pub spread_multiplier: f64,
pub slippage_multiplier: f64,
pub fx_shock_pct: f64,
pub participation_cap_multiplier: f64,
}
impl Default for StressScenario {
fn default() -> Self {
Self::neutral()
}
}
impl StressScenario {
pub fn neutral() -> Self {
Self {
price_shock_pct: 0.0,
spread_multiplier: 1.0,
slippage_multiplier: 1.0,
fx_shock_pct: 0.0,
participation_cap_multiplier: 1.0,
}
}
pub fn validate(&self) -> Result<(), StressError> {
if !self.price_shock_pct.is_finite() || self.price_shock_pct < -1.0 {
return Err(StressError::InvalidInput(
"price_shock_pct must be >= -1.0 and finite",
));
}
if !self.spread_multiplier.is_finite() || self.spread_multiplier < 0.0 {
return Err(StressError::InvalidInput(
"spread_multiplier must be non-negative and finite",
));
}
if !self.slippage_multiplier.is_finite() || self.slippage_multiplier < 0.0 {
return Err(StressError::InvalidInput(
"slippage_multiplier must be non-negative and finite",
));
}
if !self.fx_shock_pct.is_finite() || self.fx_shock_pct < -1.0 {
return Err(StressError::InvalidInput(
"fx_shock_pct must be >= -1.0 and finite",
));
}
if !self.participation_cap_multiplier.is_finite() || self.participation_cap_multiplier < 0.0
{
return Err(StressError::InvalidInput(
"participation_cap_multiplier must be non-negative and finite",
));
}
Ok(())
}
pub fn stressed_costs(&self, base: ExecutionCosts) -> ExecutionCosts {
ExecutionCosts {
fee_pct: base.fee_pct,
spread: base.spread * self.spread_multiplier,
slippage_pct: base.slippage_pct * self.slippage_multiplier,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StressedPortfolioResult {
pub snapshot: PortfolioSnapshot,
pub equity_change: f64,
pub equity_change_pct: f64,
pub gross_exposure_change: f64,
pub gross_leverage: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StressError {
InvalidInput(&'static str),
Portfolio(crate::portfolio::PortfolioError),
}
impl fmt::Display for StressError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(msg) => write!(f, "invalid stress input: {msg}"),
Self::Portfolio(e) => write!(f, "portfolio evaluation failed under stress: {e}"),
}
}
}
impl std::error::Error for StressError {}
impl From<crate::portfolio::PortfolioError> for StressError {
fn from(e: crate::portfolio::PortfolioError) -> Self {
Self::Portfolio(e)
}
}
pub fn apply_portfolio_stress(
account_currency: crate::contract::Currency,
ledger: &CashLedger,
positions: &[PositionSnapshot],
scenario: &StressScenario,
) -> Result<StressedPortfolioResult, StressError> {
scenario.validate()?;
let base_snapshot =
crate::portfolio::evaluate_portfolio(account_currency.clone(), ledger, positions)?;
let mut stressed_positions = Vec::with_capacity(positions.len());
for p in positions {
let mut sp = p.clone();
sp.current_price *= 1.0 + scenario.price_shock_pct;
sp.fx_to_account *= 1.0 + scenario.fx_shock_pct;
stressed_positions.push(sp);
}
let stressed_snapshot = crate::portfolio::evaluate_portfolio(
base_snapshot.account_currency,
ledger,
&stressed_positions,
)?;
let equity_change = stressed_snapshot.equity - base_snapshot.equity;
let equity_change_pct = if base_snapshot.equity.abs() > 1e-12 {
equity_change / base_snapshot.equity
} else {
0.0
};
let gross_exposure_change = stressed_snapshot.gross_exposure - base_snapshot.gross_exposure;
let gross_leverage = if base_snapshot.equity > 0.0 {
stressed_snapshot.gross_exposure / base_snapshot.equity
} else {
0.0
};
Ok(StressedPortfolioResult {
snapshot: stressed_snapshot,
equity_change,
equity_change_pct,
gross_exposure_change,
gross_leverage,
})
}
pub fn simulate_stop_gap_execution(stop: f64, next_open: f64, is_long: bool) -> f64 {
if is_long {
stop.min(next_open)
} else {
stop.max(next_open)
}
}
#[derive(Debug, Clone)]
struct LcgRng {
state: u64,
}
impl LcgRng {
fn new(seed: u64) -> Self {
Self {
state: seed.wrapping_add(1),
}
}
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
fn next_usize(&mut self, max: usize) -> usize {
if max == 0 {
return 0;
}
(self.next_u64() as usize) % max
}
}
pub fn multi_asset_block_bootstrap(
asset_returns: &[Vec<f64>],
block_size: usize,
path_length: usize,
num_paths: usize,
seed: u64,
) -> Result<Vec<Vec<Vec<f64>>>, StressError> {
if asset_returns.is_empty() {
return Err(StressError::InvalidInput("asset_returns cannot be empty"));
}
let t = asset_returns[0].len();
if t == 0 {
return Err(StressError::InvalidInput("return series cannot be empty"));
}
for series in asset_returns {
if series.len() != t {
return Err(StressError::InvalidInput(
"all asset return series must have identical length",
));
}
for &r in series {
if !r.is_finite() {
return Err(StressError::InvalidInput(
"return series contains non-finite values",
));
}
}
}
if block_size == 0 {
return Err(StressError::InvalidInput("block_size must be >= 1"));
}
if path_length == 0 {
return Err(StressError::InvalidInput("path_length must be >= 1"));
}
if num_paths == 0 {
return Err(StressError::InvalidInput("num_paths must be >= 1"));
}
let mut rng = LcgRng::new(seed);
let num_assets = asset_returns.len();
let mut paths = Vec::with_capacity(num_paths);
for _ in 0..num_paths {
let mut path_assets = vec![Vec::with_capacity(path_length); num_assets];
let mut steps_collected = 0;
while steps_collected < path_length {
let start_idx = rng.next_usize(t);
let take = block_size.min(path_length - steps_collected);
for offset in 0..take {
let time_idx = (start_idx + offset) % t;
for asset_idx in 0..num_assets {
path_assets[asset_idx].push(asset_returns[asset_idx][time_idx]);
}
}
steps_collected += take;
}
paths.push(path_assets);
}
Ok(paths)
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PathSimulationSummary {
pub initial_equity: f64,
pub horizon_steps: usize,
pub num_simulations: usize,
pub seed: u64,
pub terminal_equity_quantiles: (f64, f64, f64, f64, f64),
pub max_drawdown_quantiles: (f64, f64, f64),
pub empirical_mean_terminal_equity: f64,
}
impl PathSimulationSummary {
pub fn probability_drawdown_exceeds(all_max_drawdowns: &[f64], threshold_pct: f64) -> f64 {
if all_max_drawdowns.is_empty() {
return 0.0;
}
let exceeds = all_max_drawdowns
.iter()
.filter(|&&dd| dd >= threshold_pct)
.count();
exceeds as f64 / all_max_drawdowns.len() as f64
}
}
pub fn simulate_equity_paths(
initial_equity: f64,
periodic_returns: &[f64],
block_size: usize,
horizon_steps: usize,
num_simulations: usize,
seed: u64,
) -> Result<PathSimulationSummary, StressError> {
if !initial_equity.is_finite() || initial_equity <= 0.0 {
return Err(StressError::InvalidInput(
"initial_equity must be positive and finite",
));
}
if periodic_returns.is_empty() {
return Err(StressError::InvalidInput(
"periodic_returns cannot be empty",
));
}
for &r in periodic_returns {
if !r.is_finite() {
return Err(StressError::InvalidInput("returns must be finite"));
}
}
if block_size == 0 {
return Err(StressError::InvalidInput("block_size must be >= 1"));
}
if horizon_steps == 0 {
return Err(StressError::InvalidInput("horizon_steps must be >= 1"));
}
if num_simulations == 0 {
return Err(StressError::InvalidInput("num_simulations must be >= 1"));
}
let t = periodic_returns.len();
let mut rng = LcgRng::new(seed);
let mut terminal_equities = Vec::with_capacity(num_simulations);
let mut max_drawdowns = Vec::with_capacity(num_simulations);
for _ in 0..num_simulations {
let mut equity = initial_equity;
let mut peak = initial_equity;
let mut max_dd = 0.0f64;
let mut steps = 0;
while steps < horizon_steps {
let start_idx = rng.next_usize(t);
let take = block_size.min(horizon_steps - steps);
for offset in 0..take {
let idx = (start_idx + offset) % t;
let r = periodic_returns[idx];
equity *= 1.0 + r;
if equity > peak {
peak = equity;
} else if peak > 0.0 {
let dd = (peak - equity) / peak;
if dd > max_dd {
max_dd = dd;
}
}
}
steps += take;
}
terminal_equities.push(equity);
max_drawdowns.push(max_dd);
}
terminal_equities.sort_by(|a, b| a.total_cmp(b));
max_drawdowns.sort_by(|a, b| a.total_cmp(b));
let quantile = |slice: &[f64], q: f64| -> f64 {
let idx = ((q * slice.len() as f64).floor() as usize).min(slice.len() - 1);
slice[idx]
};
let p05 = quantile(&terminal_equities, 0.05);
let p25 = quantile(&terminal_equities, 0.25);
let median = quantile(&terminal_equities, 0.50);
let p75 = quantile(&terminal_equities, 0.75);
let p95 = quantile(&terminal_equities, 0.95);
let dd_p05 = quantile(&max_drawdowns, 0.05);
let dd_med = quantile(&max_drawdowns, 0.50);
let dd_p95 = quantile(&max_drawdowns, 0.95);
let mean_terminal = terminal_equities.iter().sum::<f64>() / num_simulations as f64;
Ok(PathSimulationSummary {
initial_equity,
horizon_steps,
num_simulations,
seed,
terminal_equity_quantiles: (p05, p25, median, p75, p95),
max_drawdown_quantiles: (dd_p05, dd_med, dd_p95),
empirical_mean_terminal_equity: mean_terminal,
})
}