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
+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 # 缓存命中