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:
GitHub
2026-09-01 17:45:07 +08:00
parent 12d308e1a4
commit 33d44ba5d6
31 changed files with 4600 additions and 59 deletions
+103 -28
View File
@@ -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;
+228
View File
@@ -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 /quotesmarket 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)
}
+458
View File
@@ -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>
+223
View File
@@ -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>
+87
View File
@@ -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~10050 为中性)。 */
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>
+67
View File
@@ -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>
+520
View File
@@ -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>
+298
View File
@@ -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>
+6 -1
View File
@@ -2,12 +2,14 @@
// 用到的:candlestickK线)、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,
])
+30
View File
@@ -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'
}
+150
View File
@@ -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
}
+3
View File
@@ -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'
+8 -3
View File
@@ -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 },
+76
View File
@@ -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', () => {
/** symbolSH600000)→ 最新行情快照。 */
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 }
})
+56
View File
@@ -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;
}
+117
View File
@@ -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/listMAC 协议,防御式取列) ────
/** 板块行(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-listMAC 协议,防御式取列) ────
export interface RankRow {
code?: string
name?: string
price?: number
change_pct?: number
[key: string]: unknown
}
+995
View File
@@ -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~1001:1 → 504:1 → 901: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 {
// 接口单向上限 5000DESC+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>
+18 -5
View File
@@ -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 {
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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,
+347
View File
@@ -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>