diff --git a/gzl/Selector.py b/gzl/Selector.py new file mode 100644 index 0000000..8f6790d --- /dev/null +++ b/gzl/Selector.py @@ -0,0 +1,633 @@ +from typing import Dict, List, Optional, Any + +from scipy.signal import find_peaks +import numpy as np +import pandas as pd + + +# --------------------------- 通用指标 --------------------------- # + +def compute_kdj(df: pd.DataFrame, n: int = 9) -> pd.DataFrame: + if df.empty: + return df.assign(K=np.nan, D=np.nan, J=np.nan) + + low_n = df["low"].rolling(window=n, min_periods=1).min() + high_n = df["high"].rolling(window=n, min_periods=1).max() + rsv = (df["close"] - low_n) / (high_n - low_n + 1e-9) * 100 + + K = np.zeros_like(rsv, dtype=float) + D = np.zeros_like(rsv, dtype=float) + for i in range(len(df)): + if i == 0: + K[i] = D[i] = 50.0 + else: + K[i] = 2 / 3 * K[i - 1] + 1 / 3 * rsv.iloc[i] + D[i] = 2 / 3 * D[i - 1] + 1 / 3 * K[i] + J = 3 * K - 2 * D + return df.assign(K=K, D=D, J=J) + + +def compute_bbi(df: pd.DataFrame) -> pd.Series: + ma3 = df["close"].rolling(3).mean() + ma6 = df["close"].rolling(6).mean() + ma12 = df["close"].rolling(12).mean() + ma24 = df["close"].rolling(24).mean() + return (ma3 + ma6 + ma12 + ma24) / 4 + + +def compute_rsv( + df: pd.DataFrame, + n: int, +) -> pd.Series: + """ + 按公式:RSV(N) = 100 × (C - LLV(L,N)) ÷ (HHV(C,N) - LLV(L,N)) + - C 用收盘价最高值 (HHV of close) + - L 用最低价最低值 (LLV of low) + """ + low_n = df["low"].rolling(window=n, min_periods=1).min() + high_close_n = df["close"].rolling(window=n, min_periods=1).max() + rsv = (df["close"] - low_n) / (high_close_n - low_n + 1e-9) * 100.0 + return rsv + + +def compute_dif(df: pd.DataFrame, fast: int = 12, slow: int = 26) -> pd.Series: + """计算 MACD 指标中的 DIF (EMA fast - EMA slow)。""" + ema_fast = df["close"].ewm(span=fast, adjust=False).mean() + ema_slow = df["close"].ewm(span=slow, adjust=False).mean() + return ema_fast - ema_slow + + +def bbi_deriv_uptrend( + bbi: pd.Series, + *, + min_window: int, + max_window: int | None = None, + q_threshold: float = 0.0, +) -> bool: + """ + 判断 BBI 是否“整体上升”。 + + 令最新交易日为 T,在区间 [T-w+1, T](w 自适应,w ≥ min_window 且 ≤ max_window) + 内,先将 BBI 归一化:BBI_norm(t) = BBI(t) / BBI(T-w+1)。 + + 再计算一阶差分 Δ(t) = BBI_norm(t) - BBI_norm(t-1)。 + 若 Δ(t) 的前 q_threshold 分位数 ≥ 0,则认为该窗口通过;只要存在 + **最长** 满足条件的窗口即可返回 True。q_threshold=0 时退化为 + “全程单调不降”(旧版行为)。 + + Parameters + ---------- + bbi : pd.Series + BBI 序列(最新值在最后一位)。 + min_window : int + 检测窗口的最小长度。 + max_window : int | None + 检测窗口的最大长度;None 表示不设上限。 + q_threshold : float, default 0.0 + 允许一阶差分为负的比例(0 ≤ q_threshold ≤ 1)。 + """ + if not 0.0 <= q_threshold <= 1.0: + raise ValueError("q_threshold 必须位于 [0, 1] 区间内") + + bbi = bbi.dropna() + if len(bbi) < min_window: + return False + + longest = min(len(bbi), max_window or len(bbi)) + + # 自最长窗口向下搜索,找到任一满足条件的区间即通过 + for w in range(longest, min_window - 1, -1): + seg = bbi.iloc[-w:] # 区间 [T-w+1, T] + norm = seg / seg.iloc[0] # 归一化 + diffs = np.diff(norm.values) # 一阶差分 + if np.quantile(diffs, q_threshold) >= 0: + return True + return False + + +def _find_peaks( + df: pd.DataFrame, + *, + column: str = "high", + distance: Optional[int] = None, + prominence: Optional[float] = None, + height: Optional[float] = None, + width: Optional[float] = None, + rel_height: float = 0.5, + **kwargs: Any, +) -> pd.DataFrame: + + if column not in df.columns: + raise KeyError(f"'{column}' not found in DataFrame columns: {list(df.columns)}") + + y = df[column].to_numpy() + + indices, props = find_peaks( + y, + distance=distance, + prominence=prominence, + height=height, + width=width, + rel_height=rel_height, + **kwargs, + ) + + peaks_df = df.iloc[indices].copy() + peaks_df["is_peak"] = True + + # Flatten SciPy arrays into columns (only those with same length as indices) + for key, arr in props.items(): + if isinstance(arr, (list, np.ndarray)) and len(arr) == len(indices): + peaks_df[f"peak_{key}"] = arr + + return peaks_df + + +# --------------------------- Selector 类 --------------------------- # +class BBIKDJSelector: + """ + 自适应 *BBI(导数)* + *KDJ* 选股器 + • BBI: 允许 bbi_q_threshold 比例的回撤 + • KDJ: J < threshold ;或位于历史 J 的 j_q_threshold 分位及以下 + • MACD: DIF > 0 + • 收盘价波动幅度 ≤ price_range_pct + """ + + def __init__( + self, + j_threshold: float = -5, + bbi_min_window: int = 90, + max_window: int = 90, + price_range_pct: float = 100.0, + bbi_q_threshold: float = 0.05, + j_q_threshold: float = 0.10, + ) -> None: + self.j_threshold = j_threshold + self.bbi_min_window = bbi_min_window + self.max_window = max_window + self.price_range_pct = price_range_pct + self.bbi_q_threshold = bbi_q_threshold # ← 原 q_threshold + self.j_q_threshold = j_q_threshold # ← 新增 + + # ---------- 单支股票过滤 ---------- # + def _passes_filters(self, hist: pd.DataFrame) -> bool: + hist = hist.copy() + hist["BBI"] = compute_bbi(hist) + + # 0. 收盘价波动幅度约束(最近 max_window 根 K 线) + win = hist.tail(self.max_window) + high, low = win["close"].max(), win["close"].min() + if low <= 0 or (high / low - 1) > self.price_range_pct: + return False + + # 1. BBI 上升(允许部分回撤) + if not bbi_deriv_uptrend( + hist["BBI"], + min_window=self.bbi_min_window, + max_window=self.max_window, + q_threshold=self.bbi_q_threshold, + ): + return False + + # 2. KDJ 过滤 —— 双重条件 + kdj = compute_kdj(hist) + j_today = float(kdj.iloc[-1]["J"]) + + # 最近 max_window 根 K 线的 J 分位 + j_window = kdj["J"].tail(self.max_window).dropna() + if j_window.empty: + return False + j_quantile = float(j_window.quantile(self.j_q_threshold)) + + if not (j_today < self.j_threshold or j_today <= j_quantile): + + return False + + # 3. MACD:DIF > 0 + hist["DIF"] = compute_dif(hist) + return hist["DIF"].iloc[-1] > 0 + + # ---------- 多股票批量 ---------- # + def select( + self, date: pd.Timestamp, data: Dict[str, pd.DataFrame] + ) -> List[str]: + picks: List[str] = [] + for code, df in data.items(): + hist = df[df["date"] <= date] + if hist.empty: + continue + # 额外预留 20 根 K 线缓冲 + hist = hist.tail(self.max_window + 20) + if self._passes_filters(hist): + picks.append(code) + return picks + + +class SuperB1Selector: + """SuperB1 选股器 + + 过滤逻辑概览 + ---------------- + 1. **历史匹配 (t_m)** — 在 *lookback_n* 个交易日窗口内,至少存在一日 + 满足 :class:`BBIKDJSelector`。 + + 2. **盘整区间** — 区间 ``[t_m, date-1]`` 收盘价波动率不超过 ``close_vol_pct``。 + + 3. **当日下跌** — ``(close_{date-1} - close_date) / close_{date-1}`` + ≥ ``price_drop_pct``。 + + 4. **J 值极低** — ``J < j_threshold`` *或* 位于历史 ``j_q_threshold`` 分位。 + """ + + # --------------------------------------------------------------------- + # 构造函数 + # --------------------------------------------------------------------- + def __init__( + self, + *, + lookback_n: int = 60, + close_vol_pct: float = 0.05, + price_drop_pct: float = 0.03, + j_threshold: float = -5, + j_q_threshold: float = 0.10, + # ↓↓↓ 新增:嵌套 BBIKDJSelector 配置 + B1_params: Optional[Dict[str, Any]] = None + ) -> None: + # ---------- 参数合法性检查 ---------- + if lookback_n < 2: + raise ValueError("lookback_n 应 ≥ 2") + if not (0 < close_vol_pct < 1): + raise ValueError("close_vol_pct 应位于 (0, 1) 区间") + if not (0 < price_drop_pct < 1): + raise ValueError("price_drop_pct 应位于 (0, 1) 区间") + if not (0 <= j_q_threshold <= 1): + raise ValueError("j_q_threshold 应位于 [0, 1] 区间") + if B1_params is None: + raise ValueError("bbi_params没有给出") + + # ---------- 基本参数 ---------- + self.lookback_n = lookback_n + self.close_vol_pct = close_vol_pct + self.price_drop_pct = price_drop_pct + self.j_threshold = j_threshold + self.j_q_threshold = j_q_threshold + + # ---------- 内部 BBIKDJSelector ---------- + self.bbi_selector = BBIKDJSelector(**(B1_params or {})) + + # 为保证给 BBIKDJSelector 提供足够历史,预留额外缓冲 + self._extra_for_bbi = self.bbi_selector.max_window + 20 + + # 单支股票过滤核心 + def _passes_filters(self, hist: pd.DataFrame) -> bool: + """*hist* 必须按日期升序,且最后一行为目标 *date*。""" + if len(hist) < 2: + return False + + # ---------- Step-0: 数据量判断 ---------- + if len(hist) < self.lookback_n + self._extra_for_bbi: + return False + + # ---------- Step-1: 搜索满足 BBIKDJ 的 t_m ---------- + lb_hist = hist.tail(self.lookback_n + 1) # +1 以排除自身 + tm_idx: int | None = None + # 遍历回溯窗口 + for idx in lb_hist.index[:-1]: + if self.bbi_selector._passes_filters(hist.loc[:idx]): + tm_idx = idx + stable_seg = hist.loc[tm_idx : hist.index[-2], "close"] + if len(stable_seg) < 3: + tm_idx = None + break + high, low = stable_seg.max(), stable_seg.min() + if low <= 0 or (high / low - 1) > self.close_vol_pct: + tm_idx = None + continue + else: + break + if tm_idx is None: + return False + + + # ---------- Step-3: 当日相对前一日跌幅 ---------- + close_today, close_prev = hist["close"].iloc[-1], hist["close"].iloc[-2] + if close_prev <= 0 or (close_prev - close_today) / close_prev < self.price_drop_pct: + return False + + # ---------- Step-4: J 值极低 ---------- + kdj = compute_kdj(hist) + j_today = float(kdj["J"].iloc[-1]) + j_window = kdj["J"].iloc[-self.lookback_n:].dropna() + j_q_val = float(j_window.quantile(self.j_q_threshold)) if not j_window.empty else np.nan + if not (j_today < self.j_threshold or j_today <= j_q_val): + return False + + return True + + # 批量选股接口 + def select(self, date: pd.Timestamp, data: Dict[str, pd.DataFrame]) -> List[str]: + picks: List[str] = [] + min_len = self.lookback_n + self._extra_for_bbi + + for code, df in data.items(): + hist = df[df["date"] <= date].tail(min_len) + if len(hist) < min_len: + continue + if self._passes_filters(hist): + picks.append(code) + + return picks + + +class PeakKDJSelector: + """ + Peaks + KDJ 选股器 + """ + + def __init__( + self, + j_threshold: float = -5, + max_window: int = 90, + fluc_threshold: float = 0.03, + gap_threshold: float = 0.02, + j_q_threshold: float = 0.10, + ) -> None: + self.j_threshold = j_threshold + self.max_window = max_window + self.fluc_threshold = fluc_threshold # 当日↔peak_(t-n) 波动率上限 + self.gap_threshold = gap_threshold # oc_prev 必须高于区间最低收盘价的比例 + self.j_q_threshold = j_q_threshold + + # ---------- 单支股票过滤 ---------- # + # ---------- 单支股票过滤 ---------- # + def _passes_filters(self, hist: pd.DataFrame) -> bool: + if hist.empty: + return False + + hist = hist.copy().sort_values("date") + hist["oc_max"] = hist[["open", "close"]].max(axis=1) + + # 1. 提取 peaks + peaks_df = _find_peaks( + hist, + column="oc_max", + distance=6, + prominence=0.5, + ) + + # 至少两个峰 + date_today = hist.iloc[-1]["date"] + peaks_df = peaks_df[peaks_df["date"] < date_today] + if len(peaks_df) < 2: + return False + + peak_t = peaks_df.iloc[-1] # 最新一个峰 + peaks_list = peaks_df.reset_index(drop=True) + oc_t = peak_t.oc_max + total_peaks = len(peaks_list) + + # 2. 回溯寻找 peak_(t-n) + target_peak = None + for idx in range(total_peaks - 2, -1, -1): + peak_prev = peaks_list.loc[idx] + oc_prev = peak_prev.oc_max + if oc_t <= oc_prev: # 要求 peak_t > peak_(t-n) + continue + + # 只有当“总峰数 ≥ 3”时才检查区间内其他峰 oc_max + if total_peaks >= 3 and idx < total_peaks - 2: + inter_oc = peaks_list.loc[idx + 1 : total_peaks - 2, "oc_max"] + if not (inter_oc < oc_prev).all(): + continue + + # 新增: oc_prev 高于区间最低收盘价 gap_threshold + date_prev = peak_prev.date + mask = (hist["date"] > date_prev) & (hist["date"] < peak_t.date) + min_close = hist.loc[mask, "close"].min() + if pd.isna(min_close): + continue # 区间无数据 + if oc_prev <= min_close * (1 + self.gap_threshold): + continue + + target_peak = peak_prev + + break + + if target_peak is None: + return False + + # 3. 当日收盘价波动率 + close_today = hist.iloc[-1]["close"] + fluc_pct = abs(close_today - target_peak.close) / target_peak.close + if fluc_pct > self.fluc_threshold: + return False + + # 4. KDJ 过滤 + kdj = compute_kdj(hist) + j_today = float(kdj.iloc[-1]["J"]) + j_window = kdj["J"].tail(self.max_window).dropna() + if j_window.empty: + return False + j_quantile = float(j_window.quantile(self.j_q_threshold)) + if not (j_today < self.j_threshold or j_today <= j_quantile): + return False + + return True + + # ---------- 多股票批量 ---------- # + def select( + self, + date: pd.Timestamp, + data: Dict[str, pd.DataFrame], + ) -> List[str]: + picks: List[str] = [] + for code, df in data.items(): + hist = df[df["date"] <= date] + if hist.empty: + continue + hist = hist.tail(self.max_window + 20) # 额外缓冲 + if self._passes_filters(hist): + picks.append(code) + return picks + + +class BBIShortLongSelector: + """ + BBI 上升 + 短/长期 RSV 条件 + DIF > 0 选股器 + """ + def __init__( + self, + n_short: int = 3, + n_long: int = 21, + m: int = 3, + bbi_min_window: int = 90, + max_window: int = 150, + bbi_q_threshold: float = 0.05, + ) -> None: + if m < 2: + raise ValueError("m 必须 ≥ 2") + self.n_short = n_short + self.n_long = n_long + self.m = m + self.bbi_min_window = bbi_min_window + self.max_window = max_window + self.bbi_q_threshold = bbi_q_threshold # 新增参数 + + # ---------- 单支股票过滤 ---------- # + def _passes_filters(self, hist: pd.DataFrame) -> bool: + hist = hist.copy() + hist["BBI"] = compute_bbi(hist) + + # 1. BBI 上升(允许部分回撤) + if not bbi_deriv_uptrend( + hist["BBI"], + min_window=self.bbi_min_window, + max_window=self.max_window, + q_threshold=self.bbi_q_threshold, + ): + return False + + # 2. 计算短/长期 RSV ----------------- + hist["RSV_short"] = compute_rsv(hist, self.n_short) + hist["RSV_long"] = compute_rsv(hist, self.n_long) + + if len(hist) < self.m: + return False # 数据不足 + + win = hist.iloc[-self.m :] # 最近 m 天 + long_ok = (win["RSV_long"] >= 80).all() # 长期 RSV 全 ≥ 80 + + short_series = win["RSV_short"] + short_start_end_ok = ( + short_series.iloc[0] >= 80 and short_series.iloc[-1] >= 80 + ) + short_has_below_20 = (short_series < 20).any() + + if not (long_ok and short_start_end_ok and short_has_below_20): + return False + + # 3. MACD:DIF > 0 ------------------- + hist["DIF"] = compute_dif(hist) + return hist["DIF"].iloc[-1] > 0 + + # ---------- 多股票批量 ---------- # + def select( + self, + date: pd.Timestamp, + data: Dict[str, pd.DataFrame], + ) -> List[str]: + picks: List[str] = [] + for code, df in data.items(): + hist = df[df["date"] <= date] + if hist.empty: + continue + # 预留足够长度:RSV 计算窗口 + BBI 检测窗口 + m + need_len = ( + max(self.n_short, self.n_long) + + self.bbi_min_window + + self.m + ) + hist = hist.tail(max(need_len, self.max_window)) + if self._passes_filters(hist): + picks.append(code) + return picks + + +class BreakoutVolumeKDJSelector: + """ + 放量突破 + KDJ + DIF>0 + 收盘价波动幅度 选股器 + """ + + def __init__( + self, + j_threshold: float = 0.0, + up_threshold: float = 3.0, + volume_threshold: float = 2.0 / 3, + offset: int = 15, + max_window: int = 120, + price_range_pct: float = 10.0, + j_q_threshold: float = 0.10, # ← 新增 + ) -> None: + self.j_threshold = j_threshold + self.up_threshold = up_threshold + self.volume_threshold = volume_threshold + self.offset = offset + self.max_window = max_window + self.price_range_pct = price_range_pct + self.j_q_threshold = j_q_threshold # ← 新增 + + # ---------- 单支股票过滤 ---------- # + def _passes_filters(self, hist: pd.DataFrame) -> bool: + if len(hist) < self.offset + 2: + return False + + hist = hist.tail(self.max_window).copy() + + # ---- 收盘价波动幅度约束 ---- + high, low = hist["close"].max(), hist["close"].min() + if low <= 0 or (high / low - 1) > self.price_range_pct: + return False + + # ---- 技术指标 ---- + hist = compute_kdj(hist) + hist["pct_chg"] = hist["close"].pct_change() * 100 + hist["DIF"] = compute_dif(hist) + + # 0) 指定日约束:J < j_threshold 或位于历史分位;且 DIF > 0 + j_today = float(hist["J"].iloc[-1]) + + j_window = hist["J"].tail(self.max_window).dropna() + if j_window.empty: + return False + j_quantile = float(j_window.quantile(self.j_q_threshold)) + + # 若不满足任一 J 条件,则淘汰 + if not (j_today < self.j_threshold or j_today <= j_quantile): + return False + if hist["DIF"].iloc[-1] <= 0: + return False + + # ---- 放量突破条件 ---- + n = len(hist) + wnd_start = max(0, n - self.offset - 1) + last_idx = n - 1 + + for t_idx in range(wnd_start, last_idx): # 探索突破日 T + row = hist.iloc[t_idx] + + # 1) 单日涨幅 + if row["pct_chg"] < self.up_threshold: + continue + + # 2) 相对放量 + vol_T = row["volume"] + if vol_T <= 0: + continue + vols_except_T = hist["volume"].drop(index=hist.index[t_idx]) + if not (vols_except_T <= self.volume_threshold * vol_T).all(): + continue + + # 3) 创新高 + if row["close"] <= hist["close"].iloc[:t_idx].max(): + continue + + # 4) T 之后 J 值维持高位 + if not (hist["J"].iloc[t_idx:last_idx] > hist["J"].iloc[-1] - 10).all(): + continue + + return True # 满足所有条件 + + return False + + # ---------- 多股票批量 ---------- # + def select( + self, date: pd.Timestamp, data: Dict[str, pd.DataFrame] + ) -> List[str]: + picks: List[str] = [] + for code, df in data.items(): + hist = df[df["date"] <= date] + if hist.empty: + continue + if self._passes_filters(hist): + picks.append(code) + return picks diff --git a/gzl/select_stock.py b/gzl/select_stock.py new file mode 100644 index 0000000..172cb79 --- /dev/null +++ b/gzl/select_stock.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import argparse +import importlib +import json +import logging +import sys +from pathlib import Path +from typing import Any, Dict, Iterable, List + +import pandas as pd + +# ---------- 日志 ---------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + # 将日志写入文件 + logging.FileHandler("select_results.log", encoding="utf-8"), + ], +) +logger = logging.getLogger("select") + + +# ---------- 工具 ---------- + +def load_data(data_dir: Path, codes: Iterable[str]) -> Dict[str, pd.DataFrame]: + frames: Dict[str, pd.DataFrame] = {} + for code in codes: + fp = data_dir / f"{code}.csv" + if not fp.exists(): + logger.warning("%s 不存在,跳过", fp.name) + continue + df = pd.read_csv(fp, parse_dates=["date"]).sort_values("date") + frames[code] = df + return frames + + +def load_config(cfg_path: Path) -> List[Dict[str, Any]]: + if not cfg_path.exists(): + logger.error("配置文件 %s 不存在", cfg_path) + sys.exit(1) + with cfg_path.open(encoding="utf-8") as f: + cfg_raw = json.load(f) + + # 兼容三种结构:单对象、对象数组、或带 selectors 键 + if isinstance(cfg_raw, list): + cfgs = cfg_raw + elif isinstance(cfg_raw, dict) and "selectors" in cfg_raw: + cfgs = cfg_raw["selectors"] + else: + cfgs = [cfg_raw] + + if not cfgs: + logger.error("configs.json 未定义任何 Selector") + sys.exit(1) + + return cfgs + + +def instantiate_selector(cfg: Dict[str, Any]): + """动态加载 Selector 类并实例化""" + cls_name: str = cfg.get("class") + if not cls_name: + raise ValueError("缺少 class 字段") + + try: + module = importlib.import_module("Selector") + cls = getattr(module, cls_name) + except (ModuleNotFoundError, AttributeError) as e: + raise ImportError(f"无法加载 Selector.{cls_name}: {e}") from e + + params = cfg.get("params", {}) + return cfg.get("alias", cls_name), cls(**params) + + +# ---------- 主函数 ---------- + +def main(): + p = argparse.ArgumentParser(description="Run selectors defined in configs.json") + p.add_argument("--data-dir", default="./data", help="CSV 行情目录") + p.add_argument("--config", default="./configs.json", help="Selector 配置文件") + p.add_argument("--date", help="交易日 YYYY-MM-DD;缺省=数据最新日期") + p.add_argument("--tickers", default="all", help="'all' 或逗号分隔股票代码列表") + args = p.parse_args() + + # --- 加载行情 --- + data_dir = Path(args.data_dir) + if not data_dir.exists(): + logger.error("数据目录 %s 不存在", data_dir) + sys.exit(1) + + codes = ( + [f.stem for f in data_dir.glob("*.csv")] + if args.tickers.lower() == "all" + else [c.strip() for c in args.tickers.split(",") if c.strip()] + ) + if not codes: + logger.error("股票池为空!") + sys.exit(1) + + data = load_data(data_dir, codes) + if not data: + logger.error("未能加载任何行情数据") + sys.exit(1) + + trade_date = ( + pd.to_datetime(args.date) + if args.date + else max(pd.to_datetime(df["date"].max()) for df in data.values()) + ) + if not args.date: + logger.info("未指定 --date,使用最近日期 %s", trade_date.date()) + + # --- 加载 Selector 配置 --- + selector_cfgs = load_config(Path(args.config)) + + # --- 逐个 Selector 运行 --- + for cfg in selector_cfgs: + if cfg.get("activate", True) is False: + continue + try: + alias, selector = instantiate_selector(cfg) + except Exception as e: + logger.error("跳过配置 %s:%s", cfg, e) + continue + + picks = selector.select(trade_date, data) + + # 将结果写入日志,同时输出到控制台 + logger.info("") + logger.info("============== 选股结果 [%s] ==============", alias) + logger.info("交易日: %s", trade_date.date()) + logger.info("符合条件股票数: %d", len(picks)) + logger.info("%s", ", ".join(picks) if picks else "无符合条件股票") + + +if __name__ == "__main__": + main()