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:
Justin Gu
2026-09-05 03:10:19 +08:00
parent e419f911b7
commit 2b86a7d588
12 changed files with 1161 additions and 4 deletions
+3 -3
View File
@@ -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日均量带) | 指数分钟线现成 | ⏳ 第四批 |
+86 -1
View File
@@ -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
]
+24
View File
@@ -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:
+65
View File
@@ -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"),
+99
View File
@@ -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
+183
View File
@@ -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
+204
View File
@@ -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 点只有 301500 点 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 # 缓存命中
+1
View File
@@ -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>
+29
View File
@@ -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`)
+3
View File
@@ -12,6 +12,7 @@ import LlmHistoryView from './views/LlmHistoryView.vue'
import LlmSettingsView from './views/LlmSettingsView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
import 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 },
+48
View File
@@ -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 {
/** 交易日 YYYYMMDD0 = 尚无采样 */
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 {
+416
View File
@@ -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>