Files
KArtSell.Aegis/frontend/src/features/marketData/pages/MarketDataIngestion.vue
T

187 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageLayout title="시장 데이터 수집" subtitle="KRX 과거 시세 데이터 수집을 예약합니다.">
<KsFormSection title="1. 데이터 소스 및 기간 선택">
<KsFormGrid :columns="1" aria-label="데이터 소스 기간">
<KsSelect v-model="form.dataSource" label="데이터 소스" :options="dataSourceOptions" />
<p class="hint"><strong>KRX:</strong> 시세 데이터(시가/고가/저가/종가/거래량) · <strong>OpenDart:</strong> 기업 공시</p>
</KsFormGrid>
<KsFormGrid :columns="2" aria-label="수집 기간">
<KsDateField v-model="form.fromDate" label="시작일" :min="minDateValue" :max="maxDateValue" />
<KsDateField v-model="form.toDate" label="종료일" :min="form.fromDate ? new Date(form.fromDate) : minDateValue" :max="maxDateValue" />
</KsFormGrid>
<div class="presets">
<KsButton severity="secondary" label="최근 1년" @click="setPreset('1y')" />
<KsButton severity="secondary" label="최근 2년" @click="setPreset('2y')" />
<KsButton severity="secondary" label="최근 5년" @click="setPreset('5y')" />
<KsButton severity="secondary" label="전체 가능 기간" @click="setPreset('all')" />
</div>
</KsFormSection>
<KsFormSection v-if="validationErrors.length" title="입력 오류">
<KsValidationSummary :errors="validationErrorObjects" />
</KsFormSection>
<KsFormSection v-else title="수집 요약">
<dl class="summary-grid">
<div class="summary-item"><dt>데이터 소스</dt><dd>{{ form.dataSource }}</dd></div>
<div class="summary-item"><dt>기간</dt><dd>{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
<div class="summary-item"><dt>일수</dt><dd>{{ formatQuantity(daysCount, 0) }}</dd></div>
<div class="summary-item"><dt>예상 </dt><dd>{{ estimatedRows }}</dd></div>
</dl>
<template #actions>
<KsButton severity="secondary" label="초기화" @click="resetForm" />
<KsButton severity="primary" :label="isLoading ? '처리 중...' : '수집 예약'" :loading="isLoading" :disabled="validationErrors.length > 0" @click="triggerIngestion" />
</template>
</KsFormSection>
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
<dl class="summary-grid">
<div class="summary-item"><dt>Job ID</dt><dd class="mono">{{ jobId }}</dd></div>
<div class="summary-item"><dt>상태</dt><dd><KsStatusTag value="대기" severity="info" /></dd></div>
<div class="summary-item"><dt>등록 시각</dt><dd>{{ formatAsOf(new Date()) }}</dd></div>
</dl>
<p class="hint">수집은 백그라운드에서 실행됩니다. 진행 상태는 수집 이력 화면에서 확인할 있습니다.</p>
<RouterLink to="/ops/market-data-history">수집 이력 보기 </RouterLink>
</KsFormSection>
</PageLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
import { KsButton, KsDateField, KsFormGrid, KsFormSection, KsSelect, KsStatusTag, KsValidationSummary } from '../../../shared/ui/components'
import { formatAsOf, formatQuantity } from '../../../shared/formatters/financial'
const isLoading = ref(false)
const jobId = ref<string | null>(null)
const dataSourceOptions = [
{ label: 'KRX (한국거래소) - KOSPI/KOSDAQ 일봉', value: 'KRX' },
{ label: 'OpenDart - 기업 공시(T+2)', value: 'OpenDart' },
{ label: 'Stub - 테스트 데이터', value: 'Stub' }
]
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 minDateValue = new Date(minDate)
const maxDateValue = new Date(maxDate)
const validationErrors = computed(() => {
const errors: string[] = []
if (!form.value.fromDate) errors.push('시작일은 필수입니다.')
if (!form.value.toDate) errors.push('종료일은 필수입니다.')
if (form.value.fromDate && form.value.toDate) {
if (form.value.fromDate > form.value.toDate) {
errors.push('시작일은 종료일보다 앞서야 합니다.')
}
if (form.value.toDate > maxDate) {
errors.push('종료일은 오늘 이후일 수 없습니다.')
}
}
return errors
})
const validationErrorObjects = computed(() => validationErrors.value.map(message => ({ message })))
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 formatQuantity(daysCount.value * 2000, 0)
} else if (form.value.dataSource === 'OpenDart') {
return formatQuantity(Math.ceil(daysCount.value / 90) * 200, 0)
}
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(`수집 요청에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
} 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>
.hint { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); margin: 0; }
.presets { display: flex; gap: var(--ks-space-2); flex-wrap: wrap; margin-top: var(--ks-space-3); }
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ks-space-3); margin: 0; }
.summary-item { display: flex; justify-content: space-between; padding: var(--ks-space-2) var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); }
.summary-item dt { font-weight: 600; color: var(--ks-color-text-muted); }
.summary-item dd { margin: 0; font-weight: 600; }
.mono { font-family: monospace; }
</style>