Files
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 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 全绿。
2026-09-06 22:16:48 +08:00

377 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""市场情绪采样(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 点只有 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 目录的工厂。
``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 NULLNaN 会被 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.0REAL 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