| <template> |
| <div> |
| |
| <el-card shadow="never" style="border: 1px solid var(--border-color); margin-bottom: 16px;"> |
| <div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;"> |
| <div style="display: flex; align-items: center; gap: 8px;"> |
| <span style="color: var(--text-secondary); font-size: 13px; white-space: nowrap;">分类:</span> |
| <el-select v-model="selectedCategory" size="small" style="width: 120px;" @change="onCategoryChange"> |
| <el-option label="全部" value="" /> |
| <el-option v-for="cat in store.categoryList.slice(1)" :key="cat.key" :label="cat.label" :value="cat.key" /> |
| </el-select> |
| </div> |
| <div style="display: flex; align-items: center; gap: 8px;"> |
| <span style="color: var(--text-secondary); font-size: 13px; white-space: nowrap;">合约:</span> |
| <el-select v-model="selectedSymbol" size="small" style="width: 180px;" filterable @change="runDetection"> |
| <el-option v-for="c in filteredContracts" :key="c.symbol" :label="`${c.name} (${c.exchange})`" :value="c.symbol" /> |
| </el-select> |
| </div> |
| <div style="display: flex; align-items: center; gap: 8px;"> |
| <span style="color: var(--text-secondary); font-size: 13px; white-space: nowrap;">周期:</span> |
| <el-radio-group v-model="selectedInterval" size="small" @change="runDetection"> |
| <el-radio-button v-for="tf in timeframes" :key="tf.value" :value="tf.value">{{ tf.label }}</el-radio-button> |
| </el-radio-group> |
| </div> |
| <el-button type="primary" size="small" @click="runDetection" :loading="detecting"> |
| <el-icon><Aim /></el-icon> 扫描形态 |
| </el-button> |
| </div> |
| </el-card> |
| |
| <el-row :gutter="16"> |
| |
| <el-col :span="16"> |
| <el-card shadow="never" style="border: 1px solid var(--border-color); margin-bottom: 16px;"> |
| <template #header> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-weight: 600;">{{ currentName }} · {{ selectedInterval }} K线 · 形态识别</span> |
| <div v-if="patterns.length > 0" style="display: flex; gap: 6px;"> |
| <el-tag v-for="p in patterns.slice(0, 3)" :key="p.pattern_type" |
| :type="p.direction === 'bullish' ? 'success' : p.direction === 'bearish' ? 'danger' : 'warning'" |
| size="small" effect="dark"> |
| {{ p.name }} {{ p.confidence }}% |
| </el-tag> |
| </div> |
| </div> |
| </template> |
| <v-chart :option="chartOption" autoresize style="height: 480px;" ref="chartRef" /> |
| </el-card> |
| </el-col> |
| |
| |
| <el-col :span="8"> |
| <el-card shadow="never" style="border: 1px solid var(--border-color); margin-bottom: 16px;"> |
| <template #header> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-weight: 600;">识别结果 ({{ patterns.length }})</span> |
| </div> |
| </template> |
| <div v-if="patterns.length === 0" style="text-align: center; padding: 30px; color: var(--text-secondary);"> |
| {{ detecting ? '扫描中...' : '选择合约并点击"扫描形态"' }} |
| </div> |
| <div v-else style="max-height: 280px; overflow-y: auto;"> |
| <div v-for="(p, idx) in patterns" :key="idx" |
| style="padding: 12px; margin-bottom: 8px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-secondary);"> |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> |
| <span style="font-weight: 600; font-size: 14px;">{{ p.name }}</span> |
| <el-tag :type="p.direction === 'bullish' ? 'success' : p.direction === 'bearish' ? 'danger' : 'warning'" size="small" effect="dark"> |
| {{ p.direction === 'bullish' ? '看涨' : p.direction === 'bearish' ? '看跌' : '中性' }} |
| </el-tag> |
| </div> |
| <div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 6px;">{{ p.description }}</div> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <span style="font-size: 11px; color: var(--text-secondary);">置信度</span> |
| <el-progress :percentage="p.confidence" :color="p.direction === 'bullish' ? '#3fb950' : p.direction === 'bearish' ? '#f85149' : '#d29922'" :stroke-width="6" style="width: 120px;" :show-text="true" /> |
| </div> |
| </div> |
| </div> |
| </el-card> |
| |
| |
| <el-card shadow="never" style="border: 1px solid var(--border-color);"> |
| <template #header><span style="font-weight: 600;">扫描形态设置</span></template> |
| <div style="max-height: 250px; overflow-y: auto;"> |
| <el-checkbox-group v-model="enabledPatterns"> |
| <div v-for="p in patternRegistry" :key="p.type" style="margin-bottom: 8px;"> |
| <el-checkbox :value="p.type" :label="p.type"> |
| <span style="font-size: 13px;">{{ p.name }}</span> |
| </el-checkbox> |
| <div style="font-size: 11px; color: var(--text-secondary); margin-left: 24px;">{{ p.description }}</div> |
| </div> |
| </el-checkbox-group> |
| </div> |
| </el-card> |
| </el-col> |
| </el-row> |
| </div> |
| </template> |
| |
| <script setup> |
| import { ref, computed, onMounted, watch } from 'vue' |
| import VChart from 'vue-echarts' |
| import { use } from 'echarts/core' |
| import { CandlestickChart, BarChart, LineChart, CustomChart } from 'echarts/charts' |
| import { GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, MarkAreaComponent, GraphicComponent } from 'echarts/components' |
| import { CanvasRenderer } from 'echarts/renderers' |
| import { useTradingStore } from '../stores/trading' |
| import { marketApi, patternApi } from '../api' |
| |
| use([CandlestickChart, BarChart, LineChart, CustomChart, GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, MarkAreaComponent, GraphicComponent, CanvasRenderer]) |
| |
| const store = useTradingStore() |
| const selectedSymbol = ref('') |
| const selectedCategory = ref('') |
| const selectedInterval = ref('15m') |
| const klines = ref([]) |
| const patterns = ref([]) |
| const patternRegistry = ref([]) |
| const enabledPatterns = ref([]) |
| const detecting = ref(false) |
| const chartRef = ref(null) |
| |
| const timeframes = [ |
| { label: '1分', value: '1m' }, { label: '5分', value: '5m' }, |
| { label: '15分', value: '15m' }, { label: '30分', value: '30m' }, |
| { label: '1时', value: '1h' }, { label: '4时', value: '4h' }, |
| { label: '日线', value: '1d' }, { label: '周线', value: '1w' }, |
| ] |
| |
| const filteredContracts = computed(() => { |
| if (!selectedCategory.value) return store.symbolList |
| return store.symbolList.filter(c => c.category === selectedCategory.value) |
| }) |
| |
| const currentName = computed(() => { |
| const c = store.symbolList.find(c => c.symbol === selectedSymbol.value) |
| return c ? c.name : selectedSymbol.value |
| }) |
| |
| function onCategoryChange() { |
| const list = filteredContracts.value |
| if (list.length > 0 && !list.find(c => c.symbol === selectedSymbol.value)) { |
| selectedSymbol.value = list[0].symbol |
| runDetection() |
| } |
| } |
| |
| async function loadKlines() { |
| if (!selectedSymbol.value) return |
| try { |
| const { data } = await marketApi.getKlines(selectedSymbol.value, 200, selectedInterval.value) |
| klines.value = data |
| } catch (e) { console.error(e) } |
| } |
| |
| async function runDetection() { |
| if (!selectedSymbol.value) return |
| detecting.value = true |
| try { |
| await loadKlines() |
| const patternTypes = enabledPatterns.value.length > 0 ? enabledPatterns.value.join(',') : null |
| const { data } = await patternApi.detect(selectedSymbol.value, selectedInterval.value, 200, patternTypes) |
| patterns.value = data |
| } catch (e) { console.error(e) } |
| detecting.value = false |
| } |
| |
| async function loadRegistry() { |
| try { |
| const { data } = await patternApi.getRegistry() |
| patternRegistry.value = data |
| enabledPatterns.value = data.map(p => p.type) |
| } catch (e) { console.error(e) } |
| } |
| |
| function buildPatternLines() { |
| const graphics = [] |
| for (const p of patterns.value) { |
| for (const line of (p.draw_lines || [])) { |
| graphics.push({ |
| type: 'line', |
| x1: line.x1, y1: line.y1, |
| x2: line.x2, y2: line.y2, |
| color: line.color, |
| style: line.style, |
| label: line.label, |
| }) |
| } |
| } |
| return graphics |
| } |
| |
| const chartOption = computed(() => { |
| const dates = klines.value.map(k => { |
| const ts = k.timestamp || '' |
| if (selectedInterval.value === '1d' || selectedInterval.value === '1w') return ts.substring(5, 10) |
| if (selectedInterval.value === '4h' || selectedInterval.value === '1h') return ts.substring(5, 16) |
| return ts.substring(11, 16) |
| }) |
| const ohlc = klines.value.map(k => [k.open, k.close, k.low, k.high]) |
| const volumes = klines.value.map(k => k.volume) |
| |
| const markLines = [] |
| for (const p of patterns.value) { |
| for (const line of (p.draw_lines || [])) { |
| markLines.push({ |
| name: line.label, |
| lineStyle: { |
| color: line.color, |
| type: line.style === 'dashed' ? 'dashed' : line.style === 'dotted' ? 'dotted' : 'solid', |
| width: 2, |
| }, |
| label: { |
| show: true, |
| formatter: line.label, |
| color: line.color, |
| fontSize: 10, |
| position: 'end', |
| }, |
| coord: [line.x1, line.y1], |
| endCoord: [line.x2, line.y2], |
| }) |
| } |
| } |
| |
| return { |
| backgroundColor: 'transparent', |
| tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, |
| grid: [ |
| { left: '8%', right: '4%', top: '5%', height: '60%' }, |
| { left: '8%', right: '4%', top: '72%', height: '16%' }, |
| ], |
| xAxis: [ |
| { type: 'category', data: dates, gridIndex: 0, axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| { type: 'category', data: dates, gridIndex: 1, axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| ], |
| yAxis: [ |
| { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| { scale: true, gridIndex: 1, splitLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e' } }, |
| ], |
| dataZoom: [ |
| { type: 'inside', xAxisIndex: [0, 1], start: 30, end: 100 }, |
| { type: 'slider', xAxisIndex: [0, 1], start: 30, end: 100, height: 20, bottom: 0, borderColor: '#30363d', fillerColor: 'rgba(88,166,255,0.1)' }, |
| ], |
| series: [ |
| { |
| type: 'candlestick', data: ohlc, xAxisIndex: 0, yAxisIndex: 0, |
| itemStyle: { color: '#3fb950', color0: '#f85149', borderColor: '#3fb950', borderColor0: '#f85149' }, |
| markLine: markLines.length > 0 ? { |
| symbol: ['none', 'none'], |
| data: markLines.map(ml => ([ |
| { coord: [ml.coord[0], ml.coord[1]], lineStyle: ml.lineStyle, label: ml.label }, |
| { coord: [ml.endCoord[0], ml.endCoord[1]] }, |
| ])), |
| } : undefined, |
| }, |
| { type: 'bar', data: volumes, xAxisIndex: 1, yAxisIndex: 1, itemStyle: { color: 'rgba(88,166,255,0.4)' } }, |
| ], |
| } |
| }) |
| |
| onMounted(async () => { |
| await store.fetchCategories() |
| await store.fetchContracts() |
| await loadRegistry() |
| if (store.symbolList.length > 0 && !selectedSymbol.value) { |
| selectedSymbol.value = store.symbolList[0].symbol |
| runDetection() |
| } |
| }) |
| </script> |
| |