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:
awayings
2026-09-11 11:39:10 +08:00
co-authored by Claude Code
parent 57b1a7b32b
commit fb35677802
9 changed files with 1406 additions and 7 deletions
+449
View File
@@ -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 #7mock 取数,不连网)────────────────────
_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
+331
View File
@@ -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)