feat: implement page components for ShadowRun and Models features

Add 4 Vue 3 pages with KBX adapter integration:

Pages:
- ShadowRunList.vue (252+ day validation search & grid)
- ShadowRunDetail.vue (metrics breakdown, phase analysis)
- ModelsList.vue (lifecycle management, quick filters)
- ModelDetail.vue (activation requirements, configuration)

Features:
- Registry-driven screen definitions
- KbxListPage + KbxDataGrid + KbxButton adapters
- Mock data (replaced with TanStack Query in Task F)
- Keyboard shortcuts (F3, Ctrl+N, Escape, Ctrl+E)
- Responsive density-aware layout
- Validation indicators (PBO, DSR, OOS thresholds)
- Phase lifecycle visualization
- Quick filter badges

Implementation pattern:
1. useKbxRegistry() for screen access
2. Computed state for data state management
3. useRoute/useRouter for navigation
4. Slots for flexible layout composition

Ready for Task F: TanStack Query API integration

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:43:43 +09:00
parent c41e5063b7
commit d29f0e7df9
4 changed files with 1574 additions and 0 deletions
@@ -0,0 +1,628 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
const route = useRoute()
const router = useRouter()
const registry = useKbxRegistry()
// Get screen definition from registry
const screenDef = computed(() =>
registry.getScreen('model-ops.models.detail'),
)
// Extract modelId from route
const modelId = computed(() => route.params.modelId as string)
// Phases in lifecycle order
const phases = [
'Freeze',
'Mature',
'Score',
'Diagnose',
'Hypothesis',
'Challenger',
'Validate',
'Review',
'Manual Activation',
]
// Mock model data (will be replaced with TanStack Query)
const model = ref({
modelId: '00000000-0000-0000-0000-000000000001',
name: 'Alpha Strategy v1',
description: 'Quantitative trading strategy based on technical analysis',
phase: 'Validate',
active: false,
lastValidation: '2026-08-10',
pbo: 15.2,
dsr: 96.5,
oos: 1.8,
returnMtd: 12.5,
createdAt: '2026-06-15',
updatedAt: '2026-08-10',
validationHistory: [
{
date: '2026-08-10',
phase: 'Validate',
pbo: 15.2,
dsr: 96.5,
oos: 1.8,
status: 'approved',
},
{
date: '2026-08-05',
phase: 'Review',
pbo: 16.1,
dsr: 95.2,
oos: 2.1,
status: 'approved',
},
{
date: '2026-07-28',
phase: 'Challenger',
pbo: 18.5,
dsr: 93.8,
oos: 2.9,
status: 'approved',
},
],
configuration: {
lookbackPeriod: 252,
rebalanceFrequency: 'daily',
riskLimit: 2.0,
maxPositions: 20,
minLiquidityDays: 10,
},
})
const isLoading = ref(false)
const error = ref<string | null>(null)
// Find current phase index
const currentPhaseIndex = computed(() => {
return phases.findIndex(p => p === model.value.phase)
})
// Check activation requirements
const activationRequirements = computed(() => {
return {
shadowRun: { met: true, requirement: '252+ trading days', value: '✓ 252+ days completed' },
pbo: { met: model.value.pbo <= 20, requirement: 'PBO < 20%', value: `${model.value.pbo}%` },
dsr: { met: model.value.dsr >= 95, requirement: 'DSR ≥ 95%', value: `${model.value.dsr}%` },
oos: { met: model.value.oos <= 2.5, requirement: 'OOS ≤ 2.5%', value: `${model.value.oos}%` },
approval: { met: false, requirement: 'Maker-checker approval', value: '⏳ Pending' },
}
})
// Check if all requirements met
const canActivate = computed(() => {
return Object.values(activationRequirements.value).every(r => r.met)
})
// Actions
const handleBack = () => {
router.push('/model-ops/models')
}
const handleEdit = () => {
router.push(`/model-ops/models/${modelId.value}/edit`)
}
const handleActivate = () => {
if (canActivate.value) {
console.log('Activating model:', modelId.value)
}
}
const handleDeactivate = () => {
console.log('Deactivating model:', modelId.value)
}
const handlePhaseTransition = (newPhase: string) => {
const currentIndex = currentPhaseIndex.value
const newIndex = phases.indexOf(newPhase)
if (newIndex > currentIndex) {
console.log(`Transitioning from ${model.value.phase} to ${newPhase}`)
model.value.phase = newPhase
}
}
// Keyboard shortcuts
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleBack()
} else if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
handleEdit()
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div class="model-detail">
<!-- Header -->
<header class="detail-header">
<div>
<h1>{{ model.name }}</h1>
<p class="breadcrumb">
<a href="/model-ops/models" @click="handleBack">Models</a>
/ {{ model.name }}
</p>
</div>
<div class="header-actions">
<KbxButton
label="Edit"
variant="default"
@click="handleEdit"
/>
<KbxButton
v-if="!model.active"
:label="canActivate ? 'Activate' : 'Cannot Activate'"
:variant="canActivate ? 'primary' : 'default'"
:disabled="!canActivate"
@click="handleActivate"
/>
<KbxButton
v-else
label="Deactivate"
variant="danger"
@click="handleDeactivate"
/>
<KbxButton
label="Back"
variant="default"
@click="handleBack"
/>
</div>
</header>
<!-- Status & Description -->
<section class="info-section">
<div class="info-grid">
<div>
<strong>Status:</strong>
<span :class="{ active: model.active, inactive: !model.active }">
{{ model.active ? 'Active' : 'Inactive' }}
</span>
</div>
<div>
<strong>Phase:</strong>
{{ model.phase }}
</div>
<div>
<strong>Last Validation:</strong>
{{ model.lastValidation }}
</div>
<div>
<strong>Created:</strong>
{{ model.createdAt }}
</div>
</div>
<div v-if="model.description" class="description">
<strong>Description:</strong>
<p>{{ model.description }}</p>
</div>
</section>
<!-- Activation Requirements -->
<section class="requirements-section">
<h2>Activation Requirements</h2>
<div class="requirements-grid">
<div v-for="(req, key) in activationRequirements" :key="key" class="requirement-card" :class="{ met: req.met }">
<div class="requirement-check">
{{ req.met ? '✓' : '✗' }}
</div>
<div class="requirement-info">
<div class="requirement-name">{{ req.requirement }}</div>
<div class="requirement-value">{{ req.value }}</div>
</div>
</div>
</div>
</section>
<!-- Key Metrics -->
<section class="metrics-section">
<h2>Key Metrics</h2>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">PBO</div>
<div class="metric-value" :class="{ ok: model.pbo <= 20 }">
{{ model.pbo }}%
</div>
<div class="metric-requirement">Target: 20%</div>
</div>
<div class="metric-card">
<div class="metric-label">DSR</div>
<div class="metric-value" :class="{ ok: model.dsr >= 95 }">
{{ model.dsr }}%
</div>
<div class="metric-requirement">Target: 95%</div>
</div>
<div class="metric-card">
<div class="metric-label">OOS</div>
<div class="metric-value" :class="{ ok: model.oos <= 2.5 }">
{{ model.oos }}%
</div>
<div class="metric-requirement">Target: 2.5%</div>
</div>
<div class="metric-card">
<div class="metric-label">Return (MTD)</div>
<div class="metric-value positive">
+{{ model.returnMtd }}%
</div>
<div class="metric-requirement">Month-to-date</div>
</div>
</div>
</section>
<!-- Phase Lifecycle -->
<section class="phase-section">
<h2>Model Lifecycle</h2>
<div class="phase-timeline">
<div
v-for="(phase, index) in phases"
:key="phase"
class="phase-item"
:class="{
current: phase === model.phase,
completed: index < currentPhaseIndex,
future: index > currentPhaseIndex,
}"
>
<div class="phase-dot"></div>
<div class="phase-label">{{ phase }}</div>
<div v-if="index < currentPhaseIndex" class="phase-badge"></div>
</div>
</div>
</section>
<!-- Configuration -->
<section class="config-section">
<h2>Configuration</h2>
<div class="config-grid">
<div v-for="(value, key) in model.configuration" :key="key" class="config-item">
<strong>{{ key.replace(/([A-Z])/g, ' $1').toLowerCase() }}:</strong>
{{ value }}
</div>
</div>
</section>
<!-- Validation History -->
<section class="history-section">
<h2>Validation History</h2>
<div class="history-table">
<div class="table-header">
<div>Date</div>
<div>Phase</div>
<div>PBO</div>
<div>DSR</div>
<div>OOS</div>
<div>Status</div>
</div>
<div v-for="entry in model.validationHistory" :key="entry.date" class="table-row">
<div>{{ entry.date }}</div>
<div>{{ entry.phase }}</div>
<div>{{ entry.pbo }}%</div>
<div>{{ entry.dsr }}%</div>
<div>{{ entry.oos }}%</div>
<div :class="{ approved: entry.status === 'approved', rejected: entry.status === 'rejected' }">
{{ entry.status }}
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.model-detail {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 16px;
}
.detail-header h1 {
margin: 0;
font-size: 28px;
}
.breadcrumb {
margin: 8px 0 0 0;
color: #666;
font-size: 14px;
}
.breadcrumb a {
color: var(--kbx-color-primary, #3b82f6);
text-decoration: none;
cursor: pointer;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.header-actions {
display: flex;
gap: 12px;
}
/* Info Section */
.info-section {
border: 1px solid #e0e0e0;
padding: 16px;
border-radius: 8px;
background: #f9f9f9;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.info-grid div strong {
display: block;
margin-bottom: 4px;
color: #666;
font-size: 12px;
text-transform: uppercase;
}
.info-grid .active {
color: #10b981;
font-weight: bold;
}
.info-grid .inactive {
color: #666;
font-weight: bold;
}
.description {
padding-top: 16px;
border-top: 1px solid #d0d0d0;
}
.description strong {
display: block;
margin-bottom: 8px;
}
.description p {
margin: 0;
line-height: 1.6;
}
/* Requirements Section */
.requirements-section h2,
.metrics-section h2,
.phase-section h2,
.config-section h2,
.history-section h2 {
font-size: 18px;
margin: 0 0 16px 0;
}
.requirements-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.requirement-card {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 4px;
background: #fef2f2;
border-left: 4px solid #ef4444;
}
.requirement-card.met {
background: #f0fdf4;
border-left-color: #10b981;
}
.requirement-check {
font-size: 20px;
font-weight: bold;
min-width: 24px;
}
.requirement-card.met .requirement-check {
color: #10b981;
}
.requirement-card:not(.met) .requirement-check {
color: #ef4444;
}
.requirement-name {
font-weight: 600;
margin-bottom: 4px;
}
.requirement-value {
font-size: 14px;
color: #666;
}
/* Metrics Section */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.metric-card {
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
text-align: center;
}
.metric-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 8px;
}
.metric-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 4px;
}
.metric-value.ok {
color: #10b981;
}
.metric-value.positive {
color: #10b981;
}
.metric-requirement {
font-size: 12px;
color: #999;
margin-top: 4px;
}
/* Phase Timeline */
.phase-timeline {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 16px 0;
}
.phase-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 100px;
position: relative;
}
.phase-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: #d0d0d0;
border: 2px solid white;
}
.phase-item.completed .phase-dot {
background: #10b981;
}
.phase-item.current .phase-dot {
background: var(--kbx-color-primary, #3b82f6);
width: 20px;
height: 20px;
border-width: 3px;
}
.phase-label {
font-size: 12px;
text-align: center;
max-width: 90px;
line-height: 1.3;
}
.phase-badge {
font-size: 12px;
font-weight: bold;
color: #10b981;
}
/* Configuration Section */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.config-item {
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
font-size: 14px;
}
.config-item strong {
display: block;
margin-bottom: 4px;
color: #666;
text-transform: capitalize;
}
/* History Table */
.history-table {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 100px 100px 60px 60px 60px 100px;
gap: 0;
background: #f0f0f0;
padding: 12px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
}
.table-row {
display: grid;
grid-template-columns: 100px 100px 60px 60px 60px 100px;
gap: 0;
padding: 12px;
border-top: 1px solid #e0e0e0;
font-size: 14px;
align-items: center;
}
.table-row .approved {
color: #10b981;
font-weight: 600;
}
.table-row .rejected {
color: #ef4444;
font-weight: 600;
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
const route = useRoute()
const router = useRouter()
const registry = useKbxRegistry()
// Get screen definition from registry
const screenDef = computed(() =>
registry.getScreen('model-ops.models.list'),
)
// Search and filter state
const searchQuery = ref('')
const phaseFilter = ref('all')
const activeFilter = ref('all')
// Pagination
const currentPage = ref(1)
const pageSize = ref(50)
// Mock data state
const isLoading = ref(false)
const error = ref<string | null>(null)
const dataState = computed<'idle' | 'pending' | 'ready' | 'error' | 'empty'>(() => {
if (isLoading.value) return 'pending'
if (error.value) return 'error'
if (models.value.length === 0) return 'empty'
return 'ready'
})
// Mock models data (will be replaced with TanStack Query)
const models = ref([
{
modelId: '00000000-0000-0000-0000-000000000001',
name: 'Alpha Strategy v1',
phase: 'Validate',
active: false,
lastValidation: '2026-08-10',
pbo: 15.2,
dsr: 96.5,
returnMtd: 12.5,
createdAt: '2026-06-15',
},
{
modelId: '00000000-0000-0000-0000-000000000002',
name: 'Beta Model v2',
phase: 'Review',
active: false,
lastValidation: '2026-08-09',
pbo: 18.3,
dsr: 94.2,
returnMtd: 8.3,
createdAt: '2026-07-01',
},
{
modelId: '00000000-0000-0000-0000-000000000003',
name: 'Gamma Arbitrage',
phase: 'Mature',
active: true,
lastValidation: '2026-08-08',
pbo: 8.5,
dsr: 98.1,
returnMtd: 18.7,
createdAt: '2026-05-10',
},
])
// Phase counts
const phaseCounts = computed(() => {
const counts: Record<string, number> = {}
models.value.forEach(m => {
counts[m.phase] = (counts[m.phase] || 0) + 1
})
return counts
})
// Quick filters
const quickFilters = computed(() => [
{ id: 'all', label: 'All', active: phaseFilter.value === 'all', badge: models.value.length },
{ id: 'active', label: 'Active', active: activeFilter.value === 'active', badge: models.value.filter(m => m.active).length },
{ id: 'ready', label: 'Ready to Deploy', active: phaseFilter.value === 'ready', badge: 2 },
])
// Summary items
const summaryItems = computed(() => [
{ label: 'Total Models', value: models.value.length },
{ label: 'Active', value: models.value.filter(m => m.active).length },
{ label: 'Ready to Deploy', value: 2 },
{ label: 'Avg PBO', value: (models.value.reduce((sum, m) => sum + m.pbo, 0) / models.value.length).toFixed(1) },
])
// Actions
const handleSearch = () => {
isLoading.value = true
setTimeout(() => {
isLoading.value = false
}, 500)
}
const handleNewModel = () => {
router.push('/model-ops/models/new')
}
const handleRowClick = (modelId: string) => {
router.push(`/model-ops/models/${modelId}`)
}
const handleQuickFilter = (filterId: string) => {
if (filterId === 'active') {
activeFilter.value = activeFilter.value === 'active' ? 'all' : 'active'
} else {
phaseFilter.value = filterId
}
}
const handleRefresh = () => {
handleSearch()
}
// Keyboard shortcuts
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'F3') {
e.preventDefault()
handleSearch()
} else if (e.ctrlKey && e.key === 'n') {
e.preventDefault()
handleNewModel()
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div v-if="screenDef" class="models-list">
<KbxListPage
:screen="screenDef"
:data-state="dataState"
:loading="isLoading"
:summary-items="summaryItems"
:quick-filters="quickFilters"
@quick-filter="handleQuickFilter"
@refresh="handleRefresh"
>
<!-- Header Actions -->
<template #header-actions>
<KbxButton
label="New Model"
variant="primary"
@click="handleNewModel"
/>
</template>
<!-- Search Panel -->
<template #search>
<div class="models-search">
<div class="search-row">
<KbxInput
v-model="searchQuery"
placeholder="Search by model name..."
@keydown.enter="handleSearch"
/>
<KbxButton
label="Search"
variant="default"
@click="handleSearch"
/>
</div>
<div class="search-row">
<select v-model="phaseFilter" class="phase-filter">
<option value="all">All Phases</option>
<option value="freeze">Freeze</option>
<option value="mature">Mature</option>
<option value="score">Score</option>
<option value="diagnose">Diagnose</option>
<option value="hypothesis">Hypothesis</option>
<option value="challenger">Challenger</option>
<option value="validate">Validate</option>
<option value="review">Review</option>
</select>
<select v-model="activeFilter" class="active-filter">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</template>
<!-- Content Area -->
<template #content>
<KbxDataGrid
v-if="screenDef.grid"
:columns="screenDef.grid.columnDefs"
:rows="models"
:loading="isLoading"
@row-click="(modelId) => handleRowClick(modelId)"
/>
</template>
</KbxListPage>
</div>
</template>
<style scoped>
.models-list {
display: flex;
flex-direction: column;
height: 100%;
}
.models-search {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background: var(--kbx-color-surface, #f5f5f5);
border-radius: 4px;
}
.search-row {
display: flex;
gap: 12px;
align-items: center;
}
.search-row input,
.search-row select {
height: var(--kbx-input-height, 34px);
padding: 4px 8px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: var(--kbx-font-size, 14px);
}
.phase-filter,
.active-filter {
flex: 0 0 140px;
}
.badge {
background: var(--kbx-color-primary, #3b82f6);
color: white;
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
margin-left: 4px;
}
</style>