Files
tick-stock-panel/backend/app/api/screener.py
T
shy3130 5f6444277d fix(screener): 列表涨跌幅改用实时行情, 与个股详情对齐
策略结果缓存只记录「计算那一刻」的价格。盘中跑完 run_all 后, 列表里的
close/change_pct 会一直停在那时, 而点开个股详情走的是实时 enriched 缓存,
同一只票在两处显示不同涨幅。实测 002543.SZ: 13:27 扫描时 2.49%, 收盘
4.48%, 缓存里 279 行中有 271 行与实时值不一致。

后端:
- /api/screener/cached 与 /cached-result 返回前, 用当前实时快照覆盖纯展示
  字段 (open/high/low/close/prev_close/volume/amount/change_pct/
  change_amount/turnover_rate/raw_*/quote_ts)
- 指标与信号列 (ma20/score/signal_* 等) 保持计算时点值: 用新价格重算会让
  「为什么入选」不可解释
- 仅覆盖同日结果 (优先结果级 as_of, 行级 date 兜底), 历史回看不被今日行情
  污染; 实时缓存为空时原样返回, 不伪造价格
- 覆盖只发生在响应层, 不写回 strategy_cache.json (写回会让 mtime 指纹失效,
  等于每轮触发一次全量重读)

前端:
- 明细查询在交易时段 6s 重取、收盘后 60s 兜底, 让覆盖真正反映到页面

验证: 新增 4 条回归 (实时覆盖生效且指标保留/历史日期不覆盖/实时缓存空时
不变/cached-result 同口径); 后端全量 2926 passed; pnpm build 通过;
运行实例上两接口均返回实时价, 缓存文件仍保存原值。
2026-10-20 16:06:43 +08:00

1352 lines
56 KiB
Python

