dbbfaca4d8
Problem: .master-list had hardcoded height: calc(100vh - 210px), causing scroll on first load Changes: • .master-list: calc(100vh - 210px) → flex: 1; min-height: 0; • .items: Added missing min-height: 0; (required for flex children) Result: Single-screen principle restored (no page-level scroll, height fills viewport) This aligns with AGENTS.md Height Propagation Chain standard. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
687 lines
18 KiB
Vue
687 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { reactive, computed, ref, onMounted } from 'vue'
|
|
import MasterDetailCrudPage from '../../../shared/ui/screen-types/v2/MasterDetailCrudPage.vue'
|
|
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
|
|
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
|
|
|
|
// KBX T04 Governance Audit & Screen State
|
|
const screenState = ref<StandardScreenState>('READY')
|
|
const evidence = reactive({
|
|
asOf: '2026-08-15T19:40:00Z',
|
|
version: 'v60-T04-Contract',
|
|
})
|
|
|
|
// Mock Models Data
|
|
const mockModels = [
|
|
{
|
|
modelId: '1',
|
|
name: 'Hawkeye-Alpha',
|
|
phase: 'Validate',
|
|
active: false,
|
|
pbo: 15.2,
|
|
dsr: 96.5,
|
|
returnMtd: 12.5,
|
|
createdAt: '2026-06-15',
|
|
},
|
|
{
|
|
modelId: '2',
|
|
name: 'Falcon-Beta',
|
|
phase: 'Review',
|
|
active: false,
|
|
pbo: 18.3,
|
|
dsr: 94.2,
|
|
returnMtd: 8.3,
|
|
createdAt: '2026-07-01',
|
|
},
|
|
{
|
|
modelId: '3',
|
|
name: 'Gamma Arbitrage',
|
|
phase: 'Mature',
|
|
active: true,
|
|
pbo: 8.5,
|
|
dsr: 98.1,
|
|
returnMtd: 18.7,
|
|
createdAt: '2026-05-10',
|
|
},
|
|
]
|
|
|
|
const models = ref(mockModels)
|
|
const selectedModelId = ref(mockModels[0]?.modelId)
|
|
|
|
const filterModel = reactive({
|
|
search: '',
|
|
phase: '',
|
|
})
|
|
|
|
const isSearching = ref(false)
|
|
|
|
const handleSearchTrigger = () => {
|
|
isSearching.value = true
|
|
screenState.value = 'LOADING'
|
|
setTimeout(() => {
|
|
screenState.value = 'READY'
|
|
isSearching.value = false
|
|
}, 300)
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'F3') {
|
|
e.preventDefault()
|
|
handleSearchTrigger()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
screenState.value = 'LOADING'
|
|
setTimeout(() => {
|
|
screenState.value = 'READY'
|
|
}, 500)
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
|
|
const filteredModels = computed(() => {
|
|
return models.value.filter(m => {
|
|
const matchesSearch = m.name.toLowerCase().includes(filterModel.search.toLowerCase())
|
|
const matchesPhase = !filterModel.phase || m.phase === filterModel.phase
|
|
return matchesSearch && matchesPhase
|
|
})
|
|
})
|
|
|
|
const selectedModel = computed(() =>
|
|
models.value.find(m => m.modelId === selectedModelId.value)
|
|
)
|
|
|
|
const selectModel = (id: string) => {
|
|
selectedModelId.value = id
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
})
|
|
}
|
|
|
|
const formatPercentage = (value: number) => {
|
|
return (value || 0).toFixed(2) + '%'
|
|
}
|
|
|
|
const handleRetry = () => {
|
|
screenState.value = 'LOADING'
|
|
setTimeout(() => {
|
|
screenState.value = 'READY'
|
|
}, 400)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<MasterDetailCrudPage
|
|
title="트레이딩 모델 마스터-상세 (Model Operations Master-Detail)"
|
|
subtitle="알고리즘 모델의 상태, 과최적화 검증 지표(PBO/DSR) 및 성과 메트릭을 추적 관리합니다."
|
|
:state="screenState"
|
|
:evidence="evidence"
|
|
@retry="handleRetry"
|
|
>
|
|
<template #actions>
|
|
<KsButton
|
|
label="🔍 조회 [F3]"
|
|
variant="primary"
|
|
size="sm"
|
|
aria-label="모델 목록 조회"
|
|
@click="handleSearchTrigger"
|
|
/>
|
|
</template>
|
|
|
|
<template #filters>
|
|
<section class="filters" aria-label="모델 검색 및 필터">
|
|
<input
|
|
v-model="filterModel.search"
|
|
placeholder="모델명 검색..."
|
|
class="input search-input"
|
|
aria-label="모델 이름 검색"
|
|
role="searchbox"
|
|
/>
|
|
<select
|
|
v-model="filterModel.phase"
|
|
class="input status-select"
|
|
aria-label="모델 단계별 필터"
|
|
>
|
|
<option value="">전체 단계 (All Phases)</option>
|
|
<option value="Mature">Mature</option>
|
|
<option value="Validate">Validate</option>
|
|
<option value="Review">Review</option>
|
|
</select>
|
|
</section>
|
|
</template>
|
|
|
|
<template #master>
|
|
<aside class="master-list" aria-label="모델 목록">
|
|
<h2>Models ({{ filteredModels.length }})</h2>
|
|
<ul class="items" role="list">
|
|
<li
|
|
v-for="model in filteredModels"
|
|
:key="model.modelId"
|
|
class="model-item"
|
|
:class="{ 'is-selected': selectedModelId === model.modelId }"
|
|
@click="selectModel(model.modelId)"
|
|
role="option"
|
|
:aria-selected="selectedModelId === model.modelId"
|
|
:aria-label="`${model.name}, Phase: ${model.phase}, PBO: ${formatPercentage(model.pbo)}, DSR: ${formatPercentage(model.dsr)}, Status: ${model.active ? 'Active' : 'Inactive'}`"
|
|
>
|
|
<div class="item-header">
|
|
<strong>{{ model.name }}</strong>
|
|
<KsStatusTag
|
|
:value="model.phase"
|
|
:severity="model.phase === 'Mature' || model.phase === 'Validate' ? 'success' : 'warning'"
|
|
/>
|
|
</div>
|
|
<div class="metrics" role="group" aria-label="모델 메트릭">
|
|
<div class="metric">
|
|
<span class="label">PBO</span>
|
|
<span class="value ks-financial-number" aria-label="Probability of Backtest Overfitting">{{ formatPercentage(model.pbo) }}</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="label">DSR</span>
|
|
<span class="value ks-financial-number" aria-label="Daily Sharpe Ratio">{{ formatPercentage(model.dsr) }}</span>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="label">Return</span>
|
|
<span class="value ks-financial-number" aria-label="Month to Date Return">{{ formatPercentage(model.returnMtd) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="footer">
|
|
<span v-if="model.active" class="status-active" aria-label="모델이 활성화됨">🟢 Active</span>
|
|
<span v-else class="status-inactive" aria-label="모델이 비활성화됨">⚪ Inactive</span>
|
|
<span class="date ks-financial-number" :aria-label="`Created: ${formatDate(model.createdAt)}`">{{ formatDate(model.createdAt) }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</aside>
|
|
</template>
|
|
|
|
<!-- Detail: Right Panel Slot -->
|
|
<template #detail>
|
|
<main class="detail-panel" role="main" aria-label="모델 상세 정보">
|
|
<h2 v-if="selectedModel" :aria-label="`Selected model: ${selectedModel.name}`">{{ selectedModel.name }}</h2>
|
|
<div v-else class="empty-detail" role="status" aria-live="polite">Select a model to view details</div>
|
|
|
|
<div v-if="selectedModel" class="model-detail">
|
|
<!-- Status -->
|
|
<section aria-label="모델 상태">
|
|
<h3>Status</h3>
|
|
<div class="status-grid" role="group">
|
|
<div class="status-item">
|
|
<span class="label">Phase</span>
|
|
<KsStatusTag
|
|
:value="selectedModel.phase"
|
|
:severity="selectedModel.phase === 'Mature' ? 'success' : 'warning'"
|
|
/>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="label">Active</span>
|
|
<span
|
|
class="value"
|
|
role="status"
|
|
:aria-label="`Model is ${selectedModel.active ? 'active' : 'inactive'}`"
|
|
>
|
|
{{ selectedModel.active ? '✓ Yes' : '✗ No' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Metrics -->
|
|
<section aria-label="검증 메트릭">
|
|
<h3>Metrics</h3>
|
|
<div class="metric-grid" role="group">
|
|
<div class="metric-card" role="region" aria-label="PBO - Probability of Backtest Overfitting">
|
|
<div class="metric-label">PBO (Backtest Overfit)</div>
|
|
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.pbo) }}</div>
|
|
<div class="metric-description" aria-describedby="pbo-help">Lower is better</div>
|
|
<div id="pbo-help" class="help-text">백테스트 과최적화 확률. 낮을수록 좋습니다</div>
|
|
</div>
|
|
<div class="metric-card" role="region" aria-label="DSR - Daily Sharpe Ratio">
|
|
<div class="metric-label">DSR (Daily Sharpe Ratio)</div>
|
|
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.dsr) }}</div>
|
|
<div class="metric-description" aria-describedby="dsr-help">Higher is better</div>
|
|
<div id="dsr-help" class="help-text">일일 샤프 비율. 높을수록 좋습니다</div>
|
|
</div>
|
|
<div class="metric-card" role="region" aria-label="Return MTD - Month to Date Return">
|
|
<div class="metric-label">Return MTD</div>
|
|
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.returnMtd) }}</div>
|
|
<div class="metric-description" aria-describedby="mtd-help">Month-to-date</div>
|
|
<div id="mtd-help" class="help-text">월간 누적 수익률</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Created Date -->
|
|
<section aria-label="모델 타임라인">
|
|
<h3>Timeline</h3>
|
|
<div class="timeline">
|
|
<div class="timeline-item" role="region" aria-label="Model creation date">
|
|
<span class="label">Created</span>
|
|
<span class="value ks-financial-number" role="status">{{ formatDate(selectedModel.createdAt) }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Actions -->
|
|
<section class="section" aria-label="모델 작업">
|
|
<div class="actions">
|
|
<KsButton
|
|
label="View Full Report [성과 보고서]"
|
|
variant="primary"
|
|
aria-label="전체 성과 보고서 보기"
|
|
/>
|
|
<KsButton
|
|
label="Export Metrics [내보내기]"
|
|
variant="secondary"
|
|
aria-label="메트릭 데이터 내보내기"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
</MasterDetailCrudPage>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.model-list {
|
|
padding: var(--spacing-5);
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 {
|
|
margin-bottom: var(--spacing-5);
|
|
font-size: var(--font-size-3xl);
|
|
font-weight: var(--font-weight-bold);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-size: var(--font-size-xl);
|
|
font-weight: var(--font-weight-semibold);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
h3 {
|
|
margin: 0 0 var(--spacing-2) 0;
|
|
font-size: var(--font-size-base);
|
|
font-weight: var(--font-weight-semibold);
|
|
color: var(--color-text-primary);
|
|
padding-bottom: var(--spacing-2);
|
|
border-bottom: var(--border-width-1) solid var(--color-border-secondary);
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--ks-space-2);
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.input {
|
|
height: var(--ks-control-height);
|
|
padding: 0 var(--ks-space-2);
|
|
border: 1px solid var(--ks-color-neutral-200);
|
|
border-radius: var(--ks-radius-sm);
|
|
background: var(--ks-color-surface);
|
|
color: var(--ks-color-neutral-900);
|
|
font-size: var(--ks-font-body);
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.input.search-input {
|
|
width: var(--ks-control-width-search);
|
|
}
|
|
|
|
.input.status-select {
|
|
width: var(--ks-control-width-select);
|
|
}
|
|
|
|
.input:focus {
|
|
outline: none;
|
|
border-color: var(--ks-color-action);
|
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
|
}
|
|
|
|
.content-skeleton {
|
|
display: grid;
|
|
grid-template-columns: 350px 1fr;
|
|
gap: var(--spacing-5);
|
|
}
|
|
|
|
.error-actions {
|
|
text-align: center;
|
|
padding: var(--spacing-4);
|
|
}
|
|
|
|
.content {
|
|
display: grid;
|
|
grid-template-columns: 350px 1fr;
|
|
gap: var(--spacing-5);
|
|
}
|
|
|
|
.master-list {
|
|
border: 1px solid var(--ks-color-neutral-200);
|
|
border-radius: var(--ks-radius-sm);
|
|
overflow: hidden;
|
|
background: var(--ks-color-surface);
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.master-list h2 {
|
|
padding: var(--ks-space-2) var(--ks-space-3);
|
|
background: #f1f5f9;
|
|
border-bottom: 1px solid var(--ks-color-neutral-300);
|
|
font-size: var(--ks-font-section);
|
|
font-weight: 700;
|
|
color: var(--ks-color-neutral-800);
|
|
margin: 0;
|
|
}
|
|
|
|
.items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--ks-space-2);
|
|
padding: var(--ks-space-2);
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.model-item {
|
|
padding: var(--ks-space-2) var(--ks-space-3);
|
|
background: var(--ks-color-surface);
|
|
border: 1px solid var(--ks-color-neutral-200);
|
|
border-radius: var(--ks-radius-sm);
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.model-item:hover {
|
|
background: #e0f2fe;
|
|
}
|
|
|
|
.model-item.is-selected {
|
|
border-color: var(--ks-color-action);
|
|
background: #eff6ff;
|
|
color: #0f172a;
|
|
box-shadow: 0 0 0 1px var(--ks-color-action);
|
|
}
|
|
|
|
.item-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-2);
|
|
gap: var(--spacing-2);
|
|
}
|
|
|
|
.item-header strong {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-primary);
|
|
flex: 1;
|
|
}
|
|
|
|
.phase-badge {
|
|
padding: var(--spacing-1) var(--spacing-2);
|
|
border-radius: var(--border-radius-base);
|
|
font-size: var(--font-size-xs);
|
|
font-weight: var(--font-weight-bold);
|
|
color: white;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: var(--spacing-2);
|
|
margin-bottom: var(--spacing-2);
|
|
font-size: var(--font-size-xs);
|
|
}
|
|
|
|
.metric {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-1);
|
|
}
|
|
|
|
.metric .label {
|
|
color: var(--color-text-tertiary);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.metric .value {
|
|
color: var(--color-text-primary);
|
|
font-weight: var(--font-weight-bold);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: var(--font-size-xs);
|
|
}
|
|
|
|
.status-active {
|
|
color: var(--color-success-600);
|
|
font-weight: var(--font-weight-bold);
|
|
}
|
|
|
|
.status-inactive {
|
|
color: var(--color-text-tertiary);
|
|
}
|
|
|
|
.date {
|
|
color: var(--color-text-tertiary);
|
|
}
|
|
|
|
.detail-panel {
|
|
border: 1px solid var(--ks-color-neutral-200);
|
|
border-radius: var(--ks-radius-sm);
|
|
padding: var(--ks-space-3);
|
|
background: var(--ks-color-surface);
|
|
height: calc(100vh - 210px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.empty-detail {
|
|
text-align: center;
|
|
padding: var(--ks-space-8);
|
|
color: var(--ks-color-neutral-500);
|
|
}
|
|
|
|
.model-detail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--ks-space-3);
|
|
}
|
|
|
|
.section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-2);
|
|
}
|
|
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: var(--spacing-2);
|
|
}
|
|
|
|
.status-item {
|
|
padding: var(--spacing-2) var(--spacing-3);
|
|
background: var(--color-background-primary);
|
|
border-radius: var(--border-radius-base);
|
|
border: var(--border-width-1) solid var(--color-border-secondary);
|
|
}
|
|
|
|
.status-item .label {
|
|
display: block;
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-tertiary);
|
|
margin-bottom: var(--spacing-1);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.status-item .value {
|
|
display: block;
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-primary);
|
|
font-weight: var(--font-weight-semibold);
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: var(--spacing-1) var(--spacing-2);
|
|
border-radius: var(--border-radius-base);
|
|
font-size: var(--font-size-xs);
|
|
font-weight: var(--font-weight-bold);
|
|
color: white;
|
|
}
|
|
|
|
.metric-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: var(--spacing-2);
|
|
}
|
|
|
|
.metric-card {
|
|
padding: var(--spacing-3);
|
|
background: var(--color-background-primary);
|
|
border: var(--border-width-1) solid var(--color-border-secondary);
|
|
border-radius: var(--border-radius-base);
|
|
text-align: center;
|
|
}
|
|
|
|
.metric-label {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-tertiary);
|
|
margin-bottom: var(--spacing-1);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.metric-value {
|
|
font-size: var(--font-size-lg);
|
|
font-weight: var(--font-weight-bold);
|
|
color: var(--color-primary-600);
|
|
margin-bottom: var(--spacing-1);
|
|
}
|
|
|
|
.metric-description {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-tertiary);
|
|
font-style: italic;
|
|
}
|
|
|
|
.help-text {
|
|
display: none;
|
|
}
|
|
|
|
.timeline {
|
|
padding: var(--spacing-2) var(--spacing-3);
|
|
background: var(--color-background-primary);
|
|
border-radius: var(--border-radius-base);
|
|
border: var(--border-width-1) solid var(--color-border-secondary);
|
|
}
|
|
|
|
.timeline-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--spacing-2) 0;
|
|
border-bottom: var(--border-width-1) solid var(--color-border-secondary);
|
|
}
|
|
|
|
.timeline-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.timeline-item .label {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-tertiary);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
|
|
.timeline-item .value {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: var(--spacing-2);
|
|
}
|
|
|
|
.btn {
|
|
flex: 1;
|
|
padding: var(--spacing-2) var(--spacing-3);
|
|
border: var(--border-width-1) solid var(--color-border-primary);
|
|
border-radius: var(--border-radius-base);
|
|
background: var(--color-background-primary);
|
|
color: var(--color-text-primary);
|
|
cursor: pointer;
|
|
font-size: var(--font-size-sm);
|
|
font-weight: var(--font-weight-medium);
|
|
transition: all var(--transition-fast);
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
.btn:hover:not(:disabled) {
|
|
background: var(--color-background-hover);
|
|
border-color: var(--color-border-secondary);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--color-primary-500);
|
|
color: white;
|
|
border-color: var(--color-primary-500);
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: var(--color-primary-600);
|
|
border-color: var(--color-primary-600);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--color-background-primary);
|
|
color: var(--color-text-primary);
|
|
border-color: var(--color-border-secondary);
|
|
}
|
|
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: var(--color-background-hover);
|
|
}
|
|
|
|
@media (max-width: 1000px) {
|
|
.content,
|
|
.content-skeleton {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.metric-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.filters {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|