V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -1,89 +1,47 @@
|
||||
<template>
|
||||
<div class="rebalance-form">
|
||||
<div class="header">
|
||||
<h1>Portfolio Rebalancing</h1>
|
||||
<p class="subtitle">Adjust target weights and trigger rebalancing</p>
|
||||
</div>
|
||||
<PageLayout title="포트폴리오 리밸런싱" subtitle="목표 비중을 조정하고 리밸런싱을 실행합니다.">
|
||||
<KsFormSection title="현재 구성">
|
||||
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
|
||||
<p class="ks-stack"><strong>포트폴리오 총 평가액:</strong> {{ formatCurrency(totalValue, 'USD') }}</p>
|
||||
</KsFormSection>
|
||||
|
||||
<div class="content">
|
||||
<!-- Current Composition -->
|
||||
<div class="card">
|
||||
<h2>Current Composition</h2>
|
||||
<table class="positions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Quantity</th>
|
||||
<th>Market Price</th>
|
||||
<th>Market Value</th>
|
||||
<th>Weight %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pos in currentPositions" :key="pos.symbol">
|
||||
<td>{{ pos.symbol }}</td>
|
||||
<td>{{ pos.quantity.toLocaleString() }}</td>
|
||||
<td>${{ pos.marketPrice.toFixed(2) }}</td>
|
||||
<td>${{ pos.marketValue.toLocaleString() }}</td>
|
||||
<td>{{ pos.weightPercent.toFixed(1) }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="total">
|
||||
<strong>Total Portfolio Value:</strong> ${{ totalValue.toLocaleString() }}
|
||||
<KsFormSection title="목표 비중 설정">
|
||||
<KsFormGrid :columns="1" aria-label="리밸런싱 설정">
|
||||
<KsNumberField v-model="driftThreshold" label="허용 이탈 임계값 %" :min="0" :max="50" :max-fraction-digits="0" />
|
||||
</KsFormGrid>
|
||||
|
||||
<div class="targets ks-stack">
|
||||
<div v-for="(target, idx) in targetWeights" :key="idx" class="target-row">
|
||||
<KsTextField v-model="target.symbol" label="종목코드" :input-id="`target-symbol-${idx}`" />
|
||||
<KsNumberField v-model="target.targetPercent" label="목표 비중 %" :min="0" :max="100" :max-fraction-digits="0" :input-id="`target-percent-${idx}`" />
|
||||
<KsButton severity="danger" label="삭제" @click="removeTarget(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Weights Form -->
|
||||
<div class="card">
|
||||
<h2>Set Target Weights</h2>
|
||||
<div class="form-group">
|
||||
<div class="drift-threshold">
|
||||
<label>Drift Threshold %:</label>
|
||||
<input v-model.number="driftThreshold" type="number" min="0" max="50" step="1" />
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
|
||||
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
|
||||
</template>
|
||||
</KsFormSection>
|
||||
|
||||
<div class="targets">
|
||||
<div v-for="(target, idx) in targetWeights" :key="idx" class="target-row">
|
||||
<input v-model="target.symbol" placeholder="Symbol" class="symbol-input" />
|
||||
<input v-model.number="target.targetPercent" type="number" min="0" max="100" step="1" placeholder="%" class="percent-input" />
|
||||
<button @click="removeTarget(idx)" class="btn-remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="addTarget" class="btn-secondary">+ Add Symbol</button>
|
||||
<button @click="triggerRebalance" class="btn-primary">Trigger Rebalance</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="jobResult" class="card result">
|
||||
<h2>Rebalance Queued</h2>
|
||||
<div class="result-item">
|
||||
<span>Job ID:</span>
|
||||
<span class="mono">{{ jobResult.jobId }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Status:</span>
|
||||
<span class="status-badge">{{ jobResult.status }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Estimated Trades:</span>
|
||||
<span>{{ jobResult.estimatedTradeCount }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Estimated Cost:</span>
|
||||
<span>${{ jobResult.estimatedCost.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<KsFormSection v-if="jobResult" title="리밸런싱 대기열 등록됨">
|
||||
<dl class="ks-stack">
|
||||
<dt>Job ID</dt><dd class="mono">{{ jobResult.jobId }}</dd>
|
||||
<dt>상태</dt><dd><KsStatusTag :value="jobResult.status" severity="info" /></dd>
|
||||
<dt>예상 거래 건수</dt><dd>{{ formatQuantity(jobResult.estimatedTradeCount, 0) }}</dd>
|
||||
<dt>예상 비용</dt><dd>{{ formatCurrency(jobResult.estimatedCost, 'USD') }}</dd>
|
||||
</dl>
|
||||
</KsFormSection>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import { KsButton, KsFormGrid, KsFormSection, KsNumberField, KsStatusTag, KsTextField } from '../../../shared/ui/components'
|
||||
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
interface Position {
|
||||
symbol: string
|
||||
@@ -112,6 +70,14 @@ const currentPositions = ref<Position[]>([
|
||||
{ symbol: 'GOOGL', quantity: 50, marketPrice: 140.75, marketValue: 7037.5, weightPercent: 16.5 },
|
||||
])
|
||||
|
||||
const positionColumns = computed<UiGridColumn[]>(() => [
|
||||
{ field: 'symbol', header: '종목코드' },
|
||||
{ field: 'quantity', header: '수량', formatter: value => formatQuantity(value as number, 0) },
|
||||
{ field: 'marketPrice', header: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'marketValue', header: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'weightPercent', header: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
|
||||
])
|
||||
|
||||
const driftThreshold = ref(5)
|
||||
const targetWeights = ref<TargetWeight[]>([
|
||||
{ symbol: 'AAPL', targetPercent: 40 },
|
||||
@@ -142,190 +108,10 @@ const triggerRebalance = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rebalance-form {
|
||||
padding: 2rem;
|
||||
max-width: 1000px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.positions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.positions-table thead {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.positions-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.positions-table td {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.total {
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drift-threshold {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drift-threshold label {
|
||||
font-weight: 600;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.drift-threshold input {
|
||||
width: 100px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.targets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.symbol-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.percent-input {
|
||||
width: 80px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result {
|
||||
background-color: #f0fdf4;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item span:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.target-row { display: flex; align-items: end; gap: var(--ks-space-3); }
|
||||
.target-row > :first-child { flex: 1; }
|
||||
.mono { font-family: monospace; }
|
||||
dl.ks-stack { display: grid; grid-template-columns: auto 1fr; gap: var(--ks-space-2) var(--ks-space-4); }
|
||||
dl.ks-stack dt { font-weight: 600; color: var(--ks-color-text-muted); }
|
||||
dl.ks-stack dd { margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,162 +1,112 @@
|
||||
<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>
|
||||
<ScorecardDashboardPage title="포트폴리오 리스크 대시보드" subtitle="실시간 리스크 지표, 스트레스 시나리오, 알림을 확인합니다." :state="state" :warning="error ?? undefined" @retry="fetchDashboard">
|
||||
<template #actions>
|
||||
<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-label">포트폴리오 건전성</span>
|
||||
<div class="score-bar"><div class="score-fill" :style="{ width: dashboard.healthScore + '%' }" /></div>
|
||||
<span class="score-value">{{ dashboard.healthScore }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
<button @click="fetchDashboard" class="btn-retry">Retry</button>
|
||||
</div>
|
||||
<template v-if="dashboard" #kpis>
|
||||
<div class="metric">
|
||||
<span class="label">VAR (95%)</span>
|
||||
<span class="value">{{ formatCurrency(dashboard.riskMetrics.var95, 'USD') }}</span>
|
||||
<span class="note">{{ formatPercent(dashboard.riskMetrics.var95 / dashboard.portfolio.totalValue, 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일 이동</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Sortino Ratio</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}</span>
|
||||
<span class="note">하방 리스크 중심</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">변동성</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.volatilityPercent / 100, 1) }}</span>
|
||||
<span class="note">연환산</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">상위 5종목 비중</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.topFivePercent / 100, 1) }}</span>
|
||||
<KsStatusTag :value="topFiveLabel" :severity="topFiveSeverity" />
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">최대 보유 비중</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.maxPositionPercent / 100, 1) }}</span>
|
||||
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol ?? 'N/A' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
<template v-if="dashboard" #primary>
|
||||
<h2>포트폴리오 구성</h2>
|
||||
<dl class="portfolio-summary">
|
||||
<div class="summary-item"><dt>총 평가액</dt><dd>{{ formatCurrency(dashboard.portfolio.totalValue, 'USD') }}</dd></div>
|
||||
<div class="summary-item"><dt>보유 종목 수</dt><dd>{{ formatQuantity(dashboard.portfolio.positions.length, 0) }}</dd></div>
|
||||
</dl>
|
||||
<DataGridShell :rows="dashboard.portfolio.positions.slice(0, 5)" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<template v-if="dashboard" #secondary>
|
||||
<h2>스트레스 테스트 시나리오</h2>
|
||||
<div class="scenarios">
|
||||
<button
|
||||
v-for="stress in dashboard.stressResults"
|
||||
:key="stress.scenario"
|
||||
type="button"
|
||||
class="scenario"
|
||||
@click="runStressTest(stress.scenario)"
|
||||
>
|
||||
<span class="name">{{ scenarioLabel(stress.scenario) }}</span>
|
||||
<span class="impact">{{ formatPercent(stress.portfolioLossPercent / 100, 1) }} 포트폴리오</span>
|
||||
<KsStatusTag :value="Math.abs(stress.portfolioLossPercent) > 15 ? '심각' : '보통'" :severity="Math.abs(stress.portfolioLossPercent) > 15 ? 'danger' : 'warning'" />
|
||||
</button>
|
||||
</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 v-if="stressResult" class="stress-result">
|
||||
<h3>결과: {{ stressResult.scenario }}</h3>
|
||||
<div class="result-row"><span>포트폴리오 손실</span><span :class="['value', stressResult.loss < 0 ? 'loss' : 'gain']">{{ formatPercent(stressResult.loss / 100, 2) }}</span></div>
|
||||
<div class="result-row"><span>스트레스 VAR</span><span class="value">{{ formatCurrency(stressResult.stressedVar, 'USD') }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #alerts>
|
||||
<h2>활성 리스크 알림</h2>
|
||||
<div v-if="activeAlerts.length > 0" class="alerts-list">
|
||||
<div v-for="alert in activeAlerts" :key="alert.id" class="alert">
|
||||
<div class="alert-header">
|
||||
<span class="threshold">{{ alert.threshold }}</span>
|
||||
<KsStatusTag :value="alert.severity" :severity="alertSeverity(alert.severity)" />
|
||||
</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 class="alert-details">
|
||||
<span class="current">{{ formatPercent(alert.current / 100, 1) }}</span>
|
||||
<span class="message">{{ alert.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="no-alerts">현재 활성 알림이 없습니다 — 포트폴리오가 안전 범위 내에 있습니다.</p>
|
||||
</template>
|
||||
|
||||
<!-- 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 v-if="dashboard?.riskInsights.length" #metricDefinitions>
|
||||
<h2>리스크 인사이트</h2>
|
||||
<ul class="insights-list">
|
||||
<li v-for="(insight, idx) in dashboard.riskInsights" :key="idx">{{ insight }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ScorecardDashboardPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import ScorecardDashboardPage from '../../../shared/ui/screen-types/v2/ScorecardDashboardPage.vue'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import { KsStatusTag } from '../../../shared/ui/components'
|
||||
import type { UiGridColumn, UiSeverity } from '../../../shared/ui/adapter/contracts'
|
||||
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
interface StressResult {
|
||||
scenario: string
|
||||
@@ -217,13 +167,43 @@ const portfolioId = ref('550e8400-e29b-41d4-a716-446655440001')
|
||||
const activeAlerts = ref<Alert[]>([
|
||||
{
|
||||
id: '1',
|
||||
threshold: 'Concentration (Top-5)',
|
||||
threshold: '집중도(상위 5종목)',
|
||||
current: 52.3,
|
||||
severity: 'Warning',
|
||||
message: 'Top 5 holdings at 52.3% (threshold: 60%)',
|
||||
message: '상위 5종목 비중 52.3% (임계값 60%)',
|
||||
},
|
||||
])
|
||||
|
||||
const state = computed<StandardScreenState>(() => loading.value ? 'LOADING' : error.value ? 'WARN' : 'READY')
|
||||
|
||||
const positionColumns: UiGridColumn[] = [
|
||||
{ field: 'symbol', header: '종목코드' },
|
||||
{ field: 'quantity', header: '수량', formatter: value => formatQuantity(value as number, 0) },
|
||||
{ field: 'marketPrice', header: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'marketValue', header: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'weightPercent', header: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
|
||||
]
|
||||
|
||||
const topFiveLabel = computed(() => {
|
||||
const value = dashboard.value?.riskMetrics.topFivePercent ?? 0
|
||||
return value > 70 ? '높음' : value > 50 ? '중간' : '낮음'
|
||||
})
|
||||
const topFiveSeverity = computed<UiSeverity>(() => {
|
||||
const value = dashboard.value?.riskMetrics.topFivePercent ?? 0
|
||||
return value > 60 ? 'danger' : 'warning'
|
||||
})
|
||||
|
||||
function scenarioLabel(scenario: string): string {
|
||||
return scenario.charAt(0).toUpperCase() + scenario.slice(1)
|
||||
}
|
||||
|
||||
function alertSeverity(severity: string): UiSeverity {
|
||||
const normalized = severity.toLowerCase()
|
||||
if (normalized === 'critical') return 'danger'
|
||||
if (normalized === 'warning') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDashboard()
|
||||
})
|
||||
@@ -243,22 +223,21 @@ const fetchDashboard = async () => {
|
||||
message: a.message,
|
||||
})) || []
|
||||
} else {
|
||||
error.value = 'Failed to fetch dashboard'
|
||||
error.value = '대시보드를 불러오지 못했습니다.'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||
error.value = e instanceof Error ? e.message : '알 수 없는 오류'
|
||||
} 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),
|
||||
scenario: scenarioLabel(scenario),
|
||||
loss: result.portfolioLossPercent,
|
||||
stressedVar: result.stressedVar,
|
||||
}
|
||||
@@ -267,380 +246,42 @@ const runStressTest = async (scenario: string) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.health-score { display: flex; gap: var(--ks-space-3); align-items: center; min-width: 20rem; }
|
||||
.score-label { font-weight: 600; white-space: nowrap; }
|
||||
.score-bar { flex: 1; height: 1.5rem; background: var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); overflow: hidden; }
|
||||
.score-fill { height: 100%; background: linear-gradient(90deg, var(--ks-color-danger), var(--ks-color-warning), var(--ks-color-success)); }
|
||||
.score-value { font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.metric { display: flex; flex-direction: column; gap: var(--ks-space-1); padding: var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); text-align: center; }
|
||||
.metric .label { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); font-weight: 500; }
|
||||
.metric .value { font-size: var(--ks-font-page); font-weight: 600; }
|
||||
.metric .note { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.portfolio-summary { display: flex; gap: var(--ks-space-6); margin: 0 0 var(--ks-space-3); padding: var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); }
|
||||
.summary-item { display: flex; flex-direction: column; gap: var(--ks-space-1); }
|
||||
.summary-item dt { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); font-weight: 500; }
|
||||
.summary-item dd { margin: 0; font-size: var(--ks-font-page); font-weight: 600; }
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.scenarios { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--ks-space-3); margin-bottom: var(--ks-space-4); }
|
||||
.scenario { display: flex; flex-direction: column; gap: var(--ks-space-1); padding: var(--ks-space-3); border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: var(--ks-color-surface); text-align: left; cursor: pointer; }
|
||||
.scenario:hover { border-color: var(--ks-color-action); }
|
||||
.scenario .name { font-weight: 600; font-size: var(--ks-font-body); }
|
||||
.scenario .impact { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
|
||||
.stress-result { padding: var(--ks-space-3); background: color-mix(in srgb, var(--ks-color-warning) 12%, var(--ks-color-surface)); border-radius: var(--ks-radius-sm); }
|
||||
.result-row { display: flex; justify-content: space-between; padding: var(--ks-space-1) 0; }
|
||||
.result-row .value.loss { color: var(--ks-color-danger); font-weight: 600; }
|
||||
.result-row .value.gain { color: var(--ks-color-success); font-weight: 600; }
|
||||
|
||||
.health-score {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.alerts-list { display: flex; flex-direction: column; gap: var(--ks-space-2); }
|
||||
.alert { padding: var(--ks-space-3); border-radius: var(--ks-radius-sm); background: var(--ks-color-canvas); }
|
||||
.alert-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--ks-space-2); }
|
||||
.alert-header .threshold { font-weight: 600; }
|
||||
.alert-details { display: flex; justify-content: space-between; font-size: var(--ks-font-body); }
|
||||
.alert-details .current { font-weight: 600; }
|
||||
.alert-details .message { color: var(--ks-color-text-muted); }
|
||||
.no-alerts { padding: var(--ks-space-4); text-align: center; color: var(--ks-color-success); font-weight: 500; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.insights-list { list-style: none; margin: 0; padding: 0; }
|
||||
.insights-list li { padding: var(--ks-space-2) 0; border-bottom: 1px solid var(--ks-color-border); }
|
||||
.insights-list li:last-child { border-bottom: 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user