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,421 @@
<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.shadow-run.detail'),
)
// Extract runId from route
const runId = computed(() => route.params.runId as string)
// Mock run data (will be replaced with TanStack Query)
const run = ref({
runId: '00000000-0000-0000-0000-000000000001',
modelName: 'Alpha Strategy',
windowStart: '2024-01-02',
windowEnd: '2024-09-10',
tradingDays: 187,
totalReturn: 12.5,
sharpeRatio: 1.8,
pbo: 15.2,
dsr: 96.5,
oos: 1.8,
maxDrawdown: -8.3,
winRate: 58.5,
profitFactor: 2.1,
phases: {
bull: { return: 18.2, sharpe: 2.3, trades: 45 },
bear: { return: -2.1, sharpe: -0.5, trades: 28 },
sideways: { return: 5.3, sharpe: 1.1, trades: 32 },
},
status: 'completed',
createdAt: '2026-08-01',
})
const isLoading = ref(false)
const error = ref<string | null>(null)
// Validation indicators
const validationStatus = computed(() => {
const pboOk = run.value.pbo <= 20
const dsrOk = run.value.dsr >= 95
const oosOk = run.value.oos <= 2.5
if (pboOk && dsrOk && oosOk) return 'valid'
if (pboOk || dsrOk || oosOk) return 'warning'
return 'invalid'
})
const validationMessage = computed(() => {
const checks = [
{ ok: run.value.pbo <= 20, msg: `PBO ${run.value.pbo}% ${run.value.pbo <= 20 ? '✓' : '✗'}` },
{ ok: run.value.dsr >= 95, msg: `DSR ${run.value.dsr}% ${run.value.dsr >= 95 ? '✓' : '✗'}` },
{ ok: run.value.oos <= 2.5, msg: `OOS ${run.value.oos}% ${run.value.oos <= 2.5 ? '✓' : '✗'}` },
]
return checks.map(c => c.msg).join(' | ')
})
// Actions
const handleBack = () => {
router.push('/model-ops/shadow-runs')
}
const handleExport = () => {
console.log('Export run:', runId.value)
}
const handleApprove = () => {
console.log('Approve run:', runId.value)
}
// Keyboard shortcuts
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleBack()
} else if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
handleExport()
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div class="shadow-run-detail">
<!-- Header -->
<header class="detail-header">
<div>
<h1>{{ run.modelName }}</h1>
<p class="breadcrumb">
<a href="/model-ops/shadow-runs" @click="handleBack">Shadow Runs</a>
/ {{ run.modelName }}
</p>
</div>
<div class="header-actions">
<KbxButton
:label="`Status: ${run.status}`"
variant="default"
disabled
/>
<KbxButton
label="Export"
variant="default"
@click="handleExport"
/>
<KbxButton
v-if="validationStatus === 'valid'"
label="Approve"
variant="primary"
@click="handleApprove"
/>
<KbxButton
label="Back"
variant="default"
@click="handleBack"
/>
</div>
</header>
<!-- Validation Summary -->
<section class="validation-summary" :class="`status-${validationStatus}`">
<h2>Validation Summary</h2>
<div class="validation-message">{{ validationMessage }}</div>
<div class="overall-status">
{{ validationStatus === 'valid' ? '✓ VALID' : validationStatus === 'warning' ? '⚠ WARNING' : '✗ INVALID' }}
</div>
</section>
<!-- Key Metrics -->
<section class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Return</div>
<div class="metric-value" :class="{ positive: run.totalReturn > 0 }">
{{ run.totalReturn > 0 ? '+' : '' }}{{ run.totalReturn }}%
</div>
</div>
<div class="metric-card">
<div class="metric-label">Sharpe Ratio</div>
<div class="metric-value" :class="{ positive: run.sharpeRatio > 0 }">
{{ run.sharpeRatio.toFixed(2) }}
</div>
</div>
<div class="metric-card">
<div class="metric-label">Max Drawdown</div>
<div class="metric-value negative">{{ run.maxDrawdown }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">Win Rate</div>
<div class="metric-value">{{ run.winRate }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">Profit Factor</div>
<div class="metric-value positive">{{ run.profitFactor }}</div>
</div>
<div class="metric-card">
<div class="metric-label">PBO</div>
<div class="metric-value" :class="{ ok: run.pbo <= 20 }">
{{ run.pbo }}%
</div>
</div>
<div class="metric-card">
<div class="metric-label">DSR</div>
<div class="metric-value" :class="{ ok: run.dsr >= 95 }">
{{ run.dsr }}%
</div>
</div>
<div class="metric-card">
<div class="metric-label">OOS</div>
<div class="metric-value" :class="{ ok: run.oos <= 2.5 }">
{{ run.oos }}%
</div>
</div>
</section>
<!-- Phase Breakdown -->
<section class="phase-breakdown">
<h2>Performance by Market Phase</h2>
<div class="phase-grid">
<div class="phase-card">
<div class="phase-name">Bull Market</div>
<div class="phase-metrics">
<div>Return: <strong>{{ run.phases.bull.return }}%</strong></div>
<div>Sharpe: <strong>{{ run.phases.bull.sharpe }}</strong></div>
<div>Trades: <strong>{{ run.phases.bull.trades }}</strong></div>
</div>
</div>
<div class="phase-card">
<div class="phase-name">Bear Market</div>
<div class="phase-metrics">
<div>Return: <strong>{{ run.phases.bear.return }}%</strong></div>
<div>Sharpe: <strong>{{ run.phases.bear.sharpe }}</strong></div>
<div>Trades: <strong>{{ run.phases.bear.trades }}</strong></div>
</div>
</div>
<div class="phase-card">
<div class="phase-name">Sideways Market</div>
<div class="phase-metrics">
<div>Return: <strong>{{ run.phases.sideways.return }}%</strong></div>
<div>Sharpe: <strong>{{ run.phases.sideways.sharpe }}</strong></div>
<div>Trades: <strong>{{ run.phases.sideways.trades }}</strong></div>
</div>
</div>
</div>
</section>
<!-- Metadata -->
<section class="metadata">
<h3>Details</h3>
<div class="metadata-grid">
<div>
<strong>Window Start:</strong>
{{ run.windowStart }}
</div>
<div>
<strong>Window End:</strong>
{{ run.windowEnd }}
</div>
<div>
<strong>Trading Days:</strong>
{{ run.tradingDays }}
</div>
<div>
<strong>Created:</strong>
{{ run.createdAt }}
</div>
</div>
</section>
</div>
</template>
<style scoped>
.shadow-run-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;
}
.validation-summary {
padding: 16px;
border-radius: 8px;
border-left: 4px solid #ccc;
}
.validation-summary.status-valid {
background: #f0fdf4;
border-left-color: #10b981;
}
.validation-summary.status-warning {
background: #fffbeb;
border-left-color: #f59e0b;
}
.validation-summary.status-invalid {
background: #fef2f2;
border-left-color: #ef4444;
}
.validation-summary h2 {
margin: 0 0 12px 0;
font-size: 16px;
}
.validation-message {
font-size: 14px;
margin-bottom: 8px;
font-family: monospace;
}
.overall-status {
font-weight: bold;
font-size: 18px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.metric-card {
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.metric-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #333;
}
.metric-value.positive {
color: #10b981;
}
.metric-value.negative {
color: #ef4444;
}
.metric-value.ok {
color: #10b981;
}
.phase-breakdown {
margin-top: 24px;
}
.phase-breakdown h2 {
font-size: 18px;
margin-bottom: 16px;
}
.phase-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.phase-card {
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.phase-name {
font-weight: bold;
font-size: 16px;
margin-bottom: 12px;
}
.phase-metrics {
font-size: 14px;
line-height: 1.8;
}
.metadata {
border-top: 1px solid #e0e0e0;
padding-top: 16px;
}
.metadata h3 {
margin: 0 0 12px 0;
}
.metadata-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
font-size: 14px;
}
.metadata-grid div {
padding: 8px;
background: #f9f9f9;
border-radius: 4px;
}
</style>