"""Screener API。""" from __future__ import annotations import logging import math import re import time from dataclasses import asdict from datetime import date, datetime from typing import Any, Optional from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel from app.services.screener import PRESET_STRATEGIES, ScreenerService from app.services import strategy_cache 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 class PresetRequest(BaseModel): strategy_id: str pool: Optional[list[str]] = None as_of: Optional[date] = None ext_columns: Optional[str] = None 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 _EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$") 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 def _quote_ident(name: str) -> str: return '"' + name.replace('"', '""') + '"' def _load_ext_value_maps(repo, ext_columns: Optional[str]) -> dict[str, dict[str, Any]]: """按请求加载扩展列,返回 {输出列名: {symbol: value}}。 策略结果缓存是共享文件,不能被不同 ext_columns 组合污染;因此扩展列只在 返回前通过该投影映射追加到结果副本中。 """ 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 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]] = {} for config_id, field_name in ext_specs: out_col = f"{config_id}__{field_name}" cfg = configs.get(config_id) try: if cfg: # 时序扩展表只取最新分区,避免历史分区把同一 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 ext_df = ext_df.select(["symbol", field_name]).unique(subset=["symbol"], keep="last") value_maps[out_col] = { str(row["symbol"]): _safe_ext_value(row.get(field_name)) for row in ext_df.to_dicts() if row.get("symbol") } 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 _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, ext_values: dict[str, dict[str, Any]]) -> dict: if not ext_values: return cached payload = dict(cached) payload["results"] = _results_with_ext(cached.get("results", {}), ext_values) 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, ext_values, symbol=sym) for sym, row in sym_map.items() if isinstance(row, dict) } payload["today_ever_rows"] = enriched_ever 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: results = cached.get("results", {}) results[strategy_id] = { "total": safe_data.get("total", 0), "as_of": as_of, "rows": safe_data.get("rows", []), } strategy_cache.write_cache(data_dir, as_of, results) @router.get("/strategies") def strategies(request: Request): """策略清单(内置 + 自定义 + AI)。""" data_dir = request.app.state.repo.store.data_dir presets = [] seen_ids: set[str] = set() # 内置策略 for k, v in PRESET_STRATEGIES.items(): overrides = strategy_config.load_override(data_dir, k) name = (overrides.get("name") or v["name"]) if overrides else v["name"] desc = (overrides.get("description") or v["description"]) if overrides else v["description"] presets.append({"id": k, "name": name, "description": desc, "source": "builtin"}) seen_ids.add(k) # 自定义/AI 策略(不在 PRESET_STRATEGIES 中的) engine = getattr(request.app.state, "strategy_engine", None) if engine: for meta in engine.list_strategies(): sid = meta["id"] if sid not in seen_ids: overrides = strategy_config.load_override(data_dir, sid) name = (overrides.get("name") or meta["name"]) if overrides else meta["name"] desc = (overrides.get("description") or meta.get("description", "")) if overrides else meta.get("description", "") presets.append({"id": sid, "name": name, "description": desc, "source": meta.get("source", "custom")}) seen_ids.add(sid) return {"presets": presets} @router.post("/run") def run_custom(req: CustomRequest, request: Request): repo = request.app.state.repo svc = ScreenerService(repo) 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)) ext_values = _load_ext_value_maps(repo, req.ext_columns) 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) 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_values = _load_ext_value_maps(repo, req.ext_columns) overrides = strategy_config.load_override(data_dir, req.strategy_id) bf = overrides.get("basic_filter") if overrides else None dl = overrides.get("display_limit") if overrides else None if dl is None and overrides and "display_limit" in overrides: dl = 0 # 内置策略 if req.strategy_id in PRESET_STRATEGIES: try: result = svc.run_preset(req.strategy_id, as_of=as_of, pool=req.pool, basic_filter=bf, display_limit=dl) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e safe_data = _safe(asdict(result)) _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) return _result_with_ext(safe_data, ext_values) # 自定义/AI 策略 — 通过 StrategyEngine 执行 engine = getattr(request.app.state, "strategy_engine", None) if not engine: raise HTTPException(status_code=404, detail=f"策略引擎未初始化或策略 {req.strategy_id} 不存在") try: result = engine.run(req.strategy_id, as_of, pool=req.pool, overrides=overrides or None) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e data = asdict(result) if dl is not None and dl > 0: data["rows"] = data["rows"][:dl] data["total"] = min(data["total"], dl) # 单跑后更新缓存中该策略的结果(保持缓存最新) safe_data = _safe(data) _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data) return _result_with_ext(safe_data, ext_values) @router.get("/cached") def get_cached( request: Request, ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"), ): """读取策略结果缓存。 - 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用。 """ 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} # 无任何数据 → 返回空标记, 前端据此提示 if not cached.get("results") and cached.get("as_of") is None: return {"as_of": None, "results": {}, "updated_at": None} ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns) return _cache_payload_with_ext(cached, ext_values) @router.get("/market-snapshot") def market_snapshot(request: Request, as_of: date | None = None): """全市场轻量行情快照(可选指定日期),供板块/概念聚合分析使用。""" import polars as pl repo = request.app.state.repo svc = ScreenerService(repo) if as_of is None: 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} @router.post("/run_all") def run_all(request: Request, body: Optional[dict] = None): """批量运行指定策略,只返回每个策略的命中数。 优化: 从 enriched 读取一次目标日期数据, 所有策略共享。 body.strategy_ids: 只跑指定的策略 ID 列表, 为空则跑全部。 """ from datetime import date as date_type t_total = time.perf_counter() body = body or {} repo = request.app.state.repo svc = ScreenerService(repo) # 解析日期 raw_date = body.get("as_of") if raw_date: as_of = date_type.fromisoformat(str(raw_date)) if isinstance(raw_date, str) else raw_date else: as_of = svc.latest_date() if not as_of: return {"as_of": None, "results": {}} # 一次读取目标日期的全部数据 t0 = time.perf_counter() precomputed = svc._load_enriched_for_date(as_of) logger.info("run_all: _load_enriched_for_date took %.1fms", (time.perf_counter() - t0) * 1000) results: dict[str, dict] = {} data_dir = request.app.state.repo.store.data_dir # 收集需要运行的策略 ID (如果指定了 strategy_ids 则只跑这些) requested_ids = body.get("strategy_ids") all_ids = list(PRESET_STRATEGIES.keys()) engine = getattr(request.app.state, "strategy_engine", None) if engine: for meta in engine.list_strategies(): sid = meta["id"] if sid not in PRESET_STRATEGIES: all_ids.append(sid) if requested_ids and isinstance(requested_ids, list): id_set = set(requested_ids) all_ids = [sid for sid in all_ids if sid in id_set] 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)) # 历史策略: 只在需要时加载 (只加载 all_ids 中包含的 filter_history 策略) t0 = time.perf_counter() shared_history = None id_set = set(all_ids) if engine: history_strats = [ (sid, s) for sid, s in engine._strategies.items() if s.filter_history_fn and sid in id_set ] if history_strats: max_lb = min(max(s.lookback_days for _, s in history_strats), 30) shared_history = svc._load_enriched_history(as_of, max(1, max_lb)) else: history_strats = [] logger.info("run_all: _load_enriched_history took %.1fms (history_strats=%d)", (time.perf_counter() - t0) * 1000, len(history_strats)) for sid in all_ids: try: overrides = all_overrides.get(sid, {}) bf = overrides.get("basic_filter") if overrides else None dl = overrides.get("display_limit") if overrides else None if dl is None and overrides and "display_limit" in overrides: dl = 0 if sid in PRESET_STRATEGIES: r = svc.run_preset(sid, as_of=as_of, precomputed=precomputed, basic_filter=bf, display_limit=dl) else: r = engine.run( sid, as_of, overrides=overrides or None, precomputed=precomputed, precomputed_history=shared_history, ) if dl is not None and dl > 0: r.rows = r.rows[:dl] r.total = min(r.total, dl) safe_rows = _safe(asdict(r)).get("rows", []) results[sid] = {"total": r.total, "as_of": str(as_of), "rows": safe_rows} except (ValueError, Exception): continue elapsed = (time.perf_counter() - t_total) * 1000 logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids)) # 写入策略缓存 (供页面秒加载) if results: try: strategy_cache.write_cache(data_dir, str(as_of), results) except Exception: # noqa: BLE001 pass ext_values = _load_ext_value_maps(repo, body.get("ext_columns")) return {"as_of": str(as_of), "results": _results_with_ext(results, ext_values)} @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" """ from datetime import timedelta 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 count_up = count_up_raw count_down = count_down_raw # 加载前一日数据获取 prev consecutive_limit_ups/downs prev_consec: pl.DataFrame = pl.DataFrame() for delta in range(1, 10): candidate = as_of - timedelta(days=delta) df_prev = svc._load_enriched_for_date(candidate) if not df_prev.is_empty() and consec_col in df_prev.columns: prev_consec = df_prev.select( "symbol", pl.col(consec_col).alias("prev_consec"), ) break 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)) # Add null columns for sealed_status and sealed_vol (no longer used) df = df.with_columns( pl.lit(None).alias("sealed_status"), pl.lit(None).alias("sealed_vol"), ) # 动态 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.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: ext_df = pl.from_arrow(db.query( f"SELECT symbol, \"{field_name}\" FROM {view_name}" ).arrow()) if not ext_df.is_empty() and "symbol" in ext_df.columns: ext_df = ext_df.rename({field_name: ext_col_name}) df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left") ext_col_names.append(ext_col_name) except Exception: cfg = configs.get(config_id) if cfg: try: from app.api.ext_data import _parquet_glob glob = _parquet_glob(cfg, data_dir) ext_df = pl.read_parquet(glob) 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]).rename({field_name: ext_col_name}) df = df.join(ext_df, 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"] + 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}, } 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 not config_id or not field_name: continue if not _EXT_IDENT_RE.match(config_id) or "\x00" in field_name: continue result.append((config_id, field_name)) return result