Merge remote main: align UI routes and menu with implemented screens
- Resolved merge conflicts in deploy.yml (take remote) - Removed stale publish/ binaries (should be .gitignore'd) - Synced to origin/main@9703687 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,11 @@ import { AppShellLayout } from './shared/ui/layouts'
|
||||
</script>
|
||||
<template>
|
||||
<AppShellLayout>
|
||||
<template #navigation><nav class="app-nav"><RouterLink to="/research/sell-decision">매도 의사결정</RouterLink><RouterLink to="/ops/data-quality">데이터 품질</RouterLink><RouterLink to="/ops/model-operations">모델 운영</RouterLink><RouterLink to="/internal/ui-standard">표준 UI 패턴</RouterLink></nav></template>
|
||||
<template #navigation><nav class="app-nav" aria-label="주요 메뉴">
|
||||
<section><h2>Research</h2><RouterLink to="/research/sell-decision">매도 의사결정</RouterLink></section>
|
||||
<section><h2>Portfolio</h2><RouterLink to="/portfolio/risk">포트폴리오 리스크</RouterLink><RouterLink to="/portfolio/rebalance">리밸런싱 제안</RouterLink></section>
|
||||
<section><h2>Operations</h2><RouterLink to="/ops/data-quality">데이터 품질</RouterLink><RouterLink to="/ops/market-data-ingestion">시장 데이터 수집</RouterLink><RouterLink to="/ops/market-data-history">수집 이력</RouterLink><RouterLink to="/ops/model-operations">모델 운영</RouterLink></section>
|
||||
</nav></template>
|
||||
<RouterView />
|
||||
</AppShellLayout>
|
||||
</template>
|
||||
|
||||
+52
-9
@@ -21,8 +21,11 @@ const { default: __VLS_6 } = __VLS_3.slots;
|
||||
const { navigation: __VLS_7 } = __VLS_3.slots;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.nav, __VLS_intrinsics.nav)({
|
||||
...{ class: "app-nav" },
|
||||
'aria-label': "주요 메뉴",
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['app-nav']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
let __VLS_8;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
@@ -35,15 +38,17 @@ const { default: __VLS_6 } = __VLS_3.slots;
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_9));
|
||||
const { default: __VLS_13 } = __VLS_11.slots;
|
||||
var __VLS_11;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
let __VLS_14;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_15 = __VLS_asFunctionalComponent1(__VLS_14, new __VLS_14({
|
||||
to: "/ops/data-quality",
|
||||
to: "/portfolio/risk",
|
||||
}));
|
||||
const __VLS_16 = __VLS_15({
|
||||
to: "/ops/data-quality",
|
||||
to: "/portfolio/risk",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_15));
|
||||
const { default: __VLS_19 } = __VLS_17.slots;
|
||||
var __VLS_17;
|
||||
@@ -52,32 +57,70 @@ const { default: __VLS_6 } = __VLS_3.slots;
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_21 = __VLS_asFunctionalComponent1(__VLS_20, new __VLS_20({
|
||||
to: "/ops/model-operations",
|
||||
to: "/portfolio/rebalance",
|
||||
}));
|
||||
const __VLS_22 = __VLS_21({
|
||||
to: "/ops/model-operations",
|
||||
to: "/portfolio/rebalance",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_21));
|
||||
const { default: __VLS_25 } = __VLS_23.slots;
|
||||
var __VLS_23;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
let __VLS_26;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_27 = __VLS_asFunctionalComponent1(__VLS_26, new __VLS_26({
|
||||
to: "/internal/ui-standard",
|
||||
to: "/ops/data-quality",
|
||||
}));
|
||||
const __VLS_28 = __VLS_27({
|
||||
to: "/internal/ui-standard",
|
||||
to: "/ops/data-quality",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_27));
|
||||
const { default: __VLS_31 } = __VLS_29.slots;
|
||||
var __VLS_29;
|
||||
let __VLS_32;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_33 = __VLS_asFunctionalComponent1(__VLS_32, new __VLS_32({
|
||||
to: "/ops/market-data-ingestion",
|
||||
}));
|
||||
const __VLS_34 = __VLS_33({
|
||||
to: "/ops/market-data-ingestion",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_33));
|
||||
const { default: __VLS_37 } = __VLS_35.slots;
|
||||
var __VLS_35;
|
||||
let __VLS_38;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_39 = __VLS_asFunctionalComponent1(__VLS_38, new __VLS_38({
|
||||
to: "/ops/market-data-history",
|
||||
}));
|
||||
const __VLS_40 = __VLS_39({
|
||||
to: "/ops/market-data-history",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_39));
|
||||
const { default: __VLS_43 } = __VLS_41.slots;
|
||||
var __VLS_41;
|
||||
let __VLS_44;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterLink | typeof __VLS_components.RouterLink} */
|
||||
RouterLink;
|
||||
// @ts-ignore
|
||||
const __VLS_45 = __VLS_asFunctionalComponent1(__VLS_44, new __VLS_44({
|
||||
to: "/ops/model-operations",
|
||||
}));
|
||||
const __VLS_46 = __VLS_45({
|
||||
to: "/ops/model-operations",
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_45));
|
||||
const { default: __VLS_49 } = __VLS_47.slots;
|
||||
var __VLS_47;
|
||||
}
|
||||
let __VLS_32;
|
||||
let __VLS_50;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.RouterView} */
|
||||
RouterView;
|
||||
// @ts-ignore
|
||||
const __VLS_33 = __VLS_asFunctionalComponent1(__VLS_32, new __VLS_32({}));
|
||||
const __VLS_34 = __VLS_33({}, ...__VLS_functionalComponentArgsRest(__VLS_33));
|
||||
const __VLS_51 = __VLS_asFunctionalComponent1(__VLS_50, new __VLS_50({}));
|
||||
const __VLS_52 = __VLS_51({}, ...__VLS_functionalComponentArgsRest(__VLS_51));
|
||||
var __VLS_3;
|
||||
const __VLS_export = (await import('vue')).defineComponent({});
|
||||
export default {};
|
||||
|
||||
@@ -3,6 +3,10 @@ import SellDecisionPage from '../features/sell-decision/pages/SellDecisionPage.v
|
||||
import DataQualityPage from '../features/data-quality/pages/DataQualityPage.vue';
|
||||
import ModelOperationsPage from '../features/model-operations/pages/ModelOperationsPage.vue';
|
||||
import UiStandardPage from '../features/ui-standard/pages/UiStandardPage.vue';
|
||||
import RiskDashboard from '../features/portfolio/pages/RiskDashboard.vue';
|
||||
import RebalanceForm from '../features/portfolio/pages/RebalanceForm.vue';
|
||||
import MarketDataIngestion from '../features/marketData/pages/MarketDataIngestion.vue';
|
||||
import IngestionStatus from '../features/marketData/pages/IngestionStatus.vue';
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
@@ -10,6 +14,10 @@ export const router = createRouter({
|
||||
{ path: '/research/sell-decision', component: SellDecisionPage, meta: { screenId: 'SCR-002', templateId: 'T02' } },
|
||||
{ path: '/ops/data-quality', component: DataQualityPage, meta: { screenId: 'SCR-013', templateId: 'T08' } },
|
||||
{ path: '/ops/model-operations', component: ModelOperationsPage, meta: { screenId: 'SCR-015', templateId: 'T10' } },
|
||||
{ path: '/ops/market-data-ingestion', component: MarketDataIngestion, meta: { screenId: 'SCR-016', templateId: 'T08' } },
|
||||
{ path: '/ops/market-data-history', component: IngestionStatus, meta: { screenId: 'SCR-017', templateId: 'T08' } },
|
||||
{ path: '/portfolio/risk', component: RiskDashboard, meta: { screenId: 'SCR-018', templateId: 'T07' } },
|
||||
{ path: '/portfolio/rebalance', component: RebalanceForm, meta: { screenId: 'SCR-019', templateId: 'T03' } },
|
||||
{ path: '/internal/ui-standard', component: UiStandardPage, meta: { screenId: 'SCR-DEV-001', templateId: 'T01', internalOnly: true } }
|
||||
]
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import SellDecisionPage from '../features/sell-decision/pages/SellDecisionPage.v
|
||||
import DataQualityPage from '../features/data-quality/pages/DataQualityPage.vue'
|
||||
import ModelOperationsPage from '../features/model-operations/pages/ModelOperationsPage.vue'
|
||||
import UiStandardPage from '../features/ui-standard/pages/UiStandardPage.vue'
|
||||
import RiskDashboard from '../features/portfolio/pages/RiskDashboard.vue'
|
||||
import RebalanceForm from '../features/portfolio/pages/RebalanceForm.vue'
|
||||
import MarketDataIngestion from '../features/marketData/pages/MarketDataIngestion.vue'
|
||||
import IngestionStatus from '../features/marketData/pages/IngestionStatus.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -11,6 +15,10 @@ export const router = createRouter({
|
||||
{ path: '/research/sell-decision', component: SellDecisionPage, meta: { screenId: 'SCR-002', templateId: 'T02' } },
|
||||
{ path: '/ops/data-quality', component: DataQualityPage, meta: { screenId: 'SCR-013', templateId: 'T08' } },
|
||||
{ path: '/ops/model-operations', component: ModelOperationsPage, meta: { screenId: 'SCR-015', templateId: 'T10' } },
|
||||
{ path: '/ops/market-data-ingestion', component: MarketDataIngestion, meta: { screenId: 'SCR-016', templateId: 'T08' } },
|
||||
{ path: '/ops/market-data-history', component: IngestionStatus, meta: { screenId: 'SCR-017', templateId: 'T08' } },
|
||||
{ path: '/portfolio/risk', component: RiskDashboard, meta: { screenId: 'SCR-018', templateId: 'T07' } },
|
||||
{ path: '/portfolio/rebalance', component: RebalanceForm, meta: { screenId: 'SCR-019', templateId: 'T03' } },
|
||||
{ path: '/internal/ui-standard', component: UiStandardPage, meta: { screenId: 'SCR-DEV-001', templateId: 'T01', internalOnly: true } }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="ingestion-status">
|
||||
<div class="header">
|
||||
<h1>Market Data Ingestion</h1>
|
||||
<p class="subtitle">Monitor data collection status</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Status summary -->
|
||||
<div v-if="job" class="status-card">
|
||||
<div class="status-header">
|
||||
<h2>Job {{ job.jobId.substring(0, 8) }}</h2>
|
||||
<span :class="['status-badge', `status-${job.status.toLowerCase()}`]">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="stat">
|
||||
<span class="label">Rows Processed</span>
|
||||
<span class="value">{{ job.rowsProcessed.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<span class="label">Rows Failed</span>
|
||||
<span class="value error">{{ job.rowsFailed }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<span class="label">Quality Score</span>
|
||||
<span class="value">{{ calculateQualityScore(job) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="stat" v-if="job.durationSeconds">
|
||||
<span class="label">Duration</span>
|
||||
<span class="value">{{ job.durationSeconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.errorMessage" class="error-section">
|
||||
<strong>Error:</strong> {{ job.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="loading">
|
||||
<p>Fetching ingestion status...</p>
|
||||
</div>
|
||||
|
||||
<!-- Historical jobs -->
|
||||
<div class="history-section">
|
||||
<h3>Recent Ingestions</h3>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Status</th>
|
||||
<th>Rows</th>
|
||||
<th>Duration</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in recentJobs" :key="idx" :class="`status-${item.status.toLowerCase()}`">
|
||||
<td>{{ item.jobId.substring(0, 8) }}</td>
|
||||
<td><span :class="['status-badge', `status-${item.status.toLowerCase()}`]">{{ item.status }}</span></td>
|
||||
<td>{{ item.rowsProcessed }}</td>
|
||||
<td>{{ item.durationSeconds ? `${item.durationSeconds}s` : '—' }}</td>
|
||||
<td>{{ item.completedAt ? new Date(item.completedAt).toLocaleDateString() : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
interface IngestionJob {
|
||||
jobId: string
|
||||
status: string
|
||||
rowsProcessed: number
|
||||
rowsFailed: number
|
||||
rowsSkipped?: number
|
||||
durationSeconds?: number
|
||||
completedAt?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const job = ref<IngestionJob | null>(null)
|
||||
const recentJobs = ref<IngestionJob[]>([])
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Fetch latest job status from API
|
||||
const fetchLatestJob = async () => {
|
||||
try {
|
||||
// In a real app, this would fetch from /api/market/ingest/latest
|
||||
// For now, we'll show a loading state
|
||||
const response = await fetch('/api/market/ingest/latest', {
|
||||
headers: {
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
job.value = await response.json()
|
||||
} else if (response.status === 404) {
|
||||
// No jobs yet - that's fine
|
||||
job.value = null
|
||||
} else {
|
||||
throw new Error(`API error: ${response.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch latest job:', err)
|
||||
// Don't fail the page, just show no data
|
||||
job.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recent jobs history
|
||||
const fetchRecentJobs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/market/ingest/history?limit=10', {
|
||||
headers: {
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
recentJobs.value = await response.json()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch recent jobs:', err)
|
||||
error.value = 'Failed to load job history'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLatestJob()
|
||||
fetchRecentJobs()
|
||||
|
||||
// Auto-refresh every 10 seconds if there's an active job
|
||||
const interval = setInterval(() => {
|
||||
if (job.value?.status === 'Running' || job.value?.status === 'Queued') {
|
||||
fetchLatestJob()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const calculateQualityScore = (job: IngestionJob): number => {
|
||||
const total = job.rowsProcessed + job.rowsFailed + (job.rowsSkipped || 0)
|
||||
if (total === 0) return 0
|
||||
return Math.round((job.rowsProcessed / total) * 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ingestion-status {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
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: 2rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.status-completed {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-running {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-failed {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-queued {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat .value.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
color: #7f1d1d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.history-section h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-table thead {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-table tbody tr.status-completed {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.history-table tbody tr.status-failed {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
const job = ref(null);
|
||||
const recentJobs = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const error = ref(null);
|
||||
// Fetch latest job status from API
|
||||
const fetchLatestJob = async () => {
|
||||
try {
|
||||
// In a real app, this would fetch from /api/market/ingest/latest
|
||||
// For now, we'll show a loading state
|
||||
const response = await fetch('/api/market/ingest/latest', {
|
||||
headers: {
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
job.value = await response.json();
|
||||
}
|
||||
else if (response.status === 404) {
|
||||
// No jobs yet - that's fine
|
||||
job.value = null;
|
||||
}
|
||||
else {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to fetch latest job:', err);
|
||||
// Don't fail the page, just show no data
|
||||
job.value = null;
|
||||
}
|
||||
};
|
||||
// Fetch recent jobs history
|
||||
const fetchRecentJobs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/market/ingest/history?limit=10', {
|
||||
headers: {
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
recentJobs.value = await response.json();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to fetch recent jobs:', err);
|
||||
error.value = 'Failed to load job history';
|
||||
}
|
||||
finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
fetchLatestJob();
|
||||
fetchRecentJobs();
|
||||
// Auto-refresh every 10 seconds if there's an active job
|
||||
const interval = setInterval(() => {
|
||||
if (job.value?.status === 'Running' || job.value?.status === 'Queued') {
|
||||
fetchLatestJob();
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
const calculateQualityScore = (job) => {
|
||||
const total = job.rowsProcessed + job.rowsFailed + (job.rowsSkipped || 0);
|
||||
if (total === 0)
|
||||
return 0;
|
||||
return Math.round((job.rowsProcessed / total) * 100);
|
||||
};
|
||||
const __VLS_ctx = {
|
||||
...{},
|
||||
...{},
|
||||
};
|
||||
let __VLS_components;
|
||||
let __VLS_intrinsics;
|
||||
let __VLS_directives;
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-completed']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status-failed']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "ingestion-status" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['ingestion-status']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h1, __VLS_intrinsics.h1)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "subtitle" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['subtitle']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "content" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['content']} */ ;
|
||||
if (__VLS_ctx.job) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "status-card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "status-header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
(__VLS_ctx.job.jobId.substring(0, 8));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: (['status-badge', `status-${__VLS_ctx.job.status.toLowerCase()}`]) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
(__VLS_ctx.job.status);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "status-grid" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-grid']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "stat" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.job.rowsProcessed.toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "stat" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value error" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error']} */ ;
|
||||
(__VLS_ctx.job.rowsFailed);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "stat" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.calculateQualityScore(__VLS_ctx.job));
|
||||
if (__VLS_ctx.job.durationSeconds) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "stat" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['stat']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.job.durationSeconds);
|
||||
}
|
||||
if (__VLS_ctx.job.errorMessage) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "error-section" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['error-section']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
(__VLS_ctx.job.errorMessage);
|
||||
}
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "loading" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['loading']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "history-section" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['history-section']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h3, __VLS_intrinsics.h3)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.table, __VLS_intrinsics.table)({
|
||||
...{ class: "history-table" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['history-table']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.thead, __VLS_intrinsics.thead)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tbody, __VLS_intrinsics.tbody)({});
|
||||
for (const [item, idx] of __VLS_vFor((__VLS_ctx.recentJobs))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({
|
||||
key: (idx),
|
||||
...{ class: (`status-${item.status.toLowerCase()}`) },
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(item.jobId.substring(0, 8));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: (['status-badge', `status-${item.status.toLowerCase()}`]) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
(item.status);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(item.rowsProcessed);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(item.durationSeconds ? `${item.durationSeconds}s` : '—');
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(item.completedAt ? new Date(item.completedAt).toLocaleDateString() : '—');
|
||||
// @ts-ignore
|
||||
[job, job, job, job, job, job, job, job, job, job, job, calculateQualityScore, recentJobs,];
|
||||
}
|
||||
// @ts-ignore
|
||||
[];
|
||||
const __VLS_export = (await import('vue')).defineComponent({});
|
||||
export default {};
|
||||
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="market-data-ingestion">
|
||||
<div class="header">
|
||||
<h1>📊 Market Data Ingestion</h1>
|
||||
<p class="subtitle">Schedule KRX historical data collection</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Configuration Card -->
|
||||
<div class="config-card">
|
||||
<h2>1. Select Data Source & Period</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Data Source</label>
|
||||
<select v-model="form.dataSource">
|
||||
<option value="KRX">KRX (Korea Exchange) - KOSPI/KOSDAQ Daily</option>
|
||||
<option value="OpenDart">OpenDart - Financial Disclosures (T+2)</option>
|
||||
<option value="Stub">Stub (Test Data)</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
<strong>KRX:</strong> Stock prices (Open/High/Low/Close/Volume)
|
||||
<strong>OpenDart:</strong> Corporate disclosures & filings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>From Date</label>
|
||||
<input
|
||||
v-model="form.fromDate"
|
||||
type="date"
|
||||
:min="minDate"
|
||||
:max="maxDate"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<p class="hint">Earliest: {{ minDate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>To Date</label>
|
||||
<input
|
||||
v-model="form.toDate"
|
||||
type="date"
|
||||
:min="form.fromDate || minDate"
|
||||
:max="maxDate"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<p class="hint">Latest: {{ maxDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick presets -->
|
||||
<div class="presets">
|
||||
<button @click="setPreset('1y')" class="preset-btn">Last 1 Year</button>
|
||||
<button @click="setPreset('2y')" class="preset-btn">Last 2 Years</button>
|
||||
<button @click="setPreset('5y')" class="preset-btn">Last 5 Years</button>
|
||||
<button @click="setPreset('all')" class="preset-btn">All Available</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation & Summary -->
|
||||
<div v-if="validationErrors.length" class="error-card">
|
||||
<h3>⚠️ Validation Errors</h3>
|
||||
<ul>
|
||||
<li v-for="(err, idx) in validationErrors" :key="idx">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="!validationErrors.length" class="summary-card">
|
||||
<h3>📋 Collection Summary</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<span class="label">Data Source:</span>
|
||||
<span class="value">{{ form.dataSource }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Period:</span>
|
||||
<span class="value">{{ form.fromDate }} to {{ form.toDate }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Days:</span>
|
||||
<span class="value">{{ daysCount }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Est. Rows:</span>
|
||||
<span class="value">{{ estimatedRows }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
<button
|
||||
@click="triggerIngestion"
|
||||
:disabled="isLoading || validationErrors.length > 0"
|
||||
class="btn-primary"
|
||||
>
|
||||
<span v-if="!isLoading">🚀 Schedule Ingestion</span>
|
||||
<span v-else>⏳ Processing...</span>
|
||||
</button>
|
||||
<button @click="resetForm" class="btn-secondary">↻ Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="jobId" class="success-card">
|
||||
<h3>✅ Job Scheduled Successfully</h3>
|
||||
<div class="job-info">
|
||||
<p><strong>Job ID:</strong> {{ jobId }}</p>
|
||||
<p><strong>Status:</strong> Queued</p>
|
||||
<p><strong>Queued At:</strong> {{ new Date().toLocaleString() }}</p>
|
||||
<p class="hint">The ingestion will run in the background. Check the status in History tab.</p>
|
||||
</div>
|
||||
<router-link to="/ops/market-data-history" class="btn-link">
|
||||
📈 View Collection History →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
const jobId = ref<string | null>(null)
|
||||
|
||||
const form = ref({
|
||||
dataSource: 'KRX',
|
||||
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
toDate: new Date().toISOString().split('T')[0],
|
||||
})
|
||||
|
||||
const minDate = '2015-01-01' // KRX historical data starts here
|
||||
const maxDate = new Date().toISOString().split('T')[0] // Today
|
||||
|
||||
const validationErrors = computed(() => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!form.value.fromDate) errors.push('From Date is required')
|
||||
if (!form.value.toDate) errors.push('To Date is required')
|
||||
|
||||
if (form.value.fromDate && form.value.toDate) {
|
||||
if (form.value.fromDate > form.value.toDate) {
|
||||
errors.push('From Date must be before To Date')
|
||||
}
|
||||
if (form.value.toDate > maxDate) {
|
||||
errors.push('To Date cannot be in the future')
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
})
|
||||
|
||||
const daysCount = computed(() => {
|
||||
if (!form.value.fromDate || !form.value.toDate) return 0
|
||||
const from = new Date(form.value.fromDate)
|
||||
const to = new Date(form.value.toDate)
|
||||
return Math.ceil((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
const estimatedRows = computed(() => {
|
||||
// KRX: ~2000 stocks × days
|
||||
// OpenDart: ~200 quarterly filings
|
||||
if (form.value.dataSource === 'KRX') {
|
||||
return (daysCount.value * 2000).toLocaleString()
|
||||
} else if (form.value.dataSource === 'OpenDart') {
|
||||
return (Math.ceil(daysCount.value / 90) * 200).toLocaleString()
|
||||
}
|
||||
return '0'
|
||||
})
|
||||
|
||||
const setPreset = (preset: string) => {
|
||||
const today = new Date()
|
||||
const from = new Date()
|
||||
|
||||
if (preset === '1y') from.setFullYear(from.getFullYear() - 1)
|
||||
else if (preset === '2y') from.setFullYear(from.getFullYear() - 2)
|
||||
else if (preset === '5y') from.setFullYear(from.getFullYear() - 5)
|
||||
else if (preset === 'all') from.setFullYear(2015)
|
||||
|
||||
form.value.fromDate = from.toISOString().split('T')[0]
|
||||
form.value.toDate = today.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const triggerIngestion = async () => {
|
||||
if (validationErrors.value.length > 0) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await fetch('/api/market/ingest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource: form.value.dataSource,
|
||||
fromDate: form.value.fromDate,
|
||||
toDate: form.value.toDate,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
jobId.value = data.jobId
|
||||
|
||||
// Reset form after success
|
||||
setTimeout(() => {
|
||||
form.value.fromDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
form.value.toDate = new Date().toISOString().split('T')[0]
|
||||
jobId.value = null
|
||||
}, 5000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ingestion error:', error)
|
||||
alert(`Failed to trigger ingestion: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
dataSource: 'KRX',
|
||||
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
toDate: new Date().toISOString().split('T')[0],
|
||||
}
|
||||
jobId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-data-ingestion {
|
||||
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;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.summary-card,
|
||||
.error-card,
|
||||
.success-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.config-card h2,
|
||||
.summary-card h3,
|
||||
.error-card h3,
|
||||
.success-card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--surface-secondary);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
border-color: #ef4444;
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.error-card li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
border-color: #10b981;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.success-card h3 {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
background: var(--surface);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-info p {
|
||||
margin: 0.5rem 0;
|
||||
color: #065f46;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.job-info strong {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-link {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const isLoading = ref(false);
|
||||
const jobId = ref(null);
|
||||
const form = ref({
|
||||
dataSource: 'KRX',
|
||||
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
toDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
const minDate = '2015-01-01'; // KRX historical data starts here
|
||||
const maxDate = new Date().toISOString().split('T')[0]; // Today
|
||||
const validationErrors = computed(() => {
|
||||
const errors = [];
|
||||
if (!form.value.fromDate)
|
||||
errors.push('From Date is required');
|
||||
if (!form.value.toDate)
|
||||
errors.push('To Date is required');
|
||||
if (form.value.fromDate && form.value.toDate) {
|
||||
if (form.value.fromDate > form.value.toDate) {
|
||||
errors.push('From Date must be before To Date');
|
||||
}
|
||||
if (form.value.toDate > maxDate) {
|
||||
errors.push('To Date cannot be in the future');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
const daysCount = computed(() => {
|
||||
if (!form.value.fromDate || !form.value.toDate)
|
||||
return 0;
|
||||
const from = new Date(form.value.fromDate);
|
||||
const to = new Date(form.value.toDate);
|
||||
return Math.ceil((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
|
||||
});
|
||||
const estimatedRows = computed(() => {
|
||||
// KRX: ~2000 stocks × days
|
||||
// OpenDart: ~200 quarterly filings
|
||||
if (form.value.dataSource === 'KRX') {
|
||||
return (daysCount.value * 2000).toLocaleString();
|
||||
}
|
||||
else if (form.value.dataSource === 'OpenDart') {
|
||||
return (Math.ceil(daysCount.value / 90) * 200).toLocaleString();
|
||||
}
|
||||
return '0';
|
||||
});
|
||||
const setPreset = (preset) => {
|
||||
const today = new Date();
|
||||
const from = new Date();
|
||||
if (preset === '1y')
|
||||
from.setFullYear(from.getFullYear() - 1);
|
||||
else if (preset === '2y')
|
||||
from.setFullYear(from.getFullYear() - 2);
|
||||
else if (preset === '5y')
|
||||
from.setFullYear(from.getFullYear() - 5);
|
||||
else if (preset === 'all')
|
||||
from.setFullYear(2015);
|
||||
form.value.fromDate = from.toISOString().split('T')[0];
|
||||
form.value.toDate = today.toISOString().split('T')[0];
|
||||
};
|
||||
const triggerIngestion = async () => {
|
||||
if (validationErrors.value.length > 0)
|
||||
return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await fetch('/api/market/ingest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-KArtSell-User': 'ingestion-user',
|
||||
'X-KArtSell-Role': 'DataAdmin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource: form.value.dataSource,
|
||||
fromDate: form.value.fromDate,
|
||||
toDate: form.value.toDate,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
jobId.value = data.jobId;
|
||||
// Reset form after success
|
||||
setTimeout(() => {
|
||||
form.value.fromDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
form.value.toDate = new Date().toISOString().split('T')[0];
|
||||
jobId.value = null;
|
||||
}, 5000);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Ingestion error:', error);
|
||||
alert(`Failed to trigger ingestion: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
dataSource: 'KRX',
|
||||
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
toDate: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
jobId.value = null;
|
||||
};
|
||||
const __VLS_ctx = {
|
||||
...{},
|
||||
...{},
|
||||
};
|
||||
let __VLS_components;
|
||||
let __VLS_intrinsics;
|
||||
let __VLS_directives;
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['config-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['summary-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['success-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['preset-btn']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['success-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['success-card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['job-info']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['job-info']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-secondary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-secondary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-link']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-link']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "market-data-ingestion" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['market-data-ingestion']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h1, __VLS_intrinsics.h1)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "subtitle" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['subtitle']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "content" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['content']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "config-card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['config-card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "form-group" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.select, __VLS_intrinsics.select)({
|
||||
value: (__VLS_ctx.form.dataSource),
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.option, __VLS_intrinsics.option)({
|
||||
value: "KRX",
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.option, __VLS_intrinsics.option)({
|
||||
value: "OpenDart",
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.option, __VLS_intrinsics.option)({
|
||||
value: "Stub",
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "hint" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['hint']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "form-row" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['form-row']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "form-group" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.input)({
|
||||
type: "date",
|
||||
min: (__VLS_ctx.minDate),
|
||||
max: (__VLS_ctx.maxDate),
|
||||
placeholder: "YYYY-MM-DD",
|
||||
});
|
||||
(__VLS_ctx.form.fromDate);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "hint" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['hint']} */ ;
|
||||
(__VLS_ctx.minDate);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "form-group" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.input)({
|
||||
type: "date",
|
||||
min: (__VLS_ctx.form.fromDate || __VLS_ctx.minDate),
|
||||
max: (__VLS_ctx.maxDate),
|
||||
placeholder: "YYYY-MM-DD",
|
||||
});
|
||||
(__VLS_ctx.form.toDate);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "hint" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['hint']} */ ;
|
||||
(__VLS_ctx.maxDate);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "presets" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['presets']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
return (__VLS_ctx.setPreset('1y'));
|
||||
// @ts-ignore
|
||||
[form, form, form, form, minDate, minDate, minDate, maxDate, maxDate, maxDate, setPreset,];
|
||||
} },
|
||||
...{ class: "preset-btn" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['preset-btn']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
return (__VLS_ctx.setPreset('2y'));
|
||||
// @ts-ignore
|
||||
[setPreset,];
|
||||
} },
|
||||
...{ class: "preset-btn" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['preset-btn']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
return (__VLS_ctx.setPreset('5y'));
|
||||
// @ts-ignore
|
||||
[setPreset,];
|
||||
} },
|
||||
...{ class: "preset-btn" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['preset-btn']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
return (__VLS_ctx.setPreset('all'));
|
||||
// @ts-ignore
|
||||
[setPreset,];
|
||||
} },
|
||||
...{ class: "preset-btn" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['preset-btn']} */ ;
|
||||
if (__VLS_ctx.validationErrors.length) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "error-card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['error-card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h3, __VLS_intrinsics.h3)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.ul, __VLS_intrinsics.ul)({});
|
||||
for (const [err, idx] of __VLS_vFor((__VLS_ctx.validationErrors))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.li, __VLS_intrinsics.li)({
|
||||
key: (idx),
|
||||
});
|
||||
(err);
|
||||
// @ts-ignore
|
||||
[validationErrors, validationErrors,];
|
||||
}
|
||||
}
|
||||
if (!__VLS_ctx.validationErrors.length) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h3, __VLS_intrinsics.h3)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-grid" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.form.dataSource);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.form.fromDate);
|
||||
(__VLS_ctx.form.toDate);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.daysCount);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.estimatedRows);
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "actions" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['actions']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (__VLS_ctx.triggerIngestion) },
|
||||
disabled: (__VLS_ctx.isLoading || __VLS_ctx.validationErrors.length > 0),
|
||||
...{ class: "btn-primary" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
if (!__VLS_ctx.isLoading) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (__VLS_ctx.resetForm) },
|
||||
...{ class: "btn-secondary" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-secondary']} */ ;
|
||||
if (__VLS_ctx.jobId) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "success-card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['success-card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h3, __VLS_intrinsics.h3)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "job-info" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['job-info']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
(__VLS_ctx.jobId);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
(new Date().toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "hint" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['hint']} */ ;
|
||||
let __VLS_0;
|
||||
/** @ts-ignore @type { | typeof __VLS_components.routerLink | typeof __VLS_components.RouterLink | typeof __VLS_components['router-link'] | typeof __VLS_components.routerLink | typeof __VLS_components.RouterLink | typeof __VLS_components['router-link']} */
|
||||
routerLink;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({
|
||||
to: "/ops/market-data-history",
|
||||
...{ class: "btn-link" },
|
||||
}));
|
||||
const __VLS_2 = __VLS_1({
|
||||
to: "/ops/market-data-history",
|
||||
...{ class: "btn-link" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
/** @type {__VLS_StyleScopedClasses['btn-link']} */ ;
|
||||
const { default: __VLS_5 } = __VLS_3.slots;
|
||||
// @ts-ignore
|
||||
[form, form, form, validationErrors, validationErrors, daysCount, estimatedRows, triggerIngestion, isLoading, isLoading, resetForm, jobId, jobId,];
|
||||
var __VLS_3;
|
||||
}
|
||||
// @ts-ignore
|
||||
[];
|
||||
const __VLS_export = (await import('vue')).defineComponent({});
|
||||
export default {};
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="rebalance-form">
|
||||
<div class="header">
|
||||
<h1>Portfolio Rebalancing</h1>
|
||||
<p class="subtitle">Adjust target weights and trigger rebalancing</p>
|
||||
</div>
|
||||
|
||||
<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() }}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Position {
|
||||
symbol: string
|
||||
quantity: number
|
||||
marketPrice: number
|
||||
marketValue: number
|
||||
weightPercent: number
|
||||
}
|
||||
|
||||
interface TargetWeight {
|
||||
symbol: string
|
||||
targetPercent: number
|
||||
}
|
||||
|
||||
interface JobResult {
|
||||
jobId: string
|
||||
status: string
|
||||
estimatedTradeCount: number
|
||||
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 },
|
||||
{ symbol: 'GOOGL', quantity: 50, marketPrice: 140.75, marketValue: 7037.5, weightPercent: 16.5 },
|
||||
])
|
||||
|
||||
const driftThreshold = ref(5)
|
||||
const targetWeights = ref<TargetWeight[]>([
|
||||
{ symbol: 'AAPL', targetPercent: 40 },
|
||||
{ symbol: 'MSFT', targetPercent: 35 },
|
||||
{ symbol: 'GOOGL', targetPercent: 25 },
|
||||
])
|
||||
const jobResult = ref<JobResult | null>(null)
|
||||
|
||||
const totalValue = ref(42700)
|
||||
|
||||
const addTarget = () => {
|
||||
targetWeights.value.push({ symbol: '', targetPercent: 0 })
|
||||
}
|
||||
|
||||
const removeTarget = (idx: number) => {
|
||||
targetWeights.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const triggerRebalance = async () => {
|
||||
// Mock API call
|
||||
jobResult.value = {
|
||||
jobId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
status: 'Queued',
|
||||
estimatedTradeCount: 3,
|
||||
estimatedCost: 127.35,
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
import { ref } from 'vue';
|
||||
// Mock data
|
||||
const currentPositions = ref([
|
||||
{ symbol: 'AAPL', quantity: 100, marketPrice: 150.25, marketValue: 15025, weightPercent: 35.3 },
|
||||
{ symbol: 'MSFT', quantity: 80, marketPrice: 320.50, marketValue: 25640, weightPercent: 60.2 },
|
||||
{ symbol: 'GOOGL', quantity: 50, marketPrice: 140.75, marketValue: 7037.5, weightPercent: 16.5 },
|
||||
]);
|
||||
const driftThreshold = ref(5);
|
||||
const targetWeights = ref([
|
||||
{ symbol: 'AAPL', targetPercent: 40 },
|
||||
{ symbol: 'MSFT', targetPercent: 35 },
|
||||
{ symbol: 'GOOGL', targetPercent: 25 },
|
||||
]);
|
||||
const jobResult = ref(null);
|
||||
const totalValue = ref(42700);
|
||||
const addTarget = () => {
|
||||
targetWeights.value.push({ symbol: '', targetPercent: 0 });
|
||||
};
|
||||
const removeTarget = (idx) => {
|
||||
targetWeights.value.splice(idx, 1);
|
||||
};
|
||||
const triggerRebalance = async () => {
|
||||
// Mock API call
|
||||
jobResult.value = {
|
||||
jobId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
status: 'Queued',
|
||||
estimatedTradeCount: 3,
|
||||
estimatedCost: 127.35,
|
||||
};
|
||||
};
|
||||
const __VLS_ctx = {
|
||||
...{},
|
||||
...{},
|
||||
};
|
||||
let __VLS_components;
|
||||
let __VLS_intrinsics;
|
||||
let __VLS_directives;
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-table']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['drift-threshold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['drift-threshold']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "rebalance-form" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['rebalance-form']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h1, __VLS_intrinsics.h1)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "subtitle" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['subtitle']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "content" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['content']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.table, __VLS_intrinsics.table)({
|
||||
...{ class: "positions-table" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['positions-table']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.thead, __VLS_intrinsics.thead)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tbody, __VLS_intrinsics.tbody)({});
|
||||
for (const [pos] of __VLS_vFor((__VLS_ctx.currentPositions))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({
|
||||
key: (pos.symbol),
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.symbol);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.quantity.toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.marketPrice.toFixed(2));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.marketValue.toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.weightPercent.toFixed(1));
|
||||
// @ts-ignore
|
||||
[currentPositions,];
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "total" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['total']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
(__VLS_ctx.totalValue.toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "form-group" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['form-group']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "drift-threshold" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['drift-threshold']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.label, __VLS_intrinsics.label)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.input)({
|
||||
type: "number",
|
||||
min: "0",
|
||||
max: "50",
|
||||
step: "1",
|
||||
});
|
||||
(__VLS_ctx.driftThreshold);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "targets" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['targets']} */ ;
|
||||
for (const [target, idx] of __VLS_vFor((__VLS_ctx.targetWeights))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
key: (idx),
|
||||
...{ class: "target-row" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['target-row']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.input)({
|
||||
placeholder: "Symbol",
|
||||
...{ class: "symbol-input" },
|
||||
});
|
||||
(target.symbol);
|
||||
/** @type {__VLS_StyleScopedClasses['symbol-input']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.input)({
|
||||
type: "number",
|
||||
min: "0",
|
||||
max: "100",
|
||||
step: "1",
|
||||
placeholder: "%",
|
||||
...{ class: "percent-input" },
|
||||
});
|
||||
(target.targetPercent);
|
||||
/** @type {__VLS_StyleScopedClasses['percent-input']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
return (__VLS_ctx.removeTarget(idx));
|
||||
// @ts-ignore
|
||||
[totalValue, driftThreshold, targetWeights, removeTarget,];
|
||||
} },
|
||||
...{ class: "btn-remove" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-remove']} */ ;
|
||||
// @ts-ignore
|
||||
[];
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "actions" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['actions']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (__VLS_ctx.addTarget) },
|
||||
...{ class: "btn-secondary" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-secondary']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (__VLS_ctx.triggerRebalance) },
|
||||
...{ class: "btn-primary" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-primary']} */ ;
|
||||
if (__VLS_ctx.jobResult) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card result" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['result']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "mono" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['mono']} */ ;
|
||||
(__VLS_ctx.jobResult.jobId);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "status-badge" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status-badge']} */ ;
|
||||
(__VLS_ctx.jobResult.status);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
(__VLS_ctx.jobResult.estimatedTradeCount);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
(__VLS_ctx.jobResult.estimatedCost.toFixed(2));
|
||||
}
|
||||
// @ts-ignore
|
||||
[addTarget, triggerRebalance, jobResult, jobResult, jobResult, jobResult, jobResult,];
|
||||
const __VLS_export = (await import('vue')).defineComponent({});
|
||||
export default {};
|
||||
@@ -0,0 +1,646 @@
|
||||
<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>
|
||||
<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-value">{{ dashboard.healthScore }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
<button @click="fetchDashboard" class="btn-retry">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
interface StressResult {
|
||||
scenario: string
|
||||
loss: number
|
||||
stressedVar: number
|
||||
}
|
||||
|
||||
interface Alert {
|
||||
id: string
|
||||
threshold: string
|
||||
current: number
|
||||
severity: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
portfolio: {
|
||||
totalValue: number
|
||||
positions: Array<{
|
||||
symbol: string
|
||||
quantity: number
|
||||
marketPrice: number
|
||||
marketValue: number
|
||||
weightPercent: number
|
||||
}>
|
||||
}
|
||||
riskMetrics: {
|
||||
var95: number
|
||||
sharpeRatio: number
|
||||
sortinoRatio: number
|
||||
volatilityPercent: number
|
||||
topFivePercent: number
|
||||
maxPositionPercent: number
|
||||
}
|
||||
stressResults: Array<{
|
||||
scenario: string
|
||||
portfolioLossPercent: number
|
||||
stressedVar: number
|
||||
}>
|
||||
activeAlerts: Array<{
|
||||
alertId: string
|
||||
threshold: string
|
||||
currentValue: number
|
||||
severity: string
|
||||
message: string
|
||||
}>
|
||||
healthScore: number
|
||||
riskInsights: string[]
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const stressResult = ref<StressResult | null>(null)
|
||||
const dashboard = ref<DashboardData | null>(null)
|
||||
const portfolioId = ref('550e8400-e29b-41d4-a716-446655440001')
|
||||
|
||||
const activeAlerts = ref<Alert[]>([
|
||||
{
|
||||
id: '1',
|
||||
threshold: 'Concentration (Top-5)',
|
||||
current: 52.3,
|
||||
severity: 'Warning',
|
||||
message: 'Top 5 holdings at 52.3% (threshold: 60%)',
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDashboard()
|
||||
})
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/risk?portfolioId=${portfolioId.value}`)
|
||||
if (response.ok) {
|
||||
dashboard.value = await response.json()
|
||||
activeAlerts.value = dashboard.value?.activeAlerts?.map(a => ({
|
||||
id: a.alertId,
|
||||
threshold: a.threshold,
|
||||
current: a.currentValue,
|
||||
severity: a.severity,
|
||||
message: a.message,
|
||||
})) || []
|
||||
} else {
|
||||
error.value = 'Failed to fetch dashboard'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||
} 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),
|
||||
loss: result.portfolioLossPercent,
|
||||
stressedVar: result.stressedVar,
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
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 0 1rem 0;
|
||||
}
|
||||
|
||||
.health-score {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const stressResult = ref(null);
|
||||
const dashboard = ref(null);
|
||||
const portfolioId = ref('550e8400-e29b-41d4-a716-446655440001');
|
||||
const activeAlerts = ref([
|
||||
{
|
||||
id: '1',
|
||||
threshold: 'Concentration (Top-5)',
|
||||
current: 52.3,
|
||||
severity: 'Warning',
|
||||
message: 'Top 5 holdings at 52.3% (threshold: 60%)',
|
||||
},
|
||||
]);
|
||||
onMounted(async () => {
|
||||
await fetchDashboard();
|
||||
});
|
||||
const fetchDashboard = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/risk?portfolioId=${portfolioId.value}`);
|
||||
if (response.ok) {
|
||||
dashboard.value = await response.json();
|
||||
activeAlerts.value = dashboard.value?.activeAlerts?.map(a => ({
|
||||
id: a.alertId,
|
||||
threshold: a.threshold,
|
||||
current: a.currentValue,
|
||||
severity: a.severity,
|
||||
message: a.message,
|
||||
})) || [];
|
||||
}
|
||||
else {
|
||||
error.value = 'Failed to fetch dashboard';
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const runStressTest = async (scenario) => {
|
||||
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),
|
||||
loss: result.portfolioLossPercent,
|
||||
stressedVar: result.stressedVar,
|
||||
};
|
||||
}
|
||||
};
|
||||
const __VLS_ctx = {
|
||||
...{},
|
||||
...{},
|
||||
};
|
||||
let __VLS_components;
|
||||
let __VLS_intrinsics;
|
||||
let __VLS_directives;
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['result-row']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert-header']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['severity-initial']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['severity-warning']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['severity-critical']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['badge']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert-details']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alert-details']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-mini']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-mini']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['positions-mini']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['insights-list']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['insights-list']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['insights-list']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['status']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flag']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['flag']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stress-result']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stress-result']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "risk-dashboard" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['risk-dashboard']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h1, __VLS_intrinsics.h1)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
|
||||
...{ class: "subtitle" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['subtitle']} */ ;
|
||||
if (__VLS_ctx.dashboard) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "health-score" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['health-score']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "score-label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['score-label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "score-bar" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['score-bar']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "score-fill" },
|
||||
...{ style: ({ width: __VLS_ctx.dashboard.healthScore + '%' }) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['score-fill']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "score-value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['score-value']} */ ;
|
||||
(__VLS_ctx.dashboard.healthScore);
|
||||
}
|
||||
if (__VLS_ctx.error) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "error-banner" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['error-banner']} */ ;
|
||||
(__VLS_ctx.error);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
|
||||
...{ onClick: (__VLS_ctx.fetchDashboard) },
|
||||
...{ class: "btn-retry" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['btn-retry']} */ ;
|
||||
}
|
||||
if (__VLS_ctx.loading) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "loading" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['loading']} */ ;
|
||||
}
|
||||
else if (__VLS_ctx.dashboard) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "content" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['content']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card portfolio" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['portfolio']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "portfolio-summary" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['portfolio-summary']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.portfolio.totalValue.toLocaleString('en-US', { maximumFractionDigits: 0 }));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "summary-item" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['summary-item']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.portfolio.positions.length);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.table, __VLS_intrinsics.table)({
|
||||
...{ class: "positions-mini" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['positions-mini']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.thead, __VLS_intrinsics.thead)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.th, __VLS_intrinsics.th)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tbody, __VLS_intrinsics.tbody)({});
|
||||
for (const [pos] of __VLS_vFor((__VLS_ctx.dashboard.portfolio.positions.slice(0, 5)))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.tr, __VLS_intrinsics.tr)({
|
||||
key: (pos.symbol),
|
||||
});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
|
||||
(pos.symbol);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.quantity.toLocaleString());
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.marketPrice.toFixed(2));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.marketValue.toLocaleString('en-US', { maximumFractionDigits: 0 }));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.td, __VLS_intrinsics.td)({});
|
||||
(pos.weightPercent.toFixed(1));
|
||||
// @ts-ignore
|
||||
[dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, error, error, fetchDashboard, loading,];
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card metrics" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['metrics']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metrics-grid" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metrics-grid']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.var95.toLocaleString('en-US', { maximumFractionDigits: 0 }));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "percent" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['percent']} */ ;
|
||||
((__VLS_ctx.dashboard.riskMetrics.var95 / __VLS_ctx.dashboard.portfolio.totalValue * 100).toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.sharpeRatio.toFixed(2));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "note" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['note']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.sortinoRatio.toFixed(2));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "note" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['note']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.volatilityPercent.toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "note" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['note']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.topFivePercent.toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: (['flag', __VLS_ctx.dashboard.riskMetrics.topFivePercent > 60 ? 'danger' : 'warning']) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['flag']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.topFivePercent > 70 ? '🔴 High' : __VLS_ctx.dashboard.riskMetrics.topFivePercent > 50 ? '⚠️ Medium' : '✅ Low');
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "metric" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['metric']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "label" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['label']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.dashboard.riskMetrics.maxPositionPercent.toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "note" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['note']} */ ;
|
||||
(__VLS_ctx.dashboard.portfolio.positions[0]?.symbol || 'N/A');
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card stress" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['stress']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "scenarios" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['scenarios']} */ ;
|
||||
for (const [stress] of __VLS_vFor((__VLS_ctx.dashboard.stressResults))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ onClick: (...[$event]) => {
|
||||
if (!!(__VLS_ctx.loading))
|
||||
throw 0;
|
||||
if (!(__VLS_ctx.dashboard))
|
||||
throw 0;
|
||||
return (__VLS_ctx.runStressTest(stress.scenario));
|
||||
// @ts-ignore
|
||||
[dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, dashboard, runStressTest,];
|
||||
} },
|
||||
key: (stress.scenario),
|
||||
...{ class: "scenario" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['scenario']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "name" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['name']} */ ;
|
||||
(stress.scenario.charAt(0).toUpperCase() + stress.scenario.slice(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "impact" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['impact']} */ ;
|
||||
(stress.portfolioLossPercent > 0 ? '+' : '');
|
||||
(stress.portfolioLossPercent.toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: (['status', Math.abs(stress.portfolioLossPercent) > 15 ? 'severe' : 'moderate']) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['status']} */ ;
|
||||
(Math.abs(stress.portfolioLossPercent) > 15 ? 'Severe' : 'Moderate');
|
||||
// @ts-ignore
|
||||
[];
|
||||
}
|
||||
if (__VLS_ctx.stressResult) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "stress-result" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['stress-result']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h3, __VLS_intrinsics.h3)({});
|
||||
(__VLS_ctx.stressResult.scenario);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-row" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-row']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: (['value', __VLS_ctx.stressResult.loss < 0 ? 'loss' : 'gain']) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.stressResult.loss > 0 ? '+' : '');
|
||||
(__VLS_ctx.stressResult.loss.toFixed(2));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "result-row" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['result-row']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "value" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['value']} */ ;
|
||||
(__VLS_ctx.stressResult.stressedVar.toLocaleString('en-US', { maximumFractionDigits: 0 }));
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card alerts" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['alerts']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
if (__VLS_ctx.activeAlerts.length > 0) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "alerts-list" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['alerts-list']} */ ;
|
||||
for (const [alert] of __VLS_vFor((__VLS_ctx.activeAlerts))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
key: (alert.id),
|
||||
...{ class: (['alert', `severity-${alert.severity.toLowerCase()}`]) },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['alert']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "alert-header" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['alert-header']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "threshold" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['threshold']} */ ;
|
||||
(alert.threshold);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "badge" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['badge']} */ ;
|
||||
(alert.severity);
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "alert-details" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['alert-details']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "current" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['current']} */ ;
|
||||
(alert.current.toFixed(1));
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({
|
||||
...{ class: "message" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['message']} */ ;
|
||||
(alert.message);
|
||||
// @ts-ignore
|
||||
[stressResult, stressResult, stressResult, stressResult, stressResult, stressResult, activeAlerts, activeAlerts,];
|
||||
}
|
||||
}
|
||||
else {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "no-alerts" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['no-alerts']} */ ;
|
||||
}
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
|
||||
...{ class: "card insights" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['card']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['insights']} */ ;
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({});
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.ul, __VLS_intrinsics.ul)({
|
||||
...{ class: "insights-list" },
|
||||
});
|
||||
/** @type {__VLS_StyleScopedClasses['insights-list']} */ ;
|
||||
for (const [insight, idx] of __VLS_vFor((__VLS_ctx.dashboard.riskInsights))) {
|
||||
__VLS_asFunctionalElement1(__VLS_intrinsics.li, __VLS_intrinsics.li)({
|
||||
key: (idx),
|
||||
});
|
||||
(insight);
|
||||
// @ts-ignore
|
||||
[dashboard,];
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
[];
|
||||
const __VLS_export = (await import('vue')).defineComponent({});
|
||||
export default {};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user