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:
2026-08-05 22:12:06 +09:00
parent 47021ec99a
commit 2eee44d19b
9 changed files with 1576 additions and 102 deletions
@@ -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";
}