"""Screener API。"""
from __future__ import annotations
import contextlib
import glob as _glob
import logging
import math
import os
import re
import time
from dataclasses import asdict, replace
from datetime import date, datetime
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
from app.config import settings
from app.db_safe import is_valid_ext_ident, quote_ident
from app.services import strategy_cache, strategy_run_queue
from app.services.screener import ScreenerService
from app.strategy import config as strategy_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/screener", tags=["screener"])
class CustomRequest(BaseModel):
conditions: list[str]
order_by: Optional[str] = None
limit: int = 30
pool: Optional[list[str]] = None
as_of: Optional[date] = None
ext_columns: Optional[str] = None
asset_type: str = "stock"
class PresetRequest(BaseModel):
strategy_id: str
pool: Optional[list[str]] = None
as_of: Optional[date] = None
ext_columns: Optional[str] = None
asset_type: str = "stock"
timeframe: str = "1d"
def _safe(result_dict: dict) -> dict:
"""sanitize for JSON(NaN / Inf → None)."""
rows = result_dict.get("rows", [])
for r in rows:
for k, v in list(r.items()):
if isinstance(v, float) and not math.isfinite(v):
r[k] = None
return result_dict
def _coverage_warnings(
svc, as_of, *, engine=None, strategy_ids=None, params_map=None, overrides_map=None,
) -> list[str]:
"""数据充足性提示 (#303): enriched 覆盖低于暖机需求时给出人话警告。
advisory 元数据: 任何计算失败都静默返回 [], 绝不影响策略主流程。
引擎/服务无该方法时 (测试 Fake) 同样跳过, 与 build_shared_matrix 的
可选能力探测同风格。
"""
try:
required: int | None = None
rhb = getattr(engine, "required_history_bars", None)
if engine is not None and strategy_ids and callable(rhb):
required = rhb(strategy_ids, params_map=params_map, overrides_map=overrides_map)
cw = getattr(svc, "coverage_warnings", None)
if callable(cw):
return cw(as_of, required_bars=required)
except Exception:
logger.debug("coverage warnings unavailable", exc_info=True)
return []
def _one_word_limit_expr(status_main: str, columns: list[str]) -> Any:
required = {"open", "high", "low", "close", "status"}
if not required.issubset(columns):
import polars as pl
return pl.lit(False)
import polars as pl
return (
(pl.col("status") == status_main)
& (pl.col("close") > 0)
& (pl.col("open") == pl.col("high"))
& (pl.col("high") == pl.col("low"))
& (pl.col("low") == pl.col("close"))
).fill_null(False)
def _safe_ext_value(value: Any) -> Any:
if isinstance(value, float) and not math.isfinite(value):
return None
if isinstance(value, (date, datetime)):
return value.isoformat()
return value
# 标识符安全原语 (转义 + 白名单) 集中在 app.db_safe, 见 Issue #150 注入防护。
# ── 扩展列 value_map 缓存 ────────────────────────────────────────────
# 每次请求 _load_ext_value_maps 都会重新从磁盘读 ext parquet 并重建 {symbol: value}。
# 用底层 parquet 文件的 (路径, mtime) 签名做 memoize: 文件未变则复用上次的 map,
# parquet 被重写 (mtime 变化) 时自动失效重算。仅缓存基于 config 的快照/时序路径,
# 无 config 的 DuckDB view 回退路径不缓存 (少见)。
# 键含目标日期: 同一扩展列的「结果日分区」与「最新分区」是两个独立映射, 不可混用。
_ext_value_map_cache: dict[tuple[str, str, str], tuple[Any, dict[str, Any]]] = {}
def _ext_parquet_signature(cfg, data_dir) -> Optional[tuple]:
"""该扩展配置底层 parquet 文件的 (路径, mtime) 签名; 出错返回 None (禁用缓存)。"""
try:
from app.api.ext_data import _parquet_glob
pattern = _parquet_glob(cfg, data_dir)
files = sorted(_glob.glob(pattern, recursive=True))
if not files:
return None
return tuple((f, os.path.getmtime(f)) for f in files)
except Exception: # noqa: BLE001
return None
def _load_ext_value_maps(
repo, ext_columns: Optional[str], as_of: Optional[str] = None
) -> dict[str, dict[str, Any]]:
"""按请求加载扩展列,返回 {输出列名: {symbol: value}}。
策略结果缓存是共享文件,不能被不同 ext_columns 组合污染;因此扩展列只在
返回前通过该投影映射追加到结果副本中。
as_of 给定时, timeseries 扩展表按该日期的分区取值 —— 与策略结果同日,
保证「结果里展示的扩展值(如人气)」与「策略过滤时实际使用的值」同一口径;
该日期无分区 (扩展表上线前的历史日期等) 时回退最新分区。
as_of=None 维持"当前值"语义 (自选/个股详情/告警等实时场景), 取最新分区。
基于 config 的路径按 parquet 文件 mtime 签名 memoize, 文件未变时跳过磁盘重读。
"""
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
if not ext_specs:
return {}
import polars as pl
from app.api.ext_data import _read_ext_dataframe
from app.services.ext_data import ExtConfigStore, collapse_ext_rows_at
db = repo.store.db
data_dir = repo.store.data_dir
ext_store = ExtConfigStore(data_dir)
configs = {c.id: c for c in ext_store.load_all()}
value_maps: dict[str, dict[str, Any]] = {}
want_date = str(as_of) if as_of else None
for config_id, field_name, at in ext_specs:
out_col = f"{config_id}__{field_name}"
cfg = configs.get(config_id)
cache_key = (config_id, field_name, want_date or "", at or "")
sig = _ext_parquet_signature(cfg, data_dir) if cfg else None
try:
if cfg:
# 命中缓存 (文件签名一致) → 复用, 免去磁盘重读
cached = _ext_value_map_cache.get(cache_key)
if cached is not None and sig is not None and cached[0] == sig:
value_maps[out_col] = cached[1]
continue
if want_date and cfg.mode == "timeseries":
# 与结果同日的历史分区: 展示口径 == 过滤口径
ext_df, _ = _read_ext_dataframe(cfg, data_dir, snapshot_date=want_date)
if ext_df.is_empty():
# 该日期无分区 → 回退最新, 避免整列空白
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
else:
# 时序扩展表默认只取最新分区,避免历史分区把同一 symbol JOIN 放大。
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
else:
view_name = f"ext_{config_id}"
ext_df = pl.from_arrow(db.query(
f"SELECT symbol, {quote_ident(field_name)} FROM {view_name}"
).arrow())
if ext_df.is_empty() or "symbol" not in ext_df.columns or field_name not in ext_df.columns:
continue
# 日内多盘: @时刻 → 该时刻或之前最近一盘; 缺省 → 末盘。
# 收敛须在 select 之前 — 时间列被裁掉后 collapse 检测不到会静默回退末行
time_field = cfg.pull.time_field if cfg and cfg.pull else None
ext_df = collapse_ext_rows_at(ext_df, at, time_field).select(["symbol", field_name])
vmap = {
str(row["symbol"]): _safe_ext_value(row.get(field_name))
for row in ext_df.to_dicts()
if row.get("symbol")
}
value_maps[out_col] = vmap
if cfg and sig is not None:
_ext_value_map_cache[cache_key] = (sig, vmap)
except Exception as e: # noqa: BLE001
logger.debug("screener ext column join skipped for %s.%s: %s", config_id, field_name, e)
return value_maps
def _ext_maps_for_dates(
repo, ext_columns: Optional[str], dates
) -> dict[Optional[str], dict[str, dict[str, Any]]]:
"""按日期集合构建多份 ext 投影映射 (缓存 payload 的结果/今日曾命中可能跨日)。
无 ext_columns 时返回 {} (调用方视为"无投影")。None 键 = 无日期结果, 取最新分区。
"""
if not ext_columns:
return {}
keys = {str(d) if d else None for d in dates}
return {k: _load_ext_value_maps(repo, ext_columns, as_of=k) for k in keys}
def _maps_for_date(
maps_by_date: dict[Optional[str], dict[str, dict[str, Any]]], date
) -> dict[str, dict[str, Any]]:
key = str(date) if date else None
return maps_by_date.get(key) or maps_by_date.get(None) or {}
def _row_with_ext(row: dict, ext_values: dict[str, dict[str, Any]], symbol: Optional[str] = None) -> dict:
next_row = dict(row)
sym = symbol or next_row.get("symbol")
for out_col, value_map in ext_values.items():
next_row[out_col] = value_map.get(str(sym)) if sym else None
return next_row
def _rows_with_ext(rows: list[dict], ext_values: dict[str, dict[str, Any]]) -> list[dict]:
if not ext_values:
return rows
return [_row_with_ext(r, ext_values) for r in rows]
def _result_with_ext(result_dict: dict, ext_values: dict[str, dict[str, Any]]) -> dict:
if not ext_values:
return result_dict
return {**result_dict, "rows": _rows_with_ext(result_dict.get("rows", []), ext_values)}
def _results_with_ext(results: dict[str, dict], ext_values: dict[str, dict[str, Any]]) -> dict[str, dict]:
if not ext_values:
return results
return {sid: _result_with_ext(r, ext_values) for sid, r in results.items()}
def _cache_payload_with_ext(
cached: dict, maps_by_date: dict[Optional[str], dict[str, dict[str, Any]]]
) -> dict:
"""把 ext 投影按各自日期合并进缓存 payload (结果行与今日曾命中行各用其 date)。"""
if not maps_by_date:
return cached
payload = dict(cached)
results = cached.get("results") or {}
payload["results"] = {
sid: _result_with_ext(r, _maps_for_date(maps_by_date, r.get("as_of")))
if isinstance(r, dict) else r
for sid, r in results.items()
}
ever_rows = cached.get("today_ever_rows")
if isinstance(ever_rows, dict):
enriched_ever: dict[str, dict[str, dict]] = {}
for sid, sym_map in ever_rows.items():
if not isinstance(sym_map, dict):
continue
enriched_ever[sid] = {
sym: _row_with_ext(row, _maps_for_date(maps_by_date, row.get("date")), symbol=sym)
for sym, row in sym_map.items()
if isinstance(row, dict)
}
payload["today_ever_rows"] = enriched_ever
return payload
# 被实时行情覆盖的展示字段。
#
# 策略结果缓存只记录「计算那一刻」的价格: 盘中运行 run_all 后, 列表里的
# close/change_pct 会一直停在那时, 而点开详情走的是实时 enriched 缓存 ——
# 同一个票在两个地方显示不同涨幅 (实测 13:27 的扫描结果到收盘差了 2 个点)。
# 这里在返回前用当前实时快照覆盖这些纯展示字段; 指标列 (ma20/score/signal_*)
# 保持计算时点值不动, 否则等于用新价格重算了一个过期策略结果。
_LIVE_QUOTE_FIELDS: tuple[str, ...] = (
"open", "high", "low", "close",
"prev_close", "volume", "amount",
"change_pct", "change_amount", "turnover_rate",
"raw_close", "raw_high", "raw_low", "quote_ts",
)
def _live_quote_overlay(repo) -> tuple[dict[str, dict[str, Any]], str | None]:
"""当前实时 enriched 快照的展示字段: ({symbol: row}, 日期字符串)。
缓存为空 (启动预热中) 或 repo 不支持该接口时返回空映射, 调用方原样返回
策略结果 —— 拿不到实时数据时不能伪造新价格。
"""
loader = getattr(repo, "get_enriched_latest", None)
if not callable(loader):
return {}, None
try:
frame, cache_date = loader()
except Exception: # noqa: BLE001 — 展示层增强绝不打断缓存读取
logger.debug("live quote overlay unavailable", exc_info=True)
return {}, None
if frame is None or frame.is_empty() or cache_date is None:
return {}, None
columns = [c for c in ("symbol", *_LIVE_QUOTE_FIELDS) if c in frame.columns]
if "symbol" not in columns:
return {}, None
return {
str(row["symbol"]): row
for row in frame.select(columns).to_dicts()
}, str(cache_date)
def _quote_date_matches(row: dict, live_date: str | None, result_as_of: Any = None) -> bool:
"""结果日期与实时快照同日才可覆盖。
历史日期结果 (例如回看昨天/上周) 必须保留当时价格, 不能用今天的行情覆盖。
优先用结果级 as_of (策略结果整体属于哪一天), 行级 date 作兜底 —— 个别
策略输出行不带 date 字段, 只看行日期会误覆盖历史结果。
"""
if live_date is None:
return False
for candidate in (result_as_of, row.get("date")):
if candidate is None:
continue
return str(candidate)[:10] == live_date
# 结果与行都没有日期: 视为「最新」(与 as_of=None 的缓存语义一致)
return True
def _rows_with_live_quotes(
rows: list[dict],
overlay: dict[str, dict[str, Any]],
live_date: str | None,
result_as_of: Any = None,
) -> list[dict]:
if not overlay:
return rows
merged: list[dict] = []
for row in rows:
if not isinstance(row, dict):
merged.append(row)
continue
live = overlay.get(str(row.get("symbol") or ""))
if live is None or not _quote_date_matches(row, live_date, result_as_of):
merged.append(row)
continue
next_row = dict(row)
for key, value in live.items():
if key == "symbol":
continue
next_row[key] = value
merged.append(next_row)
return merged
def _cached_with_live_quotes(request: Request) -> dict:
"""盘后缓存 + 监控实时结果 + 当前行情展示字段覆盖。
与 `_cached_with_realtime` 的区别: 后者只覆盖「加入监控的策略」(每轮行情
重算结果), 未加监控的策略仍显示计算时点价格。这里对所有策略只覆盖价格类
展示字段, 行情轮询期间列表与详情保持一致。
"""
cached = _cached_with_realtime(request)
results = cached.get("results") or {}
ever_rows = cached.get("today_ever_rows")
if not results and not ever_rows:
return cached
overlay, live_date = _live_quote_overlay(request.app.state.repo)
if not overlay:
return cached
payload = dict(cached)
payload["results"] = {
sid: (
{
**result,
"rows": _rows_with_live_quotes(
result.get("rows") or [], overlay, live_date, result.get("as_of"),
),
}
if isinstance(result, dict) else result
)
for sid, result in results.items()
}
if isinstance(ever_rows, dict):
# 「今日曾命中」行是逐行的历史快照 (各自带 date), 一律按行日期判断
payload["today_ever_rows"] = {
sid: (
{
symbol: _rows_with_live_quotes([row], overlay, live_date)[0]
for symbol, row in sym_map.items()
if isinstance(row, dict)
}
if isinstance(sym_map, dict) else sym_map
)
for sid, sym_map in ever_rows.items()
}
return payload
def _update_cache_strategy(data_dir, as_of: str, strategy_id: str, safe_data: dict) -> None:
"""单跑后更新缓存中该策略的结果,保持缓存与最新计算一致。"""
from app.services import strategy_cache
cached = strategy_cache.read_cache(data_dir)
if cached and cached.get("as_of") == as_of:
# read_cache 返回进程内共享镜像, 先拷贝再改 (write_cache 的 read-merge-write
# 以这份拷贝为准, 不污染镜像)
results = dict(cached.get("results") or {})
results[strategy_id] = {
"total": safe_data.get("total", 0),
"as_of": as_of,
"rows": safe_data.get("rows", []),
}
if safe_data.get("warnings"):
# 数据不足提示 (#303) 随缓存下发 (get_cached 原样读出),
# 单跑刷新不得冲掉 run_all 写入的提示
results[strategy_id]["warnings"] = safe_data["warnings"]
strategy_cache.write_cache(data_dir, as_of, results)
@router.get("/strategies")
def strategies(
request: Request,
asset_type: str = Query("stock"),
timeframe: str = Query("1d"),
):
"""兼容策略清单端点;唯一数据源为 StrategyEngine。"""
data_dir = request.app.state.repo.store.data_dir
engine = getattr(request.app.state, "strategy_engine", None)
if engine is None:
raise HTTPException(status_code=503, detail="策略引擎未初始化")
presets = []
for meta in engine.list_strategies():
if meta.get("research_only"):
continue
if asset_type not in meta.get("asset_types", ["stock"]):
continue
if timeframe not in meta.get("timeframes", ["1d"]):
continue
sid = meta["id"]
overrides = strategy_config.load_override(data_dir, sid)
presets.append({
**meta,
"name": overrides.get("name") or meta["name"],
"description": overrides.get("description") or meta.get("description", ""),
})
return {"presets": presets, "load_errors": engine.load_errors()}
@router.post("/run")
def run_custom(req: CustomRequest, request: Request):
repo = request.app.state.repo
svc = ScreenerService(repo, asset_type=req.asset_type)
as_of = req.as_of or svc.latest_date()
if not as_of:
raise HTTPException(status_code=400,
detail="无可用数据日期 — enriched 表为空,请先运行盘后管道")
result = svc.run(
as_of=as_of,
conditions=req.conditions,
order_by=req.order_by,
limit=req.limit,
pool=req.pool,
)
safe_data = _safe(asdict(result))
warnings = _coverage_warnings(svc, as_of)
if warnings:
safe_data["warnings"] = warnings
# 投影取结果日分区 (与过滤同口径), 而非最新分区
ext_values = _load_ext_value_maps(repo, req.ext_columns, as_of=str(as_of))
return _result_with_ext(safe_data, ext_values)
@router.post("/run_preset")
def run_preset(req: PresetRequest, request: Request):
repo = request.app.state.repo
svc = ScreenerService(repo, asset_type=req.asset_type)
as_of = req.as_of or svc.latest_date()
if not as_of:
raise HTTPException(status_code=400, detail="无可用数据日期")
# 加载用户保存的策略配置
data_dir = request.app.state.repo.store.data_dir
# ext 投影取结果日分区: 结果表里展示的扩展值(如人气) 必须与叠加条件过滤时用的同一天
ext_values = _load_ext_value_maps(repo, req.ext_columns, as_of=str(as_of))
overrides = strategy_config.load_override(data_dir, req.strategy_id)
engine = getattr(request.app.state, "strategy_engine", None)
if not engine:
raise HTTPException(status_code=404, detail=f"策略引擎未初始化或策略 {req.strategy_id} 不存在")
try:
if not engine.has(req.strategy_id):
raise ValueError(f"unknown strategy: {req.strategy_id}")
if engine.get(req.strategy_id).meta.get("research_only"):
raise ValueError(f"unknown strategy: {req.strategy_id}")
params = dict(overrides.get("params") or {})
context = svc.build_strategy_context(
engine,
as_of,
[req.strategy_id],
timeframe=req.timeframe,
params_map={req.strategy_id: params},
overrides_map={req.strategy_id: overrides or {}},
)
result = engine.run(
req.strategy_id,
context,
pool=req.pool,
params=params,
overrides=overrides or None,
)
except ValueError as e:
status_code = 404 if "unknown strategy" in str(e) else 400
raise HTTPException(status_code=status_code, detail=str(e)) from e
safe_data = _safe(asdict(result))
if req.timeframe == "1d":
# 数据不足提示随结果返回并写入盘后缓存 (#303); 分钟周期结果不写入盘后缓存
# (strategy_cache 是日线语义, as_of/updated_at 混入分钟结果会污染页面秒加载路径),
# 分钟策略数据源也与日线 enriched 历史无关, 不提示。
warnings = _coverage_warnings(
svc, as_of, engine=engine, strategy_ids=[req.strategy_id],
params_map={req.strategy_id: params},
overrides_map={req.strategy_id: overrides or {}},
)
if warnings:
safe_data["warnings"] = warnings
_update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
return _result_with_ext(safe_data, ext_values)
def _cached_with_realtime(request: Request) -> dict:
"""读取盘后缓存,并用监控引擎的实时结果覆盖同策略。"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is None:
cached = {"as_of": None, "results": {}, "updated_at": None}
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if monitor_engine is not None:
realtime_results = monitor_engine.latest_strategy_results()
if realtime_results:
results = dict(cached.get("results") or {})
results.update(realtime_results)
cached = dict(cached)
cached["results"] = results
# 有实时数据时, 以最新时间戳为准
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
return cached
@router.get("/cached")
def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
- 价格类展示字段统一用当前实时快照覆盖 (同日才覆盖), 避免列表显示扫描时刻
价格、详情显示实时价格造成的"同一只票两个涨幅"。
"""
cached = _cached_with_live_quotes(request)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") is None:
return {"as_of": None, "results": {}, "updated_at": None}
# 结果与「今日曾命中」可能跨日期 (盘后缓存 + 监控实时叠加) → 按各自日期取
# ext 投影, 保证展示的扩展值与结果计算/过滤所用同日。
result_dates = {
r.get("as_of") for r in (cached.get("results") or {}).values()
if isinstance(r, dict)
}
ever_rows = cached.get("today_ever_rows")
if isinstance(ever_rows, dict):
for sym_map in ever_rows.values():
if isinstance(sym_map, dict):
result_dates.update(
row.get("date") for row in sym_map.values() if isinstance(row, dict)
)
result_dates.add(None) # 无日期的结果 → 最新分区兜底
maps_by_date = _ext_maps_for_dates(request.app.state.repo, ext_columns, result_dates)
return _cache_payload_with_ext(cached, maps_by_date)
@router.get("/cached-summary")
def get_cached_summary(request: Request):
"""返回策略卡片所需的轻量摘要,不序列化股票明细。"""
cached = _cached_with_realtime(request)
results = cached.get("results") or {}
summary = {
sid: {
"total": int(result.get("total") or 0),
"as_of": result.get("as_of"),
# 渐进式 run_all 写入的计算时间戳; 监控实时叠加/旧缓存无此字段 → None,
# 前端视为新鲜 (有值即为最新一轮实时结果)
"computed_at": result.get("computed_at"),
}
for sid, result in results.items()
if isinstance(result, dict)
}
cached_as_of = cached.get("as_of")
ever_rows = cached.get("today_ever_rows") or {}
ever_counts = {}
for sid, result in results.items():
if not isinstance(result, dict) or result.get("as_of") != cached_as_of:
continue
current_symbols = {
str(row["symbol"])
for row in result.get("rows") or []
if isinstance(row, dict) and row.get("symbol")
}
ever_counts[sid] = len(set((ever_rows.get(sid) or {}).keys()) | current_symbols)
# 分钟策略结果不落 strategy_cache (日线语义), 渐进式转后台算完后在这里
# 以 handle 快照合入 (仅 as_of 与日线缓存同日的), 前端轮询本端点点亮卡片。
minute_pending: list[str] = []
for m in strategy_run_queue.MANAGER.minute_summaries():
if m.get("as_of") != cached_as_of:
continue
for sid, item in (m.get("results") or {}).items():
summary[sid] = item
minute_pending.extend(m.get("pending") or [])
return {
"as_of": cached_as_of,
"results": summary,
"today_ever_counts": ever_counts,
"updated_at": cached.get("updated_at"),
"minute_pending": minute_pending,
}
@router.get("/cached-result/{strategy_id}")
def get_cached_result(
strategy_id: str,
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""按需返回单个策略的完整明细及其今日失效行。
与 /cached 一致: 价格类展示字段用当前实时快照覆盖 (同日才覆盖), 保证
详情里的涨跌幅与列表、以及个股详情弹窗同源。
"""
cached = _cached_with_live_quotes(request)
raw_result = (cached.get("results") or {}).get(strategy_id)
if not isinstance(raw_result, dict):
return {
"result": None,
"today_ever_rows": None,
"strategy_ids_by_symbol": {},
"updated_at": cached.get("updated_at"),
}
# 结果行按该策略结果的 as_of 日取值 (与过滤同口径); 今日曾命中行按行日期取值
repo = request.app.state.repo
result_date = raw_result.get("as_of")
ext_values = _load_ext_value_maps(repo, ext_columns, as_of=str(result_date) if result_date else None)
result = {
"as_of": raw_result.get("as_of"),
"strategy": strategy_id,
"rows": _rows_with_ext(raw_result.get("rows") or [], ext_values),
"total": int(raw_result.get("total") or 0),
"elapsed_ms": 0.0,
}
ever_rows = None
if cached.get("as_of") == result["as_of"]:
strategy_ever_rows = (cached.get("today_ever_rows") or {}).get(strategy_id)
if isinstance(strategy_ever_rows, dict):
ever_maps = _ext_maps_for_dates(
repo, ext_columns,
{row.get("date") for row in strategy_ever_rows.values() if isinstance(row, dict)}
| {result_date},
)
ever_rows = {
symbol: _row_with_ext(row, _maps_for_date(ever_maps, row.get("date")), symbol=symbol)
for symbol, row in strategy_ever_rows.items()
if isinstance(row, dict)
}
selected_symbols = {
str(row["symbol"])
for row in raw_result.get("rows") or []
if isinstance(row, dict) and row.get("symbol")
}
strategy_ids_by_symbol: dict[str, list[str]] = {symbol: [] for symbol in selected_symbols}
for sid, cached_result in (cached.get("results") or {}).items():
if not isinstance(cached_result, dict) or cached_result.get("as_of") != result["as_of"]:
continue
for row in cached_result.get("rows") or []:
symbol = str(row.get("symbol")) if isinstance(row, dict) and row.get("symbol") else None
if symbol in strategy_ids_by_symbol:
strategy_ids_by_symbol[symbol].append(sid)
return {
"result": result,
"today_ever_rows": ever_rows,
"strategy_ids_by_symbol": strategy_ids_by_symbol,
"updated_at": cached.get("updated_at"),
}
@router.get("/market-snapshot")
def market_snapshot(request: Request):
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
import polars as pl
repo = request.app.state.repo
svc = ScreenerService(repo)
as_of = svc.latest_date()
if not as_of:
return {"as_of": None, "rows": []}
df = svc._load_enriched_for_date(as_of)
if df.is_empty():
return {"as_of": str(as_of), "rows": []}
if "close" in df.columns and "total_shares" in df.columns and "market_cap" not in df.columns:
df = df.with_columns((pl.col("close") * pl.col("total_shares")).alias("market_cap"))
if "close" in df.columns and "float_shares" in df.columns and "float_market_cap" not in df.columns:
df = df.with_columns((pl.col("close") * pl.col("float_shares")).alias("float_market_cap"))
cols = [
"symbol", "name", "close", "change_pct", "amount", "volume",
"turnover_rate", "vol_ratio_5d", "total_shares", "float_shares",
"market_cap", "float_market_cap", "consecutive_limit_ups",
]
df = df.select([c for c in cols if c in df.columns])
rows = df.to_dicts()
for r in rows:
for k, v in list(r.items()):
if isinstance(v, float) and not math.isfinite(v):
r[k] = None
return {"as_of": str(as_of), "rows": rows}
def _run_all_progressive(
*,
repo,
engine,
svc: ScreenerService,
as_of,
asset_type: str,
timeframe: str,
all_ids: list[str],
params_map: dict,
overrides_map: dict,
first_return_s: float,
t_total: float,
) -> dict:
"""run_all 渐进式执行: 快策略随响应先返回, 慢策略后台算完逐个落缓存。
执行全程在单飞执行器里 (见 services/strategy_run_queue.py): 相同请求
搭车现有执行, 不同请求排队; HTTP 侧只轮询状态快照到首返时限。
"""
data_dir = repo.store.data_dir
key = (asset_type, timeframe, str(as_of), tuple(sorted(all_ids)))
ordered_ids = strategy_run_queue.order_strategy_ids(
all_ids, strategy_run_queue.load_run_timings(data_dir)
)
def job(handle: strategy_run_queue.StrategyRunHandle) -> None:
context = svc.build_strategy_context(
engine,
as_of,
ordered_ids,
timeframe=timeframe,
params_map=params_map,
overrides_map=overrides_map,
)
# 逐策略 run_all 不会把矩阵回写 context.market → 每个矩阵策略都会重建
# 全市场矩阵 (小服务器上单次数秒到十余秒)。这里按字段并集一次建好复用;
# FakeEngine 等无该方法的实现跳过 (保持旧行为)。
if getattr(context, "market", None) is None:
build_matrix = getattr(engine, "build_shared_matrix", None)
if callable(build_matrix):
matrix = build_matrix(
context,
[(sid, engine.get(sid)) for sid in ordered_ids],
params_map,
overrides_map,
)
if matrix is not None:
context = replace(context, market=matrix)
all_results: dict[str, dict] = {}
elapsed_map: dict[str, float] = {}
for sid in ordered_ids:
t0 = time.perf_counter()
# 逐策略隔离: 单个策略崩溃 (如自定义代码的数据类型错误) 只记
# 错误跳过, 不让整批剩余策略陪葬 — 其余策略照常算完落缓存。
try:
single = engine.run_all(
context,
params_map=params_map,
overrides_map=overrides_map,
strategy_ids=[sid],
parallel=False,
)
result = single[sid]
except Exception as e:
logger.warning("run_all: 策略 %s 执行失败, 跳过: %s", sid, e, exc_info=True)
handle.fail_one(sid, str(e))
continue
payload = {
"total": result.total,
"as_of": str(as_of),
"rows": _safe(asdict(result)).get("rows", []),
"computed_at": int(time.time() * 1000),
}
if timeframe == "1d":
w = _coverage_warnings(
svc, as_of, engine=engine, strategy_ids=[sid],
params_map=params_map, overrides_map=overrides_map,
)
if w:
payload["warnings"] = w
all_results[sid] = payload
elapsed_map[sid] = (time.perf_counter() - t0) * 1000
# 逐策略增量落盘 (write_cache 同日按 sid 合并), 前端轮询即可逐个看到。
# 分钟结果不落盘 (strategy_cache 为日线语义), 改经 handle 快照合入 summary
if timeframe == "1d":
try:
strategy_cache.write_cache(data_dir, str(as_of), {sid: payload})
except Exception:
logger.warning("run_all 渐进写入缓存失败: %s", sid, exc_info=True)
handle.complete(sid, {k: v for k, v in payload.items() if k != "rows"})
# 收尾: 与旧版口径一致的整体重写 + 耗时落盘供下次排序 (分钟不落盘)
if all_results and timeframe == "1d":
with contextlib.suppress(Exception):
strategy_cache.write_cache(data_dir, str(as_of), all_results)
strategy_run_queue.record_run_timings(data_dir, elapsed_map)
handle = strategy_run_queue.MANAGER.get_or_submit(key, ordered_ids, job)
deadline = time.perf_counter() + first_return_s
snap = handle.snapshot()
while not snap["done"] and time.perf_counter() < deadline:
time.sleep(0.2)
snap = handle.snapshot()
done_results = snap["results"]
if snap["error"] and not done_results:
raise HTTPException(status_code=500, detail=snap["error"])
logger.info(
"run_all: first return %.1fms (%d done, %d pending)",
(time.perf_counter() - t_total) * 1000,
len(done_results),
len(snap["pending"]),
)
return {
"as_of": str(as_of),
"results": done_results,
"pending": snap["pending"],
"errors": snap["errors"],
"complete": snap["done"] and not snap["error"],
"error": snap["error"],
"started_at": snap["started_at_ms"],
}
@router.post("/run_all")
def run_all(request: Request, body: Optional[dict] = None):
"""批量运行指定策略;注册、路由和执行均由 StrategyEngine 负责。"""
from datetime import date as date_type
t_total = time.perf_counter()
body = body or {}
repo = request.app.state.repo
asset_type = str(body.get("asset_type") or "stock")
timeframe = str(body.get("timeframe") or "1d")
svc = ScreenerService(repo, asset_type=asset_type)
engine = getattr(request.app.state, "strategy_engine", None)
if engine is None:
raise HTTPException(status_code=503, detail="策略引擎未初始化")
# 解析日期
raw_date = body.get("as_of")
if raw_date:
# 与 /custom、/preset 的 `as_of: date` 同口径: 只收 ISO 日期字符串。
# 非字符串原样透传会让 str(as_of) 把 "20260904" 之类写进 strategy_cache.json,
# 与其它入口写的 "2026-09-04" 不是同一格式, 后续按 as_of 比对缓存永远失配。
if not isinstance(raw_date, str):
raise HTTPException(status_code=400, detail="as_of 必须是 YYYY-MM-DD 日期字符串")
try:
as_of = date_type.fromisoformat(raw_date)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"日期格式错误: {e}") from e
else:
as_of = svc.latest_date()
if not as_of:
return {"as_of": None, "results": {}}
data_dir = request.app.state.repo.store.data_dir
requested_ids = body.get("strategy_ids")
skipped_unknown: list[str] = []
if requested_ids and isinstance(requested_ids, list):
all_ids = [str(sid) for sid in requested_ids]
# 宽容降级: 策略池可能残留已不存在的策略 ID (如内置策略移除后的
# 旧数据目录) —— 跳过未知项继续跑, 响应带回 skipped_unknown 供前端
# 清池; 全部未知才整体 404 (避免空跑被当成成功)。
skipped_unknown = [
sid
for sid in all_ids
if not engine.has(sid) or engine.get(sid).meta.get("research_only")
]
if skipped_unknown:
if len(skipped_unknown) == len(all_ids):
raise HTTPException(status_code=404, detail=f"unknown strategies: {skipped_unknown}")
known = [sid for sid in all_ids if sid not in set(skipped_unknown)]
logger.warning("run_all: 跳过 %d 个不存在/不可用的策略 ID: %s", len(skipped_unknown), skipped_unknown)
all_ids = known
else:
all_ids = [
meta["id"]
for meta in engine.list_strategies()
if not meta.get("research_only")
and asset_type in meta.get("asset_types", ["stock"])
and timeframe in meta.get("timeframes", ["1d"])
]
if not all_ids:
return {"as_of": str(as_of), "results": {}}
# 批量预加载所有 override 配置
t0 = time.perf_counter()
all_overrides = strategy_config.list_overrides(data_dir)
logger.info("run_all: list_overrides took %.1fms (%d overrides)", (time.perf_counter() - t0) * 1000, len(all_overrides))
params_map = {
sid: dict((all_overrides.get(sid) or {}).get("params") or {})
for sid in all_ids
}
overrides_map = {sid: all_overrides.get(sid, {}) for sid in all_ids}
# 渐进式返回 (页面首屏路径): 按历史耗时升序执行, 首返时限内算完的随响应
# 返回, 慢策略转后台继续算并逐个写入策略缓存, 前端轮询 cached-summary 点亮。
# 日线与分钟都启用: 分钟策略结果不落盘, 后台算完经 handle 快照合入
# cached-summary (见 get_cached_summary) — 请求不再整段占用线程等计算。
first_return_s = settings.strategy_run_all_first_return_s
if body.get("summary_only") and first_return_s > 0:
progressive = _run_all_progressive(
repo=repo,
engine=engine,
svc=svc,
as_of=as_of,
asset_type=asset_type,
timeframe=timeframe,
all_ids=all_ids,
params_map=params_map,
overrides_map=overrides_map,
first_return_s=first_return_s,
t_total=t_total,
)
if skipped_unknown:
progressive["skipped_unknown"] = skipped_unknown
return progressive
try:
context = svc.build_strategy_context(
engine,
as_of,
all_ids,
timeframe=timeframe,
params_map=params_map,
overrides_map=overrides_map,
)
engine_results = engine.run_all(
context,
params_map=params_map,
overrides_map=overrides_map,
strategy_ids=all_ids,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
results: dict[str, dict] = {}
for sid, result in engine_results.items():
safe_rows = _safe(asdict(result)).get("rows", [])
results[sid] = {
"total": result.total,
"as_of": str(as_of),
"rows": safe_rows,
}
if timeframe == "1d":
w = _coverage_warnings(
svc, as_of, engine=engine, strategy_ids=[sid],
params_map=params_map, overrides_map=overrides_map,
)
if w:
results[sid]["warnings"] = w
elapsed = (time.perf_counter() - t_total) * 1000
logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids))
# 写入策略缓存 (供页面秒加载); 分钟周期结果不落盘 (日线语义缓存)
if results and timeframe == "1d":
try:
strategy_cache.write_cache(data_dir, str(as_of), results)
except Exception: # noqa: BLE001
pass
if body.get("summary_only"):
return {
"as_of": str(as_of),
"results": {
sid: {"total": result["total"], "as_of": result["as_of"]}
for sid, result in results.items()
},
"skipped_unknown": skipped_unknown,
}
# ext 投影取本次结果日分区 (与过滤同口径)
ext_values = _load_ext_value_maps(repo, body.get("ext_columns"), as_of=str(as_of))
return {
"as_of": str(as_of),
"results": _results_with_ext(results, ext_values),
"skipped_unknown": skipped_unknown,
}
@router.get("/limit-ladder")
def limit_ladder(
request: Request,
as_of: Optional[date] = None,
direction: str = Query("up", description="up=涨停梯队 | down=跌停梯队"),
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""连板/连跌梯队 — 按连板数分组, 含三状态。
返回: tiers = [{ boards, count, stocks: [{symbol,name,change_pct,status,...}] }]
direction=up (默认):
status: limit_up=涨停 | broken=炸板(摸板未封) | failed=断板(晋级失败)
direction=down:
status: limit_down=跌停 | recovery=翘板(跌停后回升,含收阳条件) | failed=止跌(昨日跌停今日未跌停也未翘板)
ext_columns: 动态 JOIN 扩展数据, 如 "concept.concept,industry.industry"
"""
import polars as pl
is_down = direction == "down"
# 按 direction 参数化字段映射
if is_down:
sig_col = "signal_limit_down"
consec_col = "consecutive_limit_downs"
broken_col = "signal_limit_down_recovery"
status_main, status_broken, status_failed = "limit_down", "recovery", "failed"
else:
sig_col = "signal_limit_up"
consec_col = "consecutive_limit_ups"
broken_col = "signal_broken_limit_up"
status_main, status_broken, status_failed = "limit_up", "broken", "failed"
repo = request.app.state.repo
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
if not as_of:
raise HTTPException(status_code=400, detail="无可用数据日期")
df = svc._load_enriched_for_date(as_of)
if df.is_empty():
return {"as_of": str(as_of), "tiers": [], "counts": {"up": 0, "down": 0}}
# 双方向涨跌停计数(不论当前 direction, 前端始终同时显示)
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数)
depth_svc_global = getattr(request.app.state, "depth_service", None)
fake_up = 0
fake_down = 0
sealed_up_ready = False
sealed_down_ready = False
if depth_svc_global:
up_map = depth_svc_global.get_sealed_map(as_of, is_down=False)
down_map = depth_svc_global.get_sealed_map(as_of, is_down=True)
sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of)
sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw
count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw
# 双方向 sealed 明细(供前端弹窗同时显示涨跌停)
def _count_sealed(m: dict, ready: bool):
if not m or not ready:
return {"real": 0, "fake": 0, "pending": 0}
real = sum(1 for v in m.values() if v.get("sealed") is True)
fake = sum(1 for v in m.values() if v.get("sealed") is False)
pending = sum(1 for v in m.values() if v.get("sealed") is None)
return {"real": real, "fake": fake, "pending": pending}
sealed_counts_up = _count_sealed(up_map, sealed_up_ready)
sealed_counts_down = _count_sealed(down_map, sealed_down_ready)
# 加载前一日的 prev consecutive_limit_ups/downs
# 窄读: 仅取前一交易日的 [symbol, consec_col] 两列 (存储列, 直接谓词下推读 parquet),
# 替代旧的 range(1,10) 循环逐日 _load_enriched_for_date 全量指标重算 (最坏 9× 全市场重算)。
prev_consec: pl.DataFrame = svc.load_prior_consecutive(as_of, consec_col)
if not prev_consec.is_empty():
df = df.join(prev_consec, on="symbol", how="left")
else:
df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("prev_consec"))
# 表达式
is_limit = pl.col(sig_col).fill_null(False) if sig_col in df.columns else pl.lit(False)
is_broken = pl.col(broken_col).fill_null(False) if broken_col in df.columns else pl.lit(False)
consec = pl.col(consec_col).fill_null(0) if consec_col in df.columns else pl.lit(0)
prev_c = pl.col("prev_consec").fill_null(0)
# 计算 status + boards (结构涨跌停对称, 仅字段与字面量不同)
is_failed = ~is_limit & ~is_broken & (prev_c > 0)
df = df.with_columns([
pl.when(is_limit).then(pl.lit(status_main))
.when(is_broken).then(pl.lit(status_broken))
.when(is_failed).then(pl.lit(status_failed))
.otherwise(None).alias("status"),
pl.when(is_limit).then(consec)
.when(is_broken | is_failed).then(prev_c + 1)
.otherwise(0).cast(pl.UInt32).alias("boards"),
])
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ──
# 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图)
# 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状
depth_svc = getattr(request.app.state, "depth_service", None)
sealed_ready = False
sealed_age: float | None = None
if depth_svc:
# 复用上方双方向计数已读取的 sealed map: 同一请求、同一 as_of、同一对象,
# 不再第三次读取 (内存路径含全量浅拷贝, parquet 路径含整文件读)。
sealed_map = down_map if is_down else up_map
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
if sealed_map:
# 构建 sealed 列(symbol → sealed bool, vol)
sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()}
sym_vol = {s: v.get("vol") for s, v in sealed_map.items()}
# JOIN sealed: 对每只 status=main 的票, 看 sealed 值
sealed_rows = pl.DataFrame({
"symbol": list(sym_sealed.keys()),
"_sealed": list(sym_sealed.values()),
"_sealed_vol": list(sym_vol.values()),
}) if sym_sealed else pl.DataFrame()
if not sealed_rows.is_empty():
df = df.join(sealed_rows, on="symbol", how="left")
# 假涨停(main 状态但 sealed=False)→ 降级为 broken
df = df.with_columns(
pl.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_not_null()
& (pl.col("_sealed") == False) # noqa: E712
).then(pl.lit(status_broken))
.otherwise(pl.col("status")).alias("status"),
# sealed_status: real/fake/pending/null
pl.when(
(pl.col("status") == status_main)
& (pl.col("_sealed") == True) # noqa: E712
).then(pl.lit("real"))
.when(
(pl.col("_sealed") == False) # noqa: E712
).then(pl.lit("fake"))
.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_null()
).then(pl.lit("pending"))
.otherwise(None).alias("sealed_status"),
pl.col("_sealed_vol").alias("sealed_vol"),
).drop(["_sealed", "_sealed_vol"])
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
df = df.with_columns(_one_word_limit_expr(status_main, df.columns).alias("is_one_word"))
# 动态 JOIN 扩展数据
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
ext_col_names: list[str] = []
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, collapse_ext_rows_at
ext_store = ExtConfigStore(data_dir)
configs = {c.id: c for c in ext_store.load_all()}
def _dedup_ext(
frame: pl.DataFrame, field: str, out_col: str,
at: str | None, time_field: str | None,
) -> pl.DataFrame | None:
"""(symbol, 字段) 两列收敛为每标的一行; @时刻取该时刻或之前最近一盘
(与 _load_ext_value_maps 同口径), 缺列时返回 None。"""
if frame.is_empty() or "symbol" not in frame.columns or field not in frame.columns:
return None
# 收敛在 select 之前: 时间列被裁掉后 collapse 会静默回退末行
return (
collapse_ext_rows_at(frame, at, time_field)
.select(["symbol", field])
.rename({field: out_col})
)
for config_id, field_name, at in ext_specs:
view_name = f"ext_{config_id}"
ext_col_name = f"{config_id}__{field_name}"
try:
# 扩展时序数据必须只取最新分区; 否则一个 symbol 会按历史分区数被 JOIN 放大
# (ext_{id} 视图覆盖 timeseries/**), 与自选列表同口径。
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())
time_field = cfg.pull.time_field if cfg and cfg.pull else None
joined = _dedup_ext(ext_df, field_name, ext_col_name, at, time_field)
if joined is not None:
df = df.join(joined, on="symbol", how="left")
ext_col_names.append(ext_col_name)
except Exception:
cfg = configs.get(config_id)
if cfg:
try:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
time_field = cfg.pull.time_field if cfg.pull else None
joined = _dedup_ext(ext_df, field_name, ext_col_name, at, time_field)
if joined is not None:
df = df.join(joined, on="symbol", how="left")
ext_col_names.append(ext_col_name)
except Exception:
pass
# 选择输出列
cols = ["symbol", "name", "close", "change_pct", "boards", "status", consec_col, "sealed_status", "sealed_vol", "is_one_word"] + ext_col_names
df = df.select([c for c in cols if c in df.columns])
# 排序: boards 降序, status 按主状态→炸/翘→断/止
status_order = pl.when(pl.col("status") == status_main).then(0)
status_order = status_order.when(pl.col("status") == status_broken).then(1)
status_order = status_order.otherwise(2).alias("_status_order")
df = df.with_columns(status_order).sort(["boards", "_status_order"], descending=[True, False]).drop("_status_order")
rows = df.to_dicts()
for r in rows:
for k, v in list(r.items()):
if isinstance(v, float) and not math.isfinite(v):
r[k] = None
# 按 boards 分组
tiers: dict[int, list] = {}
for r in rows:
n = int(r.get("boards") or 0)
tiers.setdefault(n, []).append(r)
tier_list = [
{"boards": n, "count": len(stocks), "stocks": stocks}
for n, stocks in sorted(tiers.items(), key=lambda x: -x[0])
]
return {
"as_of": str(as_of),
"tiers": tier_list,
"counts": {"up": count_up, "down": count_down},
"counts_raw": {"up": count_up_raw, "down": count_down_raw},
"sealed_ready": sealed_ready,
"sealed_age": round(sealed_age, 0) if sealed_age is not None else None,
"sealed_counts": {
"real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"),
"fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"),
"pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"),
},
"sealed_counts_up": sealed_counts_up,
"sealed_counts_down": sealed_counts_down,
}
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str, str | None]]:
"""解析 'config_id1.field1@09:20,config_id2.field2' 为 [(config_id, field_name, at), ...]。
可选 "@HH:MM[:SS]" 后缀: 日内多盘表取「该时刻或之前最近一盘」的值;
时刻非法时整条 spec 丢弃 (fail-closed, 不静默回退末盘造成口径混淆)。
"""
from app.services.ext_data import norm_ext_snapshot_at
result = []
for part in ext_columns.split(","):
part = part.strip()
if "." not in part:
continue
config_id, field_name = part.split(".", 1)
at: str | None = None
if "@" in field_name:
field_name, at_raw = field_name.rsplit("@", 1)
try:
at = norm_ext_snapshot_at(at_raw)
except ValueError:
continue
config_id = config_id.strip()
field_name = field_name.strip()
if not config_id or not field_name:
continue
if not is_valid_ext_ident(config_id) or "\x00" in field_name:
continue
result.append((config_id, field_name, at))
return result