diff --git a/docs/web-api.md b/docs/web-api.md index c19733b..b3f6872 100644 --- a/docs/web-api.md +++ b/docs/web-api.md @@ -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 同款 QFQ,count=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 落 error(no_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" \ diff --git a/src/easy_tdx/web/returns.py b/src/easy_tdx/web/returns.py new file mode 100644 index 0000000..b83063b --- /dev/null +++ b/src/easy_tdx/web/returns.py @@ -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_left:idx 是第一个 >= 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), + ) diff --git a/src/easy_tdx/web/routers/watchlist.py b/src/easy_tdx/web/routers/watchlist.py index cafa88d..97b4261 100644 --- a/src/easy_tdx/web/routers/watchlist.py +++ b/src/easy_tdx/web/routers/watchlist.py @@ -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`` 同款 QFQ,count=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]: """加入自选(幂等:重复加入仅刷新名称)。""" diff --git a/tests/unit/test_watchlist_and_streamer.py b/tests/unit/test_watchlist_and_streamer.py index fa870c9..179c9e0 100644 --- a/tests/unit/test_watchlist_and_streamer.py +++ b/tests/unit/test_watchlist_and_streamer.py @@ -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 diff --git a/tests/unit/test_web_returns.py b/tests/unit/test_web_returns.py new file mode 100644 index 0000000..953c45a --- /dev/null +++ b/tests/unit/test_web_returns.py @@ -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) diff --git a/web-ui/e2e/watchlist.spec.ts b/web-ui/e2e/watchlist.spec.ts index 93def79..7d978fa 100644 --- a/web-ui/e2e/watchlist.spec.ts +++ b/web-ui/e2e/watchlist.spec.ts @@ -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) +}) diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index ea6b067..75728ad 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -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 { return (await resp.json()) as WatchlistResponse } +/** 近 3/5/10 交易日涨跌幅的锚点收盘价(前端用实时价现算涨跌幅,后端只给锚点)。 */ +export async function fetchWatchlistReturns(): Promise { + 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 { const resp = await fetch(`${BASE}/watchlist`, { diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index 6daede0..37d2082 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -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/returns:anchor 收盘价 + T(涨跌幅由前端用实时价现算)。 */ +export interface WatchlistReturnsResponse { + trade_date: string | null + items: Record +} + // ── 行情终端:板块列表(GET /api/v1/board-mac/list,MAC 协议,防御式取列) ──── /** 板块行(MAC 协议字段随版本浮动,全部可选,渲染端容错)。 */ diff --git a/web-ui/src/views/WatchlistView.vue b/web-ui/src/views/WatchlistView.vue index 9af90aa..1f31d22 100644 --- a/web-ui/src/views/WatchlistView.vue +++ b/web-ui/src/views/WatchlistView.vue @@ -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()) + +/** 拉一次锚点(后端按天缓存,盘中/重复刷新不重复请求行情)。 */ +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()) @@ -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(() => 名称 现价 涨跌幅 + {{ w.label }} 涨跌额 成交量 成交额 @@ -224,7 +280,7 @@ const emptyHint = computed(() => - {{ emptyHint }} + {{ emptyHint }} @@ -233,6 +289,14 @@ const emptyHint = computed(() => {{ fmt2(q(item)?.price) }} {{ fmtPctSigned(pct(item)) }} + + {{ fmtPctSigned(pctVs(item, w.days)) }} + {{ q(item)?.price && q(item)?.pre_close ? fmt2(q(item)!.price! - q(item)!.pre_close!) : '-' }}