Skip to main content

kestrel_chartkit/
stress.rs

1//! Stress testing, multi-asset block bootstrapping, path simulation, and execution uncertainty.
2//!
3//! Provides deterministic scenario shock testing for portfolios, stop-loss gap slippage modeling,
4//! synchronized block bootstrapping across correlated assets, and Monte-Carlo equity path simulations.
5
6use std::fmt;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11use crate::execution::ExecutionCosts;
12use crate::portfolio::{CashLedger, PortfolioSnapshot, PositionSnapshot};
13
14/// Parameters defining a stress scenario applied to a portfolio or execution environment.
15#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17pub struct StressScenario {
18    /// Percentage shock to asset prices (e.g. `-0.10` represents a -10% market crash, `0.0` is neutral).
19    pub price_shock_pct: f64,
20    /// Multiplier on bid-ask spreads (e.g. `2.0` represents a doubling of spreads, `1.0` is neutral).
21    pub spread_multiplier: f64,
22    /// Multiplier on adverse slippage (e.g. `2.5` represents 2.5x normal slippage, `1.0` is neutral).
23    pub slippage_multiplier: f64,
24    /// Percentage shock to foreign exchange rates against account currency (e.g. `-0.05` for -5% FX devaluation, `0.0` is neutral).
25    pub fx_shock_pct: f64,
26    /// Multiplier on volume participation capacity (e.g. `0.5` represents liquidity halving, `1.0` is neutral).
27    pub participation_cap_multiplier: f64,
28}
29
30impl Default for StressScenario {
31    fn default() -> Self {
32        Self::neutral()
33    }
34}
35
36impl StressScenario {
37    /// Creates a neutral scenario where no shocks or cost increases are applied.
38    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    /// Validates that stress scenario parameters are finite and non-negative where required.
49    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    /// Computes stressed execution costs by scaling baseline costs.
80    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/// Evaluated outcome of applying a stress scenario to an active portfolio.
90#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92pub struct StressedPortfolioResult {
93    /// Stressed portfolio valuation snapshot.
94    pub snapshot: PortfolioSnapshot,
95    /// Absolute change in total equity (`stressed_equity - baseline_equity`).
96    pub equity_change: f64,
97    /// Percentage change in total equity relative to baseline equity.
98    pub equity_change_pct: f64,
99    /// Increase or decrease in total gross exposure.
100    pub gross_exposure_change: f64,
101    /// Stressed gross leverage.
102    pub gross_leverage: f64,
103}
104
105/// Error type for stress operations and path simulations.
106#[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
129/// Applies a stress scenario to positions and cash ledger, evaluating the stressed outcome.
130///
131/// Under a neutral scenario, the resulting equity and notional matches the baseline portfolio evaluation.
132pub 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    // Apply price and FX shocks to positions
144    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
181/// Simulates gap execution when an asset opens beyond a protective stop order.
182///
183/// Returns the realized execution price.
184/// - For long positions (protective sell stop):
185///   - If `next_open < stop`, price gapped down beyond the stop level; filled at the worse `next_open`.
186///   - Otherwise, filled at `stop`.
187/// - For short positions (protective buy stop):
188///   - If `next_open > stop`, price gapped up beyond the stop level; filled at the worse `next_open`.
189///   - Otherwise, filled at `stop`.
190pub fn simulate_stop_gap_execution(stop: f64, next_open: f64, is_long: bool) -> f64 {
191    if is_long {
192        // Long stop-loss is a sell order
193        stop.min(next_open)
194    } else {
195        // Short stop-loss is a buy order
196        stop.max(next_open)
197    }
198}
199
200/// Linear Congruential Generator for fast, deterministic pseudo-random sequences.
201#[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
229/// Synchronously resamples blocks across multiple assets to preserve contemporaneous correlations.
230///
231/// `asset_returns`: Slice of return series, where each inner vector is the return series of one asset.
232/// All series must have equal length `T`.
233///
234/// Returns a vector of paths, where each path contains $K$ return vectors of length `path_length`.
235pub 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/// Summary of Monte-Carlo equity path simulations.
301#[derive(Debug, Clone, PartialEq)]
302#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
303pub struct PathSimulationSummary {
304    /// Initial starting equity.
305    pub initial_equity: f64,
306    /// Horizon steps simulated for each path.
307    pub horizon_steps: usize,
308    /// Total number of simulated paths.
309    pub num_simulations: usize,
310    /// Deterministic RNG seed used.
311    pub seed: u64,
312    /// Quantiles of terminal ending equity: `(p05, p25, median, p75, p95)`.
313    pub terminal_equity_quantiles: (f64, f64, f64, f64, f64),
314    /// Quantiles of maximum drawdown across paths: `(p05, median, p95)`.
315    pub max_drawdown_quantiles: (f64, f64, f64),
316    /// Probability that maximum drawdown exceeds a given threshold during the path.
317    pub empirical_mean_terminal_equity: f64,
318}
319
320impl PathSimulationSummary {
321    /// Calculates the empirical fraction of paths where maximum drawdown exceeded `threshold_pct` (e.g. 0.20 for 20%).
322    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
334/// Simulates equity curves using circular block bootstrap of historical periodic returns.
335///
336/// Returns statistical quantiles for terminal capital and path drawdowns under deterministic execution.
337pub 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}