refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
53
crates/risk-data/Cargo.toml
Normal file
53
crates/risk-data/Cargo.toml
Normal file
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "risk-data"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
publish.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
description = "Risk data repository for high-frequency trading risk management"
|
||||
|
||||
[dependencies]
|
||||
# Core workspace dependencies
|
||||
|
||||
# Database dependencies
|
||||
sqlx.workspace = true
|
||||
redis.workspace = true
|
||||
|
||||
# Async runtime and utilities
|
||||
async-trait.workspace = true
|
||||
|
||||
# Serialization and data handling
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
|
||||
# Risk calculation dependencies
|
||||
|
||||
# Logging and monitoring
|
||||
tracing.workspace = true
|
||||
|
||||
# Error handling
|
||||
thiserror.workspace = true
|
||||
|
||||
# Performance utilities
|
||||
|
||||
# Development and testing dependencies
|
||||
[dev-dependencies]
|
||||
# Note: These dev-dependencies are currently unused but kept for future testing needs
|
||||
# tokio-test.workspace = true
|
||||
# tempfile.workspace = true
|
||||
# rstest.workspace = true
|
||||
# proptest.workspace = true
|
||||
# testcontainers.workspace = true # REMOVED - too heavy
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
989
crates/risk-data/src/compliance.rs
Normal file
989
crates/risk-data/src/compliance.rs
Normal file
@@ -0,0 +1,989 @@
|
||||
//! Compliance Repository
|
||||
//!
|
||||
//! Manages regulatory compliance logging for SOX, `MiFID` II, and other financial regulations.
|
||||
//! Provides comprehensive audit trails, regulatory reporting, and compliance monitoring.
|
||||
|
||||
// Allow pedantic lints that would require extensive API changes
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
#![allow(clippy::missing_docs_in_private_items)]
|
||||
#![allow(clippy::arithmetic_side_effects)]
|
||||
#![allow(clippy::as_conversions)]
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use redis::aio::ConnectionManager;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{RiskDataError, RiskDataResult};
|
||||
|
||||
/// Regulatory frameworks
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "regulatory_framework", rename_all = "snake_case")]
|
||||
pub enum RegulatoryFramework {
|
||||
/// Sarbanes-Oxley Act - US corporate financial reporting
|
||||
Sox,
|
||||
/// Markets in Financial Instruments Directive II - EU financial markets regulation
|
||||
MifidII,
|
||||
/// Dodd-Frank Act - US financial reform legislation
|
||||
DoddFrank,
|
||||
/// Basel III regulations - international banking regulations
|
||||
BaselIII,
|
||||
/// European Market Infrastructure Regulation - EU derivatives regulation
|
||||
Emir,
|
||||
/// Markets in Financial Instruments Regulation - EU market structure regulation
|
||||
Mifir,
|
||||
/// General Data Protection Regulation - EU data privacy regulation
|
||||
Gdpr,
|
||||
}
|
||||
|
||||
/// Compliance event types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "compliance_event_type", rename_all = "snake_case")]
|
||||
pub enum ComplianceEventType {
|
||||
/// Trade execution event requiring compliance logging
|
||||
TradeExecution,
|
||||
/// Order placement event for audit trail
|
||||
OrderPlacement,
|
||||
/// Order cancellation event for regulatory reporting
|
||||
OrderCancellation,
|
||||
/// Order modification event for tracking changes
|
||||
OrderModification,
|
||||
/// Risk limit breach requiring immediate attention
|
||||
RiskBreach,
|
||||
/// Position or exposure limit exceeded
|
||||
LimitExceeded,
|
||||
/// Best execution analysis and validation
|
||||
BestExecutionCheck,
|
||||
/// Regulatory transaction reporting submission
|
||||
TransactionReporting,
|
||||
/// Sensitive data access for audit purposes
|
||||
DataAccess,
|
||||
/// System access and authentication events
|
||||
SystemAccess,
|
||||
/// Configuration change requiring audit trail
|
||||
ConfigurationChange,
|
||||
/// Emergency action taken requiring documentation
|
||||
EmergencyAction,
|
||||
}
|
||||
|
||||
/// Compliance severity levels
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "compliance_severity", rename_all = "snake_case")]
|
||||
pub enum ComplianceSeverity {
|
||||
/// Informational event for audit trail
|
||||
Info,
|
||||
/// Warning level requiring monitoring
|
||||
Warning,
|
||||
/// Critical event requiring immediate review
|
||||
Critical,
|
||||
/// Regulatory breach requiring escalation and reporting
|
||||
Breach,
|
||||
}
|
||||
|
||||
/// Best execution criteria
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BestExecutionCriteria {
|
||||
/// Whether price improvement was achieved
|
||||
pub price_improvement: bool,
|
||||
/// Whether execution speed was optimal
|
||||
pub speed_of_execution: bool,
|
||||
/// Whether execution was likely to occur
|
||||
pub likelihood_of_execution: bool,
|
||||
/// Whether order size was considered
|
||||
pub size_of_order: bool,
|
||||
/// Whether market impact was minimized
|
||||
pub market_impact: bool,
|
||||
/// Whether transaction costs were minimized
|
||||
pub costs_of_transaction: bool,
|
||||
}
|
||||
|
||||
/// Compliance event record
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct ComplianceEvent {
|
||||
/// Unique identifier for the compliance event
|
||||
pub id: Uuid,
|
||||
/// Regulatory framework this event relates to
|
||||
pub framework: RegulatoryFramework,
|
||||
/// Type of compliance event
|
||||
pub event_type: ComplianceEventType,
|
||||
/// Severity level of the event
|
||||
pub severity: ComplianceSeverity,
|
||||
/// When the event occurred
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// User ID associated with the event
|
||||
pub user_id: Option<String>,
|
||||
/// Session ID when the event occurred
|
||||
pub session_id: Option<String>,
|
||||
/// Order ID if related to trading
|
||||
pub order_id: Option<String>,
|
||||
/// Trade ID if related to execution
|
||||
pub trade_id: Option<String>,
|
||||
/// Financial instrument symbol
|
||||
pub symbol: Option<String>,
|
||||
/// Quantity involved in the event
|
||||
pub quantity: Option<Decimal>,
|
||||
/// Price involved in the event
|
||||
pub price: Option<Decimal>,
|
||||
/// Trading venue where event occurred
|
||||
pub venue: Option<String>,
|
||||
/// Counterparty involved in the event
|
||||
pub counterparty: Option<String>,
|
||||
/// Human-readable description of the event
|
||||
pub description: String,
|
||||
/// Additional structured details as JSON
|
||||
pub details: serde_json::Value,
|
||||
/// Calculated risk score (0-100)
|
||||
pub risk_score: Option<Decimal>,
|
||||
/// Whether remediation action is required
|
||||
pub remediation_required: bool,
|
||||
/// Current status of remediation efforts
|
||||
pub remediation_status: Option<String>,
|
||||
/// Whether event was reported to regulator
|
||||
pub reported_to_regulator: bool,
|
||||
/// Regulator's reference number for the report
|
||||
pub regulator_reference: Option<String>,
|
||||
}
|
||||
|
||||
/// Transaction reporting record for `MiFID` II
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct TransactionReport {
|
||||
/// Unique identifier for this transaction report
|
||||
pub id: Uuid,
|
||||
/// Trade identifier for the executed transaction
|
||||
pub trade_id: String,
|
||||
/// Financial instrument identifier
|
||||
pub instrument_id: String,
|
||||
/// International Securities Identification Number
|
||||
pub isin: Option<String>,
|
||||
/// Date when the transaction was executed
|
||||
pub trading_date: DateTime<Utc>,
|
||||
/// Time when the transaction was executed
|
||||
pub trading_time: DateTime<Utc>,
|
||||
/// Quantity of the transaction
|
||||
pub quantity: Decimal,
|
||||
/// Price of the transaction
|
||||
pub price: Decimal,
|
||||
/// Currency of the transaction (ISO 3-letter code)
|
||||
pub currency: String,
|
||||
/// Trading venue where the transaction occurred
|
||||
pub venue: String,
|
||||
/// Counterparty identifier for the transaction
|
||||
pub counterparty_id: String,
|
||||
/// Person within firm responsible for investment decision
|
||||
pub investment_decision_within_firm: String,
|
||||
/// Person within firm responsible for execution decision
|
||||
pub execution_decision_within_firm: String,
|
||||
/// Client identification code (if applicable)
|
||||
pub client_identification: Option<String>,
|
||||
/// Whether this was a short selling transaction
|
||||
pub short_selling_indicator: bool,
|
||||
/// Commodity derivative indicator (if applicable)
|
||||
pub commodity_derivative_indicator: Option<String>,
|
||||
/// Whether this is a securities financing transaction
|
||||
pub securities_financing_transaction_indicator: bool,
|
||||
/// Waiver indicator for pre-trade transparency
|
||||
pub waiver_indicator: Option<String>,
|
||||
/// Timestamp when the report was generated
|
||||
pub reported_at: DateTime<Utc>,
|
||||
/// Current status of the report submission
|
||||
pub report_status: String,
|
||||
/// Approved Reporting Mechanism reference number
|
||||
pub arm_reference: Option<String>,
|
||||
}
|
||||
|
||||
/// Best execution report
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct BestExecutionReport {
|
||||
/// Unique identifier for this best execution report
|
||||
pub id: Uuid,
|
||||
/// Order identifier this report relates to
|
||||
pub order_id: String,
|
||||
/// Financial instrument symbol
|
||||
pub symbol: String,
|
||||
/// Type of order (Market, Limit, Stop, etc.)
|
||||
pub order_type: String,
|
||||
/// Quantity of the order
|
||||
pub quantity: Decimal,
|
||||
/// Price requested by the client (if applicable)
|
||||
pub requested_price: Option<Decimal>,
|
||||
/// Actual execution price achieved
|
||||
pub execution_price: Decimal,
|
||||
/// Venue where the order was executed
|
||||
pub execution_venue: String,
|
||||
/// Time when the order was executed
|
||||
pub execution_time: DateTime<Utc>,
|
||||
/// Price improvement achieved (if any)
|
||||
pub price_improvement: Option<Decimal>,
|
||||
/// Execution speed in milliseconds
|
||||
pub execution_speed_ms: i64,
|
||||
/// Market impact in basis points
|
||||
pub market_impact_bps: Option<Decimal>,
|
||||
/// Total transaction costs in basis points
|
||||
pub total_costs_bps: Option<Decimal>,
|
||||
/// Alternative venues that could have been used (JSON)
|
||||
pub alternative_venues: serde_json::Value,
|
||||
/// Analysis against best execution criteria (JSON)
|
||||
pub criteria_analysis: serde_json::Value,
|
||||
/// Whether best execution was achieved
|
||||
pub best_execution_achieved: bool,
|
||||
/// Justification if best execution was not achieved
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
/// Audit trail entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct AuditTrail {
|
||||
/// Unique identifier for this audit trail entry
|
||||
pub id: Uuid,
|
||||
/// Timestamp when the action occurred
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// User who performed the action
|
||||
pub user_id: String,
|
||||
/// Session identifier when the action occurred
|
||||
pub session_id: String,
|
||||
/// Action that was performed
|
||||
pub action: String,
|
||||
/// Resource that was accessed or modified
|
||||
pub resource: String,
|
||||
/// Specific resource identifier (if applicable)
|
||||
pub resource_id: Option<String>,
|
||||
/// IP address of the user
|
||||
pub ip_address: Option<String>,
|
||||
/// User agent string from the request
|
||||
pub user_agent: Option<String>,
|
||||
/// Whether the action was successful
|
||||
pub success: bool,
|
||||
/// Error message if the action failed
|
||||
pub error_message: Option<String>,
|
||||
/// State of the resource before the action (JSON)
|
||||
pub before_state: Option<serde_json::Value>,
|
||||
/// State of the resource after the action (JSON)
|
||||
pub after_state: Option<serde_json::Value>,
|
||||
/// Whether sensitive data was accessed
|
||||
pub sensitive_data_accessed: bool,
|
||||
/// Date until which this audit record must be retained
|
||||
pub retention_required_until: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Compliance repository trait
|
||||
#[async_trait]
|
||||
pub trait ComplianceRepository: Send + Sync + std::fmt::Debug {
|
||||
/// Log a compliance event
|
||||
async fn log_event(&self, event: ComplianceEvent) -> RiskDataResult<()>;
|
||||
|
||||
/// Get compliance events within a time range
|
||||
async fn get_events(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
framework: Option<RegulatoryFramework>,
|
||||
severity: Option<ComplianceSeverity>,
|
||||
) -> RiskDataResult<Vec<ComplianceEvent>>;
|
||||
|
||||
/// Report transaction for `MiFID` II compliance
|
||||
async fn report_transaction(&self, report: TransactionReport) -> RiskDataResult<()>;
|
||||
|
||||
/// Get transaction reports
|
||||
async fn get_transaction_reports(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<TransactionReport>>;
|
||||
|
||||
/// Log best execution analysis
|
||||
async fn log_best_execution(&self, report: BestExecutionReport) -> RiskDataResult<()>;
|
||||
|
||||
/// Get best execution reports
|
||||
async fn get_best_execution_reports(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<BestExecutionReport>>;
|
||||
|
||||
/// Create audit trail entry
|
||||
async fn create_audit_trail(&self, entry: AuditTrail) -> RiskDataResult<()>;
|
||||
|
||||
/// Search audit trail
|
||||
async fn search_audit_trail(
|
||||
&self,
|
||||
user_id: Option<String>,
|
||||
action: Option<String>,
|
||||
resource: Option<String>,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<AuditTrail>>;
|
||||
|
||||
/// Generate regulatory report
|
||||
async fn generate_regulatory_report(
|
||||
&self,
|
||||
framework: RegulatoryFramework,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<serde_json::Value>;
|
||||
|
||||
/// Check compliance violations
|
||||
async fn check_violations(&self, trade_id: &str) -> RiskDataResult<Vec<ComplianceEvent>>;
|
||||
|
||||
/// Mark event as reported to regulator
|
||||
async fn mark_reported(&self, event_id: Uuid, reference: String) -> RiskDataResult<()>;
|
||||
}
|
||||
|
||||
/// Compliance repository implementation
|
||||
#[derive(Clone)]
|
||||
pub struct ComplianceRepositoryImpl {
|
||||
db_pool: PgPool,
|
||||
redis_conn: ConnectionManager,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ComplianceRepositoryImpl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ComplianceRepositoryImpl")
|
||||
.field("db_pool", &"<PgPool>")
|
||||
.field("redis_conn", &"<ConnectionManager>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ComplianceRepositoryImpl {
|
||||
pub const fn new(db_pool: PgPool, redis_conn: ConnectionManager) -> Self {
|
||||
Self {
|
||||
db_pool,
|
||||
redis_conn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate compliance event data
|
||||
fn validate_event(event: &ComplianceEvent) -> RiskDataResult<()> {
|
||||
if event.description.is_empty() {
|
||||
return Err(RiskDataError::ComplianceValidation(
|
||||
"Event description cannot be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
// Specific validations per event type
|
||||
match event.event_type {
|
||||
ComplianceEventType::TradeExecution | ComplianceEventType::OrderPlacement => {
|
||||
if event.order_id.is_none() && event.trade_id.is_none() {
|
||||
return Err(RiskDataError::ComplianceValidation(
|
||||
"Trade/order events must have order_id or trade_id".to_owned(),
|
||||
));
|
||||
}
|
||||
},
|
||||
ComplianceEventType::RiskBreach | ComplianceEventType::LimitExceeded => {
|
||||
if event.severity != ComplianceSeverity::Critical
|
||||
&& event.severity != ComplianceSeverity::Breach
|
||||
{
|
||||
return Err(RiskDataError::ComplianceValidation(
|
||||
"Risk breaches must have Critical or Breach severity".to_owned(),
|
||||
));
|
||||
}
|
||||
},
|
||||
ComplianceEventType::OrderCancellation
|
||||
| ComplianceEventType::OrderModification
|
||||
| ComplianceEventType::BestExecutionCheck
|
||||
| ComplianceEventType::TransactionReporting
|
||||
| ComplianceEventType::DataAccess
|
||||
| ComplianceEventType::SystemAccess
|
||||
| ComplianceEventType::ConfigurationChange
|
||||
| ComplianceEventType::EmergencyAction => {},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate risk score for compliance event
|
||||
fn calculate_risk_score(event: &ComplianceEvent) -> Decimal {
|
||||
let mut score = Decimal::ZERO;
|
||||
|
||||
// Base score by severity
|
||||
score += match event.severity {
|
||||
ComplianceSeverity::Info => Decimal::from(10_i32),
|
||||
ComplianceSeverity::Warning => Decimal::from(30_i32),
|
||||
ComplianceSeverity::Critical => Decimal::from(70_i32),
|
||||
ComplianceSeverity::Breach => Decimal::from(100_i32),
|
||||
};
|
||||
|
||||
// Additional score by event type
|
||||
score += match event.event_type {
|
||||
ComplianceEventType::RiskBreach | ComplianceEventType::LimitExceeded => {
|
||||
Decimal::from(30_i32)
|
||||
},
|
||||
ComplianceEventType::EmergencyAction => Decimal::from(25_i32),
|
||||
ComplianceEventType::ConfigurationChange => Decimal::from(20_i32),
|
||||
ComplianceEventType::BestExecutionCheck => Decimal::from(15_i32),
|
||||
ComplianceEventType::TradeExecution
|
||||
| ComplianceEventType::OrderPlacement
|
||||
| ComplianceEventType::OrderCancellation
|
||||
| ComplianceEventType::OrderModification
|
||||
| ComplianceEventType::TransactionReporting
|
||||
| ComplianceEventType::DataAccess
|
||||
| ComplianceEventType::SystemAccess => {
|
||||
tracing::warn!("Unknown compliance event type - using minimum severity score");
|
||||
Decimal::from(1_i32) // Minimum score for unknown event types
|
||||
},
|
||||
};
|
||||
|
||||
// Framework-specific adjustments
|
||||
score += match event.framework {
|
||||
RegulatoryFramework::Sox => Decimal::from(20_i32),
|
||||
RegulatoryFramework::MifidII => Decimal::from(15_i32),
|
||||
RegulatoryFramework::DoddFrank => Decimal::from(15_i32),
|
||||
RegulatoryFramework::BaselIII
|
||||
| RegulatoryFramework::Emir
|
||||
| RegulatoryFramework::Mifir
|
||||
| RegulatoryFramework::Gdpr => {
|
||||
tracing::warn!("Unknown regulatory framework - using minimum score");
|
||||
Decimal::from(1_i32) // Minimum score for unknown frameworks
|
||||
},
|
||||
};
|
||||
|
||||
score.min(Decimal::from(100_i32)) // Cap at 100
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ComplianceRepository for ComplianceRepositoryImpl {
|
||||
async fn log_event(&self, mut event: ComplianceEvent) -> RiskDataResult<()> {
|
||||
Self::validate_event(&event)?;
|
||||
|
||||
// Calculate risk score if not provided
|
||||
if event.risk_score.is_none() {
|
||||
event.risk_score = Some(Self::calculate_risk_score(&event));
|
||||
}
|
||||
|
||||
let query = "
|
||||
INSERT INTO compliance_events (
|
||||
id, framework, event_type, severity, timestamp, user_id, session_id,
|
||||
order_id, trade_id, symbol, quantity, price, venue, counterparty,
|
||||
description, details, risk_score, remediation_required,
|
||||
remediation_status, reported_to_regulator, regulator_reference
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
";
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(event.id)
|
||||
.bind(event.framework)
|
||||
.bind(event.event_type)
|
||||
.bind(event.severity)
|
||||
.bind(event.timestamp)
|
||||
.bind(&event.user_id)
|
||||
.bind(&event.session_id)
|
||||
.bind(&event.order_id)
|
||||
.bind(&event.trade_id)
|
||||
.bind(&event.symbol)
|
||||
.bind(event.quantity)
|
||||
.bind(event.price)
|
||||
.bind(&event.venue)
|
||||
.bind(&event.counterparty)
|
||||
.bind(&event.description)
|
||||
.bind(&event.details)
|
||||
.bind(event.risk_score)
|
||||
.bind(event.remediation_required)
|
||||
.bind(&event.remediation_status)
|
||||
.bind(event.reported_to_regulator)
|
||||
.bind(&event.regulator_reference)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
// Cache high-risk events in Redis for quick access
|
||||
if let Some(risk_score) = event.risk_score {
|
||||
if risk_score >= Decimal::from(70_i32) {
|
||||
let cache_key = format!("compliance:high_risk:{}", event.id);
|
||||
let serialized = serde_json::to_string(&event)?;
|
||||
|
||||
let mut redis_conn = self.redis_conn.clone();
|
||||
redis::cmd("SETEX")
|
||||
.arg(&cache_key)
|
||||
.arg(86_400_i32) // 24 hours TTL
|
||||
.arg(&serialized)
|
||||
.query_async::<()>(&mut redis_conn)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Compliance event logged: {} - {} - {}",
|
||||
event.framework as u8, event.event_type as u8, event.description
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)] // Writing to String cannot fail
|
||||
async fn get_events(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
framework: Option<RegulatoryFramework>,
|
||||
severity: Option<ComplianceSeverity>,
|
||||
) -> RiskDataResult<Vec<ComplianceEvent>> {
|
||||
let mut query =
|
||||
"SELECT * FROM compliance_events WHERE timestamp BETWEEN $1 AND $2".to_owned();
|
||||
let mut bind_count = 2_i32;
|
||||
|
||||
if framework.is_some() {
|
||||
bind_count += 1_i32;
|
||||
use std::fmt::Write;
|
||||
write!(&mut query, " AND framework = ${}", bind_count)
|
||||
.expect("Writing to String should never fail");
|
||||
}
|
||||
|
||||
if severity.is_some() {
|
||||
bind_count += 1_i32;
|
||||
use std::fmt::Write;
|
||||
write!(&mut query, " AND severity = ${}", bind_count)
|
||||
.expect("Writing to String should never fail");
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY timestamp DESC");
|
||||
|
||||
let mut db_query = sqlx::query_as::<_, ComplianceEvent>(&query)
|
||||
.bind(from)
|
||||
.bind(to);
|
||||
|
||||
if let Some(fw) = framework {
|
||||
db_query = db_query.bind(fw);
|
||||
}
|
||||
|
||||
if let Some(sev) = severity {
|
||||
db_query = db_query.bind(sev);
|
||||
}
|
||||
|
||||
let events = db_query.fetch_all(&self.db_pool).await?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn report_transaction(&self, report: TransactionReport) -> RiskDataResult<()> {
|
||||
let query = "
|
||||
INSERT INTO transaction_reports (
|
||||
id, trade_id, instrument_id, isin, trading_date, trading_time,
|
||||
quantity, price, currency, venue, counterparty_id,
|
||||
investment_decision_within_firm, execution_decision_within_firm,
|
||||
client_identification, short_selling_indicator,
|
||||
commodity_derivative_indicator, securities_financing_transaction_indicator,
|
||||
waiver_indicator, reported_at, report_status, arm_reference
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
ON CONFLICT (trade_id) DO UPDATE SET
|
||||
report_status = EXCLUDED.report_status,
|
||||
arm_reference = EXCLUDED.arm_reference
|
||||
";
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(report.id)
|
||||
.bind(&report.trade_id)
|
||||
.bind(&report.instrument_id)
|
||||
.bind(&report.isin)
|
||||
.bind(report.trading_date)
|
||||
.bind(report.trading_time)
|
||||
.bind(report.quantity)
|
||||
.bind(report.price)
|
||||
.bind(&report.currency)
|
||||
.bind(&report.venue)
|
||||
.bind(&report.counterparty_id)
|
||||
.bind(&report.investment_decision_within_firm)
|
||||
.bind(&report.execution_decision_within_firm)
|
||||
.bind(&report.client_identification)
|
||||
.bind(report.short_selling_indicator)
|
||||
.bind(&report.commodity_derivative_indicator)
|
||||
.bind(report.securities_financing_transaction_indicator)
|
||||
.bind(&report.waiver_indicator)
|
||||
.bind(report.reported_at)
|
||||
.bind(&report.report_status)
|
||||
.bind(&report.arm_reference)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
info!("Transaction report submitted: {}", report.trade_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_transaction_reports(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<TransactionReport>> {
|
||||
let query = "
|
||||
SELECT * FROM transaction_reports
|
||||
WHERE trading_date BETWEEN $1 AND $2
|
||||
ORDER BY trading_time DESC
|
||||
";
|
||||
|
||||
let reports = sqlx::query_as::<_, TransactionReport>(query)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
async fn log_best_execution(&self, report: BestExecutionReport) -> RiskDataResult<()> {
|
||||
let query = "
|
||||
INSERT INTO best_execution_reports (
|
||||
id, order_id, symbol, order_type, quantity, requested_price,
|
||||
execution_price, execution_venue, execution_time, price_improvement,
|
||||
execution_speed_ms, market_impact_bps, total_costs_bps,
|
||||
alternative_venues, criteria_analysis, best_execution_achieved, justification
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
";
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(report.id)
|
||||
.bind(&report.order_id)
|
||||
.bind(&report.symbol)
|
||||
.bind(&report.order_type)
|
||||
.bind(report.quantity)
|
||||
.bind(report.requested_price)
|
||||
.bind(report.execution_price)
|
||||
.bind(&report.execution_venue)
|
||||
.bind(report.execution_time)
|
||||
.bind(report.price_improvement)
|
||||
.bind(report.execution_speed_ms)
|
||||
.bind(report.market_impact_bps)
|
||||
.bind(report.total_costs_bps)
|
||||
.bind(&report.alternative_venues)
|
||||
.bind(&report.criteria_analysis)
|
||||
.bind(report.best_execution_achieved)
|
||||
.bind(&report.justification)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
// Log compliance event if best execution was not achieved
|
||||
if !report.best_execution_achieved {
|
||||
let compliance_event = ComplianceEvent {
|
||||
id: Uuid::new_v4(),
|
||||
framework: RegulatoryFramework::MifidII,
|
||||
event_type: ComplianceEventType::BestExecutionCheck,
|
||||
severity: ComplianceSeverity::Warning,
|
||||
timestamp: Utc::now(),
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
order_id: Some(report.order_id.clone()),
|
||||
trade_id: None,
|
||||
symbol: Some(report.symbol.clone()),
|
||||
quantity: Some(report.quantity),
|
||||
price: Some(report.execution_price),
|
||||
venue: Some(report.execution_venue.clone()),
|
||||
counterparty: None,
|
||||
description: "Best execution not achieved".to_owned(),
|
||||
details: serde_json::json!({
|
||||
"justification": report.justification,
|
||||
"price_improvement": report.price_improvement,
|
||||
"execution_speed_ms": report.execution_speed_ms
|
||||
}),
|
||||
risk_score: None,
|
||||
remediation_required: true,
|
||||
remediation_status: Some("pending".to_owned()),
|
||||
reported_to_regulator: false,
|
||||
regulator_reference: None,
|
||||
};
|
||||
|
||||
self.log_event(compliance_event).await?;
|
||||
}
|
||||
|
||||
info!("Best execution report logged: {}", report.order_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_best_execution_reports(
|
||||
&self,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<BestExecutionReport>> {
|
||||
let query = "
|
||||
SELECT * FROM best_execution_reports
|
||||
WHERE execution_time BETWEEN $1 AND $2
|
||||
ORDER BY execution_time DESC
|
||||
";
|
||||
|
||||
let reports = sqlx::query_as::<_, BestExecutionReport>(query)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
async fn create_audit_trail(&self, entry: AuditTrail) -> RiskDataResult<()> {
|
||||
let query = "
|
||||
INSERT INTO audit_trails (
|
||||
id, timestamp, user_id, session_id, action, resource, resource_id,
|
||||
ip_address, user_agent, success, error_message, before_state,
|
||||
after_state, sensitive_data_accessed, retention_required_until
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
";
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(entry.id)
|
||||
.bind(entry.timestamp)
|
||||
.bind(&entry.user_id)
|
||||
.bind(&entry.session_id)
|
||||
.bind(&entry.action)
|
||||
.bind(&entry.resource)
|
||||
.bind(&entry.resource_id)
|
||||
.bind(&entry.ip_address)
|
||||
.bind(&entry.user_agent)
|
||||
.bind(entry.success)
|
||||
.bind(&entry.error_message)
|
||||
.bind(&entry.before_state)
|
||||
.bind(&entry.after_state)
|
||||
.bind(entry.sensitive_data_accessed)
|
||||
.bind(entry.retention_required_until)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
// Cache failed access attempts for security monitoring
|
||||
if !entry.success {
|
||||
let cache_key = format!("audit:failed:{}:{}", entry.user_id, entry.resource);
|
||||
let mut redis_conn = self.redis_conn.clone();
|
||||
|
||||
redis::cmd("INCR")
|
||||
.arg(&cache_key)
|
||||
.query_async::<()>(&mut redis_conn)
|
||||
.await?;
|
||||
|
||||
redis::cmd("EXPIRE")
|
||||
.arg(&cache_key)
|
||||
.arg(3_600_i32) // 1 hour window
|
||||
.query_async::<()>(&mut redis_conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)] // Writing to String cannot fail
|
||||
async fn search_audit_trail(
|
||||
&self,
|
||||
user_id: Option<String>,
|
||||
action: Option<String>,
|
||||
resource: Option<String>,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<Vec<AuditTrail>> {
|
||||
let mut query = "SELECT * FROM audit_trails WHERE timestamp BETWEEN $1 AND $2".to_owned();
|
||||
let mut bind_count = 2_i32;
|
||||
|
||||
if user_id.is_some() {
|
||||
bind_count += 1_i32;
|
||||
use std::fmt::Write;
|
||||
write!(&mut query, " AND user_id = ${}", bind_count)
|
||||
.expect("Writing to String should never fail");
|
||||
}
|
||||
|
||||
if action.is_some() {
|
||||
bind_count += 1_i32;
|
||||
use std::fmt::Write;
|
||||
write!(&mut query, " AND action = ${}", bind_count)
|
||||
.expect("Writing to String should never fail");
|
||||
}
|
||||
|
||||
if resource.is_some() {
|
||||
bind_count += 1_i32;
|
||||
use std::fmt::Write;
|
||||
write!(&mut query, " AND resource = ${}", bind_count)
|
||||
.expect("Writing to String should never fail");
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY timestamp DESC LIMIT 1000");
|
||||
|
||||
let mut db_query = sqlx::query_as::<_, AuditTrail>(&query).bind(from).bind(to);
|
||||
|
||||
if let Some(uid) = user_id {
|
||||
db_query = db_query.bind(uid);
|
||||
}
|
||||
|
||||
if let Some(act) = action {
|
||||
db_query = db_query.bind(act);
|
||||
}
|
||||
|
||||
if let Some(res) = resource {
|
||||
db_query = db_query.bind(res);
|
||||
}
|
||||
|
||||
let entries = db_query.fetch_all(&self.db_pool).await?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn generate_regulatory_report(
|
||||
&self,
|
||||
framework: RegulatoryFramework,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> RiskDataResult<serde_json::Value> {
|
||||
let events = self.get_events(from, to, Some(framework), None).await?;
|
||||
|
||||
let report = match framework {
|
||||
RegulatoryFramework::Sox => {
|
||||
serde_json::json!({
|
||||
"framework": "SOX",
|
||||
"period": {"from": from, "to": to},
|
||||
"total_events": events.len(),
|
||||
"critical_events": events.iter().filter(|e| e.severity == ComplianceSeverity::Critical).count(),
|
||||
"breaches": events.iter().filter(|e| e.severity == ComplianceSeverity::Breach).count(),
|
||||
"remediation_pending": events.iter().filter(|e| e.remediation_required && e.remediation_status.as_deref() == Some("pending")).count(),
|
||||
"events": events
|
||||
})
|
||||
},
|
||||
RegulatoryFramework::MifidII => {
|
||||
let transaction_reports = self.get_transaction_reports(from, to).await?;
|
||||
let best_execution_reports = self.get_best_execution_reports(from, to).await?;
|
||||
|
||||
serde_json::json!({
|
||||
"framework": "MiFID II",
|
||||
"period": {"from": from, "to": to},
|
||||
"transaction_reports": transaction_reports.len(),
|
||||
"best_execution_reports": best_execution_reports.len(),
|
||||
"compliance_events": events.len(),
|
||||
"transactions": transaction_reports,
|
||||
"best_execution": best_execution_reports,
|
||||
"events": events
|
||||
})
|
||||
},
|
||||
RegulatoryFramework::DoddFrank
|
||||
| RegulatoryFramework::BaselIII
|
||||
| RegulatoryFramework::Emir
|
||||
| RegulatoryFramework::Mifir
|
||||
| RegulatoryFramework::Gdpr => {
|
||||
serde_json::json!({
|
||||
"framework": format!("{:?}", framework),
|
||||
"period": {"from": from, "to": to},
|
||||
"total_events": events.len(),
|
||||
"events": events
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn check_violations(&self, trade_id: &str) -> RiskDataResult<Vec<ComplianceEvent>> {
|
||||
let query = "
|
||||
SELECT * FROM compliance_events
|
||||
WHERE trade_id = $1
|
||||
AND severity IN ('critical', 'breach')
|
||||
ORDER BY timestamp DESC
|
||||
";
|
||||
|
||||
let violations = sqlx::query_as::<_, ComplianceEvent>(query)
|
||||
.bind(trade_id)
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
Ok(violations)
|
||||
}
|
||||
|
||||
async fn mark_reported(&self, event_id: Uuid, reference: String) -> RiskDataResult<()> {
|
||||
let query = "
|
||||
UPDATE compliance_events
|
||||
SET reported_to_regulator = true, regulator_reference = $2
|
||||
WHERE id = $1
|
||||
";
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(event_id)
|
||||
.bind(&reference)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Compliance event {} marked as reported with reference {}",
|
||||
event_id, reference
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
#[allow(clippy::default_numeric_fallback)]
|
||||
#[allow(clippy::diverging_sub_expression)]
|
||||
#[allow(clippy::used_underscore_binding)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_compliance_event_validation() {
|
||||
// Test event validation logic without needing a database connection
|
||||
let event = ComplianceEvent {
|
||||
id: Uuid::new_v4(),
|
||||
framework: RegulatoryFramework::Sox,
|
||||
event_type: ComplianceEventType::TradeExecution,
|
||||
severity: ComplianceSeverity::Info,
|
||||
timestamp: Utc::now(),
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
order_id: None,
|
||||
trade_id: None,
|
||||
symbol: None,
|
||||
quantity: None,
|
||||
price: None,
|
||||
venue: None,
|
||||
counterparty: None,
|
||||
description: "".to_string(), // Empty description should fail
|
||||
details: serde_json::json!({}),
|
||||
risk_score: None,
|
||||
remediation_required: false,
|
||||
remediation_status: None,
|
||||
reported_to_regulator: false,
|
||||
regulator_reference: None,
|
||||
};
|
||||
|
||||
// Inline validation logic to test without database
|
||||
assert!(
|
||||
event.description.is_empty(),
|
||||
"Description should be empty for test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_risk_score_calculation() {
|
||||
// Test risk score calculation logic without database
|
||||
let event = ComplianceEvent {
|
||||
id: Uuid::new_v4(),
|
||||
framework: RegulatoryFramework::Sox,
|
||||
event_type: ComplianceEventType::RiskBreach,
|
||||
severity: ComplianceSeverity::Breach,
|
||||
timestamp: Utc::now(),
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
order_id: None,
|
||||
trade_id: None,
|
||||
symbol: None,
|
||||
quantity: None,
|
||||
price: None,
|
||||
venue: None,
|
||||
counterparty: None,
|
||||
description: "Test risk breach".to_string(),
|
||||
details: serde_json::json!({}),
|
||||
risk_score: None,
|
||||
remediation_required: true,
|
||||
remediation_status: None,
|
||||
reported_to_regulator: false,
|
||||
regulator_reference: None,
|
||||
};
|
||||
|
||||
// Test basic risk score calculation logic inline
|
||||
// Higher severity should result in higher base score
|
||||
let base_score = match event.severity {
|
||||
ComplianceSeverity::Info => Decimal::from(10_i32),
|
||||
ComplianceSeverity::Warning => Decimal::from(25_i32),
|
||||
ComplianceSeverity::Breach => Decimal::from(50_i32),
|
||||
ComplianceSeverity::Critical => Decimal::from(75_i32),
|
||||
};
|
||||
|
||||
assert!(base_score > Decimal::ZERO);
|
||||
assert!(base_score <= Decimal::from(100_i32));
|
||||
assert_eq!(
|
||||
base_score,
|
||||
Decimal::from(50_i32),
|
||||
"Breach severity should have base score of 50"
|
||||
);
|
||||
}
|
||||
}
|
||||
163
crates/risk-data/src/lib.rs
Normal file
163
crates/risk-data/src/lib.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Risk Data Repository
|
||||
//!
|
||||
//! Production-ready risk management data layer for high-frequency trading systems.
|
||||
//! Provides repositories for `VaR` calculations, compliance logging, position limits,
|
||||
//! and risk data models with `PostgreSQL` and Redis integration.
|
||||
|
||||
#![allow(missing_docs)] // Internal implementation details
|
||||
// Allow pedantic lints for repository implementation
|
||||
#![allow(clippy::missing_docs_in_private_items)]
|
||||
#![allow(clippy::clone_on_ref_ptr)]
|
||||
#![allow(clippy::str_to_string)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod compliance;
|
||||
pub mod limits;
|
||||
pub mod models;
|
||||
pub mod var;
|
||||
|
||||
// Import the repository traits and implementations
|
||||
use crate::compliance::{ComplianceRepository, ComplianceRepositoryImpl};
|
||||
use crate::limits::{LimitsRepository, LimitsRepositoryImpl};
|
||||
use crate::var::{VarRepository, VarRepositoryImpl};
|
||||
|
||||
/// Configuration for the risk data repository
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RiskDataConfig {
|
||||
/// `PostgreSQL` database URL
|
||||
pub database_url: String,
|
||||
/// Redis connection URL
|
||||
pub redis_url: String,
|
||||
/// Maximum database connections
|
||||
pub max_connections: u32,
|
||||
/// Connection timeout in seconds
|
||||
pub connection_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for RiskDataConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: "postgresql://localhost/foxhunt".to_owned(),
|
||||
redis_url: "redis://localhost:6379".to_owned(),
|
||||
max_connections: 10,
|
||||
connection_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main risk data repository facade providing access to all risk data operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RiskDataRepository {
|
||||
var_repo: Arc<dyn VarRepository>,
|
||||
compliance_repo: Arc<dyn ComplianceRepository>,
|
||||
limits_repo: Arc<dyn LimitsRepository>,
|
||||
}
|
||||
|
||||
impl RiskDataRepository {
|
||||
/// Create a new risk data repository with the given configuration
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns error if the operation fails
|
||||
pub async fn new(config: RiskDataConfig) -> Result<Self, RiskDataError> {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(config.max_connections)
|
||||
.acquire_timeout(std::time::Duration::from_secs(
|
||||
config.connection_timeout_secs,
|
||||
))
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
let redis_client = redis::Client::open(config.redis_url)?;
|
||||
let redis_conn = redis::aio::ConnectionManager::new(redis_client).await?;
|
||||
|
||||
let var_repo = Arc::new(VarRepositoryImpl::new(pool.clone(), redis_conn.clone()));
|
||||
let compliance_repo = Arc::new(ComplianceRepositoryImpl::new(
|
||||
pool.clone(),
|
||||
redis_conn.clone(),
|
||||
));
|
||||
let limits_repo = Arc::new(LimitsRepositoryImpl::new(pool.clone(), redis_conn));
|
||||
|
||||
Ok(Self {
|
||||
var_repo,
|
||||
compliance_repo,
|
||||
limits_repo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get `VaR` repository
|
||||
pub fn var(&self) -> Arc<dyn VarRepository> {
|
||||
Arc::clone(&self.var_repo)
|
||||
}
|
||||
|
||||
/// Get compliance repository
|
||||
pub fn compliance(&self) -> Arc<dyn ComplianceRepository> {
|
||||
Arc::clone(&self.compliance_repo)
|
||||
}
|
||||
|
||||
/// Get limits repository
|
||||
pub fn limits(&self) -> Arc<dyn LimitsRepository> {
|
||||
Arc::clone(&self.limits_repo)
|
||||
}
|
||||
}
|
||||
|
||||
/// Common error types for risk data operations
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RiskDataError {
|
||||
/// Database connection or query error
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
/// Redis cache operation error
|
||||
#[error("Redis error: {0}")]
|
||||
Redis(#[from] redis::RedisError),
|
||||
|
||||
/// JSON serialization/deserialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
/// `VaR` calculation or modeling error
|
||||
#[error("VaR calculation error: {0}")]
|
||||
VarCalculation(String),
|
||||
|
||||
/// Compliance rule validation error
|
||||
#[error("Compliance validation error: {0}")]
|
||||
ComplianceValidation(String),
|
||||
|
||||
/// Position limits validation error
|
||||
#[error("Position limits validation error: {0}")]
|
||||
LimitsValidation(String),
|
||||
|
||||
/// Configuration setup or validation error
|
||||
#[error("Configuration error: {0}")]
|
||||
Configuration(String),
|
||||
|
||||
/// Invalid input data error
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
}
|
||||
|
||||
/// Result type for risk data operations
|
||||
pub type RiskDataResult<T> = Result<T, RiskDataError>;
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_risk_data_config_default() {
|
||||
let config = RiskDataConfig::default();
|
||||
assert_eq!(config.database_url, "postgresql://localhost/foxhunt");
|
||||
assert_eq!(config.redis_url, "redis://localhost:6379");
|
||||
assert_eq!(config.max_connections, 10);
|
||||
assert_eq!(config.connection_timeout_secs, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_risk_data_error_display() {
|
||||
let error = RiskDataError::VarCalculation("Test error".to_string());
|
||||
assert_eq!(error.to_string(), "VaR calculation error: Test error");
|
||||
}
|
||||
}
|
||||
1078
crates/risk-data/src/limits.rs
Normal file
1078
crates/risk-data/src/limits.rs
Normal file
File diff suppressed because it is too large
Load Diff
1042
crates/risk-data/src/models.rs
Normal file
1042
crates/risk-data/src/models.rs
Normal file
File diff suppressed because it is too large
Load Diff
837
crates/risk-data/src/var.rs
Normal file
837
crates/risk-data/src/var.rs
Normal file
@@ -0,0 +1,837 @@
|
||||
//! `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>> {
|
||||
// STUB: requires broker or database integration
|
||||
info!("Getting portfolio positions for {}", portfolio_id);
|
||||
warn!("STUB: get_portfolio_positions returns empty -- wire to broker/DB for real positions");
|
||||
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?;
|
||||
|
||||
// Build per-symbol log-return series from price history
|
||||
let returns: HashMap<String, Vec<f64>> = {
|
||||
let mut map = HashMap::new();
|
||||
for symbol in &symbols {
|
||||
let prices = price_history.get(symbol).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if prices.len() >= 2 {
|
||||
let mut sym_returns = Vec::with_capacity(prices.len() - 1);
|
||||
for i in 1..prices.len() {
|
||||
let prev: f64 = prices[i - 1].price.try_into().unwrap_or(f64::NAN);
|
||||
let curr: f64 = prices[i].price.try_into().unwrap_or(f64::NAN);
|
||||
if prev > 0.0 && curr > 0.0 {
|
||||
sym_returns.push((curr / prev).ln());
|
||||
}
|
||||
}
|
||||
map.insert(symbol.clone(), sym_returns);
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
|
||||
// Minimum observations required for a reliable Pearson estimate
|
||||
const MIN_OBS: usize = 30;
|
||||
|
||||
// Helper: compute Pearson correlation between two equal-length return slices
|
||||
let pearson = |xs: &[f64], ys: &[f64]| -> f64 {
|
||||
let n = xs.len() as f64;
|
||||
let mean_x = xs.iter().sum::<f64>() / n;
|
||||
let mean_y = ys.iter().sum::<f64>() / n;
|
||||
let cov = xs.iter().zip(ys).map(|(x, y)| (x - mean_x) * (y - mean_y)).sum::<f64>();
|
||||
let std_x = (xs.iter().map(|x| (x - mean_x).powi(2)).sum::<f64>() / n).sqrt();
|
||||
let std_y = (ys.iter().map(|y| (y - mean_y).powi(2)).sum::<f64>() / n).sqrt();
|
||||
if std_x == 0.0 || std_y == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
cov / (std_x * std_y)
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate correlation matrix using real Pearson correlation where data is sufficient
|
||||
let mut matrix = HashMap::new();
|
||||
|
||||
for symbol in &symbols {
|
||||
let mut row = HashMap::new();
|
||||
for other_symbol in &symbols {
|
||||
let correlation = if symbol == other_symbol {
|
||||
Decimal::ONE
|
||||
} else {
|
||||
let xs = returns.get(symbol).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let ys = returns.get(other_symbol).map(Vec::as_slice).unwrap_or(&[]);
|
||||
// Align lengths to the shorter series
|
||||
let len = xs.len().min(ys.len());
|
||||
if len >= MIN_OBS {
|
||||
let r = pearson(&xs[..len], &ys[..len]);
|
||||
Decimal::try_from(r).unwrap_or_else(|_| {
|
||||
warn!(
|
||||
"Pearson correlation conversion failed for ({}, {}), using 0.5",
|
||||
symbol, other_symbol
|
||||
);
|
||||
Decimal::from_str_exact("0.5").unwrap_or(Decimal::ZERO)
|
||||
})
|
||||
} else {
|
||||
warn!(
|
||||
"Insufficient observations for ({}, {}) correlation: {} < {} minimum, using 0.5 fallback",
|
||||
symbol, other_symbol, len, MIN_OBS
|
||||
);
|
||||
Decimal::from_str_exact("0.5").unwrap_or(Decimal::ZERO)
|
||||
}
|
||||
};
|
||||
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 each stress factor individually and accumulate the total impact.
|
||||
// Each factor multiplies the base VaR contribution additively beyond the baseline,
|
||||
// so the combined stressed VaR reflects all factor shocks simultaneously.
|
||||
let mut cumulative_multiplier = Decimal::ONE;
|
||||
for (factor_name, factor_value) in &stress_factors {
|
||||
info!(
|
||||
"Applying stress factor '{}' = {} to scenario '{}'",
|
||||
factor_name, factor_value, scenario_name
|
||||
);
|
||||
// Each factor shifts the multiplier by its excess above 1.0 (additive shocks)
|
||||
let excess = *factor_value - Decimal::ONE;
|
||||
cumulative_multiplier += excess;
|
||||
}
|
||||
// Ensure the multiplier is at least 1.0 so stress never reduces risk below base
|
||||
if cumulative_multiplier < Decimal::ONE {
|
||||
cumulative_multiplier = Decimal::ONE;
|
||||
}
|
||||
info!(
|
||||
"Combined stress multiplier for scenario '{}': {}",
|
||||
scenario_name, cumulative_multiplier
|
||||
);
|
||||
|
||||
let stressed_var = VarResult {
|
||||
id: Uuid::new_v4(),
|
||||
var_amount: base_var.var_amount * cumulative_multiplier,
|
||||
stress_test_multiplier: Some(cumulative_multiplier),
|
||||
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)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user