mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
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:
@@ -11,9 +11,9 @@
|
|||||||
| ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 |
|
| ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 |
|
||||||
| ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) |
|
| ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) |
|
||||||
| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 |
|
| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 |
|
||||||
| ⑤ | **板块相关性热力图** | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ⏳ 第四批 |
|
| ⑤ | **板块相关性热力图** `/hotspots` | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ✅ 第四批(热点滚动页内「相关性」视图) |
|
||||||
| ③ | **异动雷达时间线** | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ⏳ 第四批 |
|
| ③ | **异动雷达时间线** `/radar` | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ✅ 第四批 |
|
||||||
| ⑧ | **量能仪表盘** | 放量/缩量(两市成交额 vs 5日均量带) | 指数分钟线现成 | ⏳ 第四批 |
|
| ⑧ | **量能仪表盘** `/sentiment` | 放量/缩量(两市累计成交 vs 5日同期均值) | 指数 5 分钟线现成 | ✅ 第四批(并入市场情绪页) |
|
||||||
| ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ⏳ 收尾(必须做) |
|
| ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ⏳ 收尾(必须做) |
|
||||||
|
|
||||||
## 批次
|
## 批次
|
||||||
|
|||||||
@@ -336,6 +336,39 @@ async def _hotspot_build(board_key: str, bt: Any, client: Any) -> None:
|
|||||||
_logger.warning("热点矩阵构建失败 (%s): %s", board_key, exc)
|
_logger.warning("热点矩阵构建失败 (%s): %s", board_key, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _hotspot_history_or_build(
|
||||||
|
key: str,
|
||||||
|
bt: Any,
|
||||||
|
client: Any,
|
||||||
|
*,
|
||||||
|
retry: bool = False,
|
||||||
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||||
|
"""热点历史缓存的公共入口。
|
||||||
|
|
||||||
|
缓存就绪返回 ``(history, None)``;否则触发/汇报后台构建,返回
|
||||||
|
``(None, building_or_error_payload)``。error 状态保持稳定不自动重建,
|
||||||
|
保证失败原因能被前端读到(``retry=1`` 才重建)。
|
||||||
|
"""
|
||||||
|
cached = _hotspot_history_cache.get(key)
|
||||||
|
if cached is not None and cached[0] == _today_str():
|
||||||
|
return cached[1], None
|
||||||
|
state = _hotspot_builds.get(key)
|
||||||
|
running = state is not None and state.get("task") is not None and not state["task"].done()
|
||||||
|
# 需要新建:无状态 / 上次成功但缓存已过期 / 显式重试
|
||||||
|
if not running and (retry or state is None or state.get("status") == "ready"):
|
||||||
|
state = {"status": "building", "progress": 0.0, "task": None, "error": ""}
|
||||||
|
_hotspot_builds[key] = state
|
||||||
|
state["task"] = asyncio.create_task(_hotspot_build(key, bt, client))
|
||||||
|
running = True
|
||||||
|
if running:
|
||||||
|
return None, {"status": "building", "progress": state.get("progress", 0.0)}
|
||||||
|
return None, {
|
||||||
|
"status": "error",
|
||||||
|
"error": state.get("error") or "热点矩阵构建失败",
|
||||||
|
"progress": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/board-mac/hotspot", response_model=DictResponse)
|
@router.get("/board-mac/hotspot", response_model=DictResponse)
|
||||||
async def board_hotspot(
|
async def board_hotspot(
|
||||||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||||||
@@ -363,26 +396,9 @@ async def board_hotspot(
|
|||||||
bt = board_type_from_str(board_type)
|
bt = board_type_from_str(board_type)
|
||||||
key = bt.name
|
key = bt.name
|
||||||
|
|
||||||
cached = _hotspot_history_cache.get(key)
|
history, build_payload = _hotspot_history_or_build(key, bt, client, retry=retry)
|
||||||
if cached is None or cached[0] != _today_str():
|
if build_payload is not None:
|
||||||
state = _hotspot_builds.get(key)
|
return DictResponse.from_dict(build_payload)
|
||||||
running = state is not None and state.get("task") is not None and not state["task"].done()
|
|
||||||
# 需要新建:无状态 / 上次成功但缓存已过期 / 显式重试。
|
|
||||||
# error 状态保持稳定不自动重建,保证失败原因能被前端读到。
|
|
||||||
if not running and (retry or state is None or state.get("status") == "ready"):
|
|
||||||
state = {"status": "building", "progress": 0.0, "task": None, "error": ""}
|
|
||||||
_hotspot_builds[key] = state
|
|
||||||
state["task"] = asyncio.create_task(_hotspot_build(key, bt, client))
|
|
||||||
running = True
|
|
||||||
if running:
|
|
||||||
return DictResponse.from_dict(
|
|
||||||
{"status": "building", "progress": state.get("progress", 0.0)}
|
|
||||||
)
|
|
||||||
return DictResponse.from_dict(
|
|
||||||
{"status": "error", "error": state.get("error") or "热点矩阵构建失败", "progress": 1.0}
|
|
||||||
)
|
|
||||||
|
|
||||||
history = cached[1]
|
|
||||||
axis_all: list[str] = history["axis"]
|
axis_all: list[str] = history["axis"]
|
||||||
pct_map: dict[str, dict[str, float]] = history["pct"]
|
pct_map: dict[str, dict[str, float]] = history["pct"]
|
||||||
names: dict[str, str] = dict(history["names"])
|
names: dict[str, str] = dict(history["names"])
|
||||||
@@ -497,6 +513,75 @@ async def board_hotspot(
|
|||||||
return DictResponse.from_dict(payload)
|
return DictResponse.from_dict(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/board-mac/hotspot-correlation", response_model=DictResponse)
|
||||||
|
async def board_hotspot_correlation(
|
||||||
|
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||||||
|
days: int = Query(20, ge=5, le=_HOTSPOT_MAX_DAYS, description="相关性窗口交易日数"),
|
||||||
|
per_day: int = Query(5, ge=2, le=10, description="每日入选名次阈值(行集合口径)"),
|
||||||
|
top: int = Query(15, ge=5, le=25, description="入阵板块数上限(按上榜次数取前 N)"),
|
||||||
|
client: Any = Depends(get_mac_client),
|
||||||
|
) -> DictResponse:
|
||||||
|
"""热点板块相关性矩阵:窗口内活跃板块两两日涨跌幅的 Pearson 相关系数。
|
||||||
|
|
||||||
|
行集合与 ``/board-mac/hotspot`` 同口径(每日 mode=top 前 per_day 名的并集,
|
||||||
|
不含今日实时列),按上榜次数取前 ``top`` 个板块入阵。复用热点历史矩阵缓存
|
||||||
|
(无缓存时返回与 hotspot 相同的 building/error 状态,前端先拉 hotspot 即可)。
|
||||||
|
相关系数 >0(红)= 同涨同跌,<0(绿)= 跷跷板。
|
||||||
|
"""
|
||||||
|
bt = board_type_from_str(board_type)
|
||||||
|
key = bt.name
|
||||||
|
|
||||||
|
history, build_payload = _hotspot_history_or_build(key, bt, client)
|
||||||
|
if build_payload is not None:
|
||||||
|
return DictResponse.from_dict(build_payload)
|
||||||
|
|
||||||
|
axis_all: list[str] = history["axis"]
|
||||||
|
pct_map: dict[str, dict[str, float]] = history["pct"]
|
||||||
|
names: dict[str, str] = dict(history["names"])
|
||||||
|
|
||||||
|
# 仅用已完成交易日(不含今日),与热点矩阵的历史段对齐
|
||||||
|
window = [d for d in axis_all if d != _today_str()][-days:]
|
||||||
|
col_pct: list[dict[str, float]] = [
|
||||||
|
{c: m[d] for c, m in pct_map.items() if d in m} for d in window
|
||||||
|
]
|
||||||
|
in_top: list[set[str]] = [
|
||||||
|
set(sorted(col, key=lambda c: col[c], reverse=True)[:per_day]) for col in col_pct
|
||||||
|
]
|
||||||
|
days_in: dict[str, int] = {}
|
||||||
|
for s in in_top:
|
||||||
|
for c in s:
|
||||||
|
days_in[c] = days_in.get(c, 0) + 1
|
||||||
|
|
||||||
|
chosen = sorted(days_in, key=lambda c: -days_in[c])[:top]
|
||||||
|
if len(chosen) < 2:
|
||||||
|
return DictResponse.from_dict(
|
||||||
|
{"status": "ready", "boards": [], "matrix": [], "days": len(window)}
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = pd.DataFrame({c: pct_map[c] for c in chosen}).T # 板块 × 交易日,缺失为 NaN
|
||||||
|
corr = frame.T.corr(min_periods=max(3, len(window) // 2))
|
||||||
|
|
||||||
|
boards = [
|
||||||
|
{"code": c, "name": names.get(c, c), "days_in": days_in[c]} for c in chosen
|
||||||
|
]
|
||||||
|
matrix: list[list[float | None]] = [
|
||||||
|
[
|
||||||
|
None if pd.isna(corr.loc[a, b]) else round(float(corr.loc[a, b]), 2)
|
||||||
|
for b in chosen
|
||||||
|
]
|
||||||
|
for a in chosen
|
||||||
|
]
|
||||||
|
return DictResponse.from_dict(
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"board_type": bt.name,
|
||||||
|
"days": days,
|
||||||
|
"boards": boards,
|
||||||
|
"matrix": matrix,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _trailing_streak(flags: list[bool]) -> int:
|
def _trailing_streak(flags: list[bool]) -> int:
|
||||||
"""从末尾向前数连续 True(末位为 False 时对齐"当前连榜"语义返 0)。"""
|
"""从末尾向前数连续 True(末位为 False 时对齐"当前连榜"语义返 0)。"""
|
||||||
if not flags or not flags[-1]:
|
if not flags or not flags[-1]:
|
||||||
|
|||||||
@@ -327,3 +327,55 @@ def test_hotspot_missing_kline_board_excluded():
|
|||||||
data, _ = _wait_ready(client, fake)
|
data, _ = _wait_ready(client, fake)
|
||||||
assert data["total_boards"] == 2
|
assert data["total_boards"] == 2
|
||||||
assert all(r["code"] != "881101" for r in data["rows"])
|
assert all(r["code"] != "881101" for r in data["rows"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_hotspot_correlation_matrix_ready():
|
||||||
|
"""缓存就绪:相关矩阵直接可算,完全同向的两板块相关系数 = 1。"""
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from easy_tdx.web.routers import board_mac
|
||||||
|
|
||||||
|
board_mac._hotspot_history_cache["HY"] = (
|
||||||
|
"2030-01-01",
|
||||||
|
{
|
||||||
|
"axis": ["2026-08-10", "2026-08-11", "2026-08-12"],
|
||||||
|
"pct": {
|
||||||
|
"881100": {"2026-08-10": 5.0, "2026-08-11": 3.0, "2026-08-12": 1.0},
|
||||||
|
"881200": {"2026-08-10": 4.0, "2026-08-11": 2.0, "2026-08-12": 0.0},
|
||||||
|
},
|
||||||
|
"names": {"881100": "甲板块", "881200": "乙板块"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fake = _FakeHotspotMacClient()
|
||||||
|
try:
|
||||||
|
with TestClient(_hotspot_app(fake)) as client:
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/board-mac/hotspot-correlation",
|
||||||
|
params={"board_type": "HY", "days": 5, "per_day": 2},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
board_mac._hotspot_history_cache.clear()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert data["status"] == "ready"
|
||||||
|
assert [b["code"] for b in data["boards"]] == ["881100", "881200"]
|
||||||
|
assert data["matrix"][0][0] == 1.0
|
||||||
|
assert data["matrix"][0][1] == pytest.approx(1.0, abs=0.01) # 完全线性同向
|
||||||
|
assert data["matrix"][1][0] == data["matrix"][0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hotspot_correlation_building_passthrough():
|
||||||
|
"""无缓存:与 hotspot 相同的 building 状态透传,前端轮询即可。"""
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
fake = _FakeHotspotMacClient()
|
||||||
|
with TestClient(_hotspot_app(fake)) as client:
|
||||||
|
resp = client.get("/api/v1/board-mac/hotspot-correlation", params={"board_type": "HY"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["status"] in ("building", "error", "ready") # 单机假客户端极快时可能已完成
|
||||||
|
if body["status"] == "building":
|
||||||
|
assert 0.0 <= body["progress"] <= 1.0
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const sseLabel: Record<string, string> = {
|
|||||||
<RouterLink to="/calendar" active-class="active">大盘日历</RouterLink>
|
<RouterLink to="/calendar" active-class="active">大盘日历</RouterLink>
|
||||||
<RouterLink to="/limitup" active-class="active">涨停生态</RouterLink>
|
<RouterLink to="/limitup" active-class="active">涨停生态</RouterLink>
|
||||||
<RouterLink to="/sentiment" 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="/watchlist" active-class="active">自选行情</RouterLink>
|
||||||
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
|
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
|
||||||
<div class="nav-group">分析</div>
|
<div class="nav-group">分析</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
CcpmProductsResponse,
|
CcpmProductsResponse,
|
||||||
CcpmRankResponse,
|
CcpmRankResponse,
|
||||||
DataFrameResponse,
|
DataFrameResponse,
|
||||||
|
HotspotCorrelationResp,
|
||||||
HotspotResp,
|
HotspotResp,
|
||||||
LimitUpEcologyResp,
|
LimitUpEcologyResp,
|
||||||
LimitUpHistoryRow,
|
LimitUpHistoryRow,
|
||||||
@@ -847,6 +848,23 @@ export async function fetchLimitUpEcology(): Promise<LimitUpEcologyResp> {
|
|||||||
return body.data
|
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 表示尚无采样)。 */
|
/** 当日情绪分钟曲线(采样器逐分钟落库;date=0 表示尚无采样)。 */
|
||||||
export async function fetchSentimentToday(): Promise<SentimentTodayResp> {
|
export async function fetchSentimentToday(): Promise<SentimentTodayResp> {
|
||||||
const resp = await fetch(`${BASE}/market/sentiment/today`)
|
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>
|
||||||
@@ -12,6 +12,7 @@ import LlmHistoryView from './views/LlmHistoryView.vue'
|
|||||||
import LlmSettingsView from './views/LlmSettingsView.vue'
|
import LlmSettingsView from './views/LlmSettingsView.vue'
|
||||||
import OptimizeView from './views/OptimizeView.vue'
|
import OptimizeView from './views/OptimizeView.vue'
|
||||||
import PortfolioView from './views/PortfolioView.vue'
|
import PortfolioView from './views/PortfolioView.vue'
|
||||||
|
import RadarView from './views/RadarView.vue'
|
||||||
import SentimentView from './views/SentimentView.vue'
|
import SentimentView from './views/SentimentView.vue'
|
||||||
import ServerSettingsView from './views/ServerSettingsView.vue'
|
import ServerSettingsView from './views/ServerSettingsView.vue'
|
||||||
import SignalRadarView from './views/SignalRadarView.vue'
|
import SignalRadarView from './views/SignalRadarView.vue'
|
||||||
@@ -38,6 +39,8 @@ const routes = [
|
|||||||
{ path: '/limitup', name: 'limitup', component: LimitUpView },
|
{ path: '/limitup', name: 'limitup', component: LimitUpView },
|
||||||
// 市场情绪(宽度分时 + 涨停温度计;采样器盘中逐分钟积累)
|
// 市场情绪(宽度分时 + 涨停温度计;采样器盘中逐分钟积累)
|
||||||
{ path: '/sentiment', name: 'sentiment', component: SentimentView },
|
{ path: '/sentiment', name: 'sentiment', component: SentimentView },
|
||||||
|
// 异动雷达(沪深异动流时间线:封板/炸板/大笔买入…)
|
||||||
|
{ path: '/radar', name: 'radar', component: RadarView },
|
||||||
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
||||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||||
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
||||||
|
|||||||
@@ -687,6 +687,19 @@ export interface LimitUpEcologyResp {
|
|||||||
blown: LimitUpEntry[]
|
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~1,null = 样本不足) */
|
||||||
|
matrix?: Array<Array<number | null>>
|
||||||
|
days?: number
|
||||||
|
}
|
||||||
|
|
||||||
// ── 市场情绪(/market/sentiment/*,盘中逐分钟采样 + vipdoc 涨停史回补) ─────
|
// ── 市场情绪(/market/sentiment/*,盘中逐分钟采样 + vipdoc 涨停史回补) ─────
|
||||||
|
|
||||||
export interface SentimentSample {
|
export interface SentimentSample {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import { fetchBoardHotspot, formatError } from '../api'
|
import { fetchBoardHotspot, formatError } from '../api'
|
||||||
import BoardDialog from '../components/BoardDialog.vue'
|
import BoardDialog from '../components/BoardDialog.vue'
|
||||||
|
import HotspotCorrelation from '../components/HotspotCorrelation.vue'
|
||||||
import HotspotMatrix from '../components/HotspotMatrix.vue'
|
import HotspotMatrix from '../components/HotspotMatrix.vue'
|
||||||
import HotspotStatStrip from '../components/HotspotStatStrip.vue'
|
import HotspotStatStrip from '../components/HotspotStatStrip.vue'
|
||||||
import type { HotspotResp, HotspotRow } from '../types'
|
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 boardType = ref<'HY' | 'GN' | 'FG'>(props.boardType ?? 'HY')
|
||||||
const days = ref<number>(20)
|
const days = ref<number>(20)
|
||||||
const mode = ref<'top' | 'bottom'>('top')
|
const mode = ref<'top' | 'bottom'>('top')
|
||||||
|
const viewMode = ref<'matrix' | 'corr'>('matrix')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.boardType,
|
() => props.boardType,
|
||||||
@@ -227,6 +229,10 @@ function openBoard(r: HotspotRow) {
|
|||||||
<button :class="{ on: mode === 'top' }" @click="setMode('top')">领涨</button>
|
<button :class="{ on: mode === 'top' }" @click="setMode('top')">领涨</button>
|
||||||
<button :class="{ on: mode === 'bottom' }" @click="setMode('bottom')">领跌</button>
|
<button :class="{ on: mode === 'bottom' }" @click="setMode('bottom')">领跌</button>
|
||||||
</span>
|
</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">排序
|
<label class="tb-label">排序
|
||||||
<select v-model="sortKey">
|
<select v-model="sortKey">
|
||||||
<option value="days_in">上榜次数</option>
|
<option value="days_in">上榜次数</option>
|
||||||
@@ -267,8 +273,8 @@ function openBoard(r: HotspotRow) {
|
|||||||
|
|
||||||
<div v-else-if="loading" class="loading">加载中…</div>
|
<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" />
|
<HotspotStatStrip :rows="resp.rows ?? []" :dates="dates" :mode="mode" @select="openBoard" />
|
||||||
|
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
@@ -296,6 +302,11 @@ function openBoard(r: HotspotRow) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 相关性视图:活跃板块两两日涨跌幅相关系数 -->
|
||||||
|
<template v-else-if="viewMode === 'corr'">
|
||||||
|
<HotspotCorrelation :board-type="boardType" :days="days" :per-day="PER_DAY" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<BoardDialog
|
<BoardDialog
|
||||||
v-if="boardDialog"
|
v-if="boardDialog"
|
||||||
:code="boardDialog.code"
|
:code="boardDialog.code"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,13 +6,14 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||||
import {
|
import {
|
||||||
|
fetchBars,
|
||||||
fetchLimitUpHistory,
|
fetchLimitUpHistory,
|
||||||
fetchMarketStat,
|
fetchMarketStat,
|
||||||
fetchSentimentHistory,
|
fetchSentimentHistory,
|
||||||
fetchSentimentToday,
|
fetchSentimentToday,
|
||||||
formatError,
|
formatError,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { fmtAmount } from '../format'
|
import { fmtAmount, fmtPctSigned } from '../format'
|
||||||
import type { LimitUpHistoryRow, MarketStat, SentimentDay, SentimentSample } from '../types'
|
import type { LimitUpHistoryRow, MarketStat, SentimentDay, SentimentSample } from '../types'
|
||||||
|
|
||||||
const today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null)
|
const today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null)
|
||||||
@@ -202,12 +203,112 @@ function renderHistory() {
|
|||||||
function onResize() {
|
function onResize() {
|
||||||
todayChart?.resize()
|
todayChart?.resize()
|
||||||
histChart?.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
|
let timer = 0
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
|
loadVolume()
|
||||||
timer = window.setInterval(() => {
|
timer = window.setInterval(() => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
load()
|
load()
|
||||||
@@ -219,6 +320,7 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
todayChart?.dispose()
|
todayChart?.dispose()
|
||||||
histChart?.dispose()
|
histChart?.dispose()
|
||||||
|
volChart?.dispose()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -276,6 +378,17 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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 日情绪 -->
|
<!-- 近 60 日情绪 -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="sec-title">近 60 日 · 涨停/跌停家数(vipdoc 回补)与上涨占比(采样积累)</div>
|
<div class="sec-title">近 60 日 · 涨停/跌停家数(vipdoc 回补)与上涨占比(采样积累)</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user