Files
foxhunt/risk-data/src/var.rs
jgrusewski 633435fc6f fix(ml): Fix varmap scale/zero_point preservation test
- Add .get(0)? before .to_scalar() for scale extraction (line 605)
- Add .get(0)? before .to_scalar() for zero_point extraction (line 624)
- Handles [1] shape tensors from Tensor::new(&[value], device)
- Fixes test_quantization_preserves_scale_and_zero_point
- Ensures reliable SafeTensors save/load round-trip
2025-10-23 13:36:34 +02:00

767 lines
26 KiB
Rust

//! `VaR` (Value at Risk) Repository
//!
//! Manages `VaR` calculations, historical data storage, and risk metrics for
//! high-frequency trading systems with multiple calculation methods.
// Allow pedantic lints for VaR calculation complexity
#![allow(clippy::missing_docs_in_private_items)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::as_conversions)]
#![allow(clippy::default_numeric_fallback)]
#![allow(clippy::str_to_string)]
#![allow(clippy::float_arithmetic)]
#![allow(clippy::indexing_slicing)]
#![allow(missing_docs)]
#![allow(clippy::expect_used)]
#![allow(clippy::module_name_repetitions)]
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use redis::aio::ConnectionManager;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
use tracing::{info, warn};
use uuid::Uuid;
use crate::{RiskDataError, RiskDataResult};
/// `VaR` calculation methods
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "var_method", rename_all = "snake_case")]
pub enum VarMethod {
/// Historical simulation method using past returns
Historical,
/// Parametric method using normal distribution
Parametric,
/// Monte Carlo simulation method
MonteCarlo,
/// Extreme Value Theory method
ExtremeValue,
}
/// `VaR` confidence levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConfidenceLevel {
/// 95% confidence level
P95,
/// 97.5% confidence level
P97_5,
/// 99% confidence level
P99,
/// 99.9% confidence level
P99_9,
}
impl ConfidenceLevel {
pub const fn as_decimal(&self) -> Decimal {
match self {
Self::P95 => Decimal::from_parts(95, 0, 0, false, 2), // 0.95
Self::P97_5 => Decimal::from_parts(975, 0, 0, false, 3), // 0.975
Self::P99 => Decimal::from_parts(99, 0, 0, false, 2), // 0.99
Self::P99_9 => Decimal::from_parts(999, 0, 0, false, 3), // 0.999
}
}
}
/// `VaR` calculation request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VarRequest {
/// Portfolio identifier for `VaR` calculation
pub portfolio_id: String,
/// `VaR` calculation method to use
pub method: VarMethod,
/// Confidence level for the `VaR` calculation
pub confidence_level: ConfidenceLevel,
/// Holding period in days
pub holding_period_days: i32,
/// Number of historical days to look back for data
pub lookback_days: i32,
/// Currency for the `VaR` result (ISO 3-letter code)
pub currency: String,
}
/// `VaR` calculation result
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct VarResult {
/// Unique identifier for this `VaR` calculation
pub id: Uuid,
/// Portfolio this `VaR` calculation applies to
pub portfolio_id: String,
/// `VaR` calculation method used
pub method: VarMethod,
/// Confidence level used in the calculation
pub confidence_level: Decimal,
/// Holding period in days used for the calculation
pub holding_period_days: i32,
/// Number of historical days used for the calculation
pub lookback_days: i32,
/// Calculated `VaR` amount
pub var_amount: Decimal,
/// Currency of the `VaR` amount (ISO 3-letter code)
pub currency: String,
/// Date and time when this calculation was performed
pub calculation_date: DateTime<Utc>,
/// Portfolio volatility (if calculated)
pub volatility: Option<Decimal>,
/// Correlation adjustment factor (if applied)
pub correlation_adjustment: Option<Decimal>,
/// Stress test multiplier (if this is a stress test result)
pub stress_test_multiplier: Option<Decimal>,
/// Additional calculation metadata as JSON
pub metadata: serde_json::Value,
}
/// Portfolio position for `VaR` calculation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioPosition {
/// Instrument symbol for this position
pub symbol: String,
/// Number of shares/contracts held
pub quantity: Decimal,
/// Current market value of the position
pub market_value: Decimal,
/// Currency of the position (ISO 3-letter code)
pub currency: String,
/// Asset class classification
pub asset_class: String,
}
/// Historical price data
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct PriceData {
/// Instrument symbol for this price point
pub symbol: String,
/// Price at this timestamp
pub price: Decimal,
/// Timestamp when this price was recorded
pub timestamp: DateTime<Utc>,
/// Trading volume at this price (if available)
pub volume: Option<Decimal>,
}
/// `VaR` repository trait
#[async_trait]
pub trait VarRepository: Send + Sync + std::fmt::Debug {
/// Calculate `VaR` for a portfolio
async fn calculate_var(&self, request: VarRequest) -> RiskDataResult<VarResult>;
/// Store `VaR` calculation result
async fn store_var_result(&self, result: VarResult) -> RiskDataResult<()>;
/// Get `VaR` history for a portfolio
async fn get_var_history(
&self,
portfolio_id: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> RiskDataResult<Vec<VarResult>>;
/// Get latest `VaR` for a portfolio
async fn get_latest_var(&self, portfolio_id: &str) -> RiskDataResult<Option<VarResult>>;
/// Store historical price data
async fn store_price_data(&self, prices: Vec<PriceData>) -> RiskDataResult<()>;
/// Get price history for `VaR` calculations
async fn get_price_history(
&self,
symbols: Vec<String>,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> RiskDataResult<HashMap<String, Vec<PriceData>>>;
/// Get portfolio positions
async fn get_portfolio_positions(
&self,
portfolio_id: &str,
) -> RiskDataResult<Vec<PortfolioPosition>>;
/// Calculate portfolio volatility matrix
async fn calculate_volatility_matrix(
&self,
symbols: Vec<String>,
lookback_days: i32,
) -> RiskDataResult<HashMap<String, HashMap<String, Decimal>>>;
/// Run stress test scenarios
async fn run_stress_test(
&self,
portfolio_id: &str,
scenario_name: &str,
stress_factors: HashMap<String, Decimal>,
) -> RiskDataResult<VarResult>;
}
/// `VaR` repository implementation
#[derive(Clone)]
pub struct VarRepositoryImpl {
db_pool: PgPool,
redis_conn: ConnectionManager,
}
impl std::fmt::Debug for VarRepositoryImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VarRepositoryImpl")
.field("db_pool", &"<PgPool>")
.field("redis_conn", &"<ConnectionManager>")
.finish()
}
}
impl VarRepositoryImpl {
pub const fn new(db_pool: PgPool, redis_conn: ConnectionManager) -> Self {
Self {
db_pool,
redis_conn,
}
}
/// Calculate historical `VaR`
async fn calculate_historical_var(
&self,
positions: Vec<PortfolioPosition>,
confidence_level: ConfidenceLevel,
lookback_days: i32,
) -> RiskDataResult<Decimal> {
info!(
"Calculating historical VaR with {} day lookback",
lookback_days
);
// Get symbols from positions
let symbols: Vec<String> = positions.iter().map(|p| p.symbol.clone()).collect();
// Get historical prices
let to_date = Utc::now();
let from_date = to_date - Duration::days(i64::from(lookback_days));
let price_history = self.get_price_history(symbols, from_date, to_date).await?;
// Calculate daily returns for each position
let mut portfolio_returns = Vec::new();
for position in positions {
if let Some(prices) = price_history.get(&position.symbol) {
if prices.len() < 2 {
warn!("Insufficient price data for {}", position.symbol);
continue;
}
// Calculate daily returns
for window in prices.windows(2) {
let prev_price = window[0].price;
let curr_price = window[1].price;
if prev_price > Decimal::ZERO {
let return_rate = (curr_price - prev_price) / prev_price;
let position_return = return_rate * position.market_value;
portfolio_returns.push(position_return);
}
}
}
}
if portfolio_returns.is_empty() {
return Err(RiskDataError::VarCalculation(
"No returns data available".to_owned(),
));
}
// Sort returns and find percentile
portfolio_returns.sort();
let confidence_f64 = confidence_level
.as_decimal()
.to_string()
.parse::<f64>()
.map_err(|e| {
RiskDataError::VarCalculation(format!("Invalid confidence level conversion: {}", e))
})?;
#[allow(clippy::cast_precision_loss)]
let returns_len_f64 = portfolio_returns.len() as f64;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let percentile_index = ((1.0 - confidence_f64) * returns_len_f64) as usize;
let var_amount = portfolio_returns
.get(percentile_index)
.copied()
.ok_or_else(|| {
RiskDataError::VarCalculation("No VaR data at calculated percentile".to_owned())
})?
.abs();
info!("Historical VaR calculated: {}", var_amount);
Ok(var_amount)
}
/// Calculate parametric `VaR` using correlation matrix
async fn calculate_parametric_var(
&self,
positions: Vec<PortfolioPosition>,
confidence_level: ConfidenceLevel,
lookback_days: i32,
) -> RiskDataResult<Decimal> {
info!("Calculating parametric VaR");
let symbols: Vec<String> = positions.iter().map(|p| p.symbol.clone()).collect();
let vol_matrix = self
.calculate_volatility_matrix(symbols, lookback_days)
.await?;
// Calculate portfolio variance using correlation matrix
let mut portfolio_variance = Decimal::ZERO;
for i in 0..positions.len() {
for j in 0..positions.len() {
let pos_i = &positions[i];
let pos_j = &positions[j];
let vol_i = vol_matrix
.get(&pos_i.symbol)
.and_then(|row| row.get(&pos_i.symbol))
.copied()
.ok_or_else(|| {
RiskDataError::VarCalculation(format!(
"Missing volatility data for symbol: {}",
pos_i.symbol
))
})?;
let correlation = if i == j {
Decimal::ONE
} else {
vol_matrix
.get(&pos_i.symbol)
.and_then(|row| row.get(&pos_j.symbol))
.copied()
.ok_or_else(|| {
RiskDataError::VarCalculation(format!(
"Missing correlation data between {} and {}",
pos_i.symbol, pos_j.symbol
))
})?
};
let vol_j = vol_matrix
.get(&pos_j.symbol)
.and_then(|row| row.get(&pos_j.symbol))
.copied()
.ok_or_else(|| {
RiskDataError::VarCalculation(format!(
"Missing volatility data for symbol: {}",
pos_j.symbol
))
})?;
portfolio_variance +=
pos_i.market_value * pos_j.market_value * vol_i * vol_j * correlation;
}
}
// Calculate square root of portfolio variance using f64 conversion
let portfolio_volatility = if portfolio_variance == Decimal::ZERO {
// CRITICAL: Zero portfolio variance indicates a serious risk calculation problem
// This could be due to:
// 1. Missing volatility data (dangerous)
// 2. All positions are zero risk (unlikely in real trading)
// 3. Data corruption or missing correlation matrix
warn!("Portfolio variance is zero - this indicates missing volatility data or data corruption");
// Return error instead of silently using zero volatility
return Err(RiskDataError::VarCalculation(
"Portfolio variance is zero - cannot calculate meaningful VaR. \
This may indicate missing volatility data, data corruption, or incorrect position data.".to_owned()
));
} else {
let variance_f64: f64 = portfolio_variance.try_into().map_err(|e| {
RiskDataError::VarCalculation(format!(
"Portfolio variance conversion failed: {}",
e
))
})?;
let volatility_f64 = variance_f64.sqrt();
Decimal::try_from(volatility_f64).map_err(|e| {
RiskDataError::VarCalculation(format!("Volatility calculation failed: {}", e))
})?
};
// Apply confidence level multiplier (normal distribution quantiles)
let z_score = match confidence_level {
ConfidenceLevel::P95 => Decimal::from_parts(1645, 0, 0, false, 3), // 1.645
ConfidenceLevel::P97_5 => Decimal::from_parts(196, 0, 0, false, 2), // 1.96
ConfidenceLevel::P99 => Decimal::from_parts(2326, 0, 0, false, 3), // 2.326
ConfidenceLevel::P99_9 => Decimal::from_parts(309, 0, 0, false, 2), // 3.09
};
let var_amount = portfolio_volatility * z_score;
info!("Parametric VaR calculated: {}", var_amount);
Ok(var_amount)
}
}
#[async_trait]
impl VarRepository for VarRepositoryImpl {
async fn calculate_var(&self, request: VarRequest) -> RiskDataResult<VarResult> {
info!("Calculating VaR for portfolio {}", request.portfolio_id);
let positions = self.get_portfolio_positions(&request.portfolio_id).await?;
if positions.is_empty() {
return Err(RiskDataError::VarCalculation(
"No positions found for portfolio".to_owned(),
));
}
let var_amount = match request.method {
VarMethod::Historical => {
self.calculate_historical_var(
positions,
request.confidence_level,
request.lookback_days,
)
.await?
},
VarMethod::Parametric => {
self.calculate_parametric_var(
positions,
request.confidence_level,
request.lookback_days,
)
.await?
},
VarMethod::MonteCarlo => {
// Simplified Monte Carlo - would need more sophisticated implementation
warn!("Monte Carlo VaR not fully implemented, using parametric method");
self.calculate_parametric_var(
positions,
request.confidence_level,
request.lookback_days,
)
.await?
},
VarMethod::ExtremeValue => {
// Extreme Value Theory - would need specialized implementation
warn!("Extreme Value VaR not fully implemented, using historical method");
self.calculate_historical_var(
positions,
request.confidence_level,
request.lookback_days,
)
.await?
},
};
// Store currency before moving request
let currency = request.currency.clone();
let result = VarResult {
id: Uuid::new_v4(),
portfolio_id: request.portfolio_id,
method: request.method,
confidence_level: request.confidence_level.as_decimal(),
holding_period_days: request.holding_period_days,
lookback_days: request.lookback_days,
var_amount,
currency: request.currency,
calculation_date: Utc::now(),
volatility: None, // Could be calculated based on method
correlation_adjustment: None,
stress_test_multiplier: None,
metadata: serde_json::json!({}),
};
// Store result
self.store_var_result(result.clone()).await?;
info!("VaR calculation completed: {} {}", var_amount, currency);
Ok(result)
}
async fn store_var_result(&self, result: VarResult) -> RiskDataResult<()> {
let query = "
INSERT INTO var_calculations (
id, portfolio_id, method, confidence_level, holding_period_days,
lookback_days, var_amount, currency, calculation_date,
volatility, correlation_adjustment, stress_test_multiplier, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
var_amount = EXCLUDED.var_amount,
calculation_date = EXCLUDED.calculation_date,
metadata = EXCLUDED.metadata
";
sqlx::query(query)
.bind(result.id)
.bind(&result.portfolio_id)
.bind(result.method)
.bind(result.confidence_level)
.bind(result.holding_period_days)
.bind(result.lookback_days)
.bind(result.var_amount)
.bind(&result.currency)
.bind(result.calculation_date)
.bind(result.volatility)
.bind(result.correlation_adjustment)
.bind(result.stress_test_multiplier)
.bind(&result.metadata)
.execute(&self.db_pool)
.await?;
// Cache latest result in Redis
let cache_key = format!("var:latest:{}", result.portfolio_id);
let serialized = serde_json::to_string(&result)?;
let mut redis_conn = self.redis_conn.clone();
redis::cmd("SETEX")
.arg(&cache_key)
.arg(3600) // 1 hour TTL
.arg(&serialized)
.query_async::<()>(&mut redis_conn)
.await?;
Ok(())
}
async fn get_var_history(
&self,
portfolio_id: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> RiskDataResult<Vec<VarResult>> {
let query = "
SELECT * FROM var_calculations
WHERE portfolio_id = $1
AND calculation_date BETWEEN $2 AND $3
ORDER BY calculation_date DESC
";
let results = sqlx::query_as::<_, VarResult>(query)
.bind(portfolio_id)
.bind(from)
.bind(to)
.fetch_all(&self.db_pool)
.await?;
Ok(results)
}
async fn get_latest_var(&self, portfolio_id: &str) -> RiskDataResult<Option<VarResult>> {
// Try Redis cache first
let cache_key = format!("var:latest:{}", portfolio_id);
let mut redis_conn = self.redis_conn.clone();
if let Ok(cached) = redis::cmd("GET")
.arg(&cache_key)
.query_async::<String>(&mut redis_conn)
.await
{
if let Ok(result) = serde_json::from_str::<VarResult>(&cached) {
return Ok(Some(result));
}
}
// Fallback to database
let query = "
SELECT * FROM var_calculations
WHERE portfolio_id = $1
ORDER BY calculation_date DESC
LIMIT 1
";
let result = sqlx::query_as::<_, VarResult>(query)
.bind(portfolio_id)
.fetch_optional(&self.db_pool)
.await?;
Ok(result)
}
async fn store_price_data(&self, prices: Vec<PriceData>) -> RiskDataResult<()> {
if prices.is_empty() {
return Ok(());
}
let mut transaction = self.db_pool.begin().await?;
for price in prices {
let query = "
INSERT INTO price_history (symbol, price, timestamp, volume)
VALUES ($1, $2, $3, $4)
ON CONFLICT (symbol, timestamp) DO UPDATE SET
price = EXCLUDED.price,
volume = EXCLUDED.volume
";
sqlx::query(query)
.bind(&price.symbol)
.bind(price.price)
.bind(price.timestamp)
.bind(price.volume)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
async fn get_price_history(
&self,
symbols: Vec<String>,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> RiskDataResult<HashMap<String, Vec<PriceData>>> {
let query = "
SELECT symbol, price, timestamp, volume
FROM price_history
WHERE symbol = ANY($1)
AND timestamp BETWEEN $2 AND $3
ORDER BY symbol, timestamp
";
let rows = sqlx::query_as::<_, PriceData>(query)
.bind(&symbols)
.bind(from)
.bind(to)
.fetch_all(&self.db_pool)
.await?;
let mut result = HashMap::new();
for row in rows {
result
.entry(row.symbol.clone())
.or_insert_with(Vec::new)
.push(row);
}
Ok(result)
}
async fn get_portfolio_positions(
&self,
portfolio_id: &str,
) -> RiskDataResult<Vec<PortfolioPosition>> {
// This would typically query a positions table
// For now, returning a placeholder implementation
info!("Getting portfolio positions for {}", portfolio_id);
Ok(vec![])
}
async fn calculate_volatility_matrix(
&self,
symbols: Vec<String>,
lookback_days: i32,
) -> RiskDataResult<HashMap<String, HashMap<String, Decimal>>> {
info!(
"Calculating volatility matrix for {} symbols",
symbols.len()
);
// Get price history
let to_date = Utc::now();
let from_date = to_date - Duration::days(i64::from(lookback_days));
let _price_history = self
.get_price_history(symbols.clone(), from_date, to_date)
.await?;
// Calculate correlation matrix (simplified implementation)
let mut matrix = HashMap::new();
for symbol in &symbols {
let mut row = HashMap::new();
for other_symbol in &symbols {
// Simplified correlation calculation - in production would use proper statistical methods
let correlation = if symbol == other_symbol {
Decimal::ONE
} else {
Decimal::from_str_exact("0.5").expect("Static decimal value should parse")
// Placeholder correlation
};
row.insert(other_symbol.clone(), correlation);
}
matrix.insert(symbol.clone(), row);
}
Ok(matrix)
}
async fn run_stress_test(
&self,
portfolio_id: &str,
scenario_name: &str,
stress_factors: HashMap<String, Decimal>,
) -> RiskDataResult<VarResult> {
info!(
"Running stress test '{}' for portfolio {}",
scenario_name, portfolio_id
);
// Simplified stress test - apply stress factors to current VaR
let base_var = self.get_latest_var(portfolio_id).await?.ok_or_else(|| {
RiskDataError::VarCalculation("No base VaR found for stress test".to_owned())
})?;
// Apply maximum stress factor as multiplier
let max_stress = stress_factors
.values()
.max()
.copied()
.unwrap_or(Decimal::ONE);
let stressed_var = VarResult {
id: Uuid::new_v4(),
var_amount: base_var.var_amount * max_stress,
stress_test_multiplier: Some(max_stress),
calculation_date: Utc::now(),
metadata: serde_json::json!({
"stress_scenario": scenario_name,
"stress_factors": stress_factors,
"base_var_id": base_var.id
}),
..base_var
};
// Store stress test result
self.store_var_result(stressed_var.clone()).await?;
Ok(stressed_var)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_confidence_level_as_decimal() {
assert_eq!(
ConfidenceLevel::P95.as_decimal(),
Decimal::from_str_exact("0.95").unwrap()
);
assert_eq!(
ConfidenceLevel::P99.as_decimal(),
Decimal::from_str_exact("0.99").unwrap()
);
}
#[test]
fn test_var_request_serialization() {
let request = VarRequest {
portfolio_id: "test_portfolio".to_string(),
method: VarMethod::Historical,
confidence_level: ConfidenceLevel::P95,
holding_period_days: 1,
lookback_days: 252,
currency: "USD".to_string(),
};
let json = serde_json::to_string(&request).unwrap();
let deserialized: VarRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request.portfolio_id, deserialized.portfolio_id);
assert_eq!(request.method, deserialized.method);
}
}