初始化工程
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
"""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.tickflow.repository import KlineRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 进程级历史数据缓存 (避免 run_all 每次重新扫描 parquet + 计算指标) ──
|
||||
_history_cache: dict[tuple[date, int], tuple[float, pl.DataFrame]] = {}
|
||||
_HISTORY_CACHE_TTL = 120.0 # 秒
|
||||
|
||||
|
||||
# 内置预设策略 — Polars 表达式方式
|
||||
PRESET_STRATEGIES: dict[str, dict] = {
|
||||
"trend_breakout": {
|
||||
"name": "趋势突破",
|
||||
"description": "MA60 上方 + 60 日新高 + 量能 ≥ 2 倍均量",
|
||||
"filter": (
|
||||
(pl.col("close") > pl.col("ma60"))
|
||||
& pl.col("signal_n_day_high").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 2.0)
|
||||
),
|
||||
"order_by": "momentum_60d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"ma_golden_cross": {
|
||||
"name": "MA 金叉",
|
||||
"description": "MA5 上穿 MA20 当日触发,量能配合",
|
||||
"filter": (
|
||||
pl.col("signal_ma_golden_5_20").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 1.2)
|
||||
& (pl.col("close") > pl.col("ma60"))
|
||||
),
|
||||
"order_by": "momentum_20d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"macd_golden": {
|
||||
"name": "MACD 金叉放量",
|
||||
"description": "MACD 金叉当日 + 量能放大",
|
||||
"filter": (
|
||||
pl.col("signal_macd_golden").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 1.5)
|
||||
),
|
||||
"order_by": "momentum_60d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"volume_price_surge": {
|
||||
"name": "量价齐升",
|
||||
"description": "突破 MA20 + 放量 + 收阳",
|
||||
"filter": (
|
||||
pl.col("signal_ma20_breakout").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 2.0)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
),
|
||||
"order_by": "vol_ratio_5d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"low_volatility_leader": {
|
||||
"name": "低波动龙头",
|
||||
"description": "20 日动量为正 + 年化波动 < 30% + MA20 上方",
|
||||
"filter": (
|
||||
(pl.col("momentum_20d") > 0)
|
||||
& (pl.col("annual_vol_20d") < 0.30)
|
||||
& (pl.col("close") > pl.col("ma20"))
|
||||
),
|
||||
"order_by": "momentum_60d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"broken_board_recovery": {
|
||||
"name": "断板反包",
|
||||
"description": "连板 ≥2 后断板 1-2 天,出现放量反包信号",
|
||||
"filter": (
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 1.5)
|
||||
& (pl.col("change_pct") > 0.03)
|
||||
),
|
||||
"order_by": "change_pct",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"oversold_bounce": {
|
||||
"name": "超跌反弹",
|
||||
"description": "RSI14 < 30 超卖区 + 当日收阳 + 放量,抄底信号",
|
||||
"filter": (
|
||||
(pl.col("rsi_14") < 30)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
& (pl.col("vol_ratio_5d") >= 1.2)
|
||||
),
|
||||
"order_by": "rsi_14",
|
||||
"descending": False,
|
||||
"limit": 100,
|
||||
},
|
||||
"boll_breakout": {
|
||||
"name": "布林突破",
|
||||
"description": "突破布林上轨 + 放量,强势加速信号",
|
||||
"filter": (
|
||||
pl.col("signal_boll_breakout_upper").fill_null(False)
|
||||
& (pl.col("vol_ratio_5d") >= 1.5)
|
||||
),
|
||||
"order_by": "vol_ratio_5d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"bullish_alignment": {
|
||||
"name": "均线多头",
|
||||
"description": "MA5 > MA10 > MA20 > MA60 多头排列 + 短期动量为正",
|
||||
"filter": (
|
||||
(pl.col("ma5") > pl.col("ma10"))
|
||||
& (pl.col("ma10") > pl.col("ma20"))
|
||||
& (pl.col("ma20") > pl.col("ma60"))
|
||||
& (pl.col("momentum_20d") > 0)
|
||||
),
|
||||
"order_by": "momentum_60d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"consecutive_limit_ups": {
|
||||
"name": "连板股",
|
||||
"description": "当日涨停且连续涨停 ≥ 2 天,强势追涨",
|
||||
"filter": (
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
& (pl.col("consecutive_limit_ups") >= 2)
|
||||
),
|
||||
"order_by": "consecutive_limit_ups",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"pullback_to_support": {
|
||||
"name": "缩量回踩",
|
||||
"description": "回踩 MA20 附近 + 缩量 + 中期趋势向上",
|
||||
"filter": (
|
||||
(pl.col("close") > pl.col("ma20") * 0.98)
|
||||
& (pl.col("close") < pl.col("ma20") * 1.02)
|
||||
& (pl.col("vol_ratio_5d") < 0.8)
|
||||
& (pl.col("close") > pl.col("ma60"))
|
||||
& (pl.col("momentum_20d") > 0)
|
||||
),
|
||||
"order_by": "momentum_60d",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
"n_day_low_reversal": {
|
||||
"name": "新低反转",
|
||||
"description": "触及 60 日新低后当日收阳放量,反转信号",
|
||||
"filter": (
|
||||
pl.col("signal_n_day_low").fill_null(False)
|
||||
& (pl.col("close") > pl.col("open"))
|
||||
& (pl.col("vol_ratio_5d") >= 1.5)
|
||||
),
|
||||
"order_by": "change_pct",
|
||||
"descending": True,
|
||||
"limit": 100,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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) -> None:
|
||||
self.repo = repo
|
||||
|
||||
@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()
|
||||
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()
|
||||
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 级预计算历史缓存中提取目标日期
|
||||
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()
|
||||
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 / "kline_daily_enriched"
|
||||
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 _compute_enriched_full(self, df_target: pl.DataFrame, target_date: date) -> pl.DataFrame:
|
||||
"""从 14 列基础数据即时计算完整 enriched (含全部指标和信号)。
|
||||
|
||||
读取历史数据作为指标计算的 warmup, 计算完成后只返回目标日期的行。
|
||||
"""
|
||||
from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals
|
||||
|
||||
# 加载 warmup 历史 (目标日期前 ~120 天)
|
||||
enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
|
||||
start = target_date - timedelta(days=150)
|
||||
read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
|
||||
"amount", "raw_close", "raw_high", "raw_low"]
|
||||
|
||||
try:
|
||||
lf = (
|
||||
pl.scan_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)
|
||||
instruments = self.repo.get_instruments()
|
||||
if instruments is not None and not instruments.is_empty():
|
||||
df_full = compute_limit_signals(df_full, instruments)
|
||||
|
||||
# 只保留目标日期
|
||||
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 已计算完整历史)
|
||||
t0 = time.perf_counter()
|
||||
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()
|
||||
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 = (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_signals, compute_limit_signals
|
||||
|
||||
warmup = 60
|
||||
start = target_date - timedelta(days=min((lookback_days + warmup) * 2, 180))
|
||||
|
||||
enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
|
||||
read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
|
||||
"amount", "raw_close", "raw_high", "raw_low"]
|
||||
|
||||
try:
|
||||
lf = (
|
||||
pl.scan_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()
|
||||
if instruments is not None and not instruments.is_empty():
|
||||
df_full = compute_limit_signals(df_full, instruments)
|
||||
|
||||
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_start = target_date - timedelta(days=lookback_days)
|
||||
if "date" in df_full.columns:
|
||||
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 过滤 (注册临时视图)
|
||||
try:
|
||||
import duckdb
|
||||
con = duckdb.connect(database=":memory:")
|
||||
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()
|
||||
con.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("screener SQL query failed: %s", e)
|
||||
df_result = pl.DataFrame()
|
||||
|
||||
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 run_preset(
|
||||
self,
|
||||
strategy_id: str,
|
||||
as_of: date,
|
||||
pool: list[str] | None = None,
|
||||
precomputed: pl.DataFrame | None = None,
|
||||
basic_filter: dict | None = None,
|
||||
display_limit: int | None = None,
|
||||
) -> ScreenerResult:
|
||||
"""预设策略选股 — 从 enriched 读取预计算好的指标列后过滤。
|
||||
|
||||
- precomputed 不为空: 直接复用(run_all 场景)
|
||||
- precomputed 为空: 从 enriched 读目标日期
|
||||
- basic_filter: 用户保存的基础参数过滤(boards、价格等)
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
strat = PRESET_STRATEGIES.get(strategy_id)
|
||||
if not strat:
|
||||
raise ValueError(f"unknown strategy: {strategy_id}")
|
||||
|
||||
if precomputed is not None and not precomputed.is_empty():
|
||||
df = precomputed
|
||||
else:
|
||||
df = self._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
return ScreenerResult(as_of=as_of, strategy=strategy_id)
|
||||
|
||||
# 应用用户基础参数过滤(boards、价格区间等)
|
||||
if basic_filter and basic_filter.get("enabled", True):
|
||||
df = self._apply_basic_filter(df, basic_filter)
|
||||
|
||||
# 应用策略过滤
|
||||
df = df.filter(strat["filter"])
|
||||
|
||||
# 应用 pool
|
||||
if pool:
|
||||
df = df.filter(pl.col("symbol").is_in(pool))
|
||||
|
||||
# 排序 + 限制
|
||||
order_col = strat["order_by"]
|
||||
if order_col in df.columns:
|
||||
df = df.sort(order_col, descending=strat.get("descending", True))
|
||||
|
||||
# display_limit: None=不限制, 0=全部, N=前N个
|
||||
if display_limit == 0:
|
||||
limit = None # 不限制
|
||||
elif display_limit is not None:
|
||||
limit = display_limit
|
||||
else:
|
||||
limit = None # 未配置时默认不限制
|
||||
if limit is not None and limit > 0:
|
||||
df = df.head(limit)
|
||||
|
||||
# 基于排序列生成 0-100 评分 (与 StrategyEngine 统一)
|
||||
if order_col in df.columns and not df.is_empty():
|
||||
col_vals = df[order_col].cast(pl.Float64)
|
||||
col_min = col_vals.min()
|
||||
col_max = col_vals.max()
|
||||
col_range = col_max - col_min
|
||||
if col_range and col_range > 0:
|
||||
normalized = (col_vals - col_min) / col_range
|
||||
else:
|
||||
normalized = pl.Series("norm", [0.5] * len(df))
|
||||
if not strat.get("descending", True):
|
||||
normalized = 1.0 - normalized
|
||||
df = df.with_columns((normalized * 100).alias("score"))
|
||||
|
||||
rows = df.to_dicts()
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# sanitize
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and (v != v or abs(v) == float("inf")):
|
||||
r[k] = None
|
||||
|
||||
return ScreenerResult(
|
||||
as_of=as_of,
|
||||
strategy=strategy_id,
|
||||
rows=rows,
|
||||
total=len(rows),
|
||||
elapsed_ms=elapsed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame:
|
||||
"""应用用户基础参数过滤(boards、价格区间、市值等)"""
|
||||
exprs: list[pl.Expr] = []
|
||||
if bf.get("price_min") is not None:
|
||||
exprs.append(pl.col("close") >= bf["price_min"])
|
||||
if bf.get("price_max") is not None:
|
||||
exprs.append(pl.col("close") <= bf["price_max"])
|
||||
if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
|
||||
exprs.append(pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"])
|
||||
if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
|
||||
exprs.append(pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"])
|
||||
if bf.get("amount_min") is not None:
|
||||
exprs.append(pl.col("amount") >= bf["amount_min"])
|
||||
if bf.get("amount_max") is not None:
|
||||
exprs.append(pl.col("amount") <= bf["amount_max"])
|
||||
if bf.get("turnover_min") is not None and "turnover_rate" in df.columns:
|
||||
exprs.append(pl.col("turnover_rate") >= bf["turnover_min"])
|
||||
if bf.get("turnover_max") is not None and "turnover_rate" in df.columns:
|
||||
exprs.append(pl.col("turnover_rate") <= bf["turnover_max"])
|
||||
if bf.get("exclude_st") and "name" in df.columns:
|
||||
exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退"))
|
||||
# 板块过滤
|
||||
boards = bf.get("boards")
|
||||
if boards and isinstance(boards, list) and len(boards) > 0:
|
||||
board_exprs: list[pl.Expr] = []
|
||||
for b in boards:
|
||||
if b == "沪主板":
|
||||
board_exprs.append(pl.col("symbol").str.starts_with("60"))
|
||||
elif b == "深主板":
|
||||
board_exprs.append(
|
||||
pl.col("symbol").str.starts_with("00")
|
||||
| pl.col("symbol").str.starts_with("001")
|
||||
)
|
||||
elif b == "创业板":
|
||||
board_exprs.append(
|
||||
pl.col("symbol").str.starts_with("300")
|
||||
| pl.col("symbol").str.starts_with("301")
|
||||
)
|
||||
elif b == "科创板":
|
||||
board_exprs.append(pl.col("symbol").str.starts_with("688"))
|
||||
elif b == "北交所":
|
||||
board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$"))
|
||||
if board_exprs:
|
||||
exprs.append(pl.any_horizontal(board_exprs))
|
||||
if exprs:
|
||||
return df.filter(pl.all_horizontal(exprs))
|
||||
return df
|
||||
|
||||
def latest_date(self) -> date | None:
|
||||
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
|
||||
Reference in New Issue
Block a user