Files
tick-stock-panel/backend/app/services/screener.py
T
shy3130 e89ea9becf fix: 修复 4 个 P1 issue (#224/#232/#223/#215)
- #224 screener 自定义 SQL 的内存连接关闭 enable_external_access,
  注入的 read_parquet/COPY 文件读写直接报错 (安全)
- #232 指数展示缓存百分数口径在消费边界显式 /100:
  pipeline._bench_rt_pct_of 与 abnormal_moves._bench_rt_pct 两处,
  修复 3/10/30 日偏离值被放大两个数量级
- #223 盘后管道按同日 daily/enriched 行数比较检测实时合并提前
  创建的部分分区, 删除后由增量重算全市场补齐
- #215 _basic_filter_for_asset 扩展中和股票专属键 (price_min/max/boards),
  并应用到回测/挖掘/策略扫描三个运行期入口, 修复 ETF 静默零信号
2026-09-03 13:01:43 +08:00

475 lines
20 KiB
Python

"""Screener 服务(§6.3)。
性能优化:
- enriched parquet 仅存 14 列基础数据, 指标和信号即时计算
- preset 策略: 从内存缓存或即时计算获取完整指标, ~10-50ms
- custom SQL: DuckDB (用户传 SQL WHERE 字符串), ~10-50ms
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from datetime import date, timedelta
import polars as pl
from app.parquet import scan_enriched_parquet
from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
# ── 进程级历史数据缓存 (避免 run_all 每次重新扫描 parquet + 计算指标) ──
_history_cache: dict[tuple[str, date, int], tuple[float, pl.DataFrame]] = {}
_HISTORY_CACHE_TTL = 120.0 # 秒
@dataclass
class ScreenerResult:
as_of: date
strategy: str | None
rows: list[dict] = field(default_factory=list)
total: int = 0
elapsed_ms: float = 0.0
class ScreenerService:
def __init__(self, repo: KlineRepository, asset_type: str = "stock") -> None:
self.repo = repo
self.asset_type = asset_type
from app.tickflow.repository import enriched_dirname
self._enriched_dirname = enriched_dirname(asset_type)
@staticmethod
def clear_history_cache() -> None:
"""清空进程级 _history_cache (TTL 缓存)。
清除数据后调用, 避免内存里的旧历史窗口残留导致策略/看板仍命中旧数据。
"""
_history_cache.clear()
def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame:
"""从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。
enriched parquet 仅存 14 列。读取后需要即时计算 ma/ema/macd/kdj/rsi/boll/momentum/signal 等列。
对于最新日, 优先使用内存缓存 (已包含完整指标)。
"""
# 优先使用 repo 最新日缓存
cache, cache_date = self.repo.get_enriched_latest_asset(self.asset_type)
if cache is not None and not cache.is_empty() and cache_date == target_date:
df = cache
# JOIN instruments
df_i = self.repo.get_instruments_asset(self.asset_type)
if not df_i.is_empty():
inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
if "name" not in df.columns:
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
return df
# 尝试从 repo 级预计算历史缓存中提取目标日期 (仅 stock: 该缓存为股票专用)
if self.asset_type == "stock":
cached_hist = self.repo.get_enriched_history(target_date, 1)
if cached_hist is not None and not cached_hist.is_empty() and "date" in cached_hist.columns:
df = cached_hist.filter(pl.col("date") == target_date)
if not df.is_empty():
logger.debug("_load_enriched_for_date: repo history cache for %s", target_date)
# JOIN instruments
df_i = self.repo.get_instruments_asset(self.asset_type)
if not df_i.is_empty():
inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
if "name" not in df.columns:
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
return df
# 历史日期: 从 parquet 读取 14 列, 即时计算指标 (慢路径)
enriched_dir = self.repo.store.data_dir / self._enriched_dirname
ds = target_date.isoformat()
target_parquet = enriched_dir / f"date={ds}" / "part.parquet"
if not target_parquet.exists():
return pl.DataFrame()
try:
df = pl.read_parquet(target_parquet)
except Exception as e: # noqa: BLE001
logger.warning("load_enriched_for_date failed: %s", e)
return pl.DataFrame()
if df.is_empty():
return df
# 即时计算指标: 需要加载历史窗口作 warmup
df_full = self._compute_enriched_full(df, target_date)
return df_full
def load_prior_consecutive(self, as_of: date, consec_col: str) -> pl.DataFrame:
"""窄读: 仅取前一交易日的 [symbol, consec_col] 两列 (谓词下推到单日 parquet)。
consecutive_limit_ups / consecutive_limit_downs 是 enriched 的存储列,
可直接从 parquet 读取, 无需 _load_enriched_for_date 的全量指标重算
(历史日期该慢路径最坏会触发 9 次全市场 compute_enriched_full)。
选取逻辑与旧循环等价: 在 as_of 前 1~9 天内找到第一个存在的日分区
(即前一交易日), 读取其 symbol + consec_col。存储列的值与重算值逐位一致
(连板计数为 run-length, 150 天 warmup 完全覆盖 A 股最长连板, 二者相等)。
返回列: symbol, prev_consec。找不到前一交易日时返回空 DataFrame。
"""
enriched_dir = self.repo.store.data_dir / self._enriched_dirname
for delta in range(1, 10):
candidate = as_of - timedelta(days=delta)
target_parquet = enriched_dir / f"date={candidate.isoformat()}" / "part.parquet"
if not target_parquet.exists():
continue
try:
lf = pl.scan_parquet(target_parquet)
cols = lf.collect_schema().names()
except Exception as e: # noqa: BLE001
logger.warning("load_prior_consecutive scan failed for %s: %s", candidate, e)
return pl.DataFrame()
# 存储列理论上必含 consec_col; 若该分区缺列则继续向前找 (与旧循环一致)
if "symbol" not in cols or consec_col not in cols:
continue
try:
return lf.select(
"symbol",
pl.col(consec_col).alias("prev_consec"),
).collect()
except Exception as e: # noqa: BLE001
logger.warning("load_prior_consecutive read failed for %s: %s", candidate, e)
return pl.DataFrame()
return pl.DataFrame()
def _compute_enriched_full(self, df_target: pl.DataFrame, target_date: date) -> pl.DataFrame:
"""从 14 列基础数据即时计算完整 enriched (含全部指标和信号)。
读取历史数据作为指标计算的 warmup, 计算完成后只返回目标日期的行。
"""
from app.indicators.pipeline import (
compute_indicators,
compute_limit_signals,
compute_signals,
)
# 加载 warmup 历史 (目标日期前 ~120 天)
enriched_dir = self.repo.store.data_dir / self._enriched_dirname
start = target_date - timedelta(days=150)
read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
"amount", "raw_close", "raw_high", "raw_low"]
try:
lf = (
scan_enriched_parquet(str(enriched_dir / "**" / "*.parquet"))
.filter(
(pl.col("date") >= start)
& (pl.col("date") <= target_date)
)
.sort(["symbol", "date"])
)
available = [c for c in read_cols if c in lf.schema]
df_hist = lf.select(available).collect()
except Exception as e: # noqa: BLE001
logger.warning("warmup history load failed: %s", e)
df_hist = df_target
if df_hist.is_empty():
df_hist = df_target
# 计算指标
df_full = compute_indicators(df_hist)
df_full = compute_signals(df_full)
# 计算涨跌停信号 (需要 instruments; 涨停为股票专有, ETF 跳过)
instruments = self.repo.get_instruments_asset(self.asset_type)
if self.asset_type == "stock" and instruments is not None and not instruments.is_empty():
df_full = compute_limit_signals(
df_full,
instruments,
historical_shares=self.repo.get_historical_shares(),
)
# 只保留目标日期
df_result = df_full.filter(pl.col("date") == target_date)
# JOIN instruments (name, total_shares, float_shares)
if not instruments.is_empty():
inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
if "name" not in df_result.columns:
df_result = df_result.join(instruments.select(inst_cols), on="symbol", how="left")
return df_result
def _load_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame:
"""读取目标日期之前的基础行情数据, 供历史窗口策略使用。
优先从 repo 内存缓存获取 (启动时已预计算), 命中时 0ms。
缓存 miss 时走 scan_parquet + compute_indicators 慢路径。
"""
# 优先级 1: repo 级预计算缓存 (启动时 _refresh_enriched 已计算完整历史; 仅 stock)
t0 = time.perf_counter()
if self.asset_type == "stock":
cached = self.repo.get_enriched_history(target_date, lookback_days)
if cached is not None and not cached.is_empty():
# JOIN instruments (repo 缓存不含 name 等列)
instruments = self.repo.get_instruments_asset(self.asset_type)
if instruments is not None and not instruments.is_empty() and "name" not in cached.columns:
inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"]
if c in instruments.columns]
cached = cached.join(instruments.select(inst_cols), on="symbol", how="left")
elapsed = (time.perf_counter() - t0) * 1000
logger.info("_load_enriched_history(%s, %d): repo cache hit, %.1fms, %d rows",
target_date, lookback_days, elapsed, len(cached))
return cached
# 优先级 2: 进程级 history_cache (之前的 TTL 缓存)
cache_key = (self.asset_type, target_date, lookback_days)
now = time.monotonic()
ttl_cached = _history_cache.get(cache_key)
if ttl_cached is not None:
ts, cached_df = ttl_cached
if now - ts < _HISTORY_CACHE_TTL:
logger.debug("history TTL cache hit: %s lookback=%d", target_date, lookback_days)
return cached_df
del _history_cache[cache_key]
# 优先级 3: scan_parquet + compute_indicators (慢路径, ~5s)
logger.warning("_load_enriched_history cache miss, computing indicators (%s, %d)...",
target_date, lookback_days)
from app.indicators.pipeline import (
compute_indicators,
compute_limit_signals,
compute_signals,
)
warmup = 60
start = target_date - timedelta(days=min((lookback_days + warmup) * 2, 180))
enriched_dir = self.repo.store.data_dir / self._enriched_dirname
read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
"amount", "raw_close", "raw_high", "raw_low"]
try:
lf = (
scan_enriched_parquet(str(enriched_dir / "**" / "*.parquet"))
.filter((pl.col("date") >= start) & (pl.col("date") <= target_date))
.sort(["symbol", "date"])
)
available = [c for c in read_cols if c in lf.collect_schema().names()]
df_hist = lf.select(available).collect()
except Exception as e: # noqa: BLE001
logger.warning("load_enriched_history failed: %s", e)
return pl.DataFrame()
if df_hist.is_empty():
return pl.DataFrame()
df_full = compute_indicators(df_hist)
df_full = compute_signals(df_full)
instruments = self.repo.get_instruments_asset(self.asset_type)
if self.asset_type == "stock" and instruments is not None and not instruments.is_empty():
df_full = compute_limit_signals(
df_full,
instruments,
historical_shares=self.repo.get_historical_shares(),
)
if instruments is not None and not instruments.is_empty():
inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
if "name" not in df_full.columns:
df_full = df_full.join(instruments.select(inst_cols), on="symbol", how="left")
# 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销)。
# 按交易日计数: 从数据里实际存在的交易日序列取最后 lookback_days 个交易日,
# 不能用 timedelta(days=N) (自然日), 否则周末/节假日会让窗口偏少, 与回测不一致。
if "date" in df_full.columns:
trading_dates = df_full["date"].unique().sort()
if len(trading_dates) > lookback_days:
lookback_start = trading_dates[-(lookback_days + 1)]
else:
lookback_start = trading_dates[0]
df_full = df_full.filter(pl.col("date") >= lookback_start)
df_full = df_full.sort(["symbol", "date"])
elapsed = (time.perf_counter() - t0) * 1000
logger.info("_load_enriched_history(%s, %d): computed in %.1fms, %d rows",
target_date, lookback_days, elapsed, len(df_full))
_history_cache[cache_key] = (now, df_full)
if len(_history_cache) > 10:
expired = [k for k, (ts, _) in _history_cache.items() if now - ts > _HISTORY_CACHE_TTL]
for k in expired:
del _history_cache[k]
return df_full
def run(
self,
as_of: date,
conditions: list[str],
order_by: str | None = None,
limit: int = 30,
pool: list[str] | None = None,
) -> ScreenerResult:
"""自定义 SQL 条件选股。
先通过 Polars 即时计算完整指标, 再用 DuckDB 做 SQL WHERE 过滤。
kline_enriched DuckDB 视图只有 14 列, 不能直接用于指标过滤。
"""
t0 = time.perf_counter()
if not conditions:
return ScreenerResult(as_of=as_of, strategy=None)
# 从即时计算获取完整 enriched 数据
df = self._load_enriched_for_date(as_of)
if df.is_empty():
return ScreenerResult(as_of=as_of, strategy=None)
# Pool 过滤
if pool:
df = df.filter(pl.col("symbol").is_in(pool))
# 用 DuckDB 做 SQL 过滤 (注册临时视图)
# 用独立的 :memory: 连接 (而非复用 repo 共享连接的 cursor): conditions 是用户
# 传入的 SQL 片段, 隔离连接下注入至多能碰 read_csv/read_parquet 文件; 若复用共享
# 连接则会把 app 已注册的真实业务表也暴露给注入, 扩大攻击面。隔离连接创建开销极低。
# 再关闭 external_access, 让注入的文件读写函数 (read_parquet/COPY 等) 直接报错,
# 视图数据仍通过 con.register 注入, 不受该开关影响 (#224)。
con = None
try:
import duckdb
con = duckdb.connect(
database=":memory:", config={"enable_external_access": False}
)
con.register("enriched", df.to_arrow())
where = " AND ".join(f"({c})" for c in conditions)
sql = f"SELECT * FROM enriched WHERE {where}"
if order_by:
sql += f" ORDER BY {order_by}"
if limit:
sql += f" LIMIT {limit}"
df_result = con.execute(sql).pl()
except Exception as e: # noqa: BLE001
logger.warning("screener SQL query failed: %s", e)
df_result = pl.DataFrame()
finally:
if con is not None:
try:
con.close()
except Exception: # noqa: BLE001
pass
rows = df_result.to_dicts() if not df_result.is_empty() else []
elapsed = (time.perf_counter() - t0) * 1000
return ScreenerResult(
as_of=as_of,
strategy=None,
rows=rows,
total=len(rows),
elapsed_ms=elapsed,
)
def build_strategy_context(
self,
engine,
as_of: date,
strategy_ids: list[str],
*,
timeframe: str = "1d",
params_map: dict[str, dict] | None = None,
overrides_map: dict[str, dict] | None = None,
current: pl.DataFrame | None = None,
market=None,
cache_key: str | None = None,
):
"""按调用方要求装配标准策略数据上下文,不解释策略公式。"""
from app.strategy.engine import StrategyDataContext
if current is None:
current = self._load_enriched_for_date(as_of)
if timeframe == "1m":
# 分钟策略数据源是本地当日分钟K分区 (单分区文件直读), 与日线
# enriched 历史窗口无关, 不走 required_history_bars 日线路径。
history = self._load_minute_history(as_of, current)
# 策略声明 META["daily_history_bars"] 时额外装配日线 enriched 窗口,
# 供分钟策略叠加日线维度条件 (如 N 日内涨停过)。
daily_history = None
if engine is not None:
daily_bars = engine.minute_daily_history_bars(strategy_ids)
if daily_bars > 0:
daily_history = self._load_enriched_history(as_of, daily_bars)
return StrategyDataContext(
asset_type=self.asset_type,
timeframe=timeframe,
as_of=as_of,
current=current,
history=history,
daily_history=daily_history,
market=None,
cache_key=cache_key,
)
history_bars = engine.required_history_bars(
strategy_ids,
params_map=params_map,
overrides_map=overrides_map,
)
history = None
if history_bars > 1:
history = self._load_enriched_history(as_of, history_bars)
return StrategyDataContext(
asset_type=self.asset_type,
timeframe=timeframe,
as_of=as_of,
current=current,
history=history,
market=market,
cache_key=cache_key,
)
def _load_minute_history(self, as_of: date, current: pl.DataFrame | None) -> pl.DataFrame:
"""分钟策略数据源: 优先 as_of 当日分钟分区, 缺失时回退全市场最近分区。
只按日期直读单个分区文件 (get_minute_by_dates), 与全量 glob 扫描解耦,
内存只随当日分区大小 (~67万行) 走。标的池限定为 enriched 快照 universe;
分区与快照的日期差是允许的 (分钟分区可能比 enriched 更新, 行自带时间戳)。
"""
if self.asset_type != "stock":
raise ValueError("分钟策略当前仅支持 A 股")
symbols: list[str] = []
if current is not None and not current.is_empty():
symbols = current["symbol"].cast(pl.Utf8).unique().to_list()
if not symbols:
return pl.DataFrame()
df = self.repo.get_minute_by_dates(symbols, [as_of])
if df.is_empty():
fallback = self.repo.latest_minute_date_global()
if fallback is None:
raise ValueError(
"无分钟K数据 — 请先在 数据→分钟K 完成同步, 或开启盘中增量刷新"
)
if fallback != as_of:
df = self.repo.get_minute_by_dates(symbols, [fallback])
return df
def latest_date(self) -> date | None:
if self.asset_type != "stock":
_, d = self.repo.get_enriched_latest_asset(self.asset_type)
return d
d = self.repo.enriched_latest_date()
if d:
return d
# 回退 DuckDB
try:
res = self.repo.execute_one(
"SELECT max(date) FROM kline_enriched",
)
if res and res[0]:
d = res[0]
return d if isinstance(d, date) else date.fromisoformat(str(d))
except Exception: # noqa: BLE001
return None
return None