CoolFace
Apppublic

Qionk/a-share-quant

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
ensemble_classifier.py1463 linesDownload Raw Back to predict
1"""2涨跌方向预测 - 二分类模块3========================4独立于回归预测管线,使用 GARCH(1,1) 波动率作为核心特征,5XGBoost + ElasticNet LogisticRegression 二分类器,6严格扩展窗口时间序列验证,智能参数推荐。7 8防泄漏保证:91. 仅使用扩展窗口 (expanding window) 划分,禁止随机 shuffle / k-fold102. GARCH(1,1) 条件波动率仅在各折训练集上拟合,验证集用前向预测113. 所有量价衍生特征(成交量变化率、相对成交量、量价配合度、放量上涨、缩量下跌)保留124. create_clf_features 对时刻 i 仅使用 [i-look_back, i-1] 的信息135. 目标变量 目标涨跌 = 涨跌标签.shift(-1),预测的是下一日涨跌14"""15 16import time17import warnings18import numpy as np19import pandas as pd20from dataclasses import dataclass, field21from typing import List, Tuple, Dict, Optional, Callable22 23from sklearn.linear_model import LogisticRegression24from sklearn.preprocessing import StandardScaler25from sklearn.metrics import roc_auc_score26from scipy.stats import pearsonr27 28from .features import compute_technical_indicators, time_series_split29from .preprocessing import preprocess_data30from .volatility import fit_garch31 32warnings.filterwarnings("ignore")33 34# ── 分类特征列(排除目标涨跌、目标收益率,保留所有量价衍生特征) ──35 36CLF_FEATURE_COLS = [37    # 价格变化38    "pct_change", "日收益率",39    # 多周期动量40    "ret_2d", "ret_3d", "ret_5d", "ret_10d",41    # 均线偏离度42    "close_ma5_bias", "close_ma10_bias", "close_ma20_bias", "ma5_ma10_cross",43    # 波动率44    "volatility_5d", "volatility_10d", "volatility_20d",45    # 技术指标46    "rsi", "rsi_6", "rsi_14", "dif", "macd",47    # K线形态48    "upper_shadow", "lower_shadow", "body_ratio", "high_low_range",49    # 量价配合50    "成交量变化率", "相对成交量", "量价配合度",51    "放量上涨", "缩量下跌",52    # 成交量多周期53    "vol_chg_3d", "vol_chg_5d", "vol_ratio_5d",54    # ── 科技股专用 ──55    # 换手率系列56    "turnover", "turnover_ma5", "turnover_ma10",57    "turnover_bias", "turnover_accel",58    "cum_turnover_5d", "cum_turnover_10d",59    # 跳空缺口60    "gap", "gap_abs", "gap_up", "gap_down",61    # 价格位置62    "price_pos_5d", "price_pos_10d", "price_pos_20d",63    "drawdown_5d", "drawdown_10d", "drawdown_20d",64    # 连涨连跌65    "streak_up", "streak_down",66    # ATR67    "atr_5", "atr_14", "atr_ratio",68    # 涨停板效应69    "near_limit_up", "near_limit_down",70    "limit_up_count_10d", "limit_down_count_10d",71    # 相对强弱(需指数数据,可选)72    "excess_ret", "excess_ret_5d", "excess_ret_10d", "excess_ret_20d",73]74 75 76# ── 数据容器 ──77 78@dataclass79class ClfResult:80    """单分类器训练结果(所有字段对应 OOS 样本)"""81    model_name: str82    model_object: object = None83    # OOS 预测与标签(按折拼接,时间顺序)84    oos_probabilities: np.ndarray = field(default_factory=lambda: np.array([]))85    oos_predictions: np.ndarray = field(default_factory=lambda: np.array([]))86    oos_actuals: np.ndarray = field(default_factory=lambda: np.array([]))87    oos_returns: np.ndarray = field(default_factory=lambda: np.array([]))88    oos_future_ret: np.ndarray = field(default_factory=lambda: np.array([]))89    oos_next_day_ret: np.ndarray = field(default_factory=lambda: np.array([]))90    oos_dates: np.ndarray = field(default_factory=lambda: np.array([]))91    # CV 指标92    fold_metrics: dict = field(default_factory=dict)93    overall_metrics: dict = field(default_factory=dict)94    # 特征重要性(XGBoost 多折平均)95    feature_importance: dict = field(default_factory=dict)96    training_time: float = 0.097    feature_names: list = field(default_factory=list)98    # 最新交易日实时预测(目标涨跌为 NaN 的最后一行)99    latest_proba: float = np.nan100    latest_date: object = None101 102 103# ── 特征工程 ──104 105def create_clf_features(106    df: pd.DataFrame,107    feature_cols: List[str],108    look_back: int,109) -> Tuple[np.ndarray, np.ndarray, List[str], pd.DatetimeIndex,110           np.ndarray, pd.DatetimeIndex]:111    """112    构建分类滞后特征(严格防泄漏)。113 114    对时刻 i,仅使用 [i-look_back, i-1] 的数据构造特征,115    目标 y[i] = df.iloc[i]['目标涨跌'] 预测的是 i→i+1 的涨跌方向。116 117    最后一天(目标涨跌为 NaN)的特征也会构建,但 y 中对应位置为 NaN,118    单独返回为 X_latest / latest_date,供实时预测使用。119 120    返回: (X, y, feature_names, dates, X_latest, latest_date)121      X: (n_labeled, n_features * look_back) 有标签样本特征122      y: (n_labeled,) 0/1 标签(仅非 NaN 行)123      feature_names: "col_lagN" 格式的特征名124      dates: 有标签样本的对齐日期125      X_latest: (1, n_features * look_back) 最新日特征(可能为空)126      latest_date: 最新日日期(可能为 None)127    """128    if "目标涨跌" not in df.columns:129        raise ValueError("df 中缺少 '目标涨跌' 列,请先调用 preprocess_data()")130 131    missing = [c for c in feature_cols if c not in df.columns]132    if missing:133        raise ValueError(f"df 中缺少以下特征列: {missing}")134 135    # 只保留需要的列 + 目标136    cols = feature_cols + ["目标涨跌"]137    df_sub = df[cols].copy()138    # 仅删除特征列有 NaN 的行,保留目标涨跌为 NaN 的最后一天139    df_sub = df_sub.dropna(subset=feature_cols)140 141    if len(df_sub) <= look_back:142        raise ValueError(f"有效数据量 {len(df_sub)} <= look_back {look_back},无法构造特征")143 144    n = len(df_sub)145    X_list = []146    for i in range(look_back, n):147        # 仅使用 i-look_back 到 i-1 的数据,不含 i(防泄漏要求 4)148        row = df_sub[feature_cols].iloc[i - look_back:i].values.flatten()149        X_list.append(row)150 151    X_all = np.array(X_list, dtype=np.float64)152    y_all = df_sub["目标涨跌"].values[look_back:]153    dates_all = df_sub.index[look_back:]154 155    # 分离有标签和无标签样本156    labeled_mask = ~np.isnan(y_all)157    X = X_all[labeled_mask]158    y = y_all[labeled_mask].astype(int)159    dates = dates_all[labeled_mask]160 161    # 最新一天(目标涨跌为 NaN)162    X_latest = np.array([], dtype=np.float64)163    latest_date = None164    unlabeled_mask = ~labeled_mask165    if unlabeled_mask.any():166        X_latest = X_all[unlabeled_mask][-1:]167        latest_date = dates_all[unlabeled_mask][-1]168 169    # 构建特征名170    feature_names = []171    for col in feature_cols:172        for lag in range(look_back, 0, -1):173            feature_names.append(f"{col}_lag{lag}")174 175    return X, y, feature_names, dates, X_latest, latest_date176 177 178# ── 模型构建 ──179 180def build_xgb_classifier(params: dict):181    """XGBoost 二分类器"""182    import xgboost as xgb183    return xgb.XGBClassifier(184        n_estimators=params.get("n_estimators", 100),185        max_depth=params.get("max_depth", 6),186        learning_rate=params.get("learning_rate", 0.1),187        subsample=params.get("subsample", 0.8),188        colsample_bytree=params.get("colsample_bytree", 0.8),189        min_child_weight=params.get("min_child_weight", 1),190        reg_alpha=params.get("reg_alpha", 0.0),191        reg_lambda=params.get("reg_lambda", 1.0),192        objective="binary:logistic",193        eval_metric="logloss",194        random_state=42,195        n_jobs=-1,196        verbosity=0,197    )198 199 200def build_elasticnet(params: dict):201    """ElasticNet LogisticRegression"""202    return LogisticRegression(203        C=params.get("C", 1.0),204        l1_ratio=params.get("l1_ratio", 0.15),205        penalty="elasticnet",206        solver="saga",207        max_iter=params.get("max_iter", 5000),208        tol=params.get("tol", 1e-3),209        random_state=42,210        n_jobs=-1,211    )212 213 214# ── GARCH 波动率特征(防泄漏) ──215 216def _expanding_hist_vol(returns: np.ndarray, min_window: int = 20) -> np.ndarray:217    """扩展窗口历史波动率(GARCH 失败时的回退方案)。218    每一点仅使用该点之前的数据。"""219    vol = np.zeros(len(returns))220    for i in range(len(returns)):221        w = max(i + 1, min_window)222        vol[i] = np.std(returns[max(0, i - w + 1):i + 1])223    return vol224 225 226def _extract_garch_vol_from_model(garch_result) -> np.ndarray:227    """从已拟合的 GARCH 模型提取条件波动率序列。228    conditional_volatility[t] 是 GARCH 模型对时刻 t 的预测波动率,229    基于 t-1 之前的信息——由 arch 库保证。"""230    cond_vol = np.asarray(garch_result.conditional_volatility, dtype=np.float64)231    # 处理 NaN(GARCH 初始化阶段可能产生)232    mask = np.isnan(cond_vol)233    if mask.any():234        first_valid = cond_vol[~mask][0] if (~mask).any() else 0.01235        cond_vol[mask] = first_valid236    return cond_vol237 238 239def _forecast_garch_vol(garch_result, horizon: int) -> np.ndarray:240    """对未来 horizon 期前向预测 GARCH 波动率(仅使用训练集信息)。"""241    try:242        forecast = garch_result.forecast(horizon=horizon)243        var_forecast = forecast.variance.iloc[-1].values244        vol = np.sqrt(np.maximum(var_forecast, 0))245        vol = np.nan_to_num(vol, nan=0.01)246        return vol247    except Exception:248        return np.full(horizon, 0.01)249 250 251# ── 评估指标 ──252 253def calculate_classification_metrics(254    y_true: np.ndarray,255    y_pred_proba: np.ndarray,256    returns: np.ndarray,257    predictions: np.ndarray,258    future_ret: np.ndarray = None,259    forecast_days: int = 1,260    next_day_ret: np.ndarray = None,261) -> dict:262    """263    计算分类及策略指标。264 265    策略逻辑: 预测涨(prob>=threshold)则T日收盘买入、T+1日收盘卖出,266    预测跌则空仓(收益=0)。回测收益 = next_day_ret * predictions(下一日收益)。267    若 next_day_ret 为 None,回退为 returns。268 269    返回:270      auc, ic, ic_pvalue,271      cum_return, ann_return, ann_volatility, sharpe, max_dd, win_rate,272      profit_loss_ratio, accuracy, precision, recall, confusion273    """274    metrics = {}275 276    # AUC277    try:278        unique = np.unique(y_true)279        if len(unique) >= 2:280            metrics["auc"] = float(roc_auc_score(y_true, y_pred_proba))281        else:282            metrics["auc"] = np.nan283    except ValueError:284        metrics["auc"] = np.nan285 286    # IC: 预测概率与下一日实际收益的相关性287    pnl = next_day_ret if next_day_ret is not None else returns288    mask = ~(np.isnan(y_pred_proba) | np.isnan(pnl))289    if mask.sum() >= 10:290        ic, ic_pv = pearsonr(y_pred_proba[mask], pnl[mask])291        metrics["ic"] = float(ic)292        metrics["ic_pvalue"] = float(ic_pv)293    else:294        metrics["ic"] = np.nan295        metrics["ic_pvalue"] = np.nan296 297    # 策略收益: 预测涨做多(future_ret),预测跌空仓(收益=0)298    n = len(predictions)299    # 过滤 future_ret 为 NaN 的行(持有期超过数据末尾时无法验证)300    pnl_slice = pnl[:n]301    valid = ~np.isnan(pnl_slice)302    n_valid = valid.sum()303    strategy_returns = pnl_slice[valid] * predictions[valid]304    if n_valid == 0:305        strategy_returns = np.array([0.0])306    cum_series = np.cumprod(1 + strategy_returns)307    cum_return = cum_series[-1] - 1308    metrics["cum_return"] = float(cum_return)309 310    # 年化收益(252 交易日,按有效样本数)311    if cum_return > -1 and n_valid > 0:312        metrics["ann_return"] = float((1 + cum_return) ** (252 / n_valid) - 1)313    else:314        metrics["ann_return"] = -1.0315 316    # 年化波动率317    if np.std(strategy_returns) > 0 and n_valid > 0:318        metrics["ann_volatility"] = float(np.std(strategy_returns) * np.sqrt(252))319    else:320        metrics["ann_volatility"] = 0.0321 322    # Sharpe(无风险利率 = 0)323    if np.std(strategy_returns) > 0 and n_valid > 0:324        metrics["sharpe"] = float(np.sqrt(252) * np.mean(strategy_returns) / np.std(strategy_returns))325    else:326        metrics["sharpe"] = 0.0327 328    # 最大回撤329    running_max = np.maximum.accumulate(cum_series)330    drawdowns = (cum_series - running_max) / running_max331    metrics["max_dd"] = float(drawdowns.min())332 333    # 胜率(做多时的正确率,按N日持有期收益方向,仅有效行)334    long_mask = predictions[valid] == 1335    if long_mask.sum() > 0:336        metrics["win_rate"] = float((pnl_slice[valid][long_mask] > 0).mean())337    else:338        metrics["win_rate"] = 0.0339 340    # 盈亏比: 平均正收益 / 平均负收益的绝对值341    pos_ret = strategy_returns[strategy_returns > 0]342    neg_ret = strategy_returns[strategy_returns < 0]343    if len(neg_ret) > 0 and np.abs(neg_ret.mean()) > 1e-12:344        metrics["profit_loss_ratio"] = float(pos_ret.mean() / np.abs(neg_ret.mean())) if len(pos_ret) > 0 else np.inf345    else:346        metrics["profit_loss_ratio"] = np.inf if len(pos_ret) > 0 else 0.0347 348    # 混淆矩阵349    tp = int(((predictions == 1) & (y_true[:n] == 1)).sum())350    tn = int(((predictions == 0) & (y_true[:n] == 0)).sum())351    fp = int(((predictions == 1) & (y_true[:n] == 0)).sum())352    fn = int(((predictions == 0) & (y_true[:n] == 1)).sum())353    total = tp + tn + fp + fn354    metrics["confusion"] = {"tp": tp, "tn": tn, "fp": fp, "fn": fn}355    metrics["accuracy"] = (tp + tn) / total if total > 0 else 0.0356    metrics["precision"] = tp / (tp + fp) if (tp + fp) > 0 else 0.0357    metrics["recall"] = tp / (tp + fn) if (tp + fn) > 0 else 0.0358 359    return metrics360 361 362# ── 扩展窗口训练引擎 ──363 364def run_expanding_window(365    X: np.ndarray,366    y: np.ndarray,367    returns: np.ndarray,368    dates: np.ndarray,369    feature_names: List[str],370    models: List[str],371    params: Dict[str, dict],372    n_splits: int = 5,373    min_train_size: int = 100,374    progress_cb: Optional[Callable] = None,375    future_ret: np.ndarray = None,376    forecast_days: int = 1,377    next_day_ret: np.ndarray = None,378    X_latest: np.ndarray = None,379    latest_date: object = None,380    all_returns: np.ndarray = None,381) -> Dict[str, ClfResult]:382    """383    严格扩展窗口时间序列训练 + 验证(禁止随机 shuffle / k-fold)。384 385    每折:386      - 训练集 = [:train_end](含所有历史数据)387      - 验证集 = [train_end:train_end+fold_size](紧接训练集之后)388      - GARCH(1,1) 仅在训练集上拟合 → training cond_vol(已由 arch 库保证无前视)389      - 验证集 GARCH vol = 前向预测(仅使用训练集参数)390 391    X, y, returns, dates: 已对齐的完整数据(由 create_clf_features 产出)392    models: ["XGBoost", "ElasticNet"] 子集393    params: {"XGBoost": {...}, "ElasticNet": {...}}394    future_ret: N日持有期收益(用于回测P&L),None则回退为returns395    forecast_days: 持有天数396 397    返回: {model_name: ClfResult}398    """399    results = {}400 401    for model_name in models:402        t0 = time.time()403 404        result = ClfResult(model_name=model_name)405        result.feature_names = feature_names + ["garch_vol"]406 407        # 仅使用扩展窗口划分(要求 2+3)408        splits = time_series_split(len(X), n_splits=n_splits, min_train_size=min_train_size)409        if len(splits) == 0:410            split_point = int(len(X) * 0.8)411            splits = [(np.arange(split_point), np.arange(split_point, len(X)))]412 413        all_oos_probs = []414        all_oos_preds = []415        all_oos_actuals = []416        all_oos_returns = []417        all_oos_dates = []418        all_oos_future_ret = []419        all_oos_next_day_ret = []420        fold_metrics_list = []421        fold_importances = []422        fold_count = len(splits)423        last_fold_scaler = None  # ElasticNet 最后一折的 scaler424        last_fold_garch_model = None  # 最后一折的 GARCH 模型425        last_fold_train_returns = None426 427        for fold_i, (train_idx, val_idx) in enumerate(splits):428            if progress_cb:429                pct = fold_i / fold_count430                msg = f"[{model_name}] 折 {fold_i + 1}/{fold_count}"431                progress_cb(pct, msg)432 433            X_train = X[train_idx]434            y_train = y[train_idx]435            X_val = X[val_idx]436            y_val = y[val_idx]437            val_dates = dates[val_idx]438            train_returns = returns[train_idx]439            val_returns = returns[val_idx]440            val_future_ret = future_ret[val_idx] if future_ret is not None else val_returns441            val_next_day_ret = next_day_ret[val_idx] if next_day_ret is not None else val_returns442 443            # ── A. 仅在训练集上拟合 GARCH(要求 4: 防未来信息泄漏) ──444            garch_model = fit_garch(train_returns, p=1, q=1, dist='t')445 446            if garch_model is not None:447                garch_vol_train = _extract_garch_vol_from_model(garch_model)448                garch_vol_val = _forecast_garch_vol(garch_model, len(val_idx))449            else:450                # GARCH 不收敛,回退为扩展历史波动率(仍仅用训练集)451                garch_vol_train = _expanding_hist_vol(train_returns, min_window=20)452                garch_vol_val = np.full(len(val_idx), np.std(train_returns[-60:]) if len(train_returns) >= 60 else np.std(train_returns))453 454            # 长度对齐(GARCH 可能少返回首元素)455            if len(garch_vol_train) < len(X_train):456                pad_len = len(X_train) - len(garch_vol_train)457                garch_vol_train = np.concatenate([np.full(pad_len, garch_vol_train[0]), garch_vol_train])458            elif len(garch_vol_train) > len(X_train):459                garch_vol_train = garch_vol_train[-len(X_train):]460 461            garch_vol_train = np.nan_to_num(garch_vol_train, nan=0.01)462            garch_vol_val = np.nan_to_num(garch_vol_val, nan=0.01)463 464            X_train_aug = np.column_stack([X_train, garch_vol_train.reshape(-1, 1)])465            X_val_aug = np.column_stack([X_val, garch_vol_val.reshape(-1, 1)])466 467            # ── B. 训练 + 预测 ──468            if model_name == "XGBoost":469                clf = build_xgb_classifier(params.get(model_name, {}))470                clf.fit(X_train_aug, y_train)471                proba = clf.predict_proba(X_val_aug)[:, 1]472                # 收集每折特征重要性(后续求平均)473                fold_importances.append(dict(zip(474                    feature_names + ["garch_vol"],475                    clf.feature_importances_,476                )))477            elif model_name == "ElasticNet":478                scaler = StandardScaler()479                X_train_scaled = scaler.fit_transform(X_train_aug)480                X_val_scaled = scaler.transform(X_val_aug)481                clf = build_elasticnet(params.get(model_name, {}))482                clf.fit(X_train_scaled, y_train)483                proba = clf.predict_proba(X_val_scaled)[:, 1]484            else:485                raise ValueError(f"不支持的模型: {model_name}")486 487            preds = (proba >= 0.5).astype(int)488 489            all_oos_probs.append(proba)490            all_oos_preds.append(preds)491            all_oos_actuals.append(y_val)492            all_oos_returns.append(val_returns)493            all_oos_future_ret.append(val_future_ret)494            all_oos_next_day_ret.append(val_next_day_ret)495            all_oos_dates.append(val_dates)496 497            # 折级指标(策略P&L用 next_day_ret)498            fold_metrics = calculate_classification_metrics(499                y_val, proba, val_returns, preds,500                future_ret=val_future_ret, forecast_days=forecast_days,501                next_day_ret=val_next_day_ret,502            )503            fold_metrics_list.append(fold_metrics)504 505            result.model_object = clf506            if model_name == "ElasticNet":507                last_fold_scaler = scaler508            last_fold_garch_model = garch_model509            last_fold_train_returns = train_returns510 511        # ── 聚合 OOS(按时序拼接,非末 N 个) ──512        result.oos_probabilities = np.concatenate(all_oos_probs)513        result.oos_predictions = np.concatenate(all_oos_preds).astype(int)514        result.oos_actuals = np.concatenate(all_oos_actuals).astype(int)515        result.oos_returns = np.concatenate(all_oos_returns)516        result.oos_future_ret = np.concatenate(all_oos_future_ret)517        result.oos_next_day_ret = np.concatenate(all_oos_next_day_ret)518        result.oos_dates = np.concatenate(all_oos_dates)519 520        # ── 最新交易日实时预测 ──521        if X_latest is not None and len(X_latest) > 0 and result.model_object is not None:522            try:523                # 用最后一折的训练集收益率构造 GARCH vol524                _ret_for_garch = all_returns if all_returns is not None else last_fold_train_returns525                if _ret_for_garch is not None:526                    gm = fit_garch(_ret_for_garch, p=1, q=1, dist='t')527                    if gm is not None:528                        garch_vol_latest = _forecast_garch_vol(gm, 1)529                    else:530                        vol_std = np.std(_ret_for_garch[-60:]) if len(_ret_for_garch) >= 60 else np.std(_ret_for_garch)531                        garch_vol_latest = np.array([vol_std])532                else:533                    garch_vol_latest = np.array([0.01])534                garch_vol_latest = np.nan_to_num(garch_vol_latest, nan=0.01)535 536                X_lat_aug = np.column_stack([X_latest, garch_vol_latest.reshape(-1, 1)])537 538                if model_name == "ElasticNet" and last_fold_scaler is not None:539                    X_lat_aug = last_fold_scaler.transform(X_lat_aug)540 541                latest_proba = float(result.model_object.predict_proba(X_lat_aug)[0, 1])542                result.latest_proba = latest_proba543                result.latest_date = latest_date544            except Exception:545                pass546 547        # 特征重要性:多折平均(XGBoost)548        if fold_importances:549            avg_imp = {}550            for key in fold_importances[0]:551                avg_imp[key] = float(np.mean([fi.get(key, 0) for fi in fold_importances]))552            result.feature_importance = avg_imp553 554        # 折级指标平均555        if fold_metrics_list:556            avg_metrics = {}557            for key in fold_metrics_list[0]:558                vals = [m[key] for m in fold_metrics_list559                        if not (isinstance(m[key], float) and np.isnan(m[key]))]560                if key == "confusion":561                    avg_metrics[key] = {562                        k: int(np.mean([m["confusion"][k] for m in fold_metrics_list]))563                        for k in fold_metrics_list[0]["confusion"]564                    }565                elif vals:566                    avg_metrics[key] = float(np.mean(vals))567                else:568                    avg_metrics[key] = np.nan569            result.fold_metrics = {570                "fold_scores": fold_metrics_list,571                "cv_avg": avg_metrics,572            }573 574        # 全量 OOS 指标(策略P&L用 next_day_ret)575        result.overall_metrics = calculate_classification_metrics(576            result.oos_actuals, result.oos_probabilities,577            result.oos_returns, result.oos_predictions,578            future_ret=result.oos_future_ret, forecast_days=forecast_days,579            next_day_ret=result.oos_next_day_ret,580        )581 582        result.training_time = time.time() - t0583        results[model_name] = result584 585        if progress_cb:586            progress_cb(1.0, f"[{model_name}] 完成 ({result.training_time:.1f}s)")587 588    return results589 590 591# ── 智能参数推荐 ──592 593def get_recommended_params(n_samples: int, n_features: int = 30) -> dict:594    """根据有效样本量返回推荐参数(针对科技股优化)。595 596    科技股特点:波动大、换手高、趋势短 → 短回溯 + 浅树 + 强正则。597 598    7 档:599      tiny       < 200600      small      200-500601      medium     500-800602      med_large  800-1200603      large      1200-2000604      xlarge     2000-3000605      xxlarge    > 3000606    """607    if n_samples < 200:608        return {609            "mode": "tiny",610            "xgb": {611                "n_estimators": 60, "max_depth": 2, "learning_rate": 0.08,612                "subsample": 0.75, "colsample_bytree": 0.75,613                "min_child_weight": 8, "reg_alpha": 0.5, "reg_lambda": 3.0,614            },615            "elasticnet": {616                "C": 0.5, "l1_ratio": 0.15, "max_iter": 5000, "tol": 1e-3,617            },618            "look_back": 5,619            "n_splits": 3,620        }621    elif n_samples < 500:622        return {623            "mode": "small",624            "xgb": {625                "n_estimators": 80, "max_depth": 2, "learning_rate": 0.06,626                "subsample": 0.75, "colsample_bytree": 0.75,627                "min_child_weight": 8, "reg_alpha": 0.4, "reg_lambda": 3.0,628            },629            "elasticnet": {630                "C": 0.3, "l1_ratio": 0.12, "max_iter": 5000, "tol": 1e-3,631            },632            "look_back": 6,633            "n_splits": 4,634        }635    elif n_samples < 800:636        return {637            "mode": "medium",638            "xgb": {639                "n_estimators": 100, "max_depth": 2, "learning_rate": 0.05,640                "subsample": 0.75, "colsample_bytree": 0.75,641                "min_child_weight": 8, "reg_alpha": 0.4, "reg_lambda": 3.0,642            },643            "elasticnet": {644                "C": 0.2, "l1_ratio": 0.10, "max_iter": 5000, "tol": 1e-4,645            },646            "look_back": 8,647            "n_splits": 5,648        }649    elif n_samples < 1200:650        return {651            "mode": "med_large",652            "xgb": {653                "n_estimators": 120, "max_depth": 2, "learning_rate": 0.05,654                "subsample": 0.75, "colsample_bytree": 0.75,655                "min_child_weight": 8, "reg_alpha": 0.4, "reg_lambda": 3.0,656            },657            "elasticnet": {658                "C": 0.15, "l1_ratio": 0.10, "max_iter": 5000, "tol": 1e-4,659            },660            "look_back": 8,661            "n_splits": 6,662        }663    elif n_samples < 2000:664        return {665            "mode": "large",666            "xgb": {667                "n_estimators": 150, "max_depth": 3, "learning_rate": 0.04,668                "subsample": 0.75, "colsample_bytree": 0.70,669                "min_child_weight": 10, "reg_alpha": 0.4, "reg_lambda": 3.0,670            },671            "elasticnet": {672                "C": 0.10, "l1_ratio": 0.08, "max_iter": 8000, "tol": 1e-4,673            },674            "look_back": 8,675            "n_splits": 7,676        }677    elif n_samples < 3000:678        return {679            "mode": "xlarge",680            "xgb": {681                "n_estimators": 180, "max_depth": 3, "learning_rate": 0.03,682                "subsample": 0.70, "colsample_bytree": 0.65,683                "min_child_weight": 12, "reg_alpha": 0.5, "reg_lambda": 3.5,684            },685            "elasticnet": {686                "C": 0.08, "l1_ratio": 0.06, "max_iter": 8000, "tol": 1e-4,687            },688            "look_back": 10,689            "n_splits": 8,690        }691    else:692        return {693            "mode": "xxlarge",694            "xgb": {695                "n_estimators": 200, "max_depth": 3, "learning_rate": 0.02,696                "subsample": 0.70, "colsample_bytree": 0.60,697                "min_child_weight": 12, "reg_alpha": 0.5, "reg_lambda": 4.0,698            },699            "elasticnet": {700                "C": 0.05, "l1_ratio": 0.05, "max_iter": 10000, "tol": 1e-4,701            },702            "look_back": 10,703            "n_splits": 8,704        }705 706 707def check_params_deviation(current: Dict[str, dict], recommended: dict) -> List[str]:708    """对比当前参数与推荐参数,返回警告列表。"""709    warnings_list = []710 711    xgb_cur = current.get("XGBoost", {})712    xgb_rec = recommended.get("xgb", {})713    for param, rec_val in xgb_rec.items():714        cur_val = xgb_cur.get(param)715        if cur_val is not None and cur_val != rec_val:716            warnings_list.append(717                f"XGBoost.{param}: 当前={cur_val}, 推荐={rec_val}"718            )719 720    en_cur = current.get("ElasticNet", {})721    en_rec = recommended.get("elasticnet", {})722    for param, rec_val in en_rec.items():723        cur_val = en_cur.get(param)724        if cur_val is not None and cur_val != rec_val:725            warnings_list.append(726                f"ElasticNet.{param}: 当前={cur_val}, 推荐={rec_val}"727            )728 729    return warnings_list730 731 732# ── 动态融合权重 ──733 734def _compute_ensemble_weights(result_a: 'ClfResult', result_b: 'ClfResult') -> dict:735    """基于 AUC(40%) + Sharpe(30%) + 胜率(30%) 综合评分计算动态权重"""736 737    def _score(r):738        m = r.overall_metrics739        auc = m.get("auc", 0.5)740        sharpe = m.get("sharpe", 0.0)741        win_rate = m.get("win_rate", 0.5)742        if np.isnan(auc) or auc < 0.5:743            auc = 0.5744        if np.isnan(sharpe) or sharpe < 0:745            sharpe = 0.0746        if np.isnan(win_rate) or win_rate < 0:747            win_rate = 0.0748        return 0.4 * auc + 0.3 * sharpe + 0.3 * win_rate749 750    score_a = _score(result_a)751    score_b = _score(result_b)752    total = score_a + score_b753 754    if total <= 0:755        w_a, w_b = 0.5, 0.5756    else:757        w_a = score_a / total758        w_b = score_b / total759 760    return {result_a.model_name: w_a, result_b.model_name: w_b}761 762 763def _load_index_returns(stock_code: str, start_date, end_date) -> Optional[pd.Series]:764    """765    根据股票代码自动选择对应板块指数,返回日收益率 Series。766    科技股(创业板300xxx/科创板688xxx) → 创业板指(399006)767    主板 → 沪深300(000300)768    加载失败返回 None(不影响训练)769    """770    if not stock_code:771        return None772    try:773        import akshare as ak774        if stock_code.startswith("3"):775            index_code = "399006"776        elif stock_code.startswith("688"):777            index_code = "399006"778        else:779            index_code = "000300"780 781        sd = pd.Timestamp(start_date).strftime("%Y%m%d")782        ed = pd.Timestamp(end_date).strftime("%Y%m%d")783        idx_df = ak.index_zh_a_hist(symbol=index_code, period="daily",784                                     start_date=sd, end_date=ed)785        if idx_df is None or idx_df.empty:786            return None787        idx_df["日期"] = pd.to_datetime(idx_df["日期"])788        idx_df = idx_df.set_index("日期").sort_index()789        idx_ret = idx_df["涨跌幅"] / 100.0790        idx_ret.index.name = None791        return idx_ret792    except Exception:793        return None794 795 796# ── 特征预筛选 ──797 798def _screen_features(799    df_ind: pd.DataFrame,800    available_features: List[str],801    look_back: int,802    top_n: int = 50,803) -> Tuple[List[str], List[dict]]:804    """805    快速 XGBoost 预训练,按 base 特征聚合重要性,保留 top_n 个。806    返回: (selected_features, eliminated_records)807    """808    import xgboost as xgb809 810    X, y, feature_names, _, _, _ = create_clf_features(df_ind, available_features, look_back)811 812    if len(X) < 50:813        return available_features, []814 815    split_idx = int(len(X) * 0.8)816    X_train, X_val = X[:split_idx], X[split_idx:]817    y_train, y_val = y[:split_idx], y[split_idx:]818 819    clf = xgb.XGBClassifier(820        n_estimators=100, max_depth=4, learning_rate=0.05,821        subsample=0.8, colsample_bytree=0.8,822        objective="binary:logistic", eval_metric="logloss",823        random_state=42, n_jobs=-1, verbosity=0,824        early_stopping_rounds=10,825    )826    clf.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)827 828    importances = clf.feature_importances_829 830    base_importance = {}831    for i, feat_name in enumerate(feature_names):832        base = feat_name.rsplit("_lag", 1)[0]833        base_importance[base] = base_importance.get(base, 0.0) + importances[i]834 835    sorted_feats = sorted(base_importance.items(), key=lambda x: x[1], reverse=True)836 837    selected = [f for f, _ in sorted_feats[:top_n]]838    eliminated = []839    for rank, (feat, imp) in enumerate(sorted_feats[top_n:], start=top_n + 1):840        eliminated.append({"feature": feat, "importance": float(imp), "rank": rank})841 842    return selected, eliminated843 844 845def _log_eliminated_features(stock_code: str, records: List[dict], kept_count: int):846    """将淘汰特征写入 MySQL 日志表"""847    if not records or not stock_code:848        return849    try:850        from .stock_data_store import _get_conn851        conn = _get_conn()852        if not conn:853            return854        cur = conn.cursor()855        cur.execute("""856            CREATE TABLE IF NOT EXISTS clf_feature_elimination_log (857                id INT AUTO_INCREMENT PRIMARY KEY,858                stock_code VARCHAR(20) NOT NULL,859                feature_name VARCHAR(100) NOT NULL,860                importance DOUBLE,861                rank_in_total INT,862                total_features INT,863                kept_features INT,864                eliminated_at DATETIME NOT NULL,865                INDEX idx_fe_stock (stock_code),866                INDEX idx_fe_feature (feature_name)867            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4868        """)869        from datetime import datetime870        now = datetime.now()871        total = kept_count + len(records)872        rows = [(stock_code, r["feature"], r["importance"], r["rank"], total, kept_count, now)873                for r in records]874        cur.executemany(875            "INSERT INTO clf_feature_elimination_log "876            "(stock_code, feature_name, importance, rank_in_total, total_features, kept_features, eliminated_at) "877            "VALUES (%s,%s,%s,%s,%s,%s,%s)",878            rows)879        cur.close()880        conn.close()881    except Exception:882        pass883 884 885# ── 完整管线 ──886 887def run_classifier_pipeline(888    df: pd.DataFrame,889    selected_models: List[str],890    params: Dict[str, dict],891    look_back: int = 20,892    n_splits: int = 5,893    progress_cb: Optional[Callable] = None,894    forecast_days: int = 1,895    threshold: float = 0.5,896    stock_code: str = None,897    feature_screen: bool = False,898    top_n_features: int = 50,899):900    """901    分类器完整管线。902 903    1. preprocess_data → 日收益率 / future_ret / 目标涨跌 / 量价衍生特征904    2. compute_technical_indicators → MA / MACD / RSI / 布林带 / OBV 等905    3. create_clf_features → 滞后特征展平(严格防泄漏)906    4. run_expanding_window → 扩展窗口训练+验证 + GARCH 注入907    5. 概率融合 → final_proba = (proba_a + proba_b) / 2, signal = final_proba >= threshold908 909    df: 原始 OHLCV DataFrame(index 为日期)910    selected_models: ["XGBoost", "ElasticNet"] 子集911    params: 每个模型的超参数字典912    look_back: 特征回溯天数913    n_splits: 扩展窗口折数914    progress_cb: callable(pct, msg) 进度回调915    forecast_days: 持有天数(N日持有期收益)916    threshold: 融合概率阈值917 918    返回: (results: Dict[str, ClfResult], ensemble_result: dict | None)919    """920    if progress_cb:921        progress_cb(0.0, "数据预处理中...")922 923    # 0. 尝试加载板块指数日收益率(用于相对强弱特征)924    index_returns = _load_index_returns(stock_code, df.index.min(), df.index.max())925 926    # 1. 预处理(添加 日收益率 / future_ret / 目标涨跌 / 成交量变化率 / 相对成交量 /927    #    量价配合度 / 放量上涨 / 缩量下跌)928    df_proc = preprocess_data(df, forecast_days=forecast_days, index_returns=index_returns)929 930    # 2. 技术指标(添加 MA / MACD / RSI / 布林带 / OBV / vol_ma / vwap 等,931    #    全部仅用历史数据,无前视)932    df_ind = compute_technical_indicators(df_proc)933 934    # 3. 筛选可用特征(仅保留 df 中实际存在的列)935    available_features = [c for c in CLF_FEATURE_COLS if c in df_ind.columns]936    if len(available_features) < 3:937        raise ValueError(f"可用特征不足 ({len(available_features)}),请检查数据完整性")938 939    # 3.5 特征预筛选:XGBoost 快速训练 → 保留 top_n base 特征940    _screening_info = None941    if feature_screen and len(available_features) > top_n_features:942        if progress_cb:943            progress_cb(0.05, f"特征预筛选: {len(available_features)} 个特征中选取 top {top_n_features}...")944        _orig_count = len(available_features)945        available_features, _eliminated = _screen_features(946            df_ind, available_features, look_back, top_n=top_n_features)947        _log_eliminated_features(stock_code, _eliminated, len(available_features))948        _screening_info = {949            "original": _orig_count,950            "kept": len(available_features),951            "eliminated": _eliminated,952            "kept_features": available_features[:],953        }954        if progress_cb:955            progress_cb(0.08, f"特征筛选完成: {_orig_count} → {len(available_features)}")956 957    if progress_cb:958        progress_cb(0.1, "构造滞后特征...")959 960    # 4. 构造特征和目标(X: 纯滞后特征, y: 目标涨跌)961    X, y, feature_names, aligned_dates, X_latest, latest_date = create_clf_features(df_ind, available_features, look_back)962 963    # 5. 用 create_clf_features 返回的对齐日期做标签索引(防 dropna 偏移)964    returns = df_ind.loc[aligned_dates, "日收益率"].values965    future_ret = df_ind.loc[aligned_dates, "future_ret"].values966    next_day_ret = df_ind.loc[aligned_dates, "next_day_ret"].values967    dates = np.array(aligned_dates)968 969    # 全量收益率序列(用于 GARCH 拟合最新日预测)970    all_returns = df_ind["日收益率"].dropna().values971 972    if progress_cb:973        progress_cb(0.15, f"有效样本: {len(X)} 行 × {len(feature_names)} 特征")974 975    # 6. 扩展窗口训练 + 验证976    results = run_expanding_window(977        X=X, y=y, returns=returns, dates=dates,978        feature_names=feature_names,979        models=selected_models,980        params=params,981        n_splits=n_splits,982        progress_cb=progress_cb,983        future_ret=future_ret,984        forecast_days=forecast_days,985        next_day_ret=next_day_ret,986        X_latest=X_latest,987        latest_date=latest_date,988        all_returns=all_returns,989    )990 991    # ── 7. 概率融合(如果两个模型都选中) ──992    ensemble_result = None993    if len(selected_models) == 2:994        model_a, model_b = selected_models[0], selected_models[1]995        if model_a in results and model_b in results:996            proba_a = results[model_a].oos_probabilities997            proba_b = results[model_b].oos_probabilities998            n_common = min(len(proba_a), len(proba_b))999 1000            # 动态权重:基于 AUC(40%) + Sharpe(30%) + 胜率(30%) 综合评分1001            weights = _compute_ensemble_weights(results[model_a], results[model_b])1002            w_a, w_b = weights[model_a], weights[model_b]1003 1004            fused_proba = w_a * proba_a[:n_common] + w_b * proba_b[:n_common]1005            fused_signal = (fused_proba >= threshold).astype(int)1006 1007            y_common = results[model_a].oos_actuals[:n_common]1008            ret_common = results[model_a].oos_returns[:n_common]1009            fut_common = results[model_a].oos_future_ret[:n_common]1010            ndr_common = results[model_a].oos_next_day_ret[:n_common]1011            dates_common = results[model_a].oos_dates[:n_common]1012 1013            ensemble_metrics = calculate_classification_metrics(1014                y_common, fused_proba,1015                ret_common, fused_signal,1016                future_ret=fut_common,1017                forecast_days=forecast_days,1018                next_day_ret=ndr_common,1019            )1020 1021            ensemble_result = {1022                "fused_proba": fused_proba,1023                "fused_signal": fused_signal,1024                "metrics": ensemble_metrics,1025                "weights": {model_a: float(w_a), model_b: float(w_b)},1026                "oos_dates": dates_common,1027                "oos_returns": ret_common,1028                "oos_future_ret": fut_common,1029                "oos_next_day_ret": ndr_common,1030                "oos_actuals": y_common,1031                "threshold": threshold,1032                "forecast_days": forecast_days,1033            }1034 1035            # 融合最新日预测1036            lat_a = results[model_a].latest_proba1037            lat_b = results[model_b].latest_proba1038            if not np.isnan(lat_a) and not np.isnan(lat_b):1039                ensemble_result["latest_proba"] = float(w_a * lat_a + w_b * lat_b)1040                ensemble_result["latest_date"] = results[model_a].latest_date1041                ensemble_result["latest_signal"] = int(ensemble_result["latest_proba"] >= threshold)1042 1043    if progress_cb:1044        progress_cb(1.0, "涨跌预测完成!")1045 1046    if ensemble_result and _screening_info:1047        ensemble_result["screening_info"] = _screening_info1048 1049    return results, ensemble_result, _screening_info1050 1051 1052# ── 自动调参:随机搜索 + 早停 ──1053 1054_TUNE_SEARCH_SPACE = {1055    "look_back": [5, 10, 15, 20, 30, 40],1056    "forecast_days": [1, 2, 3, 5],1057    "n_splits": [4, 5, 6, 7, 8],1058    "xgb_learning_rate": [0.01, 0.03, 0.05, 0.08, 0.1],1059    "xgb_n_estimators": [80, 100, 150, 200, 300],1060    "xgb_max_depth": [3, 4, 5, 6],1061    "xgb_subsample": [0.6, 0.7, 0.8, 0.9],1062    "xgb_colsample_bytree": [0.5, 0.6, 0.7, 0.8],1063    "xgb_min_child_weight": [1, 3, 5, 8, 12],1064    "xgb_reg_alpha": [0, 0.1, 0.3, 0.5, 1.0],1065    "xgb_reg_lambda": [1.0, 2.0, 3.0, 4.0],1066    "en_C": [0.05, 0.1, 0.3, 0.5, 1.0],1067    "en_l1_ratio": [0.05, 0.1, 0.15, 0.3, 0.5],1068}1069 1070 1071def auto_tune_classifier(1072    df: pd.DataFrame,1073    stock_code: str = None,1074    target_auc: float = 0.53,1075    max_trials: int = 30,1076    selected_models: List[str] = None,1077    trial_cb: Optional[Callable] = None,1078) -> dict:1079    """1080    随机搜索参数空间,找到 ensemble AUC >= target_auc 的参数组合。1081 1082    trial_cb: callable(trial_idx, max_trials, trial_result_dict) 每轮回调1083    返回: {"best_params": {...}, "best_auc": float, "trials": [...], "found": bool}1084    """1085    import random as _rand1086 1087    if selected_models is None:1088        selected_models = ["XGBoost", "ElasticNet"]1089 1090    trials = []1091    best_auc = 0.01092    best_params = None1093    tried = set()1094 1095    for trial_idx in range(max_trials):1096        # 随机采样参数1097        sample = {k: _rand.choice(v) for k, v in _TUNE_SEARCH_SPACE.items()}1098        sample_key = tuple(sorted(sample.items()))1099        if sample_key in tried:1100            continue1101        tried.add(sample_key)1102 1103        params = {1104            "XGBoost": {1105                "learning_rate": sample["xgb_learning_rate"],1106                "n_estimators": sample["xgb_n_estimators"],1107                "max_depth": sample["xgb_max_depth"],1108                "subsample": sample["xgb_subsample"],1109                "colsample_bytree": sample["xgb_colsample_bytree"],1110                "min_child_weight": sample["xgb_min_child_weight"],1111                "reg_alpha": sample["xgb_reg_alpha"],1112                "reg_lambda": sample["xgb_reg_lambda"],1113            },1114            "ElasticNet": {1115                "C": sample["en_C"],1116                "l1_ratio": sample["en_l1_ratio"],1117                "max_iter": 5000,1118                "tol": 1e-3,1119            },1120        }1121 1122        t0 = time.time()1123        try:1124            results, ensemble_result, _ = run_classifier_pipeline(1125                df=df,1126                selected_models=selected_models,1127                params=params,1128                look_back=sample["look_back"],1129                n_splits=sample["n_splits"],1130                forecast_days=sample["forecast_days"],1131                threshold=0.5,1132                stock_code=stock_code,1133            )1134        except Exception:1135            continue1136        elapsed = round(time.time() - t0, 1)1137 1138        # 提取 AUC1139        auc = np.nan1140        if ensemble_result and ensemble_result.get("metrics"):1141            auc = ensemble_result["metrics"].get("auc", np.nan)1142        elif results:1143            first_model = list(results.keys())[0]1144            auc = results[first_model].overall_metrics.get("auc", np.nan)1145 1146        if np.isnan(auc):1147            continue1148 1149        trial_result = {1150            "trial": trial_idx + 1,1151            "look_back": sample["look_back"],1152            "forecast_days": sample["forecast_days"],1153            "n_splits": sample["n_splits"],1154            "lr": sample["xgb_learning_rate"],1155            "depth": sample["xgb_max_depth"],1156            "n_est": sample["xgb_n_estimators"],1157            "auc": round(auc, 4),1158            "elapsed": elapsed,1159            "params": params,1160            "sample": sample,1161        }1162        trials.append(trial_result)1163 1164        if auc > best_auc:1165            best_auc = auc1166            best_params = sample1167 1168        if trial_cb:1169            trial_cb(trial_idx + 1, max_trials, trial_result)1170 1171        if auc >= target_auc:1172            break1173 1174    return {1175        "best_params": best_params,1176        "best_auc": round(best_auc, 4),1177        "trials": trials,1178        "found": best_auc >= target_auc,1179    }1180 1181 1182# ── 贝叶斯优化调参 (Optuna TPE) ──1183 1184def auto_tune_optuna(1185    df: pd.DataFrame,1186    stock_code: str = None,1187    target_auc: float = 0.53,1188    max_trials: int = 20,1189    selected_models: List[str] = None,1190    trial_cb: Optional[Callable] = None,1191) -> dict:1192    """1193    使用 Optuna TPE 贝叶斯优化搜索最佳参数,收敛更快。1194 1195    trial_cb: callable(trial_idx, max_trials, trial_result_dict) 每轮回调1196    返回: {"best_params": {...}, "best_auc": float, "trials": [...], "found": bool}1197    """1198    import optuna1199    optuna.logging.set_verbosity(optuna.logging.WARNING)1200 

Showing the first 1,200 of 1463 lines. Download the file for the rest.