1use std::fmt;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11use crate::execution::ExecutionCosts;
12use crate::portfolio::{CashLedger, PortfolioSnapshot, PositionSnapshot};
13
14#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17pub struct StressScenario {
18 pub price_shock_pct: f64,
20 pub spread_multiplier: f64,
22 pub slippage_multiplier: f64,
24 pub fx_shock_pct: f64,
26 pub participation_cap_multiplier: f64,
28}
29
30impl Default for StressScenario {
31 fn default() -> Self {
32 Self::neutral()
33 }
34}
35
36impl StressScenario {
37 pub fn neutral() -> Self {
39 Self {
40 price_shock_pct: 0.0,
41 spread_multiplier: 1.0,
42 slippage_multiplier: 1.0,
43 fx_shock_pct: 0.0,
44 participation_cap_multiplier: 1.0,
45 }
46 }
47
48 pub fn validate(&self) -> Result<(), StressError> {
50 if !self.price_shock_pct.is_finite() || self.price_shock_pct < -1.0 {
51 return Err(StressError::InvalidInput(
52 "price_shock_pct must be >= -1.0 and finite",
53 ));
54 }
55 if !self.spread_multiplier.is_finite() || self.spread_multiplier < 0.0 {
56 return Err(StressError::InvalidInput(
57 "spread_multiplier must be non-negative and finite",
58 ));
59 }
60 if !self.slippage_multiplier.is_finite() || self.slippage_multiplier < 0.0 {
61 return Err(StressError::InvalidInput(
62 "slippage_multiplier must be non-negative and finite",
63 ));
64 }
65 if !self.fx_shock_pct.is_finite() || self.fx_shock_pct < -1.0 {
66 return Err(StressError::InvalidInput(
67 "fx_shock_pct must be >= -1.0 and finite",
68 ));
69 }
70 if !self.participation_cap_multiplier.is_finite() || self.participation_cap_multiplier < 0.0
71 {
72 return Err(StressError::InvalidInput(
73 "participation_cap_multiplier must be non-negative and finite",
74 ));
75 }
76 Ok(())
77 }
78
79 pub fn stressed_costs(&self, base: ExecutionCosts) -> ExecutionCosts {
81 ExecutionCosts {
82 fee_pct: base.fee_pct,
83 spread: base.spread * self.spread_multiplier,
84 slippage_pct: base.slippage_pct * self.slippage_multiplier,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92pub struct StressedPortfolioResult {
93 pub snapshot: PortfolioSnapshot,
95 pub equity_change: f64,
97 pub equity_change_pct: f64,
99 pub gross_exposure_change: f64,
101 pub gross_leverage: f64,
103}
104
105#[derive(Debug, Clone, PartialEq)]
107pub enum StressError {
108 InvalidInput(&'static str),
109 Portfolio(crate::portfolio::PortfolioError),
110}
111
112impl fmt::Display for StressError {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::InvalidInput(msg) => write!(f, "invalid stress input: {msg}"),
116 Self::Portfolio(e) => write!(f, "portfolio evaluation failed under stress: {e}"),
117 }
118 }
119}
120
121impl std::error::Error for StressError {}
122
123impl From<crate::portfolio::PortfolioError> for StressError {
124 fn from(e: crate::portfolio::PortfolioError) -> Self {
125 Self::Portfolio(e)
126 }
127}
128
129pub fn apply_portfolio_stress(
133 account_currency: crate::contract::Currency,
134 ledger: &CashLedger,
135 positions: &[PositionSnapshot],
136 scenario: &StressScenario,
137) -> Result<StressedPortfolioResult, StressError> {
138 scenario.validate()?;
139
140 let base_snapshot =
141 crate::portfolio::evaluate_portfolio(account_currency.clone(), ledger, positions)?;
142
143 let mut stressed_positions = Vec::with_capacity(positions.len());
145 for p in positions {
146 let mut sp = p.clone();
147 sp.current_price *= 1.0 + scenario.price_shock_pct;
148 sp.fx_to_account *= 1.0 + scenario.fx_shock_pct;
149 stressed_positions.push(sp);
150 }
151
152 let stressed_snapshot = crate::portfolio::evaluate_portfolio(
153 base_snapshot.account_currency,
154 ledger,
155 &stressed_positions,
156 )?;
157
158 let equity_change = stressed_snapshot.equity - base_snapshot.equity;
159 let equity_change_pct = if base_snapshot.equity.abs() > 1e-12 {
160 equity_change / base_snapshot.equity
161 } else {
162 0.0
163 };
164 let gross_exposure_change = stressed_snapshot.gross_exposure - base_snapshot.gross_exposure;
165
166 let gross_leverage = if base_snapshot.equity > 0.0 {
167 stressed_snapshot.gross_exposure / base_snapshot.equity
168 } else {
169 0.0
170 };
171
172 Ok(StressedPortfolioResult {
173 snapshot: stressed_snapshot,
174 equity_change,
175 equity_change_pct,
176 gross_exposure_change,
177 gross_leverage,
178 })
179}
180
181pub fn simulate_stop_gap_execution(stop: f64, next_open: f64, is_long: bool) -> f64 {
191 if is_long {
192 stop.min(next_open)
194 } else {
195 stop.max(next_open)
197 }
198}
199
200#[derive(Debug, Clone)]
202struct LcgRng {
203 state: u64,
204}
205
206impl LcgRng {
207 fn new(seed: u64) -> Self {
208 Self {
209 state: seed.wrapping_add(1),
210 }
211 }
212
213 fn next_u64(&mut self) -> u64 {
214 self.state = self
215 .state
216 .wrapping_mul(6364136223846793005)
217 .wrapping_add(1442695040888963407);
218 self.state
219 }
220
221 fn next_usize(&mut self, max: usize) -> usize {
222 if max == 0 {
223 return 0;
224 }
225 (self.next_u64() as usize) % max
226 }
227}
228
229pub fn multi_asset_block_bootstrap(
236 asset_returns: &[Vec<f64>],
237 block_size: usize,
238 path_length: usize,
239 num_paths: usize,
240 seed: u64,
241) -> Result<Vec<Vec<Vec<f64>>>, StressError> {
242 if asset_returns.is_empty() {
243 return Err(StressError::InvalidInput("asset_returns cannot be empty"));
244 }
245 let t = asset_returns[0].len();
246 if t == 0 {
247 return Err(StressError::InvalidInput("return series cannot be empty"));
248 }
249 for series in asset_returns {
250 if series.len() != t {
251 return Err(StressError::InvalidInput(
252 "all asset return series must have identical length",
253 ));
254 }
255 for &r in series {
256 if !r.is_finite() {
257 return Err(StressError::InvalidInput(
258 "return series contains non-finite values",
259 ));
260 }
261 }
262 }
263 if block_size == 0 {
264 return Err(StressError::InvalidInput("block_size must be >= 1"));
265 }
266 if path_length == 0 {
267 return Err(StressError::InvalidInput("path_length must be >= 1"));
268 }
269 if num_paths == 0 {
270 return Err(StressError::InvalidInput("num_paths must be >= 1"));
271 }
272
273 let mut rng = LcgRng::new(seed);
274 let num_assets = asset_returns.len();
275 let mut paths = Vec::with_capacity(num_paths);
276
277 for _ in 0..num_paths {
278 let mut path_assets = vec![Vec::with_capacity(path_length); num_assets];
279 let mut steps_collected = 0;
280
281 while steps_collected < path_length {
282 let start_idx = rng.next_usize(t);
283 let take = block_size.min(path_length - steps_collected);
284
285 for offset in 0..take {
286 let time_idx = (start_idx + offset) % t;
287 for asset_idx in 0..num_assets {
288 path_assets[asset_idx].push(asset_returns[asset_idx][time_idx]);
289 }
290 }
291 steps_collected += take;
292 }
293
294 paths.push(path_assets);
295 }
296
297 Ok(paths)
298}
299
300#[derive(Debug, Clone, PartialEq)]
302#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
303pub struct PathSimulationSummary {
304 pub initial_equity: f64,
306 pub horizon_steps: usize,
308 pub num_simulations: usize,
310 pub seed: u64,
312 pub terminal_equity_quantiles: (f64, f64, f64, f64, f64),
314 pub max_drawdown_quantiles: (f64, f64, f64),
316 pub empirical_mean_terminal_equity: f64,
318}
319
320impl PathSimulationSummary {
321 pub fn probability_drawdown_exceeds(all_max_drawdowns: &[f64], threshold_pct: f64) -> f64 {
323 if all_max_drawdowns.is_empty() {
324 return 0.0;
325 }
326 let exceeds = all_max_drawdowns
327 .iter()
328 .filter(|&&dd| dd >= threshold_pct)
329 .count();
330 exceeds as f64 / all_max_drawdowns.len() as f64
331 }
332}
333
334pub fn simulate_equity_paths(
338 initial_equity: f64,
339 periodic_returns: &[f64],
340 block_size: usize,
341 horizon_steps: usize,
342 num_simulations: usize,
343 seed: u64,
344) -> Result<PathSimulationSummary, StressError> {
345 if !initial_equity.is_finite() || initial_equity <= 0.0 {
346 return Err(StressError::InvalidInput(
347 "initial_equity must be positive and finite",
348 ));
349 }
350 if periodic_returns.is_empty() {
351 return Err(StressError::InvalidInput(
352 "periodic_returns cannot be empty",
353 ));
354 }
355 for &r in periodic_returns {
356 if !r.is_finite() {
357 return Err(StressError::InvalidInput("returns must be finite"));
358 }
359 }
360 if block_size == 0 {
361 return Err(StressError::InvalidInput("block_size must be >= 1"));
362 }
363 if horizon_steps == 0 {
364 return Err(StressError::InvalidInput("horizon_steps must be >= 1"));
365 }
366 if num_simulations == 0 {
367 return Err(StressError::InvalidInput("num_simulations must be >= 1"));
368 }
369
370 let t = periodic_returns.len();
371 let mut rng = LcgRng::new(seed);
372
373 let mut terminal_equities = Vec::with_capacity(num_simulations);
374 let mut max_drawdowns = Vec::with_capacity(num_simulations);
375
376 for _ in 0..num_simulations {
377 let mut equity = initial_equity;
378 let mut peak = initial_equity;
379 let mut max_dd = 0.0f64;
380 let mut steps = 0;
381
382 while steps < horizon_steps {
383 let start_idx = rng.next_usize(t);
384 let take = block_size.min(horizon_steps - steps);
385
386 for offset in 0..take {
387 let idx = (start_idx + offset) % t;
388 let r = periodic_returns[idx];
389 equity *= 1.0 + r;
390 if equity > peak {
391 peak = equity;
392 } else if peak > 0.0 {
393 let dd = (peak - equity) / peak;
394 if dd > max_dd {
395 max_dd = dd;
396 }
397 }
398 }
399 steps += take;
400 }
401
402 terminal_equities.push(equity);
403 max_drawdowns.push(max_dd);
404 }
405
406 terminal_equities.sort_by(|a, b| a.total_cmp(b));
407 max_drawdowns.sort_by(|a, b| a.total_cmp(b));
408
409 let quantile = |slice: &[f64], q: f64| -> f64 {
410 let idx = ((q * slice.len() as f64).floor() as usize).min(slice.len() - 1);
411 slice[idx]
412 };
413
414 let p05 = quantile(&terminal_equities, 0.05);
415 let p25 = quantile(&terminal_equities, 0.25);
416 let median = quantile(&terminal_equities, 0.50);
417 let p75 = quantile(&terminal_equities, 0.75);
418 let p95 = quantile(&terminal_equities, 0.95);
419
420 let dd_p05 = quantile(&max_drawdowns, 0.05);
421 let dd_med = quantile(&max_drawdowns, 0.50);
422 let dd_p95 = quantile(&max_drawdowns, 0.95);
423
424 let mean_terminal = terminal_equities.iter().sum::<f64>() / num_simulations as f64;
425
426 Ok(PathSimulationSummary {
427 initial_equity,
428 horizon_steps,
429 num_simulations,
430 seed,
431 terminal_equity_quantiles: (p05, p25, median, p75, p95),
432 max_drawdown_quantiles: (dd_p05, dd_med, dd_p95),
433 empirical_mean_terminal_equity: mean_terminal,
434 })
435}