mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
feat(webui)!: 行情终端重大升级——市场看板/自选/个股与板块弹窗 + SSE 实时推送
展示层对标专业看盘终端(tick-stock-panel 模式),数据全部来自通达信协议直连: - 市场看板:五大指数实时条(迷你分时)、涨跌统计、四维情绪雷达、 全市场涨跌分布直方图(约 5500 只 22 桶、鼠标跟随浮窗)、涨停雷达、 行业/概念热冷榜(可下钻)、涨幅/跌幅/成交额/换手四联排行榜、异动雷达 - 自选行情:6 位代码即加(symbol-info 自动取名)、SSE 实时全表刷新、 行内迷你分时;SQLite 持久化(watchlist.db,幂等加删) - 个股弹窗:五档盘口 + 1/3/5 日分时 + 日K(MA/BOLL/EMA + MACD/KDJ/RSI 前端本地计算)+ 一键加自选 + 一键全策略寻优 - 板块弹窗:板块分时/日K + 成分股涨跌榜直达个股 - SSE 架构:QuoteStreamer 单循环轮询 fan-out(独立队列 + 背压丢旧、 无订阅休眠、盘中 8s/盘外 60s 降频);前端 pinia 单连接指数退避重连 - fix(codec): SH 000 系大盘指数与 881/885 板块指数报价按 2 位小数解析 (曾缩小 10 倍:科创50 1647.53→164.753);880 统计指数保持 3 位 - fix: 前端批量五档路径 /security/quotes→/quotes、SSE 五档白名单 bid1_vol→bid_vol1、MAC 排行 close 列归一化、日K v-if 撑满、 寻优查看跳转 /→/backtest、FastAPI 0.141 _IncludedRouter 测试适配 新增 27 个单测(streamer/自选/小数位语义),全套 1078 passed
This commit is contained in:
@@ -17,9 +17,11 @@ from .base import BaseCommand
|
||||
def _price_decimal_digits(market: Market, code: str) -> int:
|
||||
"""推断某证券报价的有效小数位数。
|
||||
|
||||
通达信协议中,price 及各档差分均以「厘」(0.001 元) 为基本单位编码,
|
||||
但报价精度按品种而异:股票 2 位(分),指数/ETF/基金/可转债/国债/国债逆回购 3 位(厘)。
|
||||
若一律按 /100 解析,ETF/指数等品种价格会被放大 10 倍(见 Issue #8)。
|
||||
通达信协议中,price 及各档差分按品种以「分」或「厘」为基本单位编码:
|
||||
股票/大盘指数 2 位(分;指数点位恒两位小数,如科创50 1647.53),
|
||||
ETF/基金/可转债/国债 3 位(厘)。
|
||||
若一律按 /100 解析,ETF 等品种价格会被放大 10 倍(见 Issue #8);
|
||||
反之指数按 3 位解析会缩小 10 倍(见 2026-09 看板指数修复)。
|
||||
|
||||
decimal_point 不在行情响应包内,只能凭 market + code 代码段推断。
|
||||
注意同一代码不同市场含义不同:SZ 000001=平安银行(股票,2位),
|
||||
@@ -34,10 +36,20 @@ def _price_decimal_digits(market: Market, code: str) -> int:
|
||||
if market == Market.SH:
|
||||
if code.startswith("5"): # 51x ETF、55x 货币基金、56x 跨境ETF、58x 科创ETF
|
||||
return 3
|
||||
if code.startswith("000"): # 000001 上证指数、000300 沪深300 等
|
||||
return 3
|
||||
if code.startswith("8"): # 880xxx 行业指数
|
||||
if code.startswith("000"):
|
||||
# 000001 上证指数、000300 沪深300、000688 科创50 等——指数系列。
|
||||
# 实测(2026-09-01):这些指数的报价原始单位是「分」(÷100),
|
||||
# 点位恒为 2 位小数(科创50 1647.53 / 沪深300 4611.44);
|
||||
# 按 3 位(厘)解析会缩小 10 倍。深市指数(39xxxx)走默认 2 位同理。
|
||||
return 2
|
||||
if code.startswith("880"):
|
||||
# 880xxx 统计指数(880005 涨跌家数等):字段是计数语义,
|
||||
# market_stat 依赖 ÷1000 解码(price×10 还原家数),勿动。
|
||||
return 3
|
||||
if code.startswith("88"):
|
||||
# 881xxx 行业板块指数、885xxx 概念板块指数——同大盘指数,
|
||||
# 点位两位小数(实测 881106 种植业 1039.93,按 3 位曾缩成 103.993)。
|
||||
return 2
|
||||
return 2 # 60xxxx / 68xxxx 科创板 A 股
|
||||
|
||||
# 深圳:1/3 开头的 15x/16x/18x 为基金,12x 为可转债,11x 为国债
|
||||
@@ -189,7 +201,7 @@ class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
|
||||
raise TdxDecodeError(f"security_quotes 非法 market 值: {market_b}") from e
|
||||
|
||||
code = code_b.decode("utf-8").rstrip("\x00")
|
||||
# 价格按品种有效小数位解析:股票/100,指数·ETF·基金/可转债/国债/1000(Issue #8)
|
||||
# 价格按品种有效小数位解析:股票/大盘指数 ÷100,ETF/基金/债券/880 统计指数 ÷1000(Issue #8)
|
||||
divisor = 10 ** _price_decimal_digits(market, code)
|
||||
p = price_raw / divisor
|
||||
|
||||
|
||||
@@ -80,6 +80,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.tdx_client = client
|
||||
|
||||
# --- 实时行情 SSE 推送器(共享轮询 + fan-out) ---
|
||||
try:
|
||||
from easy_tdx.models.enums import Market
|
||||
from easy_tdx.web.quote_streamer import QuoteStreamer
|
||||
from easy_tdx.web.watchlist_store import get_watchlist_store
|
||||
|
||||
store = get_watchlist_store()
|
||||
|
||||
async def _watch_symbols():
|
||||
# SQLite 存 "SH"/"SZ"/"BJ" 字符串,轮询器需要 Market 枚举
|
||||
return [(Market[mkt], code) for mkt, code in store.symbols()]
|
||||
|
||||
streamer = QuoteStreamer(client.get_security_quotes, _watch_symbols)
|
||||
streamer.start()
|
||||
app.state.quote_streamer = streamer
|
||||
except Exception:
|
||||
logger.warning("QuoteStreamer 启动失败 — SSE 推送不可用", exc_info=True)
|
||||
app.state.quote_streamer = None
|
||||
|
||||
# --- MAC 协议客户端 ---
|
||||
mac_client = None
|
||||
enable_mac = getattr(app.state, "enable_mac", True)
|
||||
@@ -112,6 +131,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
yield
|
||||
|
||||
# --- 关闭实时行情推送器 ---
|
||||
streamer = getattr(app.state, "quote_streamer", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
await streamer.stop()
|
||||
except Exception:
|
||||
logger.warning("QuoteStreamer stop failed", exc_info=True)
|
||||
|
||||
# --- 依次关闭 ---
|
||||
for name, cli in [
|
||||
("Ex market client", ex_client),
|
||||
@@ -214,6 +241,8 @@ def _create_app(
|
||||
from easy_tdx.web.routers.server import router as server_router
|
||||
from easy_tdx.web.routers.sina import router as sina_router
|
||||
from easy_tdx.web.routers.strategies import router as strategies_router
|
||||
from easy_tdx.web.routers.stream import router as stream_router
|
||||
from easy_tdx.web.routers.watchlist import router as watchlist_router
|
||||
|
||||
app.include_router(market_router, prefix="/api/v1")
|
||||
app.include_router(bars_router, prefix="/api/v1")
|
||||
@@ -239,6 +268,10 @@ def _create_app(
|
||||
app.include_router(strategies_router, prefix="/api/v1")
|
||||
# 服务器设置路由(列出/测速/切换 TDX host)
|
||||
app.include_router(server_router, prefix="/api/v1")
|
||||
# 自选股路由(SQLite 持久化,纯 CRUD,不依赖行情连接)
|
||||
app.include_router(watchlist_router, prefix="/api/v1")
|
||||
# 实时行情 SSE 路由(依赖 lifespan 里的 QuoteStreamer)
|
||||
app.include_router(stream_router, prefix="/api/v1")
|
||||
|
||||
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
|
||||
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""实时行情 SSE 推送器:单一共享轮询循环 + 每连接独立队列 fan-out。
|
||||
|
||||
架构(借鉴 tick-stock-panel 的成熟模式,见其 QuoteService / SSE 演进):
|
||||
|
||||
- 通达信协议是请求-响应式,没有服务端推送;"实时"本质是后端定时轮询。
|
||||
- 所有 SSE 连接共享**一条**轮询循环(避免 N 个标签页 = N 倍行情请求),
|
||||
每个连接持有独立的 :class:`asyncio.Queue`,消息 fan-out 投递。
|
||||
- 背压策略:队列满(说明该连接消费慢/挂起)时丢弃最旧消息、保最新——
|
||||
行情场景下旧快照无价值,宁可跳帧不可积压。
|
||||
- 订阅集合 = 固定指数 + 全部自选(每次轮询前重读 watchlist,SQLite 单文件
|
||||
读极快)。前端加自选后,下一个轮询周期自动纳入推送,无需重连 SSE。
|
||||
- 无人订阅时循环休眠,不产生行情请求。
|
||||
- 交易时段(沪时区 09:10-15:10)~8s 一拍,其余时段降到 60s(收盘价仍可推)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.models.enums import Market
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["QuoteStreamer", "INDEX_SYMBOLS", "INDEX_NAMES"]
|
||||
|
||||
# 看板常驻指数(标准协议行情,指数与个股同一接口)。
|
||||
INDEX_SYMBOLS: list[tuple[Market, str]] = [
|
||||
(Market.SH, "000001"), # 上证指数
|
||||
(Market.SZ, "399001"), # 深证成指
|
||||
(Market.SZ, "399006"), # 创业板指
|
||||
(Market.SH, "000688"), # 科创50
|
||||
(Market.SH, "000300"), # 沪深300
|
||||
]
|
||||
INDEX_NAMES: dict[str, str] = {
|
||||
"SH000001": "上证指数",
|
||||
"SZ399001": "深证成指",
|
||||
"SZ399006": "创业板指",
|
||||
"SH000688": "科创50",
|
||||
"SH000300": "沪深300",
|
||||
}
|
||||
|
||||
_MARKET_NAMES = {Market.SZ: "SZ", Market.SH: "SH", Market.BJ: "BJ"}
|
||||
|
||||
# 推送给前端的字段白名单(SecurityQuote 全字段中挑展示需要的,避免 unknown_* 噪音)。
|
||||
_QUOTE_FIELDS = [
|
||||
"market",
|
||||
"code",
|
||||
"price",
|
||||
"pre_close",
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"vol",
|
||||
"cur_vol",
|
||||
"amount",
|
||||
"s_vol",
|
||||
"b_vol",
|
||||
"rise_speed",
|
||||
"limit_up",
|
||||
"limit_down",
|
||||
"decimal_point",
|
||||
"server_time",
|
||||
"trading_status",
|
||||
] + [
|
||||
f"{side}{i}" for side in ("bid", "ask") for i in range(1, 6)
|
||||
] + [
|
||||
f"{side}_vol{i}" for side in ("bid", "ask") for i in range(1, 6)
|
||||
]
|
||||
|
||||
_SH_TZ = dt_timezone(timedelta(hours=8)) # Asia/Shanghai
|
||||
|
||||
|
||||
def _is_trading_hours(now: datetime | None = None) -> bool:
|
||||
"""A股盘中(含集合竞价与收盘前后缓冲):沪时间 09:10-15:10,周一至周五。"""
|
||||
t = now or datetime.now(_SH_TZ)
|
||||
if t.weekday() >= 5:
|
||||
return False
|
||||
hm = t.hour * 60 + t.minute
|
||||
return 9 * 60 + 10 <= hm <= 15 * 60 + 10
|
||||
|
||||
|
||||
class QuoteStreamer:
|
||||
"""共享轮询 + fan-out。由 FastAPI lifespan 启停(``app.state.quote_streamer``)。
|
||||
|
||||
Args:
|
||||
quote_fetcher: async ``(stocks) -> pd.DataFrame``,通常为
|
||||
``AsyncTdxClient.get_security_quotes`` 的偏函数。异常由本类兜底。
|
||||
watch_symbols: async ``() -> list[tuple[Market, str]]``,自选订阅集合
|
||||
(通常读 :class:`WatchlistStore`)。每次轮询前调用。
|
||||
trading_interval: 盘中轮询间隔(秒)。
|
||||
idle_interval: 盘外轮询间隔(秒)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
quote_fetcher: Callable[[list[tuple[Market, str]]], Awaitable[pd.DataFrame]],
|
||||
watch_symbols: Callable[[], Awaitable[list[tuple[Market, str]]]],
|
||||
*,
|
||||
trading_interval: float = 8.0,
|
||||
idle_interval: float = 60.0,
|
||||
) -> None:
|
||||
self._fetch = quote_fetcher
|
||||
self._watch_symbols = watch_symbols
|
||||
self._trading_interval = trading_interval
|
||||
self._idle_interval = idle_interval
|
||||
self._queues: dict[int, asyncio.Queue[dict[str, Any]]] = {}
|
||||
self._ids = itertools.count(1)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self.last_snapshot: list[dict[str, Any]] = [] # 最近一次成功快照(调试/健康检查)
|
||||
|
||||
# ── 订阅管理(SSE 端点调用) ──────────────────────────────────────────
|
||||
|
||||
def subscribe(self) -> tuple[int, asyncio.Queue[dict[str, Any]]]:
|
||||
"""注册一个独立队列;返回 (id, queue)。"""
|
||||
qid = next(self._ids)
|
||||
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=2)
|
||||
self._queues[qid] = q
|
||||
return qid, q
|
||||
|
||||
def unsubscribe(self, qid: int) -> None:
|
||||
self._queues.pop(qid, None)
|
||||
|
||||
@property
|
||||
def subscriber_count(self) -> int:
|
||||
return len(self._queues)
|
||||
|
||||
# ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.get_running_loop().create_task(self._run())
|
||||
logger.info("QuoteStreamer started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info("QuoteStreamer stopped")
|
||||
|
||||
# ── 轮询主循环 ────────────────────────────────────────────────────────
|
||||
|
||||
async def _run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
if not self._queues:
|
||||
await asyncio.sleep(1.0) # 无人订阅,待命
|
||||
continue
|
||||
|
||||
symbols = list(INDEX_SYMBOLS)
|
||||
try:
|
||||
symbols += await self._watch_symbols()
|
||||
except Exception:
|
||||
logger.warning("读取自选订阅集合失败", exc_info=True)
|
||||
|
||||
quotes = await self._fetch_quotes(symbols)
|
||||
if quotes:
|
||||
self.last_snapshot = quotes
|
||||
msg = {
|
||||
"type": "quotes_updated",
|
||||
"ts": datetime.now(_SH_TZ).isoformat(timespec="seconds"),
|
||||
"count": len(quotes),
|
||||
"quotes": quotes,
|
||||
}
|
||||
self._fan_out(msg)
|
||||
|
||||
interval = self._trading_interval if _is_trading_hours() else self._idle_interval
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# 单轮失败不能杀死循环(行情服务器闪断很常见)
|
||||
logger.warning("QuoteStreamer 轮询异常", exc_info=True)
|
||||
await asyncio.sleep(self._idle_interval)
|
||||
|
||||
async def _fetch_quotes(self, symbols: list[tuple[Market, str]]) -> list[dict[str, Any]]:
|
||||
"""批量拉行情(80/批),转精简 dict 列表;失败返回空。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for i in range(0, len(symbols), 80):
|
||||
batch = symbols[i : i + 80]
|
||||
try:
|
||||
df = await self._fetch(batch)
|
||||
except Exception:
|
||||
logger.warning("行情拉取失败(%d 只)", len(batch), exc_info=True)
|
||||
continue
|
||||
out.extend(self._df_to_dicts(df))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _df_to_dicts(df: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
"""DataFrame → 前端 dict(白名单列 + market 枚举转字符串 + symbol 键)。"""
|
||||
if df is None or df.empty:
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for rec in df.to_dict(orient="records"):
|
||||
market = rec.get("market")
|
||||
market_str = _MARKET_NAMES.get(market, str(market or ""))
|
||||
code = str(rec.get("code", ""))
|
||||
d: dict[str, Any] = {}
|
||||
for f in _QUOTE_FIELDS:
|
||||
if f in rec and f not in ("market", "code"):
|
||||
v = rec[f]
|
||||
d[f] = None if v != v else v # NaN → None(JSON 合法)
|
||||
d["market"] = market_str
|
||||
d["code"] = code
|
||||
d["symbol"] = f"{market_str}{code}"
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
def _fan_out(self, msg: dict[str, Any]) -> None:
|
||||
for q in list(self._queues.values()):
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
# 背压:丢最旧、保最新
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
@@ -0,0 +1,62 @@
|
||||
"""实时行情 SSE 路由。
|
||||
|
||||
``GET /api/v1/stream/quotes`` → ``text/event-stream``:
|
||||
|
||||
- 事件 ``data`` 载荷:``{"type": "quotes_updated", "ts", "count", "quotes": [...]}``
|
||||
(quotes 为指数 + 全部自选的快照,字段见 quote_streamer._QUOTE_FIELDS)。
|
||||
- 每 15s 一条 SSE 注释行(``: keepalive``)防中间层掐空闲连接。
|
||||
- 客户端断开由 ASGI cancel → generator finally 反注册队列。
|
||||
|
||||
零额外依赖:不用 sse-starlette,StreamingResponse + asyncio.Queue 足够。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["stream"])
|
||||
|
||||
_KEEPALIVE_SECONDS = 15.0
|
||||
|
||||
|
||||
@router.get("/stream/quotes")
|
||||
async def stream_quotes(request: Request) -> StreamingResponse:
|
||||
"""订阅实时行情推送(指数 + 自选,快照式全量推送)。"""
|
||||
streamer = getattr(request.app.state, "quote_streamer", None)
|
||||
if streamer is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=503, detail="行情推送服务未启动")
|
||||
|
||||
qid, queue = streamer.subscribe()
|
||||
|
||||
async def event_gen(): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
# 首帧 hello:告诉前端连接可用 + 当前订阅规模
|
||||
hello = {"type": "hello", "subscribers": streamer.subscriber_count}
|
||||
yield f"data: {json.dumps(hello, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=_KEEPALIVE_SECONDS)
|
||||
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
streamer.unsubscribe(qid)
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # nginx 反代时不缓冲
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""自选股路由:加入 / 列出 / 移除(SQLite 持久化,无行情依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from easy_tdx.web.watchlist_store import get_watchlist_store
|
||||
|
||||
router = APIRouter(tags=["watchlist"])
|
||||
|
||||
|
||||
class WatchItemAdd(BaseModel):
|
||||
"""加入自选请求。name 由前端从行情数据带过来。"""
|
||||
|
||||
market: str = Field(..., pattern=r"^(SZ|SH|BJ)$")
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
name: str = Field("", max_length=64)
|
||||
group: str = Field("默认", max_length=32)
|
||||
|
||||
|
||||
class WatchlistResponse(BaseModel):
|
||||
items: list[dict]
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/watchlist", response_model=WatchlistResponse)
|
||||
async def list_watchlist(group: str | None = Query(None, description="按分组过滤")) -> WatchlistResponse:
|
||||
"""列出全部自选(按加入顺序)。"""
|
||||
items = get_watchlist_store().list_all(group=group)
|
||||
return WatchlistResponse(items=[i.to_dict() for i in items], count=len(items))
|
||||
|
||||
|
||||
@router.post("/watchlist", response_model=dict)
|
||||
async def add_watch_item(req: WatchItemAdd) -> dict:
|
||||
"""加入自选(幂等:重复加入仅刷新名称)。"""
|
||||
item = get_watchlist_store().add(req.market, req.code, name=req.name, group=req.group)
|
||||
return {"ok": True, "item": item.to_dict()}
|
||||
|
||||
|
||||
@router.delete("/watchlist/{market}/{code}", response_model=dict)
|
||||
async def remove_watch_item(market: str, code: str) -> dict:
|
||||
"""移除自选。"""
|
||||
if market.upper() not in {"SZ", "SH", "BJ"}:
|
||||
raise HTTPException(status_code=400, detail=f"非法市场: {market}")
|
||||
removed = get_watchlist_store().remove(market, code)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"自选中不存在 {market}{code}")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,171 @@
|
||||
"""自选股列表的 SQLite 持久化(Web UI"自选"页的数据后端)。
|
||||
|
||||
设计对齐 :mod:`easy_tdx.web.strategy_store`:
|
||||
|
||||
- 单文件 SQLite,落在统一配置目录(``~/.easy_tdx/watchlist.db``,
|
||||
随 ``EASY_TDX_CONFIG_DIR`` 环境变量走)。
|
||||
- 短连接 + 写锁串行,跨线程安全(FastAPI 线程池内调用)。
|
||||
- ``(market, code)`` 唯一:重复加入同一只股票幂等(更新名称,不动排序)。
|
||||
- ``group_name`` 字段预留分组能力,v1 前端未使用,默认 ``"默认"``。
|
||||
- ``sort_order`` 由插入时 max+1 维持"新加的在最后",列出时按其升序。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"WatchItem",
|
||||
"WatchlistStore",
|
||||
"get_watchlist_store",
|
||||
]
|
||||
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
return Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
|
||||
|
||||
|
||||
def _default_db_path() -> Path:
|
||||
return _config_dir() / "watchlist.db"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchItem:
|
||||
"""一条自选记录。name 由前端在添加时填好(来自行情/选股接口)。"""
|
||||
|
||||
market: str # "SH" | "SZ" | "BJ"
|
||||
code: str # 6 位代码
|
||||
name: str = ""
|
||||
group_name: str = "默认"
|
||||
created_at: str = ""
|
||||
sort_order: int = 0
|
||||
|
||||
@property
|
||||
def symbol(self) -> str:
|
||||
"""前端统一标识:SH600000 形式。"""
|
||||
return f"{self.market}{self.code}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"market": self.market,
|
||||
"code": self.code,
|
||||
"symbol": self.symbol,
|
||||
"name": self.name,
|
||||
"group_name": self.group_name,
|
||||
"created_at": self.created_at,
|
||||
"sort_order": self.sort_order,
|
||||
}
|
||||
|
||||
|
||||
class WatchlistStore:
|
||||
"""自选股 SQLite 存储。单例由 :func:`get_watchlist_store` 提供。"""
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
market TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
group_name TEXT NOT NULL DEFAULT '默认',
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (market, code)
|
||||
);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self.db_path = db_path or _default_db_path()
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def list_all(self, group: str | None = None) -> list[WatchItem]:
|
||||
"""列出全部自选(按 sort_order 升序),可按分组过滤。"""
|
||||
with self._connect() as conn:
|
||||
if group:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM watchlist WHERE group_name = ? ORDER BY sort_order",
|
||||
(group,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM watchlist ORDER BY sort_order").fetchall()
|
||||
return [
|
||||
WatchItem(
|
||||
market=r["market"],
|
||||
code=r["code"],
|
||||
name=r["name"],
|
||||
group_name=r["group_name"],
|
||||
created_at=r["created_at"],
|
||||
sort_order=r["sort_order"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def symbols(self) -> list[tuple[str, str]]:
|
||||
"""列出 (market, code) 元组——SSE 轮询器订阅集合用。"""
|
||||
return [(i.market, i.code) for i in self.list_all()]
|
||||
|
||||
def add(self, market: str, code: str, name: str = "", group: str = "默认") -> WatchItem:
|
||||
"""加入自选;已存在时幂等返回(仅刷新名称)。"""
|
||||
market = market.upper()
|
||||
with _write_lock, self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM watchlist WHERE market = ? AND code = ?", (market, code)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if name and name != row["name"]:
|
||||
conn.execute(
|
||||
"UPDATE watchlist SET name = ? WHERE market = ? AND code = ?",
|
||||
(name, market, code),
|
||||
)
|
||||
return WatchItem(
|
||||
market=row["market"],
|
||||
code=row["code"],
|
||||
name=name or row["name"],
|
||||
group_name=row["group_name"],
|
||||
created_at=row["created_at"],
|
||||
sort_order=row["sort_order"],
|
||||
)
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM watchlist").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO watchlist (market, code, name, group_name, created_at, sort_order)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(market, code, name, group, _now_iso(), next_order),
|
||||
)
|
||||
return WatchItem(market=market, code=code, name=name, group_name=group, created_at=_now_iso(), sort_order=next_order)
|
||||
|
||||
def remove(self, market: str, code: str) -> bool:
|
||||
"""移除自选;返回是否确实删除了一条。"""
|
||||
market = market.upper()
|
||||
with _write_lock, self._connect() as conn:
|
||||
cur = conn.execute("DELETE FROM watchlist WHERE market = ? AND code = ?", (market, code))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
_store: WatchlistStore | None = None
|
||||
_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_watchlist_store() -> WatchlistStore:
|
||||
"""全局单例(首次调用惰性建库)。"""
|
||||
global _store
|
||||
if _store is None:
|
||||
with _store_lock:
|
||||
if _store is None:
|
||||
_store = WatchlistStore()
|
||||
return _store
|
||||
@@ -233,23 +233,29 @@ def _build_quote_body(market: int, code: str, price_raw: int) -> bytes:
|
||||
|
||||
|
||||
def test_security_quotes_decimal_point_classification():
|
||||
"""Issue #8:价格小数位按 market+code 代码段推断。
|
||||
"""Issue #8 + 看板指数修复:价格小数位按 market+code 代码段推断。
|
||||
|
||||
同一代码不同市场含义不同:SZ 000001=平安银行(股票,2位),
|
||||
SH 000001=上证指数(3位),故必须结合市场判断。
|
||||
SH 000001=上证指数,故必须结合市场判断。
|
||||
大盘指数点位恒两位小数(2026-09-01 实测:科创50 1647.53、沪深300 4611.44、
|
||||
上证 3979.89),报价原始单位为「分」;曾按 3 位(厘)解析导致看板指数缩小 10 倍。
|
||||
"""
|
||||
from easy_tdx.commands.security_quotes import _price_decimal_digits
|
||||
from easy_tdx.models.enums import Market
|
||||
|
||||
# ETF / 基金 / 可转债 / 国债 / 指数 -> 3 位(厘)
|
||||
# ETF / 基金 / 可转债 / 国债 / 880 统计指数 -> 3 位(厘)
|
||||
assert _price_decimal_digits(Market.SZ, "159922") == 3 # 深 ETF
|
||||
assert _price_decimal_digits(Market.SZ, "161725") == 3 # 深 LOF 基金
|
||||
assert _price_decimal_digits(Market.SZ, "128095") == 3 # 深 可转债
|
||||
assert _price_decimal_digits(Market.SZ, "111002") == 3 # 深 国债
|
||||
assert _price_decimal_digits(Market.SH, "510300") == 3 # 沪 ETF
|
||||
assert _price_decimal_digits(Market.SH, "511990") == 3 # 沪 货币基金
|
||||
assert _price_decimal_digits(Market.SH, "000001") == 3 # 上证指数
|
||||
assert _price_decimal_digits(Market.SH, "000300") == 3 # 沪深 300 指数
|
||||
assert _price_decimal_digits(Market.SH, "880005") == 3 # 统计指数(market_stat 依赖 3 位语义)
|
||||
|
||||
# 大盘指数 -> 2 位(分,点位恒两位小数)
|
||||
assert _price_decimal_digits(Market.SH, "000001") == 2 # 上证指数
|
||||
assert _price_decimal_digits(Market.SH, "000300") == 2 # 沪深 300
|
||||
assert _price_decimal_digits(Market.SZ, "399001") == 2 # 深证成指
|
||||
|
||||
# 股票 -> 2 位(分)
|
||||
assert _price_decimal_digits(Market.SZ, "000001") == 2 # 深主板(平安银行)
|
||||
@@ -290,16 +296,20 @@ def test_security_quotes_stock_price_unchanged():
|
||||
assert abs(q.price - 9.89) < 1e-9
|
||||
|
||||
|
||||
def test_security_quotes_index_price_3_digits():
|
||||
"""Issue #8:上证指数 SH000001 现价 3123.456 → 按 3 位小数解析。"""
|
||||
def test_security_quotes_index_price_2_digits():
|
||||
"""看板指数修复:上证指数 SH000001 现价 3979.89 → 按 2 位小数(分)解析。
|
||||
|
||||
实测(2026-09-01):SH 000/880 系列 decimal_point 报 3,但 000 系大盘指数
|
||||
的原始单位是分。3123456 → 31234.56。
|
||||
"""
|
||||
from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd
|
||||
from easy_tdx.models.enums import Market
|
||||
|
||||
body = _build_quote_body(int(Market.SH), "000001", 3123456)
|
||||
q = GetSecurityQuotesCmd([(Market.SH, "000001")]).parse_response(body)[0]
|
||||
|
||||
assert q.decimal_point == 3
|
||||
assert abs(q.price - 3123.456) < 1e-6
|
||||
assert q.decimal_point == 2
|
||||
assert abs(q.price - 31234.56) < 1e-6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""报价小数位推断(Issue #8 + 看板指数 ×0.1 修复)的语义锁定测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from easy_tdx.commands.security_quotes import _price_decimal_digits
|
||||
from easy_tdx.models.enums import Market
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("market", "code", "expected", "why"),
|
||||
[
|
||||
# ── 大盘指数:点位恒两位小数(科创50 1647.53),原始单位是「分」 ──
|
||||
(Market.SH, "000001", 2, "上证指数"),
|
||||
(Market.SH, "000300", 2, "沪深300(实测 4611.44,按 3 位曾缩成 461.144)"),
|
||||
(Market.SH, "000688", 2, "科创50(实测 1647.53,按 3 位曾缩成 164.753)"),
|
||||
(Market.SH, "000905", 2, "中证500"),
|
||||
(Market.SH, "000016", 2, "上证50"),
|
||||
(Market.SZ, "399001", 2, "深证成指(深市指数走默认分支,一直正确)"),
|
||||
(Market.SZ, "399006", 2, "创业板指"),
|
||||
# ── 统计指数:字段是计数语义(price×10 还原家数),必须保持 3 位 ──
|
||||
(Market.SH, "880005", 3, "全市场行情统计(market_stat 依赖)"),
|
||||
(Market.SH, "880006", 3, "涨跌停统计"),
|
||||
# ── 板块指数:与大盘指数同口径,两位小数 ──
|
||||
(Market.SH, "881106", 2, "行业板块指数(种植业,实测 1039.93)"),
|
||||
(Market.SH, "885418", 2, "概念板块指数"),
|
||||
# ── 基金/ETF:真实三位小数(Issue #8)──
|
||||
(Market.SH, "510300", 3, "沪深300ETF(价格如 4.611)"),
|
||||
(Market.SH, "588000", 3, "科创50ETF"),
|
||||
(Market.SZ, "159915", 3, "创业板ETF"),
|
||||
# ── 股票:两位 ──
|
||||
(Market.SH, "600519", 2, "主板"),
|
||||
(Market.SH, "688981", 2, "科创板"),
|
||||
(Market.SZ, "000001", 2, "深市 000 开头是股票(平安银行),与 SH000001 同码不同义"),
|
||||
(Market.SZ, "300750", 2, "创业板"),
|
||||
(Market.BJ, "920002", 2, "北交所"),
|
||||
],
|
||||
)
|
||||
def test_price_decimal_digits(market: Market, code: str, expected: int, why: str) -> None:
|
||||
assert _price_decimal_digits(market, code) == expected, why
|
||||
@@ -0,0 +1,155 @@
|
||||
"""WatchlistStore(SQLite CRUD)与 QuoteStreamer(fan-out/背压)单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.models.enums import Market
|
||||
from easy_tdx.web.quote_streamer import INDEX_SYMBOLS, QuoteStreamer, _is_trading_hours
|
||||
from easy_tdx.web.watchlist_store import WatchlistStore
|
||||
|
||||
|
||||
# ── WatchlistStore ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path: Path) -> WatchlistStore:
|
||||
return WatchlistStore(db_path=tmp_path / "watchlist.db")
|
||||
|
||||
|
||||
def test_add_list_remove_roundtrip(store: WatchlistStore) -> None:
|
||||
assert store.list_all() == []
|
||||
item = store.add("SH", "600000", name="浦发银行")
|
||||
assert item.symbol == "SH600000"
|
||||
store.add("SZ", "000001", name="平安银行")
|
||||
store.add("BJ", "920002", name="万达轴承")
|
||||
|
||||
items = store.list_all()
|
||||
assert [i.symbol for i in items] == ["SH600000", "SZ000001", "BJ920002"]
|
||||
# 按加入顺序排列
|
||||
assert [i.sort_order for i in items] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_add_is_idempotent(store: WatchlistStore) -> None:
|
||||
store.add("SH", "600000", name="浦发银行")
|
||||
store.add("sh", "600000", name="浦发银行(更名)") # 小写市场码归一
|
||||
items = store.list_all()
|
||||
assert len(items) == 1
|
||||
assert items[0].name == "浦发银行(更名)"
|
||||
assert items[0].sort_order == 1 # 幂等:不改变排序
|
||||
|
||||
|
||||
def test_remove_missing_returns_false(store: WatchlistStore) -> None:
|
||||
assert store.remove("SZ", "399006") is False
|
||||
store.add("SZ", "399006", name="创业板指")
|
||||
assert store.remove("SZ", "399006") is True
|
||||
assert store.remove("SZ", "399006") is False
|
||||
|
||||
|
||||
def test_symbols_for_streamer(store: WatchlistStore) -> None:
|
||||
store.add("SH", "600000", name="浦发银行")
|
||||
assert store.symbols() == [("SH", "600000")]
|
||||
|
||||
|
||||
# ── QuoteStreamer ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fake_df(symbols: list[tuple[Market, str]]) -> pd.DataFrame:
|
||||
rows = []
|
||||
for mkt, code in symbols:
|
||||
rows.append(
|
||||
{
|
||||
"market": mkt,
|
||||
"code": code,
|
||||
"price": 10.5,
|
||||
"pre_close": 10.0,
|
||||
"open": 10.1,
|
||||
"high": 10.8,
|
||||
"low": 9.9,
|
||||
"vol": 12345.0,
|
||||
"amount": 1_234_500.0,
|
||||
"bid1": 10.49,
|
||||
"bid_vol1": 100,
|
||||
"ask1": 10.51,
|
||||
"ask_vol1": 120,
|
||||
"bid2": 10.48,
|
||||
"bid_vol2": 90,
|
||||
"unknown_5": 0, # 应被白名单过滤
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _make_streamer() -> tuple[QuoteStreamer, list[list[tuple[Market, str]]]]:
|
||||
calls: list[list[tuple[Market, str]]] = []
|
||||
|
||||
async def fetch(symbols: list[tuple[Market, str]]) -> pd.DataFrame:
|
||||
calls.append(symbols)
|
||||
return _fake_df(symbols)
|
||||
|
||||
async def watch() -> list[tuple[Market, str]]:
|
||||
return [(Market.SZ, "000001")]
|
||||
|
||||
return QuoteStreamer(fetch, watch, trading_interval=0.01, idle_interval=0.01), calls
|
||||
|
||||
|
||||
def test_streamer_fanout_and_backpressure() -> None:
|
||||
streamer, calls = _make_streamer()
|
||||
qid1, q1 = streamer.subscribe()
|
||||
qid2, q2 = streamer.subscribe()
|
||||
|
||||
async def run_once() -> None:
|
||||
streamer.start()
|
||||
await asyncio.sleep(0.05) # 至少完成一轮轮询
|
||||
await streamer.stop()
|
||||
|
||||
asyncio.run(run_once())
|
||||
|
||||
assert calls, "应至少发起一次行情拉取"
|
||||
# 订阅集合 = 指数 + 自选
|
||||
assert (Market.SZ, "000001") in calls[0]
|
||||
assert set(INDEX_SYMBOLS).issubset(set(calls[0]))
|
||||
|
||||
for q in (q1, q2):
|
||||
msg = q.get_nowait()
|
||||
assert msg["type"] == "quotes_updated"
|
||||
assert msg["count"] == len(calls[0])
|
||||
rec = msg["quotes"][0]
|
||||
assert rec["market"] in {"SH", "SZ", "BJ"}
|
||||
assert rec["symbol"]
|
||||
assert "unknown_5" not in rec # 白名单生效
|
||||
assert rec["price"] == 10.5
|
||||
# 五档字段(bid_vol1 语义,非 bid1_vol)必须完整透传(Issue:盘口无数据)
|
||||
assert rec["bid1"] == 10.49
|
||||
assert rec["bid_vol1"] == 100
|
||||
assert rec["ask1"] == 10.51
|
||||
assert rec["ask_vol1"] == 120
|
||||
assert rec["bid_vol2"] == 90
|
||||
|
||||
streamer.unsubscribe(qid1)
|
||||
streamer.unsubscribe(qid2)
|
||||
assert streamer.subscriber_count == 0
|
||||
|
||||
|
||||
def test_streamer_backpressure_drops_oldest() -> None:
|
||||
"""队列满(maxsize=2)时丢最旧保最新——第 3 条消息应顶掉第 1 条。"""
|
||||
streamer, _ = _make_streamer()
|
||||
qid, q = streamer.subscribe()
|
||||
for i in range(3):
|
||||
streamer._fan_out({"type": "quotes_updated", "seq": i})
|
||||
seqs = [q.get_nowait()["seq"] for _ in range(2)]
|
||||
assert seqs == [1, 2]
|
||||
streamer.unsubscribe(qid)
|
||||
|
||||
|
||||
def test_is_trading_hours() -> None:
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
tz = dt_timezone(timedelta(hours=8))
|
||||
assert _is_trading_hours(datetime(2026, 9, 1, 10, 0, tzinfo=tz)) is True # 周二盘中
|
||||
assert _is_trading_hours(datetime(2026, 9, 1, 3, 0, tzinfo=tz)) is False # 凌晨
|
||||
assert _is_trading_hours(datetime(2026, 9, 5, 10, 0, tzinfo=tz)) is False # 周六
|
||||
@@ -79,11 +79,14 @@ def test_create_app_returns_fastapi_instance():
|
||||
assert app.title == "easy-tdx API"
|
||||
|
||||
# Check routers are mounted
|
||||
routes = [r.path for r in app.routes]
|
||||
# FastAPI 0.141+ 的 app.routes 含 _IncludedRouter(无 path 属性),改用
|
||||
# OpenAPI schema 验证(WebSocket 不进 schema,由 test_full_app_routes_registered 覆盖)
|
||||
routes = list(app.openapi()["paths"].keys())
|
||||
assert any("/api/v1/security" in r for r in routes)
|
||||
assert any("/api/v1/bars" in r for r in routes)
|
||||
assert any("/api/v1/chanlun" in r for r in routes)
|
||||
assert any("/ws/realtime" in r for r in routes)
|
||||
assert any("/api/v1/watchlist" in r for r in routes)
|
||||
assert any("/api/v1/stream/quotes" in r for r in routes)
|
||||
|
||||
|
||||
def test_deps_get_client_type():
|
||||
@@ -503,7 +506,10 @@ def test_full_app_routes_registered():
|
||||
from easy_tdx.web import create_app
|
||||
|
||||
app = create_app()
|
||||
all_paths = [r.path for r in app.routes]
|
||||
# FastAPI 0.141+ 的 include_router 是 _IncludedRouter 延迟对象,app.routes
|
||||
# 不再平铺子路由;用 OpenAPI schema 验证注册结果(面向行为而非内部结构)。
|
||||
# WebSocket 路由不进 OpenAPI,单独用 _IncludedRouter 展开验证。
|
||||
all_paths = list(app.openapi()["paths"].keys())
|
||||
expected_prefixes = [
|
||||
"/api/v1/security",
|
||||
"/api/v1/bars",
|
||||
@@ -512,12 +518,28 @@ def test_full_app_routes_registered():
|
||||
"/api/v1/chanlun",
|
||||
"/api/v1/announcements",
|
||||
"/api/v1/sina/financial-report",
|
||||
"/ws/realtime",
|
||||
"/api/v1/watchlist",
|
||||
"/api/v1/stream/quotes",
|
||||
]
|
||||
for prefix in expected_prefixes:
|
||||
matched = any(prefix in p for p in all_paths)
|
||||
assert matched, f"Expected route with prefix '{prefix}' not found in {all_paths}"
|
||||
|
||||
# WebSocket 路由验证:经 _IncludedRouter.original_router 展开找 ws 路径
|
||||
ws_paths: list[str] = []
|
||||
|
||||
def _collect(routes: list) -> None:
|
||||
for r in routes:
|
||||
path = getattr(r, "path", None)
|
||||
if path:
|
||||
ws_paths.append(path)
|
||||
orig = getattr(r, "original_router", None)
|
||||
if orig is not None:
|
||||
_collect(getattr(orig, "routes", []))
|
||||
|
||||
_collect(app.routes)
|
||||
assert any("/ws/realtime" in p for p in ws_paths), f"WS route missing in {ws_paths}"
|
||||
|
||||
|
||||
def test_openapi_schema_generated():
|
||||
"""OpenAPI schema should be auto-generated and contain key paths."""
|
||||
|
||||
+103
-28
@@ -1,21 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:顶部标题栏 + 路由出口。
|
||||
// 根组件:侧边栏终端外壳 + 路由出口。
|
||||
// 布局借鉴专业看盘终端(侧边栏分组导航 + 底部实时连接状态徽标)。
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { useQuoteStore } from './stores/quotes'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
onMounted(() => quoteStore.connect())
|
||||
|
||||
const sseLabel: Record<string, string> = {
|
||||
connecting: '连接中',
|
||||
open: '实时',
|
||||
closed: '离线',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<h1>easy-tdx 回测</h1>
|
||||
<nav class="app-nav">
|
||||
<RouterLink to="/" active-class="active">单标的回测</RouterLink>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-name">easy-tdx</span>
|
||||
<span class="brand-sub">行情终端</span>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">行情</div>
|
||||
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
|
||||
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
|
||||
<div class="nav-group">分析</div>
|
||||
<RouterLink to="/backtest" active-class="active">单标的回测</RouterLink>
|
||||
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
|
||||
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
|
||||
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
|
||||
<RouterLink to="/strategies" active-class="active">策略库</RouterLink>
|
||||
<RouterLink to="/signals" active-class="active">信号雷达</RouterLink>
|
||||
<div class="nav-group">系统</div>
|
||||
<RouterLink to="/settings" active-class="active">服务器设置</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="side-footer">
|
||||
<span class="dot" :class="quoteStore.status"></span>
|
||||
<span class="sse-label">{{ sseLabel[quoteStore.status] ?? '离线' }}</span>
|
||||
<span v-if="quoteStore.lastTs" class="sse-ts">{{ quoteStore.lastTs.slice(11, 19) }}</span>
|
||||
<span v-if="quoteStore.quoteCount" class="sse-n">×{{ quoteStore.quoteCount }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -25,40 +52,88 @@
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 20px;
|
||||
height: 48px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
.sidebar {
|
||||
width: 176px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.app-nav a {
|
||||
.brand {
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.side-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.nav-group {
|
||||
padding: 10px 16px 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.side-nav a {
|
||||
display: block;
|
||||
padding: 7px 16px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.app-nav a:hover {
|
||||
.side-nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.app-nav a.active {
|
||||
.side-nav a.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.side-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.dot.open {
|
||||
background: var(--up);
|
||||
box-shadow: 0 0 4px var(--up);
|
||||
}
|
||||
.dot.connecting {
|
||||
background: var(--warn);
|
||||
}
|
||||
.sse-ts {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.sse-n {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.app-main {
|
||||
flex: 1;
|
||||
|
||||
@@ -6,14 +6,20 @@ import type {
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
Bar,
|
||||
BoardRow,
|
||||
Category,
|
||||
DataFrameResponse,
|
||||
MarketStat,
|
||||
MinutePoint,
|
||||
MultiStrategyBacktestRequest,
|
||||
OptimizeAllBacktestRequest,
|
||||
OptimizeBacktestRequest,
|
||||
PortfolioBacktestRequest,
|
||||
RankRow,
|
||||
SavedStrategy,
|
||||
SavedStrategyCreate,
|
||||
SavedStrategyListResponse,
|
||||
SecurityQuote,
|
||||
ServerHostInfo,
|
||||
ServerHostListResponse,
|
||||
ServerSwitchResult,
|
||||
@@ -23,6 +29,7 @@ import type {
|
||||
TaskListResponse,
|
||||
TaskState,
|
||||
TaskSubmitResponse,
|
||||
WatchlistResponse,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
@@ -349,3 +356,224 @@ export async function switchServerHost(host: string): Promise<ServerSwitchResult
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as ServerSwitchResult
|
||||
}
|
||||
|
||||
// ── 行情终端 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 批量拉实时五档(REST 一次性;持续刷新走 SSE,见 stores/quotes.ts)。
|
||||
* 注意:后端路由是 POST /quotes(market router 挂在 /api/v1 前缀下)。 */
|
||||
export async function fetchQuotes(symbols: Array<{ market: string; code: string }>): Promise<SecurityQuote[]> {
|
||||
const resp = await fetch(`${BASE}/quotes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stocks: symbols }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map(normalizeQuote)
|
||||
}
|
||||
|
||||
/** 后端 quotes 行 → SecurityQuote(补 symbol 键;数值列容错 null)。 */
|
||||
function normalizeQuote(row: Record<string, unknown>): SecurityQuote {
|
||||
const market = String(row.market ?? '')
|
||||
const code = String(row.code ?? '')
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(v)
|
||||
return v == null || Number.isNaN(n) ? null : n
|
||||
}
|
||||
const q = { ...row } as Record<string, unknown>
|
||||
q.symbol = `${market}${code}`
|
||||
for (const k of Object.keys(q)) {
|
||||
if (k === 'symbol' || k === 'market' || k === 'code' || k === 'server_time') continue
|
||||
q[k] = num(q[k])
|
||||
}
|
||||
return q as unknown as SecurityQuote
|
||||
}
|
||||
|
||||
/** 全市场涨跌统计(涨/跌/平/涨停/跌停家数 + 总成交)。 */
|
||||
export async function fetchMarketStat(): Promise<MarketStat> {
|
||||
const resp = await fetch(`${BASE}/market/stat`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
const row = body.data[0] ?? {}
|
||||
const num = (v: unknown): number => Number(v ?? 0)
|
||||
return {
|
||||
up_count: num(row.up_count),
|
||||
down_count: num(row.down_count),
|
||||
neutral_count: num(row.neutral_count),
|
||||
suspended_count: num(row.suspended_count),
|
||||
total_count: num(row.total_count),
|
||||
total_amount: num(row.total_amount),
|
||||
total_volume: num(row.total_volume),
|
||||
total_market_cap: num(row.total_market_cap),
|
||||
limit_up_count: num(row.limit_up_count),
|
||||
limit_down_count: num(row.limit_down_count),
|
||||
}
|
||||
}
|
||||
|
||||
/** 今日分时(240 点:价格 + 每分钟量)。 */
|
||||
export async function fetchMinute(market: string, code: string): Promise<MinutePoint[]> {
|
||||
const params = new URLSearchParams({ market, code })
|
||||
const resp = await fetch(`${BASE}/minute?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? ''),
|
||||
price: Number(row.price ?? 0),
|
||||
vol: Number(row.vol ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 历史某日分时(date: YYYYMMDD 整数,如 20260829)。 */
|
||||
export async function fetchHistoryMinute(
|
||||
market: string,
|
||||
code: string,
|
||||
date: number,
|
||||
): Promise<MinutePoint[]> {
|
||||
const params = new URLSearchParams({ market, code, date: String(date) })
|
||||
const resp = await fetch(`${BASE}/minute/history?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? ''),
|
||||
price: Number(row.price ?? 0),
|
||||
vol: Number(row.vol ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 指数日 K(/bars/index;指数代码在个股接口可能返回空,用它兜底)。 */
|
||||
export async function fetchIndexBars(
|
||||
market: string,
|
||||
code: string,
|
||||
count = 250,
|
||||
): Promise<Bar[]> {
|
||||
const params = new URLSearchParams({ market, code, category: 'DAY', count: String(count) })
|
||||
const resp = await fetch(`${BASE}/bars/index?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => ({
|
||||
datetime: String(row.datetime ?? row.date ?? '').slice(0, 19).replace(' ', 'T'),
|
||||
open: Number(row.open),
|
||||
high: Number(row.high),
|
||||
low: Number(row.low),
|
||||
close: Number(row.close),
|
||||
vol: Number(row.vol),
|
||||
amount: Number(row.amount ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 板块列表(MAC 协议;CHANGE_PCT 排序时涨跌幅 = price/pre_close - 1 自行计算)。 */
|
||||
export async function fetchBoards(boardType = 'HY', count = 60): Promise<BoardRow[]> {
|
||||
const params = new URLSearchParams({ board_type: boardType, count: String(count) })
|
||||
const resp = await fetch(`${BASE}/board-mac/list?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const r = { ...row }
|
||||
const price = Number(r.price ?? 0)
|
||||
const pre = Number(r.pre_close ?? 0)
|
||||
r.change_pct = pre > 0 ? (price / pre - 1) * 100 : Number(r.change_pct ?? 0)
|
||||
return r as BoardRow
|
||||
})
|
||||
}
|
||||
|
||||
/** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */
|
||||
export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise<Record<string, unknown>[]> {
|
||||
const params = new URLSearchParams({ market, count: String(count) })
|
||||
const resp = await fetch(`${BASE}/mac/unusual?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 排行榜(MAC 排行行情)。
|
||||
* MAC 协议价格列是 close(无 price/change_pct),这里统一归一化为
|
||||
* price/change_pct,渲染端不再做多候选探测。sortBy 对应后端 SortType。 */
|
||||
export async function fetchRankList(
|
||||
sortOrder: 'DESC' | 'ASC',
|
||||
count = 20,
|
||||
sortBy = 'CHANGE_PCT',
|
||||
): Promise<RankRow[]> {
|
||||
const params = new URLSearchParams({
|
||||
category: 'A',
|
||||
sort_type: sortBy,
|
||||
sort_order: sortOrder,
|
||||
count: String(count),
|
||||
})
|
||||
const resp = await fetch(`${BASE}/mac/quote-list?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const close = Number(row.close ?? row.price ?? 0)
|
||||
const pre = Number(row.pre_close ?? 0)
|
||||
const r = { ...row } as Record<string, unknown>
|
||||
r.price = close
|
||||
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
|
||||
r.market = Number(row.market) === 1 ? 'SH' : 'SZ'
|
||||
return r as RankRow
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询证券中文名称(MAC 协议个股快照;BJ 市场可能不支持,失败返回空)。 */
|
||||
export async function fetchSymbolName(market: string, code: string): Promise<string> {
|
||||
try {
|
||||
const params = new URLSearchParams({ market, code })
|
||||
const resp = await fetch(`${BASE}/mac/symbol-info?${params}`)
|
||||
if (!resp.ok) return ''
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return String(body.data[0]?.name ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 板块成分股(MAC 协议;按涨跌幅排序,列与排行行情同构,做同款归一化)。 */
|
||||
export async function fetchBoardMembers(
|
||||
boardSymbol: string,
|
||||
count = 100,
|
||||
sortOrder: 'DESC' | 'ASC' = 'DESC',
|
||||
): Promise<RankRow[]> {
|
||||
const params = new URLSearchParams({
|
||||
board_symbol: boardSymbol,
|
||||
count: String(count),
|
||||
sort_type: 'CHANGE_PCT',
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
const resp = await fetch(`${BASE}/board-mac/members?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as DataFrameResponse
|
||||
return body.data.map((row) => {
|
||||
const close = Number(row.close ?? row.price ?? 0)
|
||||
const pre = Number(row.pre_close ?? 0)
|
||||
const r = { ...row } as Record<string, unknown>
|
||||
r.price = close
|
||||
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
|
||||
const m = Number(row.market)
|
||||
r.market = m === 1 ? 'SH' : m === 2 ? 'BJ' : 'SZ'
|
||||
return r as RankRow
|
||||
})
|
||||
}
|
||||
|
||||
// ── 自选 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 列出全部自选。 */
|
||||
export async function fetchWatchlist(): Promise<WatchlistResponse> {
|
||||
const resp = await fetch(`${BASE}/watchlist`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as WatchlistResponse
|
||||
}
|
||||
|
||||
/** 加入自选(幂等)。 */
|
||||
export async function addWatchItem(market: string, code: string, name = ''): Promise<void> {
|
||||
const resp = await fetch(`${BASE}/watchlist`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ market, code, name }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
}
|
||||
|
||||
/** 移除自选。 */
|
||||
export async function removeWatchItem(market: string, code: string): Promise<void> {
|
||||
const resp = await fetch(`${BASE}/watchlist/${market}/${code}`, { method: 'DELETE' })
|
||||
if (!resp.ok) await throwError(resp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
// 板块详情弹窗(行业/概念板块指数):左 分时/日K(含技术指标),
|
||||
// 右 成分股涨跌榜(点击行打开个股弹窗,快速定位人气股)。
|
||||
// 板块代码 881xxx/885xxx(沪市板块指数),分时与日K走与个股相同的
|
||||
// /minute、/bars 接口(协议天然支持板块)。
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchBars,
|
||||
fetchBoardMembers,
|
||||
fetchMinute,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import { dirClass, fmt2, fmtPctSigned } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, MinutePoint, RankRow } from '../types'
|
||||
import IntradayChart from './IntradayChart.vue'
|
||||
import StockKline, { type Overlay, type SubPane } from './StockKline.vue'
|
||||
import StockDialog from './StockDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string // 881106 / 885418 ...
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'watchlist-changed': [] }>()
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
const quote = computed(() => quoteStore.getQuote(`SH${props.code}`))
|
||||
|
||||
const changePct = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
})
|
||||
|
||||
const tab = ref<'minute' | 'daily'>('minute')
|
||||
const overlay = ref<Overlay>('ma')
|
||||
const subPane = ref<SubPane>('macd')
|
||||
|
||||
const overlayOptions: Array<{ value: Overlay; label: string }> = [
|
||||
{ value: 'ma', label: 'MA' },
|
||||
{ value: 'boll', label: 'BOLL' },
|
||||
{ value: 'ema', label: 'EMA' },
|
||||
{ value: 'none', label: '主图无' },
|
||||
]
|
||||
const subOptions: Array<{ value: SubPane; label: string }> = [
|
||||
{ value: 'macd', label: 'MACD' },
|
||||
{ value: 'kdj', label: 'KDJ' },
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
{ value: 'none', label: '副图无' },
|
||||
]
|
||||
|
||||
const minutePoints = ref<MinutePoint[]>([])
|
||||
const dailyBars = ref<Bar[]>([])
|
||||
const minuteError = ref('')
|
||||
const dailyError = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadMinute() {
|
||||
minuteError.value = ''
|
||||
try {
|
||||
minutePoints.value = await fetchMinute('SH', props.code)
|
||||
} catch (e) {
|
||||
minutePoints.value = []
|
||||
minuteError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDaily() {
|
||||
dailyError.value = ''
|
||||
try {
|
||||
const bars = await fetchBars('SH', props.code, 'DAY', undefined, undefined)
|
||||
if (bars.length === 0) throw new Error('该板块无日K数据')
|
||||
dailyBars.value = bars.slice(-250)
|
||||
} catch (e) {
|
||||
dailyBars.value = []
|
||||
dailyError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
loading.value = true
|
||||
await Promise.all([loadMinute(), loadDaily()])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadCharts)
|
||||
watch(() => props.code, loadCharts)
|
||||
|
||||
// ── 自选 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const inWatchlist = ref(false)
|
||||
const watchBusy = ref(false)
|
||||
|
||||
async function refreshWatchState() {
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
inWatchlist.value = resp.items.some((i) => i.symbol === `SH${props.code}`)
|
||||
} catch {
|
||||
inWatchlist.value = false
|
||||
}
|
||||
}
|
||||
onMounted(refreshWatchState)
|
||||
watch(() => props.code, refreshWatchState)
|
||||
|
||||
async function toggleWatch() {
|
||||
watchBusy.value = true
|
||||
try {
|
||||
if (inWatchlist.value) {
|
||||
await removeWatchItem('SH', props.code)
|
||||
inWatchlist.value = false
|
||||
} else {
|
||||
await addWatchItem('SH', props.code, props.name)
|
||||
inWatchlist.value = true
|
||||
}
|
||||
emit('watchlist-changed')
|
||||
} catch (e) {
|
||||
alert(formatError(e))
|
||||
} finally {
|
||||
watchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const preClose = computed(() => quote.value?.pre_close ?? null)
|
||||
|
||||
// ── 成分股涨跌榜 ────────────────────────────────────────────────────────────
|
||||
|
||||
const members = ref<RankRow[]>([])
|
||||
const memberOrder = ref<'DESC' | 'ASC'>('DESC')
|
||||
const membersError = ref('')
|
||||
const membersLoading = ref(false)
|
||||
|
||||
async function loadMembers() {
|
||||
membersLoading.value = true
|
||||
membersError.value = ''
|
||||
try {
|
||||
members.value = await fetchBoardMembers(props.code, 120, memberOrder.value)
|
||||
} catch (e) {
|
||||
members.value = []
|
||||
membersError.value = formatError(e)
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMemberOrder() {
|
||||
memberOrder.value = memberOrder.value === 'DESC' ? 'ASC' : 'DESC'
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
onMounted(loadMembers)
|
||||
watch(() => props.code, loadMembers)
|
||||
|
||||
/** 成分股行点击 → 叠开个股弹窗。 */
|
||||
const stockDlg = ref<{ market: string; code: string; name: string } | null>(null)
|
||||
|
||||
function openMember(r: RankRow) {
|
||||
stockDlg.value = {
|
||||
market: String(r.market ?? ''),
|
||||
code: String(r.code ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="dlg-mask" @click.self="emit('close')">
|
||||
<div class="dlg" :key="code">
|
||||
<div class="dlg-head">
|
||||
<div class="head-left">
|
||||
<span class="board-name">{{ name }}</span>
|
||||
<span class="board-code mono">SH{{ code }} · 板块指数</span>
|
||||
</div>
|
||||
<div class="head-quote">
|
||||
<span v-if="quote" class="price mono" :class="dirClass(changePct)">{{ fmt2(quote.price) }}</span>
|
||||
<span v-if="changePct !== null" class="chg mono" :class="dirClass(changePct)">
|
||||
{{ fmtPctSigned(changePct) }}
|
||||
</span>
|
||||
<button
|
||||
class="watch-btn"
|
||||
:class="{ watched: inWatchlist }"
|
||||
:disabled="watchBusy"
|
||||
@click="toggleWatch"
|
||||
>
|
||||
{{ inWatchlist ? '★ 移除自选' : '☆ 加入自选' }}
|
||||
</button>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlg-body">
|
||||
<!-- 左:分时 / 日K -->
|
||||
<div class="chart-area">
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'minute' }" @click="tab = 'minute'">分时</button>
|
||||
<button :class="{ active: tab === 'daily' }" @click="tab = 'daily'">日K</button>
|
||||
<template v-if="tab === 'daily'">
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in overlayOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: overlay === opt.value }"
|
||||
@click="overlay = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in subOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: subPane === opt.value }"
|
||||
@click="subPane = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="loading" class="chart-msg">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-if="tab === 'minute'">
|
||||
<div v-if="minuteError" class="chart-msg error">分时:{{ minuteError }}</div>
|
||||
<IntradayChart v-else :points="minutePoints" :pre-close="preClose" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="dailyError" class="chart-msg error">日K:{{ dailyError }}</div>
|
||||
<StockKline v-else :bars="dailyBars" :overlay="overlay" :sub-pane="subPane" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右:成分股涨跌榜 -->
|
||||
<div class="members-panel">
|
||||
<div class="members-head">
|
||||
<h3>成分股 <span class="dim members-count">({{ members.length }})</span></h3>
|
||||
<button class="order-btn" @click="toggleMemberOrder">
|
||||
{{ memberOrder === 'DESC' ? '↓ 涨幅降序' : '↑ 涨幅升序' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="membersError" class="chart-msg error">{{ membersError }}</div>
|
||||
<div v-else-if="membersLoading" class="chart-msg">成分股加载中…</div>
|
||||
<div v-else class="members-list">
|
||||
<div
|
||||
v-for="(r, i) in members"
|
||||
:key="i"
|
||||
class="member-row"
|
||||
@click="openMember(r)"
|
||||
>
|
||||
<span class="m-idx">{{ i + 1 }}</span>
|
||||
<span class="m-name">{{ r.name }}</span>
|
||||
<span class="m-code mono dim">{{ r.code }}</span>
|
||||
<span class="m-price mono">{{ fmt2(r.price) }}</span>
|
||||
<span class="m-pct mono" :class="dirClass(r.change_pct)">
|
||||
{{ fmtPctSigned(r.change_pct) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StockDialog
|
||||
v-if="stockDlg"
|
||||
:market="stockDlg.market"
|
||||
:code="stockDlg.code"
|
||||
:name="stockDlg.name"
|
||||
@close="stockDlg = null"
|
||||
@watchlist-changed="emit('watchlist-changed')"
|
||||
/>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.dlg {
|
||||
width: min(1280px, 96vw);
|
||||
max-height: 94vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dlg-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.board-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.board-code {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.head-quote {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chg {
|
||||
font-size: 13px;
|
||||
}
|
||||
.watch-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
margin-left: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
.watch-btn.watched {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.close-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
.dlg-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
.members-panel {
|
||||
width: 330px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.members-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.members-head h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.members-count {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
.order-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.members-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
.member-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 66px 58px 62px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.member-row:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.m-idx {
|
||||
color: var(--text-dim);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.m-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-code {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.m-price {
|
||||
text-align: right;
|
||||
}
|
||||
.m-pct {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ind-group {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
.chart-msg {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-msg.error {
|
||||
color: var(--up);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
// 分时图(ECharts):价格线 + 渐变面积 + 均价线 + 昨收基准虚线 + 成交量副图。
|
||||
// 单日模式:y 轴围绕昨收对称(分时图惯例)。
|
||||
// 多日模式(dayMarks 提供每天起始索引):y 轴自适应全段,天与天之间
|
||||
// markLine 竖分隔,x 轴只在每天首点显示日期(借鉴多日分时惯例)。
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||
import { fmt2 } from '../format'
|
||||
import type { MinutePoint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
points: MinutePoint[]
|
||||
preClose?: number | null
|
||||
/** 多日模式:每天起始索引 + 日期标签。 */
|
||||
dayMarks?: Array<{ start: number; date: string }>
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const isMultiDay = () => (props.dayMarks?.length ?? 0) > 1
|
||||
|
||||
function render() {
|
||||
if (!container.value) return
|
||||
if (props.points.length === 0) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
chart.setOption(buildOption(), true)
|
||||
}
|
||||
|
||||
function buildOption(): echarts.EChartsCoreOption {
|
||||
const pts = props.points
|
||||
const multi = isMultiDay()
|
||||
// 基准(量柱首色与 tooltip 涨跌幅):单日用昨收,多日用全段首价
|
||||
const base = !multi && props.preClose && props.preClose > 0 ? props.preClose : pts[0]?.price ?? 0
|
||||
|
||||
const times = pts.map((p) => p.datetime.slice(11, 16))
|
||||
const prices = pts.map((p) => p.price)
|
||||
|
||||
// 均价线:累计成交额近似 = Σ(price×vol),均价 = cumAmount / cumVol
|
||||
let cumAmt = 0
|
||||
let cumVol = 0
|
||||
const avgPrices = pts.map((p) => {
|
||||
cumAmt += p.price * Math.max(p.vol, 0)
|
||||
cumVol += Math.max(p.vol, 0)
|
||||
return cumVol > 0 ? cumAmt / cumVol : p.price
|
||||
})
|
||||
|
||||
// y 轴范围:单日围绕昨收对称;多日自适应全段
|
||||
const hi = Math.max(...prices)
|
||||
const lo = Math.min(...prices)
|
||||
let yMax: number
|
||||
let yMin: number
|
||||
if (multi) {
|
||||
const pad = (hi - lo) * 0.08 || hi * 0.002
|
||||
yMax = hi + pad
|
||||
yMin = lo - pad
|
||||
} else {
|
||||
const hi2 = Math.max(base, hi)
|
||||
const lo2 = Math.min(base, lo)
|
||||
const pad = Math.max((hi2 - lo2) * 0.1, base * 0.0025)
|
||||
yMax = hi2 + pad
|
||||
yMin = lo2 - pad
|
||||
}
|
||||
|
||||
// 量柱颜色:与前一分钟价格比较(红涨绿跌),首柱与基准比
|
||||
const volColors = pts.map((p, i) => {
|
||||
const prev = i > 0 ? pts[i - 1].price : base
|
||||
return p.price >= prev ? UP_COLOR : DOWN_COLOR
|
||||
})
|
||||
|
||||
// x 轴刻度:单日在关键时点;多日只在每天首点显示 MM-DD
|
||||
const markStarts = new Map((props.dayMarks ?? []).map((m) => [m.start, m.date]))
|
||||
const labelInterval = multi
|
||||
? (index: number) => markStarts.has(index)
|
||||
: (index: number) => [0, 60, 120, 121, 180, 239].includes(index)
|
||||
const labelFormatter = multi
|
||||
? (v: string) => {
|
||||
const idx = times.indexOf(v)
|
||||
const d = markStarts.get(idx)
|
||||
return d ? d.slice(5) : ''
|
||||
}
|
||||
: (v: string) => v
|
||||
|
||||
// 多日分隔线 + 单日昨收基准线
|
||||
const markLines: Array<Record<string, unknown>> = multi
|
||||
? (props.dayMarks ?? [])
|
||||
.filter((m) => m.start > 0)
|
||||
.map((m) => ({ xAxis: m.start - 0.5, lineStyle: { color: '#3a3f4d', type: 'dashed', width: 1 } }))
|
||||
: [{ yAxis: base, lineStyle: { color: '#5c6370', type: 'dashed', width: 1 } }]
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
animation: false,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: (params: unknown) => {
|
||||
const arr = params as Array<{ dataIndex: number }>
|
||||
const i = arr[0]?.dataIndex ?? 0
|
||||
const p = pts[i]
|
||||
if (!p) return ''
|
||||
const color = p.price >= base ? UP_COLOR : DOWN_COLOR
|
||||
return `${p.datetime.slice(5, 16)}<br/>价 <b style="color:${color}">${fmt2(p.price)}</b><br/>均 ${fmt2(avgPrices[i])}<br/>量 ${p.vol}`
|
||||
},
|
||||
},
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
grid: [
|
||||
{ left: 56, right: 16, top: 16, height: '58%' },
|
||||
{ left: 56, right: 16, top: '74%', height: '20%' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 0,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
boundaryGap: false,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { interval: labelInterval, formatter: labelFormatter },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
gridIndex: 0,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
axisLabel: { formatter: (v: number) => fmt2(v) },
|
||||
splitLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
},
|
||||
{
|
||||
gridIndex: 1,
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '价格',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: prices,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1.3, color: '#4a9eff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(74,158,255,0.25)' },
|
||||
{ offset: 1, color: 'rgba(74,158,255,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: markLines,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '均价',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: avgPrices,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: pts.map((p, i) => ({
|
||||
value: p.vol,
|
||||
itemStyle: { color: volColors[i] },
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => [props.points, props.dayMarks], render)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="intraday-chart"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intraday-chart {
|
||||
width: 100%;
|
||||
height: 440px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// 市场情绪雷达(借鉴 tick-stock-panel 看板 EmotionRadar):
|
||||
// 四维 0~100 —— 赚钱效应(涨跌比)/ 量能(较昨日成交)/ 动量(指数5日涨幅)/
|
||||
// 趋势(上证指数相对 MA20)。中心到顶端的填充区 + 综合分。
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts from '../echarts-setup'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 四维分值(0~100,50 为中性)。 */
|
||||
values: { profit: number; volume: number; momentum: number; trend: number }
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const DIMS = [
|
||||
{ name: '赚钱效应', max: 100 },
|
||||
{ name: '量能', max: 100 },
|
||||
{ name: '动量', max: 100 },
|
||||
{ name: '趋势', max: 100 },
|
||||
]
|
||||
|
||||
function render() {
|
||||
if (!container.value) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
const v = props.values
|
||||
chart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
radar: {
|
||||
indicator: DIMS,
|
||||
radius: '62%',
|
||||
center: ['50%', '52%'],
|
||||
splitNumber: 4,
|
||||
axisName: { color: '#8b919e', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
splitArea: { show: false },
|
||||
axisLine: { lineStyle: { color: '#2a2e3a' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
symbol: 'circle',
|
||||
symbolSize: 3,
|
||||
data: [
|
||||
{
|
||||
value: [v.profit, v.volume, v.momentum, v.trend],
|
||||
itemStyle: { color: '#4a9eff' },
|
||||
lineStyle: { color: '#4a9eff', width: 1.5 },
|
||||
areaStyle: { color: 'rgba(74,158,255,0.22)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => props.values, render, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="mood-radar"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mood-radar {
|
||||
width: 100%;
|
||||
height: 190px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
// 表格行内 SVG 迷你分时图(无 ECharts 实例,几千行表格也轻)。
|
||||
// 相对昨收着色:线上方偏红、下方偏绿(简化:整线按首尾涨跌着色)。
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 分时价格序列。 */
|
||||
prices: number[]
|
||||
/** 昨收基准(可选,画虚线)。 */
|
||||
base?: number | null
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
|
||||
const W = computed(() => props.width ?? 92)
|
||||
const H = computed(() => props.height ?? 28)
|
||||
|
||||
const geom = computed(() => {
|
||||
const pts = props.prices.filter((p) => Number.isFinite(p))
|
||||
if (pts.length < 2) return null
|
||||
const base = props.base ?? pts[0]
|
||||
const all = props.base != null ? [...pts, props.base] : pts
|
||||
const min = Math.min(...all)
|
||||
const max = Math.max(...all)
|
||||
const span = max - min || 1
|
||||
const dx = W.value / (pts.length - 1)
|
||||
const y = (v: number) => H.value - 2 - ((v - min) / span) * (H.value - 4)
|
||||
const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * dx).toFixed(1)},${y(p).toFixed(1)}`).join('')
|
||||
const area = `${path}L${W.value},${H.value}L0,${H.value}Z`
|
||||
const last = pts[pts.length - 1]
|
||||
const rising = last >= base
|
||||
return { path, area, baseY: y(base), rising, baseValid: props.base != null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="W" :height="H" class="sparkline" viewBox="0 0 92 28" preserveAspectRatio="none">
|
||||
<template v-if="geom">
|
||||
<line
|
||||
v-if="geom.baseValid"
|
||||
x1="0"
|
||||
:y1="geom.baseY"
|
||||
:x2="W"
|
||||
:y2="geom.baseY"
|
||||
stroke="#5c6370"
|
||||
stroke-width="0.7"
|
||||
stroke-dasharray="3 2"
|
||||
/>
|
||||
<path :d="geom.area" :fill="geom.rising ? 'rgba(239,65,70,0.12)' : 'rgba(24,160,88,0.12)'" />
|
||||
<path
|
||||
:d="geom.path"
|
||||
fill="none"
|
||||
:stroke="geom.rising ? '#ef4146' : '#18a058'"
|
||||
stroke-width="1.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</template>
|
||||
<text v-else x="46" y="18" text-anchor="middle" fill="#5c6370" font-size="9">加载中…</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup lang="ts">
|
||||
// 个股预览对话框:头部实时报价 + 加/移除自选 + 左侧五档盘口
|
||||
// + 右侧 分时/日K tab(日K 支持技术指标切换)。
|
||||
// 报价来自全局 SSE store;图表按需拉取,分时/日K 独立容错,
|
||||
// 指数代码在个股 /bars 拿不到时自动回退 /bars/index。
|
||||
|
||||
import { computed, onMounted, ref, toRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchBars,
|
||||
fetchHistoryMinute,
|
||||
fetchIndexBars,
|
||||
fetchMinute,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, MinutePoint } from '../types'
|
||||
import IntradayChart from './IntradayChart.vue'
|
||||
import StockKline, { type Overlay, type SubPane } from './StockKline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
market: string
|
||||
code: string
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; 'watchlist-changed': [] }>()
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
const quote = computed(() => quoteStore.getQuote(`${props.market}${props.code}`))
|
||||
|
||||
const changePct = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
})
|
||||
|
||||
// ── tab / 指标 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const tab = ref<'minute' | 'daily'>('minute')
|
||||
const overlay = ref<Overlay>('ma')
|
||||
const subPane = ref<SubPane>('macd')
|
||||
const minuteDays = ref<1 | 3 | 5>(1)
|
||||
const DAY_OPTIONS: Array<{ value: 1 | 3 | 5; label: string }> = [
|
||||
{ value: 1, label: '1日' },
|
||||
{ value: 3, label: '3日' },
|
||||
{ value: 5, label: '5日' },
|
||||
]
|
||||
|
||||
const overlayOptions: Array<{ value: Overlay; label: string }> = [
|
||||
{ value: 'ma', label: 'MA' },
|
||||
{ value: 'boll', label: 'BOLL' },
|
||||
{ value: 'ema', label: 'EMA' },
|
||||
{ value: 'none', label: '主图无' },
|
||||
]
|
||||
const subOptions: Array<{ value: SubPane; label: string }> = [
|
||||
{ value: 'macd', label: 'MACD' },
|
||||
{ value: 'kdj', label: 'KDJ' },
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
{ value: 'none', label: '副图无' },
|
||||
]
|
||||
|
||||
// ── 图表数据(独立容错) ────────────────────────────────────────────────────
|
||||
|
||||
const minutePoints = ref<MinutePoint[]>([])
|
||||
/** 多日分时的天分隔标记(每天起始索引 + 日期)。 */
|
||||
const minuteDayMarks = ref<Array<{ start: number; date: string }>>([])
|
||||
const dailyBars = ref<Bar[]>([])
|
||||
const minuteError = ref('')
|
||||
const dailyError = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadMinute() {
|
||||
minuteError.value = ''
|
||||
try {
|
||||
// 交易日从日K尾部取(末日=今天),今天走 /minute,历史日走 /minute/history
|
||||
const dates = dailyBars.value.slice(-minuteDays.value).map((b) => b.datetime.slice(0, 10))
|
||||
const all: MinutePoint[] = []
|
||||
const marks: Array<{ start: number; date: string }> = []
|
||||
for (const d of dates) {
|
||||
const dateInt = Number(d.replaceAll('-', ''))
|
||||
const pts =
|
||||
d === new Date().toISOString().slice(0, 10)
|
||||
? await fetchMinute(props.market, props.code)
|
||||
: await fetchHistoryMinute(props.market, props.code, dateInt)
|
||||
if (pts.length === 0) continue
|
||||
marks.push({ start: all.length, date: d })
|
||||
all.push(...pts)
|
||||
}
|
||||
minutePoints.value = all
|
||||
minuteDayMarks.value = marks
|
||||
} catch (e) {
|
||||
minutePoints.value = []
|
||||
minuteDayMarks.value = []
|
||||
minuteError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(minuteDays, loadMinute)
|
||||
|
||||
async function loadDaily() {
|
||||
dailyError.value = ''
|
||||
try {
|
||||
let bars = await fetchBars(props.market, props.code, 'DAY', undefined, undefined)
|
||||
// 指数(或 /bars 空数据的服务器)回退指数接口
|
||||
if (bars.length === 0) bars = await fetchIndexBars(props.market, props.code, 250)
|
||||
if (bars.length === 0) throw new Error('无日K数据(可能为新股或代码有误)')
|
||||
dailyBars.value = bars.slice(-250)
|
||||
} catch (e) {
|
||||
dailyBars.value = []
|
||||
dailyError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
loading.value = true
|
||||
// 先日K后分时:多日分时的交易日列表取自 dailyBars 尾部
|
||||
await loadDaily()
|
||||
await loadMinute()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadCharts)
|
||||
watch([() => props.market, () => props.code], loadCharts)
|
||||
|
||||
// ── 自选状态 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const inWatchlist = ref(false)
|
||||
const watchBusy = ref(false)
|
||||
|
||||
async function refreshWatchState() {
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
inWatchlist.value = resp.items.some((i) => i.symbol === `${props.market}${props.code}`)
|
||||
} catch {
|
||||
inWatchlist.value = false
|
||||
}
|
||||
}
|
||||
onMounted(refreshWatchState)
|
||||
watch([() => props.market, () => props.code], refreshWatchState)
|
||||
|
||||
async function toggleWatch() {
|
||||
watchBusy.value = true
|
||||
try {
|
||||
if (inWatchlist.value) {
|
||||
await removeWatchItem(props.market, props.code)
|
||||
inWatchlist.value = false
|
||||
} else {
|
||||
await addWatchItem(props.market, props.code, props.name ?? '')
|
||||
inWatchlist.value = true
|
||||
}
|
||||
emit('watchlist-changed')
|
||||
} catch (e) {
|
||||
alert(formatError(e))
|
||||
} finally {
|
||||
watchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 五档 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bids = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q) return []
|
||||
return [1, 2, 3, 4, 5].map((i) => ({
|
||||
level: i,
|
||||
price: (q as unknown as Record<string, number | null>)[`bid${i}`] ?? null,
|
||||
vol: (q as unknown as Record<string, number | null>)[`bid_vol${i}`] ?? null,
|
||||
}))
|
||||
})
|
||||
const asks = computed(() => {
|
||||
const q = quote.value
|
||||
if (!q) return []
|
||||
return [5, 4, 3, 2, 1].map((i) => ({
|
||||
level: i,
|
||||
price: (q as unknown as Record<string, number | null>)[`ask${i}`] ?? null,
|
||||
vol: (q as unknown as Record<string, number | null>)[`ask_vol${i}`] ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
const maxDepthVol = computed(() => {
|
||||
let m = 1
|
||||
for (const lv of [...bids.value, ...asks.value]) m = Math.max(m, lv.vol ?? 0)
|
||||
return m
|
||||
})
|
||||
|
||||
const preClose = toRef(() => quote.value?.pre_close ?? null)
|
||||
|
||||
// ── 一键寻优(跳 /optimize 并自动跑全策略预设网格) ─────────────────────────
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function gotoOptimize() {
|
||||
emit('close')
|
||||
router.push({ path: '/optimize', query: { code: props.code, autoAll: '1' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="dlg-mask" @click.self="emit('close')">
|
||||
<div class="dlg" :key="`${market}${code}`">
|
||||
<!-- 头部:名称 + 实时报价 + 自选开关 -->
|
||||
<div class="dlg-head">
|
||||
<div class="head-left">
|
||||
<span class="stock-name">{{ name || `${market}${code}` }}</span>
|
||||
<span class="stock-code mono">{{ market }}{{ code }}</span>
|
||||
<span v-if="quote?.server_time" class="srv-time mono">{{ quote.server_time.slice(0, 8) }}</span>
|
||||
</div>
|
||||
<div class="head-quote">
|
||||
<span class="price mono" :class="dirClass(changePct)">{{ fmt2(quote?.price) }}</span>
|
||||
<span v-if="changePct !== null" class="chg mono" :class="dirClass(changePct)">
|
||||
{{ fmt2((quote?.price ?? 0) - (quote?.pre_close ?? 0)) }}
|
||||
{{ fmtPctSigned(changePct) }}
|
||||
</span>
|
||||
<button
|
||||
class="watch-btn"
|
||||
:class="{ watched: inWatchlist }"
|
||||
:disabled="watchBusy"
|
||||
:title="inWatchlist ? '移除自选' : '加入自选'"
|
||||
@click="toggleWatch"
|
||||
>
|
||||
{{ inWatchlist ? '★ 移除自选' : '☆ 加入自选' }}
|
||||
</button>
|
||||
<button class="watch-btn" title="用全部策略的预设网格对该股一键寻优" @click="gotoOptimize">
|
||||
⚙ 一键寻优
|
||||
</button>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlg-body">
|
||||
<!-- 五档盘口 -->
|
||||
<div class="depth">
|
||||
<div class="depth-title">五档盘口</div>
|
||||
<table class="depth-table">
|
||||
<tbody>
|
||||
<tr v-for="lv in asks" :key="`a${lv.level}`">
|
||||
<td class="lv">卖{{ lv.level }}</td>
|
||||
<td class="mono" :class="lv.price && quote?.pre_close ? dirClass(lv.price - quote.pre_close) : 'flat'">
|
||||
{{ fmt2(lv.price) }}
|
||||
</td>
|
||||
<td class="vol-cell">
|
||||
<div class="vol-bar ask" :style="{ width: `${((lv.vol ?? 0) / maxDepthVol) * 100}%` }"></div>
|
||||
<span class="mono">{{ lv.vol ? Math.round(lv.vol) : '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="sep-row">
|
||||
<td colspan="3"><div class="sep"></div></td>
|
||||
</tr>
|
||||
<tr v-for="lv in bids" :key="`b${lv.level}`">
|
||||
<td class="lv">买{{ lv.level }}</td>
|
||||
<td class="mono" :class="lv.price && quote?.pre_close ? dirClass(lv.price - quote.pre_close) : 'flat'">
|
||||
{{ fmt2(lv.price) }}
|
||||
</td>
|
||||
<td class="vol-cell">
|
||||
<div class="vol-bar bid" :style="{ width: `${((lv.vol ?? 0) / maxDepthVol) * 100}%` }"></div>
|
||||
<span class="mono">{{ lv.vol ? Math.round(lv.vol) : '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="quote" class="depth-stats mono">
|
||||
<div><span class="dim2">量</span>{{ fmtVol(quote.vol) }}</div>
|
||||
<div><span class="dim2">额</span>{{ fmtAmount(quote.amount) }}</div>
|
||||
<div><span class="dim2">高</span>{{ fmt2(quote.high) }}</div>
|
||||
<div><span class="dim2">低</span>{{ fmt2(quote.low) }}</div>
|
||||
<div><span class="dim2">开</span>{{ fmt2(quote.open) }}</div>
|
||||
<div><span class="dim2">昨收</span>{{ fmt2(quote.pre_close) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<div class="chart-area">
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'minute' }" @click="tab = 'minute'">分时</button>
|
||||
<button :class="{ active: tab === 'daily' }" @click="tab = 'daily'">日K</button>
|
||||
<span v-if="tab === 'minute'" class="ind-group">
|
||||
<button
|
||||
v-for="opt in DAY_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: minuteDays === opt.value }"
|
||||
@click="minuteDays = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<template v-if="tab === 'daily'">
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in overlayOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: overlay === opt.value }"
|
||||
@click="overlay = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
<span class="ind-group">
|
||||
<button
|
||||
v-for="opt in subOptions"
|
||||
:key="opt.value"
|
||||
class="chip"
|
||||
:class="{ on: subPane === opt.value }"
|
||||
@click="subPane = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="loading" class="chart-msg">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-if="tab === 'minute'">
|
||||
<div v-if="minuteError" class="chart-msg error">分时:{{ minuteError }}</div>
|
||||
<IntradayChart
|
||||
v-else
|
||||
:points="minutePoints"
|
||||
:pre-close="preClose"
|
||||
:day-marks="minuteDayMarks"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="dailyError" class="chart-msg error">日K:{{ dailyError }}</div>
|
||||
<StockKline v-else :bars="dailyBars" :overlay="overlay" :sub-pane="subPane" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.dlg {
|
||||
width: min(1280px, 96vw);
|
||||
max-height: 94vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dlg-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.stock-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stock-code {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.srv-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.head-quote {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.price {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chg {
|
||||
font-size: 13px;
|
||||
}
|
||||
.watch-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
margin-left: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
.watch-btn.watched {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.close-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
.dlg-body {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.depth {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 14px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.depth-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.depth-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.depth-table td {
|
||||
padding: 2.5px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lv {
|
||||
color: var(--text-dim);
|
||||
width: 34px;
|
||||
}
|
||||
.vol-cell {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
}
|
||||
.vol-bar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
opacity: 0.18;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.vol-bar.ask {
|
||||
background: var(--up);
|
||||
}
|
||||
.vol-bar.bid {
|
||||
background: var(--down);
|
||||
}
|
||||
.vol-cell span {
|
||||
position: relative;
|
||||
}
|
||||
.sep {
|
||||
border-top: 1px dashed var(--border);
|
||||
margin: 3px 0;
|
||||
}
|
||||
.depth-stats {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.depth-stats > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dim2 {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ind-group {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.chip {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
.chart-msg {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chart-msg.error {
|
||||
color: var(--up);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
// 个股日K图:candlestick + 成交量 + 可选主图指标(MA/BOLL/EMA)
|
||||
// + 可选副图指标(MACD/KDJ/RSI)。指标由父组件传选择,前端本地计算
|
||||
// (indicators.ts,与后端 MyTT 同口径)。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||
import { boll, ema, macd as calcMacd, kdj as calcKdj, rsi as calcRsi } from '../indicators'
|
||||
import { fmt2 } from '../format'
|
||||
import type { Bar } from '../types'
|
||||
|
||||
export type Overlay = 'none' | 'ma' | 'boll' | 'ema'
|
||||
export type SubPane = 'none' | 'macd' | 'kdj' | 'rsi'
|
||||
|
||||
const props = defineProps<{
|
||||
bars: Bar[]
|
||||
overlay?: Overlay
|
||||
subPane?: SubPane
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
const closes = computed(() => props.bars.map((b) => b.close))
|
||||
const highs = computed(() => props.bars.map((b) => b.high))
|
||||
const lows = computed(() => props.bars.map((b) => b.low))
|
||||
|
||||
interface LineSpec {
|
||||
name: string
|
||||
data: Array<number | null>
|
||||
color: string
|
||||
dashed?: boolean
|
||||
}
|
||||
|
||||
/** 主图指标线(随 overlay 切换)。 */
|
||||
const overlayLines = computed<LineSpec[]>(() => {
|
||||
const c = closes.value
|
||||
if (props.overlay === 'ma') {
|
||||
const colors = ['#f0a020', '#4a9eff', '#c084fc', '#22d3ee']
|
||||
return [5, 10, 20, 60].map((n, i) => ({
|
||||
name: `MA${n}`,
|
||||
data: maLocal(c, n),
|
||||
color: colors[i],
|
||||
}))
|
||||
}
|
||||
if (props.overlay === 'ema') {
|
||||
return [
|
||||
{ name: 'EMA12', data: ema(c, 12), color: '#f0a020' },
|
||||
{ name: 'EMA26', data: ema(c, 26), color: '#4a9eff' },
|
||||
]
|
||||
}
|
||||
if (props.overlay === 'boll') {
|
||||
const { mid, upper, lower } = boll(c)
|
||||
return [
|
||||
{ name: 'BOLL中轨', data: mid, color: '#f0a020' },
|
||||
{ name: '上轨', data: upper, color: '#c084fc', dashed: true },
|
||||
{ name: '下轨', data: lower, color: '#c084fc', dashed: true },
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
function maLocal(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i]
|
||||
if (i >= n) sum -= data[i - n]
|
||||
out.push(i >= n - 1 ? sum / n : null)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!container.value || props.bars.length === 0) return
|
||||
chart ??= echarts.init(container.value, 'dark')
|
||||
chart.setOption(buildOption(), true)
|
||||
}
|
||||
|
||||
function buildOption(): echarts.EChartsCoreOption {
|
||||
const bars = props.bars
|
||||
const dates = bars.map((b) => b.datetime.slice(0, 10))
|
||||
const ohlc = bars.map((b) => [b.open, b.close, b.low, b.high])
|
||||
const hasSub = props.subPane !== 'none'
|
||||
const start = Math.max(0, 100 - (120 / bars.length) * 100)
|
||||
|
||||
// 布局:K 区 / 量区 /(可选)副图区
|
||||
const grids = [
|
||||
{ left: 56, right: 16, top: 28, height: hasSub ? '48%' : '60%' },
|
||||
{ left: 56, right: 16, top: hasSub ? '64%' : '74%', height: hasSub ? '13%' : '18%' },
|
||||
]
|
||||
const xAxes: echarts.EChartsCoreOption[] = [
|
||||
{ type: 'category', gridIndex: 0, data: dates, boundaryGap: true, axisLabel: { show: false }, axisTick: { show: false } },
|
||||
{ type: 'category', gridIndex: 1, data: dates, boundaryGap: true, axisTick: { show: false } },
|
||||
]
|
||||
const yAxes: echarts.EChartsCoreOption[] = [
|
||||
{ gridIndex: 0, scale: true, axisLabel: { formatter: (v: number) => fmt2(v) }, splitLine: { lineStyle: { color: '#2a2e3a' } } },
|
||||
{ gridIndex: 1, axisLabel: { show: false }, splitLine: { show: false } },
|
||||
]
|
||||
if (hasSub) {
|
||||
grids.push({ left: 56, right: 16, top: '81%', height: '13%' } as never)
|
||||
xAxes.push({ type: 'category', gridIndex: 2, data: dates, boundaryGap: true, axisTick: { show: false } })
|
||||
yAxes.push({ gridIndex: 2, scale: true, axisLabel: { fontSize: 10 }, splitLine: { show: false } })
|
||||
}
|
||||
|
||||
const series: echarts.EChartsCoreOption[] = [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: UP_COLOR,
|
||||
color0: DOWN_COLOR,
|
||||
borderColor: UP_COLOR,
|
||||
borderColor0: DOWN_COLOR,
|
||||
},
|
||||
},
|
||||
...overlayLines.value.map(
|
||||
(l) =>
|
||||
({
|
||||
name: l.name,
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: l.data,
|
||||
showSymbol: false,
|
||||
connectNulls: false,
|
||||
lineStyle: { width: 1, color: l.color, type: l.dashed ? 'dashed' : 'solid' },
|
||||
itemStyle: { color: l.color },
|
||||
}) as never,
|
||||
),
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: bars.map((b) => ({
|
||||
value: b.vol,
|
||||
itemStyle: { color: b.close >= b.open ? UP_COLOR : DOWN_COLOR },
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
]
|
||||
|
||||
// 副图指标
|
||||
const c = closes.value
|
||||
if (props.subPane === 'macd') {
|
||||
const { dif, dea, hist } = calcMacd(c)
|
||||
series.push(
|
||||
{
|
||||
name: 'DIF',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: dif,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'DEA',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: dea,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'MACD',
|
||||
type: 'bar',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: hist.map((v) => ({
|
||||
value: v,
|
||||
itemStyle: { color: (v ?? 0) >= 0 ? UP_COLOR : DOWN_COLOR },
|
||||
})),
|
||||
barWidth: '50%',
|
||||
},
|
||||
)
|
||||
} else if (props.subPane === 'kdj') {
|
||||
const { k, d, j } = calcKdj(highs.value, lows.value, c)
|
||||
series.push(
|
||||
{
|
||||
name: 'K',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: k,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'D',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: d,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'J',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: j,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#c084fc' },
|
||||
},
|
||||
)
|
||||
} else if (props.subPane === 'rsi') {
|
||||
series.push(
|
||||
{
|
||||
name: 'RSI6',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 6),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#f0a020' },
|
||||
},
|
||||
{
|
||||
name: 'RSI12',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 12),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#4a9eff' },
|
||||
},
|
||||
{
|
||||
name: 'RSI24',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: calcRsi(c, 24),
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 1, color: '#c084fc' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const legendNames = ['K线', ...overlayLines.value.map((l) => l.name)]
|
||||
if (hasSub) {
|
||||
legendNames.push(
|
||||
props.subPane === 'macd' ? 'DIF,DEA' : props.subPane === 'kdj' ? 'K,D,J' : 'RSI',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
// OHLC/指标全部两位小数(用户约定:价格一律 2 位)
|
||||
valueFormatter: (v: number | string) => fmt2(Number(v)),
|
||||
},
|
||||
legend: { data: legendNames, top: 0, textStyle: { fontSize: 11 } },
|
||||
grid: grids,
|
||||
xAxis: xAxes,
|
||||
yAxis: yAxes,
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1, ...(hasSub ? [2] : [])], start, end: 100 },
|
||||
{ type: 'slider', xAxisIndex: [0, 1, ...(hasSub ? [2] : [])], bottom: 4, start, end: 100, height: 16 },
|
||||
],
|
||||
series,
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
watch(() => [props.bars, props.overlay, props.subPane], render, { deep: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="stock-kline"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stock-kline {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,14 @@
|
||||
// 用到的:candlestick(K线)、line(净值/回撤曲线)、markPoint(买卖点标注)、heatmap(寻优热力图)。
|
||||
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, CandlestickChart, HeatmapChart, LineChart } from 'echarts/charts'
|
||||
import { BarChart, CandlestickChart, HeatmapChart, LineChart, RadarChart } from 'echarts/charts'
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkLineComponent,
|
||||
MarkPointComponent,
|
||||
RadarComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
@@ -20,12 +22,15 @@ echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
HeatmapChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
DataZoomComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
RadarComponent,
|
||||
VisualMapComponent,
|
||||
])
|
||||
|
||||
|
||||
@@ -11,3 +11,33 @@ export function fmtPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
/** 成交额/市值大数(元 → 亿/万亿,A股口径)。 */
|
||||
export function fmtAmount(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
if (Math.abs(v) >= 1e12) return `${(v / 1e12).toFixed(2)}万亿`
|
||||
if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
|
||||
if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)}万`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
/** 成交量(手 → 万手/亿手)。 */
|
||||
export function fmtVol(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)}亿手`
|
||||
if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)}万手`
|
||||
return v.toFixed(0)
|
||||
}
|
||||
|
||||
/** 涨跌幅带号(接受百分数如 2.35 → "+2.35%")。 */
|
||||
export function fmtPctSigned(v: number | null | undefined, digits = 2): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
const s = v.toFixed(digits)
|
||||
return v > 0 ? `+${s}%` : `${s}%`
|
||||
}
|
||||
|
||||
/** 按涨跌方向取色 class(涨红/跌绿/平灰)。 */
|
||||
export function dirClass(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v) || v === 0) return 'flat'
|
||||
return v > 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 技术指标前端计算库(与后端 MyTT/indicator.py 同口径的常用子集)。
|
||||
// 输入均为按时间正序的价格数组,输出与输入等长(前期无法计算的点为 null)。
|
||||
|
||||
/** 简单移动平均 MA(n)。 */
|
||||
export function ma(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i]
|
||||
if (i >= n) sum -= data[i - n]
|
||||
out.push(i >= n - 1 ? sum / n : null)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 指数移动平均 EMA(n),首值用前 n 项均值(无则首个有效值)。 */
|
||||
export function ema(data: number[], n: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
const k = 2 / (n + 1)
|
||||
let prev: number | null = null
|
||||
let seed = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (prev === null) {
|
||||
seed += data[i]
|
||||
if (i === n - 1) {
|
||||
prev = seed / n
|
||||
out.push(prev)
|
||||
} else {
|
||||
out.push(null)
|
||||
}
|
||||
} else {
|
||||
prev = data[i] * k + prev * (1 - k)
|
||||
out.push(prev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 国内 SMA(X, N, M):X*M/N + 前值*(N-M)/N,首值 = 首个 X。 */
|
||||
function smaCN(data: number[], n: number, m: number): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let prev: number | null = null
|
||||
for (const v of data) {
|
||||
if (prev === null) {
|
||||
prev = v
|
||||
out.push(v)
|
||||
} else {
|
||||
prev = (v * m) / n + (prev * (n - m)) / n
|
||||
out.push(prev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 布林带 BOLL(20, 2):中轨 MA20,上下轨 ±2×标准差。 */
|
||||
export function boll(
|
||||
data: number[],
|
||||
n = 20,
|
||||
p = 2,
|
||||
): { mid: Array<number | null>; upper: Array<number | null>; lower: Array<number | null> } {
|
||||
const mid = ma(data, n)
|
||||
const upper: Array<number | null> = []
|
||||
const lower: Array<number | null> = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (mid[i] === null) {
|
||||
upper.push(null)
|
||||
lower.push(null)
|
||||
continue
|
||||
}
|
||||
let ssum = 0
|
||||
for (let j = i - n + 1; j <= i; j++) ssum += (data[j] - (mid[i] as number)) ** 2
|
||||
const sd = Math.sqrt(ssum / n)
|
||||
upper.push((mid[i] as number) + p * sd)
|
||||
lower.push((mid[i] as number) - p * sd)
|
||||
}
|
||||
return { mid, upper, lower }
|
||||
}
|
||||
|
||||
/** MACD(12,26,9):DIF、DEA、MACD 柱(= 2×(DIF-DEA),国内口径)。 */
|
||||
export function macd(
|
||||
closes: number[],
|
||||
fast = 12,
|
||||
slow = 26,
|
||||
signal = 9,
|
||||
): { dif: Array<number | null>; dea: Array<number | null>; hist: Array<number | null> } {
|
||||
const ef = ema(closes, fast)
|
||||
const es = ema(closes, slow)
|
||||
const dif = closes.map((_, i) =>
|
||||
ef[i] !== null && es[i] !== null ? (ef[i] as number) - (es[i] as number) : null,
|
||||
)
|
||||
// DEA = DIF 的 EMA(9):剔除前缀 null 后计算再回填
|
||||
const firstIdx = dif.findIndex((v) => v !== null)
|
||||
const dea: Array<number | null> = new Array(closes.length).fill(null)
|
||||
if (firstIdx >= 0) {
|
||||
const valid = dif.slice(firstIdx) as number[]
|
||||
const deaValid = ema(valid, signal)
|
||||
for (let i = 0; i < deaValid.length; i++) dea[firstIdx + i] = deaValid[i]
|
||||
}
|
||||
const hist = dif.map((v, i) =>
|
||||
v !== null && dea[i] !== null ? 2 * (v - (dea[i] as number)) : null,
|
||||
)
|
||||
return { dif, dea, hist }
|
||||
}
|
||||
|
||||
/** KDJ(9,3,3):RSV→K=SMA(RSV,3,1)→D=SMA(K,3,1)→J=3K-2D。 */
|
||||
export function kdj(
|
||||
highs: number[],
|
||||
lows: number[],
|
||||
closes: number[],
|
||||
n = 9,
|
||||
): { k: Array<number | null>; d: Array<number | null>; j: Array<number | null> } {
|
||||
const rsv: number[] = []
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
const hi = Math.max(...highs.slice(Math.max(0, i - n + 1), i + 1))
|
||||
const lo = Math.min(...lows.slice(Math.max(0, i - n + 1), i + 1))
|
||||
rsv.push(hi === lo ? 50 : ((closes[i] - lo) / (hi - lo)) * 100)
|
||||
}
|
||||
const k = smaCN(rsv, 3, 1)
|
||||
// smaCN 等长输出;k 无 null(首值即有效),直接算
|
||||
const kk = k as number[]
|
||||
const dd = smaCN(kk, 3, 1) as number[]
|
||||
const j = kk.map((v, i) => 3 * v - 2 * dd[i])
|
||||
return { k: kk, d: dd, j }
|
||||
}
|
||||
|
||||
/** RSI(n):Wilder 平滑(SMA(U,n,1)/SMA(D,n,1) 国内近似)。 */
|
||||
export function rsi(closes: number[], n = 14): Array<number | null> {
|
||||
const out: Array<number | null> = []
|
||||
let avgU = 0
|
||||
let avgD = 0
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
if (i === 0) {
|
||||
out.push(null)
|
||||
continue
|
||||
}
|
||||
const ch = closes[i] - closes[i - 1]
|
||||
const u = Math.max(ch, 0)
|
||||
const d = Math.max(-ch, 0)
|
||||
if (i <= n) {
|
||||
avgU += u / n
|
||||
avgD += d / n
|
||||
out.push(i === n ? (avgD === 0 ? 100 : 100 - (100 * avgU) / (avgU + avgD)) : null)
|
||||
} else {
|
||||
avgU = (avgU * (n - 1) + u) / n
|
||||
avgD = (avgD * (n - 1) + d) / n
|
||||
out.push(avgD === 0 ? 100 : 100 - (100 * avgU) / (avgU + avgD))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export function detectMarket(code: string): Market {
|
||||
const c = code.trim()
|
||||
if (!/^\d{6}$/.test(c)) return 'SZ'
|
||||
|
||||
// 通达信板块指数(沪市):880 统计 / 881 行业 / 885 概念等
|
||||
if (/^88[0-9]/.test(c)) return 'SH'
|
||||
|
||||
// 北交所:43/83/87/92(含920段)/93 + 4xx/8xx(三板/小盘)
|
||||
if (/^(43|83|87|92|93|4|8)/.test(c)) return 'BJ'
|
||||
|
||||
|
||||
@@ -2,16 +2,21 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import BacktestView from './views/BacktestView.vue'
|
||||
import CompareView from './views/CompareView.vue'
|
||||
import DashboardView from './views/DashboardView.vue'
|
||||
import OptimizeView from './views/OptimizeView.vue'
|
||||
import PortfolioView from './views/PortfolioView.vue'
|
||||
import ServerSettingsView from './views/ServerSettingsView.vue'
|
||||
import SignalRadarView from './views/SignalRadarView.vue'
|
||||
import StrategiesView from './views/StrategiesView.vue'
|
||||
import WatchlistView from './views/WatchlistView.vue'
|
||||
|
||||
// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)
|
||||
// + 策略库(/strategies)+ 信号雷达(/signals)+ 服务器设置(/settings)。
|
||||
// 行情终端:市场看板(/)+ 自选(/watchlist)。
|
||||
// 分析工具:单标的回测(/backtest)+ 组合回测(/portfolio)+ 参数寻优(/optimize)
|
||||
// + 结果对比(/compare)+ 策略库(/strategies)+ 信号雷达(/signals)+ 设置(/settings)。
|
||||
const routes = [
|
||||
{ path: '/', name: 'backtest', component: BacktestView },
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
{ path: '/watchlist', name: 'watchlist', component: WatchlistView },
|
||||
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
||||
{ path: '/compare', name: 'compare', component: CompareView },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 实时行情 SSE 状态(Pinia,全局单连接)。
|
||||
// App.vue 挂载时 connect(),所有视图共享同一份 quotes 快照。
|
||||
// 断线指数退避重连(上限 30s),状态徽标挂在侧边栏底部。
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import type { SecurityQuote, SseMessage } from '../types'
|
||||
|
||||
export type SseStatus = 'connecting' | 'open' | 'closed'
|
||||
|
||||
const STREAM_URL = '/api/v1/stream/quotes'
|
||||
const MAX_RETRY_DELAY = 30_000
|
||||
|
||||
export const useQuoteStore = defineStore('quotes', () => {
|
||||
/** symbol(SH600000)→ 最新行情快照。 */
|
||||
const quotes = reactive(new Map<string, SecurityQuote>())
|
||||
const status = ref<SseStatus>('closed')
|
||||
const lastTs = ref('')
|
||||
let es: EventSource | null = null
|
||||
let retryTimer: number | null = null
|
||||
let retryCount = 0
|
||||
|
||||
const quoteCount = computed(() => quotes.size)
|
||||
|
||||
function handleMsg(ev: MessageEvent<string>) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as SseMessage
|
||||
if (msg.type === 'quotes_updated' && msg.quotes) {
|
||||
for (const q of msg.quotes) quotes.set(q.symbol, q)
|
||||
if (msg.ts) lastTs.value = msg.ts
|
||||
}
|
||||
status.value = 'open'
|
||||
retryCount = 0
|
||||
} catch {
|
||||
// 非 JSON 帧(服务端注释/心跳),忽略
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (es) return
|
||||
status.value = 'connecting'
|
||||
es = new EventSource(STREAM_URL)
|
||||
es.onmessage = handleMsg
|
||||
es.onopen = () => {
|
||||
status.value = 'open'
|
||||
retryCount = 0
|
||||
}
|
||||
es.onerror = () => {
|
||||
// EventSource 断开后进入 readyState=CONNECTING 自行重试;但若服务已停,
|
||||
// 会无限静默重试——这里手动接管:关闭后按退避重连,同时更新状态徽标。
|
||||
close(false)
|
||||
retryCount += 1
|
||||
const delay = Math.min(1000 * 2 ** (retryCount - 1), MAX_RETRY_DELAY)
|
||||
status.value = 'closed'
|
||||
retryTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
function close(markClosed = true) {
|
||||
if (retryTimer !== null) {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
es?.close()
|
||||
es = null
|
||||
if (markClosed) status.value = 'closed'
|
||||
}
|
||||
|
||||
/** 取单只行情(无数据时返回 undefined)。 */
|
||||
function getQuote(symbol: string): SecurityQuote | undefined {
|
||||
return quotes.get(symbol)
|
||||
}
|
||||
|
||||
return { quotes, status, lastTs, quoteCount, connect, close, getQuote }
|
||||
})
|
||||
@@ -104,3 +104,59 @@ label {
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 行情涨跌着色(format.dirClass 的落点) */
|
||||
.up {
|
||||
color: var(--up);
|
||||
}
|
||||
.down {
|
||||
color: var(--down);
|
||||
}
|
||||
.flat {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 通用面板卡片(看板/自选页网格单元) */
|
||||
.card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 行情数据表 */
|
||||
.qtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.qtable th {
|
||||
text-align: right;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qtable td {
|
||||
text-align: right;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.qtable th:first-child,
|
||||
.qtable td:first-child {
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -403,3 +403,120 @@ export interface ServerSwitchResult {
|
||||
host: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ── 行情终端:实时五档(SSE / POST /api/v1/security/quotes) ─────────────────
|
||||
|
||||
/** 单只标的实时五档行情(后端 SecurityQuote 白名单投影,SSE 与 REST 同构)。 */
|
||||
export interface SecurityQuote {
|
||||
symbol: string // "SH600000"
|
||||
market: string // SH/SZ/BJ
|
||||
code: string
|
||||
price: number | null
|
||||
pre_close: number | null
|
||||
open: number | null
|
||||
high: number | null
|
||||
low: number | null
|
||||
vol: number | null // 总成交量(手)
|
||||
cur_vol: number | null
|
||||
amount: number | null // 成交额(元)
|
||||
s_vol: number | null // 内盘
|
||||
b_vol: number | null // 外盘
|
||||
rise_speed: number | null // 涨速
|
||||
limit_up: number | null
|
||||
limit_down: number | null
|
||||
decimal_point: number | null
|
||||
server_time: string
|
||||
trading_status: number | null
|
||||
bid1: number | null
|
||||
bid_vol1: number | null
|
||||
bid2: number | null
|
||||
bid_vol2: number | null
|
||||
bid3: number | null
|
||||
bid_vol3: number | null
|
||||
bid4: number | null
|
||||
bid_vol4: number | null
|
||||
bid5: number | null
|
||||
bid_vol5: number | null
|
||||
ask1: number | null
|
||||
ask_vol1: number | null
|
||||
ask2: number | null
|
||||
ask_vol2: number | null
|
||||
ask3: number | null
|
||||
ask_vol3: number | null
|
||||
ask4: number | null
|
||||
ask_vol4: number | null
|
||||
ask5: number | null
|
||||
ask_vol5: number | null
|
||||
}
|
||||
|
||||
/** SSE 消息:quotes_updated / hello。 */
|
||||
export interface SseMessage {
|
||||
type: 'quotes_updated' | 'hello'
|
||||
ts?: string
|
||||
count?: number
|
||||
quotes?: SecurityQuote[]
|
||||
subscribers?: number
|
||||
}
|
||||
|
||||
// ── 行情终端:市场统计(GET /api/v1/market/stat) ────────────────────────────
|
||||
|
||||
export interface MarketStat {
|
||||
up_count: number
|
||||
down_count: number
|
||||
neutral_count: number
|
||||
suspended_count: number
|
||||
total_count: number
|
||||
total_amount: number
|
||||
total_volume: number
|
||||
total_market_cap: number
|
||||
limit_up_count: number
|
||||
limit_down_count: number
|
||||
}
|
||||
|
||||
// ── 行情终端:分时(GET /api/v1/minute) ─────────────────────────────────────
|
||||
|
||||
export interface MinutePoint {
|
||||
datetime: string
|
||||
price: number
|
||||
vol: number
|
||||
}
|
||||
|
||||
// ── 行情终端:自选(GET/POST/DELETE /api/v1/watchlist) ──────────────────────
|
||||
|
||||
export interface WatchItem {
|
||||
market: string
|
||||
code: string
|
||||
symbol: string // SH600000
|
||||
name: string
|
||||
group_name: string
|
||||
created_at: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface WatchlistResponse {
|
||||
items: WatchItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// ── 行情终端:板块列表(GET /api/v1/board-mac/list,MAC 协议,防御式取列) ────
|
||||
|
||||
/** 板块行(MAC 协议字段随版本浮动,全部可选,渲染端容错)。 */
|
||||
export interface BoardRow {
|
||||
code?: string
|
||||
name?: string
|
||||
price?: number
|
||||
pre_close?: number
|
||||
change_pct?: number
|
||||
sort_value?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ── 行情终端:排行行情(GET /api/v1/mac/quote-list,MAC 协议,防御式取列) ────
|
||||
|
||||
export interface RankRow {
|
||||
code?: string
|
||||
name?: string
|
||||
price?: number
|
||||
change_pct?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
<script setup lang="ts">
|
||||
// 市场看板 v2:指数实时条(内嵌迷你分时)+ 涨跌统计 + 市场情绪 +
|
||||
// 涨跌分布直方图 + 行业/概念热度榜 + 涨跌幅榜 + 异动雷达。
|
||||
// 指数走全局 SSE;统计/情绪/分布/板块/异动定时轮询;全市场分布懒加载。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
fetchBoards,
|
||||
fetchIndexBars,
|
||||
fetchMarketStat,
|
||||
fetchRankList,
|
||||
fetchUnusual,
|
||||
fetchMinute,
|
||||
formatError,
|
||||
} from '../api'
|
||||
import { dirClass, fmtAmount, fmtPctSigned } from '../format'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { Bar, BoardRow, MarketStat, RankRow } from '../types'
|
||||
import BoardDialog from '../components/BoardDialog.vue'
|
||||
import MoodRadar from '../components/MoodRadar.vue'
|
||||
import Sparkline from '../components/Sparkline.vue'
|
||||
import StockDialog from '../components/StockDialog.vue'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
|
||||
// ── 指数条(SSE 实时 + 迷你分时) ────────────────────────────────────────────
|
||||
|
||||
const INDEXES = [
|
||||
{ symbol: 'SH000001', name: '上证指数' },
|
||||
{ symbol: 'SZ399001', name: '深证成指' },
|
||||
{ symbol: 'SZ399006', name: '创业板指' },
|
||||
{ symbol: 'SH000688', name: '科创50' },
|
||||
{ symbol: 'SH000300', name: '沪深300' },
|
||||
]
|
||||
|
||||
function idxQuote(symbol: string) {
|
||||
return quoteStore.getQuote(symbol)
|
||||
}
|
||||
|
||||
function idxPct(symbol: string): number | null {
|
||||
const q = idxQuote(symbol)
|
||||
if (!q?.price || !q.pre_close) return null
|
||||
return (q.price / q.pre_close - 1) * 100
|
||||
}
|
||||
|
||||
const idxSparks = ref(new Map<string, number[]>())
|
||||
const idxSparkBase = ref(new Map<string, number>())
|
||||
|
||||
async function loadIdxSparks() {
|
||||
for (const idx of INDEXES) {
|
||||
try {
|
||||
const pts = await fetchMinute(idx.symbol.slice(0, 2), idx.symbol.slice(2))
|
||||
if (pts.length > 0) {
|
||||
const prices = pts.map((p) => p.price)
|
||||
idxSparks.value.set(idx.symbol, prices)
|
||||
const q = idxQuote(idx.symbol)
|
||||
idxSparkBase.value.set(idx.symbol, q?.pre_close ?? prices[0])
|
||||
}
|
||||
} catch {
|
||||
// 单指数失败跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 市场统计(轮询 30s) ─────────────────────────────────────────────────────
|
||||
|
||||
const stat = ref<MarketStat | null>(null)
|
||||
const statError = ref('')
|
||||
|
||||
async function loadStat() {
|
||||
try {
|
||||
stat.value = await fetchMarketStat()
|
||||
statError.value = ''
|
||||
} catch (e) {
|
||||
statError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const breadth = computed(() => {
|
||||
const s = stat.value
|
||||
if (!s || !s.total_count) return null
|
||||
const active = s.up_count + s.down_count + s.neutral_count || 1
|
||||
return {
|
||||
up: (s.up_count / active) * 100,
|
||||
flat: (s.neutral_count / active) * 100,
|
||||
down: (s.down_count / active) * 100,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 市场情绪(统计 + 分布 + 指数上下文合成四维雷达) ───────────────────────
|
||||
|
||||
/** 上证/深证指数近 60 日 K(量能与趋势维的数据源)。 */
|
||||
const idxBars = ref<{ sh: Bar[]; sz: Bar[] }>({ sh: [], sz: [] })
|
||||
|
||||
async function loadIndexContext() {
|
||||
try {
|
||||
const [sh, sz] = await Promise.all([
|
||||
fetchIndexBars('SH', '000001', 60),
|
||||
fetchIndexBars('SZ', '399001', 60),
|
||||
])
|
||||
idxBars.value = { sh, sz }
|
||||
} catch {
|
||||
// 指数 K 线失败静默,雷达相关维退化为中性 50
|
||||
}
|
||||
}
|
||||
|
||||
/** 沪深两市今日/昨日成交额(两指数 amount 之和)。 */
|
||||
const amountToday = computed(() => {
|
||||
const { sh, sz } = idxBars.value
|
||||
return (Number(sh.at(-1)?.amount ?? 0)) + (Number(sz.at(-1)?.amount ?? 0))
|
||||
})
|
||||
const amountYesterday = computed(() => {
|
||||
const { sh, sz } = idxBars.value
|
||||
return (Number(sh.at(-2)?.amount ?? 0)) + (Number(sz.at(-2)?.amount ?? 0))
|
||||
})
|
||||
|
||||
const amountPct = computed(() => {
|
||||
if (amountToday.value <= 0 || amountYesterday.value <= 0) return null
|
||||
return (amountToday.value / amountYesterday.value - 1) * 100
|
||||
})
|
||||
|
||||
/** 四维雷达分值(0~100,50 中性)。 */
|
||||
const radarValues = computed(() => {
|
||||
const s = stat.value
|
||||
// 赚钱效应:涨跌比 tanh 压缩到 0~100(1:1 → 50,4:1 → 90,1:4 → 10)
|
||||
let profit = 50
|
||||
if (s && s.down_count + s.up_count > 0) {
|
||||
const ratio = s.up_count / Math.max(s.down_count, 1)
|
||||
profit = 50 + Math.tanh(Math.log(ratio)) * 50
|
||||
}
|
||||
// 量能:较昨日成交额 ±50% 打满
|
||||
const ap = amountPct.value
|
||||
const volume = ap === null ? 50 : Math.min(100, Math.max(0, 50 + ap))
|
||||
// 动量:上证 5 日涨幅 ±5% 打满
|
||||
const sh = idxBars.value.sh
|
||||
let momentum = 50
|
||||
if (sh.length >= 6) {
|
||||
const ret5 = (sh.at(-1)!.close / sh.at(-6)!.close - 1) * 100
|
||||
momentum = Math.min(100, Math.max(0, 50 + ret5 * 10))
|
||||
}
|
||||
// 趋势:上证收盘相对 MA20 偏离 ±5% 打满
|
||||
let trend = 50
|
||||
if (sh.length >= 20) {
|
||||
const ma20 = sh.slice(-20).reduce((a, b) => a + b.close, 0) / 20
|
||||
trend = Math.min(100, Math.max(0, 50 + (sh.at(-1)!.close / ma20 - 1) * 1000))
|
||||
}
|
||||
return { profit, volume, momentum, trend }
|
||||
})
|
||||
|
||||
/** 综合分(四维均值,保留 1 位)。 */
|
||||
const moodScore = computed(() => {
|
||||
const v = radarValues.value
|
||||
return Math.round(((v.profit + v.volume + v.momentum + v.trend) / 4) * 10) / 10
|
||||
})
|
||||
|
||||
function moodLabel(score: number): string {
|
||||
if (score >= 70) return '亢奋'
|
||||
if (score >= 57) return '偏暖'
|
||||
if (score >= 43) return '均衡'
|
||||
if (score >= 30) return '偏冷'
|
||||
return '冰点'
|
||||
}
|
||||
|
||||
// ── 涨跌分布直方图(全市场懒加载,120s) ────────────────────────────────────
|
||||
|
||||
const BUCKETS = [
|
||||
'≤-10',
|
||||
...Array.from({ length: 20 }, (_, i) => {
|
||||
const v = -10 + i
|
||||
return v === 0 ? '0' : `${v > 0 ? '+' : ''}${v}`
|
||||
}),
|
||||
'≥10',
|
||||
]
|
||||
|
||||
interface DistData {
|
||||
counts: number[]
|
||||
gt5: number // 涨超 5%(不含涨停也计入)
|
||||
lt5: number // 跌超 5%
|
||||
total: number
|
||||
}
|
||||
|
||||
const dist = ref<DistData | null>(null)
|
||||
const distLoading = ref(false)
|
||||
const distError = ref('')
|
||||
/** 今日涨幅 ≥9.8% 的名单(涨停/触板观察,含 20cm 品种)。 */
|
||||
const limitRows = ref<RankRow[]>([])
|
||||
|
||||
async function loadDist() {
|
||||
distLoading.value = true
|
||||
distError.value = ''
|
||||
try {
|
||||
// 接口单向上限 5000:DESC+ASC 各拉 3000 覆盖两端(全 A 约 5400 只),
|
||||
// 按 code 去重合并,避免单向截断丢掉分布另一端的尾部
|
||||
const [top, bottom] = await Promise.all([fetchRankList('DESC', 3000), fetchRankList('ASC', 3000)])
|
||||
const seen = new Set<string>()
|
||||
const rows: RankRow[] = []
|
||||
for (const r of [...top, ...bottom]) {
|
||||
const key = String(r.code ?? '')
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
rows.push(r)
|
||||
}
|
||||
const counts = new Array(BUCKETS.length).fill(0)
|
||||
let gt5 = 0
|
||||
let lt5 = 0
|
||||
for (const r of rows) {
|
||||
const pct = Number(r.change_pct ?? 0)
|
||||
if (!Number.isFinite(pct)) continue
|
||||
if (pct > 5) gt5++
|
||||
if (pct < -5) lt5++
|
||||
// 桶布局:[0]='≤-10',[1..10]='-10'..'-1',[11]='0',[12..20]='+1'..'+9',[21]='≥10'
|
||||
// 统一公式 idx = 11 + floor(pct),两端 clamp
|
||||
let idx = 11 + Math.floor(pct)
|
||||
if (pct <= -10) idx = 0
|
||||
if (pct >= 10) idx = BUCKETS.length - 1
|
||||
counts[Math.max(0, Math.min(BUCKETS.length - 1, idx))]++
|
||||
}
|
||||
dist.value = { counts, gt5, lt5, total: rows.length }
|
||||
// 涨停雷达名单:涨幅 ≥9.8%(主板涨停 10%、创业/科创 20% 都会覆盖)
|
||||
limitRows.value = rows
|
||||
.filter((r) => Number(r.change_pct ?? 0) >= 9.8)
|
||||
.slice(0, 15)
|
||||
} catch (e) {
|
||||
distError.value = formatError(e)
|
||||
} finally {
|
||||
distLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 涨跌分布 hover 浮窗:跟随鼠标,超界自动贴边。 */
|
||||
const hoverBucket = ref(-1)
|
||||
const popX = ref(0)
|
||||
const popY = ref(0)
|
||||
const distChartEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDistMove(e: MouseEvent) {
|
||||
const host = distChartEl.value
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
// 浮窗约 110px 宽 56px 高:先按鼠标右上角偏移,再 clamp 到容器内
|
||||
popX.value = Math.min(Math.max(e.clientX - rect.left + 14, 0), rect.width - 110)
|
||||
popY.value = Math.min(Math.max(e.clientY - rect.top - 64, 0), rect.height - 56)
|
||||
}
|
||||
|
||||
/** 桶区间文案(如 "-5% ~ -4%";两端为 ≤-10% / ≥+10%)。 */
|
||||
function bucketRange(i: number): string {
|
||||
if (i === 0) return '≤ -10%'
|
||||
if (i === BUCKETS.length - 1) return '≥ +10%'
|
||||
const v = -10 + (i - 1) // '-10'..'+9' 桶对应 v..v+1
|
||||
if (v === 0) return '0 ~ +1%'
|
||||
const fmt = (x: number) => (x > 0 ? `+${x}%` : `${x}%`)
|
||||
return `${fmt(v)} ~ ${fmt(v + 1)}`
|
||||
}
|
||||
|
||||
/** 桶内家数占比。 */
|
||||
function bucketShare(count: number): string {
|
||||
const total = dist.value?.total ?? 0
|
||||
return total > 0 ? `${((count / total) * 100).toFixed(1)}%` : '-'
|
||||
}
|
||||
|
||||
/** 分布柱高(相对最大桶)。 */
|
||||
function distHeight(count: number): string {
|
||||
const max = Math.max(...(dist.value?.counts ?? [1]), 1)
|
||||
return `${Math.max(2, (count / max) * 100)}%`
|
||||
}
|
||||
|
||||
function distColor(i: number): string {
|
||||
// 桶 1..20 对应 -10..+9:前 10 绿(跌),后 10 红(涨);两端按方向
|
||||
if (i === 0) return 'var(--down)'
|
||||
if (i === BUCKETS.length - 1) return 'var(--up)'
|
||||
return i <= 10 ? 'var(--down)' : 'var(--up)'
|
||||
}
|
||||
|
||||
// ── 板块热度 + 冰冷(一次拉 120 个,前端切热/冷两端) ───────────────────────
|
||||
|
||||
const industryBoards = ref<BoardRow[]>([])
|
||||
const conceptBoards = ref<BoardRow[]>([])
|
||||
const boardsError = ref('')
|
||||
|
||||
/** 热榜(涨幅前 8)。 */
|
||||
function hotBoards(list: BoardRow[]): BoardRow[] {
|
||||
return list.slice(0, 8)
|
||||
}
|
||||
|
||||
/** 冷榜(跌幅前 8,倒回正序显示)。 */
|
||||
function coldBoards(list: BoardRow[]): BoardRow[] {
|
||||
return list.slice(-8).reverse()
|
||||
}
|
||||
|
||||
async function loadBoards() {
|
||||
boardsError.value = ''
|
||||
try {
|
||||
const [hy, gn] = await Promise.all([fetchBoards('HY', 120), fetchBoards('GN', 120)])
|
||||
industryBoards.value = hy
|
||||
conceptBoards.value = gn
|
||||
} catch (e) {
|
||||
boardsError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
function boardPct(b: BoardRow): number {
|
||||
return Number(b.change_pct ?? 0)
|
||||
}
|
||||
|
||||
function barWidth(pct: number, list: BoardRow[]): string {
|
||||
const maxAbs = Math.max(...list.map(boardPct), 0.5)
|
||||
return `${Math.min(100, (Math.abs(pct) / maxAbs) * 100)}%`
|
||||
}
|
||||
|
||||
// ── 异动事件流(轮询 60s) ───────────────────────────────────────────────────
|
||||
|
||||
interface UnusualRow {
|
||||
time: string
|
||||
code: string
|
||||
name: string
|
||||
desc: string
|
||||
value: string
|
||||
market: string
|
||||
}
|
||||
|
||||
const unusualRows = ref<UnusualRow[]>([])
|
||||
const unusualError = ref('')
|
||||
|
||||
function normalizeUnusual(rows: Record<string, unknown>[], market: 'SH' | 'SZ'): UnusualRow[] {
|
||||
return rows.map((r) => ({
|
||||
time: String(r.time ?? '').slice(0, 8),
|
||||
code: String(r.code ?? ''),
|
||||
name: String(r.name ?? ''),
|
||||
desc: String(r.desc ?? ''),
|
||||
value: String(r.value ?? ''),
|
||||
market,
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadUnusual() {
|
||||
unusualError.value = ''
|
||||
try {
|
||||
const [sh, sz] = await Promise.all([fetchUnusual('SH', 60), fetchUnusual('SZ', 60)])
|
||||
const merged = [...normalizeUnusual(sh, 'SH'), ...normalizeUnusual(sz, 'SZ')]
|
||||
merged.sort((a, b) => (a.time < b.time ? 1 : a.time > b.time ? -1 : 0))
|
||||
unusualRows.value = merged.slice(0, 80)
|
||||
} catch (e) {
|
||||
unusualError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 排行榜(涨幅/跌幅/成交额/换手 四 tab,轮询 60s) ───────────────────────
|
||||
|
||||
const gainers = ref<RankRow[]>([])
|
||||
const losers = ref<RankRow[]>([])
|
||||
const hotAmount = ref<RankRow[]>([])
|
||||
const hotTurnover = ref<RankRow[]>([])
|
||||
const rankError = ref('')
|
||||
const rankTab = ref<'gain' | 'loss' | 'amount' | 'turnover'>('gain')
|
||||
|
||||
const RANK_TABS: Array<{ value: 'gain' | 'loss' | 'amount' | 'turnover'; label: string }> = [
|
||||
{ value: 'gain', label: '涨幅' },
|
||||
{ value: 'loss', label: '跌幅' },
|
||||
{ value: 'amount', label: '成交额' },
|
||||
{ value: 'turnover', label: '换手' },
|
||||
]
|
||||
|
||||
const activeRankRows = computed<RankRow[]>(() => {
|
||||
switch (rankTab.value) {
|
||||
case 'loss':
|
||||
return losers.value
|
||||
case 'amount':
|
||||
return hotAmount.value
|
||||
case 'turnover':
|
||||
return hotTurnover.value
|
||||
default:
|
||||
return gainers.value
|
||||
}
|
||||
})
|
||||
|
||||
async function loadRanks() {
|
||||
rankError.value = ''
|
||||
try {
|
||||
const [top, bottom, byAmount, byTurnover] = await Promise.all([
|
||||
fetchRankList('DESC', 12),
|
||||
fetchRankList('ASC', 12),
|
||||
fetchRankList('DESC', 12, 'TOTAL_AMOUNT'),
|
||||
fetchRankList('DESC', 12, 'TURNOVER_RATE'),
|
||||
])
|
||||
gainers.value = top
|
||||
losers.value = bottom
|
||||
hotAmount.value = byAmount
|
||||
hotTurnover.value = byTurnover
|
||||
} catch (e) {
|
||||
rankError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 排行榜第三列内容:涨幅榜显示成交额,其余显示涨跌幅。 */
|
||||
function rankExtra(r: RankRow): { text: string; cls: string } {
|
||||
if (rankTab.value === 'gain' || rankTab.value === 'loss') {
|
||||
return {
|
||||
text: fmtAmount(Number(r.amount ?? 0)),
|
||||
cls: 'dim',
|
||||
}
|
||||
}
|
||||
const pct = Number(r.change_pct ?? 0)
|
||||
return { text: fmtPctSigned(pct), cls: dirClass(pct) }
|
||||
}
|
||||
|
||||
// ── 轮询调度 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let statTimer = 0
|
||||
let slowTimer = 0
|
||||
let distTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
loadStat()
|
||||
loadBoards()
|
||||
loadUnusual()
|
||||
loadRanks()
|
||||
loadIdxSparks()
|
||||
loadIndexContext()
|
||||
loadDist()
|
||||
statTimer = window.setInterval(loadStat, 30_000)
|
||||
slowTimer = window.setInterval(() => {
|
||||
loadUnusual()
|
||||
loadRanks()
|
||||
loadBoards()
|
||||
loadIdxSparks()
|
||||
loadIndexContext()
|
||||
}, 60_000)
|
||||
distTimer = window.setInterval(loadDist, 120_000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(statTimer)
|
||||
window.clearInterval(slowTimer)
|
||||
window.clearInterval(distTimer)
|
||||
})
|
||||
|
||||
// ── 弹窗(个股 / 板块) ──────────────────────────────────────────────────────
|
||||
|
||||
const dialog = ref<{ market: string; code: string; name: string } | null>(null)
|
||||
const boardDialog = ref<{ code: string; name: string } | null>(null)
|
||||
|
||||
function openDialog(code: string, name: string, marketHint?: string) {
|
||||
if (!code) return
|
||||
const mkt = marketHint ?? (/^(6|9|5)/.test(code) ? 'SH' : /^(4|8|92|43)/.test(code) ? 'BJ' : 'SZ')
|
||||
dialog.value = { market: mkt, code, name }
|
||||
}
|
||||
|
||||
/** 板块行点击 → 板块详情弹窗。 */
|
||||
function openBoard(code: string | undefined, name: string | undefined) {
|
||||
if (!code) return
|
||||
boardDialog.value = { code: String(code), name: String(name ?? code) }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash">
|
||||
<!-- 指数条(内嵌迷你分时) -->
|
||||
<div class="idx-row">
|
||||
<div
|
||||
v-for="idx in INDEXES"
|
||||
:key="idx.symbol"
|
||||
class="idx-card"
|
||||
@click="openDialog(idx.symbol.slice(2), idx.name, idx.symbol.slice(0, 2))"
|
||||
>
|
||||
<div class="idx-top">
|
||||
<span class="idx-name">{{ idx.name }}</span>
|
||||
<span class="idx-chg mono" :class="dirClass(idxPct(idx.symbol))">
|
||||
{{ fmtPctSigned(idxPct(idx.symbol)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="idx-mid">
|
||||
<span class="idx-price mono" :class="dirClass(idxPct(idx.symbol))">
|
||||
{{ idxQuote(idx.symbol)?.price?.toFixed(2) ?? '—' }}
|
||||
</span>
|
||||
<span class="idx-amt mono dim">
|
||||
{{ fmtAmount(idxQuote(idx.symbol)?.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline
|
||||
:prices="idxSparks.get(idx.symbol) ?? []"
|
||||
:base="idxSparkBase.get(idx.symbol) ?? null"
|
||||
:width="150"
|
||||
:height="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- 市场统计 -->
|
||||
<div class="card">
|
||||
<h3>市场统计</h3>
|
||||
<div v-if="statError" class="err">{{ statError }}</div>
|
||||
<template v-else-if="stat && breadth">
|
||||
<div class="breadth-bar">
|
||||
<div class="seg up" :style="{ width: `${breadth.up}%` }"></div>
|
||||
<div class="seg flat" :style="{ width: `${breadth.flat}%` }"></div>
|
||||
<div class="seg down" :style="{ width: `${breadth.down}%` }"></div>
|
||||
</div>
|
||||
<div class="stat-nums">
|
||||
<span class="up">涨 {{ stat.up_count }}</span>
|
||||
<span class="flat">平 {{ stat.neutral_count }}</span>
|
||||
<span class="down">跌 {{ stat.down_count }}</span>
|
||||
<span class="dim">停 {{ stat.suspended_count }}</span>
|
||||
</div>
|
||||
<div class="stat-rows mono">
|
||||
<div><span class="dim">涨停</span><span class="up">{{ stat.limit_up_count }}</span></div>
|
||||
<div><span class="dim">跌停</span><span class="down">{{ stat.limit_down_count }}</span></div>
|
||||
<div><span class="dim">总成交</span><span>{{ fmtAmount(stat.total_amount) }}</span></div>
|
||||
<div><span class="dim">总市值</span><span>{{ fmtAmount(stat.total_market_cap) }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="loading">加载中…</div>
|
||||
</div>
|
||||
|
||||
<!-- 市场情绪雷达 -->
|
||||
<div class="card">
|
||||
<h3>市场情绪 <span class="mood-score mono">{{ moodScore }}</span></h3>
|
||||
<MoodRadar :values="radarValues" />
|
||||
<div class="mood-foot">
|
||||
<span class="mood-word">{{ moodLabel(moodScore) }}</span>
|
||||
<span class="dim mono">量能 {{ fmtPctSigned(amountPct) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 涨跌分布直方图 -->
|
||||
<div class="card dist-card">
|
||||
<h3>涨跌分布 <span class="dim title-sub">(全市场 {{ dist?.total ?? '…' }} 只)</span></h3>
|
||||
<div v-if="distError" class="err">{{ distError }}</div>
|
||||
<div v-else-if="dist" ref="distChartEl" class="dist-chart" @mousemove="onDistMove" @mouseleave="hoverBucket = -1">
|
||||
<div
|
||||
v-for="(count, i) in dist.counts"
|
||||
:key="i"
|
||||
class="dist-col"
|
||||
:class="{ zero: BUCKETS[i] === '0', hovered: hoverBucket === i }"
|
||||
@mouseenter="hoverBucket = i"
|
||||
>
|
||||
<div class="dist-bar" :style="{ height: distHeight(count), background: distColor(i) }"></div>
|
||||
<div class="dist-label">{{ BUCKETS[i] }}</div>
|
||||
</div>
|
||||
<div v-if="hoverBucket >= 0 && dist" class="dist-pop" :style="{ left: `${popX}px`, top: `${popY}px` }">
|
||||
<div class="pop-range">{{ bucketRange(hoverBucket) }}</div>
|
||||
<div class="pop-count mono">{{ dist.counts[hoverBucket] }} 只</div>
|
||||
<div class="pop-share mono">占 {{ bucketShare(dist.counts[hoverBucket]) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading">{{ distLoading ? '全市场分布计算中…(约 5s)' : '加载中…' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 行业板块(热 + 冷) -->
|
||||
<div class="card board-card">
|
||||
<h3>行业板块 <span class="dim title-sub">热</span></h3>
|
||||
<div v-if="boardsError" class="err">{{ boardsError }}</div>
|
||||
<div v-else class="board-list">
|
||||
<div
|
||||
v-for="b in hotBoards(industryBoards)"
|
||||
:key="b.code"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), industryBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="cold-title">冷</h3>
|
||||
<div v-if="!boardsError" class="board-list">
|
||||
<div
|
||||
v-for="b in coldBoards(industryBoards)"
|
||||
:key="`c${b.code}`"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), industryBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概念板块(热 + 冷) -->
|
||||
<div class="card board-card">
|
||||
<h3>概念板块 <span class="dim title-sub">热</span></h3>
|
||||
<div v-if="boardsError" class="err">{{ boardsError }}</div>
|
||||
<div v-else class="board-list">
|
||||
<div
|
||||
v-for="b in hotBoards(conceptBoards)"
|
||||
:key="b.code"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), conceptBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="cold-title">冷</h3>
|
||||
<div v-if="!boardsError" class="board-list">
|
||||
<div
|
||||
v-for="b in coldBoards(conceptBoards)"
|
||||
:key="`c${b.code}`"
|
||||
class="board-row clickable"
|
||||
@click="openBoard(b.code, b.name)"
|
||||
>
|
||||
<span class="b-name">{{ b.name }}</span>
|
||||
<div class="b-bar-wrap">
|
||||
<div class="b-bar" :class="dirClass(boardPct(b))" :style="{ width: barWidth(boardPct(b), conceptBoards) }"></div>
|
||||
</div>
|
||||
<span class="b-pct mono" :class="dirClass(boardPct(b))">{{ fmtPctSigned(boardPct(b)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排行榜(四 tab) -->
|
||||
<div class="card">
|
||||
<h3>
|
||||
排行榜
|
||||
<span class="rank-tabs">
|
||||
<button
|
||||
v-for="t in RANK_TABS"
|
||||
:key="t.value"
|
||||
class="rank-tab"
|
||||
:class="{ on: rankTab === t.value }"
|
||||
@click="rankTab = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="rankError" class="err">{{ rankError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in activeRankRows" :key="`${rankTab}${i}`" @click="openDialog(String(r.code ?? ''), String(r.name ?? ''), r.market ? String(r.market) : undefined)">
|
||||
<td>{{ i + 1 }}. {{ r.name }}</td>
|
||||
<td class="mono dim">{{ fmtAmount(Number(r.amount ?? 0)) }}</td>
|
||||
<td class="mono" :class="rankExtra(r).cls">{{ rankExtra(r).text }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 涨停雷达 -->
|
||||
<div class="card limit-card">
|
||||
<h3>
|
||||
涨停雷达
|
||||
<span class="limit-n mono">{{ limitRows.length }}+</span>
|
||||
<span class="dim title-sub">(≥9.8%)</span>
|
||||
</h3>
|
||||
<div v-if="distError" class="err">{{ distError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in limitRows" :key="`lu${i}`" @click="openDialog(String(r.code ?? ''), String(r.name ?? ''), r.market ? String(r.market) : undefined)">
|
||||
<td>{{ i + 1 }}. {{ r.name }}</td>
|
||||
<td class="mono dim">{{ fmtAmount(Number(r.amount ?? 0)) }}</td>
|
||||
<td class="mono up">{{ fmtPctSigned(r.change_pct) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!distError && limitRows.length === 0" class="loading">
|
||||
{{ distLoading ? '全市场扫描中…' : '今日暂无 ≥9.8% 个股' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 异动事件流 -->
|
||||
<div class="card unusual-card">
|
||||
<h3>异动雷达</h3>
|
||||
<div v-if="unusualError" class="err">{{ unusualError }}</div>
|
||||
<table v-else class="qtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th style="text-align: left">股票</th>
|
||||
<th style="text-align: left">异动</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in unusualRows" :key="i" @click="openDialog(r.code, r.name, r.market)">
|
||||
<td class="dim">{{ r.time }}</td>
|
||||
<td style="text-align: left">{{ r.name }}</td>
|
||||
<td style="text-align: left"><span class="tag">{{ r.desc }}</span></td>
|
||||
<td class="mono">{{ r.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockDialog
|
||||
v-if="dialog"
|
||||
:market="dialog.market"
|
||||
:code="dialog.code"
|
||||
:name="dialog.name"
|
||||
@close="dialog = null"
|
||||
/>
|
||||
<BoardDialog
|
||||
v-if="boardDialog"
|
||||
:code="boardDialog.code"
|
||||
:name="boardDialog.name"
|
||||
@close="boardDialog = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.idx-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.idx-card {
|
||||
flex: 1;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.idx-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.idx-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.idx-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.idx-chg {
|
||||
font-size: 12px;
|
||||
}
|
||||
.idx-mid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.idx-price {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.idx-amt {
|
||||
font-size: 11px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.unusual-card {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 320px;
|
||||
}
|
||||
.dist-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
/* 市场统计 */
|
||||
.breadth-bar {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.seg.up {
|
||||
background: var(--up);
|
||||
}
|
||||
.seg.flat {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.seg.down {
|
||||
background: var(--down);
|
||||
}
|
||||
.stat-nums {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.stat-rows {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat-rows > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* 市场情绪雷达 */
|
||||
.mood-score {
|
||||
float: right;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.mood-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mood-word {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 排行榜 tab */
|
||||
.rank-tabs {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.rank-tab {
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.rank-tab.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
/* 涨停雷达 */
|
||||
.limit-n {
|
||||
float: right;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--up);
|
||||
}
|
||||
.limit-card {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 涨跌分布 */
|
||||
.title-sub {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dist-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 120px;
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.dist-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.dist-col:hover,
|
||||
.dist-col.hovered {
|
||||
z-index: 10;
|
||||
}
|
||||
.dist-pop {
|
||||
position: absolute;
|
||||
background: rgba(26, 29, 38, 0.97);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
z-index: 20;
|
||||
}
|
||||
.pop-range {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.pop-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pop-share {
|
||||
color: var(--text-dim);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.dist-bar {
|
||||
width: 100%;
|
||||
border-radius: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
.dist-col.zero {
|
||||
outline: 1px dashed var(--text-dim);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.dist-label {
|
||||
font-size: 8.5px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 3px;
|
||||
transform: rotate(-60deg);
|
||||
transform-origin: top center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 板块热度 */
|
||||
.board-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.board-row {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr 64px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.b-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.board-row.clickable:hover .b-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cold-title {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.b-bar-wrap {
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.b-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.b-bar.up {
|
||||
background: var(--up);
|
||||
}
|
||||
.b-bar.down {
|
||||
background: var(--down);
|
||||
}
|
||||
.b-pct {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 涨跌幅榜 */
|
||||
.unusual-card tr,
|
||||
.card table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 异动 */
|
||||
.tag {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--up);
|
||||
font-size: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.loading {
|
||||
color: var(--text-dim);
|
||||
padding: 12px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
// 参数寻优主页面:左配置(选标的 + 策略 + 寻优参数)/ 右报告(排名表 + 热力图)。
|
||||
// 取行情已整合进「开始寻优」。另有「一键寻优所有策略」:用各策略预设网格逐策略寻优再全局排名。
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import GradeBadge from '../components/GradeBadge.vue'
|
||||
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
|
||||
@@ -18,6 +18,7 @@ import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
const store = useBacktestStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// SymbolPicker 实例引用,用于触发取行情
|
||||
const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null)
|
||||
@@ -86,10 +87,22 @@ const selectedStrategy = computed(
|
||||
() => store.strategies.find((s) => s.name === strategy.value) ?? null,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
store.loadStrategies().catch((e) => {
|
||||
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
|
||||
})
|
||||
|
||||
// 个股弹窗「一键寻优」入口:/optimize?code=601088&autoAll=1
|
||||
// 填入代码后自动触发「一键寻优所有策略」(等 nextTick 让 SymbolPicker
|
||||
// 的 v-model 同步,onRunAll 里的 loadBars 才会取到正确标的)。
|
||||
const qCode = String(route.query.code ?? '')
|
||||
if (route.query.autoAll === '1' && /^\d{6}$/.test(qCode)) {
|
||||
code.value = qCode
|
||||
await nextTick()
|
||||
if (!store.optimizeRunning && !store.optimizeAllRunning) {
|
||||
onRunAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 网格点数(前端预校验,提示用户)
|
||||
@@ -172,12 +185,12 @@ function buildBacktestQuery(strategyName: string, params: Record<string, number
|
||||
// 点击排名表「查看」→ 跳转单标的页用该参数回测
|
||||
function onViewParams(params: Record<string, number | string>) {
|
||||
// 通过 query 传递参数,单标的页接收后自动填充
|
||||
router.push({ path: '/', query: buildBacktestQuery(strategy.value, params) })
|
||||
router.push({ path: '/backtest', query: buildBacktestQuery(strategy.value, params) })
|
||||
}
|
||||
|
||||
// 一键寻优结果点击「查看」→ 跳转单标的页用该策略 + 参数回测
|
||||
function onViewAll(strategyName: string, params: Record<string, number | string>) {
|
||||
router.push({ path: '/', query: buildBacktestQuery(strategyName, params) })
|
||||
router.push({ path: '/backtest', query: buildBacktestQuery(strategyName, params) })
|
||||
}
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
|
||||
@@ -126,7 +126,7 @@ function signalSeq(r: SignalScanRow): string {
|
||||
function onLoad(r: SignalScanRow) {
|
||||
const codeOnly = r.symbol.includes(':') ? r.symbol.split(':').pop()! : r.symbol
|
||||
router.push({
|
||||
path: '/',
|
||||
path: '/backtest',
|
||||
query: {
|
||||
strategy: r.strategy,
|
||||
params: JSON.stringify(r.params),
|
||||
|
||||
@@ -256,7 +256,7 @@ function onLoad(s: SavedStrategy) {
|
||||
const rawSymbol = (ctx.symbol as string) || ''
|
||||
const codeOnly = rawSymbol.includes(':') ? rawSymbol.split(':').pop()! : rawSymbol
|
||||
router.push({
|
||||
path: '/',
|
||||
path: '/backtest',
|
||||
query: {
|
||||
strategy: s.strategy,
|
||||
params,
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
// 自选行情:SSE 实时表格 + 行内迷你分时 + 一键加删 + 点击开个股对话框。
|
||||
// 迷你分时按需懒加载(行可见时才拉 /minute,60s 重拉)——这里简化为
|
||||
// 打开页面时批量拉前 N 只(80/批上限内),足够 MVP。
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
addWatchItem,
|
||||
fetchMinute,
|
||||
fetchQuotes,
|
||||
fetchSymbolName,
|
||||
fetchWatchlist,
|
||||
formatError,
|
||||
removeWatchItem,
|
||||
} from '../api'
|
||||
import StockDialog from '../components/StockDialog.vue'
|
||||
import BoardDialog from '../components/BoardDialog.vue'
|
||||
import Sparkline from '../components/Sparkline.vue'
|
||||
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
|
||||
import { detectMarket } from '../market'
|
||||
import { useQuoteStore } from '../stores/quotes'
|
||||
import type { WatchItem } from '../types'
|
||||
|
||||
const quoteStore = useQuoteStore()
|
||||
|
||||
/** 板块指数(881/885/880 开头)走板块弹窗,其余走个股弹窗。 */
|
||||
function isBoardCode(code: string): boolean {
|
||||
return /^88\d/.test(code)
|
||||
}
|
||||
|
||||
const items = ref<WatchItem[]>([])
|
||||
const listError = ref('')
|
||||
const adding = ref(false)
|
||||
const addCode = ref('')
|
||||
const addName = ref('')
|
||||
|
||||
// ── 列表加载 ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadList() {
|
||||
listError.value = ''
|
||||
try {
|
||||
const resp = await fetchWatchlist()
|
||||
items.value = resp.items
|
||||
loadSparks()
|
||||
restFallback()
|
||||
fillMissingNames()
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 给历史遗留的"只有代码没有名称"的自选项补中文名(幂等写回存储)。 */
|
||||
async function fillMissingNames() {
|
||||
const missing = items.value.filter((i) => !i.name)
|
||||
for (const it of missing) {
|
||||
const name = await fetchSymbolName(it.market, it.code)
|
||||
if (name) {
|
||||
it.name = name
|
||||
try {
|
||||
await addWatchItem(it.market, it.code, name)
|
||||
} catch {
|
||||
// 写回失败仅影响下次加载,静默
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 行情(SSE 快照 + REST 首次兜底) ────────────────────────────────────────
|
||||
|
||||
/** SSE 未覆盖时(自选刚加、服务重启间隙)用 REST 主动拉一次。 */
|
||||
async function restFallback() {
|
||||
if (items.value.length === 0) return
|
||||
const missing = items.value.filter((i) => !quoteStore.getQuote(i.symbol))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
await fetchQuotes(missing.map((i) => ({ market: i.market, code: i.code })))
|
||||
} catch {
|
||||
// SSE 会补上,静默
|
||||
}
|
||||
}
|
||||
|
||||
function q(item: WatchItem) {
|
||||
return quoteStore.getQuote(item.symbol)
|
||||
}
|
||||
|
||||
function pct(item: WatchItem): number | null {
|
||||
const qq = q(item)
|
||||
if (!qq?.price || !qq.pre_close) return null
|
||||
return (qq.price / qq.pre_close - 1) * 100
|
||||
}
|
||||
|
||||
// ── 迷你分时 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const sparks = ref(new Map<string, number[]>())
|
||||
const sparkBase = ref(new Map<string, number>())
|
||||
|
||||
async function loadSparks() {
|
||||
const targets = items.value.slice(0, 80)
|
||||
for (const it of targets) {
|
||||
if (sparks.value.has(it.symbol)) continue
|
||||
try {
|
||||
const pts = await fetchMinute(it.market, it.code)
|
||||
if (pts.length > 0) {
|
||||
sparks.value.set(it.symbol, pts.map((p) => p.price))
|
||||
const qq = q(it)
|
||||
sparkBase.value.set(it.symbol, qq?.pre_close ?? pts[0].price)
|
||||
}
|
||||
} catch {
|
||||
// 单只失败不影响整表
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sparkTimer = 0
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
sparkTimer = window.setInterval(loadSparks, 60_000)
|
||||
})
|
||||
onBeforeUnmount(() => window.clearInterval(sparkTimer))
|
||||
|
||||
// ── 加/删自选 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function add() {
|
||||
const code = addCode.value.trim()
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
listError.value = '请输入 6 位数字代码'
|
||||
return
|
||||
}
|
||||
const market = detectMarket(code)
|
||||
adding.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
// 名称优先级:用户备注 > 证券名称接口(五档行情协议本身不带名称)。
|
||||
// 名称取不到不阻断(BJ 等市场 MAC 协议可能不支持),退回显示代码。
|
||||
let name = addName.value.trim()
|
||||
if (!name) name = await fetchSymbolName(market, code)
|
||||
try {
|
||||
const quotes = await fetchQuotes([{ market, code }])
|
||||
const qq = quotes[0]
|
||||
if (qq && qq.price == null) {
|
||||
listError.value = `提示:${market}${code} 暂无行情返回,仍已加入自选`
|
||||
} else if (qq && Number.isFinite(qq.vol) && qq.vol === 0 && qq.amount === 0) {
|
||||
listError.value = `提示:${market}${code} 今日无成交(停牌或非交易日),仍已加入自选`
|
||||
}
|
||||
} catch {
|
||||
listError.value = `提示:行情校验不可用,${market}${code} 仍已加入自选`
|
||||
}
|
||||
await addWatchItem(market, code, name)
|
||||
addCode.value = ''
|
||||
addName.value = ''
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: WatchItem) {
|
||||
listError.value = ''
|
||||
try {
|
||||
await removeWatchItem(item.market, item.code)
|
||||
items.value = items.value.filter((i) => i.symbol !== item.symbol)
|
||||
sparks.value.delete(item.symbol)
|
||||
} catch (e) {
|
||||
listError.value = formatError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 弹窗(个股 / 板块分流) ─────────────────────────────────────────────────
|
||||
|
||||
const dialog = ref<WatchItem | null>(null)
|
||||
const boardDlg = ref<WatchItem | null>(null)
|
||||
|
||||
function openItem(item: WatchItem) {
|
||||
if (isBoardCode(item.code)) boardDlg.value = item
|
||||
else dialog.value = item
|
||||
}
|
||||
|
||||
const emptyHint = computed(() =>
|
||||
items.value.length === 0 && !listError.value
|
||||
? '自选为空:输入 6 位代码加入(如 600519)。加入后行情实时推送。'
|
||||
: '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wl">
|
||||
<div class="toolbar">
|
||||
<h2>自选行情</h2>
|
||||
<div class="add-row">
|
||||
<input v-model="addCode" class="code-input" placeholder="6 位代码" maxlength="6" @keyup.enter="add" />
|
||||
<input v-model="addName" class="name-input" placeholder="备注名(可空)" maxlength="16" @keyup.enter="add" />
|
||||
<button class="primary" :disabled="adding" @click="add">{{ adding ? '加入中…' : '加入自选' }}</button>
|
||||
</div>
|
||||
<span class="hint">共 {{ items.length }} 只 · 行情实时推送</span>
|
||||
</div>
|
||||
|
||||
<div v-if="listError" class="err">{{ listError }}</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="qtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>现价</th>
|
||||
<th>涨跌幅</th>
|
||||
<th>涨跌额</th>
|
||||
<th>成交量</th>
|
||||
<th>成交额</th>
|
||||
<th>最高</th>
|
||||
<th>最低</th>
|
||||
<th>今开</th>
|
||||
<th>昨收</th>
|
||||
<th>分时</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="emptyHint" class="empty-row">
|
||||
<td colspan="12">{{ emptyHint }}</td>
|
||||
</tr>
|
||||
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
|
||||
<td>
|
||||
<div class="cell-name">{{ item.name || item.code }}</div>
|
||||
<div class="cell-code mono dim">{{ item.symbol }}</div>
|
||||
</td>
|
||||
<td class="big" :class="dirClass(pct(item))">{{ fmt2(q(item)?.price) }}</td>
|
||||
<td :class="dirClass(pct(item))">{{ fmtPctSigned(pct(item)) }}</td>
|
||||
<td :class="dirClass(pct(item))">
|
||||
{{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}
|
||||
</td>
|
||||
<td>{{ fmtVol(q(item)?.vol) }}</td>
|
||||
<td>{{ fmtAmount(q(item)?.amount) }}</td>
|
||||
<td>{{ fmt2(q(item)?.high) }}</td>
|
||||
<td>{{ fmt2(q(item)?.low) }}</td>
|
||||
<td>{{ fmt2(q(item)?.open) }}</td>
|
||||
<td class="dim">{{ fmt2(q(item)?.pre_close) }}</td>
|
||||
<td>
|
||||
<Sparkline :prices="sparks.get(item.symbol) ?? []" :base="sparkBase.get(item.symbol) ?? null" />
|
||||
</td>
|
||||
<td>
|
||||
<button class="del" @click.stop="remove(item)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<StockDialog
|
||||
v-if="dialog"
|
||||
:market="dialog.market"
|
||||
:code="dialog.code"
|
||||
:name="dialog.name"
|
||||
@close="dialog = null"
|
||||
@watchlist-changed="loadList"
|
||||
/>
|
||||
<BoardDialog
|
||||
v-if="boardDlg"
|
||||
:code="boardDlg.code"
|
||||
:name="boardDlg.name || boardDlg.code"
|
||||
@close="boardDlg = null"
|
||||
@watchlist-changed="loadList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wl {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-input {
|
||||
width: 110px;
|
||||
}
|
||||
.name-input {
|
||||
width: 140px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
.err {
|
||||
color: var(--up);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.data-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.data-row:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-code {
|
||||
font-size: 11px;
|
||||
}
|
||||
.big {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.del {
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.del:hover {
|
||||
color: var(--up);
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user