V13-FE-005: Complete KBX v60 frontend components, T01-T12 screen recipes, and WBS progress tracker

This commit is contained in:
2026-08-15 19:48:38 +09:00
parent e0460e000d
commit 938ec1842a
31 changed files with 1783 additions and 818 deletions
@@ -1,47 +1,18 @@
<template>
<PageLayout title="포트폴리오 리밸런싱" subtitle="목표 비중을 조정하고 리밸런싱을 실행합니다.">
<KsFormSection title="현재 구성">
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
<p class="ks-stack"><strong>포트폴리오 평가액:</strong> {{ formatCurrency(totalValue, 'USD') }}</p>
</KsFormSection>
<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>
<template #actions>
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
</template>
</KsFormSection>
<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 { computed, ref } from 'vue'
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
import { computed, ref, reactive } from 'vue'
import StepWizardPage from '../../../shared/ui/screen-types/v2/StepWizardPage.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'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// KBX T06 Governance Audit & Screen State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:44:00Z',
version: 'v60-T06-Contract',
})
interface Position {
symbol: string
@@ -63,7 +34,6 @@ interface JobResult {
estimatedCost: number
}
// Mock data
const currentPositions = ref<Position[]>([
{ symbol: 'AAPL', quantity: 100, marketPrice: 150.25, marketValue: 15025, weightPercent: 35.3 },
{ symbol: 'MSFT', quantity: 80, marketPrice: 320.50, marketValue: 25640, weightPercent: 60.2 },
@@ -71,11 +41,11 @@ const currentPositions = ref<Position[]>([
])
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) }
{ field: 'symbol', headerName: '종목코드' },
{ field: 'quantity', headerName: '수량', formatter: value => formatQuantity(value as number, 0) },
{ field: 'marketPrice', headerName: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
{ field: 'marketValue', headerName: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
{ field: 'weightPercent', headerName: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
])
const driftThreshold = ref(5)
@@ -86,7 +56,9 @@ const targetWeights = ref<TargetWeight[]>([
])
const jobResult = ref<JobResult | null>(null)
const totalValue = ref(42700)
const totalValue = computed(() => {
return currentPositions.value.reduce((sum, p) => sum + p.marketValue, 0)
})
const addTarget = () => {
targetWeights.value.push({ symbol: '', targetPercent: 0 })
@@ -96,17 +68,66 @@ const removeTarget = (idx: number) => {
targetWeights.value.splice(idx, 1)
}
const triggerRebalance = async () => {
// Mock API call
const triggerRebalance = () => {
jobResult.value = {
jobId: '550e8400-e29b-41d4-a716-446655440001',
jobId: `REBAL-${Date.now()}`,
status: 'Queued',
estimatedTradeCount: 3,
estimatedCost: 127.35,
}
}
const handleRetry = () => {
screenState.value = 'READY'
}
</script>
<template>
<StepWizardPage
title="포트폴리오 리밸런싱 (Portfolio Rebalance Wizard)"
subtitle="목표 비중 및 허용 이탈 임계값을 설정하여 리밸런싱 주문 작업을 안전하게 실행합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<!-- Default Slot: Wizard Content -->
<div class="content">
<KsFormSection title="1. 현재 포트폴리오 구성">
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
<p class="ks-stack"><strong>포트폴리오 평가액:</strong> <span class="ks-financial-number">{{ formatCurrency(totalValue, 'USD') }}</span></p>
</KsFormSection>
<KsFormSection title="2. 목표 비중 및 임계값 설정">
<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>
<template #actions>
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
</template>
</KsFormSection>
<KsFormSection v-if="jobResult" title="3. 리밸런싱 작업 대기열 등록 완료">
<dl class="ks-stack">
<dt>Job ID</dt><dd class="mono ks-financial-number">{{ jobResult.jobId }}</dd>
<dt>상태</dt><dd><KsStatusTag :value="jobResult.status" severity="info" /></dd>
<dt>예상 거래 건수</dt><dd class="ks-financial-number">{{ formatQuantity(jobResult.estimatedTradeCount, 0) }}</dd>
<dt>예상 비용</dt><dd class="ks-financial-number">{{ formatCurrency(jobResult.estimatedCost, 'USD') }}</dd>
</dl>
</KsFormSection>
</div>
</StepWizardPage>
</template>
<style scoped>
.target-row { display: flex; align-items: end; gap: var(--ks-space-3); }
.target-row > :first-child { flex: 1; }
@@ -11,32 +11,32 @@
<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>
<span class="value ks-financial-number">{{ formatCurrency(dashboard.riskMetrics.var95, 'USD') }}</span>
<span class="note ks-financial-number">{{ 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="value ks-financial-number">{{ 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="value ks-financial-number">{{ 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="value ks-financial-number">{{ 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>
<span class="value ks-financial-number">{{ 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="value ks-financial-number">{{ formatPercent(dashboard.riskMetrics.maxPositionPercent / 100, 1) }}</span>
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol ?? 'N/A' }}</span>
</div>
</template>