187 lines
7.6 KiB
Vue
187 lines
7.6 KiB
Vue
<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>
|