diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS04_PortfolioPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS04_PortfolioPolicy.cs new file mode 100644 index 00000000..6e2d9f25 --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/Domain/VS04_PortfolioPolicy.cs @@ -0,0 +1,245 @@ +namespace KArtSell.Modules.ModelOperations.Domain; + +/// +/// VS-04 DOMAIN: Portfolio Composition Policy +/// +/// Pure business logic (no I/O): +/// - Aggregate positions into portfolio +/// - Calculate weights +/// - Detect drift vs target +/// - Validate rebalance feasibility +/// +/// All decisions: deterministic, testable, traceable +/// + +public record Position( + string Symbol, + decimal Quantity, + decimal MarketPrice, + decimal CostBasisPerUnit); + +public record PortfolioSnapshot( + Guid PortfolioId, + DateOnly SnapshotDate, + List Positions, + decimal TotalMarketValue); + +public record TargetWeight( + string Symbol, + decimal TargetPercent); + +public record WeightBreakdown( + string Symbol, + decimal Quantity, + decimal MarketValue, + decimal WeightPercent, + decimal TargetPercent, + decimal DriftPercent); + +public record RebalanceAnalysis( + List Breakdown, + decimal WorstDriftPercent, + bool ExceedsDriftThreshold, + List TradesRequired); + +public static class PortfolioPolicy +{ + /// + /// Aggregate positions into portfolio snapshot + /// Calculates total market value + /// + public static PortfolioSnapshot AggregatePortfolio( + Guid portfolioId, + DateOnly snapshotDate, + List positions) + { + if (positions == null || positions.Count == 0) + return new PortfolioSnapshot(portfolioId, snapshotDate, new(), 0); + + var totalValue = positions + .Where(p => p.Quantity > 0 && p.MarketPrice > 0) + .Sum(p => p.Quantity * p.MarketPrice); + + return new PortfolioSnapshot(portfolioId, snapshotDate, positions, totalValue); + } + + /// + /// Calculate current weights from portfolio snapshot + /// + public static List CalculateCurrentWeights(PortfolioSnapshot portfolio) + { + if (portfolio.TotalMarketValue == 0) + return new(); + + return portfolio.Positions + .Where(p => p.Quantity > 0 && p.MarketPrice > 0) + .Select(p => + { + var value = p.Quantity * p.MarketPrice; + var weight = (value / portfolio.TotalMarketValue) * 100; + return new WeightBreakdown( + Symbol: p.Symbol, + Quantity: p.Quantity, + MarketValue: value, + WeightPercent: Math.Round(weight, 2), + TargetPercent: 0, + DriftPercent: 0); + }) + .OrderByDescending(w => w.WeightPercent) + .ToList(); + } + + /// + /// Detect drift from target weights + /// + public static RebalanceAnalysis AnalyzeDrift( + PortfolioSnapshot portfolio, + List targetWeights, + decimal driftThreshold) + { + var currentWeights = CalculateCurrentWeights(portfolio); + + var breakdown = currentWeights + .Select(current => + { + var target = targetWeights.FirstOrDefault(t => t.Symbol == current.Symbol)?.TargetPercent ?? 0; + var drift = Math.Abs(current.WeightPercent - target); + return current with + { + TargetPercent = target, + DriftPercent = Math.Round(drift, 2) + }; + }) + .ToList(); + + // Add missing symbols (not in current portfolio) + foreach (var target in targetWeights.Where(t => !breakdown.Any(b => b.Symbol == t.Symbol))) + { + breakdown.Add(new WeightBreakdown( + Symbol: target.Symbol, + Quantity: 0, + MarketValue: 0, + WeightPercent: 0, + TargetPercent: target.TargetPercent, + DriftPercent: target.TargetPercent)); + } + + var worstDrift = breakdown.Max(b => b.DriftPercent); + var exceedsDrift = worstDrift > driftThreshold; + + // Determine trades (rebalance to target) + var trades = breakdown + .Where(b => b.DriftPercent > driftThreshold / 2) // Trade if drift > half threshold + .Select(b => b.WeightPercent > b.TargetPercent + ? $"SELL {b.Symbol} to reduce {b.WeightPercent}% → {b.TargetPercent}%" + : $"BUY {b.Symbol} to increase {b.WeightPercent}% → {b.TargetPercent}%") + .ToList(); + + return new RebalanceAnalysis( + Breakdown: breakdown.OrderByDescending(b => b.DriftPercent).ToList(), + WorstDriftPercent: worstDrift, + ExceedsDriftThreshold: exceedsDrift, + TradesRequired: trades); + } + + /// + /// Validate position concentrations (risk limits) + /// + public static (bool IsValid, List Violations) ValidateConcentration( + PortfolioSnapshot portfolio, + decimal maxSinglePosition = 40, + decimal maxTopFivePercent = 60) + { + var violations = new List(); + var weights = CalculateCurrentWeights(portfolio); + + // Check single position limit + var maxPosition = weights.FirstOrDefault(); + if (maxPosition != null && maxPosition.WeightPercent > maxSinglePosition) + violations.Add($"Single position {maxPosition.Symbol} exceeds {maxSinglePosition}% limit (actual: {maxPosition.WeightPercent}%)"); + + // Check top-5 concentration + var topFive = weights.Take(5).Sum(w => w.WeightPercent); + if (topFive > maxTopFivePercent) + violations.Add($"Top 5 holdings exceed {maxTopFivePercent}% limit (actual: {topFive}%)"); + + return (violations.Count == 0, violations); + } + + /// + /// Calculate rebalance cost (trading slippage + fees) + /// Rough estimate: 0.1% per trade, 0.05% per share + /// + public static decimal EstimateRebalanceCost( + RebalanceAnalysis analysis, + decimal slippageBps = 10m, // 10 basis points per trade + decimal feePercent = 0.001m) // 0.1% commission + { + var tradeCount = analysis.TradesRequired.Count; + var portfolioValue = analysis.Breakdown.Sum(b => b.MarketValue); + + if (portfolioValue == 0) + return 0; + + var slippageCost = (portfolioValue * slippageBps / 10000); + var tradeFeesCost = (portfolioValue * feePercent) * tradeCount; + + return Math.Round(slippageCost + tradeFeesCost, 2); + } + + /// + /// Detect if portfolio is sufficiently balanced (no rebalance needed) + /// + public static bool IsBalanced( + RebalanceAnalysis analysis, + decimal driftThreshold = 5) + { + return analysis.WorstDriftPercent <= driftThreshold; + } + + /// + /// Validate rebalance request (feasibility check) + /// + public static (bool IsValid, List Issues) ValidateRebalanceRequest( + PortfolioSnapshot portfolio, + List targetWeights, + decimal minPortfolioValue = 1000) + { + var issues = new List(); + + if (portfolio.TotalMarketValue < minPortfolioValue) + issues.Add($"Portfolio too small (${portfolio.TotalMarketValue}, minimum ${minPortfolioValue})"); + + if (!targetWeights.Any()) + issues.Add("No target weights specified"); + + var targetSum = targetWeights.Sum(t => t.TargetPercent); + if (Math.Abs(targetSum - 100) > 1m) // Allow 1% tolerance + issues.Add($"Target weights don't sum to 100% (actual: {targetSum}%)"); + + foreach (var target in targetWeights.Where(t => t.TargetPercent < 0 || t.TargetPercent > 100)) + issues.Add($"Invalid target weight for {target.Symbol}: {target.TargetPercent}%"); + + return (issues.Count == 0, issues); + } + + /// + /// Generate rebalance summary (human-readable) + /// + public static string SummarizeRebalance(RebalanceAnalysis analysis) + { + if (analysis.TradesRequired.Count == 0) + return "Portfolio is already balanced. No trades needed."; + + var summary = $"Rebalancing required ({analysis.TradesRequired.Count} trades):\n"; + foreach (var trade in analysis.TradesRequired.Take(5)) + { + summary += $" • {trade}\n"; + } + + if (analysis.TradesRequired.Count > 5) + summary += $" • ... and {analysis.TradesRequired.Count - 5} more trades"; + + return summary; + } +} diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS05_RiskMetricsPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS05_RiskMetricsPolicy.cs new file mode 100644 index 00000000..e31c6457 --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/Domain/VS05_RiskMetricsPolicy.cs @@ -0,0 +1,232 @@ +namespace KArtSell.Modules.ModelOperations.Domain; + +/// +/// VS-05 DOMAIN: Risk Metrics Policy +/// +/// Pure business logic (no I/O): +/// - Value at Risk (VAR) calculation +/// - Sharpe ratio (risk-adjusted return) +/// - Sortino ratio (downside focus) +/// - Concentration metrics +/// +/// All calculations: deterministic, numerically stable +/// + +public record PriceHistory( + string Symbol, + List<(DateOnly Date, decimal Price)> Prices); + +public record PortfolioReturns( + List DailyReturns, + int SampleSize); + +public record RiskMetrics( + decimal VAR95, + decimal Sharpe, + decimal Sortino, + decimal Volatility, + decimal TopFivePercent, + decimal HirschmanIndex, + decimal MaxSinglePosition); + +public static class RiskMetricsPolicy +{ + /// + /// Calculate daily returns from price series + /// + public static PortfolioReturns CalculateReturns( + List prices, + int lookbackDays = 252) + { + if (prices.Count < 2) + return new PortfolioReturns(new(), 0); + + var returns = new List(); + for (int i = 1; i < prices.Count && i <= lookbackDays; i++) + { + if (prices[i - 1] > 0) + { + var dailyReturn = (prices[i] - prices[i - 1]) / prices[i - 1]; + returns.Add(dailyReturn); + } + } + + return new PortfolioReturns(returns, returns.Count); + } + + /// + /// Calculate Value at Risk (95% confidence, parametric method) + /// VAR = Mean - (1.645 * StdDev) + /// + public static decimal CalculateVAR95( + PortfolioReturns returns, + decimal portfolioValue) + { + if (returns.DailyReturns.Count < 30) + return 0; // Insufficient data + + var mean = returns.DailyReturns.Average(); + var variance = returns.DailyReturns.Sum(r => (r - mean) * (r - mean)) / returns.DailyReturns.Count; + var stdDev = (decimal)Math.Sqrt((double)variance); + + // 95% confidence: z-score = 1.645 + var dailyVAR = mean - (1.645m * stdDev); + + // Annualize (252 trading days) + var annualizedVAR = dailyVAR * (decimal)Math.Sqrt(252); + + // Apply to portfolio value + return Math.Abs(annualizedVAR * portfolioValue); + } + + /// + /// Calculate Sharpe Ratio + /// Sharpe = (Return - RiskFreeRate) / StdDev + /// + public static decimal CalculateSharpe( + PortfolioReturns returns, + decimal riskFreeRate = 0.045m) + { + if (returns.DailyReturns.Count < 30) + return 0; + + var mean = returns.DailyReturns.Average(); + var variance = returns.DailyReturns.Sum(r => (r - mean) * (r - mean)) / returns.DailyReturns.Count; + var stdDev = (decimal)Math.Sqrt((double)variance); + + if (stdDev == 0) + return 0; + + // Annualize + var annualReturn = (1 + mean) * (decimal)Math.Pow(1 + (double)mean, 251) - 1; + var annualVolatility = stdDev * (decimal)Math.Sqrt(252); + + return Math.Round((annualReturn - riskFreeRate) / annualVolatility, 3); + } + + /// + /// Calculate Sortino Ratio (downside focus) + /// Sortino = (Return - RiskFreeRate) / DownsideDeviation + /// + public static decimal CalculateSortino( + PortfolioReturns returns, + decimal riskFreeRate = 0.045m) + { + if (returns.DailyReturns.Count < 30) + return 0; + + var mean = returns.DailyReturns.Average(); + + // Downside deviation (only negative returns) + var downsideVariance = returns.DailyReturns + .Where(r => r < 0) + .Sum(r => r * r) / returns.DailyReturns.Count; + var downsideDeviation = (decimal)Math.Sqrt((double)downsideVariance); + + if (downsideDeviation == 0) + return 0; + + // Annualize + var annualReturn = (1 + mean) * (decimal)Math.Pow(1 + (double)mean, 251) - 1; + var annualDownsideDeviation = downsideDeviation * (decimal)Math.Sqrt(252); + + return Math.Round((annualReturn - riskFreeRate) / annualDownsideDeviation, 3); + } + + /// + /// Calculate annualized volatility + /// + public static decimal CalculateVolatility(PortfolioReturns returns) + { + if (returns.DailyReturns.Count < 30) + return 0; + + var mean = returns.DailyReturns.Average(); + var variance = returns.DailyReturns.Sum(r => (r - mean) * (r - mean)) / returns.DailyReturns.Count; + var dailyStdDev = (decimal)Math.Sqrt((double)variance); + + return Math.Round(dailyStdDev * (decimal)Math.Sqrt(252), 4); + } + + /// + /// Calculate concentration metrics + /// Top-5 as %, Hirschman index (0-1) + /// + public static (decimal TopFivePercent, decimal HirschmanIndex, decimal MaxPosition) CalculateConcentration( + List weights) + { + if (!weights.Any()) + return (0, 0, 0); + + var topFive = weights.Take(5).Sum(w => w.WeightPercent); + var maxPosition = weights.First().WeightPercent; // Already sorted descending + + // Hirschman Index (Herfindahl): Σ(weight%)² + var hirschman = weights.Sum(w => w.WeightPercent * w.WeightPercent) / 10000m; + + return ( + Math.Round(topFive, 2), + Math.Round(Math.Min(hirschman, 1), 2), + Math.Round(maxPosition, 2)); + } + + /// + /// Detect concentration risks + /// + public static List DetectConcentrationRisks( + List weights, + decimal maxSinglePosition = 40, + decimal maxTopFivePercent = 60) + { + var risks = new List(); + + if (!weights.Any()) + return risks; + + var maxPosition = weights.First().WeightPercent; + if (maxPosition > maxSinglePosition) + risks.Add($"High single-position concentration: {maxPosition}% > {maxSinglePosition}%"); + + var topFive = weights.Take(5).Sum(w => w.WeightPercent); + if (topFive > maxTopFivePercent) + risks.Add($"High top-5 concentration: {topFive}% > {maxTopFivePercent}%"); + + return risks; + } + + /// + /// Calculate data quality score + /// Factors: price availability, return distribution, sample size + /// + public static (int QualityScore, List Issues) AssessDataQuality( + PortfolioReturns returns, + int minSampleSize = 30) + { + var score = 100; + var issues = new List(); + + if (returns.SampleSize < minSampleSize) + { + score -= (minSampleSize - returns.SampleSize) * 2; + issues.Add($"Insufficient data: {returns.SampleSize} days < {minSampleSize}"); + } + + // Check for extreme values + if (returns.DailyReturns.Any(r => r > 1 || r < -1)) + { + score -= 30; + issues.Add("Extreme or invalid returns detected"); + } + + // Check distribution skewness (simplified) + var mean = returns.DailyReturns.Average(); + var outliers = returns.DailyReturns.Count(r => Math.Abs(r - mean) > 0.1m); + if (outliers > returns.SampleSize * 0.1m) + { + score -= 15; + issues.Add("High outlier count detected"); + } + + return (Math.Max(0, score), issues); + } +} diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS06_StressTestingPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS06_StressTestingPolicy.cs new file mode 100644 index 00000000..f3f770fe --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/Domain/VS06_StressTestingPolicy.cs @@ -0,0 +1,240 @@ +namespace KArtSell.Modules.ModelOperations.Domain; + +/// +/// VS-06 DOMAIN: Stress Testing Policy +/// +/// Pure business logic (no I/O): +/// - Apply scenario shocks to prices +/// - Calculate portfolio loss under stress +/// - Identify worst-case exposures +/// +/// All scenarios: deterministic, repeatable +/// + +public record ScenarioShock( + string AssetClass, + decimal PriceShockPercent, + decimal VolatilityMultiplier = 1.0m); + +public record StressedPosition( + string Symbol, + decimal BaselinePrice, + decimal StressedPrice, + decimal Quantity, + decimal BaselineValue, + decimal StressedValue, + decimal Loss, + decimal LossPercent); + +public record StressScenarioResult( + string ScenarioId, + decimal BaselinePortfolioValue, + decimal StressedPortfolioValue, + decimal PortfolioLoss, + decimal PortfolioLossPercent, + List PositionResults, + StressedPosition WorstPosition, + decimal BaselineVAR, + decimal StressedVAR); + +public static class StressTestingPolicy +{ + /// + /// Apply price shocks to positions (scenario) + /// + public static List ApplyScenarioShock( + List currentPositions, + List shocks, + Func getAssetClass) // Map symbol to asset class + { + var results = new List(); + + foreach (var position in currentPositions.Where(p => p.MarketValue > 0)) + { + var assetClass = getAssetClass(position.Symbol); + var shock = shocks.FirstOrDefault(s => s.AssetClass == assetClass) + ?? shocks.First(); // Default shock if not found + + // Apply price shock + var shockFactor = 1 + shock.PriceShockPercent; + var baselinePrice = position.MarketValue / position.Quantity; + var stressedPrice = baselinePrice * shockFactor; + + var stressedValue = position.Quantity * stressedPrice; + var loss = stressedValue - position.MarketValue; + var lossPercent = (loss / position.MarketValue) * 100; + + results.Add(new StressedPosition( + Symbol: position.Symbol, + BaselinePrice: Math.Round(baselinePrice, 2), + StressedPrice: Math.Round(stressedPrice, 2), + Quantity: position.Quantity, + BaselineValue: position.MarketValue, + StressedValue: Math.Round(stressedValue, 2), + Loss: Math.Round(loss, 2), + LossPercent: Math.Round(lossPercent, 2))); + } + + return results.OrderBy(p => p.Loss).ToList(); // Worst first + } + + /// + /// Calculate portfolio-level impact + /// + public static StressScenarioResult CalculateStressResult( + string scenarioId, + decimal baselinePortfolioValue, + decimal baselineVAR, + List stressedPositions) + { + if (!stressedPositions.Any()) + { + var emptyResult = new StressedPosition( + Symbol: "", + BaselinePrice: 0, + StressedPrice: 0, + Quantity: 0, + BaselineValue: 0, + StressedValue: 0, + Loss: 0, + LossPercent: 0); + return new StressScenarioResult( + scenarioId, baselinePortfolioValue, baselinePortfolioValue, 0, 0, + new(), emptyResult, baselineVAR, baselineVAR); + } + + var stressedPortfolioValue = stressedPositions.Sum(p => p.StressedValue); + var totalLoss = stressedPortfolioValue - baselinePortfolioValue; + var lossPercent = (totalLoss / baselinePortfolioValue) * 100; + + var worstPosition = stressedPositions.FirstOrDefault() ?? stressedPositions.First(); // Already sorted + + // Estimate VAR increase (rough: loss increases VAR proportionally) + var varChange = Math.Abs(lossPercent) / 100 * baselineVAR; + var stressedVAR = baselineVAR + varChange; + + return new StressScenarioResult( + ScenarioId: scenarioId, + BaselinePortfolioValue: baselinePortfolioValue, + StressedPortfolioValue: Math.Round(stressedPortfolioValue, 2), + PortfolioLoss: Math.Round(totalLoss, 2), + PortfolioLossPercent: Math.Round(lossPercent, 2), + PositionResults: stressedPositions, + WorstPosition: worstPosition, + BaselineVAR: baselineVAR, + StressedVAR: Math.Round(stressedVAR, 2)); + } + + /// + /// Predefined scenarios (library) + /// + public static List GetBullScenario() + { + return new() + { + new("Equities", 0.15m, 0.8m), + new("Bonds", 0, 0.7m), + new("Alternatives", 0.10m, 0.9m), + }; + } + + public static List GetBearScenario() + { + return new() + { + new("Equities", -0.20m, 1.5m), + new("Bonds", 0.015m, 1.2m), + new("Alternatives", -0.15m, 1.3m), + }; + } + + public static List GetRateShockScenario() + { + return new() + { + new("Equities", -0.08m, 1.1m), + new("Bonds", 0.020m, 1.0m), // +200 bps + new("Alternatives", -0.05m, 0.95m), + }; + } + + public static List GetVolSpikeScenario() + { + return new() + { + new("Equities", -0.10m, 5.0m), + new("Bonds", 0.005m, 2.0m), + new("Alternatives", -0.08m, 3.0m), + }; + } + + /// + /// Determine scenario severity (user-facing label) + /// + public static string ClassifySeverity(decimal lossPercent) + { + return Math.Abs(lossPercent) switch + { + < 5 => "Mild", + < 10 => "Moderate", + < 20 => "Severe", + _ => "Extreme" + }; + } + + /// + /// Identify concentration-driven losses + /// If top-5 losses account for >70% of total, concentration is a factor + /// + public static bool IsConcentrationDriven(List positions) + { + if (positions.Count == 0) + return false; + + var totalAbsLoss = positions.Sum(p => Math.Abs(p.Loss)); + if (totalAbsLoss == 0) + return false; + + var top5Loss = positions.Take(5).Sum(p => Math.Abs(p.Loss)); + var concentrationRatio = top5Loss / totalAbsLoss; + + return concentrationRatio > 0.70m; + } + + /// + /// Validate scenario definition (sanity checks) + /// + public static (bool IsValid, List Issues) ValidateScenario(List shocks) + { + var issues = new List(); + + if (!shocks.Any()) + issues.Add("Scenario must have at least one shock"); + + foreach (var shock in shocks.Where(s => s.PriceShockPercent < -1 || s.PriceShockPercent > 1)) + issues.Add($"Extreme price shock: {shock.AssetClass} {shock.PriceShockPercent:P}"); + + foreach (var shock in shocks.Where(s => s.VolatilityMultiplier <= 0 || s.VolatilityMultiplier > 10)) + issues.Add($"Invalid volatility multiplier: {shock.AssetClass} {shock.VolatilityMultiplier}x"); + + return (issues.Count == 0, issues); + } + + /// + /// Generate scenario summary (human-readable) + /// + public static string SummarizeStressResult(StressScenarioResult result) + { + var summary = $"Scenario: {result.ScenarioId}\n"; + summary += $"Portfolio Loss: ${result.PortfolioLoss:N2} ({result.PortfolioLossPercent:N2}%)\n"; + + if (result.WorstPosition != null) + { + summary += $"Worst Position: {result.WorstPosition.Symbol} loses ${Math.Abs(result.WorstPosition.Loss):N2}\n"; + } + + summary += $"Stress VAR Change: ${result.StressedVAR - result.BaselineVAR:N2}"; + + return summary; + } +} diff --git a/src/KArtSell.Modules.ModelOperations/Domain/VS07_RiskAlertsPolicy.cs b/src/KArtSell.Modules.ModelOperations/Domain/VS07_RiskAlertsPolicy.cs new file mode 100644 index 00000000..fd7549ff --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/Domain/VS07_RiskAlertsPolicy.cs @@ -0,0 +1,304 @@ +namespace KArtSell.Modules.ModelOperations.Domain; + +/// +/// VS-07 DOMAIN: Risk Alerts Policy +/// +/// Pure business logic (no I/O): +/// - Evaluate thresholds against current metrics +/// - Determine alert status (Initial/Warning/Critical) +/// - Calculate escalation timing +/// - Detect alert resolution +/// +/// All decisions: deterministic, time-based, repeatable +/// + +public enum AlertSeverity +{ + Initial, + Warning, + Critical, + Resolved +} + +public record AlertThreshold( + string ThresholdType, + string ThresholdName, + decimal ThresholdValue, + int WarnAtMinutes = 2, + int CriticalAtMinutes = 5); + +public record AlertEvaluationResult( + bool ThresholdBreached, + string ThresholdType, + string ThresholdName, + decimal CurrentValue, + decimal Threshold, + decimal Deviation, + string Message); + +public record AlertStatus( + Guid AlertId, + string ThresholdType, + AlertSeverity Severity, + DateTime TriggeredAt, + DateTime? WarnedAt, + DateTime? CriticalAt, + int MinutesElapsed, + string Message); + +public record AlertEscalationDecision( + bool ShouldEscalate, + AlertSeverity FromSeverity, + AlertSeverity ToSeverity, + string Reason); + +public record AlertResolutionDecision( + bool ShouldResolve, + string ResolutionType, // 'threshold_back_to_safe', 'manual' + string Reason); + +public static class RiskAlertsPolicy +{ + /// + /// Evaluate if metric breaches threshold + /// + public static AlertEvaluationResult EvaluateThreshold( + AlertThreshold threshold, + decimal currentValue) + { + var breached = currentValue > threshold.ThresholdValue; + var deviation = currentValue - threshold.ThresholdValue; + + var message = breached + ? $"{threshold.ThresholdName}: {currentValue:N2} exceeds {threshold.ThresholdValue:N2}" + : $"{threshold.ThresholdName}: {currentValue:N2} within safe limits ({threshold.ThresholdValue:N2})"; + + return new AlertEvaluationResult( + ThresholdBreached: breached, + ThresholdType: threshold.ThresholdType, + ThresholdName: threshold.ThresholdName, + CurrentValue: currentValue, + Threshold: threshold.ThresholdValue, + Deviation: Math.Max(0, deviation), + Message: message); + } + + /// + /// Determine current alert severity (time-based escalation) + /// + public static AlertSeverity DetermineSeverity( + AlertThreshold threshold, + DateTime triggeredAt, + DateTime now) + { + var minutesElapsed = (int)(now - triggeredAt).TotalMinutes; + + if (minutesElapsed >= threshold.CriticalAtMinutes) + return AlertSeverity.Critical; + + if (minutesElapsed >= threshold.WarnAtMinutes) + return AlertSeverity.Warning; + + return AlertSeverity.Initial; + } + + /// + /// Evaluate whether to escalate alert + /// + public static AlertEscalationDecision EvaluateEscalation( + AlertThreshold threshold, + AlertSeverity currentSeverity, + DateTime triggeredAt, + DateTime now, + bool thresholdStillBreached) + { + if (!thresholdStillBreached) + return new AlertEscalationDecision(false, currentSeverity, currentSeverity, "Threshold no longer breached"); + + var minutesElapsed = (int)(now - triggeredAt).TotalMinutes; + var targetSeverity = DetermineSeverity(threshold, triggeredAt, now); + + if (targetSeverity > currentSeverity) + { + return new AlertEscalationDecision( + ShouldEscalate: true, + FromSeverity: currentSeverity, + ToSeverity: targetSeverity, + Reason: targetSeverity == AlertSeverity.Warning + ? $"Alert persisting for {minutesElapsed} minutes (warn threshold: {threshold.WarnAtMinutes})" + : $"Alert persisting for {minutesElapsed} minutes (critical threshold: {threshold.CriticalAtMinutes})"); + } + + return new AlertEscalationDecision(false, currentSeverity, currentSeverity, "No escalation needed"); + } + + /// + /// Evaluate whether to resolve alert + /// + public static AlertResolutionDecision EvaluateResolution( + AlertThreshold threshold, + decimal currentValue, + DateTime triggeredAt, + DateTime now) + { + // Check if threshold back to safe + if (currentValue <= threshold.ThresholdValue) + { + var minutesBreached = (int)(now - triggeredAt).TotalMinutes; + return new AlertResolutionDecision( + ShouldResolve: true, + ResolutionType: "threshold_back_to_safe", + Reason: $"Metric back to safe level ({currentValue:N2} <= {threshold.ThresholdValue:N2}) after {minutesBreached} minutes"); + } + + return new AlertResolutionDecision( + ShouldResolve: false, + ResolutionType: "", + Reason: "Threshold still breached"); + } + + /// + /// Calculate deviation severity (for filtering) + /// Returns a score 0-10 (0=mild, 10=extreme) + /// + public static int CalculateDeviationSeverity( + decimal currentValue, + decimal thresholdValue) + { + if (currentValue <= thresholdValue) + return 0; + + var deviationPercent = ((currentValue - thresholdValue) / thresholdValue) * 100; + + return (int)Math.Min(10, Math.Ceiling(deviationPercent / 10)); + } + + /// + /// Detect concentration-based alerts + /// + public static bool IsConcentrationAlert( + List weights, + decimal maxSinglePosition = 40, + decimal maxTopFivePercent = 60) + { + if (!weights.Any()) + return false; + + var maxPosition = weights.First().WeightPercent; + var topFive = weights.Take(5).Sum(w => w.WeightPercent); + + return maxPosition > maxSinglePosition || topFive > maxTopFivePercent; + } + + /// + /// Detect volatility-based alerts + /// + public static bool IsVolatilityAlert( + decimal annualizedVolatility, + decimal volatilityThreshold = 0.30m) + { + return annualizedVolatility > volatilityThreshold; + } + + /// + /// Detect VAR-based alerts + /// + public static bool IsVARAlert( + decimal varAmount, + decimal portfolioValue, + decimal varThreshold = 0.20m) + { + var varPercent = varAmount / portfolioValue; + return varPercent > varThreshold; + } + + /// + /// Validate alert threshold configuration + /// + public static (bool IsValid, List Issues) ValidateThreshold(AlertThreshold threshold) + { + var issues = new List(); + + if (threshold.ThresholdValue < 0) + issues.Add($"Threshold value must be non-negative (got {threshold.ThresholdValue})"); + + if (threshold.WarnAtMinutes < 0 || threshold.WarnAtMinutes > 60) + issues.Add($"Warn timing must be 0-60 minutes (got {threshold.WarnAtMinutes})"); + + if (threshold.CriticalAtMinutes <= threshold.WarnAtMinutes) + issues.Add($"Critical timing must be > warn timing ({threshold.CriticalAtMinutes} must be > {threshold.WarnAtMinutes})"); + + if (string.IsNullOrWhiteSpace(threshold.ThresholdType)) + issues.Add("Threshold type required"); + + return (issues.Count == 0, issues); + } + + /// + /// Generate alert message (human-readable) + /// + public static string GenerateAlertMessage( + AlertThreshold threshold, + decimal currentValue, + AlertSeverity severity, + int minutesElapsed) + { + var deviation = currentValue - threshold.ThresholdValue; + var severityLabel = severity switch + { + AlertSeverity.Initial => "⚠️", + AlertSeverity.Warning => "⚠️⚠️", + AlertSeverity.Critical => "🚨", + _ => "" + }; + + return $"{severityLabel} {threshold.ThresholdName}: {currentValue:N2} " + + $"(threshold: {threshold.ThresholdValue:N2}, deviation: +{deviation:N2}) " + + $"[{minutesElapsed}min]"; + } + + /// + /// Determine alert priority (for sorting/notification) + /// + public static int CalculateAlertPriority( + AlertSeverity severity, + decimal deviationPercent) + { + var severityScore = severity switch + { + AlertSeverity.Critical => 300, + AlertSeverity.Warning => 200, + AlertSeverity.Initial => 100, + _ => 0 + }; + + var deviationScore = (int)(deviationPercent * 10); + + return severityScore + deviationScore; + } + + /// + /// Batch evaluate all thresholds (for background job) + /// + public static List EvaluateAllThresholds( + List thresholds, + Dictionary currentMetrics) + { + return thresholds + .Select(t => + { + if (currentMetrics.TryGetValue(t.ThresholdType, out var value)) + return EvaluateThreshold(t, value); + + return new AlertEvaluationResult( + ThresholdBreached: false, + ThresholdType: t.ThresholdType, + ThresholdName: t.ThresholdName, + CurrentValue: 0, + Threshold: t.ThresholdValue, + Deviation: 0, + Message: "Metric not available"); + }) + .ToList(); + } +}