mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
feat: 自选列表新增「近3日/近1周/近2周」涨跌幅三列(issue #7)
交易日偏移口径:T = 上证指数日线(交易日历)中 <= 今天的最后一天, D_n = T 往前 n 个交易日,锚点 = 个股日线(/bars 同款 QFQ)中 date <= D_n 的最后一根 bar。后端只回锚点收盘价,涨跌幅由前端用 SSE 实时价现算,盘中三列随报价免费跳动、无需轮询本接口。 - 新增 GET /watchlist/returns + 纯计算模块 web/returns.py(零 IO,单测覆盖 停牌回退/次新 null/除权日 QFQ/非交易日回退等口径) - 个股日线与交易日历均进程内缓存(当日不变、次日失效),重复刷新零行情请求; 日历缺今天(serve 盘前启动)时按 60s 间隔重取,避免 T 整体前移 - 取数并发 ≤ 4(TDX 防封红线);单只失败只落 error,不影响整表 - 前端 +3 列(着色复用 dirClass/fmtPctSigned),e2e 同步断言 与 issue 定稿的两处偏差(/simplify 收敛,"减少不必要的改动"): - 删除 windows 查询参数:列名与窗口一一对应(issue 亦将"用户自定义窗口" 列为 out of scope),固定 3/5/10 - 个股缓存由磁盘 JSON 改为进程内 dict:可观测行为不变(当日不重复请求), 但 serve 重启后当天首次请求会重取一次 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -202,3 +204,450 @@ def test_watchlist_remove_validates_code_format(monkeypatch, tmp_path):
|
||||
with TestClient(app) as client:
|
||||
resp = client.delete("/api/v1/watchlist/SZ/abc123")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── /watchlist/returns 端点(issue #7;mock 取数,不连网)────────────────────
|
||||
|
||||
_TODAY = date(2026, 9, 11) # 周五
|
||||
|
||||
|
||||
def _cal() -> list[date]:
|
||||
"""15 个工作日(2026-08-24 ~ 2026-09-11);T=09-11 → D_3=09-08 / D_5=09-04 / D_10=08-28。"""
|
||||
days: list[date] = []
|
||||
cur = date(2026, 8, 24)
|
||||
while len(days) < 15:
|
||||
if cur.weekday() < 5:
|
||||
days.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
_CAL = _cal()
|
||||
_IDX_D3, _IDX_D5, _IDX_D10 = 11, 9, 4 # _CAL 中 09-08 / 09-04 / 08-28 的下标
|
||||
|
||||
|
||||
def _ramp(cal: list[date], base: float = 10.0) -> list[tuple[date, float]]:
|
||||
return [(d, base + i) for i, d in enumerate(cal)]
|
||||
|
||||
|
||||
class _FakeMac:
|
||||
"""AsyncMacClient 替身:按 code 回预置日线;记录调用(校验 QFQ/count/缓存命中)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
series: dict[str, list[tuple[date, float]]],
|
||||
*,
|
||||
fail: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.series = series
|
||||
self.fail = set(fail)
|
||||
self.calls: list[str] = []
|
||||
self.kwargs: list[dict[str, Any]] = []
|
||||
|
||||
async def get_stock_kline(
|
||||
self,
|
||||
market: Any,
|
||||
code: str,
|
||||
period: Any,
|
||||
start: int = 0,
|
||||
count: int = 800,
|
||||
times: int = 1,
|
||||
**kw: Any,
|
||||
) -> pd.DataFrame:
|
||||
self.calls.append(code)
|
||||
self.kwargs.append({"market": market, "count": count, "times": times, **kw})
|
||||
if code in self.fail:
|
||||
raise RuntimeError("MAC 取数失败")
|
||||
rows = self.series.get(code)
|
||||
if rows is None: # 板块代码 / 无数据
|
||||
return pd.DataFrame()
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": pd.to_datetime([d for d, _ in rows]),
|
||||
"close": [c for _, c in rows],
|
||||
"float_shares": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _FakeStd:
|
||||
"""标准 TdxClient 替身(MAC 缺失时的降级路径);返回 date 列(非 datetime)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
series: dict[str, list[tuple[date, float]]] | None = None,
|
||||
*,
|
||||
fail: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.series = series or {}
|
||||
self.fail = set(fail)
|
||||
self.calls: list[str] = []
|
||||
|
||||
def _df(self, code: str) -> pd.DataFrame:
|
||||
self.calls.append(code)
|
||||
if code in self.fail:
|
||||
raise RuntimeError("标准客户端取数失败")
|
||||
rows = self.series.get(code)
|
||||
if rows is None:
|
||||
return pd.DataFrame()
|
||||
return pd.DataFrame(
|
||||
{"date": pd.to_datetime([d for d, _ in rows]), "close": [c for _, c in rows]}
|
||||
)
|
||||
|
||||
async def get_index_bars(self, market: Any, code: str, *a: Any, **kw: Any) -> pd.DataFrame:
|
||||
return self._df(code)
|
||||
|
||||
async def get_security_bars(self, market: Any, code: str, *a: Any, **kw: Any) -> pd.DataFrame:
|
||||
return self._df(code)
|
||||
|
||||
|
||||
def _returns_app(
|
||||
monkeypatch: Any, tmp_path: Path, mac: Any, std: Any, today: date = _TODAY
|
||||
) -> tuple[Any, Any]:
|
||||
"""自选页应用:注入假 MAC / 假标准客户端 + 固定"今天"(不连网)。"""
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi import FastAPI
|
||||
|
||||
from easy_tdx.web import watchlist_store as ws
|
||||
from easy_tdx.web.errors import register_exception_handlers
|
||||
from easy_tdx.web.routers import watchlist as watchlist_mod
|
||||
|
||||
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
monkeypatch.setattr(watchlist_mod, "_today", lambda: today)
|
||||
ws._store = None # 单例重建 → 用临时配置目录的 db
|
||||
watchlist_mod._calendar_cache.clear() # 进程内缓存不跨测试复用
|
||||
watchlist_mod._bars_cache.clear()
|
||||
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
app.include_router(watchlist_mod.router, prefix="/api/v1")
|
||||
app.state.mac_client = mac
|
||||
app.state.tdx_client = std
|
||||
return app, watchlist_mod
|
||||
|
||||
|
||||
def test_watchlist_returns_ok(monkeypatch, tmp_path):
|
||||
"""正常锚定:T + 三窗口锚点日期/收盘价,key 用 symbol,取数走 MAC + QFQ。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from easy_tdx.mac.enums import Adjust
|
||||
|
||||
mac = _FakeMac(
|
||||
{
|
||||
"000001": _ramp(_CAL, 3000.0), # 上证指数(交易日历)
|
||||
"600519": _ramp(_CAL, 10.0),
|
||||
"002594": _ramp(_CAL, 20.0),
|
||||
}
|
||||
)
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
store = mod.get_watchlist_store()
|
||||
store.add("SH", "600519", name="贵州茅台")
|
||||
store.add("SZ", "002594", name="比亚迪")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["trade_date"] == "2026-09-11" # T = 日历中 <= 今天的最后一个交易日
|
||||
assert set(body["items"]) == {"SH600519", "SZ002594"}
|
||||
item = body["items"]["SH600519"]
|
||||
assert item["last_close"] == pytest.approx(10.0 + 14) # 09-11 的 close
|
||||
assert item["last_date"] == "2026-09-11"
|
||||
assert item["stale_days"] == 0
|
||||
assert [(a["days"], a["date"]) for a in item["anchors"]] == [
|
||||
(3, "2026-09-08"),
|
||||
(5, "2026-09-04"),
|
||||
(10, "2026-08-28"),
|
||||
]
|
||||
assert item["anchors"][0]["close"] == pytest.approx(10.0 + _IDX_D3)
|
||||
assert item["anchors"][1]["close"] == pytest.approx(10.0 + _IDX_D5)
|
||||
assert item["anchors"][2]["close"] == pytest.approx(10.0 + _IDX_D10)
|
||||
# /bars 同款语义:MAC + QFQ + count=800
|
||||
assert {k["adjust"] for k in mac.kwargs} == {Adjust.QFQ}
|
||||
assert {k["count"] for k in mac.kwargs} == {800}
|
||||
assert set(mac.calls) == {"000001", "600519", "002594"}
|
||||
|
||||
|
||||
def test_watchlist_returns_single_failure_isolated(monkeypatch, tmp_path):
|
||||
"""单只失败(板块代码取不到)只在该 key 落 error,整表照常 200。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)}, fail=("881001",))
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
store = mod.get_watchlist_store()
|
||||
store.add("SH", "600519", name="贵州茅台")
|
||||
store.add("SH", "881001", name="某板块")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200 # 板块代码不得 500
|
||||
body = resp.json()
|
||||
# 失败项只有 error(None 字段不下发)
|
||||
assert body["items"]["SH881001"] == {"error": "no_data"}
|
||||
assert body["items"]["SH600519"]["anchors"][0]["days"] == 3
|
||||
|
||||
|
||||
def test_watchlist_returns_fetch_failed_when_both_paths_raise(monkeypatch, tmp_path):
|
||||
"""MAC 抛错 + 标准客户端也抛错 → 该只记 fetch_failed,其余照常。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0)}, fail=("600519",))
|
||||
std = _FakeStd({"000001": _ramp(_CAL, 3000.0)}, fail=("600519",))
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, std)
|
||||
store = mod.get_watchlist_store()
|
||||
store.add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["items"]["SH600519"] == {"error": "fetch_failed"}
|
||||
|
||||
|
||||
def test_watchlist_returns_insufficient_data_null_anchors(monkeypatch, tmp_path):
|
||||
"""次新股(09-09 才上市)→ 三窗口 close 为 null(前端显示 '-'),不是 500。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
listed = [d for d in _CAL if d >= date(2026, 9, 9)]
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "301999": _ramp(listed, 30.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SZ", "301999", name="次新股")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"]["SZ301999"]
|
||||
assert item["anchors"] == [
|
||||
{"days": 3, "close": None, "date": None},
|
||||
{"days": 5, "close": None, "date": None},
|
||||
{"days": 10, "close": None, "date": None},
|
||||
]
|
||||
assert item["last_date"] == "2026-09-11"
|
||||
|
||||
|
||||
def test_watchlist_returns_suspended_stock_reports_stale(monkeypatch, tmp_path):
|
||||
"""长期停牌:回 last_date + stale_days,锚点退到停牌前最后一根。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
halted = [(d, 8.0) for d in _CAL if d <= date(2026, 9, 4)]
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600001": halted})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600001", name="停牌股")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
item = resp.json()["items"]["SH600001"]
|
||||
assert item["last_date"] == "2026-09-04"
|
||||
assert item["stale_days"] == 5 # 09-07 ~ 09-11
|
||||
assert item["anchors"][0]["date"] == "2026-09-04"
|
||||
|
||||
|
||||
def test_watchlist_returns_cached_within_day(monkeypatch, tmp_path):
|
||||
"""进程内缓存(个股日线 + 日历):同一天第二次请求零行情请求。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/v1/watchlist/returns").status_code == 200
|
||||
first = (mac.calls.count("000001"), mac.calls.count("600519"))
|
||||
assert client.get("/api/v1/watchlist/returns").status_code == 200
|
||||
second = (mac.calls.count("000001"), mac.calls.count("600519"))
|
||||
|
||||
assert (first, second) == ((1, 1), (1, 1))
|
||||
|
||||
|
||||
def test_watchlist_returns_cache_expires_next_day(monkeypatch, tmp_path):
|
||||
"""缓存 TTL 到次日:跨日后重新取数(不返回昨日锚点)。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
client.get("/api/v1/watchlist/returns")
|
||||
monkeypatch.setattr(mod, "_today", lambda: _TODAY + timedelta(days=1))
|
||||
client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert mac.calls.count("600519") == 2
|
||||
|
||||
|
||||
def test_watchlist_returns_no_mac_degrades_to_standard_client(monkeypatch, tmp_path):
|
||||
"""MAC 未连接 → 降级标准 TdxClient(不复权),仍正常返回(日志标注,不静默)。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
std = _FakeStd({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, None, std)
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["trade_date"] == "2026-09-11"
|
||||
assert body["items"]["SH600519"]["anchors"][0]["date"] == "2026-09-08"
|
||||
assert "000001" in std.calls # 日历走标准客户端的 get_index_bars
|
||||
|
||||
|
||||
def test_watchlist_returns_empty_watchlist_no_request(monkeypatch, tmp_path):
|
||||
"""空自选:直接返回空表,一个行情请求都不发。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({})
|
||||
app, _mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"trade_date": None, "items": {}}
|
||||
assert mac.calls == []
|
||||
|
||||
|
||||
def test_watchlist_returns_empty_calendar_returns_503(monkeypatch, tmp_path):
|
||||
"""交易日历取不到(指数无数据)→ 503,不静默算错锚点。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({}) # 000001 也返回空
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_watchlist_returns_today_not_trading_day(monkeypatch, tmp_path):
|
||||
"""今日非交易日(周日)→ T 退回上一交易日,整表正常返回。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd(), today=date(2026, 9, 13))
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/v1/watchlist/returns")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["trade_date"] == "2026-09-11"
|
||||
assert body["items"]["SH600519"]["anchors"][0]["date"] == "2026-09-08"
|
||||
|
||||
|
||||
# ── 日历缓存的刷新时机(盘前启动的 serve 必须能等到今天的 bar) ────────────────
|
||||
|
||||
|
||||
def _at(hour: int, minute: int = 0, second: int = 0) -> Any:
|
||||
"""2026-09-11(周五)指定时刻的沪市时间。"""
|
||||
from datetime import datetime
|
||||
|
||||
from easy_tdx.realtime.session import SHANGHAI_TZ
|
||||
|
||||
return datetime(2026, 9, 11, hour, minute, second, tzinfo=SHANGHAI_TZ)
|
||||
|
||||
|
||||
def test_watchlist_returns_calendar_refetched_after_open(monkeypatch, tmp_path):
|
||||
"""盘前首取 → 日历缺今天 → 开盘后重取,``T`` 不再整体前移一个交易日。
|
||||
|
||||
这是 serve 常驻 + 机器早开机的真实路径:盘前第一次取数时今天的日线 bar
|
||||
还没生成,若日历缓存当天不再刷新,三个锚点会一路错到次日且不报任何错。
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pre_open = _CAL[:-1] # 缺 09-11(今天的 bar 尚未生成)
|
||||
mac = _FakeMac({"000001": _ramp(pre_open, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
# 盘前 08:30(非交易时段):T 退回 09-10,锚点整体前移一天
|
||||
monkeypatch.setattr(mod, "_now", lambda: _at(8, 30))
|
||||
with TestClient(app) as client:
|
||||
before = client.get("/api/v1/watchlist/returns").json()
|
||||
assert before["trade_date"] == "2026-09-10"
|
||||
assert before["items"]["SH600519"]["anchors"][0]["date"] == "2026-09-07"
|
||||
|
||||
# 开盘后 10:00(交易时段):今天的 bar 已生成 → 重取日历 → T 回到今天
|
||||
mac.series["000001"] = _ramp(_CAL, 3000.0)
|
||||
monkeypatch.setattr(mod, "_now", lambda: _at(10, 0))
|
||||
with TestClient(app) as client:
|
||||
after = client.get("/api/v1/watchlist/returns?windows=3").json()
|
||||
assert after["trade_date"] == "2026-09-11"
|
||||
assert after["items"]["SH600519"]["anchors"][0]["date"] == "2026-09-08"
|
||||
|
||||
|
||||
def test_watchlist_returns_calendar_not_refetched_when_confirmed(monkeypatch, tmp_path):
|
||||
"""日历含今天 = 已确认:交易时段内重复请求也只取一次(不引入额外请求)。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mac = _FakeMac({"000001": _ramp(_CAL, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
monkeypatch.setattr(mod, "_now", lambda: _at(10, 0))
|
||||
|
||||
with TestClient(app) as client:
|
||||
for _ in range(3):
|
||||
assert client.get("/api/v1/watchlist/returns").status_code == 200
|
||||
|
||||
assert mac.calls.count("000001") == 1 # 日历只取一次
|
||||
|
||||
|
||||
def test_watchlist_returns_calendar_not_refetched_outside_session(monkeypatch, tmp_path):
|
||||
"""时段外(收盘后/节假日)缺今天不重试——bar 不可能再生成,避免无谓请求。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pre_open = _CAL[:-1]
|
||||
mac = _FakeMac({"000001": _ramp(pre_open, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
monkeypatch.setattr(mod, "_now", lambda: _at(20, 0)) # 收盘后
|
||||
|
||||
with TestClient(app) as client:
|
||||
for _ in range(3):
|
||||
assert client.get("/api/v1/watchlist/returns").status_code == 200
|
||||
|
||||
assert mac.calls.count("000001") == 1
|
||||
assert mod._calendar_cache["2026-09-11"][0][-1] == date(2026, 9, 10)
|
||||
|
||||
|
||||
def test_calendar_stale_rules():
|
||||
"""日历重取规则:含今天 / 时段外一律不重取;缺今天则按间隔重取。"""
|
||||
from easy_tdx.web.routers.watchlist import _CalendarEntry, _calendar_stale
|
||||
|
||||
today = date(2026, 9, 11)
|
||||
no_today = [d for d in _CAL if d < today] # "今天"的 bar 始终没生成
|
||||
|
||||
# 含今天 = 已确认:永不重取(正常盘中路径,零额外请求)
|
||||
assert not _calendar_stale(_CalendarEntry(_CAL, _at(10, 0)), today, _at(15, 0))
|
||||
# 时段外:bar 不可能再生成,不重取
|
||||
assert not _calendar_stale(_CalendarEntry(no_today, _at(20, 0)), today, _at(20, 30))
|
||||
# 缺今天 + 盘中:未满间隔不重取,满了才重取
|
||||
assert not _calendar_stale(_CalendarEntry(no_today, _at(10, 0)), today, _at(10, 0, 59))
|
||||
assert _calendar_stale(_CalendarEntry(no_today, _at(10, 0)), today, _at(10, 1, 0))
|
||||
|
||||
|
||||
def test_watchlist_returns_calendar_refresh_rate_limited(monkeypatch, tmp_path):
|
||||
"""节假日(日历永远缺今天):连续请求下日历重取被间隔限流,不是每个请求一次。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
no_today = _CAL[:-1] # 永远是"今天的 bar 没生成",等价于休市
|
||||
mac = _FakeMac({"000001": _ramp(no_today, 3000.0), "600519": _ramp(_CAL, 10.0)})
|
||||
app, mod = _returns_app(monkeypatch, tmp_path, mac, _FakeStd())
|
||||
mod.get_watchlist_store().add("SH", "600519", name="贵州茅台")
|
||||
|
||||
with TestClient(app) as client:
|
||||
for second in range(0, 60, 10): # 盘中 60 秒内每 10 秒来一次请求
|
||||
monkeypatch.setattr(mod, "_now", lambda s=second: _at(9, 15) + timedelta(seconds=s))
|
||||
assert client.get("/api/v1/watchlist/returns").status_code == 200
|
||||
|
||||
# 6 次请求全部落在重取间隔内 → 日历与个股日线都只取了 1 次
|
||||
assert mac.calls.count("000001") == 1
|
||||
assert mac.calls.count("600519") == 1
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""``easy_tdx.web.returns`` 纯计算单测(issue #7 口径:按日期锚定,不按 index)。
|
||||
|
||||
覆盖 issue 列出的 5 个场景:正常锚定 / 锚点日停牌回退 / 次新数据不足 /
|
||||
除权日不出现假跌幅 / 今日非交易日退回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from easy_tdx.web.returns import (
|
||||
compute_stock_returns,
|
||||
last_bar_on_or_before,
|
||||
resolve_trade_date,
|
||||
shift_trade_date,
|
||||
)
|
||||
|
||||
# 15 个连续工作日:2026-08-24(一) ~ 2026-09-11(五)
|
||||
# → T=09-11 时 D_3=09-08 / D_5=09-04 / D_10=08-28
|
||||
CALENDAR: list[date] = [
|
||||
date(2026, 8, 24),
|
||||
date(2026, 8, 25),
|
||||
date(2026, 8, 26),
|
||||
date(2026, 8, 27),
|
||||
date(2026, 8, 28),
|
||||
date(2026, 8, 31),
|
||||
date(2026, 9, 1),
|
||||
date(2026, 9, 2),
|
||||
date(2026, 9, 3),
|
||||
date(2026, 9, 4),
|
||||
date(2026, 9, 7),
|
||||
date(2026, 9, 8),
|
||||
date(2026, 9, 9),
|
||||
date(2026, 9, 10),
|
||||
date(2026, 9, 11),
|
||||
]
|
||||
|
||||
TODAY = date(2026, 9, 11)
|
||||
T = date(2026, 9, 11)
|
||||
|
||||
|
||||
def _series(pairs: dict[date, float]) -> list[tuple[date, float]]:
|
||||
return sorted(pairs.items())
|
||||
|
||||
|
||||
def _pct(price: float, anchor: float) -> float:
|
||||
"""前端算涨跌幅的口径(后端只回锚点,涨跌幅由前端现算)。"""
|
||||
return (price / anchor - 1) * 100
|
||||
|
||||
|
||||
# ── 场景 1:正常锚定 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_anchor_dates_follow_calendar_offset() -> None:
|
||||
"""D_3 / D_5 / D_10 取自交易日历(不是自然日,也不是个股自己的序列)。"""
|
||||
bars = _series({d: 100.0 for d in CALENDAR})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
assert result.trade_date == T
|
||||
assert [(a.days, a.date, a.close) for a in result.anchors] == [
|
||||
(3, date(2026, 9, 8), 100.0),
|
||||
(5, date(2026, 9, 4), 100.0),
|
||||
(10, date(2026, 8, 28), 100.0),
|
||||
]
|
||||
assert result.last_date == T
|
||||
assert result.stale_days == 0
|
||||
|
||||
|
||||
def test_anchor_close_is_the_window_base() -> None:
|
||||
"""近3日 = 现价 / close(D_3) − 1(锚点收盘价即该窗口基准)。"""
|
||||
prices = {d: 10.0 for d in CALENDAR}
|
||||
prices[date(2026, 9, 8)] = 8.0 # D_3
|
||||
prices[date(2026, 9, 11)] = 10.0 # 现价
|
||||
result = compute_stock_returns(CALENDAR, _series(prices), today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
d3, d5, d10 = result.anchors
|
||||
assert d3.close == 8.0
|
||||
assert _pct(10.0, d3.close) == pytest.approx(25.0)
|
||||
assert _pct(10.0, d5.close) == pytest.approx(0.0)
|
||||
assert _pct(10.0, d10.close) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_windows_keep_request_order() -> None:
|
||||
"""windows 与返回 anchors 同序(调用方按 days 取用)。"""
|
||||
bars = _series({d: 1.0 for d in CALENDAR})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY, windows=[10, 3])
|
||||
assert result is not None
|
||||
assert [a.days for a in result.anchors] == [10, 3]
|
||||
|
||||
|
||||
def test_calendar_may_be_unsorted_and_has_duplicates() -> None:
|
||||
"""日历输入可乱序/含重复(内部 set + sort 规整)。"""
|
||||
bars = _series({d: 1.0 for d in CALENDAR})
|
||||
result = compute_stock_returns([*CALENDAR[::-1], T, T], bars, today=TODAY)
|
||||
assert result is not None
|
||||
assert [a.date for a in result.anchors] == [
|
||||
date(2026, 9, 8),
|
||||
date(2026, 9, 4),
|
||||
date(2026, 8, 28),
|
||||
]
|
||||
|
||||
|
||||
# ── 场景 2:锚点日停牌 → 退到最近一根 bar(返回实际日期)────────────────────
|
||||
|
||||
|
||||
def test_suspended_on_anchor_day_falls_back() -> None:
|
||||
"""个股 D_3 当日停牌(缺 09-08)→ 锚点退到 09-07 的 bar,并回实际日期。"""
|
||||
prices = {d: 10.0 for d in CALENDAR}
|
||||
del prices[date(2026, 9, 8)] # 停牌:个股序列缺这一天
|
||||
prices[date(2026, 9, 7)] = 7.5
|
||||
result = compute_stock_returns(CALENDAR, _series(prices), today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
d3 = result.anchors[0]
|
||||
assert d3.days == 3
|
||||
assert d3.date == date(2026, 9, 7) # 实际 bar 日期(不是 D_3)
|
||||
assert d3.close == 7.5
|
||||
# 停牌不改其余窗口
|
||||
assert result.anchors[1].date == date(2026, 9, 4)
|
||||
|
||||
|
||||
def test_anchor_does_not_drift_by_index_when_last_bar_missing() -> None:
|
||||
"""当日 bar 未入库(盘中)也不影响锚点:按日期锚定,与"最后一根"无关。"""
|
||||
bars = _series({d: 10.0 for d in CALENDAR if d < T}) # 今日 bar 还没落库
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
assert [a.date for a in result.anchors] == [
|
||||
date(2026, 9, 8),
|
||||
date(2026, 9, 4),
|
||||
date(2026, 8, 28),
|
||||
]
|
||||
assert result.last_date == date(2026, 9, 10)
|
||||
assert result.stale_days == 1
|
||||
|
||||
|
||||
# ── 场景 3:次新股数据不足 → close 为 None ─────────────────────────────────
|
||||
|
||||
|
||||
def test_new_stock_all_windows_null_when_listed_after_d3() -> None:
|
||||
"""09-09 上市的次新:D_3(09-08) 之前无 bar → 三个窗口全 null。"""
|
||||
bars = _series({d: 20.0 for d in CALENDAR if d >= date(2026, 9, 9)})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
assert [(a.days, a.close, a.date) for a in result.anchors] == [
|
||||
(3, None, None),
|
||||
(5, None, None),
|
||||
(10, None, None),
|
||||
]
|
||||
# 有 last_close 但仍可用于展示(前端显示 '-')
|
||||
assert result.last_close == 20.0
|
||||
assert result.last_date == T
|
||||
|
||||
|
||||
def test_new_stock_partial_windows_null() -> None:
|
||||
"""09-08 上市:近3日有锚点(08 当天首根),近1周/近2周不足 → null。"""
|
||||
listed = [d for d in CALENDAR if d >= date(2026, 9, 8)]
|
||||
bars = _series({d: 20.0 + i for i, d in enumerate(listed)})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
d3, d5, d10 = result.anchors
|
||||
assert (d3.close, d3.date) == (20.0, date(2026, 9, 8))
|
||||
assert (d5.close, d5.date) == (None, None)
|
||||
assert (d10.close, d10.date) == (None, None)
|
||||
|
||||
|
||||
def test_no_bars_returns_none() -> None:
|
||||
"""该股一根 bar 都没有 → None(端点据此记 error,不影响整表)。"""
|
||||
assert compute_stock_returns(CALENDAR, [], today=TODAY) is None
|
||||
|
||||
|
||||
# ── 场景 4:除权日不出现假跌幅(口径 = QFQ)────────────────────────────────
|
||||
|
||||
|
||||
def test_ex_dividend_day_no_fake_drop_under_qfq() -> None:
|
||||
"""跨除权日:QFQ 序列无假跌幅;同一算法喂不复权序列就会算出假跌幅。
|
||||
|
||||
构造 10 送 3(除权价 = 前收 × 0.7,09-09 除权):
|
||||
- 不复权:09-08 收 10.00 → 09-11 收 7.10,近3日 = −29%(假跌幅,实为除权)
|
||||
- 前复权:除权前价格整体 ×0.7 → 09-08 锚点 7.00,近3日 = +1.43%(真实收益)
|
||||
"""
|
||||
qfq = _series(
|
||||
{
|
||||
**{d: 7.00 for d in CALENDAR if d < date(2026, 9, 9)},
|
||||
date(2026, 9, 9): 7.00,
|
||||
date(2026, 9, 10): 7.05,
|
||||
date(2026, 9, 11): 7.10,
|
||||
}
|
||||
)
|
||||
none_adj = _series(
|
||||
{
|
||||
**{d: 10.00 for d in CALENDAR if d < date(2026, 9, 9)},
|
||||
date(2026, 9, 9): 7.00,
|
||||
date(2026, 9, 10): 7.05,
|
||||
date(2026, 9, 11): 7.10,
|
||||
}
|
||||
)
|
||||
|
||||
r_qfq = compute_stock_returns(CALENDAR, qfq, today=TODAY)
|
||||
r_none = compute_stock_returns(CALENDAR, none_adj, today=TODAY)
|
||||
assert r_qfq is not None and r_none is not None
|
||||
|
||||
# 锚定日期一致(除权不影响交易日历)
|
||||
assert [a.date for a in r_qfq.anchors] == [a.date for a in r_none.anchors]
|
||||
# 除权日锚点(09-08)在两套口径下价格不同 → 涨跌幅口径截然不同
|
||||
assert r_qfq.anchors[0].close == pytest.approx(7.00)
|
||||
assert r_none.anchors[0].close == pytest.approx(10.00)
|
||||
assert _pct(7.10, r_qfq.anchors[0].close) == pytest.approx(1.4286, abs=1e-4)
|
||||
assert _pct(7.10, r_none.anchors[0].close) == pytest.approx(-29.0, abs=0.01)
|
||||
|
||||
|
||||
def test_ex_dividend_day_in_window_does_not_shift_anchor() -> None:
|
||||
"""除权日恰好是锚点日:按日期锚定取到底就是该日 bar(除权后价),不做插值。"""
|
||||
bars = _series(
|
||||
{
|
||||
**{d: 7.00 for d in CALENDAR if d < date(2026, 9, 8)},
|
||||
date(2026, 9, 8): 7.02,
|
||||
date(2026, 9, 9): 7.00,
|
||||
date(2026, 9, 10): 7.05,
|
||||
date(2026, 9, 11): 7.10,
|
||||
}
|
||||
)
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
assert result is not None
|
||||
assert (result.anchors[0].close, result.anchors[0].date) == (7.02, date(2026, 9, 8))
|
||||
|
||||
|
||||
# ── 场景 5:今日非交易日 → T 退回最近交易日 ─────────────────────────────────
|
||||
|
||||
|
||||
def test_today_not_a_trading_day_falls_back() -> None:
|
||||
"""2026-09-13 是周日 → T = 09-11,三个锚点与交易日当天完全一致。"""
|
||||
bars = _series({d: 10.0 for d in CALENDAR})
|
||||
weekend = compute_stock_returns(CALENDAR, bars, today=date(2026, 9, 13))
|
||||
friday = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert weekend is not None and friday is not None
|
||||
assert weekend.trade_date == T
|
||||
assert [(a.days, a.date) for a in weekend.anchors] == [(a.days, a.date) for a in friday.anchors]
|
||||
|
||||
|
||||
def test_today_before_calendar_returns_none() -> None:
|
||||
"""日历里没有任何 <= today 的交易日 → None(端点 503,不静默算错)。"""
|
||||
assert compute_stock_returns(CALENDAR, _series({T: 10.0}), today=date(2026, 8, 1)) is None
|
||||
assert resolve_trade_date(CALENDAR, date(2026, 8, 1)) is None
|
||||
|
||||
|
||||
def test_today_is_in_calendar_uses_it() -> None:
|
||||
"""今日是交易日且 bar 已入库 → T = 今日。"""
|
||||
assert resolve_trade_date(CALENDAR, TODAY) == TODAY
|
||||
assert resolve_trade_date(CALENDAR, date(2026, 9, 5)) == date(2026, 9, 4) # 周六 → 周五
|
||||
assert resolve_trade_date([], TODAY) is None
|
||||
|
||||
|
||||
# ── 长期停牌:stale_days ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stale_days_counts_calendar_gap() -> None:
|
||||
"""最后一根 bar 停在 09-04 → 到 T(09-11) 相隔 5 个交易日。"""
|
||||
bars = _series({d: 10.0 for d in CALENDAR if d <= date(2026, 9, 4)})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
assert result.last_date == date(2026, 9, 4)
|
||||
assert result.stale_days == 5 # 09-07 / 08 / 09 / 10 / 11
|
||||
# 停牌期间锚点仍按日历算:D_3(09-08) 退到 09-04
|
||||
assert result.anchors[0].date == date(2026, 9, 4)
|
||||
|
||||
|
||||
def test_stale_days_zero_when_last_bar_is_t() -> None:
|
||||
bars = _series({d: 10.0 for d in CALENDAR})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
assert result is not None and result.stale_days == 0
|
||||
|
||||
|
||||
# ── 底层函数边界 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_shift_trade_date_edges() -> None:
|
||||
assert shift_trade_date(CALENDAR, T, 3) == date(2026, 9, 8)
|
||||
assert shift_trade_date(CALENDAR, T, 14) == date(2026, 8, 24) # 日历首根
|
||||
assert shift_trade_date(CALENDAR, T, 15) is None # 日历不够长
|
||||
assert shift_trade_date(CALENDAR, date(2026, 9, 13), 3) is None # T 不在日历里
|
||||
with pytest.raises(ValueError):
|
||||
shift_trade_date(CALENDAR, T, 0)
|
||||
|
||||
|
||||
def test_last_bar_on_or_before_edges() -> None:
|
||||
bars = [(date(2026, 9, 8), 1.0), (date(2026, 9, 10), 2.0)]
|
||||
assert last_bar_on_or_before(bars, date(2026, 9, 10)) == (date(2026, 9, 10), 2.0)
|
||||
assert last_bar_on_or_before(bars, date(2026, 9, 9)) == (date(2026, 9, 8), 1.0)
|
||||
assert last_bar_on_or_before(bars, date(2026, 9, 7)) is None # 早于首根
|
||||
assert last_bar_on_or_before(bars, None) is None
|
||||
|
||||
|
||||
def test_calendar_shorter_than_window_gives_null() -> None:
|
||||
"""日历自身太短(如指数只有 4 根)→ 远期窗口 null,不 IndexError。"""
|
||||
short = CALENDAR[-4:]
|
||||
bars = _series({d: 10.0 for d in short})
|
||||
result = compute_stock_returns(short, bars, today=TODAY)
|
||||
|
||||
assert result is not None
|
||||
assert [(a.days, a.date) for a in result.anchors] == [
|
||||
(3, short[-4]), # 4 根日历里 D_3 = 最早一根
|
||||
(5, None),
|
||||
(10, None),
|
||||
]
|
||||
|
||||
|
||||
def test_anchor_uses_last_bar_on_or_before_dn() -> None:
|
||||
"""锚点只受 ``date <= D_n`` 约束,与 bar 总数无关(800 根/稀疏序列都一样)。"""
|
||||
bars = _series({d: 5.0 for d in [date(2026, 8, 3), date(2026, 9, 11)]})
|
||||
result = compute_stock_returns(CALENDAR, bars, today=TODAY)
|
||||
assert result is not None
|
||||
assert all(a.date == date(2026, 8, 3) for a in result.anchors)
|
||||
assert result.stale_days == 0
|
||||
|
||||
|
||||
def test_calendar_fixture_is_what_the_expectations_assume() -> None:
|
||||
"""守卫:CALENDAR 确实是 15 个升序工作日(上面 D_n 硬编码期望值的依据)。"""
|
||||
assert len(CALENDAR) == 15
|
||||
assert all(d.weekday() < 5 for d in CALENDAR)
|
||||
assert CALENDAR == sorted(CALENDAR)
|
||||
assert CALENDAR[0] == date(2026, 8, 24)
|
||||
assert CALENDAR[-1] == date(2026, 9, 11)
|
||||
Reference in New Issue
Block a user