mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
feat(webui)!: 行情终端重大升级——市场看板/自选/个股与板块弹窗 + SSE 实时推送
展示层对标专业看盘终端(tick-stock-panel 模式),数据全部来自通达信协议直连: - 市场看板:五大指数实时条(迷你分时)、涨跌统计、四维情绪雷达、 全市场涨跌分布直方图(约 5500 只 22 桶、鼠标跟随浮窗)、涨停雷达、 行业/概念热冷榜(可下钻)、涨幅/跌幅/成交额/换手四联排行榜、异动雷达 - 自选行情:6 位代码即加(symbol-info 自动取名)、SSE 实时全表刷新、 行内迷你分时;SQLite 持久化(watchlist.db,幂等加删) - 个股弹窗:五档盘口 + 1/3/5 日分时 + 日K(MA/BOLL/EMA + MACD/KDJ/RSI 前端本地计算)+ 一键加自选 + 一键全策略寻优 - 板块弹窗:板块分时/日K + 成分股涨跌榜直达个股 - SSE 架构:QuoteStreamer 单循环轮询 fan-out(独立队列 + 背压丢旧、 无订阅休眠、盘中 8s/盘外 60s 降频);前端 pinia 单连接指数退避重连 - fix(codec): SH 000 系大盘指数与 881/885 板块指数报价按 2 位小数解析 (曾缩小 10 倍:科创50 1647.53→164.753);880 统计指数保持 3 位 - fix: 前端批量五档路径 /security/quotes→/quotes、SSE 五档白名单 bid1_vol→bid_vol1、MAC 排行 close 列归一化、日K v-if 撑满、 寻优查看跳转 /→/backtest、FastAPI 0.141 _IncludedRouter 测试适配 新增 27 个单测(streamer/自选/小数位语义),全套 1078 passed
This commit is contained in:
+103
-28
@@ -1,21 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:顶部标题栏 + 路由出口。
|
||||
// 根组件:侧边栏终端外壳 + 路由出口。
|
||||
// 布局借鉴专业看盘终端(侧边栏分组导航 + 底部实时连接状态徽标)。
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { useQuoteStore } from './stores/quotes'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
onMounted(() => quoteStore.connect())
|
||||
|
||||
const sseLabel: Record<string, string> = {
|
||||
connecting: '连接中',
|
||||
open: '实时',
|
||||
closed: '离线',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<h1>easy-tdx 回测</h1>
|
||||
<nav class="app-nav">
|
||||
<RouterLink to="/" active-class="active">单标的回测</RouterLink>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-name">easy-tdx</span>
|
||||
<span class="brand-sub">行情终端</span>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">行情</div>
|
||||
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
|
||||
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
|
||||
<div class="nav-group">分析</div>
|
||||
<RouterLink to="/backtest" active-class="active">单标的回测</RouterLink>
|
||||
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
|
||||
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
|
||||
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
|
||||
<RouterLink to="/strategies" active-class="active">策略库</RouterLink>
|
||||
<RouterLink to="/signals" active-class="active">信号雷达</RouterLink>
|
||||
<div class="nav-group">系统</div>
|
||||
<RouterLink to="/settings" active-class="active">服务器设置</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="side-footer">
|
||||
<span class="dot" :class="quoteStore.status"></span>
|
||||
<span class="sse-label">{{ sseLabel[quoteStore.status] ?? '离线' }}</span>
|
||||
<span v-if="quoteStore.lastTs" class="sse-ts">{{ quoteStore.lastTs.slice(11, 19) }}</span>
|
||||
<span v-if="quoteStore.quoteCount" class="sse-n">×{{ quoteStore.quoteCount }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -25,40 +52,88 @@
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
height: 48px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
.sidebar {
|
||||
width: 176px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.app-nav a {
|
||||
.brand {
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.side-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.nav-group {
|
||||
padding: 10px 16px 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.side-nav a {
|
||||
display: block;
|
||||
padding: 7px 16px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.app-nav a:hover {
|
||||
.side-nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.app-nav a.active {
|
||||
.side-nav a.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.side-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.dot.open {
|
||||
background: var(--up);
|
||||
box-shadow: 0 0 4px var(--up);
|
||||
}
|
||||
.dot.connecting {
|
||||
background: var(--warn);
|
||||
}
|
||||
.sse-ts {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.sse-n {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.app-main {
|
||||
flex: 1;
|
||||
|
||||
@@ -6,14 +6,20 @@ import type {
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
Bar,
|
||||
BoardRow,
|
||||
Category,
|
||||
DataFrameResponse,
|
||||
MarketStat,
|
||||
MinutePoint,
|
||||
MultiStrategyBacktestRequest,
|
||||
OptimizeAllBacktestRequest,
|
||||
OptimizeBacktestRequest,
|
||||
PortfolioBacktestRequest,
|
||||
RankRow,
|
||||
SavedStrategy,
|
||||
SavedStrategyCreate,
|
||||
SavedStrategyListResponse,
|
||||
SecurityQuote,
|
||||
ServerHostInfo,
|
||||
ServerHostListResponse,
|
||||
ServerSwitchResult,
|
||||
@@ -23,6 +29,7 @@ import type {
|
||||
TaskListResponse,
|
||||
TaskState,
|
||||
TaskSubmitResponse,
|
||||
WatchlistResponse,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
@@ -349,3 +356,224 @@ export async function switchServerHost(host: string): Promise<ServerSwitchResult
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as ServerSwitchResult
|
||||
}
|
||||
|
||||
// ── 行情终端 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 批量拉实时五档(REST 一次性;持续刷新走 SSE,见 stores/quotes.ts)。
|
||||
* 注意:后端路由是 POST /quotes(market router 挂在 /api/v1 前缀下)。 */
|
||||
export async function fetchQuotes(symbols: Array<{ market: string; code: string }>): Promise<SecurityQuote[]> {
|
||||
const resp = await fetch(`${BASE}/quotes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stocks: symbols }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map(normalizeQuote)
|
||||
}
|
||||
|
||||
/** 后端 quotes 行 → SecurityQuote(补 symbol 键;数值列容错 null)。 */
|
||||
function normalizeQuote(row: Record<string, unknown>): SecurityQuote {
|
||||
const market = String(row.market ?? '')
|
||||
const code = String(row.code ?? '')
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(v)
|
||||
return v == null || Number.isNaN(n) ? null : n
|
||||
}
|
||||
const q = { ...row } as Record<string, unknown>
|
||||
q.symbol = `${market}${code}`
|
||||
for (const k of Object.keys(q)) {
|
||||
if (k === 'symbol' || k === 'market' || k === 'code' || k === 'server_time') continue
|
||||
q[k] = num(q[k])
|
||||
}
|
||||
return q as unknown as SecurityQuote
|
||||
}
|
||||
|
||||
/** 全市场涨跌统计(涨/跌/平/涨停/跌停家数 + 总成交)。 */
|
||||
export async function fetchMarketStat(): Promise<MarketStat> {
|
||||
const resp = await fetch(`${BASE}/market/stat`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
const row = body.data[0] ?? {}
|
||||
const num = (v: unknown): number => Number(v ?? 0)
|
||||
return {
|
||||
up_count: num(row.up_count),
|
||||
down_count: num(row.down_count),
|
||||
neutral_count: num(row.neutral_count),
|
||||
suspended_count: num(row.suspended_count),
|
||||
total_count: num(row.total_count),
|
||||
total_amount: num(row.total_amount),
|
||||
total_volume: num(row.total_volume),
|
||||
total_market_cap: num(row.total_market_cap),
|
||||
limit_up_count: num(row.limit_up_count),
|
||||
limit_down_count: num(row.limit_down_count),
|
||||
}
|
||||
}
|
||||
|
||||
/** 今日分时(240 点:价格 + 每分钟量)。 */
|
||||
export async function fetchMinute(market: string, code: string): Promise<MinutePoint[]> {
|
||||
const params = new URLSearchParams({ market, code })
|
||||
const resp = await fetch(`${BASE}/minute?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? ''),
|
||||
price: Number(row.price ?? 0),
|
||||
vol: Number(row.vol ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 历史某日分时(date: YYYYMMDD 整数,如 20260829)。 */
|
||||
export async function fetchHistoryMinute(
|
||||
market: string,
|
||||
code: string,
|
||||
date: number,
|
||||
): Promise<MinutePoint[]> {
|
||||
const params = new URLSearchParams({ market, code, date: String(date) })
|
||||
const resp = await fetch(`${BASE}/minute/history?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? ''),
|
||||
price: Number(row.price ?? 0),
|
||||
vol: Number(row.vol ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 指数日 K(/bars/index;指数代码在个股接口可能返回空,用它兜底)。 */
|
||||
export async function fetchIndexBars(
|
||||
market: string,
|
||||
code: string,
|
||||
count = 250,
|
||||
): Promise<Bar[]> {
|
||||
const params = new URLSearchParams({ market, code, category: 'DAY', count: String(count) })
|
||||
const resp = await fetch(`${BASE}/bars/index?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? row.date ?? '').slice(0, 19).replace(' ', 'T'),
|
||||
open: Number(row.open),
|
||||
high: Number(row.high),
|
||||
low: Number(row.low),
|
||||
close: Number(row.close),
|
||||
vol: Number(row.vol),
|
||||
amount: Number(row.amount ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 板块列表(MAC 协议;CHANGE_PCT 排序时涨跌幅 = price/pre_close - 1 自行计算)。 */
|
||||
export async function fetchBoards(boardType = 'HY', count = 60): Promise<BoardRow[]> {
|
||||
const params = new URLSearchParams({ board_type: boardType, count: String(count) })
|
||||
const resp = await fetch(`${BASE}/board-mac/list?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const r = { ...row }
|
||||
const price = Number(r.price ?? 0)
|
||||
const pre = Number(r.pre_close ?? 0)
|
||||
r.change_pct = pre > 0 ? (price / pre - 1) * 100 : Number(r.change_pct ?? 0)
|
||||
return r as BoardRow
|
||||
})
|
||||
}
|
||||
|
||||
/** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */
|
||||
export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise<Record<string, unknown>[]> {
|
||||
const params = new URLSearchParams({ market, count: String(count) })
|
||||
const resp = await fetch(`${BASE}/mac/unusual?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 排行榜(MAC 排行行情)。
|
||||
* MAC 协议价格列是 close(无 price/change_pct),这里统一归一化为
|
||||
* price/change_pct,渲染端不再做多候选探测。sortBy 对应后端 SortType。 */
|
||||
export async function fetchRankList(
|
||||
sortOrder: 'DESC' | 'ASC',
|
||||
count = 20,
|
||||
sortBy = 'CHANGE_PCT',
|
||||
): Promise<RankRow[]> {
|
||||
const params = new URLSearchParams({
|
||||
category: 'A',
|
||||
sort_type: sortBy,
|
||||
sort_order: sortOrder,
|
||||
count: String(count),
|
||||
})
|
||||
const resp = await fetch(`${BASE}/mac/quote-list?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const close = Number(row.close ?? row.price ?? 0)
|
||||
const pre = Number(row.pre_close ?? 0)
|
||||
const r = { ...row } as Record<string, unknown>
|
||||
r.price = close
|
||||
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
|
||||
r.market = Number(row.market) === 1 ? 'SH' : 'SZ'
|
||||
return r as RankRow
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询证券中文名称(MAC 协议个股快照;BJ 市场可能不支持,失败返回空)。 */
|
||||
export async function fetchSymbolName(market: string, code: string): Promise<string> {
|
||||
try {
|
||||
const params = new URLSearchParams({ market, code })
|
||||
const resp = await fetch(`${BASE}/mac/symbol-info?${params}`)
|
||||
if (!resp.ok) return ''
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return String(body.data[0]?.name ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 板块成分股(MAC 协议;按涨跌幅排序,列与排行行情同构,做同款归一化)。 */
|
||||
export async function fetchBoardMembers(
|
||||
boardSymbol: string,
|
||||
count = 100,
|
||||
sortOrder: 'DESC' | 'ASC' = 'DESC',
|
||||
): Promise<RankRow[]> {
|
||||
const params = new URLSearchParams({
|
||||
board_symbol: boardSymbol,
|
||||
count: String(count),
|
||||
sort_type: 'CHANGE_PCT',
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
const resp = await fetch(`${BASE}/board-mac/members?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const close = Number(row.close ?? row.price ?? 0)
|
||||
const pre = Number(row.pre_close ?? 0)
|
||||
const r = { ...row } as Record<string, unknown>
|
||||
r.price = close
|
||||
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
|
||||
const m = Number(row.market)
|
||||
r.market = m === 1 ? 'SH' : m === 2 ? 'BJ' : 'SZ'
|
||||
return r as RankRow
|
||||
})
|
||||
}
|
||||
|
||||
// ── 自选 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 列出全部自选。 */
|
||||
export async function fetchWatchlist(): Promise<WatchlistResponse> {
|
||||
const resp = await fetch(`${BASE}/watchlist`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as WatchlistResponse
|
||||
}
|
||||
|
||||
/** 加入自选(幂等)。 */
|
||||
export async function addWatchItem(market: string, code: string, name = ''): Promise<void> {
|
||||
const resp = await fetch(`${BASE}/watchlist`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ market, code, name }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
}
|
||||
|
||||
/** 移除自选。 */
|
||||
export async function removeWatchItem(market: string, code: string): Promise<void> {
|
||||
const resp = await fetch(`${BASE}/watchlist/${market}/${code}`, { method: 'DELETE' })
|
||||
if (!resp.ok) await throwError(resp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
// 板块详情弹窗(行业/概念板块指数):左 分时/日K(含技术指标),
|
||||
// 右 成分股涨跌榜(点击行打开个股弹窗,快速定位人气股)。
|
||||
// 板块代码 881xxx/885xxx(沪市板块指数),分时与日K走与个股相同的
|
||||
// /minute、/bars 接口(协议天然支持板块)。
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchBars,
|
||||
fetchBoardMembers,
|
||||
fetchMinute,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import { dirClass, fmt2, fmtPctSigned } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, MinutePoint, RankRow } from '../types'
|
||||
import IntradayChart from './IntradayChart.vue'
|
||||
import StockKline, { type Overlay, type SubPane } from './StockKline.vue'
|
||||
import StockDialog from './StockDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string // 881106 / 885418 ...
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'watchlist-changed': [] }>()
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
const quote = computed(() => quoteStore.getQuote(`SH${props.code}`))
|
||||
|
||||
const changePct = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
})
|
||||
|
||||
const tab = ref<'minute' | 'daily'>('minute')
|
||||
const overlay = ref<Overlay>('ma')
|
||||
const subPane = ref<SubPane>('macd')
|
||||
|
||||
const overlayOptions: Array<{ value: Overlay; label: string }> = [
|
||||
{ value: 'ma', label: 'MA' },
|
||||
{ value: 'boll', label: 'BOLL' },
|
||||
{ value: 'ema', label: 'EMA' },
|
||||
{ value: 'none', label: '主图无' },
|
||||
]
|
||||
const subOptions: Array<{ value: SubPane; label: string }> = [
|
||||
{ value: 'macd', label: 'MACD' },
|
||||
{ value: 'kdj', label: 'KDJ' },
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
{ value: 'none', label: '副图无' },
|
||||
]
|
||||
|
||||
const minutePoints = ref<MinutePoint[]>([])
|
||||
const dailyBars = ref<Bar[]>([])
|
||||
const minuteError = ref('')
|
||||
const dailyError = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadMinute() {
|
||||
minuteError.value = ''
|
||||
try {
|
||||
minutePoints.value = await fetchMinute('SH', props.code)
|
||||
} catch (e) {
|
||||
minutePoints.value = []
|
||||
minuteError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDaily() {
|
||||
dailyError.value = ''
|
||||
try {
|
||||
const bars = await fetchBars('SH', props.code, 'DAY', undefined, undefined)
|
||||
if (bars.length === 0) throw new Error('该板块无日K数据')
|
||||
dailyBars.value = bars.slice(-250)
|
||||
} catch (e) {
|
||||
dailyBars.value = []
|
||||
dailyError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
loading.value = true
|
||||
await Promise.all([loadMinute(), loadDaily()])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadCharts)
|
||||
watch(() => props.code, loadCharts)
|
||||
|
||||
// ── 自选 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const inWatchlist = ref(false)
|
||||
const watchBusy = ref(false)
|
||||
|
||||
async function refreshWatchState() {
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
inWatchlist.value = resp.items.some((i) => i.symbol === `SH${props.code}`)
|
||||
} catch {
|
||||
inWatchlist.value = false
|
||||
}
|
||||
}
|
||||
onMounted(refreshWatchState)
|
||||
watch(() => props.code, refreshWatchState)
|
||||
|
||||
async function toggleWatch() {
|
||||
watchBusy.value = true
|
||||
try {
|
||||
if (inWatchlist.value) {
|
||||
await removeWatchItem('SH', props.code)
|
||||
inWatchlist.value = false
|
||||
} else {
|
||||
await addWatchItem('SH', props.code, props.name)
|
||||
inWatchlist.value = true
|
||||
}
|
||||
emit('watchlist-changed')
|
||||
} catch (e) {
|
||||
alert(formatError(e))
|
||||
} finally {
|
||||
watchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const preClose = computed(() => quote.value?.pre_close ?? null)
|
||||
|
||||
// ── 成分股涨跌榜 ────────────────────────────────────────────────────────────
|
||||
|
||||
const members = ref<RankRow[]>([])
|
||||
const memberOrder = ref<'DESC' | 'ASC'>('DESC')
|
||||
const membersError = ref('')
|
||||
const membersLoading = ref(false)
|
||||
|
||||
async function loadMembers() {
|
||||
membersLoading.value = true
|
||||
membersError.value = ''
|
||||
try {
|
||||
members.value = await fetchBoardMembers(props.code, 120, memberOrder.value)
|
||||
} catch (e) {
|
||||
members.value = []
|
||||
membersError.value = formatError(e)
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMemberOrder() {
|
||||
memberOrder.value = memberOrder.value === 'DESC' ? 'ASC' : 'DESC'
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
onMounted(loadMembers)
|
||||
watch(() => props.code, loadMembers)
|
||||
|
||||
/** 成分股行点击 → 叠开个股弹窗。 */
|
||||
const stockDlg = ref<{ market: string; code: string; name: string } | null>(null)
|
||||
|
||||
function openMember(r: RankRow) {
|
||||
stockDlg.value = {
|
||||
market: String(r.market ?? ''),
|
||||
code: String(r.code ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="dlg-mask" @click.self="emit('close')">
|
||||
<div class="dlg" :key="code">
|
||||
<div class="dlg-head">
|
||||
<div class="head-left">
|
||||
<span class="board-name">{{ name }}</span>
|
||||
<span class="board-code mono">SH{{ code }} · 板块指数</span>
|
||||
</div>
|
||||
<div class="head-quote">
|
||||
<span v-if="quote" class="price mono" :class="dirClass(changePct)">{{ fmt2(quote.price) }}</span>
|
||||
<span v-if="changePct !== null" class="chg mono" :class="dirClass(changePct)">
|
||||
{{ fmtPctSigned(changePct) }}
|
||||
</span>
|
||||
<button
|
||||
class="watch-btn"
|
||||
:class="{ watched: inWatchlist }"
|
||||
:disabled="watchBusy"
|
||||
@click="toggleWatch"
|
||||
>
|
||||
{{ inWatchlist ? '★ 移除自选' : '☆ 加入自选' }}
|
||||
</button>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlg-body">
|
||||
<!-- 左:分时 / 日K -->
|
||||
<div class="chart-area">
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'minute' }" @click="tab = 'minute'">分时</button>
|
||||
<button :class="{ active: tab === 'daily' }" @click="tab = 'daily'">日K</button>
|
||||
<template v-if="tab === 'daily'">
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in overlayOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: overlay === opt.value }"
|
||||
@click="overlay = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in subOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: subPane === opt.value }"
|
||||
@click="subPane = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="loading" class="chart-msg">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-if="tab === 'minute'">
|
||||
<div v-if="minuteError" class="chart-msg error">分时:{{ minuteError }}</div>
|
||||
<IntradayChart v-else :points="minutePoints" :pre-close="preClose" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="dailyError" class="chart-msg error">日K:{{ dailyError }}</div>
|
||||
<StockKline v-else :bars="dailyBars" :overlay="overlay" :sub-pane="subPane" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右:成分股涨跌榜 -->
|
||||
<div class="members-panel">
|
||||
<div class="members-head">
|
||||
<h3>成分股 <span class="dim members-count">({{ members.length }})</span></h3>
|
||||
<button class="order-btn" @click="toggleMemberOrder">
|
||||
{{ memberOrder === 'DESC' ? '↓ 涨幅降序' : '↑ 涨幅升序' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="membersError" class="chart-msg error">{{ membersError }}</div>
|
||||
<div v-else-if="membersLoading" class="chart-msg">成分股加载中…</div>
|
||||
<div v-else class="members-list">
|
||||
<div
|
||||
v-for="(r, i) in members"
|
||||
:key="i"
|
||||
class="member-row"
|
||||
@click="openMember(r)"
|
||||
>
|
||||
<span class="m-idx">{{ i + 1 }}</span>
|
||||
<span class="m-name">{{ r.name }}</span>
|
||||
<span class="m-code mono dim">{{ r.code }}</span>
|
||||
<span class="m-price mono">{{ fmt2(r.price) }}</span>
|
||||
<span class="m-pct mono" :class="dirClass(r.change_pct)">
|
||||
{{ fmtPctSigned(r.change_pct) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StockDialog
|
||||
v-if="stockDlg"
|
||||
:market="stockDlg.market"
|
||||
:code="stockDlg.code"
|
||||
:name="stockDlg.name"
|
||||
@close="stockDlg = null"
|
||||
@watchlist-changed="emit('watchlist-changed')"
|
||||
/>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.dlg {
|
||||
width: min(1280px, 96vw);
|
||||
max-height: 94vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dlg-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.board-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.board-code {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.head-quote {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chg {
|
||||
font-size: 13px;
|
||||
}
|
||||
.watch-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
margin-left: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
.watch-btn.watched {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.close-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
.dlg-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
.members-panel {
|
||||
width: 330px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.members-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.members-head h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.members-count {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
.order-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.members-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
.member-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 66px 58px 62px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.member-row:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.m-idx {
|
||||
color: var(--text-dim);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.m-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-code {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.m-price {
|
||||
text-align: right;
|
||||
}
|
||||
.m-pct {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ind-group {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
.chart-msg {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-msg.error {
|
||||
color: var(--up);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
// 分时图(ECharts):价格线 + 渐变面积 + 均价线 + 昨收基准虚线 + 成交量副图。
|
||||
// 单日模式:y 轴围绕昨收对称(分时图惯例)。
|
||||
// 多日模式(dayMarks 提供每天起始索引):y 轴自适应全段,天与天之间
|
||||
// markLine 竖分隔,x 轴只在每天首点显示日期(借鉴多日分时惯例)。
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||
import { fmt2 } from '../format'
|
||||
import type { MinutePoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: MinutePoint[]
|
||||
preClose?: number | null
|
||||
/** 多日模式:每天起始索引 + 日期标签。 */
|
||||
dayMarks?: Array<{ start: number; date: string }>
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const isMultiDay = () => (props.dayMarks?.length ?? 0) > 1
|
||||
|
||||
function render() {
|
||||
if (!container.value) return
|
||||
if (props.points.length === 0) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
chart.setOption(buildOption(), true)
|
||||
}
|
||||
|
||||
function buildOption(): echarts.EChartsCoreOption {
|
||||
const pts = props.points
|
||||
const multi = isMultiDay()
|
||||
// 基准(量柱首色与 tooltip 涨跌幅):单日用昨收,多日用全段首价
|
||||
const base = !multi && props.preClose && props.preClose > 0 ? props.preClose : pts[0]?.price ?? 0
|
||||
|
||||
const times = pts.map((p) => p.datetime.slice(11, 16))
|
||||
const prices = pts.map((p) => p.price)
|
||||
|
||||
// 均价线:累计成交额近似 = Σ(price×vol),均价 = cumAmount / cumVol
|
||||
let cumAmt = 0
|
||||
let cumVol = 0
|
||||
const avgPrices = pts.map((p) => {
|
||||
cumAmt += p.price * Math.max(p.vol, 0)
|
||||
cumVol += Math.max(p.vol, 0)
|
||||
return cumVol > 0 ? cumAmt / cumVol : p.price
|
||||
})
|
||||
|
||||
// y 轴范围:单日围绕昨收对称;多日自适应全段
|
||||
const hi = Math.max(...prices)
|
||||
const lo = Math.min(...prices)
|
||||
let yMax: number
|
||||
let yMin: number
|
||||
if (multi) {
|
||||
const pad = (hi - lo) * 0.08 || hi * 0.002
|
||||
yMax = hi + pad
|
||||
yMin = lo - pad
|
||||
} else {
|
||||
const hi2 = Math.max(base, hi)
|
||||
const lo2 = Math.min(base, lo)
|
||||
const pad = Math.max((hi2 - lo2) * 0.1, base * 0.0025)
|
||||
yMax = hi2 + pad
|
||||
yMin = lo2 - pad
|
||||
}
|
||||
|
||||
// 量柱颜色:与前一分钟价格比较(红涨绿跌),首柱与基准比
|
||||
const volColors = pts.map((p, i) => {
|
||||
const prev = i > 0 ? pts[i - 1].price : base
|
||||
return p.price >= prev ? UP_COLOR : DOWN_COLOR
|
||||
})
|
||||
|
||||
// x 轴刻度:单日在关键时点;多日只在每天首点显示 MM-DD
|
||||
const markStarts = new Map((props.dayMarks ?? []).map((m) => [m.start, m.date]))
|
||||
const labelInterval = multi
|
||||
? (index: number) => markStarts.has(index)
|
||||
: (index: number) => [0, 60, 120, 121, 180, 239].includes(index)
|
||||
const labelFormatter = multi
|
||||
? (v: string) => {
|
||||
const idx = times.indexOf(v)
|
||||
const d = markStarts.get(idx)
|
||||
return d ? d.slice(5) : ''
|
||||
}
|
||||
: (v: string) => v
|
||||
|
||||
// 多日分隔线 + 单日昨收基准线
|
||||
const markLines: Array<Record<string, unknown>> = multi
|
||||
? (props.dayMarks ?? [])
|
||||
.filter((m) => m.start > 0)
|
||||
.map((m) => ({ xAxis: m.start - 0.5, lineStyle: { color: '#3a3f4d', type: 'dashed', width: 1 } }))
|
||||
: [{ yAxis: base, lineStyle: { color: '#5c6370', type: 'dashed', width: 1 } }]
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
animation: false,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: (params: unknown) => {
|
||||
const arr = params as Array<{ dataIndex: number }>
|
||||
const i = arr[0]?.dataIndex ?? 0
|
||||
const p = pts[i]
|
||||
if (!p) return ''
|
||||
const color = p.price >= base ? UP_COLOR : DOWN_COLOR
|
||||
return `${p.datetime.slice(5, 16)}<br/>价 <b style="color:${color}">${fmt2(p.price)}</b><br/>均 ${fmt2(avgPrices[i])}<br/>量 ${p.vol}`
|
||||
},
|
||||
},
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
grid: [
|
||||
{ left: 56, right: 16, top: 16, height: '58%' },
|
||||
{ left: 56, right: 16, top: '74%', height: '20%' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 0,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { interval: labelInterval, formatter: labelFormatter },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
gridIndex: 0,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
axisLabel: { formatter: (v: number) => fmt2(v) },
|
||||
splitLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
},
|
||||
{
|
||||
gridIndex: 1,
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '价格',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: prices,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1.3, color: '#4a9eff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(74,158,255,0.25)' },
|
||||
{ offset: 1, color: 'rgba(74,158,255,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: markLines,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '均价',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: avgPrices,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: pts.map((p, i) => ({
|
||||
value: p.vol,
|
||||
itemStyle: { color: volColors[i] },
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => [props.points, props.dayMarks], render)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="intraday-chart"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intraday-chart {
|
||||
width: 100%;
|
||||
height: 440px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// 市场情绪雷达(借鉴 tick-stock-panel 看板 EmotionRadar):
|
||||
// 四维 0~100 —— 赚钱效应(涨跌比)/ 量能(较昨日成交)/ 动量(指数5日涨幅)/
|
||||
// 趋势(上证指数相对 MA20)。中心到顶端的填充区 + 综合分。
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts from '../echarts-setup'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 四维分值(0~100,50 为中性)。 */
|
||||
values: { profit: number; volume: number; momentum: number; trend: number }
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const DIMS = [
|
||||
{ name: '赚钱效应', max: 100 },
|
||||
{ name: '量能', max: 100 },
|
||||
{ name: '动量', max: 100 },
|
||||
{ name: '趋势', max: 100 },
|
||||
]
|
||||
|
||||
function render() {
|
||||
if (!container.value) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
const v = props.values
|
||||
chart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
radar: {
|
||||
indicator: DIMS,
|
||||
radius: '62%',
|
||||
center: ['50%', '52%'],
|
||||
splitNumber: 4,
|
||||
axisName: { color: '#8b919e', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
splitArea: { show: false },
|
||||
axisLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
symbol: 'circle',
|
||||
symbolSize: 3,
|
||||
data: [
|
||||
{
|
||||
value: [v.profit, v.volume, v.momentum, v.trend],
|
||||
itemStyle: { color: '#4a9eff' },
|
||||
lineStyle: { color: '#4a9eff', width: 1.5 },
|
||||
areaStyle: { color: 'rgba(74,158,255,0.22)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => props.values, render, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="mood-radar"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mood-radar {
|
||||
width: 100%;
|
||||
height: 190px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
// 表格行内 SVG 迷你分时图(无 ECharts 实例,几千行表格也轻)。
|
||||
// 相对昨收着色:线上方偏红、下方偏绿(简化:整线按首尾涨跌着色)。
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 分时价格序列。 */
|
||||
prices: number[]
|
||||
/** 昨收基准(可选,画虚线)。 */
|
||||
base?: number | null
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
|
||||
const W = computed(() => props.width ?? 92)
|
||||
const H = computed(() => props.height ?? 28)
|
||||
|
||||
const geom = computed(() => {
|
||||
const pts = props.prices.filter((p) => Number.isFinite(p))
|
||||
if (pts.length < 2) return null
|
||||
const base = props.base ?? pts[0]
|
||||
const all = props.base != null ? [...pts, props.base] : pts
|
||||
const min = Math.min(...all)
|
||||
const max = Math.max(...all)
|
||||
const span = max - min || 1
|
||||
const dx = W.value / (pts.length - 1)
|
||||
const y = (v: number) => H.value - 2 - ((v - min) / span) * (H.value - 4)
|
||||
const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * dx).toFixed(1)},${y(p).toFixed(1)}`).join('')
|
||||
const area = `${path}L${W.value},${H.value}L0,${H.value}Z`
|
||||
const last = pts[pts.length - 1]
|
||||
const rising = last >= base
|
||||
return { path, area, baseY: y(base), rising, baseValid: props.base != null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="W" :height="H" class="sparkline" viewBox="0 0 92 28" preserveAspectRatio="none">
|
||||
<template v-if="geom">
|
||||
<line
|
||||
v-if="geom.baseValid"
|
||||
x1="0"
|
||||
:y1="geom.baseY"
|
||||
:x2="W"
|
||||
:y2="geom.baseY"
|
||||
stroke="#5c6370"
|
||||
stroke-width="0.7"
|
||||
stroke-dasharray="3 2"
|
||||
/>
|
||||
<path :d="geom.area" :fill="geom.rising ? 'rgba(239,65,70,0.12)' : 'rgba(24,160,88,0.12)'" />
|
||||
<path
|
||||
:d="geom.path"
|
||||
fill="none"
|
||||
:stroke="geom.rising ? '#ef4146' : '#18a058'"
|
||||
stroke-width="1.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</template>
|
||||
<text v-else x="46" y="18" text-anchor="middle" fill="#5c6370" font-size="9">加载中…</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
// 个股预览对话框:头部实时报价 + 加/移除自选 + 左侧五档盘口
|
||||
// + 右侧 分时/日K tab(日K 支持技术指标切换)。
|
||||
// 报价来自全局 SSE store;图表按需拉取,分时/日K 独立容错,
|
||||
// 指数代码在个股 /bars 拿不到时自动回退 /bars/index。
|
||||
|
||||
import { computed, onMounted, ref, toRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchBars,
|
||||
fetchHistoryMinute,
|
||||
fetchIndexBars,
|
||||
fetchMinute,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, MinutePoint } from '../types'
|
||||
import IntradayChart from './IntradayChart.vue'
|
||||
import StockKline, { type Overlay, type SubPane } from './StockKline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
market: string
|
||||
code: string
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'watchlist-changed': [] }>()
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
const quote = computed(() => quoteStore.getQuote(`${props.market}${props.code}`))
|
||||
|
||||
const changePct = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
})
|
||||
|
||||
// ── tab / 指标 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const tab = ref<'minute' | 'daily'>('minute')
|
||||
const overlay = ref<Overlay>('ma')
|
||||
const subPane = ref<SubPane>('macd')
|
||||
const minuteDays = ref<1 | 3 | 5>(1)
|
||||
const DAY_OPTIONS: Array<{ value: 1 | 3 | 5; label: string }> = [
|
||||
{ value: 1, label: '1日' },
|
||||
{ value: 3, label: '3日' },
|
||||
{ value: 5, label: '5日' },
|
||||
]
|
||||
|
||||
const overlayOptions: Array<{ value: Overlay; label: string }> = [
|
||||
{ value: 'ma', label: 'MA' },
|
||||
{ value: 'boll', label: 'BOLL' },
|
||||
{ value: 'ema', label: 'EMA' },
|
||||
{ value: 'none', label: '主图无' },
|
||||
]
|
||||
const subOptions: Array<{ value: SubPane; label: string }> = [
|
||||
{ value: 'macd', label: 'MACD' },
|
||||
{ value: 'kdj', label: 'KDJ' },
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
{ value: 'none', label: '副图无' },
|
||||
]
|
||||
|
||||
// ── 图表数据(独立容错) ────────────────────────────────────────────────────
|
||||
|
||||
const minutePoints = ref<MinutePoint[]>([])
|
||||
/** 多日分时的天分隔标记(每天起始索引 + 日期)。 */
|
||||
const minuteDayMarks = ref<Array<{ start: number; date: string }>>([])
|
||||
const dailyBars = ref<Bar[]>([])
|
||||
const minuteError = ref('')
|
||||
const dailyError = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadMinute() {
|
||||
minuteError.value = ''
|
||||
try {
|
||||
// 交易日从日K尾部取(末日=今天),今天走 /minute,历史日走 /minute/history
|
||||
const dates = dailyBars.value.slice(-minuteDays.value).map((b) => b.datetime.slice(0, 10))
|
||||
const all: MinutePoint[] = []
|
||||
const marks: Array<{ start: number; date: string }> = []
|
||||
for (const d of dates) {
|
||||
const dateInt = Number(d.replaceAll('-', ''))
|
||||
const pts =
|
||||
d === new Date().toISOString().slice(0, 10)
|
||||
? await fetchMinute(props.market, props.code)
|
||||
: await fetchHistoryMinute(props.market, props.code, dateInt)
|
||||
if (pts.length === 0) continue
|
||||
marks.push({ start: all.length, date: d })
|
||||
all.push(...pts)
|
||||
}
|
||||
minutePoints.value = all
|
||||
minuteDayMarks.value = marks
|
||||
} catch (e) {
|
||||
minutePoints.value = []
|
||||
minuteDayMarks.value = []
|
||||
minuteError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(minuteDays, loadMinute)
|
||||
|
||||
async function loadDaily() {
|
||||
dailyError.value = ''
|
||||
try {
|
||||
let bars = await fetchBars(props.market, props.code, 'DAY', undefined, undefined)
|
||||
// 指数(或 /bars 空数据的服务器)回退指数接口
|
||||
if (bars.length === 0) bars = await fetchIndexBars(props.market, props.code, 250)
|
||||
if (bars.length === 0) throw new Error('无日K数据(可能为新股或代码有误)')
|
||||
dailyBars.value = bars.slice(-250)
|
||||
} catch (e) {
|
||||
dailyBars.value = []
|
||||
dailyError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
loading.value = true
|
||||
// 先日K后分时:多日分时的交易日列表取自 dailyBars 尾部
|
||||
await loadDaily()
|
||||
await loadMinute()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadCharts)
|
||||
watch([() => props.market, () => props.code], loadCharts)
|
||||
|
||||
// ── 自选状态 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const inWatchlist = ref(false)
|
||||
const watchBusy = ref(false)
|
||||
|
||||
async function refreshWatchState() {
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
inWatchlist.value = resp.items.some((i) => i.symbol === `${props.market}${props.code}`)
|
||||
} catch {
|
||||
inWatchlist.value = false
|
||||
}
|
||||
}
|
||||
onMounted(refreshWatchState)
|
||||
watch([() => props.market, () => props.code], refreshWatchState)
|
||||
|
||||
async function toggleWatch() {
|
||||
watchBusy.value = true
|
||||
try {
|
||||
if (inWatchlist.value) {
|
||||
await removeWatchItem(props.market, props.code)
|
||||
inWatchlist.value = false
|
||||
} else {
|
||||
await addWatchItem(props.market, props.code, props.name ?? '')
|
||||
inWatchlist.value = true
|
||||
}
|
||||
emit('watchlist-changed')
|
||||
} catch (e) {
|
||||
alert(formatError(e))
|
||||
} finally {
|
||||
watchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 五档 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bids = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q) return []
|
||||
return [1, 2, 3, 4, 5].map((i) => ({
|
||||
level: i,
|
||||
price: (q as unknown as Record<string, number | null>)[`bid${i}`] ?? null,
|
||||
vol: (q as unknown as Record<string, number | null>)[`bid_vol${i}`] ?? null,
|
||||
}))
|
||||
})
|
||||
const asks = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q) return []
|
||||
return [5, 4, 3, 2, 1].map((i) => ({
|
||||
level: i,
|
||||
price: (q as unknown as Record<string, number | null>)[`ask${i}`] ?? null,
|
||||
vol: (q as unknown as Record<string, number | null>)[`ask_vol${i}`] ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
const maxDepthVol = computed(() => {
|
||||
let m = 1
|
||||
for (const lv of [...bids.value, ...asks.value]) m = Math.max(m, lv.vol ?? 0)
|
||||
return m
|
||||
})
|
||||
|
||||
const preClose = toRef(() => quote.value?.pre_close ?? null)
|
||||
|
||||
// ── 一键寻优(跳 /optimize 并自动跑全策略预设网格) ─────────────────────────
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function gotoOptimize() {
|
||||
emit('close')
|
||||
router.push({ path: '/optimize', query: { code: props.code, autoAll: '1' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="dlg-mask" @click.self="emit('close')">
|
||||
<div class="dlg" :key="`${market}${code}`">
|
||||
<!-- 头部:名称 + 实时报价 + 自选开关 -->
|
||||
<div class="dlg-head">
|
||||
<div class="head-left">
|
||||
<span class="stock-name">{{ name || `${market}${code}` }}</span>
|
||||
<span class="stock-code mono">{{ market }}{{ code }}</span>
|
||||
<span v-if="quote?.server_time" class="srv-time mono">{{ quote.server_time.slice(0, 8) }}</span>
|
||||
</div>
|
||||
<div class="head-quote">
|
||||
<span class="price mono" :class="dirClass(changePct)">{{ fmt2(quote?.price) }}</span>
|
||||
<span v-if="changePct !== null" class="chg mono" :class="dirClass(changePct)">
|
||||
{{ fmt2((quote?.price ?? 0) - (quote?.pre_close ?? 0)) }}
|
||||
{{ fmtPctSigned(changePct) }}
|
||||
</span>
|
||||
<button
|
||||
class="watch-btn"
|
||||
:class="{ watched: inWatchlist }"
|
||||
:disabled="watchBusy"
|
||||
:title="inWatchlist ? '移除自选' : '加入自选'"
|
||||
@click="toggleWatch"
|
||||
>
|
||||
{{ inWatchlist ? '★ 移除自选' : '☆ 加入自选' }}
|
||||
</button>
|
||||
<button class="watch-btn" title="用全部策略的预设网格对该股一键寻优" @click="gotoOptimize">
|
||||
⚙ 一键寻优
|
||||
</button>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlg-body">
|
||||
<!-- 五档盘口 -->
|
||||
<div class="depth">
|
||||
<div class="depth-title">五档盘口</div>
|
||||
<table class="depth-table">
|
||||
<tbody>
|
||||
<tr v-for="lv in asks" :key="`a${lv.level}`">
|
||||
<td class="lv">卖{{ lv.level }}</td>
|
||||
<td class="mono" :class="lv.price && quote?.pre_close ? dirClass(lv.price - quote.pre_close) : 'flat'">
|
||||
{{ fmt2(lv.price) }}
|
||||
</td>
|
||||
<td class="vol-cell">
|
||||
<div class="vol-bar ask" :style="{ width: `${((lv.vol ?? 0) / maxDepthVol) * 100}%` }"></div>
|
||||
<span class="mono">{{ lv.vol ? Math.round(lv.vol) : '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="sep-row">
|
||||
<td colspan="3"><div class="sep"></div></td>
|
||||
</tr>
|
||||
<tr v-for="lv in bids" :key="`b${lv.level}`">
|
||||
<td class="lv">买{{ lv.level }}</td>
|
||||
<td class="mono" :class="lv.price && quote?.pre_close ? dirClass(lv.price - quote.pre_close) : 'flat'">
|
||||
{{ fmt2(lv.price) }}
|
||||
</td>
|
||||
<td class="vol-cell">
|
||||
<div class="vol-bar bid" :style="{ width: `${((lv.vol ?? 0) / maxDepthVol) * 100}%` }"></div>
|
||||
<span class="mono">{{ lv.vol ? Math.round(lv.vol) : '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="quote" class="depth-stats mono">
|
||||
<div><span class="dim2">量</span>{{ fmtVol(quote.vol) }}</div>
|
||||
<div><span class="dim2">额</span>{{ fmtAmount(quote.amount) }}</div>
|
||||
<div><span class="dim2">高</span>{{ fmt2(quote.high) }}</div>
|
||||
<div><span class="dim2">低</span>{{ fmt2(quote.low) }}</div>
|
||||
<div><span class="dim2">开</span>{{ fmt2(quote.open) }}</div>
|
||||
<div><span class="dim2">昨收</span>{{ fmt2(quote.pre_close) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<div class="chart-area">
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'minute' }" @click="tab = 'minute'">分时</button>
|
||||
<button :class="{ active: tab === 'daily' }" @click="tab = 'daily'">日K</button>
|
||||
<span v-if="tab === 'minute'" class="ind-group">
|
||||
<button
|
||||
v-for="opt in DAY_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: minuteDays === opt.value }"
|
||||
@click="minuteDays = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<template v-if="tab === 'daily'">
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in overlayOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: overlay === opt.value }"
|
||||
@click="overlay = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in subOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: subPane === opt.value }"
|
||||
@click="subPane = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="loading" class="chart-msg">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-if="tab === 'minute'">
|
||||
<div v-if="minuteError" class="chart-msg error">分时:{{ minuteError }}</div>
|
||||
<IntradayChart
|
||||
v-else
|
||||
:points="minutePoints"
|
||||
:pre-close="preClose"
|
||||
:day-marks="minuteDayMarks"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="dailyError" class="chart-msg error">日K:{{ dailyError }}</div>
|
||||
<StockKline v-else :bars="dailyBars" :overlay="overlay" :sub-pane="subPane" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.dlg {
|
||||
width: min(1280px, 96vw);
|
||||
max-height: 94vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dlg-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.stock-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stock-code {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.srv-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.head-quote {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.price {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chg {
|
||||
font-size: 13px;
|
||||
}
|
||||
.watch-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
margin-left: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
.watch-btn.watched {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.close-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
.dlg-body {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.depth {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 14px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.depth-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.depth-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.depth-table td {
|
||||
padding: 2.5px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lv {
|
||||
color: var(--text-dim);
|
||||
width: 34px;
|
||||
}
|
||||
.vol-cell {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
}
|
||||
.vol-bar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
opacity: 0.18;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.vol-bar.ask {
|
||||
background: var(--up);
|
||||
}
|
||||
.vol-bar.bid {
|
||||
background: var(--down);
|
||||
}
|
||||
.vol-cell span {
|
||||
position: relative;
|
||||
}
|
||||
.sep {
|
||||
border-top: 1px dashed var(--border);
|
||||
margin: 3px 0;
|
||||
}
|
||||
.depth-stats {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.depth-stats > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dim2 {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ind-group {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
.chart-msg {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-msg.error {
|
||||
color: var(--up);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
// 个股日K图:candlestick + 成交量 + 可选主图指标(MA/BOLL/EMA)
|
||||
// + 可选副图指标(MACD/KDJ/RSI)。指标由父组件传选择,前端本地计算
|
||||
// (indicators.ts,与后端 MyTT 同口径)。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||
import { boll, ema, macd as calcMacd, kdj as calcKdj, rsi as calcRsi } from '../indicators'
|
||||
import { fmt2 } from '../format'
|
||||
import type { Bar } from '../types'
|
||||
|
||||
export type Overlay = 'none' | 'ma' | 'boll' | 'ema'
|
||||
export type SubPane = 'none' | 'macd' | 'kdj' | 'rsi'
|
||||
|
||||
const props = defineProps<{
|
||||
bars: Bar[]
|
||||
overlay?: Overlay
|
||||
subPane?: SubPane
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const closes = computed(() => props.bars.map((b) => b.close))
|
||||
const highs = computed(() => props.bars.map((b) => b.high))
|
||||
const lows = computed(() => props.bars.map((b) => b.low))
|
||||
|
||||
interface LineSpec {
|
||||
name: string
|
||||
data: Array<number | null>
|
||||
color: string
|
||||
dashed?: boolean
|
||||
}
|
||||
|
||||
/** 主图指标线(随 overlay 切换)。 */
|
||||
const overlayLines = computed<LineSpec[]>(() => {
|
||||
const c = closes.value
|
||||
if (props.overlay === 'ma') {
|
||||
const colors = ['#f0a020', '#4a9eff', '#c084fc', '#22d3ee']
|
||||
return [5, 10, 20, 60].map((n, i) => ({
|
||||
name: `MA${n}`,
|
||||
data: maLocal(c, n),
|
||||
color: colors[i],
|
||||
}))
|
||||
}
|
||||
if (props.overlay === 'ema') {
|
||||
return [
|
||||
{ name: 'EMA12', data: ema(c, 12), color: '#f0a020' },
|
||||
{ name: 'EMA26', data: ema(c, 26), color: '#4a9eff' },
|
||||
]
|
||||
}
|
||||
if (props.overlay === 'boll') {
|
||||
const { mid, upper, lower } = boll(c)
|
||||
return [
|
||||
{ name: 'BOLL中轨', data: mid, color: '#f0a020' },
|
||||
{ name: '上轨', data: upper, color: '#c084fc', dashed: true },
|
||||
{ name: '下轨', data: lower, color: '#c084fc', dashed: true },
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
function maLocal(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i]
|
||||
if (i >= n) sum -= data[i - n]
|
||||
out.push(i >= n - 1 ? sum / n : null)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!container.value || props.bars.length === 0) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
chart.setOption(buildOption(), true)
|
||||
}
|
||||
|
||||
function buildOption(): echarts.EChartsCoreOption {
|
||||
const bars = props.bars
|
||||
const dates = bars.map((b) => b.datetime.slice(0, 10))
|
||||
const ohlc = bars.map((b) => [b.open, b.close, b.low, b.high])
|
||||
const hasSub = props.subPane !== 'none'
|
||||
const start = Math.max(0, 100 - (120 / bars.length) * 100)
|
||||
|
||||
// 布局:K 区 / 量区 /(可选)副图区
|
||||
const grids = [
|
||||
{ left: 56, right: 16, top: 28, height: hasSub ? '48%' : '60%' },
|
||||
{ left: 56, right: 16, top: hasSub ? '64%' : '74%', height: hasSub ? '13%' : '18%' },
|
||||
]
|
||||
const xAxes: echarts.EChartsCoreOption[] = [
|
||||
{ type: 'category', gridIndex: 0, data: dates, boundaryGap: true, axisLabel: { show: false }, axisTick: { show: false } },
|
||||
{ type: 'category', gridIndex: 1, data: dates, boundaryGap: true, axisTick: { show: false } },
|
||||
]
|
||||
const yAxes: echarts.EChartsCoreOption[] = [
|
||||
{ gridIndex: 0, scale: true, axisLabel: { formatter: (v: number) => fmt2(v) }, splitLine: { lineStyle: { color: '#2a2e3a' } } },
|
||||
{ gridIndex: 1, axisLabel: { show: false }, splitLine: { show: false } },
|
||||
]
|
||||
if (hasSub) {
|
||||
grids.push({ left: 56, right: 16, top: '81%', height: '13%' } as never)
|
||||
xAxes.push({ type: 'category', gridIndex: 2, data: dates, boundaryGap: true, axisTick: { show: false } })
|
||||
yAxes.push({ gridIndex: 2, scale: true, axisLabel: { fontSize: 10 }, splitLine: { show: false } })
|
||||
}
|
||||
|
||||
const series: echarts.EChartsCoreOption[] = [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: UP_COLOR,
|
||||
color0: DOWN_COLOR,
|
||||
borderColor: UP_COLOR,
|
||||
borderColor0: DOWN_COLOR,
|
||||
},
|
||||
},
|
||||
...overlayLines.value.map(
|
||||
(l) =>
|
||||
({
|
||||
name: l.name,
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: l.data,
|
||||
showSymbol: false,
|
||||
connectNulls: false,
|
||||
lineStyle: { width: 1, color: l.color, type: l.dashed ? 'dashed' : 'solid' },
|
||||
itemStyle: { color: l.color },
|
||||
}) as never,
|
||||
),
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: bars.map((b) => ({
|
||||
value: b.vol,
|
||||
itemStyle: { color: b.close >= b.open ? UP_COLOR : DOWN_COLOR },
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
]
|
||||
|
||||
// 副图指标
|
||||
const c = closes.value
|
||||
if (props.subPane === 'macd') {
|
||||
const { dif, dea, hist } = calcMacd(c)
|
||||
series.push(
|
||||
{
|
||||
name: 'DIF',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: dif,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'DEA',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: dea,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'MACD',
|
||||
type: 'bar',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: hist.map((v) => ({
|
||||
value: v,
|
||||
itemStyle: { color: (v ?? 0) >= 0 ? UP_COLOR : DOWN_COLOR },
|
||||
})),
|
||||
barWidth: '50%',
|
||||
},
|
||||
)
|
||||
} else if (props.subPane === 'kdj') {
|
||||
const { k, d, j } = calcKdj(highs.value, lows.value, c)
|
||||
series.push(
|
||||
{
|
||||
name: 'K',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: k,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'D',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: d,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'J',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: j,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#c084fc' },
|
||||
},
|
||||
)
|
||||
} else if (props.subPane === 'rsi') {
|
||||
series.push(
|
||||
{
|
||||
name: 'RSI6',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 6),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'RSI12',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 12),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'RSI24',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 24),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#c084fc' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const legendNames = ['K线', ...overlayLines.value.map((l) => l.name)]
|
||||
if (hasSub) {
|
||||
legendNames.push(
|
||||
props.subPane === 'macd' ? 'DIF,DEA' : props.subPane === 'kdj' ? 'K,D,J' : 'RSI',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
// OHLC/指标全部两位小数(用户约定:价格一律 2 位)
|
||||
valueFormatter: (v: number | string) => fmt2(Number(v)),
|
||||
},
|
||||
legend: { data: legendNames, top: 0, textStyle: { fontSize: 11 } },
|
||||
grid: grids,
|
||||
xAxis: xAxes,
|
||||
yAxis: yAxes,
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1, ...(hasSub ? [2] : [])], start, end: 100 },
|
||||
{ type: 'slider', xAxisIndex: [0, 1, ...(hasSub ? [2] : [])], bottom: 4, start, end: 100, height: 16 },
|
||||
],
|
||||
series,
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => [props.bars, props.overlay, props.subPane], render, { deep: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="stock-kline"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stock-kline {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,14 @@
|
||||
// 用到的:candlestick(K线)、line(净值/回撤曲线)、markPoint(买卖点标注)、heatmap(寻优热力图)。
|
||||
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, CandlestickChart, HeatmapChart, LineChart } from 'echarts/charts'
|
||||
import { BarChart, CandlestickChart, HeatmapChart, LineChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkLineComponent,
|
||||
MarkPointComponent,
|
||||
RadarComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
@@ -20,12 +22,15 @@ echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
HeatmapChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
DataZoomComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
RadarComponent,
|
||||
VisualMapComponent,
|
||||
])
|
||||
|
||||
|
||||
@@ -11,3 +11,33 @@ export function fmtPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
/** 成交额/市值大数(元 → 亿/万亿,A股口径)。 */
|
||||
export function fmtAmount(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
if (Math.abs(v) >= 1e12) return `${(v / 1e12).toFixed(2)}万亿`
|
||||
if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
|
||||
if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)}万`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
/** 成交量(手 → 万手/亿手)。 */
|
||||
export function fmtVol(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)}亿手`
|
||||
if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)}万手`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
/** 涨跌幅带号(接受百分数如 2.35 → "+2.35%")。 */
|
||||
export function fmtPctSigned(v: number | null | undefined, digits = 2): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
const s = v.toFixed(digits)
|
||||
return v > 0 ? `+${s}%` : `${s}%`
|
||||
}
|
||||
|
||||
/** 按涨跌方向取色 class(涨红/跌绿/平灰)。 */
|
||||
export function dirClass(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v) || v === 0) return 'flat'
|
||||
return v > 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 技术指标前端计算库(与后端 MyTT/indicator.py 同口径的常用子集)。
|
||||
// 输入均为按时间正序的价格数组,输出与输入等长(前期无法计算的点为 null)。
|
||||
|
||||
/** 简单移动平均 MA(n)。 */
|
||||
export function ma(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i]
|
||||
if (i >= n) sum -= data[i - n]
|
||||
out.push(i >= n - 1 ? sum / n : null)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 指数移动平均 EMA(n),首值用前 n 项均值(无则首个有效值)。 */
|
||||
export function ema(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
const k = 2 / (n + 1)
|
||||
let prev: number | null = null
|
||||
let seed = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (prev === null) {
|
||||
seed += data[i]
|
||||
if (i === n - 1) {
|
||||
prev = seed / n
|
||||
out.push(prev)
|
||||
} else {
|
||||
out.push(null)
|
||||
}
|
||||
} else {
|
||||
prev = data[i] * k + prev * (1 - k)
|
||||
out.push(prev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 国内 SMA(X, N, M):X*M/N + 前值*(N-M)/N,首值 = 首个 X。 */
|
||||
function smaCN(data: number[], n: number, m: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let prev: number | null = null
|
||||
for (const v of data) {
|
||||
if (prev === null) {
|
||||
prev = v
|
||||
out.push(v)
|
||||
} else {
|
||||
prev = (v * m) / n + (prev * (n - m)) / n
|
||||
out.push(prev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 布林带 BOLL(20, 2):中轨 MA20,上下轨 ±2×标准差。 */
|
||||
export function boll(
|
||||
data: number[],
|
||||
n = 20,
|
||||
p = 2,
|
||||
): { mid: Array<number | null>; upper: Array<number | null>; lower: Array<number | null> } {
|
||||
const mid = ma(data, n)
|
||||
const upper: Array<number | null> = []
|
||||
const lower: Array<number | null> = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (mid[i] === null) {
|
||||
upper.push(null)
|
||||
lower.push(null)
|
||||
continue
|
||||
}
|
||||
let ssum = 0
|
||||
for (let j = i - n + 1; j <= i; j++) ssum += (data[j] - (mid[i] as number)) ** 2
|
||||
const sd = Math.sqrt(ssum / n)
|
||||
upper.push((mid[i] as number) + p * sd)
|
||||
lower.push((mid[i] as number) - p * sd)
|
||||
}
|
||||
return { mid, upper, lower }
|
||||
}
|
||||
|
||||
/** MACD(12,26,9):DIF、DEA、MACD 柱(= 2×(DIF-DEA),国内口径)。 */
|
||||
export function macd(
|
||||
closes: number[],
|
||||
fast = 12,
|
||||
slow = 26,
|
||||
signal = 9,
|
||||
): { dif: Array<number | null>; dea: Array<number | null>; hist: Array<number | null> } {
|
||||
const ef = ema(closes, fast)
|
||||
const es = ema(closes, slow)
|
||||
const dif = closes.map((_, i) =>
|
||||
ef[i] !== null && es[i] !== null ? (ef[i] as number) - (es[i] as number) : null,
|
||||
)
|
||||
// DEA = DIF 的 EMA(9):剔除前缀 null 后计算再回填
|
||||
const firstIdx = dif.findIndex((v) => v !== null)
|
||||
const dea: Array<number | null> = new Array(closes.length).fill(null)
|
||||
if (firstIdx >= 0) {
|
||||
const valid = dif.slice(firstIdx) as number[]
|
||||
const deaValid = ema(valid, signal)
|
||||
for (let i = 0; i < deaValid.length; i++) dea[firstIdx + i] = deaValid[i]
|
||||
}
|
||||
const hist = dif.map((v, i) =>
|
||||
v !== null && dea[i] !== null ? 2 * (v - (dea[i] as number)) : null,
|
||||
)
|
||||
return { dif, dea, hist }
|
||||
}
|
||||
|
||||
/** KDJ(9,3,3):RSV→K=SMA(RSV,3,1)→D=SMA(K,3,1)→J=3K-2D。 */
|
||||
export function kdj(
|
||||
highs: number[],
|
||||
lows: number[],
|
||||
closes: number[],
|
||||
n = 9,
|
||||
): { k: Array<number | null>; d: Array<number | null>; j: Array<number | null> } {
|
||||
const rsv: number[] = []
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
const hi = Math.max(...highs.slice(Math.max(0, i - n + 1), i + 1))
|
||||
const lo = Math.min(...lows.slice(Math.max(0, i - n + 1), i + 1))
|
||||
rsv.push(hi === lo ? 50 : ((closes[i] - lo) / (hi - lo)) * 100)
|
||||
}
|
||||
const k = smaCN(rsv, 3, 1)
|
||||
// smaCN 等长输出;k 无 null(首值即有效),直接算
|
||||
const kk = k as number[]
|
||||
const dd = smaCN(kk, 3, 1) as number[]
|
||||
const j = kk.map((v, i) => 3 * v - 2 * dd[i])
|
||||
return { k: kk, d: dd, j }
|
||||
}
|
||||
|
||||
/** RSI(n):Wilder 平滑(SMA(U,n,1)/SMA(D,n,1) 国内近似)。 */
|
||||
export function rsi(closes: number[], n = 14): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let avgU = 0
|
||||
let avgD = 0
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
if (i === 0) {
|
||||
out.push(null)
|
||||
continue
|
||||
}
|
||||
const ch = closes[i] - closes[i - 1]
|
||||
const u = Math.max(ch, 0)
|
||||
const d = Math.max(-ch, 0)
|
||||
if (i <= n) {
|
||||
avgU += u / n
|
||||
avgD += d / n
|
||||
out.push(i === n ? (avgD === 0 ? 100 : 100 - (100 * avgU) / (avgU + avgD)) : null)
|
||||
} else {
|
||||
avgU = (avgU * (n - 1) + u) / n
|
||||
avgD = (avgD * (n - 1) + d) / n
|
||||
out.push(avgD === 0 ? 100 : 100 - (100 * avgU) / (avgU + avgD))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export function detectMarket(code: string): Market {
|
||||
const c = code.trim()
|
||||
if (!/^\d{6}$/.test(c)) return 'SZ'
|
||||
|
||||
// 通达信板块指数(沪市):880 统计 / 881 行业 / 885 概念等
|
||||
if (/^88[0-9]/.test(c)) return 'SH'
|
||||
|
||||
// 北交所:43/83/87/92(含920段)/93 + 4xx/8xx(三板/小盘)
|
||||
if (/^(43|83|87|92|93|4|8)/.test(c)) return 'BJ'
|
||||
|
||||
|
||||
@@ -2,16 +2,21 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import BacktestView from './views/BacktestView.vue'
|
||||
import CompareView from './views/CompareView.vue'
|
||||
import DashboardView from './views/DashboardView.vue'
|
||||
import OptimizeView from './views/OptimizeView.vue'
|
||||
import PortfolioView from './views/PortfolioView.vue'
|
||||
import ServerSettingsView from './views/ServerSettingsView.vue'
|
||||
import SignalRadarView from './views/SignalRadarView.vue'
|
||||
import StrategiesView from './views/StrategiesView.vue'
|
||||
import WatchlistView from './views/WatchlistView.vue'
|
||||
|
||||
// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)
|
||||
// + 策略库(/strategies)+ 信号雷达(/signals)+ 服务器设置(/settings)。
|
||||
// 行情终端:市场看板(/)+ 自选(/watchlist)。
|
||||
// 分析工具:单标的回测(/backtest)+ 组合回测(/portfolio)+ 参数寻优(/optimize)
|
||||
// + 结果对比(/compare)+ 策略库(/strategies)+ 信号雷达(/signals)+ 设置(/settings)。
|
||||
const routes = [
|
||||
{ path: '/', name: 'backtest', component: BacktestView },
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
{ path: '/watchlist', name: 'watchlist', component: WatchlistView },
|
||||
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
||||
{ path: '/compare', name: 'compare', component: CompareView },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 实时行情 SSE 状态(Pinia,全局单连接)。
|
||||
// App.vue 挂载时 connect(),所有视图共享同一份 quotes 快照。
|
||||
// 断线指数退避重连(上限 30s),状态徽标挂在侧边栏底部。
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import type { SecurityQuote, SseMessage } from '../types'
|
||||
|
||||
export type SseStatus = 'connecting' | 'open' | 'closed'
|
||||
|
||||
const STREAM_URL = '/api/v1/stream/quotes'
|
||||
const MAX_RETRY_DELAY = 30_000
|
||||
|
||||
export const useQuoteStore = defineStore('quotes', () => {
|
||||
/** symbol(SH600000)→ 最新行情快照。 */
|
||||
const quotes = reactive(new Map<string, SecurityQuote>())
|
||||
const status = ref<SseStatus>('closed')
|
||||
const lastTs = ref('')
|
||||
let es: EventSource | null = null
|
||||
let retryTimer: number | null = null
|
||||
let retryCount = 0
|
||||
|
||||
const quoteCount = computed(() => quotes.size)
|
||||
|
||||
function handleMsg(ev: MessageEvent<string>) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as SseMessage
|
||||
if (msg.type === 'quotes_updated' && msg.quotes) {
|
||||
for (const q of msg.quotes) quotes.set(q.symbol, q)
|
||||
if (msg.ts) lastTs.value = msg.ts
|
||||
}
|
||||
status.value = 'open'
|
||||
retryCount = 0
|
||||
} catch {
|
||||
// 非 JSON 帧(服务端注释/心跳),忽略
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (es) return
|
||||
status.value = 'connecting'
|
||||
es = new EventSource(STREAM_URL)
|
||||
es.onmessage = handleMsg
|
||||
es.onopen = () => {
|
||||
status.value = 'open'
|
||||
retryCount = 0
|
||||
}
|
||||
es.onerror = () => {
|
||||
// EventSource 断开后进入 readyState=CONNECTING 自行重试;但若服务已停,
|
||||
// 会无限静默重试——这里手动接管:关闭后按退避重连,同时更新状态徽标。
|
||||
close(false)
|
||||
retryCount += 1
|
||||
const delay = Math.min(1000 * 2 ** (retryCount - 1), MAX_RETRY_DELAY)
|
||||
status.value = 'closed'
|
||||
retryTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
function close(markClosed = true) {
|
||||
if (retryTimer !== null) {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
es?.close()
|
||||
es = null
|
||||
if (markClosed) status.value = 'closed'
|
||||
}
|
||||
|
||||
/** 取单只行情(无数据时返回 undefined)。 */
|
||||
function getQuote(symbol: string): SecurityQuote | undefined {
|
||||
return quotes.get(symbol)
|
||||
}
|
||||
|
||||
return { quotes, status, lastTs, quoteCount, connect, close, getQuote }
|
||||
})
|
||||
@@ -104,3 +104,59 @@ label {
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 行情涨跌着色(format.dirClass 的落点) */
|
||||
.up {
|
||||
color: var(--up);
|
||||
}
|
||||
.down {
|
||||
color: var(--down);
|
||||
}
|
||||
.flat {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 通用面板卡片(看板/自选页网格单元) */
|
||||
.card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 行情数据表 */
|
||||
.qtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.qtable th {
|
||||
text-align: right;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qtable td {
|
||||
text-align: right;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.qtable th:first-child,
|
||||
.qtable td:first-child {
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -403,3 +403,120 @@ export interface ServerSwitchResult {
|
||||
host: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ── 行情终端:实时五档(SSE / POST /api/v1/security/quotes) ─────────────────
|
||||
|
||||
/** 单只标的实时五档行情(后端 SecurityQuote 白名单投影,SSE 与 REST 同构)。 */
|
||||
export interface SecurityQuote {
|
||||
symbol: string // "SH600000"
|
||||
market: string // SH/SZ/BJ
|
||||
code: string
|
||||
price: number | null
|
||||
pre_close: number | null
|
||||
open: number | null
|
||||
high: number | null
|
||||
low: number | null
|
||||
vol: number | null // 总成交量(手)
|
||||
cur_vol: number | null
|
||||
amount: number | null // 成交额(元)
|
||||
s_vol: number | null // 内盘
|
||||
b_vol: number | null // 外盘
|
||||
rise_speed: number | null // 涨速
|
||||
limit_up: number | null
|
||||
limit_down: number | null
|
||||
decimal_point: number | null
|
||||
server_time: string
|
||||
trading_status: number | null
|
||||
bid1: number | null
|
||||
bid_vol1: number | null
|
||||
bid2: number | null
|
||||
bid_vol2: number | null
|
||||
bid3: number | null
|
||||
bid_vol3: number | null
|
||||
bid4: number | null
|
||||
bid_vol4: number | null
|
||||
bid5: number | null
|
||||
bid_vol5: number | null
|
||||
ask1: number | null
|
||||
ask_vol1: number | null
|
||||
ask2: number | null
|
||||
ask_vol2: number | null
|
||||
ask3: number | null
|
||||
ask_vol3: number | null
|
||||
ask4: number | null
|
||||
ask_vol4: number | null
|
||||
ask5: number | null
|
||||
ask_vol5: number | null
|
||||
}
|
||||
|
||||
/** SSE 消息:quotes_updated / hello。 */
|
||||
export interface SseMessage {
|
||||
type: 'quotes_updated' | 'hello'
|
||||
ts?: string
|
||||
count?: number
|
||||
quotes?: SecurityQuote[]
|
||||
subscribers?: number
|
||||
}
|
||||
|
||||
// ── 行情终端:市场统计(GET /api/v1/market/stat) ────────────────────────────
|
||||
|
||||
export interface MarketStat {
|
||||
up_count: number
|
||||
down_count: number
|
||||
neutral_count: number
|
||||
suspended_count: number
|
||||
total_count: number
|
||||
total_amount: number
|
||||
total_volume: number
|
||||
total_market_cap: number
|
||||
limit_up_count: number
|
||||
limit_down_count: number
|
||||
}
|
||||
|
||||
// ── 行情终端:分时(GET /api/v1/minute) ─────────────────────────────────────
|
||||
|
||||
export interface MinutePoint {
|
||||
datetime: string
|
||||
price: number
|
||||
vol: number
|
||||
}
|
||||
|
||||
// ── 行情终端:自选(GET/POST/DELETE /api/v1/watchlist) ──────────────────────
|
||||
|
||||
export interface WatchItem {
|
||||
market: string
|
||||
code: string
|
||||
symbol: string // SH600000
|
||||
name: string
|
||||
group_name: string
|
||||
created_at: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface WatchlistResponse {
|
||||
items: WatchItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// ── 行情终端:板块列表(GET /api/v1/board-mac/list,MAC 协议,防御式取列) ────
|
||||
|
||||
/** 板块行(MAC 协议字段随版本浮动,全部可选,渲染端容错)。 */
|
||||
export interface BoardRow {
|
||||
code?: string
|
||||
name?: string
|
||||
price?: number
|
||||
pre_close?: number
|
||||
change_pct?: number
|
||||
sort_value?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ── 行情终端:排行行情(GET /api/v1/mac/quote-list,MAC 协议,防御式取列) ────
|
||||
|
||||
export interface RankRow {
|
||||
code?: string
|
||||
name?: string
|
||||
price?: number
|
||||
change_pct?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
<script setup lang="ts">
|
||||
// 市场看板 v2:指数实时条(内嵌迷你分时)+ 涨跌统计 + 市场情绪 +
|
||||
// 涨跌分布直方图 + 行业/概念热度榜 + 涨跌幅榜 + 异动雷达。
|
||||
// 指数走全局 SSE;统计/情绪/分布/板块/异动定时轮询;全市场分布懒加载。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
fetchBoards,
|
||||
fetchIndexBars,
|
||||
fetchMarketStat,
|
||||
fetchRankList,
|
||||
fetchUnusual,
|
||||
fetchMinute,
|
||||
formatError,
|
||||
} from '../api'
|
||||
import { dirClass, fmtAmount, fmtPctSigned } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, BoardRow, MarketStat, RankRow } from '../types'
|
||||
import BoardDialog from '../components/BoardDialog.vue'
|
||||
import MoodRadar from '../components/MoodRadar.vue'
|
||||
import Sparkline from '../components/Sparkline.vue'
|
||||
import StockDialog from '../components/StockDialog.vue'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
|
||||
// ── 指数条(SSE 实时 + 迷你分时) ────────────────────────────────────────────
|
||||
|
||||
const INDEXES = [
|
||||
{ symbol: 'SH000001', name: '上证指数' },
|
||||
{ symbol: 'SZ399001', name: '深证成指' },
|
||||
{ symbol: 'SZ399006', name: '创业板指' },
|
||||
{ symbol: 'SH000688', name: '科创50' },
|
||||
{ symbol: 'SH000300', name: '沪深300' },
|
||||
]
|
||||
|
||||
function idxQuote(symbol: string) {
|
||||
return quoteStore.getQuote(symbol)
|
||||
}
|
||||
|
||||
function idxPct(symbol: string): number | null {
|
||||
const q = idxQuote(symbol)
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
}
|
||||
|
||||
const idxSparks = ref(new Map<string, number[]>())
|
||||
const idxSparkBase = ref(new Map<string, number>())
|
||||
|
||||
async function loadIdxSparks() {
|
||||
for (const idx of INDEXES) {
|
||||
try {
|
||||
const pts = await fetchMinute(idx.symbol.slice(0, 2), idx.symbol.slice(2))
|
||||
if (pts.length > 0) {
|
||||
const prices = pts.map((p) => p.price)
|
||||
idxSparks.value.set(idx.symbol, prices)
|
||||
const q = idxQuote(idx.symbol)
|
||||
idxSparkBase.value.set(idx.symbol, q?.pre_close ?? prices[0])
|
||||
}
|
||||
} catch {
|
||||
// 单指数失败跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 市场统计(轮询 30s) ─────────────────────────────────────────────────────
|
||||
|
||||
const stat = ref<MarketStat | null>(null)
|
||||
const statError = ref('')
|
||||
|
||||
async function loadStat() {
|
||||
try {
|
||||
stat.value = await fetchMarketStat()
|
||||
statError.value = ''
|
||||
} catch (e) {
|
||||
statError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const breadth = computed(() => {
|
||||
const s = stat.value
|
||||
if (!s || !s.total_count) return null
|
||||
const active = s.up_count + s.down_count + s.neutral_count || 1
|
||||
return {
|
||||
up: (s.up_count / active) * 100,
|
||||
flat: (s.neutral_count / active) * 100,
|
||||
down: (s.down_count / active) * 100,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 市场情绪(统计 + 分布 + 指数上下文合成四维雷达) ───────────────────────
|
||||
|
||||
/** 上证/深证指数近 60 日 K(量能与趋势维的数据源)。 */
|
||||
const idxBars = ref<{ sh: Bar[]; sz: Bar[] }>({ sh: [], sz: [] })
|
||||
|
||||
async function loadIndexContext() {
|
||||
try {
|
||||
const [sh, sz] = await Promise.all([
|
||||
fetchIndexBars('SH', '000001', 60),
|
||||
fetchIndexBars('SZ', '399001', 60),
|
||||
])
|
||||
idxBars.value = { sh, sz }
|
||||
} catch {
|
||||
// 指数 K 线失败静默,雷达相关维退化为中性 50
|
||||
}
|
||||
}
|
||||
|
||||
/** 沪深两市今日/昨日成交额(两指数 amount 之和)。 */
|
||||
const amountToday = computed(() => {
|
||||
const { sh, sz } = idxBars.value
|
||||
return (Number(sh.at(-1)?.amount ?? 0)) + (Number(sz.at(-1)?.amount ?? 0))
|
||||
})
|
||||
const amountYesterday = computed(() => {
|
||||
const { sh, sz } = idxBars.value
|
||||
return (Number(sh.at(-2)?.amount ?? 0)) + (Number(sz.at(-2)?.amount ?? 0))
|
||||
})
|
||||
|
||||
const amountPct = computed(() => {
|
||||
if (amountToday.value <= 0 || amountYesterday.value <= 0) return null
|
||||
return (amountToday.value / amountYesterday.value - 1) * 100
|
||||
})
|
||||
|
||||
/** 四维雷达分值(0~100,50 中性)。 */
|
||||
const radarValues = computed(() => {
|
||||
const s = stat.value
|
||||
// 赚钱效应:涨跌比 tanh 压缩到 0~100(1:1 → 50,4:1 → 90,1:4 → 10)
|
||||
let profit = 50
|
||||
if (s && s.down_count + s.up_count > 0) {
|
||||
const ratio = s.up_count / Math.max(s.down_count, 1)
|
||||
profit = 50 + Math.tanh(Math.log(ratio)) * 50
|
||||
}
|
||||
// 量能:较昨日成交额 ±50% 打满
|
||||
const ap = amountPct.value
|
||||
const volume = ap === null ? 50 : Math.min(100, Math.max(0, 50 + ap))
|
||||
// 动量:上证 5 日涨幅 ±5% 打满
|
||||
const sh = idxBars.value.sh
|
||||
let momentum = 50
|
||||
if (sh.length >= 6) {
|
||||
const ret5 = (sh.at(-1)!.close / sh.at(-6)!.close - 1) * 100
|
||||
momentum = Math.min(100, Math.max(0, 50 + ret5 * 10))
|
||||
}
|
||||
// 趋势:上证收盘相对 MA20 偏离 ±5% 打满
|
||||
let trend = 50
|
||||
if (sh.length >= 20) {
|
||||
const ma20 = sh.slice(-20).reduce((a, b) => a + b.close, 0) / 20
|
||||
trend = Math.min(100, Math.max(0, 50 + (sh.at(-1)!.close / ma20 - 1) * 1000))
|
||||
}
|
||||
return { profit, volume, momentum, trend }
|
||||
})
|
||||
|
||||
/** 综合分(四维均值,保留 1 位)。 */
|
||||
const moodScore = computed(() => {
|
||||
const v = radarValues.value
|
||||
return Math.round(((v.profit + v.volume + v.momentum + v.trend) / 4) * 10) / 10
|
||||
})
|
||||
|
||||
function moodLabel(score: number): string {
|
||||
if (score >= 70) return '亢奋'
|
||||
if (score >= 57) return '偏暖'
|
||||
if (score >= 43) return '均衡'
|
||||
if (score >= 30) return '偏冷'
|
||||
return '冰点'
|
||||
}
|
||||
|
||||
// ── 涨跌分布直方图(全市场懒加载,120s) ────────────────────────────────────
|
||||
|
||||
const BUCKETS = [
|
||||
'≤-10',
|
||||
...Array.from({ length: 20 }, (_, i) => {
|
||||
const v = -10 + i
|
||||
return v === 0 ? '0' : `${v > 0 ? '+' : ''}${v}`
|
||||
}),
|
||||
'≥10',
|
||||
]
|
||||
|
||||
interface DistData {
|
||||
counts: number[]
|
||||
gt5: number // 涨超 5%(不含涨停也计入)
|
||||
lt5: number // 跌超 5%
|
||||
total: number
|
||||
}
|
||||
|
||||
const dist = ref<DistData | null>(null)
|
||||
const distLoading = ref(false)
|
||||
const distError = ref('')
|
||||
/** 今日涨幅 ≥9.8% 的名单(涨停/触板观察,含 20cm 品种)。 */
|
||||
const limitRows = ref<RankRow[]>([])
|
||||
|
||||
async function loadDist() {
|
||||
distLoading.value = true
|
||||
distError.value = ''
|
||||
try {
|
||||
// 接口单向上限 5000:DESC+ASC 各拉 3000 覆盖两端(全 A 约 5400 只),
|
||||
// 按 code 去重合并,避免单向截断丢掉分布另一端的尾部
|
||||
const [top, bottom] = await Promise.all([fetchRankList('DESC', 3000), fetchRankList('ASC', 3000)])
|
||||
const seen = new Set<string>()
|
||||
const rows: RankRow[] = []
|
||||
for (const r of [...top, ...bottom]) {
|
||||
const key = String(r.code ?? '')
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
rows.push(r)
|
||||
}
|
||||
const counts = new Array(BUCKETS.length).fill(0)
|
||||
let gt5 = 0
|
||||
let lt5 = 0
|
||||
for (const r of rows) {
|
||||
const pct = Number(r.change_pct ?? 0)
|
||||
if (!Number.isFinite(pct)) continue
|
||||
if (pct > 5) gt5++
|
||||
if (pct < -5) lt5++
|
||||
// 桶布局:[0]='≤-10',[1..10]='-10'..'-1',[11]='0',[12..20]='+1'..'+9',[21]='≥10'
|
||||
// 统一公式 idx = 11 + floor(pct),两端 clamp
|
||||
let idx = 11 + Math.floor(pct)
|
||||
if (pct <= -10) idx = 0
|
||||
if (pct >= 10) idx = BUCKETS.length - 1
|
||||
counts[Math.max(0, Math.min(BUCKETS.length - 1, idx))]++
|
||||
}
|
||||
dist.value = { counts, gt5, lt5, total: rows.length }
|
||||
// 涨停雷达名单:涨幅 ≥9.8%(主板涨停 10%、创业/科创 20% 都会覆盖)
|
||||
limitRows.value = rows
|
||||
.filter((r) => Number(r.change_pct ?? 0) >= 9.8)
|
||||
.slice(0, 15)
|
||||
} catch (e) {
|
||||
distError.value = formatError(e)
|
||||
} finally {
|
||||
distLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 涨跌分布 hover 浮窗:跟随鼠标,超界自动贴边。 */
|
||||
const hoverBucket = ref(-1)
|
||||
const popX = ref(0)
|
||||
const popY = ref(0)
|
||||
const distChartEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDistMove(e: MouseEvent) {
|
||||
const host = distChartEl.value
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
// 浮窗约 110px 宽 56px 高:先按鼠标右上角偏移,再 clamp 到容器内
|
||||
popX.value = Math.min(Math.max(e.clientX - rect.left + 14, 0), rect.width - 110)
|
||||
popY.value = Math.min(Math.max(e.clientY - rect.top - 64, 0), rect.height - 56)
|
||||
}
|
||||
|
||||
/** 桶区间文案(如 "-5% ~ -4%";两端为 ≤-10% / ≥+10%)。 */
|
||||
function bucketRange(i: number): string {
|
||||
if (i === 0) return '≤ -10%'
|
||||
if (i === BUCKETS.length - 1) return '≥ +10%'
|
||||
const v = -10 + (i - 1) // '-10'..'+9' 桶对应 v..v+1
|
||||
if (v === 0) return '0 ~ +1%'
|
||||
const fmt = (x: number) => (x > 0 ? `+${x}%` : `${x}%`)
|
||||
return `${fmt(v)} ~ ${fmt(v + 1)}`
|
||||
}
|
||||
|
||||
/** 桶内家数占比。 */
|
||||
function bucketShare(count: number): string {
|
||||
const total = dist.value?.total ?? 0
|
||||
return total > 0 ? `${((count / total) * 100).toFixed(1)}%` : '-'
|
||||
}
|
||||
|
||||
/** 分布柱高(相对最大桶)。 */
|
||||
function distHeight(count: number): string {
|
||||
const max = Math.max(...(dist.value?.counts ?? [1]), 1)
|
||||
return `${Math.max(2, (count / max) * 100)}%`
|
||||
}
|
||||
|
||||
function distColor(i: number): string {
|
||||
// 桶 1..20 对应 -10..+9:前 10 绿(跌),后 10 红(涨);两端按方向
|
||||
if (i === 0) return 'var(--down)'
|
||||
if (i === BUCKETS.length - 1) return 'var(--up)'
|
||||
return i <= 10 ? 'var(--down)' : 'var(--up)'
|
||||
}
|
||||
|
||||
// ── 板块热度 + 冰冷(一次拉 120 个,前端切热/冷两端) ───────────────────────
|
||||
|
||||
const industryBoards = ref<BoardRow[]>([])
|
||||
const conceptBoards = ref<BoardRow[]>([])
|
||||
const boardsError = ref('')
|
||||
|
||||
/** 热榜(涨幅前 8)。 */
|
||||
function hotBoards(list: BoardRow[]): BoardRow[] {
|
||||
return list.slice(0, 8)
|
||||
}
|
||||
|
||||
/** 冷榜(跌幅前 8,倒回正序显示)。 */
|
||||
function coldBoards(list: BoardRow[]): BoardRow[] {
|
||||
return list.slice(-8).reverse()
|
||||
}
|
||||
|
||||
async function loadBoards() {
|
||||
boardsError.value = ''
|
||||
try {
|
||||
const [hy, gn] = await Promise.all([fetchBoards('HY', 120), fetchBoards('GN', 120)])
|
||||
industryBoards.value = hy
|
||||
conceptBoards.value = gn
|
||||
} catch (e) {
|
||||
boardsError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
function boardPct(b: BoardRow): number {
|
||||
return Number(b.change_pct ?? 0)
|
||||
}
|
||||
|
||||
function barWidth(pct: number, list: BoardRow[]): string {
|
||||
const maxAbs = Math.max(...list.map(boardPct), 0.5)
|
||||
return `${Math.min(100, (Math.abs(pct) / maxAbs) * 100)}%`
|
||||
}
|
||||
|
||||
// ── 异动事件流(轮询 60s) ───────────────────────────────────────────────────
|
||||
|
||||
interface UnusualRow {
|
||||
time: string
|
||||
code: string
|
||||
name: string
|
||||
desc: string
|
||||
value: string
|
||||
market: string
|
||||
}
|
||||
|
||||
const unusualRows = ref<UnusualRow[]>([])
|
||||
const unusualError = ref('')
|
||||
|
||||
function normalizeUnusual(rows: Record<string, unknown>[], market: 'SH' | 'SZ'): UnusualRow[] {
|
||||
return rows.map((r) => ({
|
||||
time: String(r.time ?? '').slice(0, 8),
|
||||
code: String(r.code ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
desc: String(r.desc ?? ''),
|
||||
value: String(r.value ?? ''),
|
||||
market,
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadUnusual() {
|
||||
unusualError.value = ''
|
||||
try {
|
||||
const [sh, sz] = await Promise.all([fetchUnusual('SH', 60), fetchUnusual('SZ', 60)])
|
||||
const merged = [...normalizeUnusual(sh, 'SH'), ...normalizeUnusual(sz, 'SZ')]
|
||||
merged.sort((a, b) => (a.time < b.time ? 1 : a.time > b.time ? -1 : 0))
|
||||
unusualRows.value = merged.slice(0, 80)
|
||||
} catch (e) {
|
||||
unusualError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 排行榜(涨幅/跌幅/成交额/换手 四 tab,轮询 60s) ───────────────────────
|
||||
|
||||
const gainers = ref<RankRow[]>([])
|
||||
const losers = ref<RankRow[]>([])
|
||||
const hotAmount = ref<RankRow[]>([])
|
||||
const hotTurnover = ref<RankRow[]>([])
|
||||
const rankError = ref('')
|
||||
const rankTab = ref<'gain' | 'loss' | 'amount' | 'turnover'>('gain')
|
||||
|
||||
const RANK_TABS: Array<{ value: 'gain' | 'loss' | 'amount' | 'turnover'; label: string }> = [
|
||||
{ value: 'gain', label: '涨幅' },
|
||||
{ value: 'loss', label: '跌幅' },
|
||||
{ value: 'amount', label: '成交额' },
|
||||
{ value: 'turnover', label: '换手' },
|
||||
]
|
||||
|
||||
const activeRankRows = computed<RankRow[]>(() => {
|
||||
switch (rankTab.value) {
|
||||
case 'loss':
|
||||
return losers.value
|
||||
case 'amount':
|
||||
return hotAmount.value
|
||||
case 'turnover':
|
||||
return hotTurnover.value
|
||||
default:
|
||||
return gainers.value
|
||||
}
|
||||
})
|
||||
|
||||
async function loadRanks() {
|
||||
rankError.value = ''
|
||||
try {
|
||||
const [top, bottom, byAmount, byTurnover] = await Promise.all([
|
||||
fetchRankList('DESC', 12),
|
||||
fetchRankList('ASC', 12),
|
||||
fetchRankList('DESC', 12, 'TOTAL_AMOUNT'),
|
||||
fetchRankList('DESC', 12, 'TURNOVER_RATE'),
|
||||
])
|
||||
gainers.value = top
|
||||
losers.value = bottom
|
||||
hotAmount.value = byAmount
|
||||
hotTurnover.value = byTurnover
|
||||
} catch (e) {
|
||||
rankError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 排行榜第三列内容:涨幅榜显示成交额,其余显示涨跌幅。 */
|
||||
function rankExtra(r: RankRow): { text: string; cls: string } {
|
||||
if (rankTab.value === 'gain' || rankTab.value === 'loss') {
|
||||
return {
|
||||
text: fmtAmount(Number(r.amount ?? 0)),
|
||||
cls: 'dim',
|
||||
}
|
||||
}
|
||||
const pct = Number(r.change_pct ?? 0)
|
||||
return { text: fmtPctSigned(pct), cls: dirClass(pct) }
|
||||
}
|
||||
|
||||
// ── 轮询调度 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let statTimer = 0
|
||||
let slowTimer = 0
|
||||
let distTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
loadStat()
|
||||
loadBoards()
|
||||
loadUnusual()
|
||||
loadRanks()
|
||||
loadIdxSparks()
|
||||
loadIndexContext()
|
||||
loadDist()
|
||||
statTimer = window.setInterval(loadStat, 30_000)
|
||||
slowTimer = window.setInterval(() => {
|
||||
loadUnusual()
|
||||
loadRanks()
|
||||
loadBoards()
|
||||
loadIdxSparks()
|
||||
loadIndexContext()
|
||||
}, 60_000)
|
||||
distTimer = window.setInterval(loadDist, 120_000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(statTimer)
|
||||
window.clearInterval(slowTimer)
|
||||
window.clearInterval(distTimer)
|
||||
})
|
||||
|
||||
// ── 弹窗(个股 / 板块) ──────────────────────────────────────────────────────
|
||||
|
||||
const dialog = ref<{ market: string; code: string; name: string } | null>(null)
|
||||
const boardDialog = ref<{ code: string; name: string } | null>(null)
|
||||
|
||||
function openDialog(code: string, name: string, marketHint?: string) {
|
||||
if (!code) return
|
||||
const mkt = marketHint ?? (/^(6|9|5)/.test(code) ? 'SH' : /^(4|8|92|43)/.test(code) ? 'BJ' : 'SZ')
|
||||
dialog.value = { market: mkt, code, name }
|
||||
}
|
||||
|
||||
/** 板块行点击 → 板块详情弹窗。 */
|
||||
function openBoard(code: string | undefined, name: string | undefined) {
|
||||
if (!code) return
|
||||
boardDialog.value = { code: String(code), name: String(name ?? code) }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash">
|
||||
<!-- 指数条(内嵌迷你分时) -->
|
||||
<div class="idx-row">
|
||||
<div
|
||||
v-for="idx in INDEXES"
|
||||
:key="idx.symbol"
|
||||
class="idx-card"
|
||||
@click="openDialog(idx.symbol.slice(2), idx.name, idx.symbol.slice(0, 2))"
|
||||
>
|
||||
<div class="idx-top">
|
||||
<span class="idx-name">{{ idx.name }}</span>
|
||||
<span class="idx-chg mono" :class="dirClass(idxPct(idx.symbol))">
|
||||
{{ fmtPctSigned(idxPct(idx.symbol)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="idx-mid">
|
||||
<span class="idx-price mono" :class="dirClass(idxPct(idx.symbol))">
|
||||
{{ idxQuote(idx.symbol)?.price?.toFixed(2) ?? '—' }}
|
||||
</span>
|
||||
<span class="idx-amt mono dim">
|
||||
{{ fmtAmount(idxQuote(idx.symbol)?.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline
|
||||
:prices="idxSparks.get(idx.symbol) ?? []"
|
||||
:base="idxSparkBase.get(idx.symbol) ?? null"
|
||||
:width="150"
|
||||
:height="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- 市场统计 -->
|
||||
<div class="card">
|
||||
<h3>市场统计</h3>
|
||||
<div v-if="statError" class="err">{{ statError }}</div>
|
||||
<template v-else-if="stat && breadth">
|
||||
<div class="breadth-bar">
|
||||
<div class="seg up" :style="{ width: `${breadth.up}%` }"></div>
|
||||
<div class="seg flat" :style="{ width: `${breadth.flat}%` }"></div>
|
||||
<div class="seg down" :style="{ width: `${breadth.down}%` }"></div>
|
||||
</div>
|
||||
<div class="stat-nums">
|
||||
<span class="up">涨 {{ stat.up_count }}</span>
|
||||
<span class="flat">平 {{ stat.neutral_count }}</span>
|
||||
<span class="down">跌 {{ stat.down_count }}</span>
|
||||
<span class="dim">停 {{ stat.suspended_count }}</span>
|
||||
</div>
|
||||
<div class="stat-rows mono">
|
||||
<div><span class="dim">涨停</span><span class="up">{{ stat.limit_up_count }}</span></div>
|
||||
<div><span class="dim">跌停</span><span class="down">{{ stat.limit_down_count }}</span></div>
|
||||
<div><span class="dim">总成交</span><span>{{ fmtAmount(stat.total_amount) }}</span></div>
|
||||
<div><span class="dim">总市值</span><span>{{ fmtAmount(stat.total_market_cap) }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="loading">加载中…</div>
|
||||
</div>
|
||||
|
||||
<!-- 市场情绪雷达 -->
|
||||
<div class="card">
|
||||
<h3>市场情绪 <span class="mood-score mono">{{ moodScore }}</span></h3>
|
||||
<MoodRadar :values="radarValues" />
|
||||
<div class="mood-foot">
|
||||
<span class="mood-word">{{ moodLabel(moodScore) }}</span>
|
||||
<span class="dim mono">量能 {{ fmtPctSigned(amountPct) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 涨跌分布直方图 -->
|
||||
<div class="card dist-card">
|
||||
<h3>涨跌分布 <span class="dim title-sub">(全市场 {{ dist?.total ?? '…' }} 只)</span></h3>
|
||||
<div v-if="distError" class="err">{{ distError }}</div>
|
||||
<div v-else-if="dist" ref="distChartEl" class="dist-chart" @mousemove="onDistMove" @mouseleave="hoverBucket = -1">
|
||||
<div
|
||||
v-for="(count, i) in dist.counts"
|
||||
:key="i"
|
||||
class="dist-col"
|
||||
:class="{ zero: BUCKETS[i] === '0', hovered: hoverBucket === i }"
|
||||
@mouseenter="hoverBucket = i"
|
||||
>
|
||||
<div class="dist-bar" :style="{ height: distHeight(count), background: distColor(i) }"></div>
|
||||
<div class="dist-label">{{ BUCKETS[i] }}</div>
|
||||
</div>
|
||||
<div v-if="hoverBucket >= 0 && dist" class="dist-pop" :style="{ left: `${popX}px`, top: `${popY}px` }">
|
||||
<div class="pop-range">{{ bucketRange(hoverBucket) }}</div>
|
||||
<div class="pop-count mono">{{ dist.counts[hoverBucket] }} 只</div>
|
||||
<div class="pop-share mono">占 {{ bucketShare(dist.counts[hoverBucket]) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading">{{ distLoading ? '全市场分布计算中…(约 5s)' : '加载中…' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 行业板块(热 + 冷) -->
|
||||
<div class="card board-card">
|
||||
<h3>行业板块 <span class="dim title-sub">热</span></h3>
|
||||
<div v-if="boardsError" class="err">{{ boardsError }}</div>
|
||||
<div v-else class="board-list">
|
||||
<div
|
||||
v-for="b in hotBoards(industryBoards)"
|
||||
:key="b.code"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), industryBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="cold-title">冷</h3>
|
||||
<div v-if="!boardsError" class="board-list">
|
||||
<div
|
||||
v-for="b in coldBoards(industryBoards)"
|
||||
:key="`c${b.code}`"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), industryBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概念板块(热 + 冷) -->
|
||||
<div class="card board-card">
|
||||
<h3>概念板块 <span class="dim title-sub">热</span></h3>
|
||||
<div v-if="boardsError" class="err">{{ boardsError }}</div>
|
||||
<div v-else class="board-list">
|
||||
<div
|
||||
v-for="b in hotBoards(conceptBoards)"
|
||||
:key="b.code"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), conceptBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="cold-title">冷</h3>
|
||||
<div v-if="!boardsError" class="board-list">
|
||||
<div
|
||||
v-for="b in coldBoards(conceptBoards)"
|
||||
:key="`c${b.code}`"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), conceptBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排行榜(四 tab) -->
|
||||
<div class="card">
|
||||
<h3>
|
||||
排行榜
|
||||
<span class="rank-tabs">
|
||||
<button
|
||||
v-for="t in RANK_TABS"
|
||||
:key="t.value"
|
||||
class="rank-tab"
|
||||
:class="{ on: rankTab === t.value }"
|
||||
@click="rankTab = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="rankError" class="err">{{ rankError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in activeRankRows" :key="`${rankTab}${i}`" @click="openDialog(String(r.code ?? ''), String(r.name ?? ''), r.market ? String(r.market) : undefined)">
|
||||
<td>{{ i + 1 }}. {{ r.name }}</td>
|
||||
<td class="mono dim">{{ fmtAmount(Number(r.amount ?? 0)) }}</td>
|
||||
<td class="mono" :class="rankExtra(r).cls">{{ rankExtra(r).text }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 涨停雷达 -->
|
||||
<div class="card limit-card">
|
||||
<h3>
|
||||
涨停雷达
|
||||
<span class="limit-n mono">{{ limitRows.length }}+</span>
|
||||
<span class="dim title-sub">(≥9.8%)</span>
|
||||
</h3>
|
||||
<div v-if="distError" class="err">{{ distError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in limitRows" :key="`lu${i}`" @click="openDialog(String(r.code ?? ''), String(r.name ?? ''), r.market ? String(r.market) : undefined)">
|
||||
<td>{{ i + 1 }}. {{ r.name }}</td>
|
||||
<td class="mono dim">{{ fmtAmount(Number(r.amount ?? 0)) }}</td>
|
||||
<td class="mono up">{{ fmtPctSigned(r.change_pct) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!distError && limitRows.length === 0" class="loading">
|
||||
{{ distLoading ? '全市场扫描中…' : '今日暂无 ≥9.8% 个股' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 异动事件流 -->
|
||||
<div class="card unusual-card">
|
||||
<h3>异动雷达</h3>
|
||||
<div v-if="unusualError" class="err">{{ unusualError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th style="text-align: left">股票</th>
|
||||
<th style="text-align: left">异动</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in unusualRows" :key="i" @click="openDialog(r.code, r.name, r.market)">
|
||||
<td class="dim">{{ r.time }}</td>
|
||||
<td style="text-align: left">{{ r.name }}</td>
|
||||
<td style="text-align: left"><span class="tag">{{ r.desc }}</span></td>
|
||||
<td class="mono">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockDialog
|
||||
v-if="dialog"
|
||||
:market="dialog.market"
|
||||
:code="dialog.code"
|
||||
:name="dialog.name"
|
||||
@close="dialog = null"
|
||||
/>
|
||||
<BoardDialog
|
||||
v-if="boardDialog"
|
||||
:code="boardDialog.code"
|
||||
:name="boardDialog.name"
|
||||
@close="boardDialog = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.idx-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.idx-card {
|
||||
flex: 1;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.idx-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.idx-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.idx-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.idx-chg {
|
||||
font-size: 12px;
|
||||
}
|
||||
.idx-mid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.idx-price {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.idx-amt {
|
||||
font-size: 11px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.unusual-card {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 320px;
|
||||
}
|
||||
.dist-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
/* 市场统计 */
|
||||
.breadth-bar {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.seg.up {
|
||||
background: var(--up);
|
||||
}
|
||||
.seg.flat {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.seg.down {
|
||||
background: var(--down);
|
||||
}
|
||||
.stat-nums {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.stat-rows {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat-rows > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 市场情绪雷达 */
|
||||
.mood-score {
|
||||
float: right;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.mood-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mood-word {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 排行榜 tab */
|
||||
.rank-tabs {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.rank-tab {
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rank-tab.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
/* 涨停雷达 */
|
||||
.limit-n {
|
||||
float: right;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--up);
|
||||
}
|
||||
.limit-card {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 涨跌分布 */
|
||||
.title-sub {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dist-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 120px;
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.dist-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.dist-col:hover,
|
||||
.dist-col.hovered {
|
||||
z-index: 10;
|
||||
}
|
||||
.dist-pop {
|
||||
position: absolute;
|
||||
background: rgba(26, 29, 38, 0.97);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
z-index: 20;
|
||||
}
|
||||
.pop-range {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.pop-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pop-share {
|
||||
color: var(--text-dim);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.dist-bar {
|
||||
width: 100%;
|
||||
border-radius: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
.dist-col.zero {
|
||||
outline: 1px dashed var(--text-dim);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.dist-label {
|
||||
font-size: 8.5px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 3px;
|
||||
transform: rotate(-60deg);
|
||||
transform-origin: top center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 板块热度 */
|
||||
.board-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.board-row {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr 64px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.b-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.board-row.clickable:hover .b-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cold-title {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.b-bar-wrap {
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.b-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.b-bar.up {
|
||||
background: var(--up);
|
||||
}
|
||||
.b-bar.down {
|
||||
background: var(--down);
|
||||
}
|
||||
.b-pct {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 涨跌幅榜 */
|
||||
.unusual-card tr,
|
||||
.card table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 异动 */
|
||||
.tag {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--up);
|
||||
font-size: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.loading {
|
||||
color: var(--text-dim);
|
||||
padding: 12px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
// 参数寻优主页面:左配置(选标的 + 策略 + 寻优参数)/ 右报告(排名表 + 热力图)。
|
||||
// 取行情已整合进「开始寻优」。另有「一键寻优所有策略」:用各策略预设网格逐策略寻优再全局排名。
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import GradeBadge from '../components/GradeBadge.vue'
|
||||
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
|
||||
@@ -18,6 +18,7 @@ import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
const store = useBacktestStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// SymbolPicker 实例引用,用于触发取行情
|
||||
const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null)
|
||||
@@ -86,10 +87,22 @@ const selectedStrategy = computed(
|
||||
() => store.strategies.find((s) => s.name === strategy.value) ?? null,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
store.loadStrategies().catch((e) => {
|
||||
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
|
||||
})
|
||||
|
||||
// 个股弹窗「一键寻优」入口:/optimize?code=601088&autoAll=1
|
||||
// 填入代码后自动触发「一键寻优所有策略」(等 nextTick 让 SymbolPicker
|
||||
// 的 v-model 同步,onRunAll 里的 loadBars 才会取到正确标的)。
|
||||
const qCode = String(route.query.code ?? '')
|
||||
if (route.query.autoAll === '1' && /^\d{6}$/.test(qCode)) {
|
||||
code.value = qCode
|
||||
await nextTick()
|
||||
if (!store.optimizeRunning && !store.optimizeAllRunning) {
|
||||
onRunAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 网格点数(前端预校验,提示用户)
|
||||
@@ -172,12 +185,12 @@ function buildBacktestQuery(strategyName: string, params: Record<string, number
|
||||
// 点击排名表「查看」→ 跳转单标的页用该参数回测
|
||||
function onViewParams(params: Record<string, number | string>) {
|
||||
// 通过 query 传递参数,单标的页接收后自动填充
|
||||
router.push({ path: '/', query: buildBacktestQuery(strategy.value, params) })
|
||||
router.push({ path: '/backtest', query: buildBacktestQuery(strategy.value, params) })
|
||||
}
|
||||
|
||||
// 一键寻优结果点击「查看」→ 跳转单标的页用该策略 + 参数回测
|
||||
function onViewAll(strategyName: string, params: Record<string, number | string>) {
|
||||
router.push({ path: '/', query: buildBacktestQuery(strategyName, params) })
|
||||
router.push({ path: '/backtest', query: buildBacktestQuery(strategyName, params) })
|
||||
}
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
|
||||
@@ -126,7 +126,7 @@ function signalSeq(r: SignalScanRow): string {
|
||||
function onLoad(r: SignalScanRow) {
|
||||
const codeOnly = r.symbol.includes(':') ? r.symbol.split(':').pop()! : r.symbol
|
||||
router.push({
|
||||
path: '/',
|
||||
path: '/backtest',
|
||||
query: {
|
||||
strategy: r.strategy,
|
||||
params: JSON.stringify(r.params),
|
||||
|
||||
@@ -256,7 +256,7 @@ function onLoad(s: SavedStrategy) {
|
||||
const rawSymbol = (ctx.symbol as string) || ''
|
||||
const codeOnly = rawSymbol.includes(':') ? rawSymbol.split(':').pop()! : rawSymbol
|
||||
router.push({
|
||||
path: '/',
|
||||
path: '/backtest',
|
||||
query: {
|
||||
strategy: s.strategy,
|
||||
params,
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
// 自选行情:SSE 实时表格 + 行内迷你分时 + 一键加删 + 点击开个股对话框。
|
||||
// 迷你分时按需懒加载(行可见时才拉 /minute,60s 重拉)——这里简化为
|
||||
// 打开页面时批量拉前 N 只(80/批上限内),足够 MVP。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchMinute,
|
||||
fetchQuotes,
|
||||
fetchSymbolName,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import StockDialog from '../components/StockDialog.vue'
|
||||
import BoardDialog from '../components/BoardDialog.vue'
|
||||
import Sparkline from '../components/Sparkline.vue'
|
||||
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
|
||||
import { detectMarket } from '../market'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { WatchItem } from '../types'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
|
||||
/** 板块指数(881/885/880 开头)走板块弹窗,其余走个股弹窗。 */
|
||||
function isBoardCode(code: string): boolean {
|
||||
return /^88\d/.test(code)
|
||||
}
|
||||
|
||||
const items = ref<WatchItem[]>([])
|
||||
const listError = ref('')
|
||||
const adding = ref(false)
|
||||
const addCode = ref('')
|
||||
const addName = ref('')
|
||||
|
||||
// ── 列表加载 ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadList() {
|
||||
listError.value = ''
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
items.value = resp.items
|
||||
loadSparks()
|
||||
restFallback()
|
||||
fillMissingNames()
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 给历史遗留的"只有代码没有名称"的自选项补中文名(幂等写回存储)。 */
|
||||
async function fillMissingNames() {
|
||||
const missing = items.value.filter((i) => !i.name)
|
||||
for (const it of missing) {
|
||||
const name = await fetchSymbolName(it.market, it.code)
|
||||
if (name) {
|
||||
it.name = name
|
||||
try {
|
||||
await addWatchItem(it.market, it.code, name)
|
||||
} catch {
|
||||
// 写回失败仅影响下次加载,静默
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 行情(SSE 快照 + REST 首次兜底) ────────────────────────────────────────
|
||||
|
||||
/** SSE 未覆盖时(自选刚加、服务重启间隙)用 REST 主动拉一次。 */
|
||||
async function restFallback() {
|
||||
if (items.value.length === 0) return
|
||||
const missing = items.value.filter((i) => !quoteStore.getQuote(i.symbol))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
await fetchQuotes(missing.map((i) => ({ market: i.market, code: i.code })))
|
||||
} catch {
|
||||
// SSE 会补上,静默
|
||||
}
|
||||
}
|
||||
|
||||
function q(item: WatchItem) {
|
||||
return quoteStore.getQuote(item.symbol)
|
||||
}
|
||||
|
||||
function pct(item: WatchItem): number | null {
|
||||
const qq = q(item)
|
||||
if (!qq?.price || !qq.pre_close) return null
|
||||
return (qq.price / qq.pre_close - 1) * 100
|
||||
}
|
||||
|
||||
// ── 迷你分时 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const sparks = ref(new Map<string, number[]>())
|
||||
const sparkBase = ref(new Map<string, number>())
|
||||
|
||||
async function loadSparks() {
|
||||
const targets = items.value.slice(0, 80)
|
||||
for (const it of targets) {
|
||||
if (sparks.value.has(it.symbol)) continue
|
||||
try {
|
||||
const pts = await fetchMinute(it.market, it.code)
|
||||
if (pts.length > 0) {
|
||||
sparks.value.set(it.symbol, pts.map((p) => p.price))
|
||||
const qq = q(it)
|
||||
sparkBase.value.set(it.symbol, qq?.pre_close ?? pts[0].price)
|
||||
}
|
||||
} catch {
|
||||
// 单只失败不影响整表
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sparkTimer = 0
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
sparkTimer = window.setInterval(loadSparks, 60_000)
|
||||
})
|
||||
onBeforeUnmount(() => window.clearInterval(sparkTimer))
|
||||
|
||||
// ── 加/删自选 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function add() {
|
||||
const code = addCode.value.trim()
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
listError.value = '请输入 6 位数字代码'
|
||||
return
|
||||
}
|
||||
const market = detectMarket(code)
|
||||
adding.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
// 名称优先级:用户备注 > 证券名称接口(五档行情协议本身不带名称)。
|
||||
// 名称取不到不阻断(BJ 等市场 MAC 协议可能不支持),退回显示代码。
|
||||
let name = addName.value.trim()
|
||||
if (!name) name = await fetchSymbolName(market, code)
|
||||
try {
|
||||
const quotes = await fetchQuotes([{ market, code }])
|
||||
const qq = quotes[0]
|
||||
if (qq && qq.price == null) {
|
||||
listError.value = `提示:${market}${code} 暂无行情返回,仍已加入自选`
|
||||
} else if (qq && Number.isFinite(qq.vol) && qq.vol === 0 && qq.amount === 0) {
|
||||
listError.value = `提示:${market}${code} 今日无成交(停牌或非交易日),仍已加入自选`
|
||||
}
|
||||
} catch {
|
||||
listError.value = `提示:行情校验不可用,${market}${code} 仍已加入自选`
|
||||
}
|
||||
await addWatchItem(market, code, name)
|
||||
addCode.value = ''
|
||||
addName.value = ''
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: WatchItem) {
|
||||
listError.value = ''
|
||||
try {
|
||||
await removeWatchItem(item.market, item.code)
|
||||
items.value = items.value.filter((i) => i.symbol !== item.symbol)
|
||||
sparks.value.delete(item.symbol)
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 弹窗(个股 / 板块分流) ─────────────────────────────────────────────────
|
||||
|
||||
const dialog = ref<WatchItem | null>(null)
|
||||
const boardDlg = ref<WatchItem | null>(null)
|
||||
|
||||
function openItem(item: WatchItem) {
|
||||
if (isBoardCode(item.code)) boardDlg.value = item
|
||||
else dialog.value = item
|
||||
}
|
||||
|
||||
const emptyHint = computed(() =>
|
||||
items.value.length === 0 && !listError.value
|
||||
? '自选为空:输入 6 位代码加入(如 600519)。加入后行情实时推送。'
|
||||
: '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wl">
|
||||
<div class="toolbar">
|
||||
<h2>自选行情</h2>
|
||||
<div class="add-row">
|
||||
<input v-model="addCode" class="code-input" placeholder="6 位代码" maxlength="6" @keyup.enter="add" />
|
||||
<input v-model="addName" class="name-input" placeholder="备注名(可空)" maxlength="16" @keyup.enter="add" />
|
||||
<button class="primary" :disabled="adding" @click="add">{{ adding ? '加入中…' : '加入自选' }}</button>
|
||||
</div>
|
||||
<span class="hint">共 {{ items.length }} 只 · 行情实时推送</span>
|
||||
</div>
|
||||
|
||||
<div v-if="listError" class="err">{{ listError }}</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="qtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>现价</th>
|
||||
<th>涨跌幅</th>
|
||||
<th>涨跌额</th>
|
||||
<th>成交量</th>
|
||||
<th>成交额</th>
|
||||
<th>最高</th>
|
||||
<th>最低</th>
|
||||
<th>今开</th>
|
||||
<th>昨收</th>
|
||||
<th>分时</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="emptyHint" class="empty-row">
|
||||
<td colspan="12">{{ emptyHint }}</td>
|
||||
</tr>
|
||||
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
|
||||
<td>
|
||||
<div class="cell-name">{{ item.name || item.code }}</div>
|
||||
<div class="cell-code mono dim">{{ item.symbol }}</div>
|
||||
</td>
|
||||
<td class="big" :class="dirClass(pct(item))">{{ fmt2(q(item)?.price) }}</td>
|
||||
<td :class="dirClass(pct(item))">{{ fmtPctSigned(pct(item)) }}</td>
|
||||
<td :class="dirClass(pct(item))">
|
||||
{{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}
|
||||
</td>
|
||||
<td>{{ fmtVol(q(item)?.vol) }}</td>
|
||||
<td>{{ fmtAmount(q(item)?.amount) }}</td>
|
||||
<td>{{ fmt2(q(item)?.high) }}</td>
|
||||
<td>{{ fmt2(q(item)?.low) }}</td>
|
||||
<td>{{ fmt2(q(item)?.open) }}</td>
|
||||
<td class="dim">{{ fmt2(q(item)?.pre_close) }}</td>
|
||||
<td>
|
||||
<Sparkline :prices="sparks.get(item.symbol) ?? []" :base="sparkBase.get(item.symbol) ?? null" />
|
||||
</td>
|
||||
<td>
|
||||
<button class="del" @click.stop="remove(item)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<StockDialog
|
||||
v-if="dialog"
|
||||
:market="dialog.market"
|
||||
:code="dialog.code"
|
||||
:name="dialog.name"
|
||||
@close="dialog = null"
|
||||
@watchlist-changed="loadList"
|
||||
/>
|
||||
<BoardDialog
|
||||
v-if="boardDlg"
|
||||
:code="boardDlg.code"
|
||||
:name="boardDlg.name || boardDlg.code"
|
||||
@close="boardDlg = null"
|
||||
@watchlist-changed="loadList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wl {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-input {
|
||||
width: 110px;
|
||||
}
|
||||
.name-input {
|
||||
width: 140px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
.err {
|
||||
color: var(--up);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.data-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.data-row:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-code {
|
||||
font-size: 11px;
|
||||
}
|
||||
.big {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.del {
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.del:hover {
|
||||
color: var(--up);
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user