mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(watchlist): 新增「加入日期」「加入以来」两列与日K加入自选日标注
自选 parquet 一直记着 added_at, 但前端从未消费过, 用户看不到加入时间也算不出加入以来收益。 本次在 /api/watchlist/enriched 读时计算并下发 (与涨跌停价同属现算、不落盘): - added_at 原样透传 (naive UTC), 前端按 UTC 解析后转北京时间展示 - pct_since_added 以「加入日或之前最近交易日」的前复权收盘价为基准, 小数口径 (与 momentum_* 一致); 超出内存历史缓存窗口 (约 300 自然日) 与 ETF/指数显式降级为 null —— 该端点位于 SSE 行情失效前缀, 每几秒被重拉一次, 故不做逐只 scan 兜底 - 新增 KlineRepository.get_enriched_history_span(): 纯读 O(1), 用于区分「预热中」与「日期超窗」 (get_enriched_range 对两者都返回 None) 前端两列默认隐藏 (自定义列 → 自选分组); K线弹窗日K主图用 custom series 画蓝色竖虚线 + 带框「自选」标签, 从该根K线下沿连到网格底边, 缩放后自动重绘, 工具栏提供独立开关。 日期不在可视区间 / 周末节假日加入时竖线不画 (不做 snap), 由工具栏文字兜底。 验证: 后端定向 pytest 45 passed, ruff 相对改动前基线零新增; 前端 pnpm build 通过, vitest 20 passed Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Callable
|
||||
|
||||
import anyio
|
||||
@@ -13,6 +13,7 @@ from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db_safe import is_valid_ext_ident, quote_ident
|
||||
from app.market_time import CN_TZ
|
||||
from app.price_limits import (
|
||||
polars_is_risk_warning_name,
|
||||
polars_limit_price,
|
||||
@@ -381,12 +382,89 @@ _WATCHLIST_COLS = [
|
||||
]
|
||||
|
||||
|
||||
def _added_dates_bj(entries: list[dict]) -> dict[str, date]:
|
||||
"""自选条目的 added_at (naive UTC ISO 串) → 北京日期; 空值/非法值跳过该条。
|
||||
|
||||
added_at 由 watchlist.add_batch 以 datetime.utcnow() 写入 (无 Z 后缀), 属 UTC 语义;
|
||||
必须显式按 UTC 解析再转北京 —— 否则北京时间 00:00-08:00 加入的记录会被算到前一天。
|
||||
换算惯例同 backtest/minute_replay.py, 服务器本地时区不参与 (CONTRIBUTING §3.3)。
|
||||
"""
|
||||
out: dict[str, date] = {}
|
||||
for entry in entries:
|
||||
raw = entry.get("added_at")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(raw))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
out[entry["symbol"]] = dt.astimezone(CN_TZ).date()
|
||||
return out
|
||||
|
||||
|
||||
def _added_base_closes(
|
||||
repo,
|
||||
added_dates: dict[str, date],
|
||||
stock_symbols: set[str],
|
||||
) -> pl.DataFrame:
|
||||
"""取各标的「加入日或之前最近一个交易日」的前复权收盘价, 作为区间收益基准。
|
||||
|
||||
返回 symbol/base_close 两列表; 无可用值返回空表, 由调用方按 null 降级。
|
||||
|
||||
- **只覆盖股票**: 内存历史缓存 (get_enriched_range) 仅含股票, ETF/指数只有最新日
|
||||
缓存, 逐只回补会在行情 tick 热路径上形成 N+1 parquet 扫描, 故显式降级为「—」。
|
||||
- **基准日取 <= 加入日**: 周末/节假日/停牌自然回落到前一交易日。与 K 线竖线的
|
||||
「不 snap」有意不同 —— 价格口径可以回落, 日期事实不行。
|
||||
- **超出缓存窗口 (约 300 个自然日) 的标的直接跳过**, 不触发 scan 兜底。
|
||||
"""
|
||||
stock_targets = {s: d for s, d in added_dates.items() if s in stock_symbols}
|
||||
if not stock_targets:
|
||||
return pl.DataFrame() # 没有可算的股票标的 → 不碰 repo
|
||||
span = repo.get_enriched_history_span()
|
||||
if span is None:
|
||||
return pl.DataFrame()
|
||||
span_start, span_end = span
|
||||
in_range = {s: d for s, d in stock_targets.items() if d >= span_start}
|
||||
if len(in_range) < len(stock_targets):
|
||||
logger.debug(
|
||||
"自选加入后涨跌幅: %d/%d 只早于可查询历史 (%s 之前), 降级为 null",
|
||||
len(stock_targets) - len(in_range), len(stock_targets), span_start,
|
||||
)
|
||||
if not in_range:
|
||||
return pl.DataFrame()
|
||||
# 窗口必须钳制在缓存区间内: get_enriched_range 是全有或全无的 (cache_min > start
|
||||
# 或 cache_max < end 即整体返回 None), 一只越界的标的会让全部标的失去基准价。
|
||||
# start > end 只出现在「加入日晚于缓存末日」(本地数据未同步) 时, 退化为取缓存末日。
|
||||
end = min(max(in_range.values()), span_end)
|
||||
start = min(min(in_range.values()), end)
|
||||
hist = repo.get_enriched_range(
|
||||
start, end, symbols=list(in_range), columns=["symbol", "date", "close"]
|
||||
)
|
||||
if hist is None or hist.is_empty():
|
||||
return pl.DataFrame()
|
||||
added = pl.DataFrame(
|
||||
{"symbol": list(in_range), "added_date": list(in_range.values())},
|
||||
schema={"symbol": pl.Utf8, "added_date": pl.Date},
|
||||
)
|
||||
return (
|
||||
hist.join(added, on="symbol", how="inner")
|
||||
.filter(pl.col("date") <= pl.col("added_date"))
|
||||
.group_by("symbol")
|
||||
.agg(pl.col("close").sort_by(pl.col("date")).last().alias("base_close"))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/enriched")
|
||||
def watchlist_enriched(
|
||||
request: Request,
|
||||
ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||||
):
|
||||
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
|
||||
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无重计算。
|
||||
|
||||
仅两列为按行向量化现算 (不落盘): 涨跌停价, 以及「加入后涨跌幅」(pct_since_added,
|
||||
以加入日收盘价为基准)。本端点在行情 tick 热路径上被反复调用, 故不做历史 scan 兜底。
|
||||
|
||||
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
|
||||
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
|
||||
@@ -394,7 +472,8 @@ def watchlist_enriched(
|
||||
t0 = time.perf_counter()
|
||||
|
||||
repo = request.app.state.repo
|
||||
symbols = [r["symbol"] for r in watchlist.list_symbols()]
|
||||
entries = watchlist.list_symbols()
|
||||
symbols = [r["symbol"] for r in entries]
|
||||
if not symbols:
|
||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
@@ -496,6 +575,47 @@ def watchlist_enriched(
|
||||
.alias("limit_down_price"),
|
||||
)
|
||||
|
||||
# 「加入日期」与「加入以来」— 与涨跌停价同为读时现算, 不落盘。只在这一处挂一次即
|
||||
# 覆盖 stock/etf/index 三分支。pct_since_added 为小数口径 (与 momentum_* 一致)。
|
||||
try:
|
||||
added_at_df = pl.DataFrame(
|
||||
{
|
||||
"symbol": symbols,
|
||||
# 旧 schema 迁移补的空串归一为 null, 前端只需判断一种「缺失」
|
||||
"added_at": [(r.get("added_at") or None) for r in entries],
|
||||
},
|
||||
schema={"symbol": pl.Utf8, "added_at": pl.Utf8},
|
||||
)
|
||||
with_base = df.join(added_at_df, on="symbol", how="left")
|
||||
base_df = _added_base_closes(repo, _added_dates_bj(entries), set(stock_symbols))
|
||||
if base_df.is_empty():
|
||||
# 无基准价 (缓存冷 / 全为 ETF 或超窗): 整列降级为 null
|
||||
df = with_base.with_columns(pl.lit(None, dtype=pl.Float64).alias("pct_since_added"))
|
||||
else:
|
||||
# 整链算完再回绑 df, 异常时 df 保持原样
|
||||
df = (
|
||||
with_base.join(base_df, on="symbol", how="left")
|
||||
.with_columns(
|
||||
pl.when(
|
||||
pl.col("base_close").is_not_null()
|
||||
& pl.col("base_close").is_finite()
|
||||
& (pl.col("base_close") > 0)
|
||||
& pl.col("close").is_not_null()
|
||||
)
|
||||
.then(pl.col("close") / pl.col("base_close") - 1.0)
|
||||
.otherwise(None)
|
||||
.alias("pct_since_added")
|
||||
)
|
||||
.drop("base_close")
|
||||
)
|
||||
except Exception as e:
|
||||
# 装饰列失败不得影响自选主表; 两列必须存在 (值可为 null), 否则前端排序/筛选静默失效
|
||||
logger.warning("自选加入日期/加入后涨跌幅计算跳过: %s", e, exc_info=True)
|
||||
df = df.with_columns(
|
||||
pl.lit(None, dtype=pl.Utf8).alias("added_at"),
|
||||
pl.lit(None, dtype=pl.Float64).alias("pct_since_added"),
|
||||
)
|
||||
|
||||
# 动态 JOIN 扩展数据表
|
||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
||||
if ext_specs:
|
||||
|
||||
@@ -1278,6 +1278,20 @@ class KlineRepository:
|
||||
df = cache.clear() if df.is_empty() else df.select(existing)
|
||||
return df.sort(["symbol", "date"])
|
||||
|
||||
def get_enriched_history_span(self) -> tuple[date, date] | None:
|
||||
"""内存 enriched 历史缓存的可用日期区间 (含端点); 不可用/预热中返回 None。
|
||||
|
||||
纯读 O(1), 不触发刷新: 调用方 (随行情 tick 反复调用的自选 enriched 端点) 需要
|
||||
区分「预热中」与「日期超窗」, 而 get_enriched_range 对两者都返回 None。
|
||||
"""
|
||||
cache = self._enriched_history_cache
|
||||
start = self._enriched_history_start
|
||||
if cache is None or cache.is_empty() or start is None:
|
||||
return None
|
||||
# 历史缓存与最新日缓存同批写入/清空, end 通常即 _enriched_cache_date;
|
||||
# 仅当首次刷新在写 latest 之前中断时缺失, 回退取日期列最大值
|
||||
return start, self._enriched_cache_date or cache["date"].max()
|
||||
|
||||
def get_live_agg(self) -> pl.DataFrame:
|
||||
"""返回盘中实时指标预计算聚合表。如无缓存则懒加载。
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""get_enriched_history_span 的区间判定测试。
|
||||
|
||||
该方法供自选 enriched 端点判定「加入日是否在可查询历史范围内」, 必须满足:
|
||||
- 纯读 O(1): 缓存冷/预热中不得触发 _refresh_enriched (端点随行情 tick 被反复调用);
|
||||
- 缓存不可用时返回 None, 而不是把「预热中」和「日期超窗」混为一谈。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.tickflow.repository import KlineRepository
|
||||
|
||||
|
||||
def _bare_repo(cache=None, start=None, cache_date=None) -> KlineRepository:
|
||||
"""跳过 __init__ (避免 DataStore/目录依赖), 只装配区间访问涉及的属性。"""
|
||||
repo = KlineRepository.__new__(KlineRepository)
|
||||
repo._enriched_history_cache = cache
|
||||
repo._enriched_history_start = start
|
||||
repo._enriched_cache_date = cache_date
|
||||
return repo
|
||||
|
||||
|
||||
def _spy_refresh(repo):
|
||||
calls: list[int] = []
|
||||
repo._refresh_enriched = lambda: calls.append(1) # type: ignore[method-assign]
|
||||
return calls
|
||||
|
||||
|
||||
def test_span_none_when_cache_cold():
|
||||
repo = _bare_repo()
|
||||
calls = _spy_refresh(repo)
|
||||
|
||||
assert repo.get_enriched_history_span() is None
|
||||
assert calls == [], "缓存冷时不得触发全量重算"
|
||||
|
||||
|
||||
def test_span_none_when_cache_empty():
|
||||
repo = _bare_repo(cache=pl.DataFrame(schema={"symbol": pl.Utf8, "date": pl.Date}),
|
||||
start=date(2026, 1, 1), cache_date=date(2026, 8, 14))
|
||||
|
||||
assert repo.get_enriched_history_span() is None
|
||||
|
||||
|
||||
def test_span_none_when_start_missing():
|
||||
"""预热降级路径会写入 cache_date 但不装配历史起点, 此时不得报出区间。"""
|
||||
cache = pl.DataFrame({"symbol": ["600000.SH"], "date": [date(2026, 8, 14)]})
|
||||
repo = _bare_repo(cache=cache, start=None, cache_date=date(2026, 8, 14))
|
||||
|
||||
assert repo.get_enriched_history_span() is None
|
||||
|
||||
|
||||
def test_span_returns_bounds_without_refresh():
|
||||
cache = pl.DataFrame({
|
||||
"symbol": ["600000.SH", "600000.SH"],
|
||||
"date": [date(2026, 1, 1), date(2026, 8, 14)],
|
||||
})
|
||||
repo = _bare_repo(cache=cache, start=date(2026, 1, 1), cache_date=date(2026, 8, 14))
|
||||
calls = _spy_refresh(repo)
|
||||
|
||||
assert repo.get_enriched_history_span() == (date(2026, 1, 1), date(2026, 8, 14))
|
||||
assert calls == [], "命中缓存时必须纯读, 不得触发 _refresh_enriched"
|
||||
|
||||
|
||||
def test_span_falls_back_to_column_max():
|
||||
"""cache_date 缺失时回退取日期列最大值 (仍不触发刷新)。"""
|
||||
cache = pl.DataFrame({
|
||||
"symbol": ["600000.SH", "600000.SH"],
|
||||
"date": [date(2026, 1, 1), date(2026, 8, 14)],
|
||||
})
|
||||
repo = _bare_repo(cache=cache, start=date(2026, 1, 1), cache_date=None)
|
||||
calls = _spy_refresh(repo)
|
||||
|
||||
assert repo.get_enriched_history_span() == (date(2026, 1, 1), date(2026, 8, 14))
|
||||
assert calls == []
|
||||
@@ -9,9 +9,11 @@ universe 里的自选股静默丢弃.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.api import watchlist as wl_api
|
||||
|
||||
@@ -21,7 +23,8 @@ class _FakeRepo:
|
||||
|
||||
def __init__(self, enriched_df, enriched_date, etf_df=None, etf_date=None,
|
||||
instruments_df=None, name_map=None, etf_set=None,
|
||||
index_df=None, index_date=None, index_set=None):
|
||||
index_df=None, index_date=None, index_set=None,
|
||||
history_span=None, hist_df=None, history_raises=False):
|
||||
self._enriched = enriched_df
|
||||
self._enriched_date = enriched_date
|
||||
self._etf = etf_df
|
||||
@@ -32,6 +35,24 @@ class _FakeRepo:
|
||||
self._name_map = name_map or {}
|
||||
self._etf_set = etf_set or set()
|
||||
self._index_set = index_set or set()
|
||||
# 加入后涨跌幅基准价所需的内存历史缓存 (未注入时端点按 null 降级)
|
||||
self._history_span = history_span
|
||||
self._hist = hist_df if hist_df is not None else pl.DataFrame()
|
||||
self._history_raises = history_raises
|
||||
|
||||
def get_enriched_history_span(self):
|
||||
return self._history_span
|
||||
|
||||
def get_enriched_range(self, start, end, symbols=None, columns=None):
|
||||
if self._history_raises:
|
||||
raise RuntimeError("history cache exploded")
|
||||
if self._hist.is_empty():
|
||||
return self._hist
|
||||
# 与真实实现同语义: 按日期区间 + symbol 过滤
|
||||
return self._hist.filter(
|
||||
(pl.col("date") >= start) & (pl.col("date") <= end)
|
||||
& pl.col("symbol").is_in(list(symbols or []))
|
||||
)
|
||||
|
||||
def get_enriched_latest(self):
|
||||
return self._enriched, self._enriched_date
|
||||
@@ -349,3 +370,210 @@ def test_watchlist_enriched_limit_prices_legacy_st(monkeypatch):
|
||||
assert rows["600029.SH"]["limit_up_price"] == 10.5
|
||||
assert rows["600029.SH"]["limit_down_price"] == 9.5
|
||||
assert rows["600519.SH"]["limit_up_price"] == 1980.0
|
||||
|
||||
|
||||
# ===== 加入日期 / 加入后涨跌幅 =====
|
||||
|
||||
def _hist_df(rows):
|
||||
"""rows: [(symbol, date, close), ...]"""
|
||||
return pl.DataFrame(
|
||||
[{"symbol": s, "date": d, "close": c} for s, d, c in rows],
|
||||
schema={"symbol": pl.Utf8, "date": pl.Date, "close": pl.Float64},
|
||||
)
|
||||
|
||||
|
||||
def _entries(*pairs):
|
||||
"""(symbol, added_at) 对 → list_symbols 返回值"""
|
||||
return [{"symbol": s, "added_at": a} for s, a in pairs]
|
||||
|
||||
|
||||
def _span():
|
||||
return (date(2026, 9, 1), date(2026, 9, 18))
|
||||
|
||||
|
||||
def test_pct_since_added_baseline_is_added_day_close(monkeypatch):
|
||||
"""正常路径: 基准 = 加入日收盘 (<= 语义), 结果为小数口径, added_at 原样透传。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-10T03:21:07")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(),
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 9), 9.0),
|
||||
("600519.SH", date(2026, 9, 10), 10.0),
|
||||
("600519.SH", date(2026, 9, 11), 10.5)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
row = res["rows"][0]
|
||||
|
||||
# 基准取 09-10 收盘 10.0 (不是更早的 9.0, 也不是更晚的 10.5)
|
||||
assert row["pct_since_added"] == pytest.approx(11.0 / 10.0 - 1.0)
|
||||
# 小数口径: 10% 存 0.1 而非 10 (与 momentum_* 一致, 前端 fmtPct 期望小数)
|
||||
assert row["pct_since_added"] == pytest.approx(0.1)
|
||||
assert row["pct_since_added"] < 1, "必须是小数口径, 不能写成百分数"
|
||||
# added_at 原样透传 (前端负责 UTC→北京换算)
|
||||
assert row["added_at"] == "2026-09-10T03:21:07"
|
||||
# 中间量不得泄漏到响应
|
||||
assert "base_close" not in row
|
||||
|
||||
|
||||
def test_pct_since_added_snaps_back_to_previous_trading_day(monkeypatch):
|
||||
"""周末/节假日加入: 基准回落到 <= 加入日的最近交易日 (周五), 而非无值。"""
|
||||
# 2026-09-19 是周六 (北京时间), 无 bar
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-19T02:00:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 13.2, 1.0, 1e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(),
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 17), 10.0),
|
||||
("600519.SH", date(2026, 9, 18), 12.0)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
assert res["rows"][0]["pct_since_added"] == pytest.approx(13.2 / 12.0 - 1.0)
|
||||
|
||||
|
||||
def test_pct_since_added_uses_beijing_date_not_utc(monkeypatch):
|
||||
"""added_at 是 naive UTC: 北京时间 09-19 04:30 加入的, 基准必须取 09-19 收盘。
|
||||
|
||||
该字符串的 UTC 日期是 09-18 —— 若按 UTC 日取基准会错取到 10.0 (结果 1.2),
|
||||
正确结果 0.1。两者可区分, 故本用例能捕捉口径回归。
|
||||
"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-18T20:30:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 22.0, 1.0, 1e9)]),
|
||||
enriched_date="2026-09-19",
|
||||
history_span=(date(2026, 9, 1), date(2026, 9, 19)),
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 18), 10.0),
|
||||
("600519.SH", date(2026, 9, 19), 20.0)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
pct = res["rows"][0]["pct_since_added"]
|
||||
assert pct == pytest.approx(22.0 / 20.0 - 1.0), \
|
||||
f"基准应取北京日 09-19 的收盘 20.0; 按 UTC 日会得到 1.2, 实际 {pct}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_span,history_raises,keeps_added_at", [
|
||||
(None, False, True), # 历史缓存不可用 (冷启动/预热中): 只降级涨跌幅
|
||||
(_span(), True, False), # 取数抛异常: 整个装饰块降级
|
||||
])
|
||||
def test_pct_since_added_degrades_without_breaking_rows(
|
||||
monkeypatch, history_span, history_raises, keeps_added_at,
|
||||
):
|
||||
"""装饰列取不到时不得 fail 整个请求: 返回完整 rows, 新列为 null。"""
|
||||
syms = ["600519.SH", "000001.SZ"]
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-10T03:00:00"),
|
||||
("000001.SZ", "2026-09-11T03:00:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9),
|
||||
("000001.SZ", 15.0, 0.3, 2e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=history_span,
|
||||
history_raises=history_raises,
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
|
||||
assert [r["symbol"] for r in res["rows"]] == syms
|
||||
assert all(r["pct_since_added"] is None for r in res["rows"])
|
||||
assert all(bool(r["added_at"]) is keeps_added_at for r in res["rows"])
|
||||
# 中间量不得残留在响应里
|
||||
assert all("base_close" not in r for r in res["rows"])
|
||||
# 主表数据完好
|
||||
assert res["rows"][0]["close"] == 11.0
|
||||
|
||||
|
||||
def test_pct_since_added_null_when_added_before_history_span(monkeypatch):
|
||||
"""加入日早于可查询历史范围 (约 300 天) 的标的显式降级, 同表其它标的不受影响。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-10T03:00:00"),
|
||||
("000001.SZ", "2025-01-05T03:00:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9),
|
||||
("000001.SZ", 15.0, 0.3, 2e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(), # 只覆盖 2026-09-01 起
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 10), 10.0)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
rows = {r["symbol"]: r for r in res["rows"]}
|
||||
|
||||
assert rows["000001.SZ"]["pct_since_added"] is None, "超窗应降级为 null"
|
||||
assert rows["000001.SZ"]["added_at"] == "2025-01-05T03:00:00", "加入日期仍应返回"
|
||||
assert rows["600519.SH"]["pct_since_added"] == pytest.approx(0.1), \
|
||||
"一只超窗不得拖垮同表其它标的"
|
||||
|
||||
|
||||
def test_added_at_empty_string_normalized_to_null(monkeypatch):
|
||||
"""老 schema 迁移补的空串归一为 null, 不抛异常也不渲染成空日期。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(),
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 10), 10.0)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
row = res["rows"][0]
|
||||
assert row["added_at"] is None
|
||||
assert row["pct_since_added"] is None
|
||||
|
||||
|
||||
def test_pct_since_added_null_for_etf_row(monkeypatch):
|
||||
"""ETF 显式降级: 加入日期正常返回, 加入后涨跌幅为 null。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("510300.SH", "2026-09-10T03:00:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=pl.DataFrame(schema={"symbol": pl.Utf8}),
|
||||
enriched_date=None,
|
||||
etf_df=_enriched_df([("510300.SH", 4.0, 0.5, 1e8)]),
|
||||
etf_date="2026-09-18",
|
||||
etf_set={"510300.SH"},
|
||||
history_span=_span(),
|
||||
hist_df=_hist_df([("510300.SH", date(2026, 9, 10), 3.8)]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
row = res["rows"][0]
|
||||
assert row["added_at"] == "2026-09-10T03:00:00"
|
||||
assert row["pct_since_added"] is None, "ETF 无历史缓存, 显式降级为 —"
|
||||
|
||||
|
||||
def test_pct_since_added_null_for_invalid_baseline(monkeypatch):
|
||||
"""基准价非正/NaN 时返回 null, 不得产出 inf 或 NaN 这类会污染前端排序的值。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols",
|
||||
lambda: _entries(("600519.SH", "2026-09-10T03:00:00"),
|
||||
("000001.SZ", "2026-09-10T03:00:00")))
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9),
|
||||
("000001.SZ", 15.0, 0.3, 2e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(),
|
||||
hist_df=_hist_df([("600519.SH", date(2026, 9, 10), 0.0),
|
||||
("000001.SZ", date(2026, 9, 10), float("nan"))]),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
for row in res["rows"]:
|
||||
assert row["pct_since_added"] is None, f"{row['symbol']} 应降级为 null"
|
||||
|
||||
|
||||
def test_empty_watchlist_returns_no_rows(monkeypatch):
|
||||
"""空自选: 保持既有早退契约, 不因新字段改变响应结构。"""
|
||||
monkeypatch.setattr(wl_api.watchlist, "list_symbols", lambda: [])
|
||||
repo = _FakeRepo(
|
||||
enriched_df=_enriched_df([("600519.SH", 11.0, 1.0, 1e9)]),
|
||||
enriched_date="2026-09-18",
|
||||
history_span=_span(),
|
||||
)
|
||||
|
||||
res = wl_api.watchlist_enriched(_make_request(repo), ext_columns=None)
|
||||
assert res == {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
@@ -340,6 +340,8 @@ interface Props {
|
||||
activeIndicators?: string[]
|
||||
/** 成交量柱相对前 N 个交易日均量的显示设置 */
|
||||
volumeCompare?: VolumeCompareConfig
|
||||
/** 加入自选日 (北京时间 YYYY-MM-DD); 有值且落在当前区间内时, 主图画一条「自选」竖虚线 */
|
||||
addedDate?: string | null
|
||||
}
|
||||
|
||||
// 序列颜色 (双主题通用); 画布轴/网格/文字等主题相关色走 CT() 动态取
|
||||
@@ -361,6 +363,9 @@ const CT = () => chartTheme(getTheme())
|
||||
/** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */
|
||||
const COMPACT_THRESHOLD = 60
|
||||
|
||||
/** 加入自选日竖线颜色 (蓝色虚线, 与同花顺的标注观感一致) */
|
||||
const ADDED_DATE_COLOR = '#3B82F6'
|
||||
|
||||
/** 子图上方信息栏高度 (px) */
|
||||
const INFO_BAR_H = 16
|
||||
/** 子图之间的间距 (px) */
|
||||
@@ -474,6 +479,7 @@ function buildOption(
|
||||
infoIdx: number,
|
||||
linkedPrice: number | null | undefined,
|
||||
volumeCompare: VolumeCompareConfig,
|
||||
addedDate: string | null | undefined,
|
||||
): EChartsOption {
|
||||
const candleData = data.map(d => [d.open, d.close, d.low, d.high])
|
||||
|
||||
@@ -694,6 +700,57 @@ function buildOption(
|
||||
series.push(maLine('ma60', THEME.ma60, 'MA60'))
|
||||
}
|
||||
|
||||
// 加入自选日标注: 蓝色竖虚线从加入日那根K线的下沿连到主图底端的「自选」标签。
|
||||
// 用 custom series 而非 markLine: 两端要贴网格底边, 而 y 轴随 dataZoom 动态定界,
|
||||
// 静态 option 里算不出底边价格; markLine 的坐标是数据坐标, 越界值会让整条线被丢弃。
|
||||
// 日期不在当前区间就静默不画; 周末/节假日加入的不做 snap —— 回退到相邻交易日
|
||||
// 等于在图上断言一个用户没加入过的日期。区间外由弹窗工具栏「自选于 …」文字兜底。
|
||||
const addedIdx = addedDate ? dateIndexMap.get(addedDate) : undefined
|
||||
if (addedIdx != null && data[addedIdx]) {
|
||||
// 标签内留白四边各 4px (两个汉字 @10px ≈ 20x10 → 框 28x18);
|
||||
// 整个框再离图表下边框留 8px margin
|
||||
const BADGE_PAD = 4
|
||||
const BADGE_W = 20 + BADGE_PAD * 2
|
||||
const BADGE_H = 10 + BADGE_PAD * 2
|
||||
const BADGE_MARGIN_BOTTOM = 8
|
||||
series.push({
|
||||
name: 'addedDateMark',
|
||||
type: 'custom',
|
||||
xAxisIndex: 0, yAxisIndex: 0,
|
||||
silent: true, animation: false, z: 3,
|
||||
data: [[addedIdx, data[addedIdx].low]],
|
||||
renderItem: (params: any, api: any) => {
|
||||
const cs = params.coordSys
|
||||
const [x, lowY] = api.coord([api.value(0), api.value(1)])
|
||||
const badgeTop = cs.y + cs.height - BADGE_H - BADGE_MARGIN_BOTTOM
|
||||
return {
|
||||
type: 'group',
|
||||
children: [
|
||||
{
|
||||
type: 'line',
|
||||
// 该根K线下沿低于标签时 (贴底的长下影) 夹到标签顶, 避免线段反向
|
||||
shape: { x1: x, y1: Math.min(lowY, badgeTop), x2: x, y2: badgeTop },
|
||||
style: { stroke: ADDED_DATE_COLOR, lineWidth: 1, lineDash: [4, 3] },
|
||||
},
|
||||
{
|
||||
type: 'rect',
|
||||
shape: { x: x - BADGE_W / 2, y: badgeTop, width: BADGE_W, height: BADGE_H, r: 2 },
|
||||
style: { fill: CT().tooltipBg, stroke: ADDED_DATE_COLOR, lineWidth: 1 },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
text: '自选', x, y: badgeTop + BADGE_H / 2, fill: ADDED_DATE_COLOR,
|
||||
font: '10px JetBrains Mono, monospace',
|
||||
textAlign: 'center', textVerticalAlign: 'middle',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// BOLL 布林带 — 需在 activeIndicators 中激活
|
||||
const showBOLL = activeIndicators.includes('boll') && data.some(d => d.boll_upper != null || d.boll_lower != null)
|
||||
if (showBOLL) {
|
||||
@@ -817,6 +874,7 @@ export function EChartsCandlestick({
|
||||
visibleBars = 60,
|
||||
activeIndicators = [],
|
||||
volumeCompare = { enabled: true, days: 1 },
|
||||
addedDate,
|
||||
}: Props) {
|
||||
const hoverSurfaceRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -1178,6 +1236,7 @@ export function EChartsCandlestick({
|
||||
infoIdxRef.current,
|
||||
linkedPrice,
|
||||
volumeCompare,
|
||||
addedDate,
|
||||
)
|
||||
|
||||
chart.setOption(option, true)
|
||||
@@ -1195,7 +1254,7 @@ export function EChartsCandlestick({
|
||||
if (infoEl) {
|
||||
infoEl.innerHTML = getInfoBarHTML()
|
||||
}
|
||||
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, volumeCompare, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme])
|
||||
}, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, volumeCompare, addedDate, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme])
|
||||
|
||||
// 渲染信息栏容器 (内容由 JS 直接写入)
|
||||
const initialHTML = useMemo(() => {
|
||||
|
||||
@@ -45,6 +45,8 @@ interface Props {
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
|
||||
extColumns?: string
|
||||
/** 加入自选日 (北京时间 YYYY-MM-DD); 有值时日K主图绘制「自选」竖虚线 */
|
||||
addedDate?: string | null
|
||||
}
|
||||
|
||||
function isValidRow(r: any): boolean {
|
||||
@@ -102,6 +104,30 @@ export function getDefaultRange(): { start: string; end: string } {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/** 工具栏小胶囊开关(指标与标注开关共用一套尺寸/关闭态样式,激活色由调用方给字面量)。 */
|
||||
function ChartPill({
|
||||
active, label, activeClass, title, onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
activeClass: string
|
||||
title?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
|
||||
active ? activeClass : 'bg-elevated text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StockDailyKChart({
|
||||
symbol,
|
||||
height = 520,
|
||||
@@ -120,9 +146,12 @@ export function StockDailyKChart({
|
||||
onDateClick,
|
||||
onPriceDoubleClick,
|
||||
extColumns,
|
||||
addedDate,
|
||||
}: Props) {
|
||||
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
|
||||
const [showMarkers, setShowMarkers] = useState(true)
|
||||
// 加入自选日标注(与「异动」标记相互独立)
|
||||
const [showAddedMark, setShowAddedMark] = useState(true)
|
||||
const [volumeCompare, setVolumeCompare] = useState<VolumeCompareConfig>(() =>
|
||||
normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)),
|
||||
)
|
||||
@@ -166,30 +195,22 @@ export function StockDailyKChart({
|
||||
{showIndicatorControls && rows.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pb-0.5">
|
||||
{SUB_CHARTS.map(ind => (
|
||||
<button
|
||||
<ChartPill
|
||||
key={ind.key}
|
||||
active={activeIndicators.includes(ind.key)}
|
||||
label={ind.label}
|
||||
activeClass="bg-accent/20 text-accent"
|
||||
onClick={() => toggleIndicator(ind.key)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
|
||||
activeIndicators.includes(ind.key)
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-elevated text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{ind.label}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
{OVERLAY_INDICATORS.map(ind => (
|
||||
<button
|
||||
<ChartPill
|
||||
key={ind.key}
|
||||
active={activeIndicators.includes(ind.key)}
|
||||
label={ind.label}
|
||||
activeClass="bg-accent/20 text-accent"
|
||||
onClick={() => toggleIndicator(ind.key)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
|
||||
activeIndicators.includes(ind.key)
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-elevated text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{ind.label}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
{activeIndicators.includes('vol') && (
|
||||
<div className="ml-0.5 flex h-5 items-center gap-1.5 border-l border-border/70 pl-2">
|
||||
@@ -222,18 +243,27 @@ export function StockDailyKChart({
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{showMarkerToggle && showLimitMarkers && (
|
||||
<button
|
||||
onClick={() => setShowMarkers(v => !v)}
|
||||
className={`ml-auto px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
|
||||
showMarkers
|
||||
? 'text-[#FACC15] bg-[#FACC15]/10'
|
||||
: 'bg-elevated text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
异动
|
||||
</button>
|
||||
)}
|
||||
{/* 两个标注开关统一右对齐 */}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{showMarkerToggle && showLimitMarkers && (
|
||||
<ChartPill
|
||||
active={showMarkers}
|
||||
label="异动"
|
||||
activeClass="text-[#FACC15] bg-[#FACC15]/10"
|
||||
onClick={() => setShowMarkers(v => !v)}
|
||||
/>
|
||||
)}
|
||||
{/* 激活色与图表里的 ADDED_DATE_COLOR 一致; Tailwind 只认字面量类名, 故写死色值 */}
|
||||
{showMarkerToggle && addedDate && (
|
||||
<ChartPill
|
||||
active={showAddedMark}
|
||||
label="自选"
|
||||
activeClass="text-[#3B82F6] bg-[#3B82F6]/10"
|
||||
title={showAddedMark ? '隐藏K线上的加入自选日标注' : '显示K线上的加入自选日标注'}
|
||||
onClick={() => setShowAddedMark(v => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{kline.isLoading && <div className="text-sm text-muted py-4">加载中…</div>}
|
||||
@@ -259,6 +289,7 @@ export function StockDailyKChart({
|
||||
visibleBars={visibleBars}
|
||||
activeIndicators={activeIndicators}
|
||||
volumeCompare={volumeCompare}
|
||||
addedDate={showAddedMark ? addedDate : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@ interface Props {
|
||||
dailyKlineFlex?: string
|
||||
/** 初始可见蜡烛根数 (默认 60); 'all' = 初始适配显示全部数据 (用于全区间回放) */
|
||||
visibleBars?: number | 'all'
|
||||
/** 加入自选日 (北京时间 YYYY-MM-DD); 有值时日K主图绘制「自选」竖虚线 */
|
||||
addedDate?: string | null
|
||||
}
|
||||
|
||||
export { getDefaultRange }
|
||||
@@ -79,6 +81,7 @@ export function StockPanel({
|
||||
intradayDays = DEFAULT_INTRADAY_DAYS,
|
||||
dailyKlineFlex = 'flex-1',
|
||||
visibleBars,
|
||||
addedDate,
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
@@ -217,6 +220,7 @@ export function StockPanel({
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
visibleBars={visibleBars ?? (showIntraday ? 40 : 60)}
|
||||
extColumns={extColumns}
|
||||
addedDate={addedDate}
|
||||
/>
|
||||
|
||||
{showIntraday && selectedDate && !intradayDismissed && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { useCustomSignalNames } from '@/lib/useCustomSignalNames'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { fmtPct, cnDateFromUtc } from '@/lib/format'
|
||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
|
||||
import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart'
|
||||
@@ -151,7 +151,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
() => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [],
|
||||
[monitorRules.data?.rules, symbol],
|
||||
)
|
||||
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
||||
const watchlistEntry = useMemo(
|
||||
() => (watchlist.data?.symbols ?? []).find(s => s.symbol === symbol),
|
||||
[watchlist.data, symbol],
|
||||
)
|
||||
const inWatchlist = !!watchlistEntry
|
||||
// 加入自选日 (北京时间)。该日不在当前K线区间内或恰是非交易日时竖线不画, 由工具栏文字兜底。
|
||||
const addedDate = useMemo(
|
||||
() => cnDateFromUtc(watchlistEntry?.added_at) || null,
|
||||
[watchlistEntry],
|
||||
)
|
||||
|
||||
const toggleWatchlist = useMutation({
|
||||
mutationFn: ({
|
||||
@@ -404,6 +413,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
onChange={(v) => setDateRange(prev => ({ ...prev, end: v }))}
|
||||
min={dateRange.start}
|
||||
/>
|
||||
{/* 常显而非仅超窗时显示: 弹窗不知道区间内那天是否交易日,
|
||||
常显既避免错误的区间判断, 也覆盖「区间外」与「周末加入」两种情况 */}
|
||||
{addedDate && (
|
||||
<span
|
||||
className="text-[10px] text-muted shrink-0"
|
||||
title="加入自选日 (北京时间); 该日无K线时不画竖线"
|
||||
>
|
||||
自选于 {addedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -617,6 +636,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
prefetchSymbols={prefetchSymbols}
|
||||
intradayDays={effectiveIntradayDays}
|
||||
dailyKlineFlex="flex-[1.4]"
|
||||
addedDate={addedDate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 财务率类用 fmtPct、kdj 用 toFixed(1)、vol_ma 用 fmtBigNum 等。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { fmtPrice, fmtPct, fmtBigNum, fmtVolume, priceColorClass } from '@/lib/format'
|
||||
import { fmtPrice, fmtPct, fmtBigNum, fmtVolume, priceColorClass, cnDateFromUtc } from '@/lib/format'
|
||||
import type { ColumnConfig } from '@/lib/list-columns'
|
||||
import { NUM_CELL_CLASS } from '@/lib/stock-table'
|
||||
|
||||
@@ -153,6 +153,15 @@ export function renderBuiltinDataCell(r: any, col: ColumnConfig): ReactNode | nu
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
// 自选加入信息(后端读时计算;added_at 为 naive UTC,须转北京时间展示)
|
||||
case 'added_at':
|
||||
return (
|
||||
<td key={col.id} className={`${alignTdClass(col.align)} text-secondary`}>
|
||||
{cnDateFromUtc(r.added_at) || '—'}
|
||||
</td>
|
||||
)
|
||||
case 'pct_since_added':
|
||||
return <td key={col.id} className={`${numCls} ${priceColorClass(r.pct_since_added)}`}>{fmtPct(r.pct_since_added)}</td>
|
||||
// 财务指标(后端 enriched 未返回时显示 —)
|
||||
case 'eps': return <td key={col.id} className={numCls}>{r.eps != null ? fmtPrice(r.eps) : '—'}</td>
|
||||
case 'bps': return <td key={col.id} className={numCls}>{r.bps != null ? fmtPrice(r.bps) : '—'}</td>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cnDateTimeFromUtc } from './format'
|
||||
|
||||
// 后端自选 added_at 是 datetime.utcnow() 写入的 naive UTC 串 (无 Z 后缀)。
|
||||
// 若按 JS 默认的本地时区解析, 非北京时区会显示错日; 北京时间 00:00-08:00 加入的
|
||||
// 记录则会被显示成前一天。以下用例在任何时区的机器上都必须通过。
|
||||
describe('cnDateTimeFromUtc: naive UTC 串 → 北京时间', () => {
|
||||
it('跨 UTC 日界: 北京凌晨加入的不能显示成前一天', () => {
|
||||
// UTC 09-18 20:30 = 北京 09-19 04:30
|
||||
expect(cnDateTimeFromUtc('2026-09-18T20:30:00')).toBe('2026-09-19 04:30:00')
|
||||
})
|
||||
|
||||
it('常规时段换算 (+8 小时)', () => {
|
||||
expect(cnDateTimeFromUtc('2026-09-19T03:21:07')).toBe('2026-09-19 11:21:07')
|
||||
})
|
||||
|
||||
it('北京午夜显示 00:00 而非 24:00', () => {
|
||||
expect(cnDateTimeFromUtc('2026-09-18T16:00:00')).toBe('2026-09-19 00:00:00')
|
||||
})
|
||||
|
||||
it('已带时区标记的串按原语义解析', () => {
|
||||
expect(cnDateTimeFromUtc('2026-09-18T20:30:00Z')).toBe('2026-09-19 04:30:00')
|
||||
expect(cnDateTimeFromUtc('2026-09-19T04:30:00+08:00')).toBe('2026-09-19 04:30:00')
|
||||
})
|
||||
|
||||
it('空值与非法值返回空串 (不抛异常、不产生 Invalid Date)', () => {
|
||||
for (const v of ['', ' ', null, undefined, 'garbage', 'not-a-date']) {
|
||||
expect(cnDateTimeFromUtc(v)).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,51 @@ export function fmtDate(s: string | Date | null | undefined): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 naive UTC ISO 串 → 北京时间串; 空/非法返回 ''。
|
||||
*
|
||||
* 不能走 fmtDate: 后端落盘的这类时间戳无 Z 后缀, JS 会把它按**本地时区**解析 ——
|
||||
* 非北京时区 (CI 的 UTC、欧美用户) 会显示错日, 北京时区则会把北京时间 00:00-08:00
|
||||
* 的记录显示成前一天。必须显式按 UTC 解析再按 Asia/Shanghai 渲染。
|
||||
*
|
||||
* 与 kline.ts 的 cnToday/cnNowHHMM 分工不同: 那两个处理「当前时刻」, 这里处理任意时间戳。
|
||||
*/
|
||||
// options 固定, 提到模块级复用: 构造 Intl.DateTimeFormat 约 47µs, 而本函数会在排序
|
||||
// 比较里被调用 O(n·log n) 次。
|
||||
const CN_DATE_TIME = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
const CN_DATE = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
})
|
||||
|
||||
/** naive UTC 串 → Date; 空/非法返回 null。naive 串按 UTC 解析, 已带偏移的保持原样。 */
|
||||
function parseUtcNaive(utcIso: string | null | undefined): Date | null {
|
||||
const raw = String(utcIso ?? '').trim()
|
||||
if (!raw) return null
|
||||
const d = new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw}Z`)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** naive UTC ISO 串 → 北京日期 'YYYY-MM-DD'; 空/非法返回 ''。 */
|
||||
export function cnDateFromUtc(utcIso: string | null | undefined): string {
|
||||
const d = parseUtcNaive(utcIso)
|
||||
return d ? CN_DATE.format(d) : ''
|
||||
}
|
||||
|
||||
/** naive UTC ISO 串 → 北京日期时间 'YYYY-MM-DD HH:mm:ss'; 空/非法返回 ''。 */
|
||||
export function cnDateTimeFromUtc(utcIso: string | null | undefined): string {
|
||||
const d = parseUtcNaive(utcIso)
|
||||
if (!d) return ''
|
||||
const parts = CN_DATE_TIME.formatToParts(d)
|
||||
const pick = (type: string) => parts.find(p => p.type === type)?.value ?? ''
|
||||
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}:${pick('second')}`
|
||||
}
|
||||
|
||||
// ===== Data 页面工具函数 =====
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSortValue } from './stock-table'
|
||||
import type { ColumnConfig } from './list-columns'
|
||||
|
||||
function builtinCol(key: string): ColumnConfig {
|
||||
return { id: `builtin:${key}`, source: { type: 'builtin', key }, label: key, visible: false, align: 'center' }
|
||||
}
|
||||
|
||||
describe('getSortValue: 自选加入信息列', () => {
|
||||
it('added_at 返回北京时间完整串 (与展示同口径, 字典序即时间序)', () => {
|
||||
expect(getSortValue({ added_at: '2026-09-18T20:30:00' }, builtinCol('added_at')))
|
||||
.toBe('2026-09-19 04:30:00')
|
||||
})
|
||||
|
||||
it('added_at 缺失返回 null 而非空串', () => {
|
||||
// useTableSort 会先试 Number('') === 0, 返回空串会让空值排到最前
|
||||
for (const v of ['', null, undefined]) {
|
||||
expect(getSortValue({ added_at: v }, builtinCol('added_at'))).toBe(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('pct_since_added 透传数值 (含 0 与负值)', () => {
|
||||
expect(getSortValue({ pct_since_added: 0.1 }, builtinCol('pct_since_added'))).toBe(0.1)
|
||||
expect(getSortValue({ pct_since_added: -0.05 }, builtinCol('pct_since_added'))).toBe(-0.05)
|
||||
expect(getSortValue({ pct_since_added: 0 }, builtinCol('pct_since_added'))).toBe(0)
|
||||
expect(getSortValue({}, builtinCol('pct_since_added'))).toBe(undefined)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import type { ColumnConfig } from '@/lib/list-columns'
|
||||
import type { MinuteKlineRow } from '@/lib/api'
|
||||
import { cnDateTimeFromUtc } from '@/lib/format'
|
||||
|
||||
// ===== 信号 =====
|
||||
|
||||
@@ -108,6 +109,10 @@ export function getSortValue(r: any, col: ColumnConfig): any {
|
||||
case 'deviate_30d': return r.deviate_30d
|
||||
case 'limit_ups': return r.consecutive_limit_ups ?? 0
|
||||
case 'limit_downs': return r.consecutive_limit_downs ?? 0
|
||||
// 用北京时间的完整串排序而非原始 UTC: 与展示同口径。缺值返回 null 而非 '' ——
|
||||
// useTableSort 会先试 Number(''), 空串会被当成 0 排到最前。
|
||||
case 'added_at': return r.added_at ? cnDateTimeFromUtc(r.added_at) : null
|
||||
case 'pct_since_added': return r.pct_since_added
|
||||
case 'score': return r.score
|
||||
default: return null
|
||||
}
|
||||
|
||||
@@ -56,3 +56,42 @@ describe('watchlist columns: 新增内置列向后兼容', () => {
|
||||
expect(byId.get('builtin:high')!.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// 自选加入信息列 (加入日期 / 加入后涨跌幅): 数据来自 /enriched 的读时计算字段,
|
||||
// 同样遵循「新增列默认隐藏」契约。列 key 必须与后端返回的字段名逐字一致。
|
||||
describe('watchlist columns: 自选加入信息列', () => {
|
||||
const ADDED_KEYS = ['added_at', 'pct_since_added']
|
||||
|
||||
it('两列已注册且默认隐藏, 并归入「自选」分组', () => {
|
||||
for (const key of ADDED_KEYS) {
|
||||
const col = BUILTIN_COLUMNS.find(c => c.source.type === 'builtin' && c.source.key === key)
|
||||
expect(col, `内置列 ${key} 应存在`).toBeTruthy()
|
||||
expect(col!.visible).toBe(false)
|
||||
}
|
||||
// 必须进 COLUMN_GROUPS, 否则列存在于表格配置但在自定义列面板里不可见
|
||||
const group = COLUMN_GROUPS.find(g => g.id === 'added')
|
||||
expect(group, '应存在「自选」列分组').toBeTruthy()
|
||||
for (const key of ADDED_KEYS) {
|
||||
expect(group!.keys).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('老用户配置 (不含新列 id) 合并后自动补齐两列且默认隐藏', () => {
|
||||
const saved = BUILTIN_COLUMNS
|
||||
.filter(c => ['builtin:symbol', 'builtin:price', 'builtin:pct'].includes(c.id))
|
||||
.map(c => ({ ...c }))
|
||||
|
||||
const merged = mergeColumns(saved, BUILTIN_COLUMNS)
|
||||
const byId = new Map(merged.map(c => [c.id, c]))
|
||||
|
||||
for (const key of ADDED_KEYS) {
|
||||
const col = byId.get(`builtin:${key}`)
|
||||
expect(col, `合并后应包含新列 builtin:${key}`).toBeTruthy()
|
||||
expect(col!.visible).toBe(false)
|
||||
}
|
||||
// 用户已有列的显隐与顺序不受影响, 新列追加在末尾
|
||||
expect(byId.get('builtin:price')!.visible).toBe(true)
|
||||
expect(merged[0].id).toBe('builtin:symbol')
|
||||
expect(merged.map(c => c.id).slice(-2)).toEqual(['builtin:added_at', 'builtin:pct_since_added'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,10 +90,14 @@ export const BUILTIN_COLUMNS: ColumnConfig[] = [
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
|
||||
// 自选加入信息 (后端读时计算, 不落盘; 默认隐藏)
|
||||
{ id: 'builtin:added_at', source: { type: 'builtin', key: 'added_at' }, label: '加入日期', visible: false, align: 'center' },
|
||||
{ id: 'builtin:pct_since_added', source: { type: 'builtin', key: 'pct_since_added' }, label: '加入以来', visible: false, align: 'center' },
|
||||
]
|
||||
|
||||
export const COLUMN_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude', 'open', 'high', 'low', 'prev_close', 'limit_up_price', 'limit_down_price'] },
|
||||
{ id: 'added', label: '自选', icon: '⭐', keys: ['added_at', 'pct_since_added'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
||||
|
||||
Reference in New Issue
Block a user