//! Position Limits Repository //! //! Manages position limits tracking, enforcement, and breach detection for //! high-frequency trading systems with real-time monitoring capabilities. use async_trait::async_trait; use chrono::{DateTime, Utc}; use redis::aio::ConnectionManager; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use std::collections::HashMap; use tracing::{error, info, warn}; use rust_decimal::Decimal; use uuid::Uuid; use crate::{RiskDataError, RiskDataResult}; /// Position limit types #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "limit_type", rename_all = "snake_case")] pub enum LimitType { MaxPosition, // Maximum position size MaxDailyVolume, // Maximum daily trading volume MaxDailyLoss, // Maximum daily loss MaxDrawdown, // Maximum drawdown VarLimit, // VaR-based limit ConcentrationLimit, // Portfolio concentration limit LeverageLimit, // Maximum leverage ExposureLimit, // Market exposure limit } /// Limit scope (what the limit applies to) #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "limit_scope", rename_all = "snake_case")] pub enum LimitScope { Global, // System-wide limit Portfolio, // Portfolio-specific limit Strategy, // Strategy-specific limit Symbol, // Symbol-specific limit Sector, // Sector-specific limit AssetClass, // Asset class specific limit Trader, // Trader-specific limit } /// Limit breach severity #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "breach_severity", rename_all = "snake_case")] pub enum BreachSeverity { Warning, // Approaching limit (80-90%) Soft, // Soft breach (90-100%) Hard, // Hard breach (>100%) Critical, // Critical breach (>120%) } /// Position limit definition #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct PositionLimit { pub id: Uuid, pub name: String, pub limit_type: LimitType, pub scope: LimitScope, pub scope_value: Option, // Portfolio ID, symbol, etc. pub threshold: Decimal, pub warning_threshold: Option, // Optional warning level pub currency: String, pub enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: String, pub description: Option, pub metadata: serde_json::Value, } /// Current position exposure #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct PositionExposure { pub scope: LimitScope, pub scope_value: String, pub symbol: Option, pub current_position: Decimal, pub current_value: Decimal, pub daily_volume: Decimal, pub daily_pnl: Decimal, pub currency: String, pub updated_at: DateTime, } /// Limit breach event #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct LimitBreach { pub id: Uuid, pub limit_id: Uuid, pub severity: BreachSeverity, pub breach_time: DateTime, pub current_value: Decimal, pub limit_threshold: Decimal, pub breach_percentage: Decimal, pub scope: LimitScope, pub scope_value: Option, pub symbol: Option, pub portfolio_id: Option, pub strategy_id: Option, pub trader_id: Option, pub acknowledged: bool, pub acknowledged_by: Option, pub acknowledged_at: Option>, pub resolved: bool, pub resolved_at: Option>, pub resolution_note: Option, pub action_taken: Option, } /// Limit check request #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LimitCheckRequest { pub scope: LimitScope, pub scope_value: String, pub symbol: Option, pub proposed_position: Decimal, pub proposed_value: Decimal, pub currency: String, } /// Limit check result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LimitCheckResult { pub allowed: bool, pub violations: Vec, pub warnings: Vec, pub max_allowed_position: Option, pub max_allowed_value: Option, } /// Individual limit violation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LimitViolation { pub limit_id: Uuid, pub limit_name: String, pub limit_type: LimitType, pub current_value: Decimal, pub limit_threshold: Decimal, pub breach_percentage: Decimal, pub severity: BreachSeverity, } /// Limits repository trait #[async_trait] pub trait LimitsRepository: Send + Sync + std::fmt::Debug { /// Create or update a position limit async fn upsert_limit(&self, limit: PositionLimit) -> RiskDataResult<()>; /// Get all limits for a scope async fn get_limits( &self, scope: LimitScope, scope_value: Option, ) -> RiskDataResult>; /// Get a specific limit by ID async fn get_limit(&self, limit_id: Uuid) -> RiskDataResult>; /// Delete a limit async fn delete_limit(&self, limit_id: Uuid) -> RiskDataResult<()>; /// Update position exposure async fn update_exposure(&self, exposure: PositionExposure) -> RiskDataResult<()>; /// Get current exposure for a scope async fn get_exposure( &self, scope: LimitScope, scope_value: &str, ) -> RiskDataResult>; /// Check if a proposed position would violate limits async fn check_limits(&self, request: LimitCheckRequest) -> RiskDataResult; /// Record a limit breach async fn record_breach(&self, breach: LimitBreach) -> RiskDataResult<()>; /// Get recent breaches async fn get_breaches( &self, from: DateTime, to: DateTime, severity: Option, ) -> RiskDataResult>; /// Acknowledge a breach async fn acknowledge_breach( &self, breach_id: Uuid, acknowledged_by: String, ) -> RiskDataResult<()>; /// Resolve a breach async fn resolve_breach( &self, breach_id: Uuid, resolution_note: String, action_taken: String, ) -> RiskDataResult<()>; /// Get limit utilization summary async fn get_utilization_summary(&self) -> RiskDataResult>; /// Perform real-time limit monitoring async fn monitor_limits(&self) -> RiskDataResult>; } /// Limits repository implementation #[derive(Clone)] pub struct LimitsRepositoryImpl { db_pool: PgPool, redis_conn: ConnectionManager, } impl std::fmt::Debug for LimitsRepositoryImpl { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LimitsRepositoryImpl") .field("db_pool", &"") .field("redis_conn", &"") .finish() } } impl LimitsRepositoryImpl { pub const fn new(db_pool: PgPool, redis_conn: ConnectionManager) -> Self { Self { db_pool, redis_conn, } } /// Validate limit configuration fn validate_limit(&self, limit: &PositionLimit) -> RiskDataResult<()> { if limit.name.is_empty() { return Err(RiskDataError::LimitsValidation( "Limit name cannot be empty".to_owned(), )); } if limit.threshold <= Decimal::ZERO { return Err(RiskDataError::LimitsValidation( "Limit threshold must be positive".to_owned(), )); } // Validate warning threshold if provided if let Some(warning) = limit.warning_threshold { if warning <= Decimal::ZERO || warning >= limit.threshold { return Err(RiskDataError::LimitsValidation( "Warning threshold must be positive and less than limit threshold".to_owned(), )); } } // Validate scope-specific requirements match limit.scope { LimitScope::Portfolio | LimitScope::Strategy | LimitScope::Symbol | LimitScope::Sector | LimitScope::AssetClass | LimitScope::Trader => { if limit.scope_value.is_none() || limit.scope_value.as_ref().unwrap().is_empty() { return Err(RiskDataError::LimitsValidation(format!( "Scope value required for {:?} limits", limit.scope ))); } } LimitScope::Global => { // Global limits don't require scope_value } } Ok(()) } /// Calculate breach severity based on percentage fn calculate_breach_severity(&self, breach_percentage: Decimal) -> BreachSeverity { if breach_percentage >= Decimal::from(120) { BreachSeverity::Critical } else if breach_percentage >= Decimal::from(100) { BreachSeverity::Hard } else if breach_percentage >= Decimal::from(90) { BreachSeverity::Soft } else { BreachSeverity::Warning } } /// Check a single limit against current exposure async fn check_single_limit( &self, limit: &PositionLimit, current_exposure: &PositionExposure, proposed_value: Decimal, ) -> RiskDataResult> { if !limit.enabled { return Ok(None); } let test_value = match limit.limit_type { LimitType::MaxPosition => current_exposure.current_position + proposed_value, LimitType::MaxDailyVolume => current_exposure.daily_volume + proposed_value.abs(), LimitType::MaxDailyLoss => { if current_exposure.daily_pnl < Decimal::ZERO { current_exposure.daily_pnl.abs() // Current loss } else { Decimal::ZERO } } LimitType::ExposureLimit => current_exposure.current_value + proposed_value, _ => current_exposure.current_value, // Simplified for other types }; let breach_percentage = (test_value / limit.threshold) * Decimal::from(100); // Check for breach or warning let threshold_to_check = if let Some(warning) = limit.warning_threshold { if breach_percentage >= (warning / limit.threshold) * Decimal::from(100) { warning } else { return Ok(None); // No violation } } else { limit.threshold }; if test_value > threshold_to_check { let severity = if test_value > limit.threshold { self.calculate_breach_severity(breach_percentage) } else { BreachSeverity::Warning }; Ok(Some(LimitViolation { limit_id: limit.id, limit_name: limit.name.clone(), limit_type: limit.limit_type, current_value: test_value, limit_threshold: limit.threshold, breach_percentage, severity, })) } else { Ok(None) } } } #[async_trait] impl LimitsRepository for LimitsRepositoryImpl { async fn upsert_limit(&self, limit: PositionLimit) -> RiskDataResult<()> { self.validate_limit(&limit)?; let query = " INSERT INTO position_limits ( id, name, limit_type, scope, scope_value, threshold, warning_threshold, currency, enabled, created_at, updated_at, created_by, description, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, threshold = EXCLUDED.threshold, warning_threshold = EXCLUDED.warning_threshold, enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at, description = EXCLUDED.description, metadata = EXCLUDED.metadata "; sqlx::query(query) .bind(limit.id) .bind(&limit.name) .bind(limit.limit_type) .bind(limit.scope) .bind(&limit.scope_value) .bind(limit.threshold) .bind(limit.warning_threshold) .bind(&limit.currency) .bind(limit.enabled) .bind(limit.created_at) .bind(limit.updated_at) .bind(&limit.created_by) .bind(&limit.description) .bind(&limit.metadata) .execute(&self.db_pool) .await?; // Cache active limits in Redis for fast access if limit.enabled { let cache_key = format!( "limits:{}:{}", limit.scope as u8, limit.scope_value.as_deref().unwrap_or("global") ); let serialized = serde_json::to_string(&limit)?; let mut redis_conn = self.redis_conn.clone(); redis::cmd("HSET") .arg(&cache_key) .arg(limit.id.to_string()) .arg(&serialized) .query_async::<()>(&mut redis_conn) .await?; } info!( "Position limit upserted: {} - {}", limit.name, limit.threshold ); Ok(()) } async fn get_limits( &self, scope: LimitScope, scope_value: Option, ) -> RiskDataResult> { let query = if scope_value.is_some() { "SELECT * FROM position_limits WHERE scope = $1 AND scope_value = $2 ORDER BY name" } else { "SELECT * FROM position_limits WHERE scope = $1 AND scope_value IS NULL ORDER BY name" }; let limits = if let Some(sv) = scope_value { sqlx::query_as::<_, PositionLimit>(query) .bind(scope) .bind(sv) .fetch_all(&self.db_pool) .await? } else { sqlx::query_as::<_, PositionLimit>(query) .bind(scope) .fetch_all(&self.db_pool) .await? }; Ok(limits) } async fn get_limit(&self, limit_id: Uuid) -> RiskDataResult> { let query = "SELECT * FROM position_limits WHERE id = $1"; let limit = sqlx::query_as::<_, PositionLimit>(query) .bind(limit_id) .fetch_optional(&self.db_pool) .await?; Ok(limit) } async fn delete_limit(&self, limit_id: Uuid) -> RiskDataResult<()> { // Remove from database let query = "DELETE FROM position_limits WHERE id = $1"; sqlx::query(query) .bind(limit_id) .execute(&self.db_pool) .await?; // Remove from Redis cache let mut redis_conn = self.redis_conn.clone(); let cache_keys: Vec = redis::cmd("KEYS") .arg("limits:*") .query_async(&mut redis_conn) .await?; for key in cache_keys { redis::cmd("HDEL") .arg(&key) .arg(limit_id.to_string()) .query_async::<()>(&mut redis_conn) .await?; } info!("Position limit deleted: {}", limit_id); Ok(()) } async fn update_exposure(&self, exposure: PositionExposure) -> RiskDataResult<()> { let query = " INSERT INTO position_exposures ( scope, scope_value, symbol, current_position, current_value, daily_volume, daily_pnl, currency, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (scope, scope_value, COALESCE(symbol, '')) DO UPDATE SET current_position = EXCLUDED.current_position, current_value = EXCLUDED.current_value, daily_volume = EXCLUDED.daily_volume, daily_pnl = EXCLUDED.daily_pnl, updated_at = EXCLUDED.updated_at "; sqlx::query(query) .bind(exposure.scope) .bind(&exposure.scope_value) .bind(&exposure.symbol) .bind(exposure.current_position) .bind(exposure.current_value) .bind(exposure.daily_volume) .bind(exposure.daily_pnl) .bind(&exposure.currency) .bind(exposure.updated_at) .execute(&self.db_pool) .await?; // Cache current exposure in Redis for real-time access let cache_key = format!("exposure:{}:{}", exposure.scope as u8, exposure.scope_value); let serialized = serde_json::to_string(&exposure)?; let mut redis_conn = self.redis_conn.clone(); redis::cmd("SETEX") .arg(&cache_key) .arg(300) // 5 minutes TTL .arg(&serialized) .query_async::<()>(&mut redis_conn) .await?; Ok(()) } async fn get_exposure( &self, scope: LimitScope, scope_value: &str, ) -> RiskDataResult> { // Try Redis cache first let cache_key = format!("exposure:{}:{}", scope as u8, scope_value); let mut redis_conn = self.redis_conn.clone(); if let Ok(cached) = redis::cmd("GET") .arg(&cache_key) .query_async::(&mut redis_conn) .await { if let Ok(exposure) = serde_json::from_str::(&cached) { return Ok(Some(exposure)); } } // Fallback to database let query = "SELECT * FROM position_exposures WHERE scope = $1 AND scope_value = $2"; let exposure = sqlx::query_as::<_, PositionExposure>(query) .bind(scope) .bind(scope_value) .fetch_optional(&self.db_pool) .await?; Ok(exposure) } async fn check_limits(&self, request: LimitCheckRequest) -> RiskDataResult { // Get applicable limits let mut all_limits = Vec::new(); // Global limits all_limits.extend(self.get_limits(LimitScope::Global, None).await?); // Scope-specific limits all_limits.extend( self.get_limits(request.scope, Some(request.scope_value.clone())) .await?, ); // Symbol-specific limits if symbol provided if let Some(symbol) = &request.symbol { all_limits.extend( self.get_limits(LimitScope::Symbol, Some(symbol.clone())) .await?, ); } // Get current exposure let current_exposure = self .get_exposure(request.scope, &request.scope_value) .await? .unwrap_or_else(|| PositionExposure { scope: request.scope, scope_value: request.scope_value.clone(), symbol: request.symbol.clone(), current_position: Decimal::ZERO, current_value: Decimal::ZERO, daily_volume: Decimal::ZERO, daily_pnl: Decimal::ZERO, currency: request.currency.clone(), updated_at: Utc::now(), }); let mut violations = Vec::new(); let mut warnings = Vec::new(); // Check each limit for limit in &all_limits { if let Some(violation) = self .check_single_limit(limit, ¤t_exposure, request.proposed_value) .await? { match violation.severity { BreachSeverity::Warning => warnings.push(violation), _ => violations.push(violation), } } } let allowed = violations.is_empty(); // Calculate max allowed position/value if there are violations let (max_allowed_position, max_allowed_value) = if !allowed { // Find the most restrictive limit let min_limit = all_limits .iter() .filter(|l| l.enabled) .map(|l| l.threshold) .min() .unwrap_or(Decimal::ZERO); let max_pos = min_limit - current_exposure.current_position; let max_val = min_limit - current_exposure.current_value; ( Some(max_pos.max(Decimal::ZERO)), Some(max_val.max(Decimal::ZERO)), ) } else { (None, None) }; Ok(LimitCheckResult { allowed, violations, warnings, max_allowed_position, max_allowed_value, }) } async fn record_breach(&self, breach: LimitBreach) -> RiskDataResult<()> { let query = " INSERT INTO limit_breaches ( id, limit_id, severity, breach_time, current_value, limit_threshold, breach_percentage, scope, scope_value, symbol, portfolio_id, strategy_id, trader_id, acknowledged, acknowledged_by, acknowledged_at, resolved, resolved_at, resolution_note, action_taken ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) "; sqlx::query(query) .bind(breach.id) .bind(breach.limit_id) .bind(breach.severity) .bind(breach.breach_time) .bind(breach.current_value) .bind(breach.limit_threshold) .bind(breach.breach_percentage) .bind(breach.scope) .bind(&breach.scope_value) .bind(&breach.symbol) .bind(&breach.portfolio_id) .bind(&breach.strategy_id) .bind(&breach.trader_id) .bind(breach.acknowledged) .bind(&breach.acknowledged_by) .bind(breach.acknowledged_at) .bind(breach.resolved) .bind(breach.resolved_at) .bind(&breach.resolution_note) .bind(&breach.action_taken) .execute(&self.db_pool) .await?; // Cache critical breaches for immediate alerting if matches!( breach.severity, BreachSeverity::Critical | BreachSeverity::Hard ) { let cache_key = format!("breach:critical:{}", breach.id); let serialized = serde_json::to_string(&breach)?; let mut redis_conn = self.redis_conn.clone(); redis::cmd("SETEX") .arg(&cache_key) .arg(7200) // 2 hours TTL .arg(&serialized) .query_async::<()>(&mut redis_conn) .await?; } error!( "Limit breach recorded: {:?} - {} exceeds {} by {}%", breach.severity, breach.current_value, breach.limit_threshold, breach.breach_percentage ); Ok(()) } async fn get_breaches( &self, from: DateTime, to: DateTime, severity: Option, ) -> RiskDataResult> { let query = if severity.is_some() { " SELECT * FROM limit_breaches WHERE breach_time BETWEEN $1 AND $2 AND severity = $3 ORDER BY breach_time DESC " } else { " SELECT * FROM limit_breaches WHERE breach_time BETWEEN $1 AND $2 ORDER BY breach_time DESC " }; let breaches = if let Some(sev) = severity { sqlx::query_as::<_, LimitBreach>(query) .bind(from) .bind(to) .bind(sev) .fetch_all(&self.db_pool) .await? } else { sqlx::query_as::<_, LimitBreach>(query) .bind(from) .bind(to) .fetch_all(&self.db_pool) .await? }; Ok(breaches) } async fn acknowledge_breach( &self, breach_id: Uuid, acknowledged_by: String, ) -> RiskDataResult<()> { let query = " UPDATE limit_breaches SET acknowledged = true, acknowledged_by = $2, acknowledged_at = $3 WHERE id = $1 "; sqlx::query(query) .bind(breach_id) .bind(&acknowledged_by) .bind(Utc::now()) .execute(&self.db_pool) .await?; info!( "Limit breach {} acknowledged by {}", breach_id, acknowledged_by ); Ok(()) } async fn resolve_breach( &self, breach_id: Uuid, resolution_note: String, action_taken: String, ) -> RiskDataResult<()> { let query = " UPDATE limit_breaches SET resolved = true, resolved_at = $2, resolution_note = $3, action_taken = $4 WHERE id = $1 "; sqlx::query(query) .bind(breach_id) .bind(Utc::now()) .bind(&resolution_note) .bind(&action_taken) .execute(&self.db_pool) .await?; info!("Limit breach {} resolved: {}", breach_id, resolution_note); Ok(()) } async fn get_utilization_summary(&self) -> RiskDataResult> { let query = " SELECT l.name, l.threshold, COALESCE(e.current_value, 0) as current_value FROM position_limits l LEFT JOIN position_exposures e ON l.scope = e.scope AND l.scope_value = e.scope_value WHERE l.enabled = true "; let rows = sqlx::query(query).fetch_all(&self.db_pool).await?; let mut summary = HashMap::new(); for row in rows { let name: String = row.get("name"); let threshold: Decimal = row.get("threshold"); let current: Decimal = row.get("current_value"); let utilization = if threshold > Decimal::ZERO { (current / threshold) * Decimal::from(100) } else { Decimal::ZERO }; summary.insert(name, utilization); } Ok(summary) } async fn monitor_limits(&self) -> RiskDataResult> { info!("Starting real-time limit monitoring"); // Get all enabled limits let query = "SELECT * FROM position_limits WHERE enabled = true"; let limits = sqlx::query_as::<_, PositionLimit>(query) .fetch_all(&self.db_pool) .await?; let mut new_breaches = Vec::new(); for limit in limits { let scope_value = limit.scope_value.as_deref().unwrap_or("global"); if let Some(exposure) = self.get_exposure(limit.scope, scope_value).await? { let test_value = match limit.limit_type { LimitType::MaxPosition => exposure.current_position.abs(), LimitType::MaxDailyVolume => exposure.daily_volume, LimitType::MaxDailyLoss => exposure.daily_pnl.abs(), LimitType::ExposureLimit => exposure.current_value.abs(), _ => exposure.current_value, }; if test_value > limit.threshold { let breach_percentage = (test_value / limit.threshold) * Decimal::from(100); let severity = self.calculate_breach_severity(breach_percentage); let breach = LimitBreach { id: Uuid::new_v4(), limit_id: limit.id, severity, breach_time: Utc::now(), current_value: test_value, limit_threshold: limit.threshold, breach_percentage, scope: limit.scope, scope_value: limit.scope_value.clone(), symbol: exposure.symbol, portfolio_id: None, // Would be derived from scope_value strategy_id: None, trader_id: None, acknowledged: false, acknowledged_by: None, acknowledged_at: None, resolved: false, resolved_at: None, resolution_note: None, action_taken: None, }; new_breaches.push(breach); } } } // Record any new breaches for breach in &new_breaches { self.record_breach(breach.clone()).await?; } if !new_breaches.is_empty() { warn!("Detected {} new limit breaches", new_breaches.len()); } Ok(new_breaches) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_breach_severity_calculation() { let repo = LimitsRepositoryImpl { db_pool: PgPool::connect("").unwrap_or_else(|_| panic!("Test DB connection")), redis_conn: panic!("Mock Redis connection"), }; assert_eq!( repo.calculate_breach_severity(Decimal::from(85)), BreachSeverity::Warning ); assert_eq!( repo.calculate_breach_severity(Decimal::from(95)), BreachSeverity::Soft ); assert_eq!( repo.calculate_breach_severity(Decimal::from(105)), BreachSeverity::Hard ); assert_eq!( repo.calculate_breach_severity(Decimal::from(125)), BreachSeverity::Critical ); } #[test] fn test_limit_validation() { let repo = LimitsRepositoryImpl { db_pool: PgPool::connect("").unwrap_or_else(|_| panic!("Test DB connection")), redis_conn: panic!("Mock Redis connection"), }; let valid_limit = PositionLimit { id: Uuid::new_v4(), name: "Test Limit".to_string(), limit_type: LimitType::MaxPosition, scope: LimitScope::Global, scope_value: None, threshold: Decimal::from(100000), warning_threshold: Some(Decimal::from(80000)), currency: "USD".to_string(), enabled: true, created_at: Utc::now(), updated_at: Utc::now(), created_by: "test_user".to_string(), description: Some("Test limit".to_string()), metadata: serde_json::json!({}), }; assert!(repo.validate_limit(&valid_limit).is_ok()); // Test invalid limit (negative threshold) let invalid_limit = PositionLimit { threshold: Decimal::from(-100), ..valid_limit }; assert!(repo.validate_limit(&invalid_limit).is_err()); } }