新增合约全历史拉取与 K 线打分叠加功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -53,10 +53,34 @@ export interface RunRangeResponse {
|
||||
results: RunRangeResult[]
|
||||
}
|
||||
|
||||
export interface RunFullRequest {
|
||||
ts_code: string
|
||||
}
|
||||
|
||||
export interface RunFullResult {
|
||||
trade_date: string
|
||||
close: number
|
||||
composite: number
|
||||
signal: string
|
||||
}
|
||||
|
||||
export interface RunFullResponse {
|
||||
ts_code: string
|
||||
total_days: number
|
||||
scored_count: number
|
||||
skipped_count: number
|
||||
warnings: string[]
|
||||
results: RunFullResult[]
|
||||
}
|
||||
|
||||
export function runRange(req: RunRangeRequest) {
|
||||
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function runFull(req: RunFullRequest) {
|
||||
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function runBatch() {
|
||||
return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useMobile } from '@/composables/useMobile'
|
||||
|
||||
const props = defineProps<{ data: Candle[] }>()
|
||||
const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
|
||||
const theme = useThemeStore()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -28,51 +28,86 @@ function render() {
|
||||
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
|
||||
const oi = props.data.map((c) => c.oi)
|
||||
|
||||
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
|
||||
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
|
||||
|
||||
const hasScores = props.scores && props.scores.length > 0
|
||||
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
|
||||
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
|
||||
const grids: any[] = [
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
|
||||
]
|
||||
const xAxes: any[] = [
|
||||
{ type: 'category', data: dates, scale: true, boundaryGap: false },
|
||||
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
|
||||
]
|
||||
const yAxes: any[] = [
|
||||
{ scale: true, splitArea: { show: true } },
|
||||
{ gridIndex: 1, scale: true, splitNumber: 3 },
|
||||
]
|
||||
const series: any[] = [
|
||||
{
|
||||
name: 'K 线',
|
||||
type: 'candlestick',
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: '#ec3a3a',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ec3a3a',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '持仓量',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: oi,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#5470c6' },
|
||||
areaStyle: { opacity: 0.15, color: '#5470c6' },
|
||||
},
|
||||
]
|
||||
|
||||
if (hasScores) {
|
||||
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
|
||||
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
|
||||
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2 })
|
||||
series.push({
|
||||
name: '综合分',
|
||||
type: 'bar',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: compositeData,
|
||||
barWidth: '60%',
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const val = params.value as number | null
|
||||
if (val == null) return 'transparent'
|
||||
if (val >= 80) return '#ec3a3a'
|
||||
if (val >= 50) return '#f89898'
|
||||
if (val >= 40) return '#89d6c7'
|
||||
return '#26a69a'
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['K 线', '持仓量'], top: 0 },
|
||||
grid: [
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' },
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' },
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: dates, scale: true, boundaryGap: false },
|
||||
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
|
||||
],
|
||||
yAxis: [
|
||||
{ scale: true, splitArea: { show: true } },
|
||||
{ gridIndex: 1, scale: true, splitNumber: 3 },
|
||||
],
|
||||
legend: { data: legendData, top: 0 },
|
||||
grid: grids,
|
||||
xAxis: xAxes,
|
||||
yAxis: yAxes,
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1] },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K 线',
|
||||
type: 'candlestick',
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: '#ec3a3a',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ec3a3a',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '持仓量',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: oi,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#5470c6' },
|
||||
areaStyle: { opacity: 0.15, color: '#5470c6' },
|
||||
},
|
||||
{ type: 'inside', xAxisIndex: xAxisIndices },
|
||||
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
|
||||
],
|
||||
series,
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listContracts } from '@/api/scores'
|
||||
import { listCandles, type Candle } from '@/api/candles'
|
||||
import { listScores, type Score } from '@/api/scores'
|
||||
import { runFull, type RunFullResponse } from '@/api/run'
|
||||
import KLineChart from '@/components/KLineChart.vue'
|
||||
import { useMobile } from '@/composables/useMobile'
|
||||
|
||||
@@ -15,7 +17,10 @@ const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
|
||||
|
||||
const contracts = ref<string[]>([])
|
||||
const candles = ref<Candle[]>([])
|
||||
const scores = ref<Score[]>([])
|
||||
const loading = ref(false)
|
||||
const fullLoading = ref(false)
|
||||
const fullResult = ref<RunFullResponse | null>(null)
|
||||
|
||||
async function reload() {
|
||||
if (!filter.ts_code) {
|
||||
@@ -25,12 +30,48 @@ async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [start, end] = filter.range || []
|
||||
candles.value = await listCandles(filter.ts_code, start, end)
|
||||
const [candleData, scoreData] = await Promise.all([
|
||||
listCandles(filter.ts_code, start, end),
|
||||
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
|
||||
])
|
||||
candles.value = candleData
|
||||
scores.value = scoreData
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchAndScore() {
|
||||
if (!filter.ts_code) {
|
||||
ElMessage.warning('请输入或选择合约')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`即将拉取 ${filter.ts_code} 的全部历史数据并逐日打分,这可能需要一些时间。`,
|
||||
'拉取并打分',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
fullLoading.value = true
|
||||
fullResult.value = null
|
||||
try {
|
||||
const resp = await runFull({ ts_code: filter.ts_code })
|
||||
fullResult.value = resp
|
||||
ElMessage.success(`完成: ${resp.scored_count} 天已打分, ${resp.skipped_count} 天跳过`)
|
||||
await reload()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || err.message || '请求失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
fullLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
contracts.value = await listContracts().catch(() => [])
|
||||
if (contracts.value.length > 0) {
|
||||
@@ -45,14 +86,22 @@ onMounted(async () => {
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="!isMobile">
|
||||
<el-form-item label="合约">
|
||||
<el-select
|
||||
v-model="filter.ts_code"
|
||||
placeholder="选择合约"
|
||||
filterable
|
||||
:style="{ width: isMobile ? '100%' : '200px' }"
|
||||
>
|
||||
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input
|
||||
v-model="filter.ts_code"
|
||||
placeholder="输入合约代码如 FG2509.ZCE"
|
||||
clearable
|
||||
:style="{ width: isMobile ? '100%' : '200px' }"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filter.ts_code"
|
||||
placeholder="已存合约"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker
|
||||
@@ -67,12 +116,27 @@ onMounted(async () => {
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
|
||||
<el-button type="warning" :loading="fullLoading" @click="handleFetchAndScore">
|
||||
拉取并打分
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="fullResult" shadow="never" class="result-card">
|
||||
<el-descriptions :column="isMobile ? 2 : 4" border>
|
||||
<el-descriptions-item label="合约">{{ fullResult.ts_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总天数">{{ fullResult.total_days }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已打分">{{ fullResult.scored_count }}</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过">{{ fullResult.skipped_count }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<KLineChart :data="candles" />
|
||||
<KLineChart
|
||||
:data="candles"
|
||||
:scores="scores.map((s) => ({ trade_date: s.trade_date, composite: s.composite }))"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,6 +150,9 @@ onMounted(async () => {
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.result-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.chart-card :deep(.el-card__body) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user