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