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:
richard
2026-09-19 16:35:26 +08:00
co-authored by Claude
parent 7687278ec6
commit 5123274fb0
15 changed files with 753 additions and 38 deletions
+123 -3
View File
@@ -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:
+14
View File
@@ -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 == []
+229 -1
View File
@@ -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}
+60 -1
View File
@@ -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(() => {
+61 -30
View File
@@ -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>
+4
View File
@@ -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 && (
+22 -2
View File
@@ -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>
+32
View File
@@ -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('')
}
})
})
+45
View File
@@ -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 {
+29
View File
@@ -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)
})
})
+5
View File
@@ -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'])
})
})
+4
View File
@@ -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'] },