feat: Phase 3 VS-08 Risk Dashboard — GOV+DATA+DOMAIN+BE+FE (5/7)
- VS-08_DASHBOARD_SLICE_SPEC.md: Comprehensive dashboard specification - VS-08_DATA_CONTRACT.md: PIT aggregation schema + caching strategy - VS08_DashboardPolicy.cs: Aggregation logic (health score, insights, validation) - VS08_DashboardEndpoint.cs: GET /api/dashboard/risk + cache layer - RiskDashboard.vue: Unified portfolio view with real-time metrics - VS08_DashboardIntegrationTests.cs: 5 core policy tests Status: GOV+DATA+DOMAIN+BE+ASYNC+FE complete (5/7 vertical slices) TESTOPS: In progress (test suite has minor compatibility issues with VS-04/07) Cumulative: Phase 2 Batch 3 + Phase 3 = 27/36 components (75% COMPLETE) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// VS-08 DOMAIN: Dashboard aggregation policy
|
||||
/// Pure business logic for combining portfolio, risk metrics, stress, alerts into unified snapshot
|
||||
/// No I/O, no DateTime.Now (all times injected)
|
||||
/// </summary>
|
||||
|
||||
// Note: This policy combines results from VS-04~07 components
|
||||
// VS-08 uses simplified aggregation types (not the complex Domain entities)
|
||||
|
||||
public sealed record Portfolio(
|
||||
decimal TotalValue,
|
||||
List<PortfolioPosition> Positions);
|
||||
|
||||
public sealed record PortfolioPosition(
|
||||
string Symbol,
|
||||
decimal Quantity,
|
||||
decimal MarketPrice,
|
||||
decimal MarketValue,
|
||||
decimal WeightPercent);
|
||||
|
||||
public sealed record RiskMetricsSnapshot(
|
||||
decimal VAR95,
|
||||
decimal SharpeRatio,
|
||||
decimal SortinoRatio,
|
||||
decimal VolatilityPercent,
|
||||
decimal TopFivePercent,
|
||||
decimal MaxPositionPercent);
|
||||
|
||||
// Simplified stress scenario for dashboard display
|
||||
public sealed record SimpleStressResult(
|
||||
string Scenario,
|
||||
decimal PortfolioLossPercent,
|
||||
decimal StressedVAR);
|
||||
|
||||
public sealed record ActiveAlert(
|
||||
Guid AlertId,
|
||||
string Threshold,
|
||||
decimal CurrentValue,
|
||||
string Severity,
|
||||
string Message);
|
||||
|
||||
public static class DashboardPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregate portfolio positions into single view
|
||||
/// Calculates total value and weight percentages
|
||||
/// </summary>
|
||||
public static Portfolio AggregatePortfolio(List<PortfolioPosition> positions)
|
||||
{
|
||||
if (positions.Count == 0)
|
||||
return new Portfolio(0, new());
|
||||
|
||||
var totalValue = positions.Sum(p => p.MarketValue);
|
||||
|
||||
var weightsWithTotal = positions.Select(p => new PortfolioPosition(
|
||||
p.Symbol,
|
||||
p.Quantity,
|
||||
p.MarketPrice,
|
||||
p.MarketValue,
|
||||
totalValue > 0 ? (p.MarketValue / totalValue) * 100 : 0
|
||||
)).ToList();
|
||||
|
||||
return new Portfolio(totalValue, weightsWithTotal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate dashboard data quality
|
||||
/// Ensures totals and percentages are consistent
|
||||
/// </summary>
|
||||
public static (bool IsValid, List<string> Issues) ValidateDashboardData(
|
||||
Portfolio portfolio,
|
||||
RiskMetricsSnapshot riskMetrics,
|
||||
List<SimpleStressResult> stressResults,
|
||||
List<ActiveAlert> alerts)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
// Portfolio validation
|
||||
if (portfolio.TotalValue < 0)
|
||||
issues.Add("Portfolio total value cannot be negative");
|
||||
|
||||
if (portfolio.Positions.Count > 0)
|
||||
{
|
||||
var totalWeight = portfolio.Positions.Sum(p => p.WeightPercent);
|
||||
if (Math.Abs(totalWeight - 100) > 0.1m)
|
||||
issues.Add($"Portfolio weights must sum to 100% (actual: {totalWeight:F2}%)");
|
||||
}
|
||||
|
||||
// Risk metrics validation
|
||||
if (riskMetrics.VAR95 < 0)
|
||||
issues.Add("VAR95 cannot be negative");
|
||||
|
||||
if (riskMetrics.VolatilityPercent < 0)
|
||||
issues.Add("Volatility cannot be negative");
|
||||
|
||||
if (riskMetrics.TopFivePercent < 0 || riskMetrics.TopFivePercent > 100)
|
||||
issues.Add("Top-5% concentration must be between 0-100");
|
||||
|
||||
// Stress results validation
|
||||
foreach (var stress in stressResults)
|
||||
{
|
||||
if (!IsValidScenarioName(stress.Scenario))
|
||||
issues.Add($"Invalid scenario name: {stress.Scenario}");
|
||||
|
||||
if (stress.StressedVAR < 0)
|
||||
issues.Add($"Stressed VAR for {stress.Scenario} cannot be negative");
|
||||
}
|
||||
|
||||
return (issues.Count == 0, issues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate health score (0-100) based on risk metrics and alerts
|
||||
/// Higher score = healthier portfolio
|
||||
/// </summary>
|
||||
public static int CalculateHealthScore(
|
||||
RiskMetricsSnapshot riskMetrics,
|
||||
List<ActiveAlert> alerts)
|
||||
{
|
||||
var score = 100;
|
||||
|
||||
// Deduct for concentration risk
|
||||
if (riskMetrics.TopFivePercent > 70)
|
||||
score -= 20;
|
||||
else if (riskMetrics.TopFivePercent > 50)
|
||||
score -= 10;
|
||||
|
||||
// Deduct for volatility
|
||||
if (riskMetrics.VolatilityPercent > 25)
|
||||
score -= 15;
|
||||
else if (riskMetrics.VolatilityPercent > 15)
|
||||
score -= 5;
|
||||
|
||||
// Deduct for active alerts
|
||||
var criticalAlerts = alerts.Count(a => a.Severity == "Critical");
|
||||
var warningAlerts = alerts.Count(a => a.Severity == "Warning");
|
||||
|
||||
score -= criticalAlerts * 15;
|
||||
score -= warningAlerts * 5;
|
||||
|
||||
return Math.Max(0, Math.Min(100, score));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summarize key risk insights for display
|
||||
/// Returns human-readable summary of portfolio state
|
||||
/// </summary>
|
||||
public static List<string> SummarizeRiskInsights(
|
||||
RiskMetricsSnapshot riskMetrics,
|
||||
List<SimpleStressResult> stressResults,
|
||||
List<ActiveAlert> alerts)
|
||||
{
|
||||
var insights = new List<string>();
|
||||
|
||||
// Concentration insight
|
||||
if (riskMetrics.TopFivePercent > 60)
|
||||
insights.Add($"High concentration risk: Top 5 holdings at {riskMetrics.TopFivePercent:F1}%");
|
||||
|
||||
// Volatility insight
|
||||
if (riskMetrics.VolatilityPercent > 20)
|
||||
insights.Add($"Elevated volatility: {riskMetrics.VolatilityPercent:F1}% annualized");
|
||||
else if (riskMetrics.VolatilityPercent < 8)
|
||||
insights.Add($"Low volatility: {riskMetrics.VolatilityPercent:F1}% annualized");
|
||||
|
||||
// Sharpe ratio insight
|
||||
if (riskMetrics.SharpeRatio < 0.5m)
|
||||
insights.Add("Low risk-adjusted returns (Sharpe < 0.5)");
|
||||
else if (riskMetrics.SharpeRatio > 2.0m)
|
||||
insights.Add("Excellent risk-adjusted returns (Sharpe > 2.0)");
|
||||
|
||||
// Stress scenario insight
|
||||
var worstStress = stressResults.OrderBy(s => s.PortfolioLossPercent).FirstOrDefault();
|
||||
if (worstStress != null && worstStress.PortfolioLossPercent < -15)
|
||||
insights.Add($"Significant downside risk: {worstStress.Scenario} scenario = {worstStress.PortfolioLossPercent:F1}% loss");
|
||||
|
||||
// Alert insight
|
||||
if (alerts.Any(a => a.Severity == "Critical"))
|
||||
insights.Add("⚠️ Critical alerts require immediate attention");
|
||||
|
||||
if (insights.Count == 0)
|
||||
insights.Add("Portfolio is within safe parameters — no major risks detected");
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine if stress scenario result is "severe" (>15% portfolio loss)
|
||||
/// </summary>
|
||||
public static bool IsStressSevere(SimpleStressResult stress)
|
||||
=> stress.PortfolioLossPercent < -15;
|
||||
|
||||
/// <summary>
|
||||
/// Rank alerts by severity (Critical > Warning > Initial)
|
||||
/// </summary>
|
||||
public static List<ActiveAlert> RankAlertsBySeverity(List<ActiveAlert> alerts)
|
||||
{
|
||||
var severityOrder = new Dictionary<string, int>
|
||||
{
|
||||
["Critical"] = 3,
|
||||
["Warning"] = 2,
|
||||
["Initial"] = 1,
|
||||
};
|
||||
|
||||
return alerts
|
||||
.OrderByDescending(a => severityOrder.GetValueOrDefault(a.Severity, 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsValidScenarioName(string name)
|
||||
=> name is "bull" or "bear" or "rateShock" or "volSpike";
|
||||
}
|
||||
Reference in New Issue
Block a user