220 lines
5.8 KiB
Rust
220 lines
5.8 KiB
Rust
//! RiskFlow - Real-Time Risk Attribution Engine.
|
|
//!
|
|
//! This demo showcases real-time portfolio risk analysis with factor attribution.
|
|
|
|
pub mod attribution;
|
|
pub mod risk_model;
|
|
pub mod sample_data;
|
|
pub mod stress_test;
|
|
|
|
use riskflow_shared::{Portfolio, RiskAnalysisRequest, RiskAnalysisResult, RiskUpdate};
|
|
use thiserror::Error;
|
|
|
|
use attribution::RiskAttributor;
|
|
use risk_model::RiskModel;
|
|
use stress_test::StressTester;
|
|
|
|
/// Errors that can occur during risk analysis.
|
|
#[derive(Debug, Error)]
|
|
pub enum RiskFlowError {
|
|
/// Empty portfolio.
|
|
#[error("Portfolio is empty")]
|
|
EmptyPortfolio,
|
|
|
|
/// Invalid position data.
|
|
#[error("Invalid position data: {0}")]
|
|
InvalidPosition(String),
|
|
|
|
/// Risk calculation failed.
|
|
#[error("Risk calculation failed: {0}")]
|
|
CalculationFailed(String),
|
|
|
|
/// Insufficient data.
|
|
#[error("Insufficient data for analysis")]
|
|
InsufficientData,
|
|
}
|
|
|
|
/// Main RiskFlow engine.
|
|
#[derive(Debug)]
|
|
pub struct RiskFlow {
|
|
/// Risk model.
|
|
risk_model: RiskModel,
|
|
/// Risk attributor.
|
|
attributor: RiskAttributor,
|
|
/// Stress tester.
|
|
stress_tester: StressTester,
|
|
}
|
|
|
|
impl Default for RiskFlow {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl RiskFlow {
|
|
/// Create a new RiskFlow engine.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
risk_model: RiskModel::new(),
|
|
attributor: RiskAttributor::new(),
|
|
stress_tester: StressTester::new(),
|
|
}
|
|
}
|
|
|
|
/// Analyze portfolio risk.
|
|
pub fn analyze(
|
|
&self,
|
|
request: &RiskAnalysisRequest,
|
|
) -> Result<RiskAnalysisResult, RiskFlowError> {
|
|
// Validate portfolio
|
|
if request.portfolio.positions.is_empty() {
|
|
return Err(RiskFlowError::EmptyPortfolio);
|
|
}
|
|
|
|
// Calculate risk metrics
|
|
let metrics = self.risk_model.calculate_metrics(&request.portfolio)?;
|
|
|
|
// Calculate factor exposures
|
|
let exposures = self.risk_model.calculate_exposures(&request.portfolio);
|
|
|
|
// Calculate risk attribution
|
|
let attribution = self.attributor.attribute(&request.portfolio, &exposures)?;
|
|
|
|
// Calculate position-level risk
|
|
let position_risk = self.risk_model.position_risk(&request.portfolio)?;
|
|
|
|
// Calculate sector risk
|
|
let sector_risk = self.risk_model.sector_risk(&request.portfolio);
|
|
|
|
// Run stress tests
|
|
let stress_tests = self.stress_tester.run_all(&request.portfolio);
|
|
|
|
Ok(RiskAnalysisResult {
|
|
portfolio_id: request.portfolio.id.clone(),
|
|
timestamp: request.analysis_date.clone(),
|
|
metrics,
|
|
exposures,
|
|
attribution,
|
|
position_risk,
|
|
sector_risk,
|
|
stress_tests,
|
|
})
|
|
}
|
|
|
|
/// Get real-time risk update.
|
|
pub fn get_update(&self, portfolio: &Portfolio) -> Result<RiskUpdate, RiskFlowError> {
|
|
let metrics = self.risk_model.calculate_metrics(portfolio)?;
|
|
|
|
// Determine alert level
|
|
let alert_level = if metrics.var_95 > 3.0 {
|
|
2 // Critical
|
|
} else if metrics.var_95 > 2.0 {
|
|
1 // Warning
|
|
} else {
|
|
0 // Normal
|
|
};
|
|
|
|
let alert_message = match alert_level {
|
|
2 => Some("VaR exceeds risk limit".to_string()),
|
|
1 => Some("Elevated risk detected".to_string()),
|
|
_ => None,
|
|
};
|
|
|
|
Ok(RiskUpdate {
|
|
timestamp: chrono_stub(),
|
|
current_var: metrics.var_95,
|
|
var_change: 0.0, // Would require previous state
|
|
volatility: metrics.volatility,
|
|
vol_change: 0.0,
|
|
alert_level,
|
|
alert_message,
|
|
})
|
|
}
|
|
|
|
/// Get the risk model.
|
|
#[must_use]
|
|
pub fn risk_model(&self) -> &RiskModel {
|
|
&self.risk_model
|
|
}
|
|
}
|
|
|
|
/// Simple timestamp stub (no chrono dependency).
|
|
fn chrono_stub() -> String {
|
|
"2024-01-15T16:00:00Z".to_string()
|
|
}
|
|
|
|
/// Run the full demo.
|
|
pub fn run_demo() -> Result<RiskAnalysisResult, RiskFlowError> {
|
|
let engine = RiskFlow::new();
|
|
let request = sample_data::create_sample_request();
|
|
engine.analyze(&request)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use riskflow_shared::get_sample_portfolio;
|
|
|
|
#[test]
|
|
fn test_riskflow_creation() {
|
|
let engine = RiskFlow::new();
|
|
assert!(std::mem::size_of_val(&engine) > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_analyze() {
|
|
let engine = RiskFlow::new();
|
|
let request = RiskAnalysisRequest {
|
|
portfolio: get_sample_portfolio(),
|
|
historical_returns: None,
|
|
benchmark: Some("SPY".to_string()),
|
|
analysis_date: "2024-01-15".to_string(),
|
|
risk_free_rate: 0.04,
|
|
};
|
|
|
|
let result = engine.analyze(&request);
|
|
assert!(result.is_ok());
|
|
|
|
let analysis = result.unwrap();
|
|
assert!(!analysis.position_risk.is_empty());
|
|
assert!(!analysis.sector_risk.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_portfolio_error() {
|
|
let engine = RiskFlow::new();
|
|
let mut portfolio = get_sample_portfolio();
|
|
portfolio.positions.clear();
|
|
|
|
let request = RiskAnalysisRequest {
|
|
portfolio,
|
|
historical_returns: None,
|
|
benchmark: None,
|
|
analysis_date: "2024-01-15".to_string(),
|
|
risk_free_rate: 0.04,
|
|
};
|
|
|
|
let result = engine.analyze(&request);
|
|
assert!(matches!(result, Err(RiskFlowError::EmptyPortfolio)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_update() {
|
|
let engine = RiskFlow::new();
|
|
let portfolio = get_sample_portfolio();
|
|
|
|
let result = engine.get_update(&portfolio);
|
|
assert!(result.is_ok());
|
|
|
|
let update = result.unwrap();
|
|
assert!(update.current_var > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_demo() {
|
|
let result = run_demo();
|
|
assert!(result.is_ok());
|
|
}
|
|
}
|