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
@@ -3,42 +3,95 @@
<div class="header">
<h1>Portfolio Risk Dashboard</h1>
<p class="subtitle">Real-time risk metrics, stress scenarios, and alerts</p>
<div v-if="dashboard" class="health-score">
<span class="score-label">Portfolio Health:</span>
<div class="score-bar">
<div class="score-fill" :style="{ width: dashboard.healthScore + '%' }"></div>
</div>
<span class="score-value">{{ dashboard.healthScore }}/100</span>
</div>
</div>
<div class="content">
<div v-if="error" class="error-banner">
{{ error }}
<button @click="fetchDashboard" class="btn-retry">Retry</button>
</div>
<div v-if="loading" class="loading">
Loading dashboard...
</div>
<div v-else-if="dashboard" class="content">
<!-- Portfolio Composition (VS-04) -->
<div class="card portfolio">
<h2>Portfolio Composition</h2>
<div class="portfolio-summary">
<div class="summary-item">
<span class="label">Total Value</span>
<span class="value">${{ dashboard.portfolio.totalValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
</div>
<div class="summary-item">
<span class="label">Positions</span>
<span class="value">{{ dashboard.portfolio.positions.length }}</span>
</div>
</div>
<table class="positions-mini">
<thead>
<tr>
<th>Symbol</th>
<th>Quantity</th>
<th>Price</th>
<th>Value</th>
<th>Weight</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in dashboard.portfolio.positions.slice(0, 5)" :key="pos.symbol">
<td><strong>{{ pos.symbol }}</strong></td>
<td>{{ pos.quantity.toLocaleString() }}</td>
<td>${{ pos.marketPrice.toFixed(2) }}</td>
<td>${{ pos.marketValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</td>
<td>{{ pos.weightPercent.toFixed(1) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- VS-05: Risk Metrics -->
<div class="card metrics">
<h2>Risk Metrics</h2>
<div class="metrics-grid">
<div class="metric">
<span class="label">VAR (95%)</span>
<span class="value">$15,250</span>
<span class="percent">5.2%</span>
<span class="value">${{ dashboard.riskMetrics.var95.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
<span class="percent">{{ (dashboard.riskMetrics.var95 / dashboard.portfolio.totalValue * 100).toFixed(1) }}%</span>
</div>
<div class="metric">
<span class="label">Sharpe Ratio</span>
<span class="value">1.85</span>
<span class="value">{{ dashboard.riskMetrics.sharpeRatio.toFixed(2) }}</span>
<span class="note">252-day rolling</span>
</div>
<div class="metric">
<span class="label">Sortino Ratio</span>
<span class="value">2.45</span>
<span class="value">{{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}</span>
<span class="note">Downside focus</span>
</div>
<div class="metric">
<span class="label">Volatility</span>
<span class="value">18.5%</span>
<span class="value">{{ dashboard.riskMetrics.volatilityPercent.toFixed(1) }}%</span>
<span class="note">Annualized</span>
</div>
<div class="metric">
<span class="label">Top 5 Holdings</span>
<span class="value">52.3%</span>
<span class="flag"> High</span>
<span class="value">{{ dashboard.riskMetrics.topFivePercent.toFixed(1) }}%</span>
<span :class="['flag', dashboard.riskMetrics.topFivePercent > 60 ? 'danger' : 'warning']">
{{ dashboard.riskMetrics.topFivePercent > 70 ? '🔴 High' : dashboard.riskMetrics.topFivePercent > 50 ? '⚠️ Medium' : '✅ Low' }}
</span>
</div>
<div class="metric">
<span class="label">Max Position</span>
<span class="value">40.0%</span>
<span class="note">AAPL</span>
<span class="value">{{ dashboard.riskMetrics.maxPositionPercent.toFixed(1) }}%</span>
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol || 'N/A' }}</span>
</div>
</div>
</div>
@@ -47,25 +100,12 @@
<div class="card stress">
<h2>Stress Test Scenarios</h2>
<div class="scenarios">
<div class="scenario" @click="runStressTest('bull')">
<span class="name">Bull Market</span>
<span class="impact">+15% Equities</span>
<span class="status">Ready</span>
</div>
<div class="scenario" @click="runStressTest('bear')">
<span class="name">Bear Market</span>
<span class="impact">-20% Equities</span>
<span class="status">Ready</span>
</div>
<div class="scenario" @click="runStressTest('rateShock')">
<span class="name">Rate Shock</span>
<span class="impact">+200 bps Yields</span>
<span class="status">Ready</span>
</div>
<div class="scenario" @click="runStressTest('volSpike')">
<span class="name">Vol Spike</span>
<span class="impact">5x Volatility</span>
<span class="status">Ready</span>
<div v-for="stress in dashboard.stressResults" :key="stress.scenario" class="scenario" @click="runStressTest(stress.scenario)">
<span class="name">{{ stress.scenario.charAt(0).toUpperCase() + stress.scenario.slice(1) }}</span>
<span class="impact">{{ stress.portfolioLossPercent > 0 ? '+' : '' }}{{ stress.portfolioLossPercent.toFixed(1) }}% Portfolio</span>
<span :class="['status', Math.abs(stress.portfolioLossPercent) > 15 ? 'severe' : 'moderate']">
{{ Math.abs(stress.portfolioLossPercent) > 15 ? 'Severe' : 'Moderate' }}
</span>
</div>
</div>
@@ -73,11 +113,11 @@
<h3>Results: {{ stressResult.scenario }}</h3>
<div class="result-row">
<span>Portfolio Loss:</span>
<span class="value">{{ stressResult.loss }}%</span>
<span :class="['value', stressResult.loss < 0 ? 'loss' : 'gain']">{{ stressResult.loss > 0 ? '+' : '' }}{{ stressResult.loss.toFixed(2) }}%</span>
</div>
<div class="result-row">
<span>Stressed VAR:</span>
<span class="value">${{ stressResult.stressedVar.toLocaleString() }}</span>
<span class="value">${{ stressResult.stressedVar.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
</div>
</div>
</div>
@@ -92,7 +132,7 @@
<span class="badge">{{ alert.severity }}</span>
</div>
<div class="alert-details">
<span class="current">{{ alert.current }}%</span>
<span class="current">{{ alert.current.toFixed(1) }}%</span>
<span class="message">{{ alert.message }}</span>
</div>
</div>
@@ -101,12 +141,22 @@
No active alerts portfolio within safe limits
</div>
</div>
<!-- Risk Insights (VS-08 aggregated summary) -->
<div class="card insights">
<h2>Risk Insights</h2>
<ul class="insights-list">
<li v-for="(insight, idx) in dashboard.riskInsights" :key="idx">
{{ insight }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
interface StressResult {
scenario: string
@@ -122,7 +172,47 @@ interface Alert {
message: string
}
interface DashboardData {
portfolio: {
totalValue: number
positions: Array<{
symbol: string
quantity: number
marketPrice: number
marketValue: number
weightPercent: number
}>
}
riskMetrics: {
var95: number
sharpeRatio: number
sortinoRatio: number
volatilityPercent: number
topFivePercent: number
maxPositionPercent: number
}
stressResults: Array<{
scenario: string
portfolioLossPercent: number
stressedVar: number
}>
activeAlerts: Array<{
alertId: string
threshold: string
currentValue: number
severity: string
message: string
}>
healthScore: number
riskInsights: string[]
lastUpdate: string
}
const loading = ref(false)
const error = ref<string | null>(null)
const stressResult = ref<StressResult | null>(null)
const dashboard = ref<DashboardData | null>(null)
const portfolioId = ref('550e8400-e29b-41d4-a716-446655440001')
const activeAlerts = ref<Alert[]>([
{
@@ -134,19 +224,44 @@ const activeAlerts = ref<Alert[]>([
},
])
const runStressTest = async (scenario: string) => {
// Mock stress test
const losses: Record<string, number> = {
bull: 12.5,
bear: -20.0,
rateShock: -8.5,
volSpike: -15.0,
}
onMounted(async () => {
await fetchDashboard()
})
stressResult.value = {
scenario: scenario.charAt(0).toUpperCase() + scenario.slice(1),
loss: losses[scenario] || 0,
stressedVar: 42800,
const fetchDashboard = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/dashboard/risk?portfolioId=${portfolioId.value}`)
if (response.ok) {
dashboard.value = await response.json()
activeAlerts.value = dashboard.value.activeAlerts.map(a => ({
id: a.alertId,
threshold: a.threshold,
current: a.currentValue,
severity: a.severity,
message: a.message,
}))
} else {
error.value = 'Failed to fetch dashboard'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
const runStressTest = async (scenario: string) => {
const scenarioKey = scenario === 'bull' ? 'bull' : scenario === 'bear' ? 'bear' : scenario === 'rateShock' ? 'rateShock' : 'volSpike'
const result = dashboard.value?.stressResults.find(s => s.scenario.toLowerCase() === scenario.toLowerCase())
if (result) {
stressResult.value = {
scenario: scenario.charAt(0).toUpperCase() + scenario.slice(1),
loss: result.portfolioLossPercent,
stressedVar: result.stressedVar,
}
}
}
</script>
@@ -169,7 +284,65 @@ const runStressTest = async (scenario: string) => {
.subtitle {
color: var(--text-secondary);
margin: 0;
margin: 0 0 1rem 0;
}
.health-score {
display: flex;
gap: 1rem;
align-items: center;
margin-top: 1rem;
}
.score-label {
font-weight: 600;
min-width: 120px;
}
.score-bar {
flex: 1;
height: 24px;
background-color: #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
.score-fill {
height: 100%;
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
transition: width 0.3s ease;
}
.score-value {
font-weight: 600;
min-width: 60px;
}
.error-banner {
padding: 1rem;
background-color: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 8px;
color: #991b1b;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-retry {
padding: 0.5rem 1rem;
background-color: #991b1b;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.content {
@@ -371,4 +544,103 @@ const runStressTest = async (scenario: string) => {
color: #10b981;
font-weight: 500;
}
/* Portfolio Card */
.portfolio-summary {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
padding: 1rem;
background-color: var(--surface-secondary);
border-radius: 6px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-item .label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.summary-item .value {
font-size: 1.5rem;
font-weight: 600;
}
.positions-mini {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.positions-mini thead {
background-color: var(--surface-secondary);
}
.positions-mini th {
padding: 0.5rem;
text-align: left;
font-weight: 600;
}
.positions-mini td {
padding: 0.5rem;
border-top: 1px solid var(--border-color);
}
/* Risk Insights */
.insights {
background-color: #f3f4f6;
}
.insights-list {
list-style: none;
padding: 0;
margin: 0;
}
.insights-list li {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
color: #374151;
}
.insights-list li:last-child {
border-bottom: none;
}
.insights-list li::before {
content: '💡 ';
margin-right: 0.5rem;
}
/* Stress scenario status badges */
.scenario .status.severe {
color: #ef4444;
}
.scenario .status.moderate {
color: #f59e0b;
}
.metric .flag.danger {
color: #ef4444;
}
.metric .flag.warning {
color: #f59e0b;
}
.stress-result .value.loss {
color: #ef4444;
}
.stress-result .value.gain {
color: #10b981;
}
</style>