Files
tick-stock-panel/backend/app/api/watchlist.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

713 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自选 API。"""
from __future__ import annotations
import logging
import math
import time
from datetime import UTC, date, datetime
from typing import Callable
import anyio
import polars as pl
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,
polars_price_limit_pct,
)
from app.services import watchlist
from app.services.watchlist_csv import import_watchlist_codes, import_watchlist_csv
from app.services.watchlist_ocr import import_watchlist_image
from app.services.watchlist_ocr.provider import get_ocr_provider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
_MAX_IMPORT_IMAGE_BYTES = 12 * 1024 * 1024 # 12MB
_IMPORT_IMAGE_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/bmp",
"image/gif",
}
# OCR 独立并发上限:避免多张大图同时解码 + 多 Tesseract 子进程
_OCR_LIMITER = anyio.CapacityLimiter(2)
# CSV/TXT 导入:文本远小于截图,上限 5MB 足够
_MAX_IMPORT_CSV_BYTES = 5 * 1024 * 1024
_IMPORT_CSV_TYPES = {
"text/csv",
"text/plain",
"application/csv",
}
# 上传分块读取粒度 (与 ext_data 上传一致)
_UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _read_upload_capped(file: UploadFile, max_bytes: int, too_large: str) -> bytes:
"""分块读取上传内容, 累计超过 max_bytes 立即拒绝(400), 返回完整字节。
与 ext_data._write_upload_capped 同类保护: 一次性 `await file.read()` 会先把整个
文件读入内存再比较长度, 上限在那之后才生效, 一个远超上限的上传照样把进程内存
顶满; 分块读取在越过上限的那一块就停止, 内存占用不超过上限 + 一块。
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(400, too_large)
chunks.append(chunk)
return b"".join(chunks)
class AddRequest(BaseModel):
symbol: str
note: str = ""
group_id: str | None = None
class BatchAddRequest(BaseModel):
symbols: list[str]
note: str = ""
group_id: str | None = None
group_ids: list[str] | None = None
class GroupNameRequest(BaseModel):
name: str
color: str | None = None
class GroupReorderRequest(BaseModel):
ordered_ids: list[str]
class GroupAssignRequest(BaseModel):
group_id: str | None = None
class ImportCodesRequest(BaseModel):
text: str
def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
try:
# 股票 + ETF 名称统一由 repo.get_name_map 解析, 自选列表可混合持有
name_by_symbol = request.app.state.repo.get_name_map([r.get("symbol") for r in rows])
if not name_by_symbol:
return rows
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
except Exception as e: # noqa: BLE001
logger.debug("attach watchlist names failed: %s", e)
return rows
@router.get("")
def list_all(request: Request):
return {"symbols": _with_names(watchlist.list_symbols(), request)}
@router.post("")
def add_one(req: AddRequest, request: Request):
try:
rows = watchlist.add(req.symbol, req.note, req.group_id)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.post("/batch")
def add_batch(req: BatchAddRequest, request: Request):
try:
rows, added = watchlist.add_batch(
req.symbols,
req.note,
group_id=req.group_id,
group_ids=req.group_ids,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request), "added": added}
@router.get("/groups")
def list_groups():
return {"groups": watchlist.list_groups()}
@router.post("/groups")
def create_group(req: GroupNameRequest):
try:
groups, group = watchlist.create_group(req.name, req.color)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups, "group": group}
@router.put("/groups/reorder")
def reorder_groups(req: GroupReorderRequest):
"""重排分组前后顺序 (json 数组顺序即定义顺序, 侧边栏/标签栏/分组视图共用)。"""
try:
groups = watchlist.reorder_groups(req.ordered_ids)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups}
@router.put("/groups/{group_id}")
def rename_group(group_id: str, req: GroupNameRequest):
try:
groups = watchlist.rename_group(group_id, req.name, req.color)
except KeyError as e:
raise HTTPException(404, "自选分组不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"groups": groups}
@router.delete("/groups/{group_id}")
def delete_group(group_id: str, request: Request):
try:
groups, rows = watchlist.delete_group(group_id)
except KeyError as e:
raise HTTPException(404, "自选分组不存在") from e
return {"groups": groups, "symbols": _with_names(rows, request)}
@router.post("/groups/{group_id}/clear")
def clear_group(group_id: str, request: Request):
"""清空分组成员:把该分组内所有股票转为未分组,保留分组定义。"""
try:
rows = watchlist.clear_group(group_id)
except KeyError as e:
raise HTTPException(404, "自选分组不存在") from e
return {"symbols": _with_names(rows, request)}
@router.get("/ocr-status")
def ocr_status():
"""当前 OCR 引擎是否可用(前端可据此提示安装依赖)。"""
provider = get_ocr_provider()
return {"provider": provider.name, "available": provider.available()}
@router.post("/import-image")
async def import_from_image(request: Request, file: UploadFile = File(...)):
"""从自选截图识别股票代码,返回候选列表(不自动写入自选)。"""
content_type = (file.content_type or "").split(";")[0].strip().lower()
filename = (file.filename or "").lower()
# 严格白名单:不接受任意 image/*(如 image/svg+xml)
ok_type = content_type in _IMPORT_IMAGE_TYPES
ok_ext = filename.endswith((".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"))
if not ok_type and not ok_ext:
raise HTTPException(400, "仅支持 JPG / PNG / WebP / BMP / GIF 图片")
data = await _read_upload_capped(file, _MAX_IMPORT_IMAGE_BYTES, "图片过大(上限 12MB)")
if not data:
raise HTTPException(400, "空文件")
existing = {r["symbol"] for r in watchlist.list_symbols()}
data_dir = request.app.state.repo.store.data_dir
try:
# OCR 为同步 CPU/子进程;独立 limiter 限制并发,避免卡住事件循环(行情 SSE 等)
result = await anyio.to_thread.run_sync(
lambda: import_watchlist_image(data, data_dir, existing_symbols=existing),
limiter=_OCR_LIMITER,
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except RuntimeError as e:
raise HTTPException(503, str(e)) from e
except Exception as e: # noqa: BLE001
logger.exception("watchlist import-image failed")
raise HTTPException(500, f"识别失败: {e}") from e
# 响应不回传整段 raw_text(可能很长);调试时可开 query,这里默认省略
result.pop("raw_text", None)
return result
def _run_candidate_import(parse: Callable[[], dict], empty_msg: str) -> dict:
"""执行候选解析:ValueError→400、其他→500、空候选→400、剥离 raw_text。"""
try:
result = parse()
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e: # noqa: BLE001
logger.exception("watchlist import failed")
raise HTTPException(500, f"解析失败: {e}") from e
if not result["candidates"]:
raise HTTPException(400, empty_msg)
result.pop("raw_text", None)
return result
@router.post("/import-csv")
async def import_from_csv(request: Request, file: UploadFile = File(...)):
"""从 CSV / TXT 导入自选候选列表(不自动写入自选)。
兼容同花顺/东财/通达信导出(逗号或 Tab 分隔、UTF-8 或 GBK 编码)。目标分组
在候选确认时由前端传入 batch 接口,本端点只做解析与主数据校验。
"""
content_type = (file.content_type or "").split(";")[0].strip().lower()
filename = (file.filename or "").lower()
ok_type = content_type in _IMPORT_CSV_TYPES
ok_ext = filename.endswith((".csv", ".txt"))
if not ok_type and not ok_ext:
raise HTTPException(400, "仅支持 CSV / TXT 文件")
data = await _read_upload_capped(file, _MAX_IMPORT_CSV_BYTES, "文件过大(上限 5MB)")
if not data:
raise HTTPException(400, "空文件")
data_dir = request.app.state.repo.store.data_dir
# 解码与自选/instruments parquet 读取为同步 CPU/IO,挪线程池避免卡事件循环
return await anyio.to_thread.run_sync(
lambda: _run_candidate_import(
lambda: import_watchlist_csv(
data,
data_dir,
existing_symbols={r["symbol"] for r in watchlist.list_symbols()},
),
"文件中未识别到股票代码或名称",
)
)
@router.post("/import-codes")
def import_from_codes(req: ImportCodesRequest, request: Request):
"""从粘贴的证券代码导入自选候选列表(不自动写入自选)。"""
text = req.text.strip()
if not text:
raise HTTPException(400, "请输入要导入的股票代码")
existing = {r["symbol"] for r in watchlist.list_symbols()}
data_dir = request.app.state.repo.store.data_dir
return _run_candidate_import(
lambda: import_watchlist_codes(text, data_dir, existing_symbols=existing),
"未识别到股票代码",
)
@router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol)
return {"symbols": _with_names(rows, request)}
@router.put("/{symbol}/group")
def assign_group(symbol: str, req: GroupAssignRequest, request: Request):
"""互斥设定分组(仅保留此组; None=移出全部分组)。多组操作用 members 端点。"""
try:
rows = watchlist.set_group(symbol, req.group_id)
except KeyError as e:
raise HTTPException(404, "自选标的不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.post("/groups/{group_id}/members/{symbol}")
def add_member(group_id: str, symbol: str, request: Request):
"""把标的加入分组(多组成员关系: 不影响其他分组)。"""
try:
rows = watchlist.add_to_group(symbol, group_id)
except KeyError as e:
raise HTTPException(404, "自选标的不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.delete("/groups/{group_id}/members/{symbol}")
def remove_member(group_id: str, symbol: str, request: Request):
"""把标的移出分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。"""
try:
rows = watchlist.remove_from_group(symbol, group_id)
except KeyError as e:
raise HTTPException(404, "自选标的不存在") from e
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"symbols": _with_names(rows, request)}
@router.delete("/{symbol}")
def remove_one(symbol: str, request: Request):
rows = watchlist.remove(symbol)
return {"symbols": _with_names(rows, request)}
@router.delete("")
def clear_all():
"""清空自选列表。"""
count = watchlist.clear()
return {"removed": count}
# 自选页需要的列
_WATCHLIST_COLS = [
"symbol", "close", "open", "high", "low", "prev_close", "volume",
"change_pct", "change_amount", "amount",
"turnover_rate",
"amplitude", "annual_vol_20d",
"vol_ratio_5d",
"ma5", "ma10", "ma20", "ma60",
"vol_ma5", "vol_ma10",
"high_60d", "low_60d",
"rsi_6", "rsi_14", "rsi_24",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
"boll_upper", "boll_lower",
"atr_14",
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
"deviate_3d", "deviate_10d", "deviate_30d",
"consecutive_limit_ups", "consecutive_limit_downs",
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
"signal_boll_breakout_upper", "signal_ma20_breakout",
"signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
"signal_boll_breakdown_lower", "signal_ma20_breakdown",
]
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 最新日读取, 无重计算。
仅两列为按行向量化现算 (不落盘): 涨跌停价, 以及「加入后涨跌幅」(pct_since_added,
以加入日收盘价为基准)。本端点在行情 tick 热路径上被反复调用, 故不做历史 scan 兜底。
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
"""
t0 = time.perf_counter()
repo = request.app.state.repo
entries = watchlist.list_symbols()
symbols = [r["symbol"] for r in entries]
if not symbols:
return {"rows": [], "as_of": None, "elapsed_ms": 0,
"dates": {"stock": None, "etf": None, "index": None}}
# 按资产拆分自选 symbol; ETF enriched 是独立缓存, 仅自选真的含 ETF 才去加载
# (避免无 ETF 用户在缓存冷启动时触发 ETF 全量懒加载)
etf_set = repo.get_etf_symbol_set()
index_set = repo.get_index_symbol_set()
etf_symbols = [s for s in symbols if s in etf_set]
index_symbols = [s for s in symbols if s not in etf_set and s in index_set]
stock_symbols = [s for s in symbols if s not in etf_set and s not in index_set]
df_e, cache_date = repo.get_enriched_latest()
# 以自选列表为主表 LEFT JOIN enriched, 保证自选的每一只都返回一行;
# 不在 enriched 缓存里的标的 (新股/冷门股/新用户未同步) 指标为 null, 前端渲染为 "—".
# 旧实现是 df_e.filter(is_in(stock_symbols)), 方向反了 (以 enriched 为主),
# 会把不在缓存 universe 里的自选静默丢弃.
if stock_symbols:
watchlist_df = pl.DataFrame({"symbol": stock_symbols})
if df_e.is_empty():
df = watchlist_df
else:
df = watchlist_df.join(df_e, on="symbol", how="left")
else:
df = pl.DataFrame()
# ETF 行合并; 缺失列 (换手率/涨跌停信号等) 为 null
etf_date = None
if etf_symbols:
df_etf_all, etf_date = repo.get_enriched_latest_asset("etf")
etf_watchlist_df = pl.DataFrame({"symbol": etf_symbols})
if not df_etf_all.is_empty():
# ETF 同样以自选为主表 LEFT JOIN, 缺失标的指标为 null
df_etf = etf_watchlist_df.join(df_etf_all, on="symbol", how="left")
else:
df_etf = etf_watchlist_df
df = df_etf if df.is_empty() else pl.concat([df, df_etf], how="diagonal_relaxed")
# 指数行合并 (镜像 ETF 分支); 缺失列 (换手率/涨跌停信号等) 为 null
index_date = None
if index_symbols:
df_idx_all, index_date = repo.get_enriched_latest_asset("index")
idx_watchlist_df = pl.DataFrame({"symbol": index_symbols})
if not df_idx_all.is_empty():
df_idx = idx_watchlist_df.join(df_idx_all, on="symbol", how="left")
else:
df_idx = idx_watchlist_df
df = df_idx if df.is_empty() else pl.concat([df, df_idx], how="diagonal_relaxed")
# as_of 取三类缓存中较旧者
dates = [d for d in (cache_date if stock_symbols else None, etf_date, index_date) if d is not None]
as_of = min(dates) if dates else None
# 按资产类型的行情日期 (ISO): as_of 是三类取 min 的全局值, 分不清哪类过期。
# 前端分时缩略图续画据此判定「该类行情是否为当日」—— ETF 未开实时拉取时
# etf 缓存停在旧日, 过期 close 拼到当日分钟K尾部会画出错价 (issue 场景)。
# None = 未加载/无缓存, 前端一律按不新鲜处理 (fail-closed)。
quote_dates = {
"stock": str(cache_date) if cache_date else None,
"etf": str(etf_date) if etf_date else None,
"index": str(index_date) if index_date else None,
}
if df.is_empty():
return {"rows": [], "as_of": str(as_of) if as_of else None,
"elapsed_ms": 0, "dates": quote_dates}
# JOIN float_shares (仅股票有) + 名称 (股票/ETF 统一走 get_name_map)
df_i = repo.get_instruments()
if not df_i.is_empty() and "float_shares" in df_i.columns:
df = df.join(df_i.select(["symbol", "float_shares"]), on="symbol", how="left")
name_map = repo.get_name_map(df["symbol"].to_list())
df = df.with_columns(
pl.col("symbol").replace_strict(name_map, default=None, return_dtype=pl.Utf8).alias("name")
)
# 标注资产类型: 前端据此渲染徽标/豁免板块筛选/分时列降级
asset_map = {**{s: "etf" for s in etf_symbols}, **{s: "index" for s in index_symbols}}
df = df.with_columns(
pl.col("symbol").replace_strict(asset_map, default="stock", return_dtype=pl.Utf8).alias("asset_type")
)
# 选择内置需要的列
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares", "asset_type"] if c in df.columns]
df = df.select(keep)
# 涨跌停价 (交易所整数分半进位口径, 仅股票): prev_close/名称已在行上, 对自选的
# 几十~几百行向量化现算为亚毫秒级, 不写回 enriched 存储。ETF (跨境/债券 5% 等)
# 与指数的涨跌幅规则不在 price_limits 覆盖内, 置 null 由前端渲染 "—"。
as_of_date = as_of if isinstance(as_of, date) else None
if as_of_date is None and as_of:
try:
as_of_date = date.fromisoformat(str(as_of)[:10])
except ValueError:
as_of_date = None
if {"symbol", "prev_close", "name", "asset_type"}.issubset(df.columns) and as_of_date is not None:
pct = polars_price_limit_pct(
pl.col("symbol"),
pl.lit(as_of_date),
polars_is_risk_warning_name(pl.col("name")),
)
stock_with_prev = (pl.col("asset_type") == "stock") & pl.col("prev_close").is_not_null()
df = df.with_columns(
pl.when(stock_with_prev)
.then(polars_limit_price(pl.col("prev_close"), pct, up=True))
.otherwise(None)
.alias("limit_up_price"),
pl.when(stock_with_prev)
.then(polars_limit_price(pl.col("prev_close"), pct, up=False))
.otherwise(None)
.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:
db = repo.store.db
data_dir = repo.store.data_dir
from app.api.ext_data import _read_ext_dataframe
from app.services.ext_data import ExtConfigStore
ext_store = ExtConfigStore(data_dir)
configs = {c.id: c for c in ext_store.load_all()}
for config_id, field_name in ext_specs:
view_name = f"ext_{config_id}"
ext_col_name = f"{config_id}__{field_name}"
try:
# 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
cfg = configs.get(config_id)
if cfg:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
else:
ext_df = pl.from_arrow(db.query(
f"SELECT symbol, {quote_ident(field_name)} FROM {view_name}"
).arrow())
if not ext_df.is_empty() and "symbol" in ext_df.columns:
ext_df = (
ext_df
.select(["symbol", field_name])
.unique(subset=["symbol"], keep="last")
.rename({field_name: ext_col_name})
)
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
except Exception:
# view 不存在或字段不存在,尝试直接读 parquet
cfg = configs.get(config_id)
if cfg:
try:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
ext_df = (
ext_df
.select(["symbol", field_name])
.unique(subset=["symbol"], keep="last")
.rename({field_name: ext_col_name})
)
df = df.join(ext_df, on="symbol", how="left")
except Exception as e2:
logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
# sanitize NaN / Inf
float_cols = [c for c in df.columns if df[c].dtype.is_float()]
if float_cols:
df = df.with_columns([
pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
.then(None)
.otherwise(pl.col(c))
.alias(c)
for c in float_cols
])
# 按自选添加顺序(新加的在前)重排行
order_map = {s: i for i, s in enumerate(symbols)}
df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
df = df.sort("_sort_order").drop("_sort_order")
rows = df.to_dicts()
elapsed = (time.perf_counter() - t0) * 1000
return {"rows": rows, "as_of": str(as_of) if as_of else None,
"elapsed_ms": elapsed, "dates": quote_dates}
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
result = []
for part in ext_columns.split(","):
part = part.strip()
if "." not in part:
continue
config_id, field_name = part.split(".", 1)
config_id = config_id.strip()
field_name = field_name.strip()
if config_id and field_name and is_valid_ext_ident(config_id):
result.append((config_id, field_name))
return result