Skip to main content

qualia_core_db/specialized_libs/financial_modeling/
risk.rs

1use super::*;
2
3/// Risk assessor
4pub struct RiskAssessor {
5    risk_models: HashMap<String, RiskModel>,
6    risk_metrics: HashMap<String, RiskMetric>,
7    scenario_analyzer: ScenarioAnalyzer,
8}
9
10/// Risk models
11#[derive(Debug, Clone)]
12pub struct RiskModel {
13    pub model_id: String,
14    pub model_type: RiskModelType,
15    pub parameters: RiskModelParameters,
16    pub validation_results: ValidationResults,
17}
18
19/// Risk model types
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub enum RiskModelType {
22    VaR,
23    CVaR,
24    MonteCarlo,
25    Historical,
26    Parametric,
27    StressTest,
28}
29
30/// Risk model parameters
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct RiskModelParameters {
33    pub confidence_level: f64,
34    pub time_horizon: u32,
35    pub lookback_period: u32,
36    pub simulation_count: u32,
37}
38
39/// Validation results
40#[derive(Debug, Clone)]
41pub struct ValidationResults {
42    pub backtest_results: BacktestResults,
43    pub model_accuracy: f64,
44    pub calibration_quality: f64,
45}
46
47/// Backtest results
48#[derive(Debug, Clone)]
49pub struct BacktestResults {
50    pub period: (u64, u64),
51    pub hit_rate: f64,
52    pub average_loss: f64,
53    pub maximum_loss: f64,
54    pub sharpe_ratio: f64,
55}
56
57/// Risk metrics
58#[derive(Debug, Clone)]
59pub struct RiskMetric {
60    pub metric_id: String,
61    pub metric_name: String,
62    pub metric_type: MetricType,
63    pub value: f64,
64    pub timestamp: u64,
65}
66
67/// Metric types
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub enum MetricType {
70    VaR,
71    CVaR,
72    Volatility,
73    Beta,
74    Alpha,
75    Sharpe,
76    Sortino,
77}
78
79/// Scenario analyzer
80pub struct ScenarioAnalyzer {
81    scenarios: HashMap<String, Scenario>,
82    stress_tests: HashMap<String, StressTest>,
83    sensitivity_analyzer: SensitivityAnalyzer,
84    /// Registered `MarketScenario`s used by `run_scenarios` and as the basis
85    /// for deterministic scenario-based stress testing.
86    market_scenarios: Vec<MarketScenario>,
87}
88
89/// Scenarios
90#[derive(Debug, Clone)]
91pub struct Scenario {
92    pub scenario_id: String,
93    pub scenario_name: String,
94    pub scenario_type: ScenarioType,
95    pub parameters: ScenarioParameters,
96    pub probability: f64,
97}
98
99/// Scenario types
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub enum ScenarioType {
102    Economic,
103    Market,
104    Geopolitical,
105    Environmental,
106    Regulatory,
107}
108
109/// Scenario parameters
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ScenarioParameters {
112    pub market_shocks: HashMap<String, f64>,
113    pub interest_rate_changes: HashMap<String, f64>,
114    pub currency_movements: HashMap<String, f64>,
115    pub commodity_price_changes: HashMap<String, f64>,
116}
117
118/// Stress tests
119#[derive(Debug, Clone)]
120pub struct StressTest {
121    pub test_id: String,
122    pub test_name: String,
123    pub test_type: StressTestType,
124    pub scenarios: Vec<String>,
125    pub results: StressTestResults,
126}
127
128/// Stress test types
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub enum StressTestType {
131    Historical,
132    Hypothetical,
133    Reverse,
134    Custom,
135}
136
137/// Stress test results
138#[derive(Debug, Clone)]
139pub struct StressTestResults {
140    pub portfolio_value_change: f64,
141    pub worst_case_loss: f64,
142    pub recovery_time: u32,
143    pub affected_assets: Vec<String>,
144}
145
146/// A market scenario used for scenario-based stress testing.
147///
148/// `shocks` maps `asset_id` → price shock percentage, where e.g. `-0.20`
149/// means a 20% drop in that asset's price. Assets without an entry are
150/// assumed to be unaffected by the scenario.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct MarketScenario {
153    pub name: String,
154    pub probability: f64,
155    pub shocks: HashMap<String, f64>,
156}
157
158impl MarketScenario {
159    pub fn new(name: impl Into<String>, probability: f64, shocks: HashMap<String, f64>) -> Self {
160        Self {
161            name: name.into(),
162            probability,
163            shocks,
164        }
165    }
166}
167
168/// Aggregated results of a Monte Carlo stress-test simulation run.
169///
170/// All monetary values are expressed in the portfolio's currency. VaR figures
171/// are reported as positive numbers representing the magnitude of loss at the
172/// given confidence level (i.e. the loss that is not exceeded with the stated
173/// probability). `expected_shortfall` is the average loss in the tail beyond
174/// the 95% VaR. `max_drawdown` is the worst single-simulation loss relative to
175/// the initial portfolio value.
176#[derive(Debug, Clone, PartialEq)]
177pub struct StressTestResult {
178    /// Value-at-Risk at the 95% confidence level (positive = loss magnitude).
179    pub var_95: f64,
180    /// Value-at-Risk at the 99% confidence level (positive = loss magnitude).
181    pub var_99: f64,
182    /// Expected shortfall (average loss beyond the 95% VaR).
183    pub expected_shortfall: f64,
184    /// Largest single-simulation loss relative to the initial portfolio value.
185    pub max_drawdown: f64,
186    /// Fraction of simulations that ended below the initial portfolio value.
187    pub probability_of_loss: f64,
188    /// Mean portfolio value across all simulations.
189    pub mean_portfolio_value: f64,
190    /// Standard deviation of simulated portfolio values.
191    pub std_dev: f64,
192    /// Number of simulations run.
193    pub num_simulations: usize,
194}
195
196/// The impact of a single defined `MarketScenario` on a portfolio.
197#[derive(Debug, Clone, PartialEq)]
198pub struct ScenarioResult {
199    /// Name of the scenario that was applied.
200    pub scenario_name: String,
201    /// Change in portfolio value (negative = loss) as an absolute amount.
202    pub portfolio_impact: f64,
203    /// Portfolio value after applying the scenario's shocks.
204    pub final_value: f64,
205    /// The probability assigned to this scenario.
206    pub probability: f64,
207}
208
209/// Sensitivity analyzer
210pub struct SensitivityAnalyzer {
211    sensitivity_factors: HashMap<String, SensitivityFactor>,
212    correlation_matrix: CorrelationMatrix,
213}
214
215/// Sensitivity factors
216#[derive(Debug, Clone)]
217pub struct SensitivityFactor {
218    pub factor_id: String,
219    pub factor_name: String,
220    pub factor_type: FactorType,
221    pub sensitivity: f64,
222}
223
224/// Factor types
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub enum FactorType {
227    InterestRate,
228    Equity,
229    Credit,
230    Currency,
231    Commodity,
232}
233
234/// Correlation matrix
235#[derive(Debug, Clone)]
236pub struct CorrelationMatrix {
237    pub assets: Vec<String>,
238    pub correlations: Vec<Vec<f64>>,
239    pub last_updated: u64,
240}
241
242/// Risk analyzer
243pub struct RiskAnalyzer {
244    risk_models: HashMap<String, RiskModel>,
245    risk_metrics: HashMap<String, RiskMetric>,
246    scenario_analyzer: ScenarioAnalyzer,
247    /// Registered benchmark return series used to compute real beta/alpha (see
248    /// `portfolio_risk::compute_risk_metrics`). Without an active benchmark,
249    /// beta/alpha are honestly reported as NaN rather than fabricated.
250    benchmark_comparator: BenchmarkComparator,
251    /// Name of the benchmark to use in `calculate_risk_metrics`, if any.
252    active_benchmark: Option<String>,
253}
254
255impl RiskAssessor {
256    pub fn new() -> Self {
257        Self {
258            risk_models: HashMap::new(),
259            risk_metrics: HashMap::new(),
260            scenario_analyzer: ScenarioAnalyzer::new(),
261        }
262    }
263
264    pub fn initialize(&mut self) -> Result<(), FinancialError> {
265        self.scenario_analyzer.initialize()?;
266        Ok(())
267    }
268
269    pub fn add_risk_model(&mut self, model: RiskModel) {
270        self.risk_models.insert(model.model_id.clone(), model);
271    }
272
273    pub fn get_risk_model(&self, model_id: &str) -> Option<&RiskModel> {
274        self.risk_models.get(model_id)
275    }
276
277    pub fn list_risk_models(&self) -> Vec<String> {
278        self.risk_models.keys().cloned().collect()
279    }
280
281    pub fn add_risk_metric(&mut self, metric: RiskMetric) {
282        self.risk_metrics.insert(metric.metric_id.clone(), metric);
283    }
284
285    pub fn get_risk_metric(&self, metric_id: &str) -> Option<&RiskMetric> {
286        self.risk_metrics.get(metric_id)
287    }
288
289    pub fn list_risk_metrics(&self) -> Vec<String> {
290        self.risk_metrics.keys().cloned().collect()
291    }
292}
293
294impl ScenarioAnalyzer {
295    pub fn new() -> Self {
296        Self {
297            scenarios: HashMap::new(),
298            stress_tests: HashMap::new(),
299            sensitivity_analyzer: SensitivityAnalyzer::new(),
300            market_scenarios: Vec::new(),
301        }
302    }
303
304    pub fn initialize(&mut self) -> Result<(), FinancialError> {
305        Ok(())
306    }
307
308    /// Register a `MarketScenario` for later use by `run_scenarios`.
309    pub fn add_scenario(&mut self, scenario: MarketScenario) {
310        self.market_scenarios.push(scenario);
311    }
312
313    /// Run a Monte Carlo stress-test simulation over `portfolio`.
314    ///
315    /// For each of `num_simulations` trials, every asset receives an
316    /// independent multiplicative shock drawn from a normal distribution with
317    /// mean `0.0` and standard deviation `volatility` (i.e. a simple return
318    /// model: `new_price = price * (1 + z * volatility)` where `z ~ N(0,1)`
319    /// via an inline Box-Muller transform). The portfolio value after shocks
320    /// is recorded and aggregated into a `StressTestResult`.
321    ///
322    /// A fixed deterministic seed is used so results are reproducible across
323    /// runs (important for test stability). Returns
324    /// `FinancialError::PortfolioError` if the portfolio has no assets or no
325    /// positive value, and `FinancialError::ValidationError` if
326    /// `num_simulations` is zero or `volatility` is negative.
327    pub fn run_monte_carlo(
328        &self,
329        portfolio: &Portfolio,
330        num_simulations: usize,
331        volatility: f64,
332    ) -> Result<StressTestResult, FinancialError> {
333        if num_simulations == 0 {
334            return Err(FinancialError::ValidationError(
335                "num_simulations must be greater than zero".to_string(),
336            ));
337        }
338        if volatility < 0.0 {
339            return Err(FinancialError::ValidationError(
340                "volatility must be non-negative".to_string(),
341            ));
342        }
343        if portfolio.assets.is_empty() {
344            return Err(FinancialError::PortfolioError(
345                "portfolio has no assets to simulate".to_string(),
346            ));
347        }
348
349        let initial_value: f64 =
350            portfolio.assets.iter().map(|a| a.market_value).sum::<f64>() + portfolio.cash_balance;
351        if !(initial_value > 0.0) {
352            return Err(FinancialError::PortfolioError(
353                "portfolio has no positive value to simulate".to_string(),
354            ));
355        }
356
357        // Deterministic seed for reproducible results (and stable tests).
358        let mut rng = McRng::new(0x9E37_79B9_7F4A_7C15);
359
360        let mut values: Vec<f64> = Vec::with_capacity(num_simulations);
361        for _ in 0..num_simulations {
362            let mut sim_value = portfolio.cash_balance;
363            for asset in &portfolio.assets {
364                let z = if volatility > 0.0 {
365                    rng.next_normal()
366                } else {
367                    0.0
368                };
369                let shock = z * volatility;
370                let new_price = asset.current_price * (1.0 + shock);
371                sim_value += new_price * asset.quantity;
372            }
373            values.push(sim_value);
374        }
375
376        Ok(aggregate_stress_test_result(
377            &values,
378            initial_value,
379            num_simulations,
380        ))
381    }
382
383    /// Apply each registered `MarketScenario` to `portfolio` and compute its
384    /// impact. Returns one `ScenarioResult` per registered scenario, in
385    /// registration order. An empty scenario set yields an empty result list.
386    pub fn run_scenarios(
387        &self,
388        portfolio: &Portfolio,
389    ) -> Result<Vec<ScenarioResult>, FinancialError> {
390        if portfolio.assets.is_empty() {
391            return Err(FinancialError::PortfolioError(
392                "portfolio has no assets to stress".to_string(),
393            ));
394        }
395
396        let initial_value: f64 =
397            portfolio.assets.iter().map(|a| a.market_value).sum::<f64>() + portfolio.cash_balance;
398
399        let mut results = Vec::with_capacity(self.market_scenarios.len());
400        for scenario in &self.market_scenarios {
401            let mut final_value = portfolio.cash_balance;
402            for asset in &portfolio.assets {
403                let shock = scenario.shocks.get(&asset.asset_id).copied().unwrap_or(0.0);
404                let new_price = asset.current_price * (1.0 + shock);
405                final_value += new_price * asset.quantity;
406            }
407            results.push(ScenarioResult {
408                scenario_name: scenario.name.clone(),
409                portfolio_impact: final_value - initial_value,
410                final_value,
411                probability: scenario.probability,
412            });
413        }
414        Ok(results)
415    }
416
417    pub fn add_named_scenario(&mut self, scenario: Scenario) {
418        self.scenarios
419            .insert(scenario.scenario_id.clone(), scenario);
420    }
421
422    pub fn get_named_scenario(&self, scenario_id: &str) -> Option<&Scenario> {
423        self.scenarios.get(scenario_id)
424    }
425
426    pub fn list_named_scenarios(&self) -> Vec<String> {
427        self.scenarios.keys().cloned().collect()
428    }
429
430    pub fn add_stress_test(&mut self, test: StressTest) {
431        self.stress_tests.insert(test.test_id.clone(), test);
432    }
433
434    pub fn get_stress_test(&self, test_id: &str) -> Option<&StressTest> {
435        self.stress_tests.get(test_id)
436    }
437
438    pub fn list_stress_tests(&self) -> Vec<String> {
439        self.stress_tests.keys().cloned().collect()
440    }
441
442    pub fn sensitivity_analyzer(&self) -> &SensitivityAnalyzer {
443        &self.sensitivity_analyzer
444    }
445
446    pub fn sensitivity_analyzer_mut(&mut self) -> &mut SensitivityAnalyzer {
447        &mut self.sensitivity_analyzer
448    }
449}
450
451/// Aggregate a vector of simulated portfolio values into a `StressTestResult`.
452///
453/// `initial_value` is the pre-shock portfolio value used as the reference for
454/// loss/drawdown calculations. Expects `values.len() == num_simulations`.
455fn aggregate_stress_test_result(
456    values: &[f64],
457    initial_value: f64,
458    num_simulations: usize,
459) -> StressTestResult {
460    let n = values.len();
461    let mean: f64 = values.iter().sum::<f64>() / n as f64;
462    let variance: f64 = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
463    let std_dev = variance.sqrt();
464
465    // Losses relative to the initial value (positive = loss).
466    let mut losses: Vec<f64> = values.iter().map(|v| initial_value - v).collect();
467    losses.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
468
469    // Percentile helper: returns the loss at the given percentile (p in [0,1])
470    // using nearest-rank interpolation.
471    let percentile = |p: f64| -> f64 {
472        if n == 1 {
473            return losses[0];
474        }
475        let rank = (p * (n - 1) as f64).round() as usize;
476        losses[rank.min(n - 1)]
477    };
478
479    let var_95 = percentile(0.95).max(0.0);
480    let var_99 = percentile(0.99).max(0.0);
481
482    // Expected shortfall: average of losses at/above the 95% VaR threshold.
483    let tail_threshold = percentile(0.95);
484    let tail_losses: Vec<f64> = losses
485        .iter()
486        .filter(|&&l| l >= tail_threshold)
487        .copied()
488        .collect();
489    let expected_shortfall = if tail_losses.is_empty() {
490        var_95
491    } else {
492        tail_losses.iter().sum::<f64>() / tail_losses.len() as f64
493    };
494
495    let max_drawdown = losses.last().copied().unwrap_or(0.0).max(0.0);
496
497    let num_losses = values.iter().filter(|v| **v < initial_value).count() as f64;
498    let probability_of_loss = num_losses / n as f64;
499
500    StressTestResult {
501        var_95,
502        var_99,
503        expected_shortfall,
504        max_drawdown,
505        probability_of_loss,
506        mean_portfolio_value: mean,
507        std_dev,
508        num_simulations,
509    }
510}
511
512/// A small, deterministic, seedable PRNG for Monte Carlo simulation.
513///
514/// Implements a 64-bit linear congruential generator (Numerical Recipes
515/// constants) plus an inline Box-Muller transform for standard normal
516/// samples. No external crate required.
517struct McRng {
518    state: u64,
519}
520
521impl McRng {
522    fn new(seed: u64) -> Self {
523        // LCG requires a non-zero state; fall back to a canonical seed.
524        Self {
525            state: if seed == 0 {
526                0x9E37_79B9_7F4A_7C15
527            } else {
528                seed
529            },
530        }
531    }
532
533    /// Next raw u64 from the LCG.
534    fn next_u64(&mut self) -> u64 {
535        // Numerical Recipes 64-bit LCG constants.
536        self.state = self
537            .state
538            .wrapping_mul(6364136223846793005)
539            .wrapping_add(1442695040888963407);
540        self.state
541    }
542
543    /// Next uniform f64 in [0, 1).
544    fn next_uniform(&mut self) -> f64 {
545        // Use the high 53 bits for a full-precision mantissa.
546        let x = self.next_u64() >> 11;
547        (x as f64) * (1.0 / (1u64 << 53) as f64)
548    }
549
550    /// Next standard normal sample via the Box-Muller transform.
551    fn next_normal(&mut self) -> f64 {
552        // z = sqrt(-2 ln u1) * cos(2π u2)
553        let mut u1 = self.next_uniform();
554        if u1 < f64::MIN_POSITIVE {
555            u1 = f64::MIN_POSITIVE;
556        }
557        let u2 = self.next_uniform();
558        let r = (-2.0 * u1.ln()).sqrt();
559        let theta = 2.0 * std::f64::consts::PI * u2;
560        r * theta.cos()
561    }
562}
563
564impl SensitivityAnalyzer {
565    pub fn new() -> Self {
566        Self {
567            sensitivity_factors: HashMap::new(),
568            correlation_matrix: CorrelationMatrix::new(),
569        }
570    }
571
572    pub fn add_factor(&mut self, factor: SensitivityFactor) {
573        self.sensitivity_factors
574            .insert(factor.factor_id.clone(), factor);
575    }
576
577    pub fn get_factor(&self, factor_id: &str) -> Option<&SensitivityFactor> {
578        self.sensitivity_factors.get(factor_id)
579    }
580
581    pub fn list_factors(&self) -> Vec<String> {
582        self.sensitivity_factors.keys().cloned().collect()
583    }
584
585    pub fn correlation_matrix(&self) -> &CorrelationMatrix {
586        &self.correlation_matrix
587    }
588
589    pub fn set_correlation_matrix(&mut self, matrix: CorrelationMatrix) {
590        self.correlation_matrix = matrix;
591    }
592}
593
594impl CorrelationMatrix {
595    pub fn new() -> Self {
596        Self {
597            assets: Vec::new(),
598            correlations: Vec::new(),
599            last_updated: 0,
600        }
601    }
602}
603
604impl RiskAnalyzer {
605    pub fn new() -> Self {
606        Self {
607            risk_models: HashMap::new(),
608            risk_metrics: HashMap::new(),
609            scenario_analyzer: ScenarioAnalyzer::new(),
610            benchmark_comparator: BenchmarkComparator::new(),
611            active_benchmark: None,
612        }
613    }
614
615    pub fn initialize(&mut self) -> Result<(), FinancialError> {
616        self.scenario_analyzer.initialize()?;
617        Ok(())
618    }
619
620    /// Register a benchmark return series by name. If no benchmark is currently
621    /// active, the newly registered one becomes active so that subsequent
622    /// `calculate_risk_metrics` calls produce real beta/alpha.
623    pub fn add_benchmark(&mut self, name: &str, returns: Vec<f64>) {
624        self.benchmark_comparator.add_benchmark(name, returns);
625        if self.active_benchmark.is_none() {
626            self.active_benchmark = Some(name.to_string());
627        }
628    }
629
630    /// Select which registered benchmark `calculate_risk_metrics` should use, or
631    /// `None` to compute beta/alpha-free (NaN) metrics.
632    pub fn set_active_benchmark(&mut self, name: Option<&str>) {
633        self.active_benchmark = name.map(|s| s.to_string());
634    }
635
636    pub fn calculate_risk_metrics(
637        &self,
638        portfolio: &Portfolio,
639    ) -> Result<RiskMetrics, FinancialError> {
640        // REAL: computed from the portfolio's asset return time series (derived
641        // from each Asset's price_history, value-weighted into a portfolio return
642        // series), in the `portfolio_risk` library submodule. Volatility, 95% VaR
643        // and CVaR (historical), Sharpe, Sortino and max-drawdown are genuine
644        // sample statistics — never the old fabricated defaults (Sharpe 0.75 etc).
645        // When no return history is present, it still REFUSES with InsufficientData
646        // (the metrics are undefined without returns); beta/alpha are reported NaN
647        // unless an active benchmark is registered, in which case they are
648        // Cov(R_p,R_b)/Var(R_b) and mean(R_p)−beta·mean(R_b).
649        let benchmark_returns = self
650            .active_benchmark
651            .as_deref()
652            .and_then(|name| self.benchmark_comparator.benchmark_returns(name));
653        let mut metrics = portfolio_risk::compute_risk_metrics(portfolio, benchmark_returns)?;
654
655        // Risk-profile validation: compare the computed volatility / 95% VaR
656        // against the portfolio's declared RiskTolerance. A mismatch yields a
657        // plain-language warning in `risk_profile_assessment` (never a fabricated
658        // "all clear" — `None` means within tolerance, not "unchecked").
659        metrics.risk_profile_assessment =
660            assess_risk_profile(&portfolio.risk_profile.risk_tolerance, &metrics);
661        Ok(metrics)
662    }
663
664    pub fn add_risk_model(&mut self, model: RiskModel) {
665        self.risk_models.insert(model.model_id.clone(), model);
666    }
667
668    pub fn get_risk_model(&self, model_id: &str) -> Option<&RiskModel> {
669        self.risk_models.get(model_id)
670    }
671
672    pub fn list_risk_models(&self) -> Vec<String> {
673        self.risk_models.keys().cloned().collect()
674    }
675
676    pub fn add_risk_metric(&mut self, metric: RiskMetric) {
677        self.risk_metrics.insert(metric.metric_id.clone(), metric);
678    }
679
680    pub fn get_risk_metric(&self, metric_id: &str) -> Option<&RiskMetric> {
681        self.risk_metrics.get(metric_id)
682    }
683
684    pub fn list_risk_metrics(&self) -> Vec<String> {
685        self.risk_metrics.keys().cloned().collect()
686    }
687}
688
689/// Compare computed risk metrics against a declared `RiskTolerance` and return a
690/// warning string when the portfolio is riskier than its profile permits. Returns
691/// `None` when the metrics fit the declared tolerance.
692fn assess_risk_profile(tolerance: &RiskTolerance, metrics: &RiskMetrics) -> Option<String> {
693    // Per-period volatility / VaR thresholds for each tolerance band. These are
694    // stated, conservative guards — a Conservative portfolio carrying >10%
695    // per-period volatility or >5% 95% VaR is flagged, etc.
696    let (max_vol, max_var): (f64, f64) = match tolerance {
697        RiskTolerance::Conservative => (0.10, 0.05),
698        RiskTolerance::Moderate => (0.20, 0.10),
699        RiskTolerance::Aggressive => (0.35, 0.18),
700        RiskTolerance::VeryAggressive => (f64::INFINITY, f64::INFINITY),
701    };
702    let over_vol = metrics.volatility > max_vol;
703    let over_var = metrics.var_95 > max_var;
704    if over_vol || over_var {
705        let label = match tolerance {
706            RiskTolerance::Conservative => "Conservative",
707            RiskTolerance::Moderate => "Moderate",
708            RiskTolerance::Aggressive => "Aggressive",
709            RiskTolerance::VeryAggressive => "VeryAggressive",
710        };
711        Some(format!(
712            "Portfolio declared as {label} but computed risk exceeds its tolerance band \
713             (volatility {:.4} > limit {:.4}, VaR(95%) {:.4} > limit {:.4}).",
714            metrics.volatility, max_vol, metrics.var_95, max_var,
715        ))
716    } else {
717        None
718    }
719}
720
721impl RiskModel {
722    pub fn new() -> Self {
723        Self {
724            model_id: "model_1".to_string(),
725            model_type: RiskModelType::VaR,
726            parameters: RiskModelParameters::new(),
727            validation_results: ValidationResults::new(),
728        }
729    }
730}
731
732impl RiskModelParameters {
733    pub fn new() -> Self {
734        Self {
735            confidence_level: 0.95,
736            time_horizon: 1,
737            lookback_period: 252,
738            simulation_count: 10000,
739        }
740    }
741}
742
743impl ValidationResults {
744    pub fn new() -> Self {
745        Self {
746            backtest_results: BacktestResults::new(),
747            // not measured (scaffold defaults; no model validation is performed)
748            model_accuracy: 0.0,
749            calibration_quality: 0.0,
750        }
751    }
752}
753
754impl BacktestResults {
755    pub fn new() -> Self {
756        Self {
757            period: (0, 86400 * 365), // 1 year
758            hit_rate: 0.95,
759            average_loss: 1000.0,
760            maximum_loss: 5000.0,
761            sharpe_ratio: 1.5,
762        }
763    }
764}
765
766impl RiskMetric {
767    pub fn new() -> Self {
768        Self {
769            metric_id: "metric_1".to_string(),
770            metric_name: "VaR".to_string(),
771            metric_type: MetricType::VaR,
772            value: 1000.0,
773            timestamp: 0,
774        }
775    }
776}
777
778impl Scenario {
779    pub fn new() -> Self {
780        Self {
781            scenario_id: "scenario_1".to_string(),
782            scenario_name: "Market crash".to_string(),
783            scenario_type: ScenarioType::Market,
784            parameters: ScenarioParameters::new(),
785            probability: 0.05,
786        }
787    }
788}
789
790impl ScenarioParameters {
791    pub fn new() -> Self {
792        Self {
793            market_shocks: HashMap::new(),
794            interest_rate_changes: HashMap::new(),
795            currency_movements: HashMap::new(),
796            commodity_price_changes: HashMap::new(),
797        }
798    }
799}
800
801impl StressTest {
802    pub fn new() -> Self {
803        Self {
804            test_id: "test_1".to_string(),
805            test_name: "Market stress test".to_string(),
806            test_type: StressTestType::Historical,
807            scenarios: vec!["scenario_1".to_string()],
808            results: StressTestResults::new(),
809        }
810    }
811}
812
813impl StressTestResults {
814    pub fn new() -> Self {
815        Self {
816            portfolio_value_change: -0.2,
817            worst_case_loss: 20000.0,
818            recovery_time: 30,
819            affected_assets: vec!["asset_1".to_string()],
820        }
821    }
822}
823
824impl SensitivityFactor {
825    pub fn new() -> Self {
826        Self {
827            factor_id: "factor_1".to_string(),
828            factor_name: "Interest rate".to_string(),
829            factor_type: FactorType::InterestRate,
830            sensitivity: 0.5,
831        }
832    }
833}