mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44: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:
@@ -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 # 缓存命中
|
||||
Reference in New Issue
Block a user