feat: 盘面洞察第四批 — 板块相关性热力图 + 异动雷达 + 量能仪表盘

- 相关性 /hotspots 页内新增「相关性」视图:/board-mac/hotspot-correlation
  复用热点历史矩阵缓存,对窗口内活跃板块(每日前 per_day 名并集,按上榜次数
  取前 N)两两算日涨跌幅 Pearson 相关;红=同涨同跌(抱团)、绿=跷跷板(轮动),
  ECharts 热力图 + 双向色阶 visualMap;无缓存时透传 building 状态
- 异动雷达 /radar:沪深异动流时间线(封板/炸板/大笔买入/逼近涨停…),
  每分钟异动密度柱 + 类型筛选 chips(带计数),行点击直达个股弹窗,15s 轮询
- 量能仪表盘并入市场情绪页:两市(上证+深成 5 分钟线)累计成交额曲线
  vs 近 5 日同期均值(虚线),标题给出偏离百分比——放量/缩量一眼可辨
- 热点缓存判定重构为 _hotspot_history_or_build 公共入口,correlation 与
  hotspot 共用同一构建状态机;新增相关性矩阵回归单测
This commit is contained in:
Justin Gu
2026-09-05 04:09:03 +08:00
parent 37c732fb30
commit 0a205ffbf3
11 changed files with 782 additions and 26 deletions
+1
View File
@@ -32,6 +32,7 @@ const sseLabel: Record<string, string> = {
<RouterLink to="/calendar" active-class="active">大盘日历</RouterLink>
<RouterLink to="/limitup" active-class="active">涨停生态</RouterLink>
<RouterLink to="/sentiment" active-class="active">市场情绪</RouterLink>
<RouterLink to="/radar" active-class="active">异动雷达</RouterLink>
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
<div class="nav-group">分析</div>
+18
View File
@@ -12,6 +12,7 @@ import type {
CcpmProductsResponse,
CcpmRankResponse,
DataFrameResponse,
HotspotCorrelationResp,
HotspotResp,
LimitUpEcologyResp,
LimitUpHistoryRow,
@@ -847,6 +848,23 @@ export async function fetchLimitUpEcology(): Promise<LimitUpEcologyResp> {
return body.data
}
/** 热点板块相关性矩阵(复用热点历史缓存;未构建时返回 building/error)。 */
export async function fetchHotspotCorrelation(
boardType: string,
days: number,
perDay = 5,
): Promise<HotspotCorrelationResp> {
const params = new URLSearchParams({
board_type: boardType,
days: String(days),
per_day: String(perDay),
})
const resp = await fetch(`${BASE}/board-mac/hotspot-correlation?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as { data: HotspotCorrelationResp }
return body.data
}
/** 当日情绪分钟曲线(采样器逐分钟落库;date=0 表示尚无采样)。 */
export async function fetchSentimentToday(): Promise<SentimentTodayResp> {
const resp = await fetch(`${BASE}/market/sentiment/today`)
@@ -0,0 +1,154 @@
<script setup lang="ts">
// 热点板块相关性热力图:窗口内活跃板块两两日涨跌幅 Pearson 相关。
// 红 = 同涨同跌(抱团),绿 = 跷跷板(资金轮动换手)。复用热点历史缓存,秒级出图。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
import { fetchHotspotCorrelation, formatError } from '../api'
import type { HotspotCorrelationResp } from '../types'
const props = defineProps<{
boardType: 'HY' | 'GN' | 'FG'
days: number
perDay: number
}>()
const resp = ref<HotspotCorrelationResp | null>(null)
const error = ref('')
const loading = ref(false)
let poll: number | null = null
function stopPoll() {
if (poll !== null) {
window.clearInterval(poll)
poll = null
}
}
async function load() {
error.value = ''
try {
const r = await fetchHotspotCorrelation(props.boardType, props.days, props.perDay)
if (r.status === 'building') {
resp.value = null
loading.value = true
if (poll === null) poll = window.setInterval(load, 1500)
return
}
stopPoll()
loading.value = false
if (r.status === 'error') {
error.value = r.error || '相关性矩阵不可用'
return
}
resp.value = r
render()
} catch (e) {
stopPoll()
loading.value = false
error.value = formatError(e)
}
}
// ── 热力图渲染 ────────────────────────────────────────────────────────────────
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value || !resp.value?.boards || !resp.value.matrix) return
const boards = resp.value.boards
const matrix = resp.value.matrix
const names = boards.map((b) => b.name)
const data: Array<[number, number, number]> = []
matrix.forEach((row, i) =>
row.forEach((v, j) => {
if (v !== null) data.push([i, j, v])
}),
)
chart ??= echarts.init(container.value, 'dark')
chart.setOption(
{
backgroundColor: 'transparent',
tooltip: {
formatter: (p: { value: [number, number, number] }) => {
const [i, j, v] = p.value
return `${names[i]} × ${names[j]}<br/>相关系数 <b>${v.toFixed(2)}</b>`
},
},
grid: { left: 90, top: 10, bottom: 90, right: 20 },
xAxis: {
type: 'category',
data: names,
axisLabel: { rotate: 45, fontSize: 10, interval: 0 },
},
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 10 } },
visualMap: {
min: -1,
max: 1,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
text: ['同涨同跌', '跷跷板'],
textStyle: { fontSize: 10 },
inRange: { color: [DOWN_COLOR, '#1f2430', UP_COLOR] },
},
series: [
{
type: 'heatmap',
data,
label: { show: boards.length <= 12, fontSize: 9, formatter: (p: { value: [number, number, number] }) => p.value[2].toFixed(1) },
},
],
},
true,
)
}
function onResize() {
chart?.resize()
}
onMounted(() => {
load()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
stopPoll()
window.removeEventListener('resize', onResize)
chart?.dispose()
})
watch(
() => [props.boardType, props.days, props.perDay],
() => {
resp.value = null
load()
},
)
</script>
<template>
<div class="corr-wrap card">
<div v-if="loading" class="corr-hint dim">等待热点矩阵构建若行业矩阵已构建则为秒级</div>
<div v-else-if="error" class="corr-hint up">{{ error }}</div>
<div v-else-if="resp && (resp.boards?.length ?? 0) < 2" class="corr-hint dim">窗口内活跃板块不足 2 无法计算相关性</div>
<div v-else ref="container" class="corr-chart"></div>
</div>
</template>
<style scoped>
.corr-wrap {
padding: 8px;
}
.corr-chart {
height: 520px;
}
.corr-hint {
padding: 40px 0;
text-align: center;
font-size: 12px;
}
</style>
+3
View File
@@ -12,6 +12,7 @@ import LlmHistoryView from './views/LlmHistoryView.vue'
import LlmSettingsView from './views/LlmSettingsView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
import RadarView from './views/RadarView.vue'
import SentimentView from './views/SentimentView.vue'
import ServerSettingsView from './views/ServerSettingsView.vue'
import SignalRadarView from './views/SignalRadarView.vue'
@@ -38,6 +39,8 @@ const routes = [
{ path: '/limitup', name: 'limitup', component: LimitUpView },
// 市场情绪(宽度分时 + 涨停温度计;采样器盘中逐分钟积累)
{ path: '/sentiment', name: 'sentiment', component: SentimentView },
// 异动雷达(沪深异动流时间线:封板/炸板/大笔买入…)
{ path: '/radar', name: 'radar', component: RadarView },
{ path: '/backtest', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
{ path: '/optimize', name: 'optimize', component: OptimizeView },
+13
View File
@@ -687,6 +687,19 @@ export interface LimitUpEcologyResp {
blown: LimitUpEntry[]
}
// ── 热点板块相关性(GET /api/v1/board-mac/hotspot-correlation ──────────────
export interface HotspotCorrelationResp {
status: 'ready' | 'building' | 'error'
progress?: number
error?: string
/** 入阵板块(按上榜次数降序),matrix 行列与之对齐 */
boards?: Array<{ code: string; name: string; days_in: number }>
/** Pearson 相关系数矩阵(-1~1null = 样本不足) */
matrix?: Array<Array<number | null>>
days?: number
}
// ── 市场情绪(/market/sentiment/*,盘中逐分钟采样 + vipdoc 涨停史回补) ─────
export interface SentimentSample {
+13 -2
View File
@@ -6,6 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchBoardHotspot, formatError } from '../api'
import BoardDialog from '../components/BoardDialog.vue'
import HotspotCorrelation from '../components/HotspotCorrelation.vue'
import HotspotMatrix from '../components/HotspotMatrix.vue'
import HotspotStatStrip from '../components/HotspotStatStrip.vue'
import type { HotspotResp, HotspotRow } from '../types'
@@ -20,6 +21,7 @@ type SortKey = 'days_in' | 'sum_pct' | 'first_date'
const boardType = ref<'HY' | 'GN' | 'FG'>(props.boardType ?? 'HY')
const days = ref<number>(20)
const mode = ref<'top' | 'bottom'>('top')
const viewMode = ref<'matrix' | 'corr'>('matrix')
watch(
() => props.boardType,
@@ -227,6 +229,10 @@ function openBoard(r: HotspotRow) {
<button :class="{ on: mode === 'top' }" @click="setMode('top')">领涨</button>
<button :class="{ on: mode === 'bottom' }" @click="setMode('bottom')">领跌</button>
</span>
<span class="seg">
<button :class="{ on: viewMode === 'matrix' }" @click="viewMode = 'matrix'">热点矩阵</button>
<button :class="{ on: viewMode === 'corr' }" @click="viewMode = 'corr'">相关性</button>
</span>
<label class="tb-label">排序
<select v-model="sortKey">
<option value="days_in">上榜次数</option>
@@ -267,8 +273,8 @@ function openBoard(r: HotspotRow) {
<div v-else-if="loading" class="loading">加载中</div>
<!-- 主区统计卡 + 图例 + 矩阵 -->
<template v-else-if="resp">
<!-- 主区统计卡 + 图例 + 矩阵 / 相关性 -->
<template v-else-if="resp && viewMode === 'matrix'">
<HotspotStatStrip :rows="resp.rows ?? []" :dates="dates" :mode="mode" @select="openBoard" />
<div class="legend">
@@ -296,6 +302,11 @@ function openBoard(r: HotspotRow) {
</div>
</template>
<!-- 相关性视图活跃板块两两日涨跌幅相关系数 -->
<template v-else-if="viewMode === 'corr'">
<HotspotCorrelation :board-type="boardType" :days="days" :per-day="PER_DAY" />
</template>
<BoardDialog
v-if="boardDialog"
:code="boardDialog.code"
+306
View File
@@ -0,0 +1,306 @@
<script setup lang="ts">
// 异动雷达(/radar):沪深异动流时间线(封板/炸板/大笔买入/火箭发射…)
// + 每分钟异动密度柱。异动流为交易所最近交易日的盘中记录;15s 轮询、
// 交易时段外仅手动刷新;类型筛选 chips;单击行直达个股弹窗。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import echarts from '../echarts-setup'
import { fetchUnusual, formatError } from '../api'
import StockDialog from '../components/StockDialog.vue'
type UnusualRow = {
index: number
market: number
code: string
name: string
time: string
desc: string
value: string
unusual_type: number
mkt: string
}
const rows = ref<UnusualRow[]>([])
const loading = ref(false)
const error = ref('')
const lastRefresh = ref('')
const activeType = ref('全部')
let timer = 0
async function load() {
loading.value = rows.value.length === 0
error.value = ''
try {
const [sh, sz] = await Promise.all([fetchUnusual('SH', 300), fetchUnusual('SZ', 300)])
rows.value = [...sh, ...sz]
.map((r) => ({ ...(r as unknown as UnusualRow), mkt: Number(r.market) === 1 ? 'SH' : 'SZ' }))
.sort((a, b) => b.time.localeCompare(a.time))
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
renderDensity()
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
const types = computed(() => {
const count = new Map<string, number>()
for (const r of rows.value) count.set(r.desc, (count.get(r.desc) ?? 0) + 1)
return ['全部', ...[...count.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)]
})
const filtered = computed(() =>
activeType.value === '全部' ? rows.value : rows.value.filter((r) => r.desc === activeType.value),
)
function typeCount(t: string): number {
return t === '全部' ? rows.value.length : (rows.value.filter((r) => r.desc === t).length ?? 0)
}
// ── 密度图(每分钟异动条数) ─────────────────────────────────────────────────
const densityEl = ref<HTMLDivElement>()
let densityChart: echarts.ECharts | null = null
function renderDensity() {
if (!densityEl.value) return
const perMinute = new Map<string, number>()
for (const r of rows.value) {
const m = r.time.slice(0, 5)
perMinute.set(m, (perMinute.get(m) ?? 0) + 1)
}
const x = [...perMinute.keys()].sort()
const y = x.map((k) => perMinute.get(k) ?? 0)
densityChart ??= echarts.init(densityEl.value, 'dark')
densityChart.setOption(
{
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
grid: { left: 40, top: 8, bottom: 24, right: 10 },
xAxis: { type: 'category', data: x, axisLabel: { fontSize: 9 } },
yAxis: { type: 'value', name: '条/分', nameTextStyle: { fontSize: 9 } },
series: [
{
type: 'bar',
data: y,
itemStyle: { color: '#f5a623' },
barMaxWidth: 6,
},
],
},
true,
)
}
function onResize() {
densityChart?.resize()
}
function tick() {
const now = new Date()
const day = now.getDay()
if (day === 0 || day === 6 || document.hidden) return
const m = now.getHours() * 60 + now.getMinutes()
if ((m >= 555 && m <= 690) || (m >= 780 && m <= 905)) load()
}
const stockDlg = ref<{ market: string; code: string; name: string } | null>(null)
function openStock(r: UnusualRow) {
stockDlg.value = { market: r.mkt, code: r.code, name: r.name }
}
onMounted(() => {
load()
timer = window.setInterval(tick, 15_000)
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.clearInterval(timer)
window.removeEventListener('resize', onResize)
densityChart?.dispose()
})
</script>
<template>
<div class="radar-view">
<div class="view-head">
<h2>异动雷达</h2>
<span class="dim head-sub">沪深异动流 · 最近交易日盘中记录</span>
<span class="tb-spacer"></span>
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
<button class="manual-refresh" @click="load"> 刷新</button>
</div>
<div v-if="error" class="err card">
加载失败{{ error }}
<button @click="load">重试</button>
</div>
<div v-else-if="loading" class="loading">异动流加载中</div>
<template v-else>
<div class="card chart-card">
<div ref="densityEl" class="density-chart"></div>
</div>
<div class="chips">
<button
v-for="t in types"
:key="t"
class="chip-s"
:class="{ on: activeType === t }"
@click="activeType = t"
>
{{ t }} <span class="mono dim">{{ typeCount(t) }}</span>
</button>
</div>
<div class="card list-card">
<div v-for="r in filtered" :key="`${r.mkt}${r.code}${r.time}${r.index}`" class="u-row" @click="openStock(r)">
<span class="mono u-time">{{ r.time }}</span>
<span class="u-name">{{ r.name }}</span>
<span class="mono dim u-code">{{ r.mkt }}·{{ r.code }}</span>
<span class="u-desc" :class="{ hot: r.desc.includes('涨停') || r.desc.includes('买'), cold: r.desc.includes('跌') }">{{ r.desc }}</span>
<span class="mono dim u-val">{{ r.value }}</span>
</div>
<div v-if="filtered.length === 0" class="empty dim">暂无对应类型的异动记录</div>
</div>
</template>
<StockDialog
v-if="stockDlg"
:market="stockDlg.market"
:code="stockDlg.code"
:name="stockDlg.name"
@close="stockDlg = null"
/>
</div>
</template>
<style scoped>
.radar-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.view-head,
.err,
.loading,
.chart-card,
.chips {
flex-shrink: 0;
}
.view-head {
display: flex;
align-items: center;
gap: 8px;
}
.view-head h2 {
font-size: 17px;
font-weight: 700;
}
.head-sub {
font-size: 12px;
}
.tb-spacer {
flex: 1;
}
.refresh-ts {
font-family: var(--font-mono);
font-size: 11.5px;
}
.manual-refresh {
font-size: 12px;
padding: 4px 10px;
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.chart-card {
padding: 6px;
}
.density-chart {
height: 110px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip-s {
padding: 3px 10px;
font-size: 11.5px;
border-radius: 999px;
background: var(--bg-elevated);
border: 1px solid var(--border);
cursor: pointer;
}
.chip-s.on {
border-color: var(--accent);
color: var(--accent);
}
.list-card {
padding: 4px 0;
}
.u-row {
display: flex;
align-items: center;
gap: 12px;
padding: 5px 14px;
font-size: 12.5px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.u-row:last-child {
border-bottom: none;
}
.u-row:hover {
background: var(--bg-elevated);
}
.u-time {
font-size: 11.5px;
color: var(--text-dim);
width: 62px;
}
.u-name {
font-weight: 600;
width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.u-code {
font-size: 11px;
width: 100px;
}
.u-desc {
flex: 1;
}
.u-desc.hot {
color: var(--up);
}
.u-desc.cold {
color: var(--down);
}
.u-val {
font-size: 11px;
}
.empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
}
</style>
+114 -1
View File
@@ -6,13 +6,14 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
import {
fetchBars,
fetchLimitUpHistory,
fetchMarketStat,
fetchSentimentHistory,
fetchSentimentToday,
formatError,
} from '../api'
import { fmtAmount } from '../format'
import { fmtAmount, fmtPctSigned } from '../format'
import type { LimitUpHistoryRow, MarketStat, SentimentDay, SentimentSample } from '../types'
const today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null)
@@ -202,12 +203,112 @@ function renderHistory() {
function onResize() {
todayChart?.resize()
histChart?.resize()
volChart?.resize()
}
// ── ⑧ 量能仪表盘:两市累计成交额(最近交易日)vs 近 5 日同期均值 ─────────────
const volEl = ref<HTMLDivElement>()
let volChart: echarts.ECharts | null = null
const volRatio = ref<number | null>(null)
const volDate = ref('')
async function loadVolume() {
try {
const start = new Date(Date.now() - 14 * 86400_000).toISOString().slice(0, 10)
const [sh, sz] = await Promise.all([
fetchBars('SH', '000001', 'MIN_5', start),
fetchBars('SZ', '399001', 'MIN_5', start),
])
// 按日期聚合两市场 5 分钟 amount(元)
const byDate = new Map<string, Map<string, number>>()
for (const b of [...sh, ...sz]) {
const d = b.datetime.slice(0, 10)
const t = b.datetime.slice(11, 16)
if (!d || !t) continue
const slot = byDate.get(d) ?? new Map<string, number>()
slot.set(t, (slot.get(t) ?? 0) + Number(b.amount ?? 0))
byDate.set(d, slot)
}
const dates = [...byDate.keys()].sort()
if (dates.length < 2) return
volDate.value = dates[dates.length - 1]
const cur = byDate.get(volDate.value)!
const prevDates = dates.slice(-6, -1)
const times = [...cur.keys()].sort()
const cumAt = (m: Map<string, number>, upto: number): number => {
let s = 0
for (let i = 0; i <= upto; i++) s += m.get(times[i]) ?? 0
return s / 1e12 // 万亿
}
const todayCurve = times.map((_, i) => cumAt(cur, i))
const baseCurve = times.map((_, i) => {
let s = 0
let n = 0
for (const d of prevDates) {
const m = byDate.get(d)
if (!m) continue
s += cumAt(m, i)
n += 1
}
return n ? s / n : null
})
const lastT = todayCurve[todayCurve.length - 1]
const lastB = baseCurve[baseCurve.length - 1]
volRatio.value = lastB && lastB > 0 ? ((lastT - lastB) / lastB) * 100 : null
volChart ??= echarts.init(volEl.value!, 'dark')
volChart.setOption(
{
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
valueFormatter: (v: number | string) => fmtAmount(Number(v) * 1e12),
},
legend: { data: ['最近交易日累计', '近 5 日同期均值'], top: 0 },
grid: { left: 60, right: 20, top: 30, bottom: 30 },
xAxis: { type: 'category', data: times },
yAxis: {
type: 'value',
name: '万亿',
scale: true,
splitLine: { lineStyle: { color: '#2a2e3a' } },
axisLabel: { formatter: (v: number) => v.toFixed(1) },
},
series: [
{
name: '最近交易日累计',
type: 'line',
data: todayCurve,
showSymbol: false,
lineStyle: { color: UP_COLOR, width: 2 },
itemStyle: { color: UP_COLOR },
areaStyle: { color: 'rgba(239,65,70,0.08)' },
},
{
name: '近 5 日同期均值',
type: 'line',
data: baseCurve,
showSymbol: false,
lineStyle: { color: '#8b919e', width: 1.5, type: 'dashed' },
itemStyle: { color: '#8b919e' },
},
],
},
true,
)
} catch {
volRatio.value = null // 量能图独立降级
}
}
let timer = 0
onMounted(async () => {
await load()
loadVolume()
timer = window.setInterval(() => {
if (document.hidden) return
load()
@@ -219,6 +320,7 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
todayChart?.dispose()
histChart?.dispose()
volChart?.dispose()
})
</script>
@@ -276,6 +378,17 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 量能仪表盘 -->
<div class="section">
<div class="sec-title">
量能 · 两市累计成交额最近交易日{{ volDate ? ` ${volDate.slice(5)}` : '' }} vs 5 日同期均值
<span v-if="volRatio !== null" :class="volRatio > 0 ? 'up' : 'down'">{{ fmtPctSigned(volRatio) }}</span>
</div>
<div class="card chart-card">
<div ref="volEl" class="chart"></div>
</div>
</div>
<!-- 60 日情绪 -->
<div class="section">
<div class="sec-title"> 60 · 涨停/跌停家数vipdoc 回补与上涨占比采样积累</div>