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
+19
View File
@@ -155,6 +155,25 @@ curl "http://localhost:8000/api/v1/watchlist"
curl -X POST "http://localhost:8000/api/v1/watchlist" \
-H "Content-Type: application/json" -d '{"market": "SH", "code": "600519", "name": "贵州茅台"}'
# 自选「近 3 日 / 近 1 周 / 近 2 周」涨跌幅锚点(窗口固定为 3,5,10;交易日偏移口径)
# T = 上证指数日线(交易日历)中 <= 今天的最后一天;D_n = T 往前 n 个交易日;
# 锚点 = 个股日线(/bars 同款 QFQcount=800)中 date <= D_n 的最后一根 bar。
# 只回锚点收盘价:涨跌幅由前端用实时价现算(盘中随 SSE 跳动,无需轮询本接口)。
# 个股日线与日历都走进程内缓存(当日不变、次日失效),同一天重复刷新零行情请求。
curl "http://localhost:8000/api/v1/watchlist/returns"
# 实测样例(2026-09-11 盘中):
# {"trade_date":"2026-09-11",
# "items":{"SH600519":{"last_close":1272.95,"last_date":"2026-09-11","stale_days":0,
# "anchors":[{"days":3,"close":1309.3,"date":"2026-09-08"},
# {"days":5,"close":1330.0,"date":"2026-09-04"},
# {"days":10,"close":1297.4,"date":"2026-08-28"}]},
# "SZ301999":{"error":"no_data"}}}
# 注:anchors[].close 是**锚点收盘价**(不是涨跌幅),前端 (实时价/锚点 − 1)×100 得该列。
# 容错:今日非交易日 → T 回退;锚点日停牌 → 退到最近一根并回实际 date;数据不足
# (次新)→ anchors[].close 为 null(前端显示 '-');长期停牌 → last_date +
# stale_days;单只失败只在该 key 落 errorno_data/fetch_failed),不影响整表。
# 无 MAC 连接时按 /bars 语义降级标准协议(不复权,除权日可能出现假跌幅,日志标注)。
# ── AI 解读(模型 Key 只存本地 ~/.easy_tdx/llm.json)──
curl "http://localhost:8000/api/v1/llm/config" # 当前配置 + Provider 预设
curl -X POST "http://localhost:8000/api/v1/llm/chat/async" \
+185
View File
@@ -0,0 +1,185 @@
"""自选列表「近 3 日 / 近 1 周 / 近 2 周」涨跌幅的锚点计算(纯函数,零 IO)。
口径(issue #7 定稿,勿改):
- ``T`` = 交易日历中 ``<=`` 今天的最后一个交易日
- ``D_n`` = 交易日历中 ``T`` 往前数 ``n`` 个交易日的**日期**
- 锚点收盘 = 个股日线中 ``date <= D_n`` 的**最后一根 bar**(返回其实际日期)
两个设计要点(别"优化"掉):
1. **按日期锚定而不是按 index 往回数**:当日 bar 是否已入库不定(盘中未收盘就没有),
按 index 数会在收盘瞬间跳变;按日期 ``<=`` 锚定天然稳定。
2. **日历用上证指数而不是个股自己的序列**:个股停牌会缺日期,用它自己的序列数
``n`` 天会数错。
本模块只做"日历 + 个股序列 + windows → 锚点"的纯计算,取数与缓存见
:mod:`easy_tdx.web.routers.watchlist`。涨跌幅由前端用实时价现算(后端只回锚点收盘价)。
"""
from __future__ import annotations
from bisect import bisect_left
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date
__all__ = [
"StockReturns",
"WindowAnchor",
"compute_stock_returns",
"last_bar_on_or_before",
"resolve_trade_date",
"shift_trade_date",
]
#: 默认窗口(交易日):近 3 日 / 近 1 周 / 近 2 周
DEFAULT_WINDOWS: tuple[int, ...] = (3, 5, 10)
@dataclass(frozen=True)
class WindowAnchor:
"""单个窗口的锚点。
``close`` / ``date`` 为 ``None`` = 该窗口数据不足(次新股 / 长期停牌),
前端显示 ``-``。
"""
days: int
close: float | None
date: date | None
@dataclass(frozen=True)
class StockReturns:
"""一只标的的锚点计算结果。
Attributes:
trade_date: 日历锚定出的 ``T``。
last_close: 个股最后一根 bar 的收盘价(前端无实时报价时兜底算涨跌幅)。
last_date: 该 bar 的日期。
stale_days: ``last_date`` 到 ``T`` 之间相隔的交易日数(``T`` 当日有 bar = 0)。
anchors: 与请求的 ``windows`` 同序的锚点列表。
"""
trade_date: date
last_close: float | None
last_date: date | None
stale_days: int
anchors: tuple[WindowAnchor, ...]
def resolve_trade_date(calendar: Sequence[date], today: date) -> date | None:
"""取交易日历中 ``<= today`` 的最后一个交易日(今日非交易日则自动回退)。
Args:
calendar: 交易日历(可乱序,内部排序;通常来自上证指数日线的日期列)。
today: 今天的日历日。
Returns:
``T``;日历为空或全部晚于 ``today`` 时返回 ``None``。
"""
ordered = sorted(calendar)
idx = bisect_left(ordered, today)
# bisect_leftidx 是第一个 >= today 的位置;today 本身在日历里则取它
if idx < len(ordered) and ordered[idx] == today:
return ordered[idx]
return ordered[idx - 1] if idx > 0 else None
def shift_trade_date(calendar: Sequence[date], t: date, n: int) -> date | None:
"""取交易日历中 ``t`` 往前数 ``n`` 个交易日的日期。
Args:
calendar: 交易日历。
t: 基准交易日(应由 :func:`resolve_trade_date` 得到)。
n: 交易日偏移(≥ 1)。
Returns:
``D_n````t`` 不在日历中或往前不足 ``n`` 个交易日时返回 ``None``
(次新股 / 日历过短)。
"""
if n < 1:
raise ValueError(f"交易日偏移必须 ≥ 1,收到 {n}")
ordered = sorted(calendar)
idx = bisect_left(ordered, t)
if idx >= len(ordered) or ordered[idx] != t:
return None
back = idx - n
return ordered[back] if back >= 0 else None
def last_bar_on_or_before(
bars: Sequence[tuple[date, float]], target: date | None
) -> tuple[date, float] | None:
"""取个股序列中 ``date <= target`` 的最后一根 bar(按日期锚定,非按 index)。
Args:
bars: ``(日期, 收盘价)`` 升序序列。
target: 锚定日期 ``D_n````None`` 直接返回 ``None``。
Returns:
``(实际日期, 收盘价)``;锚定日停牌时退到最近一根(返回其真实日期),
序列中没有任何 ``date <= target`` 的 bar 时返回 ``None``。
"""
if target is None:
return None
found: tuple[date, float] | None = None
for bar_date, close in bars:
if bar_date > target:
break # bars 升序:后面只会更晚
found = (bar_date, close)
return found
def compute_stock_returns(
calendar: Sequence[date],
bars: Sequence[tuple[date, float]],
*,
today: date,
windows: Sequence[int] = DEFAULT_WINDOWS,
) -> StockReturns | None:
"""按交易日历锚定个股各窗口的锚点收盘价。
Args:
calendar: 交易日历(上证指数日线日期,见模块 docstring 设计要点 2)。
bars: 个股日线 ``(日期, 收盘价)`` 升序序列(QFQ 口径,见 issue #6)。
today: 今天的日历日。
windows: 交易日偏移列表(默认 3/5/10)。
Returns:
:class:`StockReturns`;日历为空、无 ``T`` 或个股无任何 bar 时返回 ``None``
(调用方记 ``error``,不影响整表)。
"""
ordered_cal = sorted(set(calendar))
trade_date = resolve_trade_date(ordered_cal, today)
if trade_date is None:
return None
series = sorted(bars)
if not series:
return None
last_date, last_close = series[-1]
stale_days = (
sum(1 for d in ordered_cal if last_date < d <= trade_date) if last_date < trade_date else 0
)
anchors: list[WindowAnchor] = []
for n in windows:
d_n = shift_trade_date(ordered_cal, trade_date, n)
bar = last_bar_on_or_before(series, d_n)
anchors.append(
WindowAnchor(
days=n,
close=None if bar is None else bar[1],
date=None if bar is None else bar[0],
)
)
return StockReturns(
trade_date=trade_date,
last_close=last_close,
last_date=last_date,
stale_days=stale_days,
anchors=tuple(anchors),
)
+296 -5
View File
@@ -1,18 +1,68 @@
"""自选股路由:加入 / 列出 / 移除(SQLite 持久化,无行情依赖)。"""
"""自选股路由:加入 / 列出 / 移除(SQLite 持久化),以及近 N 交易日涨跌幅锚点。
``GET /watchlist/returns``issue #7)只回**锚点收盘价**,涨跌幅由前端用 SSE
实时价现算——三列跟着报价免费跳动,盘中无需轮询本接口。
取数语义对齐 ``/bars``MAC 优先(``adjust=QFQ``,除权日不出假跌幅)→ MAC
不可用/失败时降级标准 TdxClient(**不复权**,日志标注,不静默)。个股当日序列与
交易日历(上证指数日线)都用进程内缓存——两者当日不变、次日失效;日历的重取时机
见 :func:`_calendar_stale`(盘前启动的 serve 必须能等到今天的 bar 生成,否则
``T`` 会整体前移一个交易日)。
口径与锚定算法见 :mod:`easy_tdx.web.returns`(纯计算,本模块只负责取数/缓存)。
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from fastapi import Path as PathParam
from pydantic import BaseModel, Field
import asyncio
import logging
from datetime import date, datetime
from typing import Any, NamedTuple
from easy_tdx.web.watchlist_store import get_watchlist_store
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import Path as PathParam
from pydantic import BaseModel, Field, model_serializer
from easy_tdx.exceptions import TdxConnectionError
from easy_tdx.mac.enums import Adjust, Period
from easy_tdx.models.enums import KlineCategory, Market
from easy_tdx.realtime.session import SHANGHAI_TZ, is_trading_time
from easy_tdx.web.convert import market_from_str, market_value_from_str
from easy_tdx.web.deps import get_client, get_mac_client_optional
from easy_tdx.web.returns import StockReturns, compute_stock_returns, resolve_trade_date
from easy_tdx.web.watchlist_store import WatchItem, get_watchlist_store
_logger = logging.getLogger(__name__)
router = APIRouter(tags=["watchlist"])
# 6 位数字代码(自选会被 QuoteStreamer 拿去轮询,非数字代码产生无效请求)
_CODE_PATTERN = r"^\d{6}$"
# ── 近 N 交易日涨跌幅(issue #7)────────────────────────────────────────────
_CALENDAR_MARKET = Market.SH # 交易日历 = 上证指数(个股停牌会缺日期,不能当日历)
_CALENDAR_CODE = "000001"
_BAR_COUNT = 800 # 日线一次覆盖 3 年+(与 /bars 默认同值),锚点与 last_close 同一次请求
_CONCURRENCY = 4 # TDX 防封红线:并发 ≤ 4
# 日历"未确认"(缺今天)时的重取间隔,详见 _calendar_stale
_CALENDAR_RETRY_SECONDS = 60.0
class _CalendarEntry(NamedTuple):
"""交易日历缓存值。"""
calendar: list[date]
fetched_at: datetime
# 交易日历进程内缓存:{"当时日历日": _CalendarEntry},一天一条
_calendar_cache: dict[str, _CalendarEntry] = {}
# 个股日线进程内缓存:symbol → (取数当日, 序列)。锚点只用历史 bar(当日不变),
# 同一天里前端加载/增删自选各拉一次都零行情请求;次日 key 不匹配自动失效。
_bars_cache: dict[str, tuple[str, list[tuple[date, float]]]] = {}
class WatchItemAdd(BaseModel):
"""加入自选请求。name 由前端从行情数据带过来。"""
@@ -28,6 +78,201 @@ class WatchlistResponse(BaseModel):
count: int
class ReturnAnchorItem(BaseModel):
"""单个窗口的锚点(``close``/``date`` 为 null = 数据不足,前端显示 ``-``)。"""
days: int
close: float | None = None
date: str | None = None
class WatchReturnsItem(BaseModel):
"""一只自选的锚点结果;取数失败时只落 ``error``(不影响整表)。
字段全为可选:失败项只设 ``error``,其余 ``None`` 字段由
:meth:`_drop_none` 从 JSON 中剔除。
"""
last_close: float | None = None
last_date: str | None = None
stale_days: int | None = None
anchors: list[ReturnAnchorItem] | None = None
error: str | None = None
@model_serializer(mode="wrap")
def _drop_none(self, handler: Any) -> dict[str, Any]:
"""``None`` 字段不落 JSON:失败项即 ``{"error": "no_data"}``(契约同款)。"""
return {k: v for k, v in handler(self).items() if v is not None}
class WatchlistReturnsResponse(BaseModel):
"""``trade_date`` = 锚定出的 ``T``(自选为空时为 null,不请求行情)。"""
trade_date: str | None
items: dict[str, WatchReturnsItem]
# ── 交易日历 / 个股日线取数(纯 IO,缓存与降级都在这里)──────────────────────
def _today() -> date:
"""今天的日历日(沪市时区,与主机时区无关;单测可 monkeypatch)。"""
return datetime.now(SHANGHAI_TZ).date()
def _now() -> datetime:
"""当前沪市时间(单测可 monkeypatch;须与 ``_today`` 的桩同一天)。"""
return datetime.now(SHANGHAI_TZ)
def _calendar_stale(entry: _CalendarEntry, today: date, now: datetime) -> bool:
"""日历缓存是否该重取。
**为什么不能无脑缓存一天**:``T`` 由"日历中 ``<=`` 今天的最后一个交易日"定出,
而今天的日线 bar 要等开盘后才生成。若 serve 当天第一次取数发生在**开盘前**
(机器早开机、服务常驻),日历里就没有今天 → ``T`` 退到前一个交易日 →
三个锚点**整体前移一个交易日**。缓存键就是日期本身,当天不会自我纠正,
会一路错到次日,且数值看起来完全合理、不报任何错。
**为什么缺今天不是每个请求都重取**:交易日与节假日无法从日历本身分辨 ——
"缺今天"既可能是"今天的 bar 还没生成",也可能是"今天根本不开市"。所以只在
交易时段内、距上次取数满 :data:`_CALENDAR_RETRY_SECONDS` 才重取。真正的交易日
今天的 bar 一出现就命中确认、此后当天不再请求(正常盘中路径零额外请求);
节假日则退化成每个请求间隔最多 1 次指数日线,与页面打开时拉一次同级。
"""
if today in entry.calendar:
return False
if not is_trading_time(now):
return False
return (now - entry.fetched_at).total_seconds() >= _CALENDAR_RETRY_SECONDS
def _series_from_df(df: Any) -> list[tuple[date, float]]:
"""DataFrame → ``(日期, 收盘价)`` 升序去重序列(MAC 的 datetime / 标准的 date 列都认)。
非正收盘价丢弃:QFQ 深层历史可能返回 0/负价(见 ``/bars`` 文档),
作锚点算涨跌幅无意义。
"""
if df is None or getattr(df, "empty", True) or "close" not in getattr(df, "columns", []):
return []
time_col = next((c for c in ("date", "datetime") if c in df.columns), None)
if time_col is None:
return []
times = pd.to_datetime(df[time_col], errors="coerce")
closes = pd.to_numeric(df["close"], errors="coerce")
out: dict[date, float] = {}
for ts, close in zip(times, closes):
if pd.isna(ts) or not close > 0:
continue
out[ts.date()] = float(close)
return sorted(out.items())
async def _fetch_bars(
market: str, code: str, mac_client: Any, client: Any, *, is_index: bool = False
) -> list[tuple[date, float]]:
"""按 ``/bars`` 语义取日线:MAC 优先(QFQ)→ 标准 TdxClient 降级(不复权)。
Raises:
最后一级失败时的原始异常(调用方按"单只失败不影响整表"处理)。
"""
if mac_client is not None:
try:
df = await mac_client.get_stock_kline(
market=market_value_from_str(market),
code=code,
period=Period.DAILY,
start=0,
count=_BAR_COUNT,
times=1,
adjust=Adjust.QFQ,
)
bars = _series_from_df(df)
if bars:
return bars
_logger.info("/watchlist/returns MAC 返回空,转标准 TdxClient (%s%s)", market, code)
except Exception as exc: # noqa: BLE001 — 降级到标准客户端,不中断
_logger.warning(
"/watchlist/returns MAC 获取失败,转标准 TdxClient (%s%s): %s", market, code, exc
)
else:
_logger.warning(
"/watchlist/returns MAC 客户端未连接,降级标准 TdxClient"
"%s%s 不复权,除权日可能出现假跌幅)",
market,
code,
)
market_enum = market_from_str(market)
if is_index:
df = await client.get_index_bars(market_enum, code, KlineCategory.DAY, 0, _BAR_COUNT)
else:
df = await client.get_security_bars(market_enum, code, KlineCategory.DAY, 0, _BAR_COUNT)
return _series_from_df(df)
async def _trade_calendar(mac_client: Any, client: Any, today: date, now: datetime) -> list[date]:
"""交易日历 = 上证指数日线的日期列(进程内缓存,刷新时机见 :func:`_calendar_stale`)。
含今天的日历取一次即长期命中;缺今天(盘前首次取数)则按退避节奏探针若干次,
直到今天的 bar 生成、或判定今天不开市而停止。
"""
key = today.isoformat()
cached = _calendar_cache.get(key)
if cached is not None and not _calendar_stale(cached, today, now):
return cached.calendar
bars = await _fetch_bars(
_CALENDAR_MARKET.name, _CALENDAR_CODE, mac_client, client, is_index=True
)
# 取数失败时沿用当天旧日历(比整个端点 503 好);时间戳照常刷新,下轮按间隔再试
calendar = sorted({d for d, _ in bars}) or (cached.calendar if cached is not None else [])
if calendar:
_calendar_cache.clear() # 只保留当天一条,避免跨日堆积
_calendar_cache[key] = _CalendarEntry(calendar, now)
return calendar
async def _returns_for(
item: WatchItem,
calendar: list[date],
today: date,
day: str,
mac_client: Any,
client: Any,
) -> WatchReturnsItem:
"""单只自选 → 锚点结果;任何失败都收敛成 ``error``(整表不受影响)。"""
try:
cached = _bars_cache.get(item.symbol)
bars = cached[1] if cached is not None and cached[0] == day else None
if bars is None:
bars = await _fetch_bars(item.market, item.code, mac_client, client)
if bars:
_bars_cache[item.symbol] = (day, bars)
if not bars:
return WatchReturnsItem(error="no_data")
result: StockReturns | None = compute_stock_returns(calendar, bars, today=today)
if result is None:
return WatchReturnsItem(error="no_data")
return WatchReturnsItem(
last_close=None if result.last_close is None else round(result.last_close, 4),
last_date=result.last_date.isoformat() if result.last_date else None,
stale_days=result.stale_days,
anchors=[
ReturnAnchorItem(
days=a.days,
close=None if a.close is None else round(a.close, 4),
date=a.date.isoformat() if a.date else None,
)
for a in result.anchors
],
)
except Exception as exc: # noqa: BLE001 — 单只失败不影响整表
_logger.warning("/watchlist/returns 单只取数失败 %s: %s", item.symbol, exc)
return WatchReturnsItem(error="fetch_failed")
# ── 端点 ────────────────────────────────────────────────────────────────────
@router.get("/watchlist", response_model=WatchlistResponse)
async def list_watchlist(
group: str | None = Query(None, description="按分组过滤"),
@@ -37,6 +282,52 @@ async def list_watchlist(
return WatchlistResponse(items=[i.to_dict() for i in items], count=len(items))
@router.get("/watchlist/returns", response_model=WatchlistReturnsResponse)
async def watchlist_returns(
mac_client: Any = Depends(get_mac_client_optional),
client: Any = Depends(get_client),
) -> WatchlistReturnsResponse:
"""自选列表「近 3 日 / 近 1 周 / 近 2 周」涨跌幅的**锚点收盘价**(前端用实时价现算)。
窗口固定为 :data:`easy_tdx.web.returns.DEFAULT_WINDOWS`——列名与窗口一一对应
``web-ui/.../WatchlistView.vue`` 的 ``WINDOWS`` 必须与它同步)。
锚定算法(详见 :mod:`easy_tdx.web.returns`):``T`` = 上证指数日线(交易日历)
中 ``<=`` 今天的最后一个交易日;``D_n`` = 日历中 ``T`` 往前 n 个交易日的日期;
锚点 = 个股日线(``/bars`` 同款 QFQcount=800)中 ``date <= D_n`` 的最后一根 bar。
容错:今日非交易日 → ``T`` 自动回退;个股锚点日停牌 → 退到最近一根并回实际
``date``;数据不足(次新)→ ``anchors[].close`` 为 ``null``;长期停牌 → 回
``last_date`` + ``stale_days``;单只取数失败 → 该 key 只落 ``error``,整表照常
返回。``last_close`` 供前端在没有实时报价时兜底算涨跌幅。
性能:个股日线与交易日历都是进程内缓存(当日不变、次日失效),同一天重复拉
零行情请求;日历缺今天(serve 盘前启动,今天的 bar 尚未生成)时会按
:func:`_calendar_stale` 的间隔重取,避免 ``T`` 整体前移一个交易日且当天不自我
纠正。个股取数并发 ≤ 4。
"""
items = get_watchlist_store().list_all()
if not items:
return WatchlistReturnsResponse(trade_date=None, items={})
today = _today()
now = _now()
calendar = await _trade_calendar(mac_client, client, today, now)
trade_date = resolve_trade_date(calendar, today)
if trade_date is None:
raise TdxConnectionError("交易日历为空(上证指数日线获取失败),无法锚定近 N 日涨跌幅")
day = today.isoformat()
sem = asyncio.Semaphore(_CONCURRENCY) # MAC 单连接本身串行,信号量做背压与秩序
async def one(item: WatchItem) -> tuple[str, WatchReturnsItem]:
async with sem:
return item.symbol, await _returns_for(item, calendar, today, day, mac_client, client)
pairs = await asyncio.gather(*(one(i) for i in items))
return WatchlistReturnsResponse(trade_date=trade_date.isoformat(), items=dict(pairs))
@router.post("/watchlist", response_model=dict[str, object])
async def add_watch_item(req: WatchItemAdd) -> dict[str, object]:
"""加入自选(幂等:重复加入仅刷新名称)。"""
+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)
+30
View File
@@ -1,4 +1,6 @@
// 自选页 E2E:加入自选(行情校验 + 名称补全走 mock)→ 表格出现 → 删除 → 消失。
// 另覆盖 issue #7 的「近3日 / 近1周 / 近2周」三列(交易日偏移口径,锚点走后端
// /watchlist/returns,涨跌幅由前端用实时价现算)。
//
// 每轮 E2E 用独立的临时 EASY_TDX_CONFIG_DIR,自选从空开始,断言可写死。
@@ -9,6 +11,8 @@ test('自选页增删自选', async ({ page }) => {
// 初始为空(临时配置目录)
await expect(page.locator('.empty-row')).toBeVisible()
// 空行 colspan 与表头列数一致(新增 3 列后 = 15)
await expect(page.locator('.empty-row td')).toHaveAttribute('colspan', '15')
// 加入 600519(市场自动识别 SH;名称走 mock /mac/symbol-info → 贵州茅台)
await page.fill('.code-input', '600519')
@@ -22,3 +26,29 @@ test('自选页增删自选', async ({ page }) => {
await expect(page.locator('.data-row')).toHaveCount(0)
await expect(page.locator('.empty-row')).toBeVisible()
})
test('自选页近3日/近1周/近2周涨跌幅三列', async ({ page }) => {
await page.goto('/watchlist')
await page.fill('.code-input', '600519')
await page.getByRole('button', { name: '加入自选' }).click()
await expect(page.locator('.data-row')).toHaveCount(1, { timeout: 30_000 })
// 表头:现价/涨跌幅之后依次是 近3日、近1周、近2周(共 15 列 = 12 + 3
const headers = page.locator('.qtable thead th')
await expect(headers).toHaveCount(15)
await expect(headers.nth(2)).toHaveText('涨跌幅')
await expect(headers.nth(3)).toHaveText('近3日')
await expect(headers.nth(4)).toHaveText('近1周')
await expect(headers.nth(5)).toHaveText('近2周')
// 数据格:与表头列数一致,三列都是带符号百分比(合成行情锚点 → 一定会算出数)
const cells = page.locator('.data-row td')
await expect(cells).toHaveCount(15)
for (const i of [3, 4, 5]) {
await expect(cells.nth(i)).toHaveText(/^[+-]?\d+\.\d+%$/)
}
await page.locator('.data-row .del').first().click()
await expect(page.locator('.data-row')).toHaveCount(0)
})
+8
View File
@@ -48,6 +48,7 @@ import type {
TaskState,
TaskSubmitResponse,
WatchlistResponse,
WatchlistReturnsResponse,
} from './types'
const BASE = '/api/v1'
@@ -703,6 +704,13 @@ export async function fetchWatchlist(): Promise<WatchlistResponse> {
return (await resp.json()) as WatchlistResponse
}
/** 近 3/5/10 交易日涨跌幅的锚点收盘价(前端用实时价现算涨跌幅,后端只给锚点)。 */
export async function fetchWatchlistReturns(): Promise<WatchlistReturnsResponse> {
const resp = await fetch(`${BASE}/watchlist/returns`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as WatchlistReturnsResponse
}
/** 加入自选(幂等)。 */
export async function addWatchItem(market: string, code: string, name = ''): Promise<void> {
const resp = await fetch(`${BASE}/watchlist`, {
+22
View File
@@ -555,6 +555,28 @@ export interface WatchlistResponse {
count: number
}
/** 单个交易日窗口的锚点(close 为 null = 数据不足,前端显示 '-')。 */
export interface WatchReturnAnchor {
days: number
close: number | null
date: string | null
}
/** 一只自选的锚点结果;取数失败时只有 error(后端不下发 null 字段)。 */
export interface WatchReturnItem {
last_close?: number
last_date?: string
stale_days?: number
anchors?: WatchReturnAnchor[]
error?: string
}
/** GET /api/v1/watchlist/returnsanchor 收盘价 + T(涨跌幅由前端用实时价现算)。 */
export interface WatchlistReturnsResponse {
trade_date: string | null
items: Record<string, WatchReturnItem>
}
// ── 行情终端:板块列表(GET /api/v1/board-mac/listMAC 协议,防御式取列) ────
/** 板块行(MAC 协议字段随版本浮动,全部可选,渲染端容错)。 */
+66 -2
View File
@@ -11,6 +11,7 @@ import {
fetchQuotes,
fetchSymbolName,
fetchWatchlist,
fetchWatchlistReturns,
formatError,
removeWatchItem,
} from '../api'
@@ -20,10 +21,18 @@ import Sparkline from '../components/Sparkline.vue'
import { dirClass, fmt2, fmtAmount, fmtPctSigned, fmtVol } from '../format'
import { detectMarket } from '../market'
import { useQuoteStore } from '../stores/quotes'
import type { WatchItem } from '../types'
import type { WatchItem, WatchReturnItem } from '../types'
const quoteStore = useQuoteStore()
// 近 N 交易日涨跌幅(交易日偏移口径,见后端 /watchlist/returns):列名与窗口一一对应,
// 窗口本身由后端 returns.DEFAULT_WINDOWS 固定,这里只负责标签与取值顺序。
const WINDOWS: ReadonlyArray<{ days: number; label: string }> = [
{ days: 3, label: '近3日' },
{ days: 5, label: '近1周' },
{ days: 10, label: '近2周' },
]
/** 板块指数(881/885/880 开头)走板块弹窗,其余走个股弹窗。 */
function isBoardCode(code: string): boolean {
return /^88\d/.test(code)
@@ -43,6 +52,7 @@ async function loadList() {
const resp = await fetchWatchlist()
items.value = resp.items
loadSparks()
loadReturns()
restFallback()
fillMissingNames()
} catch (e) {
@@ -95,6 +105,50 @@ function pct(item: WatchItem): number | null {
return (qq.price / qq.pre_close - 1) * 100
}
// ── 近 N 交易日涨跌幅(锚点收盘价来自后端,涨跌幅在这里用实时价现算) ──────────
const returns = ref(new Map<string, WatchReturnItem>())
/** 拉一次锚点(后端按天缓存,盘中/重复刷新不重复请求行情)。 */
async function loadReturns() {
if (items.value.length === 0) {
returns.value = new Map()
return
}
try {
const resp = await fetchWatchlistReturns()
returns.value = new Map(Object.entries(resp.items))
} catch {
// 单只失败/整体失败都不影响其余列,静默(与 loadSparks 同语义)
}
}
function retItem(item: WatchItem): WatchReturnItem | undefined {
return returns.value.get(item.symbol)
}
/** 锚点日期(悬停提示用):近N日涨跌幅的基准 bar 实际日期。 */
function anchorDate(item: WatchItem, days: number): string {
const a = retItem(item)?.anchors?.find((x) => x.days === days)
return a?.date ? `锚点 ${a.date}` : '锚点不可用'
}
/** 近 N 交易日涨跌幅:优先 SSE 实时价,无报价时用后端 last_close 兜底。 */
function pctVs(item: WatchItem, days: number): number | null {
const r = retItem(item)
const anchor = r?.anchors?.find((a) => a.days === days)?.close
if (anchor == null || !(anchor > 0)) return null
const price = q(item)?.price ?? r?.last_close
if (price == null || !Number.isFinite(price) || price <= 0) return null
return (price / anchor - 1) * 100
}
/** 长期停牌(最后一根 bar 不在 T):标灰,避免误读成当日行情。 */
function isStale(item: WatchItem): boolean {
const r = retItem(item)
return !!r && !r.error && (r.stale_days ?? 0) > 0
}
// ── 迷你分时 ────────────────────────────────────────────────────────────────
const sparks = ref(new Map<string, number[]>())
@@ -168,6 +222,7 @@ async function remove(item: WatchItem) {
await removeWatchItem(item.market, item.code)
items.value = items.value.filter((i) => i.symbol !== item.symbol)
sparks.value.delete(item.symbol)
returns.value.delete(item.symbol)
} catch (e) {
listError.value = formatError(e)
}
@@ -211,6 +266,7 @@ const emptyHint = computed(() =>
<th>名称</th>
<th>现价</th>
<th>涨跌幅</th>
<th v-for="w in WINDOWS" :key="w.days">{{ w.label }}</th>
<th>涨跌额</th>
<th>成交量</th>
<th>成交额</th>
@@ -224,7 +280,7 @@ const emptyHint = computed(() =>
</thead>
<tbody>
<tr v-if="emptyHint" class="empty-row">
<td colspan="12">{{ emptyHint }}</td>
<td colspan="15">{{ emptyHint }}</td>
</tr>
<tr v-for="item in items" :key="item.symbol" class="data-row" @click="openItem(item)">
<td>
@@ -233,6 +289,14 @@ const emptyHint = computed(() =>
</td>
<td class="big" :class="dirClass(pct(item))">{{ fmt2(q(item)?.price) }}</td>
<td :class="dirClass(pct(item))">{{ fmtPctSigned(pct(item)) }}</td>
<td
v-for="w in WINDOWS"
:key="w.days"
:class="[dirClass(pctVs(item, w.days)), { dim: isStale(item) }]"
:title="anchorDate(item, w.days)"
>
{{ fmtPctSigned(pctVs(item, w.days)) }}
</td>
<td :class="dirClass(pct(item))">
{{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}
</td>