feat: align UI routes and menu with implemented screens
ci / backend (push) Failing after 0s
ci / static (push) Failing after 11s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 12s
Build & Test with Secrets / build (pull_request) Failing after 2s
ci / publish (push) Has been cancelled
ci / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (pull_request) Has been cancelled
Build & Test with Secrets / notification (pull_request) Has been cancelled
Build & Test with Secrets / frontend (pull_request) Has been cancelled
ci / publish (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / backend (push) Failing after 0s
ci / static (push) Failing after 11s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 12s
Build & Test with Secrets / build (pull_request) Failing after 2s
ci / publish (push) Has been cancelled
ci / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (pull_request) Has been cancelled
Build & Test with Secrets / notification (pull_request) Has been cancelled
Build & Test with Secrets / frontend (pull_request) Has been cancelled
ci / publish (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user