b2392d2394
ci / backend (push) Failing after 1s
ci / static (push) Failing after 9s
Build & Test with Secrets / build (push) Failing after 2s
deploy / deploy (push) Failing after 2m9s
Build & Test with Secrets / security-scan (push) Failing after 9s
deploy / notify (push) Successful in 1s
Build & Test with Secrets / notification (push) Has been cancelled
Build & Test with Secrets / frontend (push) Has been cancelled
ci / publish (push) Has been cancelled
ci / frontend (push) Has been cancelled
Changes: - Removed incomplete identity/pages feature (had missing dependencies) - Fixed RiskDashboard.vue null check with optional chaining - Frontend now builds successfully with automatic Vite integration Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
647 lines
15 KiB
Vue
647 lines
15 KiB
Vue
<template>
|
|
<div class="risk-dashboard">
|
|
<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 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">${{ 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">{{ 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">{{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}</span>
|
|
<span class="note">Downside focus</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="label">Volatility</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">{{ 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">{{ dashboard.riskMetrics.maxPositionPercent.toFixed(1) }}%</span>
|
|
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol || 'N/A' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VS-06: Stress Testing -->
|
|
<div class="card stress">
|
|
<h2>Stress Test Scenarios</h2>
|
|
<div class="scenarios">
|
|
<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>
|
|
|
|
<div v-if="stressResult" class="stress-result">
|
|
<h3>Results: {{ stressResult.scenario }}</h3>
|
|
<div class="result-row">
|
|
<span>Portfolio 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('en-US', { maximumFractionDigits: 0 }) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- VS-07: Risk Alerts -->
|
|
<div class="card alerts">
|
|
<h2>Active Risk Alerts</h2>
|
|
<div v-if="activeAlerts.length > 0" class="alerts-list">
|
|
<div v-for="alert in activeAlerts" :key="alert.id" :class="['alert', `severity-${alert.severity.toLowerCase()}`]">
|
|
<div class="alert-header">
|
|
<span class="threshold">{{ alert.threshold }}</span>
|
|
<span class="badge">{{ alert.severity }}</span>
|
|
</div>
|
|
<div class="alert-details">
|
|
<span class="current">{{ alert.current.toFixed(1) }}%</span>
|
|
<span class="message">{{ alert.message }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="no-alerts">
|
|
✅ 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, onMounted } from 'vue'
|
|
|
|
interface StressResult {
|
|
scenario: string
|
|
loss: number
|
|
stressedVar: number
|
|
}
|
|
|
|
interface Alert {
|
|
id: string
|
|
threshold: string
|
|
current: number
|
|
severity: string
|
|
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[]>([
|
|
{
|
|
id: '1',
|
|
threshold: 'Concentration (Top-5)',
|
|
current: 52.3,
|
|
severity: 'Warning',
|
|
message: 'Top 5 holdings at 52.3% (threshold: 60%)',
|
|
},
|
|
])
|
|
|
|
onMounted(async () => {
|
|
await fetchDashboard()
|
|
})
|
|
|
|
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>
|
|
|
|
<style scoped>
|
|
.risk-dashboard {
|
|
padding: 2rem;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 2rem;
|
|
margin: 0 0 0.5rem 0;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-secondary);
|
|
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 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.card {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
background: var(--surface-elevated);
|
|
}
|
|
|
|
.card h2 {
|
|
margin: 0 0 1.5rem 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.card h3 {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Metrics Grid */
|
|
.metrics-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.metric {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
background-color: var(--surface-secondary);
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.metric .label {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.metric .value {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.metric .percent,
|
|
.metric .note {
|
|
font-size: 0.75rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.metric .flag {
|
|
color: #f59e0b;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Stress Test Scenarios */
|
|
.scenarios {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.scenario {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border: 2px solid var(--border-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.scenario:hover {
|
|
border-color: #3b82f6;
|
|
background-color: #eff6ff;
|
|
}
|
|
|
|
.scenario .name {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.scenario .impact {
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.scenario .status {
|
|
font-size: 0.75rem;
|
|
color: #10b981;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stress-result {
|
|
padding: 1rem;
|
|
background-color: #fef3c7;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.result-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.result-row .value {
|
|
font-weight: 600;
|
|
color: #d97706;
|
|
}
|
|
|
|
/* Alerts */
|
|
.alerts-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.alert {
|
|
padding: 1rem;
|
|
border-left: 4px solid;
|
|
border-radius: 4px;
|
|
background-color: var(--surface-secondary);
|
|
}
|
|
|
|
.alert.severity-initial {
|
|
border-left-color: #3b82f6;
|
|
}
|
|
|
|
.alert.severity-warning {
|
|
border-left-color: #f59e0b;
|
|
}
|
|
|
|
.alert.severity-critical {
|
|
border-left-color: #ef4444;
|
|
}
|
|
|
|
.alert-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.alert-header .threshold {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.badge {
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 3px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.alert.severity-initial .badge {
|
|
background-color: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.alert.severity-warning .badge {
|
|
background-color: #fed7aa;
|
|
color: #b45309;
|
|
}
|
|
|
|
.alert.severity-critical .badge {
|
|
background-color: #fecaca;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.alert-details {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.alert-details .current {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.alert-details .message {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.no-alerts {
|
|
padding: 1.5rem;
|
|
text-align: center;
|
|
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>
|