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
+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