mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""市场情绪采样(store / sampler / 端点)与涨停历史回补单测。
|
||
|
||
sentiment_store 用 EASY_TDX_CONFIG_DIR 指向临时目录;limitup 历史复用
|
||
合成 .day 文件;端点侧验证 DictResponse 包装与缓存命中。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import pathlib
|
||
|
||
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 目录的工厂。
|
||
|
||
``factory(specs, root=None)``:root 缺省写 tmp_path;同一测试需要多个
|
||
独立 vipdoc 目录时传不同 root。
|
||
"""
|
||
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], root=None) -> object:
|
||
base = pathlib.Path(root) if root is not None else tmp_path
|
||
for filename, spec in specs.items():
|
||
exchange = filename[:2]
|
||
lday = base / 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 base
|
||
|
||
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 # 缓存命中
|
||
|
||
|
||
def test_board_fund_history_null_main_net_no_type_error(tmp_path):
|
||
"""历史遗留的 main_net NULL 行不得让 /market/board-fund/history 抛 TypeError。
|
||
|
||
正式 schema 的 main_net 是 REAL NOT NULL(NaN 会被 SQLite 存成 NULL 而
|
||
被 NOT NULL 拒绝),但手工编辑/旧版本库可能存在 NULL 行——读侧须兜底。
|
||
旧实现:float(None) 抛 TypeError。
|
||
"""
|
||
import sqlite3
|
||
|
||
db = tmp_path / "legacy_sentiment.db"
|
||
conn = sqlite3.connect(db)
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE samples (date INTEGER NOT NULL, minute INTEGER NOT NULL, ts INTEGER NOT NULL,
|
||
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));
|
||
CREATE TABLE board_fund (date INTEGER NOT NULL, rank INTEGER NOT NULL,
|
||
code TEXT NOT NULL, name TEXT NOT NULL, main_net REAL,
|
||
PRIMARY KEY (date, rank));
|
||
"""
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO board_fund (date, rank, code, name, main_net) VALUES (?,?,?,?,?)",
|
||
(20260901, 1, "881001", "银行", None),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
from easy_tdx.web.sentiment_store import SentimentStore
|
||
|
||
store = SentimentStore(db_path=db)
|
||
assert store.list_fund_days(5) == [
|
||
{
|
||
"date": 20260901,
|
||
"boards": [{"rank": 1, "code": "881001", "name": "银行", "main_net": 0.0}],
|
||
}
|
||
]
|
||
|
||
|
||
def test_upsert_fund_day_nan_main_net_stored_as_zero(store):
|
||
"""写入口径:NaN 主力净流入落库为 0.0(REAL NOT NULL 列不吃 NaN)。"""
|
||
store.upsert_fund_day(
|
||
20260902,
|
||
[{"code": "881001", "name": "银行", "main_net": float("nan")}],
|
||
)
|
||
days = store.list_fund_days(5)
|
||
assert days[0]["boards"][0]["main_net"] == 0.0
|
||
|
||
|
||
def test_limitup_history_cache_key_includes_vipdoc(vipdoc_factory, monkeypatch, tmp_path):
|
||
"""limitup-history 缓存键须含 (days, vipdoc),不同 vipdoc 不互相命中。"""
|
||
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
|
||
|
||
v1 = vipdoc_factory({"sh600100": {"dates": [20260801, 20260802], "closes": [10.0, 11.0]}})
|
||
v2_dir = tmp_path / "vipdoc_v2"
|
||
(v2_dir / "sh" / "lday").mkdir(parents=True)
|
||
v2 = vipdoc_factory(
|
||
{
|
||
"sh600100": {"dates": [20260801, 20260802], "closes": [10.0, 11.0]},
|
||
"sz000200": {"dates": [20260801, 20260802], "closes": [10.0, 9.0]},
|
||
},
|
||
root=v2_dir,
|
||
)
|
||
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(v1)})
|
||
assert r1.status_code == 200
|
||
r2 = client.get("/api/v1/market/limitup-history", params={"days": 10, "vipdoc": str(v2)})
|
||
assert r2.status_code == 200
|
||
# v2 多一只跌停股 → 结果必须不同(不允许命中 v1 的缓存)
|
||
assert r2.json()["data"]["days"][0]["limit_down"] == 1
|
||
assert calls["n"] == 2
|
||
|
||
|
||
# ── 采样器时区统一(v1.32.6):日期/分钟键一律取沪市时区 ─────────────────────
|
||
|
||
|
||
class _RecorderDatetime:
|
||
"""记录 now(tz) 实参的 datetime 替身。"""
|
||
|
||
captured: dict = {}
|
||
|
||
@classmethod
|
||
def now(cls, tz=None):
|
||
cls.captured["tz"] = tz
|
||
from datetime import datetime as _dt
|
||
|
||
return _dt(2026, 9, 6, 2, 30, tzinfo=tz) if tz else _dt(2026, 9, 6, 2, 30)
|
||
|
||
|
||
def test_sentiment_sampler_uses_shanghai_tz(store, monkeypatch):
|
||
"""SentimentSampler._sample_once 的 (date, minute) 键必须取沪市时区。"""
|
||
import asyncio
|
||
|
||
import pandas as pd
|
||
|
||
from easy_tdx.realtime.session import SHANGHAI_TZ
|
||
from easy_tdx.web import sentiment_sampler as ss_mod
|
||
|
||
async def fake_stat():
|
||
return pd.DataFrame(
|
||
[
|
||
{
|
||
"up_count": 2000,
|
||
"down_count": 2000,
|
||
"neutral_count": 100,
|
||
"total_count": 4100,
|
||
"limit_up_count": 50,
|
||
"limit_down_count": 10,
|
||
"total_amount": 8e11,
|
||
}
|
||
]
|
||
)
|
||
|
||
monkeypatch.setattr(ss_mod, "datetime", _RecorderDatetime)
|
||
sampler = ss_mod.SentimentSampler(fake_stat, store=store)
|
||
asyncio.run(sampler._sample_once())
|
||
|
||
assert _RecorderDatetime.captured["tz"] is SHANGHAI_TZ
|
||
rows = store.day_samples(20260906)
|
||
assert len(rows) == 1 and rows[0]["minute"] == 230
|
||
|
||
|
||
def test_fund_flow_sampler_uses_shanghai_tz(store, monkeypatch):
|
||
"""FundFlowSampler._sample_once 的采样日期必须取沪市时区。"""
|
||
import asyncio
|
||
|
||
import pandas as pd
|
||
|
||
from easy_tdx.realtime.session import SHANGHAI_TZ
|
||
from easy_tdx.web import sentiment_sampler as ss_mod
|
||
|
||
class _FakeMac:
|
||
async def get_board_ranking(self, **kw):
|
||
return pd.DataFrame(
|
||
[
|
||
{"code": "881001", "name": "银行", "main_net_amount": 1.2e9},
|
||
]
|
||
)
|
||
|
||
monkeypatch.setattr(ss_mod, "datetime", _RecorderDatetime)
|
||
sampler = ss_mod.FundFlowSampler(_FakeMac(), store=store)
|
||
asyncio.run(sampler._sample_once())
|
||
|
||
assert _RecorderDatetime.captured["tz"] is SHANGHAI_TZ
|
||
days = store.list_fund_days(5)
|
||
assert days[0]["date"] == 20260906
|