mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
feat: 市场情绪栏目 — 盘中情绪采样器 + 宽度分时 + 涨停史离线回补
- /sentiment 市场情绪:温度卡(实时上涨占比/涨跌停/总成交,五档情绪判定) + 今日宽度分时(上涨/下跌/涨停家数三线)+ 近 60 日涨停跌停家数与上涨占比 - SentimentSampler:交易时段每分钟采样全市场广度(get_market_stat), 停牌/盘外自动跳过、失败不中断;SentimentStore 落 SQLite (~/.easy_tdx/sentiment.db,(date,minute) 幂等主键,重启不丢) - /market/sentiment/today|history:当日分钟曲线 + 逐日聚合(收盘快照占比/峰值) - /market/limitup-history:涨停跌停家数逐日历史由 vipdoc 离线回补, 无需采样积累即时可用;缓存按 days 分键(修复 10 天缓存被 60 天请求命中) - 采样历史需交易日积累,页面空态有明示;涨停/跌停历史开箱即有 60 天
This commit is contained in:
@@ -8,9 +8,9 @@
|
||||
| ④ | **风格轮动** `/styles` | 今天是大票还是小票、高股息还是成长 | 热点滚动基建 × FG 风格板块,纯复用 | ✅ 第一批(后并入热点滚动页内「风格」档,独立导航已移除) |
|
||||
| ⑦ | **大盘日历** `/calendar` | 全年情绪一眼扫完(红绿日历热力图) | 指数日K(`/bars/index`)现成 | ✅ 第一批(含悬停浮框 + 成交额编码方框大小) |
|
||||
| ② | **涨停生态 / 连板天梯** `/limitup` | 连板高度、首板/二板分布、炸板率、跌停 | 本地 vipdoc .day 文件(strength 扫描器同款读取器),close==涨停价 连续天数可回算 | ✅ 第二批 |
|
||||
| ① | **市场情绪时间线** | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ⏳ 第三批 |
|
||||
| ⑨ | **市场宽度分时** | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ⏳ 第三批(随①) |
|
||||
| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 第三批(随①) |
|
||||
| ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 |
|
||||
| ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) |
|
||||
| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 |
|
||||
| ⑤ | **板块相关性热力图** | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ⏳ 第四批 |
|
||||
| ③ | **异动雷达时间线** | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ⏳ 第四批 |
|
||||
| ⑧ | **量能仪表盘** | 放量/缩量(两市成交额 vs 5日均量带) | 指数分钟线现成 | ⏳ 第四批 |
|
||||
|
||||
@@ -26,7 +26,12 @@ from easy_tdx.offline.paths import resolve_vipdoc
|
||||
|
||||
_A_STOCK_TYPES = frozenset({"SH_A_STOCK", "SZ_A_STOCK"})
|
||||
|
||||
__all__ = ["LimitUpEntry", "LimitUpEcology", "compute_limitup_ecology"]
|
||||
__all__ = [
|
||||
"LimitUpEntry",
|
||||
"LimitUpEcology",
|
||||
"compute_limitup_ecology",
|
||||
"compute_limitup_history",
|
||||
]
|
||||
|
||||
|
||||
def _round_price(x: float) -> float:
|
||||
@@ -34,6 +39,10 @@ def _round_price(x: float) -> float:
|
||||
return math.floor(x * 100 + 0.5) / 100
|
||||
|
||||
|
||||
def _eq_price(a: float, b: float) -> bool:
|
||||
return abs(a - b) < 1e-4
|
||||
|
||||
|
||||
def _limit_ratio(code: str) -> float:
|
||||
"""涨幅上限:创业板/科创板 20%,其余主板 10%(ST 由调用侧按 5% 二次判定)。"""
|
||||
if code.startswith(("30", "68")):
|
||||
@@ -245,3 +254,79 @@ def compute_limitup_ecology(
|
||||
eco.limit_down.sort(key=lambda e: (-e.streak, e.pct))
|
||||
eco.blown.sort(key=lambda e: -e.pct)
|
||||
return eco
|
||||
|
||||
|
||||
def compute_limitup_history(
|
||||
vipdoc_path: str | Path | None = None,
|
||||
*,
|
||||
days: int = 60,
|
||||
max_files: int = 20000,
|
||||
) -> list[dict[str, int]]:
|
||||
"""逐日统计最近 ``days`` 个交易日的涨停/跌停家数(离线回补,无需采样积累)。
|
||||
|
||||
与 :func:`compute_limitup_ecology` 的"只看最新交易日"不同,本函数把每只股票
|
||||
窗口内的每一根 bar 都按同一涨停判定规则计数——历史日期上它就是当时真实的
|
||||
涨停家数(陈旧文件在此是合法的历史数据,无污染问题)。
|
||||
|
||||
Returns:
|
||||
按 date 升序的 ``[{"date": YYYYMMDD, "limit_up": n, "limit_down": m}]``;
|
||||
vipdoc 不可用时返回空列表。
|
||||
"""
|
||||
try:
|
||||
vipdoc = resolve_vipdoc(vipdoc_path)
|
||||
except Exception: # noqa: BLE001 — 路径不存在/自动检测失败:按空数据处理
|
||||
return []
|
||||
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if not vipdoc.is_dir():
|
||||
return []
|
||||
|
||||
n_files = 0
|
||||
for exchange in ("sz", "sh"):
|
||||
lday_dir = vipdoc / exchange / "lday"
|
||||
if not lday_dir.is_dir():
|
||||
continue
|
||||
for filepath in sorted(lday_dir.glob("*.day")):
|
||||
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
|
||||
continue
|
||||
code = filepath.name.lower()[2:8]
|
||||
try:
|
||||
bars = read_daily_bars(filepath)
|
||||
except Exception: # noqa: BLE001 — 单文件损坏不阻塞整体
|
||||
continue
|
||||
tail = bars[-(days + 13) :]
|
||||
if len(tail) < 2:
|
||||
continue
|
||||
n_files += 1
|
||||
if n_files >= max_files:
|
||||
break
|
||||
up_ratio = _limit_ratio(code)
|
||||
closes = [b.close for b in tail]
|
||||
date_ints = [b.year * 10000 + b.month * 100 + b.day for b in tail]
|
||||
for i in range(1, len(tail)):
|
||||
p, c = closes[i - 1], closes[i]
|
||||
if p <= 0:
|
||||
continue
|
||||
st_applicable = up_ratio == 0.10 and p >= 3.0
|
||||
d = date_ints[i]
|
||||
bucket = counts.setdefault(d, {"limit_up": 0, "limit_down": 0})
|
||||
if _eq_price(c, _round_price(p * (1 + up_ratio))) or (
|
||||
st_applicable and _eq_price(c, _round_price(p * 1.05))
|
||||
):
|
||||
bucket["limit_up"] += 1
|
||||
elif _eq_price(c, _round_price(p * (1 - up_ratio))) or (
|
||||
st_applicable and _eq_price(c, _round_price(p * 0.95))
|
||||
):
|
||||
bucket["limit_down"] += 1
|
||||
if n_files >= max_files:
|
||||
break
|
||||
|
||||
recent = sorted(counts)[-days:] if days > 0 else []
|
||||
return [
|
||||
{
|
||||
"date": d,
|
||||
"limit_up": counts[d]["limit_up"],
|
||||
"limit_down": counts[d]["limit_down"],
|
||||
}
|
||||
for d in recent
|
||||
]
|
||||
|
||||
@@ -173,8 +173,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
ex_client = None
|
||||
app.state.ex_client = ex_client
|
||||
|
||||
# --- 市场情绪采样器(交易时段每分钟落一条广度快照,供 /market/sentiment/*) ---
|
||||
# 依赖标准 TDX 客户端(get_market_stat),mock 模式缩短间隔让曲线快速成形。
|
||||
app.state.sentiment_sampler = None
|
||||
try:
|
||||
from easy_tdx.web.sentiment_sampler import SentimentSampler
|
||||
|
||||
sampler = SentimentSampler(
|
||||
client.get_market_stat,
|
||||
interval=5.0 if mock_mode else 60.0,
|
||||
)
|
||||
sampler.start()
|
||||
app.state.sentiment_sampler = sampler
|
||||
logger.info("SentimentSampler 已启动")
|
||||
except Exception:
|
||||
logger.warning("SentimentSampler 启动失败 — 情绪采样不可用", exc_info=True)
|
||||
|
||||
yield
|
||||
|
||||
# --- 停止市场情绪采样器 ---
|
||||
sampler_svc = getattr(app.state, "sentiment_sampler", None)
|
||||
if sampler_svc is not None:
|
||||
try:
|
||||
await sampler_svc.stop()
|
||||
except Exception:
|
||||
logger.warning("SentimentSampler stop failed", exc_info=True)
|
||||
|
||||
# --- 关闭实时行情推送器 ---
|
||||
streamer_svc = getattr(app.state, "quote_streamer", None)
|
||||
if streamer_svc is not None:
|
||||
|
||||
@@ -23,6 +23,8 @@ router = APIRouter(tags=["market"])
|
||||
# 涨停生态结果缓存(vipdoc 盘中随通达信客户端落盘更新,60s 足够新鲜)
|
||||
_limitup_cache: tuple[float, dict[str, Any]] | None = None
|
||||
_LIMITUP_TTL = 60.0
|
||||
# 涨停逐日历史缓存(历史数据不变,10 分钟;按 days 分键)
|
||||
_limitup_history_cache: dict[int, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _df_response(df: Any) -> DataFrameResponse:
|
||||
@@ -130,6 +132,69 @@ async def limitup_ecology(
|
||||
return DictResponse.from_dict(payload)
|
||||
|
||||
|
||||
@router.get("/market/sentiment/today", response_model=DictResponse)
|
||||
async def sentiment_today(
|
||||
date: int | None = Query(None, description="交易日 YYYYMMDD,缺省=最近有采样的日期"),
|
||||
) -> DictResponse:
|
||||
"""当日情绪分钟曲线(上涨/下跌/涨停/跌停家数、上涨占比、总成交额)。
|
||||
|
||||
数据来自 :class:`easy_tdx.web.sentiment_sampler.SentimentSampler` 的盘中
|
||||
逐分钟采样——服务重启不丢(SQLite 持久化),但首次上线前无历史。
|
||||
"""
|
||||
from easy_tdx.web.sentiment_store import get_sentiment_store
|
||||
|
||||
store = get_sentiment_store()
|
||||
d = date or store.latest_date()
|
||||
if not d:
|
||||
return DictResponse.from_dict({"date": 0, "count": 0, "samples": []})
|
||||
rows = store.day_samples(d)
|
||||
for r in rows:
|
||||
denom = max(r["up_count"] + r["down_count"], 1)
|
||||
r["up_ratio"] = round(100.0 * r["up_count"] / denom, 1)
|
||||
return DictResponse.from_dict({"date": d, "count": len(rows), "samples": rows})
|
||||
|
||||
|
||||
@router.get("/market/sentiment/history", response_model=DictResponse)
|
||||
async def sentiment_history(
|
||||
days: int = Query(60, ge=5, le=250, description="聚合天数"),
|
||||
) -> DictResponse:
|
||||
"""逐日情绪聚合(收盘快照的上涨占比/涨跌停家数/成交额 + 涨停峰值)。
|
||||
|
||||
同样依赖采样器的积累;涨停/跌停家数的"无采样历史"可用
|
||||
``/market/limitup-history``(vipdoc 离线回补)替代。
|
||||
"""
|
||||
from easy_tdx.web.sentiment_store import get_sentiment_store
|
||||
|
||||
rows = get_sentiment_store().daily_history(days)
|
||||
return DictResponse.from_dict({"count": len(rows), "days": rows})
|
||||
|
||||
|
||||
@router.get("/market/limitup-history", response_model=DictResponse)
|
||||
async def limitup_history(
|
||||
days: int = Query(60, ge=5, le=250, description="回补交易日数"),
|
||||
vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"),
|
||||
) -> DictResponse:
|
||||
"""涨停/跌停家数逐日历史(本地 vipdoc 离线回补,无需采样积累)。
|
||||
|
||||
全市场扫描约需数十秒,结果缓存 10 分钟。日期覆盖受 vipdoc 数据范围限制。
|
||||
"""
|
||||
global _limitup_history_cache
|
||||
now = time.monotonic()
|
||||
cached = _limitup_history_cache.get(days)
|
||||
if cached is not None and now - cached[0] < 600:
|
||||
return DictResponse.from_dict(cached[1])
|
||||
|
||||
def _scan() -> dict[str, Any]:
|
||||
from easy_tdx.screen.limitup import compute_limitup_history
|
||||
|
||||
rows = compute_limitup_history(vipdoc, days=days)
|
||||
return {"count": len(rows), "days": rows}
|
||||
|
||||
payload = await asyncio.to_thread(_scan)
|
||||
_limitup_history_cache[days] = (now, payload)
|
||||
return DictResponse.from_dict(payload)
|
||||
|
||||
|
||||
@router.get("/fund-flow", response_model=DataFrameResponse)
|
||||
async def fund_flow(
|
||||
market: str = Query(..., description="市场: SZ, SH"),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""市场情绪采样器(交易时段每分钟落一条全市场广度快照)。
|
||||
|
||||
模式对齐 :class:`easy_tdx.web.quote_streamer.QuoteStreamer`:
|
||||
|
||||
- 后台 asyncio 任务,``start()`` 启动 / ``stop()`` 取消,进程生命周期由
|
||||
:mod:`easy_tdx.web.app` 的 lifespan 管理。
|
||||
- 仅在 :func:`easy_tdx.realtime.session.is_trading_time` 内采样(盘外采样
|
||||
只会产生重复的静止快照,浪费且污染"当日分钟曲线")。
|
||||
- 采样失败静默跳过(计数告警日志),绝不中断循环——情绪曲线缺失几个点
|
||||
远好于采样器罢工。
|
||||
- 写入经 :class:`easy_tdx.web.sentiment_store.SentimentStore`,(date, minute)
|
||||
幂等主键,重复采样只覆盖不累积。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from easy_tdx.realtime.session import is_trading_time
|
||||
from easy_tdx.web.sentiment_store import SentimentStore, get_sentiment_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["SentimentSampler"]
|
||||
|
||||
|
||||
class SentimentSampler:
|
||||
"""交易时段全市场广度采样器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_get_stat: Any,
|
||||
store: SentimentStore | None = None,
|
||||
interval: float = 60.0,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
client_get_stat: 异步可调用(``AsyncTdxClient.get_market_stat``),
|
||||
返回含 up_count/limit_up_count 等列的单行 DataFrame。
|
||||
store: 情绪存储,None 则取进程级单例。
|
||||
interval: 采样间隔(秒)。E2E mock 可调小。
|
||||
"""
|
||||
self._get_stat = client_get_stat
|
||||
self._store = store or get_sentiment_store()
|
||||
self._interval = interval
|
||||
self._task: asyncio.Task | None = None
|
||||
self.samples = 0
|
||||
self.failures = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
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
|
||||
|
||||
async def _run(self) -> None:
|
||||
logger.info("SentimentSampler 启动(间隔 %ss,仅交易时段)", self._interval)
|
||||
while True:
|
||||
try:
|
||||
if is_trading_time():
|
||||
await self._sample_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — 采样器永不退出
|
||||
self.failures += 1
|
||||
logger.warning("情绪采样失败(累计 %d 次)", self.failures, exc_info=True)
|
||||
await asyncio.sleep(self._interval)
|
||||
|
||||
async def _sample_once(self) -> None:
|
||||
df = await self._get_stat()
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError("get_market_stat 返回空数据")
|
||||
row = df.iloc[0]
|
||||
now = datetime.now()
|
||||
self._store.insert(
|
||||
{
|
||||
"date": now.year * 10000 + now.month * 100 + now.day,
|
||||
"minute": now.hour * 100 + now.minute,
|
||||
"ts": int(now.timestamp()),
|
||||
"up_count": int(row.get("up_count") or 0),
|
||||
"down_count": int(row.get("down_count") or 0),
|
||||
"neutral_count": int(row.get("neutral_count") or 0),
|
||||
"total_count": int(row.get("total_count") or 0),
|
||||
"limit_up_count": int(row.get("limit_up_count") or 0),
|
||||
"limit_down_count": int(row.get("limit_down_count") or 0),
|
||||
"total_amount": float(row.get("total_amount") or 0.0),
|
||||
}
|
||||
)
|
||||
self.samples += 1
|
||||
@@ -0,0 +1,183 @@
|
||||
"""市场情绪采样持久化(「市场情绪」页的数据后端)。
|
||||
|
||||
设计对齐 :mod:`easy_tdx.web.watchlist_store` / :mod:`easy_tdx.web.llm_history_store`:
|
||||
|
||||
- 单文件 SQLite,落在统一配置目录(``~/.easy_tdx/sentiment.db``,
|
||||
随 ``EASY_TDX_CONFIG_DIR`` 环境变量走)。
|
||||
- 短连接 + 写锁串行,跨线程/跨事件循环安全。
|
||||
- 由 :class:`easy_tdx.web.sentiment_sampler.SentimentSampler` 在交易时段每分钟
|
||||
采一条全市场广度快照(涨/跌/平/涨停/跌停家数、总成交额),主键 (date, minute)
|
||||
幂等写入(采样器重启/重复采样不产生重复行)。
|
||||
- 查询侧供 ``/market/sentiment/today``(当日分钟曲线)与
|
||||
``/market/sentiment/history``(逐日聚合)使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["SentimentStore", "get_sentiment_store"]
|
||||
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
return Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
|
||||
|
||||
|
||||
class SentimentStore:
|
||||
"""情绪采样 SQLite 存储。"""
|
||||
|
||||
def __init__(self, db_path: str | Path | None = None):
|
||||
self._path = Path(db_path) if db_path else _config_dir() / "sentiment.db"
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _write_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
date INTEGER NOT NULL, -- YYYYMMDD
|
||||
minute INTEGER NOT NULL, -- HHMM
|
||||
ts INTEGER NOT NULL, -- epoch 秒
|
||||
up_count INTEGER NOT NULL,
|
||||
down_count INTEGER NOT NULL,
|
||||
neutral_count INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
limit_up_count INTEGER NOT NULL,
|
||||
limit_down_count INTEGER NOT NULL,
|
||||
total_amount REAL NOT NULL,
|
||||
PRIMARY KEY (date, minute)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_samples_date ON samples(date)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._path, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def insert(self, sample: dict[str, Any]) -> None:
|
||||
"""写入/覆盖一条采样(同 minute 幂等,保留最新值)。"""
|
||||
with _write_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO samples (
|
||||
date, minute, ts, up_count, down_count, neutral_count,
|
||||
total_count, limit_up_count, limit_down_count, total_amount
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(sample["date"]),
|
||||
int(sample["minute"]),
|
||||
int(sample["ts"]),
|
||||
int(sample["up_count"]),
|
||||
int(sample["down_count"]),
|
||||
int(sample["neutral_count"]),
|
||||
int(sample["total_count"]),
|
||||
int(sample["limit_up_count"]),
|
||||
int(sample["limit_down_count"]),
|
||||
float(sample["total_amount"]),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def day_samples(self, date: int) -> list[dict[str, Any]]:
|
||||
"""某交易日的全部分钟采样(按时间升序)。"""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM samples WHERE date = ? ORDER BY minute",
|
||||
(int(date),),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def latest_date(self) -> int:
|
||||
"""最近有采样的交易日(YYYYMMDD),无数据返回 0。"""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute("SELECT MAX(date) AS d FROM samples").fetchone()
|
||||
return int(row["d"] or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def daily_history(self, days: int = 60) -> list[dict[str, Any]]:
|
||||
"""逐日聚合(近 N 个有采样的交易日,升序)。
|
||||
|
||||
每日输出:收盘快照(当日最后一条采样)的上涨占比/涨跌停家数/成交额,
|
||||
以及当日涨停家数峰值(情绪高潮探针)与样本数。
|
||||
"""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT c.date AS date,
|
||||
c.n AS n,
|
||||
c.limit_up_peak AS limit_up_peak,
|
||||
l.up_count AS up_count,
|
||||
l.down_count AS down_count,
|
||||
l.limit_up_close AS limit_up_close,
|
||||
l.limit_down_close AS limit_down_close,
|
||||
l.amount_close AS amount_close
|
||||
FROM (
|
||||
SELECT date,
|
||||
COUNT(*) AS n,
|
||||
MAX(limit_up_count) AS limit_up_peak
|
||||
FROM samples GROUP BY date
|
||||
) c
|
||||
JOIN (
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT date,
|
||||
up_count,
|
||||
down_count,
|
||||
limit_up_count AS limit_up_close,
|
||||
limit_down_count AS limit_down_close,
|
||||
total_amount AS amount_close,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY date ORDER BY minute DESC
|
||||
) AS rn
|
||||
FROM samples
|
||||
) WHERE rn = 1
|
||||
) l ON l.date = c.date
|
||||
ORDER BY c.date DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(days),),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in reversed(rows):
|
||||
d = dict(r)
|
||||
denom = max(int(d["up_count"]) + int(d["down_count"]), 1)
|
||||
d["up_ratio"] = round(100.0 * int(d["up_count"]) / denom, 1)
|
||||
out.append(d)
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_store: SentimentStore | None = None
|
||||
_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_sentiment_store() -> SentimentStore:
|
||||
"""进程级单例(测试可先 set ``sentiment_store._store = None`` 重置)。"""
|
||||
global _store
|
||||
with _store_lock:
|
||||
if _store is None:
|
||||
_store = SentimentStore()
|
||||
return _store
|
||||
@@ -0,0 +1,204 @@
|
||||
"""市场情绪采样(store / sampler / 端点)与涨停历史回补单测。
|
||||
|
||||
sentiment_store 用 EASY_TDX_CONFIG_DIR 指向临时目录;limitup 历史复用
|
||||
合成 .day 文件;端点侧验证 DictResponse 包装与缓存命中。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""独立配置目录 + 全新单例的 SentimentStore。"""
|
||||
from easy_tdx.web import sentiment_store as ss
|
||||
|
||||
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
ss._store = None
|
||||
s = ss.get_sentiment_store()
|
||||
yield s
|
||||
ss._store = None
|
||||
|
||||
|
||||
def _sample(date: int, minute: int, up=2000, down=2000, limit_up=50, limit_down=10, amount=8e11):
|
||||
from datetime import datetime
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"minute": minute,
|
||||
"ts": int(datetime(2026, 9, 4).timestamp()),
|
||||
"up_count": up,
|
||||
"down_count": down,
|
||||
"neutral_count": 100,
|
||||
"total_count": up + down + 100,
|
||||
"limit_up_count": limit_up,
|
||||
"limit_down_count": limit_down,
|
||||
"total_amount": amount,
|
||||
}
|
||||
|
||||
|
||||
def test_store_day_samples_and_idempotent(store):
|
||||
store.insert(_sample(20260904, 935))
|
||||
store.insert(_sample(20260904, 930))
|
||||
# 同 (date, minute) 覆盖不累积
|
||||
store.insert(_sample(20260904, 930, limit_up=77))
|
||||
|
||||
rows = store.day_samples(20260904)
|
||||
assert [r["minute"] for r in rows] == [930, 935] # 升序
|
||||
assert rows[0]["limit_up_count"] == 77 # 覆盖生效
|
||||
assert store.latest_date() == 20260904
|
||||
|
||||
|
||||
def test_store_daily_history_close_snapshot_and_peak(store):
|
||||
# 收盘快照 = 当日最后一条采样;峰值 = 当日涨停最大值
|
||||
store.insert(_sample(20260903, 930, up=1500, limit_up=30, limit_down=40, amount=7e11))
|
||||
store.insert(
|
||||
_sample(20260903, 1500, up=2500, down=1500, limit_up=90, limit_down=5, amount=9e11)
|
||||
)
|
||||
store.insert(
|
||||
_sample(20260904, 930, up=1800, down=2200, limit_up=20, limit_down=60, amount=6e11)
|
||||
)
|
||||
|
||||
days = store.daily_history(10)
|
||||
assert [d["date"] for d in days] == [20260903, 20260904] # 升序
|
||||
|
||||
d3 = days[0]
|
||||
assert d3["limit_up_peak"] == 90 # 日内峰值(930 点只有 30,1500 点 90)
|
||||
assert d3["limit_up_close"] == 90 # 收盘快照取当日最后一条
|
||||
assert d3["up_count"] == 2500
|
||||
assert d3["up_ratio"] == 62.5 # 2500 / (2500+1500)
|
||||
|
||||
d4 = days[1]
|
||||
assert d4["limit_up_peak"] == 20
|
||||
assert d4["up_ratio"] == 45.0 # 1800 / 4000
|
||||
|
||||
|
||||
def test_sampler_inserts_store_rows(store):
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.web.sentiment_sampler import SentimentSampler
|
||||
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"up_count": 2100,
|
||||
"down_count": 2300,
|
||||
"neutral_count": 120,
|
||||
"total_count": 4520,
|
||||
"limit_up_count": 44,
|
||||
"limit_down_count": 9,
|
||||
"total_amount": 8.5e11,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
async def get_market_stat(self):
|
||||
return df
|
||||
|
||||
sampler = SentimentSampler(FakeClient().get_market_stat, store=store, interval=1.0)
|
||||
asyncio.run(sampler._sample_once())
|
||||
|
||||
rows = store.day_samples(store.latest_date())
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["limit_up_count"] == 44
|
||||
assert rows[0]["total_amount"] == 8.5e11
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vipdoc_factory(tmp_path):
|
||||
"""按 {文件名: {dates, closes}} 合成 vipdoc 目录的工厂。"""
|
||||
from easy_tdx.offline.daily_bar import _DAILY_FMT
|
||||
|
||||
def _day(date: int, close: float) -> bytes:
|
||||
return _DAILY_FMT.pack(
|
||||
date,
|
||||
round((close - 0.05) * 100),
|
||||
round(close * 100),
|
||||
round((close - 0.10) * 100),
|
||||
round(close * 100),
|
||||
5_000_000.0,
|
||||
1_000_000,
|
||||
0,
|
||||
)
|
||||
|
||||
def factory(specs: dict[str, dict]) -> object:
|
||||
for filename, spec in specs.items():
|
||||
exchange = filename[:2]
|
||||
lday = tmp_path / exchange / "lday"
|
||||
lday.mkdir(parents=True, exist_ok=True)
|
||||
data = b"".join(
|
||||
_day(d, c) for d, c in zip(spec["dates"], spec["closes"])
|
||||
)
|
||||
(lday / f"{filename}.day").write_bytes(data)
|
||||
return tmp_path
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def test_limitup_history_counts(vipdoc_factory):
|
||||
from easy_tdx.screen.limitup import compute_limitup_history
|
||||
|
||||
v = vipdoc_factory(
|
||||
# A 股票:0802、0803 连续两日涨停
|
||||
{
|
||||
"sh600100": {
|
||||
"dates": [20260801, 20260802, 20260803, 20260804],
|
||||
"closes": [10.00, 11.00, 12.10, 12.50],
|
||||
},
|
||||
# B 股票:0804 跌停
|
||||
"sz000200": {
|
||||
"dates": [20260801, 20260802, 20260803, 20260804],
|
||||
"closes": [10.00, 10.00, 10.00, 9.00],
|
||||
},
|
||||
}
|
||||
)
|
||||
rows = compute_limitup_history(v, days=10)
|
||||
by_date = {r["date"]: r for r in rows}
|
||||
assert by_date[20260802]["limit_up"] == 1
|
||||
assert by_date[20260803]["limit_up"] == 1
|
||||
assert by_date[20260804]["limit_down"] == 1
|
||||
assert by_date[20260804]["limit_up"] == 0
|
||||
# 升序
|
||||
dates = [r["date"] for r in rows]
|
||||
assert dates == sorted(dates)
|
||||
|
||||
|
||||
def test_limitup_history_endpoint_cache(vipdoc_factory, monkeypatch):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from easy_tdx.screen import limitup as limitup_mod
|
||||
from easy_tdx.web.errors import register_exception_handlers
|
||||
from easy_tdx.web.routers import market as market_mod
|
||||
|
||||
v = vipdoc_factory(
|
||||
{"sh600100": {"dates": [20260801, 20260802], "closes": [10.0, 11.0]}}
|
||||
)
|
||||
calls = {"n": 0}
|
||||
real = limitup_mod.compute_limitup_history
|
||||
|
||||
def counting(*a, **kw):
|
||||
calls["n"] += 1
|
||||
return real(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(limitup_mod, "compute_limitup_history", counting)
|
||||
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
app.include_router(market_mod.router, prefix="/api/v1")
|
||||
app.state.tdx_client = object()
|
||||
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get("/api/v1/market/limitup-history", params={"days": 10, "vipdoc": str(v)})
|
||||
assert r1.status_code == 200
|
||||
body = r1.json()["data"]
|
||||
# 仅 0802 有一天涨停(0801 无前收不计数)
|
||||
assert body["count"] == 1
|
||||
assert body["days"][0] == {"date": 20260802, "limit_up": 1, "limit_down": 0}
|
||||
client.get("/api/v1/market/limitup-history", params={"days": 10, "vipdoc": str(v)})
|
||||
assert calls["n"] == 1 # 缓存命中
|
||||
@@ -31,6 +31,7 @@ const sseLabel: Record<string, string> = {
|
||||
<RouterLink to="/hotspots" active-class="active">热点滚动</RouterLink>
|
||||
<RouterLink to="/calendar" active-class="active">大盘日历</RouterLink>
|
||||
<RouterLink to="/limitup" active-class="active">涨停生态</RouterLink>
|
||||
<RouterLink to="/sentiment" active-class="active">市场情绪</RouterLink>
|
||||
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
|
||||
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
|
||||
<div class="nav-group">分析</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
DataFrameResponse,
|
||||
HotspotResp,
|
||||
LimitUpEcologyResp,
|
||||
LimitUpHistoryRow,
|
||||
LlmChatResponse,
|
||||
LlmChatContext,
|
||||
LlmHistoryResponse,
|
||||
@@ -32,6 +33,8 @@ import type {
|
||||
SavedStrategyCreate,
|
||||
SavedStrategyListResponse,
|
||||
SecurityQuote,
|
||||
SentimentHistoryResp,
|
||||
SentimentTodayResp,
|
||||
ServerHostInfo,
|
||||
ServerHostListResponse,
|
||||
ServerSwitchResult,
|
||||
@@ -843,6 +846,32 @@ export async function fetchLimitUpEcology(): Promise<LimitUpEcologyResp> {
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 当日情绪分钟曲线(采样器逐分钟落库;date=0 表示尚无采样)。 */
|
||||
export async function fetchSentimentToday(): Promise<SentimentTodayResp> {
|
||||
const resp = await fetch(`${BASE}/market/sentiment/today`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as { data: SentimentTodayResp }
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 逐日情绪聚合(收盘快照上涨占比 + 涨跌停家数,依赖采样积累)。 */
|
||||
export async function fetchSentimentHistory(days = 60): Promise<SentimentHistoryResp> {
|
||||
const params = new URLSearchParams({ days: String(days) })
|
||||
const resp = await fetch(`${BASE}/market/sentiment/history?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as { data: SentimentHistoryResp }
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 涨停/跌停家数逐日历史(vipdoc 离线回补,服务端缓存 10 分钟)。 */
|
||||
export async function fetchLimitUpHistory(days = 60): Promise<LimitUpHistoryRow[]> {
|
||||
const params = new URLSearchParams({ days: String(days) })
|
||||
const resp = await fetch(`${BASE}/market/limitup-history?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
const body = (await resp.json()) as { data: { count: number; days: LimitUpHistoryRow[] } }
|
||||
return body.data.days
|
||||
}
|
||||
|
||||
/** 中金所成交持仓排名:品种列表(含科普元数据)。 */
|
||||
export async function fetchCcpmProducts(): Promise<CcpmProductsResponse> {
|
||||
const resp = await fetch(`${BASE}/ccpm/products`)
|
||||
|
||||
@@ -12,6 +12,7 @@ import LlmHistoryView from './views/LlmHistoryView.vue'
|
||||
import LlmSettingsView from './views/LlmSettingsView.vue'
|
||||
import OptimizeView from './views/OptimizeView.vue'
|
||||
import PortfolioView from './views/PortfolioView.vue'
|
||||
import SentimentView from './views/SentimentView.vue'
|
||||
import ServerSettingsView from './views/ServerSettingsView.vue'
|
||||
import SignalRadarView from './views/SignalRadarView.vue'
|
||||
import StrategiesView from './views/StrategiesView.vue'
|
||||
@@ -35,6 +36,8 @@ const routes = [
|
||||
{ path: '/watchlist', name: 'watchlist', component: WatchlistView },
|
||||
// 涨停生态(连板天梯/炸板/跌停,本地 vipdoc 离线回算)
|
||||
{ path: '/limitup', name: 'limitup', component: LimitUpView },
|
||||
// 市场情绪(宽度分时 + 涨停温度计;采样器盘中逐分钟积累)
|
||||
{ path: '/sentiment', name: 'sentiment', component: SentimentView },
|
||||
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
||||
|
||||
@@ -687,6 +687,54 @@ export interface LimitUpEcologyResp {
|
||||
blown: LimitUpEntry[]
|
||||
}
|
||||
|
||||
// ── 市场情绪(/market/sentiment/*,盘中逐分钟采样 + vipdoc 涨停史回补) ─────
|
||||
|
||||
export interface SentimentSample {
|
||||
date: number
|
||||
minute: number
|
||||
ts: number
|
||||
up_count: number
|
||||
down_count: number
|
||||
neutral_count: number
|
||||
total_count: number
|
||||
limit_up_count: number
|
||||
limit_down_count: number
|
||||
total_amount: number
|
||||
up_ratio: number
|
||||
}
|
||||
|
||||
export interface SentimentTodayResp {
|
||||
/** 交易日 YYYYMMDD;0 = 尚无采样 */
|
||||
date: number
|
||||
count: number
|
||||
samples: SentimentSample[]
|
||||
}
|
||||
|
||||
export interface SentimentDay {
|
||||
date: number
|
||||
/** 当日样本数(<10 视为不完整交易日,曲线渲染时可忽略) */
|
||||
n: number
|
||||
limit_up_peak: number
|
||||
up_count: number
|
||||
down_count: number
|
||||
limit_up_close: number
|
||||
limit_down_close: number
|
||||
amount_close: number
|
||||
up_ratio: number
|
||||
}
|
||||
|
||||
export interface SentimentHistoryResp {
|
||||
count: number
|
||||
days: SentimentDay[]
|
||||
}
|
||||
|
||||
/** vipdoc 回补的逐日涨停/跌停家数(无需采样积累)。 */
|
||||
export interface LimitUpHistoryRow {
|
||||
date: number
|
||||
limit_up: number
|
||||
limit_down: number
|
||||
}
|
||||
|
||||
// ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)──────────────
|
||||
|
||||
export interface WalkForwardWindow {
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
// 市场情绪(/sentiment):盘中宽度分时 + 涨停家数历史,回答"今天市场冷还是热"。
|
||||
// 数据两层:采样器分钟快照(/market/sentiment/*,随使用逐渐积累)
|
||||
// + vipdoc 离线回补的逐日涨停/跌停家数(/market/limitup-history,即时可用)。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
|
||||
import {
|
||||
fetchLimitUpHistory,
|
||||
fetchMarketStat,
|
||||
fetchSentimentHistory,
|
||||
fetchSentimentToday,
|
||||
formatError,
|
||||
} from '../api'
|
||||
import { fmtAmount } from '../format'
|
||||
import type { LimitUpHistoryRow, MarketStat, SentimentDay, SentimentSample } from '../types'
|
||||
|
||||
const today = ref<{ date: number; count?: number; samples: SentimentSample[] } | null>(null)
|
||||
const histDays = ref<SentimentDay[]>([])
|
||||
const luHistory = ref<LimitUpHistoryRow[]>([])
|
||||
const stat = ref<MarketStat | null>(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const lastRefresh = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = today.value === null
|
||||
error.value = ''
|
||||
try {
|
||||
const [t, h, lu, st] = await Promise.all([
|
||||
fetchSentimentToday(),
|
||||
fetchSentimentHistory(60),
|
||||
fetchLimitUpHistory(60),
|
||||
fetchMarketStat().catch(() => null),
|
||||
])
|
||||
today.value = t
|
||||
histDays.value = h.days
|
||||
luHistory.value = lu
|
||||
stat.value = st
|
||||
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// loading 复位触发 v-else-if 切换后,图表容器才挂载到 DOM
|
||||
await nextTick()
|
||||
render()
|
||||
}
|
||||
|
||||
// ── 温度卡(今日实时 = /market/stat;缺省回退最后一条采样) ───────────────────
|
||||
|
||||
const latest = computed(() => {
|
||||
const s = today.value?.samples ?? []
|
||||
return s.length > 0 ? s[s.length - 1] : null
|
||||
})
|
||||
|
||||
const upRatio = computed(() => {
|
||||
const s = stat.value
|
||||
if (s && s.up_count + s.down_count > 0) {
|
||||
return (100 * s.up_count) / (s.up_count + s.down_count)
|
||||
}
|
||||
return latest.value?.up_ratio ?? null
|
||||
})
|
||||
|
||||
const limitUpNow = computed(() => stat.value?.limit_up_count ?? latest.value?.limit_up_count ?? null)
|
||||
const limitDownNow = computed(
|
||||
() => stat.value?.limit_down_count ?? latest.value?.limit_down_count ?? null,
|
||||
)
|
||||
const amountNow = computed(() => stat.value?.total_amount ?? latest.value?.total_amount ?? null)
|
||||
|
||||
/** 情绪判定:上涨占比 + 涨跌停差 粗分五档 */
|
||||
const mood = computed(() => {
|
||||
const r = upRatio.value
|
||||
if (r === null) return { label: '—', cls: 'flat' }
|
||||
if (r >= 70) return { label: '普涨 · 情绪高潮', cls: 'up' }
|
||||
if (r >= 55) return { label: '偏暖', cls: 'up' }
|
||||
if (r > 45) return { label: '均衡', cls: 'flat' }
|
||||
if (r > 30) return { label: '偏冷', cls: 'down' }
|
||||
return { label: '普跌 · 情绪冰点', cls: 'down' }
|
||||
})
|
||||
|
||||
// ── 图表 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const todayEl = ref<HTMLDivElement>()
|
||||
const histEl = ref<HTMLDivElement>()
|
||||
let todayChart: echarts.ECharts | null = null
|
||||
let histChart: echarts.ECharts | null = null
|
||||
|
||||
function hm(minute: number): string {
|
||||
return `${String(Math.floor(minute / 100)).padStart(2, '0')}:${String(minute % 100).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderToday()
|
||||
renderHistory()
|
||||
}
|
||||
|
||||
function renderToday() {
|
||||
if (!todayEl.value) return
|
||||
todayChart ??= echarts.init(todayEl.value, 'dark')
|
||||
const samples = today.value?.samples ?? []
|
||||
const x = samples.map((s) => hm(s.minute))
|
||||
todayChart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['上涨家数', '下跌家数', '涨停家数'], top: 0 },
|
||||
grid: { left: 60, right: 60, top: 30, bottom: 30 },
|
||||
xAxis: { type: 'category', data: x, boundaryGap: false },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '家数', scale: true, splitLine: { lineStyle: { color: '#2a2e3a' } } },
|
||||
{ type: 'value', name: '涨停', scale: true, position: 'right', splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '上涨家数',
|
||||
type: 'line',
|
||||
data: samples.map((s) => s.up_count),
|
||||
showSymbol: false,
|
||||
lineStyle: { color: UP_COLOR, width: 2 },
|
||||
itemStyle: { color: UP_COLOR },
|
||||
areaStyle: { color: 'rgba(239,65,70,0.08)' },
|
||||
},
|
||||
{
|
||||
name: '下跌家数',
|
||||
type: 'line',
|
||||
data: samples.map((s) => s.down_count),
|
||||
showSymbol: false,
|
||||
lineStyle: { color: DOWN_COLOR, width: 2 },
|
||||
itemStyle: { color: DOWN_COLOR },
|
||||
},
|
||||
{
|
||||
name: '涨停家数',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: samples.map((s) => s.limit_up_count),
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#f5a623', width: 1.5, type: 'dashed' },
|
||||
itemStyle: { color: '#f5a623' },
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
if (!histEl.value) return
|
||||
histChart ??= echarts.init(histEl.value, 'dark')
|
||||
// 基底 = vipdoc 回补的逐日涨跌停;采样聚合有值的日期叠加上涨占比线
|
||||
const lu = luHistory.value
|
||||
const sampled = new Map(histDays.value.map((d) => [d.date, d]))
|
||||
const x = lu.map((r: LimitUpHistoryRow) => String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$2-$3'))
|
||||
const ratios = lu.map((r) => {
|
||||
const d = sampled.get(r.date)
|
||||
return d && d.n >= 10 ? d.up_ratio : null // 样本不足的交易日不画占比线
|
||||
})
|
||||
histChart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['涨停家数', '跌停家数', '上涨占比%'], top: 0 },
|
||||
grid: { left: 50, right: 55, top: 30, bottom: 30 },
|
||||
xAxis: { type: 'category', data: x },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '家数', splitLine: { lineStyle: { color: '#2a2e3a' } } },
|
||||
{ type: 'value', name: '上涨占比%', position: 'right', max: 100, splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '涨停家数',
|
||||
type: 'bar',
|
||||
data: lu.map((r) => r.limit_up),
|
||||
itemStyle: { color: UP_COLOR },
|
||||
barMaxWidth: 8,
|
||||
},
|
||||
{
|
||||
name: '跌停家数',
|
||||
type: 'bar',
|
||||
data: lu.map((r) => -r.limit_down),
|
||||
itemStyle: { color: DOWN_COLOR },
|
||||
barMaxWidth: 8,
|
||||
tooltip: { valueFormatter: (v: number) => String(Math.abs(Number(v))) },
|
||||
},
|
||||
{
|
||||
name: '上涨占比%',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: ratios,
|
||||
connectNulls: false,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#f5a623', width: 2 },
|
||||
itemStyle: { color: '#f5a623' },
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
todayChart?.resize()
|
||||
histChart?.resize()
|
||||
}
|
||||
|
||||
let timer = 0
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
timer = window.setInterval(() => {
|
||||
if (document.hidden) return
|
||||
load()
|
||||
}, 60_000)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
todayChart?.dispose()
|
||||
histChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sentiment-view">
|
||||
<div class="view-head">
|
||||
<h2>市场情绪</h2>
|
||||
<span class="dim head-sub">宽度 · 涨停温度计</span>
|
||||
<span class="tb-spacer"></span>
|
||||
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
|
||||
<button class="manual-refresh" @click="load">↻ 刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="err card">
|
||||
加载失败:{{ error }}
|
||||
<button @click="load">重试</button>
|
||||
</div>
|
||||
<div v-else-if="loading" class="loading">加载中…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 温度卡 -->
|
||||
<div class="stat-strip">
|
||||
<div class="stat-card card">
|
||||
<div class="stat-title">上涨占比</div>
|
||||
<div class="stat-main mono" :class="mood.cls">{{ upRatio === null ? '-' : upRatio.toFixed(1) + '%' }}</div>
|
||||
<div class="stat-sub" :class="mood.cls">{{ mood.label }}</div>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<div class="stat-title">涨停 / 跌停</div>
|
||||
<div class="stat-main">
|
||||
<span class="up">{{ limitUpNow ?? '-' }}</span>
|
||||
<span class="dim"> / </span>
|
||||
<span class="down">{{ limitDownNow ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<div class="stat-title">今日总成交</div>
|
||||
<div class="stat-main">{{ fmtAmount(amountNow) }}</div>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<div class="stat-title">今日采样点</div>
|
||||
<div class="stat-main">{{ today?.count ?? 0 }} <span class="unit">个</span></div>
|
||||
<div class="stat-sub dim">交易时段每分钟一条 · 持续积累</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日宽度分时 -->
|
||||
<div class="section">
|
||||
<div class="sec-title">今日宽度分时</div>
|
||||
<div class="card chart-card">
|
||||
<div ref="todayEl" class="chart"></div>
|
||||
<div v-if="(today?.samples?.length ?? 0) === 0" class="empty-hint dim">
|
||||
今日尚无采样数据。采样器在交易时段每分钟落一条,服务持续运行后曲线自动成形。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 近 60 日情绪 -->
|
||||
<div class="section">
|
||||
<div class="sec-title">近 60 日 · 涨停/跌停家数(vipdoc 回补)与上涨占比(采样积累)</div>
|
||||
<div class="card chart-card">
|
||||
<div ref="histEl" class="chart-lg"></div>
|
||||
<div v-if="luHistory.length === 0" class="empty-hint dim">
|
||||
未检测到本地 vipdoc 数据,历史涨停家数不可用。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sentiment-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.view-head,
|
||||
.stat-strip,
|
||||
.err,
|
||||
.loading,
|
||||
.section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.view-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.head-sub {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tb-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.refresh-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.manual-refresh {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.err {
|
||||
color: var(--up);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.loading {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.stat-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-card {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-main {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-sub {
|
||||
font-size: 11.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stat-sub.up,
|
||||
.stat-main.up {
|
||||
color: var(--up);
|
||||
}
|
||||
.stat-sub.down,
|
||||
.stat-main.down {
|
||||
color: var(--down);
|
||||
}
|
||||
.stat-sub.flat,
|
||||
.stat-main.flat {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.sec-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.chart-card {
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.chart {
|
||||
height: 260px;
|
||||
}
|
||||
.chart-lg {
|
||||
height: 300px;
|
||||
}
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
padding: 0 40px;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.stat-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user