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();
+ }
+}