diff --git a/core/qmt_dummy.py b/core/qmt_dummy.py index 19207dd..e396e8a 100644 --- a/core/qmt_dummy.py +++ b/core/qmt_dummy.py @@ -242,6 +242,18 @@ class DummyQmtV: """停止市场数据订阅""" PrintLog(LogLevel.INFO, '- 停止市场数据订阅 (模拟)') + def _is_trading_time(self) -> bool: + import zoneinfo + beijing_tz = zoneinfo.ZoneInfo('Asia/Shanghai') + now = datetime.datetime.now(beijing_tz) + if now.weekday() >= 5: + return False + t = now.time() + return ( + datetime.time(9, 30) <= t <= datetime.time(11, 30) or + datetime.time(13, 0) <= t <= datetime.time(15, 0) + ) + def _generate_market_data(self): """生成模拟市场数据""" stocks = ['600519', '000858', '600036', '000001', '000002', '600000'] @@ -263,8 +275,12 @@ class DummyQmtV: base_prices[i] = data['last_price'] self.lastMarketDataUpdateTimestamp = time.time() - self.isMarketActive = True - eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) + if self._is_trading_time(): + self.isMarketActive = True + eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) + else: + self.isMarketActive = False + eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False) time.sleep(3) diff --git a/core/qmt_real.py b/core/qmt_real.py index 206568e..ac14014 100644 --- a/core/qmt_real.py +++ b/core/qmt_real.py @@ -552,16 +552,32 @@ class RealQmtV: eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) eBus.event_bus.publish(eBus.MarketDataUpdate, datas) + def _is_trading_time(self) -> bool: + """判断当前是否在交易时间内(工作日 09:30-11:30 / 13:00-15:00,北京时间 UTC+8)""" + import zoneinfo + beijing_tz = zoneinfo.ZoneInfo("Asia/Shanghai") + now = datetime.datetime.now(beijing_tz) + if now.weekday() >= 5: # 周六、周日 + return False + t = now.time() + morning_start = datetime.time(9, 30) + morning_end = datetime.time(11, 30) + afternoon_start = datetime.time(13, 0) + afternoon_end = datetime.time(15, 0) + return (morning_start <= t <= morning_end) or (afternoon_start <= t <= afternoon_end) + def _market_data_watchdog(self): - """行情活跃监控 — 超过 120 秒无行情则标记市场不活跃""" + """行情活跃监控 — 超过 120 秒无行情 且 非交易时间 则标记市场不活跃""" while True: time.sleep(15) if self.isMarketActive: elapsed = time.time() - self.lastMarketDataUpdateTimestamp - if elapsed > 120: + # 非交易时间直接标记休市;交易时间内才用超时判断 + if not self._is_trading_time() or elapsed > 120: self.isMarketActive = False eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False) - PrintLog(LogLevel.WARNING, f'- [行情] 超过 {elapsed:.0f} 秒无更新,市场标记为不活跃') + if not self._is_trading_time(): + PrintLog(LogLevel.INFO, '- [行情] 非交易时间,市场标记为不活跃') def stopMarketDataSubscription(self): """停止市场数据订阅""" diff --git a/core/scoring/__init__.py b/core/scoring/__init__.py new file mode 100644 index 0000000..41baa95 --- /dev/null +++ b/core/scoring/__init__.py @@ -0,0 +1 @@ +# grid_seeker v6.4 评分模块 diff --git a/core/scoring/cli.py b/core/scoring/cli.py new file mode 100644 index 0000000..bf9d6b6 --- /dev/null +++ b/core/scoring/cli.py @@ -0,0 +1,165 @@ +""" +grid_seeker v6.4 CLI 入口 + +Usage: + python -m core.scoring.cli sync all # 按依赖顺序执行全部同步 + python -m core.scoring.cli sync kline # 仅同步个股+指数K线 + python -m core.scoring.cli sync stocks # 仅同步股票基础信息 + python -m core.scoring.cli sync industry # 仅同步行业映射 + python -m core.scoring.cli sync market # 仅计算市场状态 + python -m core.scoring.cli sync sector # 仅计算行业指数 + python -m core.scoring.cli score # 完整评分管道 (最新交易日) + python -m core.scoring.cli score --date 20260615 # 指定日期 + python -m core.scoring.cli score --dry-run # 试运行 (不写库) + python -m core.scoring.cli check 000001 # 检查单只股票数据充分性 + python -m core.scoring.cli list-candidates # 列出全部候选股 +""" +import sys +from datetime import date, datetime + + +def main(): + if len(sys.argv) < 2: + _usage() + return + + cmd = sys.argv[1] + + if cmd == 'sync': + _cmd_sync() + elif cmd == 'score': + _cmd_score() + elif cmd == 'check': + _cmd_check() + elif cmd == 'list-candidates': + _cmd_list_candidates() + else: + print(f'未知命令: {cmd}') + _usage() + + +def _usage(): + print(__doc__) + + +# ============================================================ +# sync 命令 +# ============================================================ +def _cmd_sync(): + target = sys.argv[2] if len(sys.argv) > 2 else 'all' + from core.scoring.sync import ( + KlineStockSync, KlineIndexSync, + StocksSync, IndustrySync, + MarketRegimeSync, SectorFeaturesSync, + ) + + syncs = { + 'kline': [KlineStockSync, KlineIndexSync], + 'stocks': [StocksSync], + 'industry': [IndustrySync], + 'market': [MarketRegimeSync], + 'sector': [SectorFeaturesSync], + } + + if target == 'all': + # 按依赖顺序执行 + order = [ + ('K线(个股)', KlineStockSync(count=300)), + ('K线(指数)', KlineIndexSync(count=300)), + ('股票信息', StocksSync()), + ('行业映射', IndustrySync()), + ('市场状态', MarketRegimeSync()), + ('行业指数', SectorFeaturesSync()), + ] + for label, sync in order: + print(f'\n===== {label} =====') + sync.run() + print('\n===== 全部同步完成 =====') + elif target in syncs: + for cls in syncs[target]: + cls().run() + else: + print(f'未知同步目标: {target}') + print(f'可用: all, {", ".join(syncs.keys())}') + + +# ============================================================ +# score 命令 +# ============================================================ +def _cmd_score(): + dry_run = '--dry-run' in sys.argv + date_str = None + for i, arg in enumerate(sys.argv): + if arg == '--date' and i + 1 < len(sys.argv): + date_str = sys.argv[i + 1] + break + + if date_str: + trade_date = datetime.strptime(date_str, '%Y%m%d').date() + else: + trade_date = date.today() + + print(f'评分日期: {trade_date}') + + from core.scoring.inference.scorer import GridSeekerPipeline + + engine = GridSeekerPipeline() + rankings = engine.run(trade_date) + + if rankings.empty: + print('无评分结果') + return + + if not dry_run: + engine.persist(rankings, trade_date) + print(f'结果已写入 ScoringResult ({len(rankings)} 条)') + + # 打印 Top-20 + print('\n===== Top-20 =====') + print(f'{"Rank":<6} {"Code":<10} {"Profit":>10} {"Rounds":>10} {"Prob":>10}') + print('-' * 50) + for code, row in rankings.head(20).iterrows(): + print(f'{int(row["score_rank"]):<6} {code:<10} ' + f'{row["stacking_probability"]:>10.4f} ' + f'{row.get("rank_predicted_rounds", 0):>10.2f} ' + f'{row.get("stacking_probability", 0):>10.4f}') + + +# ============================================================ +# check 命令 +# ============================================================ +def _cmd_check(): + if len(sys.argv) < 3: + print('Usage: python -m core.scoring.cli check ') + return + + stock_code = sys.argv[2] + from core.scoring.features.validator import _check_kline_sufficiency + + ok, reason, close = _check_kline_sufficiency(stock_code, date.today()) + if ok: + print(f'{stock_code}: ✅ 通过 (close={close:.2f})') + else: + print(f'{stock_code}: ❌ {reason}') + + +# ============================================================ +# list-candidates 命令 +# ============================================================ +def _cmd_list_candidates(): + from core.scoring.features.validator import load_candidates + + ctx = load_candidates(date.today()) + print(f'候选股总数: {len(ctx.candidates)}') + print(f'排除: {len(ctx.excluded)}') + print(f'\n候选股 (前100):') + for i, code in enumerate(ctx.candidates[:100]): + print(f' {i + 1}. {code}') + if ctx.excluded: + print(f'\n排除原因 (前20):') + for i, (code, reason) in enumerate(list(ctx.excluded.items())[:20]): + print(f' {code}: {reason}') + + +if __name__ == '__main__': + main() diff --git a/core/scoring/config.py b/core/scoring/config.py new file mode 100644 index 0000000..9dd7664 --- /dev/null +++ b/core/scoring/config.py @@ -0,0 +1,54 @@ +""" +grid_seeker v6.6 评分配置常量 +""" +from pathlib import Path +import config as app_config + +# ---- 网格交易参数 ---- +GRID_LOW = 1 # 网格下限 +GRID_HIGH = 11 # 网格上限 +GRID_STEP = 1 # 网格间距(整数格) + +# ---- 候选股过滤 ---- +FILTER_MIN_CLOSE = 8 # 最低收盘价 +FILTER_MAX_CLOSE = 13 # 最高收盘价 +REQUIRE_DAYS = 120 # 最少交易日数 + +# ---- 窗口参数 ---- +WINDOW_180D = 180 # 长窗口(情绪特征) +WINDOW_60D = 60 # 中窗口 +WINDOW_20D = 20 # 短窗口(独立性特征) +ATR_PERIOD = 14 # ATR 周期 + +# ---- 指数 ---- +HS300_CODE = '000300' # 沪深300基准指数 +TRACKED_INDICES = [ + '000001', # 上证指数 + '000300', # 沪深300 + '000852', # 中证1000 + '000905', # 中证500 + '399001', # 深证成指 + '399006', # 创业板指 +] + +# ---- 市场状态 ---- +PANIC_ADVANCE_RATIO = 0.20 # 恐慌日涨跌比阈值 +GREED_ADVANCE_RATIO = 0.55 # 贪婪日涨跌比阈值 +PANIC_TURNOVER_RATIO = 1.5 # 恐慌日量比阈值 + +# ---- 模型文件 ---- +def model_dir() -> Path: + """模型文件目录""" + return app_config.app_dir() / 'models' + +def get_model_path(name: str) -> Path: + """获取指定模型文件路径""" + return model_dir() / f'{name}.pkl' + +# 3 级模型文件名 (v6.6) +RANK_MODEL = 'rank' +TOP_MODEL = 'top' +STACKING_MODEL = 'stacking' + +# Stacking 选股阈值 +STACKING_THRESHOLD = 0.35 \ No newline at end of file diff --git a/core/scoring/features/__init__.py b/core/scoring/features/__init__.py new file mode 100644 index 0000000..ea3dda3 --- /dev/null +++ b/core/scoring/features/__init__.py @@ -0,0 +1,3 @@ +# 特征工程子包 +from core.scoring.features.validator import load_candidates, DataContext +from core.scoring.features.pipeline import FeaturePipeline diff --git a/core/scoring/features/emotion_features.py b/core/scoring/features/emotion_features.py new file mode 100644 index 0000000..b3b334e --- /dev/null +++ b/core/scoring/features/emotion_features.py @@ -0,0 +1,147 @@ +""" +情绪弹性特征 (4维) — calculate_relaxed_emotion_features(df, market_regime) +180 日窗口,筛选 advance_ratio < 0.20 恐慌日 + advance_ratio > 0.55 贪婪日。 +""" +import numpy as np +import pandas as pd +from core.scoring.config import PANIC_ADVANCE_RATIO, GREED_ADVANCE_RATIO, WINDOW_180D + + +def calculate_relaxed_emotion_features(ctx) -> pd.DataFrame: + """计算情绪弹性特征 (4维)""" + kline = ctx.kline.copy() + mkt = ctx.market_regime.copy() if ctx.market_regime is not None else pd.DataFrame() + + if kline.empty or mkt.empty: + return pd.DataFrame() + + # 筛选恐慌日和贪婪日 + panic_dates = set() + greed_dates = set() + + for _, row in mkt.iterrows(): + td = row['trade_date'] + ar = row.get('advance_ratio', 0.5) + if ar < PANIC_ADVANCE_RATIO: + panic_dates.add(td.date() if hasattr(td, 'date') else td) + if ar > GREED_ADVANCE_RATIO: + greed_dates.add(td.date() if hasattr(td, 'date') else td) + + kline = kline.sort_values(['stock_code', 'trade_date']) + kline['td'] = (kline['trade_date'].dt.date + if hasattr(kline['trade_date'], 'dt') + else pd.to_datetime(kline['trade_date']).dt.date) + + candidates = ctx.candidates + + # 计算全市场平均振幅 (用于恐慌/贪婪 ratio) + kline['amp'] = (kline['high'] - kline['low']) / np.where( + kline['open'] > 0, kline['open'], 1 + ) + market_amp = kline.groupby('td')['amp'].mean().to_dict() + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + if len(group) < 20: + continue + + g = group.sort_values('trade_date').tail(WINDOW_180D) + closes = g['close'].values + opens = g['open'].values + highs = g['high'].values + lows = g['low'].values + dates = g['td'].values + amps = (highs - lows) / np.where(opens > 0, opens, 1) + + feat = {} + + # 恐慌日分析 + panic_indices = [i for i, d in enumerate(dates) if d in panic_dates] + if panic_indices: + panic_amp_ratios = [] + panic_drop_ratios = [] + panic_rebounds = [] + + for pi in panic_indices: + td = dates[pi] + mkt_amp_val = market_amp.get(td, amps[pi]) + if mkt_amp_val > 0: + panic_amp_ratios.append(amps[pi] / mkt_amp_val) + + # 恐慌日跌幅 + if pi > 0 and closes[pi - 1] > 0: + drop = (closes[pi] - closes[pi - 1]) / closes[pi - 1] + # 全市场跌幅: 用 advance_ratio 估算 + ar = _get_advance_ratio(mkt, td) + market_drop = -0.02 if ar < PANIC_ADVANCE_RATIO else -0.005 + if market_drop != 0: + panic_drop_ratios.append(drop / market_drop) + + # 恐慌次日反弹 + if pi + 1 < len(g) and closes[pi] > 0: + panic_rebounds.append(highs[pi + 1] / closes[pi] - 1) + + # 64. relaxed_panic_amplitude_ratio + feat['relaxed_panic_amplitude_ratio'] = ( + np.median(panic_amp_ratios) if panic_amp_ratios else 1.0 + ) + + # 65. relaxed_panic_rebound_strength + feat['relaxed_panic_rebound_strength'] = ( + np.median(panic_rebounds) if panic_rebounds else 0.0 + ) + else: + feat['relaxed_panic_amplitude_ratio'] = 1.0 + feat['relaxed_panic_rebound_strength'] = 0.0 + + # 贪婪日分析 + greed_indices = [i for i, d in enumerate(dates) if d in greed_dates] + if greed_indices: + greed_gains = [] + greed_amp_ratios = [] + + for gi in greed_indices: + td = dates[gi] + if gi > 0 and closes[gi - 1] > 0: + gain = (closes[gi] - closes[gi - 1]) / closes[gi - 1] + # 全市场收益 + ar = _get_advance_ratio(mkt, td) + market_gain = 0.01 if ar > GREED_ADVANCE_RATIO else 0.003 + if market_gain > 0: + greed_gains.append(gain / market_gain) + + mkt_amp_val = market_amp.get(td, amps[gi]) + if mkt_amp_val > 0: + greed_amp_ratios.append(amps[gi] / mkt_amp_val) + + # 66. relaxed_greed_relative_gain + feat['relaxed_greed_relative_gain'] = ( + np.median(greed_gains) if greed_gains else 0.0 + ) + + # 67. relaxed_greed_amplitude_ratio + feat['relaxed_greed_amplitude_ratio'] = ( + np.median(greed_amp_ratios) if greed_amp_ratios else 1.0 + ) + else: + feat['relaxed_greed_relative_gain'] = 0.0 + feat['relaxed_greed_amplitude_ratio'] = 1.0 + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') + + +def _get_advance_ratio(mkt_df, trade_date): + """获取指定日期的 advance_ratio""" + if mkt_df is None or mkt_df.empty: + return 0.5 + td = trade_date + match = mkt_df[mkt_df['trade_date'] == td] + if not match.empty: + return match.iloc[0].get('advance_ratio', 0.5) + return 0.5 diff --git a/core/scoring/features/independence_features.py b/core/scoring/features/independence_features.py new file mode 100644 index 0000000..044d746 --- /dev/null +++ b/core/scoring/features/independence_features.py @@ -0,0 +1,97 @@ +""" +大盘独立性特征 (3维) — calculate_independence_features(df) +使用 HS300(000300) 作为 benchmark,最近 20 个交易日 OLS 回归。 +""" +import numpy as np +import pandas as pd +from core.scoring.config import WINDOW_20D +from core.scoring.features.v3_2_features import _ols_slope + + +def calculate_independence_features(ctx) -> pd.DataFrame: + """计算大盘独立性特征 (3维)""" + kline = ctx.kline.copy() + hs300 = ctx.hs300_kline.copy() if ctx.hs300_kline is not None else pd.DataFrame() + + if kline.empty or hs300.empty: + return pd.DataFrame() + + # 准备 HS300 收益率序列 + hs300 = hs300.sort_values('trade_date') + hs300['market_return'] = hs300['close'].pct_change() + hs300['market_amplitude'] = (hs300['high'] - hs300['low']) / hs300['open'] + hs300 = hs300.dropna(subset=['market_return', 'market_amplitude']) + + # 对齐日期 + hs300_dates = set(hs300['trade_date'].dt.date + if hasattr(hs300['trade_date'], 'dt') else hs300['trade_date']) + + kline = kline.sort_values(['stock_code', 'trade_date']) + kline['trade_date_dt'] = (kline['trade_date'].dt.date + if hasattr(kline['trade_date'], 'dt') + else pd.to_datetime(kline['trade_date']).dt.date) + + candidates = ctx.candidates + + # 计算 HS300 最近20日平均振幅 + hs300_tail = hs300.tail(WINDOW_20D) + hs300_avg_amp = hs300_tail['market_amplitude'].mean() if len(hs300_tail) > 0 else 0 + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + if len(group) < 20: + continue + + g = group.sort_values('trade_date').tail(120) + closes = g['close'].values + + # 计算个股日收益率 + stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1) + + # 对齐 HS300 收益率 (取对应日期) + # 简化: 取最近 N 个交易日的数据点 + n = min(WINDOW_20D, len(stock_rets)) + + # 获取 HS300 最近 n 天的 market_return + market_rets = hs300['market_return'].tail(n + 1).values + if len(market_rets) < n: + market_rets = hs300['market_return'].values[-n - 1:] + + # 对齐长度 + min_len = min(n, len(market_rets) - 1, len(stock_rets)) + if min_len < 5: # 至少需要5个数据点做回归 + continue + + stock_ret_window = stock_rets[-min_len:] + market_ret_window = market_rets[-min_len:] + + feat = {} + + # OLS 回归: stock_ret ~ market_return + try: + slope, r_value = _ols_slope(market_ret_window, stock_ret_window) + residuals = stock_ret_window - slope * market_ret_window + + # 58. market_residual_volatility_20d: std(residuals) × √252 + feat['market_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252) + + # 59. market_independence_ratio_20d: 1 - R² + feat['market_independence_ratio_20d'] = 1 - r_value ** 2 + + except Exception: + feat['market_residual_volatility_20d'] = 0 + feat['market_independence_ratio_20d'] = 1 + + # 60. market_amplitude_deviation_20d: 个股平均振幅 - HS300 平均振幅 + g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where( + g['open'].values[-20:] > 0, g['open'].values[-20:], 1 + ) + feat['market_amplitude_deviation_20d'] = np.mean(g_amps) - hs300_avg_amp + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') diff --git a/core/scoring/features/negative_features.py b/core/scoring/features/negative_features.py new file mode 100644 index 0000000..95795cd --- /dev/null +++ b/core/scoring/features/negative_features.py @@ -0,0 +1,84 @@ +""" +负向指标 (5维) — calculate_negative_features(df) +注意: #53 trend_consistency_20d 与 #39 同名不同义,更新字典时会覆盖 #39 +""" +import numpy as np +import pandas as pd + + +def calculate_negative_features(ctx) -> pd.DataFrame: + """计算负向指标 (5维)""" + kline = ctx.kline.copy() + if kline.empty: + return pd.DataFrame() + + kline = kline.sort_values(['stock_code', 'trade_date']) + candidates = ctx.candidates + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + g = group.tail(120) + if len(g) < 20: + continue + + closes = g['close'].values + opens = g['open'].values + highs = g['high'].values + lows = g['low'].values + volumes = g['volume'].values + + feat = {} + + # 53. trend_consistency_20d (负向版本): |mean(return>0) - 0.5| × 100 + # 衡量偏离均衡的程度,越接近50%越弱 + rets_20 = np.diff(closes[-21:]) / np.where(closes[-21:-1] > 0, closes[-21:-1], 1) + feat['trend_consistency_20d'] = abs(np.mean(rets_20 > 0) - 0.5) * 100 + + # 54. max_consecutive_direction_20d: 最大连续同向天数 + rets_sign = np.sign(np.diff(closes[-21:])) + max_consec = 0 + curr_consec = 0 + curr_sign = 0 + for s in rets_sign: + if s != 0 and s == curr_sign: + curr_consec += 1 + elif s != 0: + curr_sign = s + curr_consec = 1 + else: + curr_consec = 0 + max_consec = max(max_consec, curr_consec) + feat['max_consecutive_direction_20d'] = max_consec + + # 55. gap_risk_20d: mean(|open_t - close_{t-1}|/close_{t-1} > 0.02) × 100 + gap_count = 0 + n = 0 + for i in range(max(0, len(g) - 20), len(g)): + if i > 0 and closes[i - 1] > 0: + gap_pct = abs(opens[i] - closes[i - 1]) / closes[i - 1] + if gap_pct > 0.02: + gap_count += 1 + n += 1 + feat['gap_risk_20d'] = gap_count / n * 100 if n > 0 else 0 + + # 56. liquidity_drying_up_20d: min(vol_20d)/mean(vol_60d) + vol_20 = volumes[-20:] + vol_60 = volumes[-60:] if len(volumes) >= 60 else volumes + feat['liquidity_drying_up_20d'] = ( + np.min(vol_20) / np.mean(vol_60) if np.mean(vol_60) > 0 else 1 + ) + + # 57. price_stagnation_20d: (max(high_20d)-min(low_20d))/close × 100 + h20 = np.max(highs[-20:]) + l20 = np.min(lows[-20:]) + feat['price_stagnation_20d'] = ( + (h20 - l20) / closes[-1] * 100 if closes[-1] > 0 else 0 + ) + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') diff --git a/core/scoring/features/pipeline.py b/core/scoring/features/pipeline.py new file mode 100644 index 0000000..5969428 --- /dev/null +++ b/core/scoring/features/pipeline.py @@ -0,0 +1,106 @@ +""" +特征工程编排器 — 串联全部特征组,输出完整特征 DataFrame +""" +import pandas as pd +from datetime import date +from core.scoring.features.validator import load_candidates, DataContext +from core.scoring.features.v3_2_features import calculate_features_v3_2 +from core.scoring.features.v3_3_features import calculate_features_v3_3 +from core.scoring.features.v3_4_features import calculate_features_v3_4 +from core.scoring.features.negative_features import calculate_negative_features +from core.scoring.features.independence_features import calculate_independence_features +from core.scoring.features.sector_features import calculate_sector_independence_features +from core.scoring.features.emotion_features import calculate_relaxed_emotion_features +from core.logger import LogLevel, PrintLog + + +class FeaturePipeline: + """ + 特征工程管道 — 串联 8 组特征计算,输出完整特征矩阵。 + + Usage: + pipeline = FeaturePipeline(trade_date=date.today()) + feature_df = pipeline.run() # DataFrame indexed by stock_code + """ + + def __init__(self, trade_date: date): + self.trade_date = trade_date + self.ctx: DataContext = None + + def run(self) -> pd.DataFrame: + """ + 执行完整特征工程管道。 + 返回: DataFrame indexed by stock_code, columns = 全部特征 + """ + # Stage 0: 加载候选股和数据 + PrintLog(LogLevel.INFO, '[pipeline] Stage 0: 加载候选股...') + self.ctx = load_candidates(self.trade_date) + if not self.ctx.candidates: + PrintLog(LogLevel.WARNING, '[pipeline] 无候选股通过过滤') + return pd.DataFrame() + + PrintLog(LogLevel.INFO, f'[pipeline] 候选股: {len(self.ctx.candidates)}, ' + f'排除: {len(self.ctx.excluded)}') + + # Stage 1: v3.2 基础特征 (20维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 1: v3.2 基础特征 (20维)...') + df = calculate_features_v3_2(self.ctx) + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 2: v3.3 扩展特征 (16维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 2: v3.3 扩展特征 (16维)...') + df_v33 = calculate_features_v3_3(self.ctx) + df = df.join(df_v33, how='inner', rsuffix='_v33') + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 3: v3.4 扩展特征 (16维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 3: v3.4 扩展特征 (16维)...') + df_v34 = calculate_features_v3_4(self.ctx) + df = df.join(df_v34, how='inner', rsuffix='_v34') + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 4: 负向指标 (5维) — 注意 #53 覆盖 #39 + PrintLog(LogLevel.INFO, '[pipeline] Stage 4: 负向指标 (5维)...') + df_neg = calculate_negative_features(self.ctx) + # 使用 update 模式: 负向指标的 trend_consistency_20d 覆盖 v3.4 版本 + common_cols = set(df.columns) & set(df_neg.columns) + for col in common_cols: + df[col] = df_neg[col] # 覆盖 + new_cols = set(df_neg.columns) - common_cols + for col in new_cols: + df[col] = df_neg[col] + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 5: 大盘独立性 (3维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 5: 大盘独立性 (3维)...') + df_ind = calculate_independence_features(self.ctx) + df = df.join(df_ind, how='left') + df[df_ind.columns] = df[df_ind.columns].fillna(0) + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 6: 行业独立性 (3维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 6: 行业独立性 (3维)...') + df_sec = calculate_sector_independence_features(self.ctx) + df = df.join(df_sec, how='left') + df[df_sec.columns] = df[df_sec.columns].fillna(0) + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # Stage 7: 情绪弹性 (4维) + PrintLog(LogLevel.INFO, '[pipeline] Stage 7: 情绪弹性 (4维)...') + df_emo = calculate_relaxed_emotion_features(self.ctx) + df = df.join(df_emo, how='left') + df[df_emo.columns] = df[df_emo.columns].fillna(1.0) + PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features') + + # 添加 latest_close 列 (Meta Ranker 输入) + df['latest_close'] = 0.0 + for code in df.index: + g = self.ctx.kline[self.ctx.kline['stock_code'] == code] + if not g.empty: + g_sorted = g.sort_values('trade_date') + df.at[code, 'latest_close'] = float(g_sorted['close'].iloc[-1]) + + PrintLog(LogLevel.INFO, + f'[pipeline] 完成: {len(df)} 只股票, {len(df.columns)} 维特征') + + return df diff --git a/core/scoring/features/sector_features.py b/core/scoring/features/sector_features.py new file mode 100644 index 0000000..f687485 --- /dev/null +++ b/core/scoring/features/sector_features.py @@ -0,0 +1,88 @@ +""" +行业独立性特征 (3维) — calculate_sector_independence_features(df) +匹配 sector_features_daily,最近 20 个交易日 OLS 回归。 +""" +import numpy as np +import pandas as pd +from core.scoring.config import WINDOW_20D +from core.scoring.features.v3_2_features import _ols_slope + + +def calculate_sector_independence_features(ctx) -> pd.DataFrame: + """计算行业独立性特征 (3维)""" + kline = ctx.kline.copy() + sector_df = ctx.sector_features.copy() if ctx.sector_features is not None else pd.DataFrame() + industry_map = ctx.industry_map + + if kline.empty or sector_df.empty or not industry_map: + return pd.DataFrame() + + sector_df = sector_df.sort_values(['sector_name', 'trade_date']) + kline = kline.sort_values(['stock_code', 'trade_date']) + + candidates = ctx.candidates + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + if len(group) < 20: + continue + + # 获取行业名称 + sector_name = industry_map.get(str(code), '') + if not sector_name: + continue + + # 获取该行业的指数数据 + sector_data = sector_df[sector_df['sector_name'] == sector_name] + if sector_data.empty or len(sector_data) < 5: + continue + + sector_data = sector_data.sort_values('trade_date').tail(120) + sector_rets = sector_data['sector_ret'].values / 100 # 转为小数 + sector_amps = sector_data['sector_amplitude'].values / 100 + + g = group.sort_values('trade_date').tail(120) + closes = g['close'].values + + # 个股日收益率 + stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1) + + # 对齐长度 + n = min(WINDOW_20D, len(stock_rets), len(sector_rets) - 1) + if n < 5: + continue + + stock_ret_window = stock_rets[-n:] + sector_ret_window = sector_rets[-n:] + + feat = {} + + # OLS: stock_ret ~ sector_ret + try: + slope, r_value = _ols_slope(sector_ret_window, stock_ret_window) + residuals = stock_ret_window - slope * sector_ret_window + + # 61. sector_residual_volatility_20d + feat['sector_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252) + + # 62. sector_independence_ratio_20d: 1 - R² + feat['sector_independence_ratio_20d'] = 1 - r_value ** 2 + + except Exception: + feat['sector_residual_volatility_20d'] = 0 + feat['sector_independence_ratio_20d'] = 1 + + # 63. sector_amplitude_deviation_20d: 个股振幅 - 行业平均振幅 + g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where( + g['open'].values[-20:] > 0, g['open'].values[-20:], 1 + ) + sector_avg_amp = np.mean(sector_amps[-20:]) if len(sector_amps) >= 20 else np.mean(sector_amps) + feat['sector_amplitude_deviation_20d'] = np.mean(g_amps) - sector_avg_amp + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') diff --git a/core/scoring/features/v3_2_features.py b/core/scoring/features/v3_2_features.py new file mode 100644 index 0000000..4ef0fd2 --- /dev/null +++ b/core/scoring/features/v3_2_features.py @@ -0,0 +1,203 @@ +""" +基础特征 v3.2 (20维) — calculate_features(df, "v3.2") +""" +import numpy as np +import pandas as pd +import numpy as np +import pandas as pd +from core.scoring.config import GRID_LOW, GRID_HIGH + + +def calculate_features_v3_2(ctx) -> pd.DataFrame: + """ + 计算 v3.2 基础特征 (20维)。 + 返回 DataFrame indexed by stock_code。 + """ + kline = ctx.kline.copy() + if kline.empty: + return pd.DataFrame() + + kline = kline.sort_values(['stock_code', 'trade_date']) + candidates = ctx.candidates + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + g = group.tail(120) # 取最近120日用于大部分窗口 + if len(g) < 20: + continue + + closes = g['close'].values + opens = g['open'].values + highs = g['high'].values + lows = g['low'].values + volumes = g['volume'].values + + feat = {} + + # 1. rolling_grid_ratio_20d: mean(close∈[1,11]) × 100 + recent_20_close = closes[-20:] + feat['rolling_grid_ratio_20d'] = ( + np.mean((recent_20_close >= GRID_LOW) & (recent_20_close <= GRID_HIGH)) * 100 + ) + + # 2. cross_freq_20d: 日高低区间穿越≥1条整数网格线的天数比例 + cross_count = 0 + for i in range(max(0, len(g) - 20), len(g)): + h, l = highs[i], lows[i] + if h > l: + grid_low = int(np.ceil(l)) + grid_high = int(np.floor(h)) + if grid_high >= grid_low: + cross_count += 1 + feat['cross_freq_20d'] = cross_count / min(20, len(g)) * 100 + + # 3. avg_daily_amp: mean((high-low)/open × 100) + amps = (highs - lows) / np.where(opens > 0, opens, 1) * 100 + feat['avg_daily_amp'] = np.mean(amps[-20:]) + + # 4. high_amp_days: mean(振幅>2%) × 100 + feat['high_amp_days'] = np.mean(amps[-20:] > 2) * 100 + + # 5. atr_pct: mean(ATR_14)/close × 100 + atr = _compute_atr(highs, lows, closes, 14) + feat['atr_pct'] = np.mean(atr[-14:]) / closes[-1] * 100 if closes[-1] > 0 else 0 + + # 6. volatility_20d: std(log_return) × √252 × 100 + log_rets = np.diff(np.log(np.maximum(closes, 1e-10))) + vol_20 = np.std(log_rets[-20:], ddof=1) if len(log_rets) >= 20 else 0 + feat['volatility_20d'] = vol_20 * np.sqrt(252) * 100 + + # 7. price_cv: std(close)/mean(close) × 100 + feat['price_cv'] = np.std(closes) / np.mean(closes) * 100 if np.mean(closes) > 0 else 0 + + # 8. bb_width: (MA20+2σ - (MA20-2σ))/MA20 × 100 + ma20 = np.mean(closes[-20:]) + std20 = np.std(closes[-20:], ddof=1) + feat['bb_width'] = (4 * std20) / ma20 * 100 if ma20 > 0 else 0 + + # 9. volume_ratio: mean(vol_20d)/mean(vol_60d) + vol_20m = np.mean(volumes[-20:]) + vol_60m = np.mean(volumes[-60:]) if len(volumes) >= 60 else vol_20m + feat['volume_ratio'] = vol_20m / vol_60m if vol_60m > 0 else 1 + + # 10. obv_slope: OBV序列最近20日线性回归斜率 + obv = _compute_obv(closes, volumes) + feat['obv_slope'] = _ols_slope(np.arange(20), obv[-20:])[0] if len(obv) >= 20 else 0 + + # 11. amplitude_cv: std(振幅)/mean(振幅) + feat['amplitude_cv'] = (np.std(amps[-20:]) / np.mean(amps[-20:]) + if np.mean(amps[-20:]) > 0 else 0) + + # 12. price_entropy: Shannon熵 (10 bins) + feat['price_entropy'] = _shannon_entropy(closes[-60:], bins=10) + + # 13. intraday_trend_strength: mean(|close-open|/open) × 100 + intraday = np.abs(closes[-20:] - opens[-20:]) / np.where(opens[-20:] > 0, opens[-20:], 1) * 100 + feat['intraday_trend_strength'] = np.mean(intraday) + + # 14-17. 交叉特征 (依赖前序特征) + feat['amp_x_grid'] = feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100 + feat['amp_x_grid_vol'] = (feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100 * + feat['volume_ratio'] / 10000) + feat['amp_cv_x_entropy'] = feat['amplitude_cv'] * feat['price_entropy'] + feat['cross_freq_x_bb'] = feat['cross_freq_20d'] * feat['bb_width'] / 100 + + # 18. ln_float_mv: ln(close × total_share + 1) + total_share = _get_total_share(ctx, code) + float_mv = closes[-1] * total_share if total_share else closes[-1] * 1e8 + feat['ln_float_mv'] = np.log(float_mv + 1) + + # 19. mv_vol_interact: ln_float_mv × volatility_20d/100 + feat['mv_vol_interact'] = feat['ln_float_mv'] * feat['volatility_20d'] / 100 + + # 20. small_cap_premium: 1/(浮动市值 + 1) + feat['small_cap_premium'] = 1.0 / (float_mv + 1) + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') + + +# ---- 辅助函数 ---- + +def _compute_atr(highs, lows, closes, period=14): + """计算 ATR""" + n = len(closes) + tr = np.zeros(n) + for i in range(1, n): + h_l = highs[i] - lows[i] + h_c = abs(highs[i] - closes[i - 1]) + l_c = abs(lows[i] - closes[i - 1]) + tr[i] = max(h_l, h_c, l_c) + atr = np.zeros(n) + atr[:period] = np.mean(tr[:period]) + for i in range(period, n): + atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period + return atr + + +def _compute_obv(closes, volumes): + """计算 OBV""" + obv = np.zeros(len(closes)) + obv[0] = volumes[0] + for i in range(1, len(closes)): + if closes[i] > closes[i - 1]: + obv[i] = obv[i - 1] + volumes[i] + elif closes[i] < closes[i - 1]: + obv[i] = obv[i - 1] - volumes[i] + else: + obv[i] = obv[i - 1] + return obv + + +def _shannon_entropy(values, bins=10): + """Shannon 熵""" + if len(values) < bins: + return 0.0 + hist, _ = np.histogram(values, bins=bins) + hist = hist / hist.sum() + hist = hist[hist > 0] + return -np.sum(hist * np.log2(hist)) + + +def _get_total_share(ctx, code): + """从 StockInfo 获取总股本""" + # 尝试各种前缀 + for prefix in ['', 'SH', 'SZ', 'BJ']: + key = f'{code}.{prefix}' if prefix else code + info = ctx.stock_info.get(key, {}) + ts = info.get('total_share', None) + if ts and ts > 0: + return ts + return None + + +def _ols_slope(x, y): + """ + 纯 numpy OLS 线性回归。 + 等价于 scipy.stats.linregress(x, y),返回 (slope, r_value)。 + slope=0 且 r_value=0 表示计算失败(数据不足或方差为零)。 + """ + if len(x) < 2 or len(y) < 2 or len(x) != len(y): + return 0.0, 0.0 + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + x_mean = x.mean() + y_mean = y.mean() + num = np.sum((x - x_mean) * (y - y_mean)) + den = np.sum((x - x_mean) ** 2) + if den < 1e-12: + return 0.0, 0.0 + slope = num / den + ss_xy = num + ss_xx = np.sum((x - x_mean) ** 2) + ss_yy = np.sum((y - y_mean) ** 2) + if ss_xx < 1e-12 or ss_yy < 1e-12: + r_value = 0.0 + else: + r_value = ss_xy / (np.sqrt(ss_xx) * np.sqrt(ss_yy)) + return slope, r_value diff --git a/core/scoring/features/v3_3_features.py b/core/scoring/features/v3_3_features.py new file mode 100644 index 0000000..3f102df --- /dev/null +++ b/core/scoring/features/v3_3_features.py @@ -0,0 +1,130 @@ +""" +扩展特征 v3.3 (16维) +""" +import numpy as np +import pandas as pd +from core.scoring.features.v3_2_features import _ols_slope + + +def calculate_features_v3_3(ctx) -> pd.DataFrame: + """计算 v3.3 扩展特征 (16维)""" + kline = ctx.kline.copy() + if kline.empty: + return pd.DataFrame() + + kline = kline.sort_values(['stock_code', 'trade_date']) + candidates = ctx.candidates + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + g = group.tail(120) + if len(g) < 20: + continue + + closes = g['close'].values + highs = g['high'].values + lows = g['low'].values + volumes = g['volume'].values + + feat = {} + + # 21. grid_touch_count_20d: 高低区间触碰网格线总次数 + feat['grid_touch_count_20d'] = _grid_touch_count(highs[-20:], lows[-20:]) + + # 22. grid_touch_count_60d + h60 = highs[-60:] if len(highs) >= 60 else highs + l60 = lows[-60:] if len(lows) >= 60 else lows + feat['grid_touch_count_60d'] = _grid_touch_count(h60, l60) + + # 23. grid_cross_density_20d + cross_count = 0 + w = min(20, len(g)) + for i in range(len(g) - w, len(g)): + h, l = highs[i], lows[i] + if h > l and int(np.floor(h)) >= int(np.ceil(l)): + cross_count += 1 + feat['grid_cross_density_20d'] = cross_count / w + + # 24. near_grid_line_ratio_20d + recent_closes = closes[-20:] + dist_to_int = np.abs(recent_closes - np.round(recent_closes)) + feat['near_grid_line_ratio_20d'] = np.mean(dist_to_int <= 0.15) * 100 + + # 25. trend_slope_20d + feat['trend_slope_20d'] = _ols_slope(np.arange(20), closes[-20:])[0] + + # 26. trend_slope_60d + c60 = closes[-60:] if len(closes) >= 60 else closes + feat['trend_slope_60d'] = _ols_slope(np.arange(len(c60)), c60)[0] + + # 27. trend_abs_slope_20d + feat['trend_abs_slope_20d'] = abs(feat['trend_slope_20d']) + + # 28. range_position_60d + h60_mx = np.max(highs[-60:]) if len(highs) >= 60 else np.max(highs) + l60_mn = np.min(lows[-60:]) if len(lows) >= 60 else np.min(lows) + feat['range_position_60d'] = (closes[-1] - l60_mn) / (h60_mx - l60_mn) * 100 \ + if h60_mx > l60_mn else 50 + + # 29. drawdown_60d + c60_arr = closes[-60:] if len(closes) >= 60 else closes + cummax = np.maximum.accumulate(c60_arr) + dd = (1 - c60_arr / cummax) * 100 + feat['drawdown_60d'] = np.max(dd) + + # 30. rebound_from_low_60d + feat['rebound_from_low_60d'] = (closes[-1] - l60_mn) / l60_mn * 100 if l60_mn > 0 else 0 + + # 31. dist_to_grid_upper + from core.scoring.config import GRID_HIGH + feat['dist_to_grid_upper'] = max(0, (GRID_HIGH - closes[-1]) / 10 * 100) + + # 32. dist_to_grid_lower + from core.scoring.config import GRID_LOW + feat['dist_to_grid_lower'] = max(0, (closes[-1] - GRID_LOW) / 10 * 100) + + # 33. grid_room_balance + upper = feat['dist_to_grid_upper'] + lower = feat['dist_to_grid_lower'] + feat['grid_room_balance'] = min(upper, lower) / (upper + lower) * 100 \ + if (upper + lower) > 0 else 50 + + # 34. amount_mean_20d + amounts = closes[-20:] * volumes[-20:] + feat['amount_mean_20d'] = np.mean(amounts) + + # 35. amount_cv_20d + feat['amount_cv_20d'] = np.std(amounts) / np.mean(amounts) * 100 \ + if np.mean(amounts) > 0 else 0 + + # 36. turnover_proxy_20d + total_share = _get_total_share(ctx, code) or 1e8 + feat['turnover_proxy_20d'] = np.mean(volumes[-20:]) / (total_share * 1e8) * 100 + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') + + +def _get_total_share(ctx, code): + for prefix in ['', 'SH', 'SZ', 'BJ']: + key = f'{code}.{prefix}' if prefix else code + info = ctx.stock_info.get(key, {}) + ts = info.get('total_share', None) + if ts and ts > 0: + return ts + return None + + +def _grid_touch_count(highs, lows): + count = 0 + for h, l in zip(highs, lows): + if h > l: + grid_low = int(np.ceil(l)) + grid_high = int(np.floor(h)) + count += max(0, grid_high - grid_low + 1) + return count \ No newline at end of file diff --git a/core/scoring/features/v3_4_features.py b/core/scoring/features/v3_4_features.py new file mode 100644 index 0000000..58c60c7 --- /dev/null +++ b/core/scoring/features/v3_4_features.py @@ -0,0 +1,109 @@ +""" +扩展特征 v3.4 (16维) +""" +import numpy as np +import pandas as pd +from core.scoring.features.v3_2_features import _ols_slope +from core.scoring.config import GRID_LOW, GRID_HIGH + + +def calculate_features_v3_4(ctx) -> pd.DataFrame: + """计算 v3.4 扩展特征 (16维)""" + kline = ctx.kline.copy() + if kline.empty: + return pd.DataFrame() + + kline = kline.sort_values(['stock_code', 'trade_date']) + candidates = ctx.candidates + + features = {} + grouped = kline.groupby('stock_code') + + for code, group in grouped: + if code not in candidates: + continue + g = group.tail(120) + if len(g) < 20: + continue + + closes = g['close'].values + opens = g['open'].values + highs = g['high'].values + lows = g['low'].values + volumes = g['volume'].values + + feat = {} + + # 37. down_days_20d + rets_20 = np.diff(closes[-21:]) / closes[-21:-1] + feat['down_days_20d'] = np.mean(rets_20 < 0) * 100 + + # 38. up_days_20d + feat['up_days_20d'] = np.mean(rets_20 > 0) * 100 + + # 39. trend_consistency_20d + feat['trend_consistency_20d'] = max(feat['up_days_20d'], feat['down_days_20d']) + + # 40. ma20_deviation_pct + ma20 = np.mean(closes[-20:]) + feat['ma20_deviation_pct'] = (closes[-1] - ma20) / ma20 * 100 if ma20 > 0 else 0 + + # 41. ma60_deviation_pct + ma60 = np.mean(closes[-60:]) if len(closes) >= 60 else np.mean(closes) + feat['ma60_deviation_pct'] = (closes[-1] - ma60) / ma60 * 100 if ma60 > 0 else 0 + + # 42. ma20_ma60_gap_pct + feat['ma20_ma60_gap_pct'] = (ma20 - ma60) / ma60 * 100 if ma60 > 0 else 0 + + # 43. usable_grid_count_upper + feat['usable_grid_count_upper'] = sum( + 1 for g in range(GRID_LOW, GRID_HIGH + 1) if g > closes[-1]) + + # 44. usable_grid_count_lower + feat['usable_grid_count_lower'] = sum( + 1 for g in range(GRID_LOW, GRID_HIGH + 1) if g < closes[-1]) + + # 45. near_upper_boundary_risk + feat['near_upper_boundary_risk'] = max(0, min(100, (closes[-1] - 9) / 2 * 100)) + + # 46. near_lower_boundary_risk + feat['near_lower_boundary_risk'] = max(0, min(100, (3 - closes[-1]) / 2 * 100)) + + # 47. volume_cv_20d + vol_20 = volumes[-20:] + feat['volume_cv_20d'] = np.std(vol_20) / np.mean(vol_20) * 100 \ + if np.mean(vol_20) > 0 else 0 + + # 48. amount_trend_20d + amounts = closes[-20:] * volumes[-20:] + feat['amount_trend_20d'] = _ols_slope(np.arange(len(amounts)), amounts)[0] + + # 49. low_volume_days_20d + mean_vol = np.mean(vol_20) + feat['low_volume_days_20d'] = np.mean(vol_20 < 0.5 * mean_vol) * 100 + + # 50. close_reversal_count_20d + rets_sign = np.sign(np.diff(closes[-21:])) + reversals = sum( + 1 for i in range(1, len(rets_sign)) + if rets_sign[i] != 0 and rets_sign[i - 1] != 0 and rets_sign[i] != rets_sign[i - 1]) + feat['close_reversal_count_20d'] = reversals + + # 51. range_compression_20d + amps = (highs - lows) / np.where(closes > 0, closes, 1) * 100 + amp_20_mean = np.mean(amps[-20:]) + amp_60_mean = np.mean(amps[-60:]) if len(amps) >= 60 else amp_20_mean + feat['range_compression_20d'] = amp_20_mean / amp_60_mean * 100 if amp_60_mean > 0 else 100 + + # 52. wick_ratio_20d + upper_wick = highs[-20:] - np.maximum(opens[-20:], closes[-20:]) + lower_wick = np.minimum(opens[-20:], closes[-20:]) - lows[-20:] + body = np.abs(closes[-20:] - opens[-20:]) + total_len = highs[-20:] - lows[-20:] + wick_len = upper_wick + lower_wick + feat['wick_ratio_20d'] = np.mean( + wick_len / np.where(total_len > 0, total_len, 1)) * 100 + + features[code] = feat + + return pd.DataFrame.from_dict(features, orient='index') diff --git a/core/scoring/features/validator.py b/core/scoring/features/validator.py new file mode 100644 index 0000000..2f47c5c --- /dev/null +++ b/core/scoring/features/validator.py @@ -0,0 +1,197 @@ +""" +候选股过滤与数据加载 +""" +import pandas as pd +from datetime import date, timedelta +from core.scoring.models import ( + KlineStock, StockInfo, IndustryMapping, + KlineIndex, MarketRegimeDaily, SectorFeaturesDaily, +) +from core.scoring.config import ( + FILTER_MIN_CLOSE, FILTER_MAX_CLOSE, REQUIRE_DAYS, + WINDOW_180D, HS300_CODE, +) +from core.logger import LogLevel, PrintLog + + +class DataContext: + """特征计算所需的全部数据上下文""" + + def __init__(self, trade_date: date): + self.trade_date = trade_date + self.require_days = REQUIRE_DAYS + # 原始数据 + self.kline: pd.DataFrame = None # 候选股 K线 (180d) + self.stock_info: dict = {} # code → StockInfo dict + self.industry_map: dict = {} # code → industry_name + self.hs300_kline: pd.DataFrame = None # HS300 K线 (180d) + self.market_regime: pd.DataFrame = None # 市场状态 (180d) + self.sector_features: pd.DataFrame = None # 行业指数 (180d) + # 候选股列表 + self.candidates: list[str] = [] + self.excluded: dict[str, str] = {} # code → reason + + +def _get_stock_codes_for_date(trade_date: date) -> list: + """获取评分日所有符合条件的股票代码(非ST/退市)""" + rows = (StockInfo + .select(StockInfo.code, StockInfo.listing_status) + .where(StockInfo.listing_status.not_in(['delisted', 'ST'])) + .dicts()) + return [row['code'] for row in rows] + + +def _check_kline_sufficiency(stock_code: str, trade_date: date) -> tuple[bool, str, float]: + """检查单只股票的K线数据是否满足评分条件""" + # 查询最近 REQUIRE_DAYS + 30 (留缓冲) 个交易日 + start = trade_date - timedelta(days=(REQUIRE_DAYS + 60) * 2) + rows = (KlineStock + .select(KlineStock.trade_date, KlineStock.close) + .where( + (KlineStock.stock_code == stock_code) & + (KlineStock.trade_date >= start) & + (KlineStock.trade_date <= trade_date) + ) + .order_by(KlineStock.trade_date.desc()) + .dicts()) + + if not rows: + return False, '无K线数据', 0 + + # 过滤有效收盘价 (close > 0) + valid_rows = [r for r in rows if r['close'] and r['close'] > 0] + if len(valid_rows) < REQUIRE_DAYS: + return False, f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})', 0 + + latest_close = valid_rows[0]['close'] + + # 价格区间检查 + if latest_close < FILTER_MIN_CLOSE: + return False, f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})', latest_close + if latest_close > FILTER_MAX_CLOSE: + return False, f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})', latest_close + + return True, '', latest_close + + +def load_candidates(trade_date: date) -> DataContext: + """ + 加载评分日候选股及全部所需数据。 + 返回 DataContext,包含候选股列表和预加载的原始数据。 + """ + ctx = DataContext(trade_date) + start_180 = trade_date - timedelta(days=365) # 取约1年数据覆盖180个交易日 + + # 1. 获取非ST/退市的全部股票 + all_codes = [r.split('.')[0] for r in _get_stock_codes_for_date(trade_date)] + PrintLog(LogLevel.INFO, f'[validator] 全市场有效股票: {len(all_codes)} 只') + + # 2. 批量加载 KlineStock (180d 窗口) + raw_codes = all_codes # 使用纯数字代码查询 + kline_rows = (KlineStock + .select() + .where( + (KlineStock.stock_code.in_(raw_codes)) & + (KlineStock.trade_date >= start_180) & + (KlineStock.trade_date <= trade_date) + ) + .order_by(KlineStock.stock_code, KlineStock.trade_date) + .dicts()) + + kline_df = pd.DataFrame(kline_rows) + if kline_df.empty: + PrintLog(LogLevel.WARNING, '[validator] KlineStock 无数据') + return ctx + + kline_df['trade_date'] = pd.to_datetime(kline_df['trade_date']) + PrintLog(LogLevel.INFO, f'[validator] K线原始数据: {len(kline_df)} 行') + + # 3. 逐股过滤 + candidates = [] + excluded = {} + grouped = kline_df.groupby('stock_code') + for code, group in grouped: + g_sorted = group.sort_values('trade_date', ascending=False) + valid_rows = g_sorted[g_sorted['close'].notna() & (g_sorted['close'] > 0)] + if len(valid_rows) < REQUIRE_DAYS: + excluded[code] = f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})' + continue + latest_close = valid_rows.iloc[0]['close'] + if latest_close < FILTER_MIN_CLOSE: + excluded[code] = f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})' + continue + if latest_close > FILTER_MAX_CLOSE: + excluded[code] = f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})' + continue + candidates.append(code) + + PrintLog(LogLevel.INFO, + f'[validator] 候选: {len(candidates)} 通过, {len(excluded)} 排除') + + # 4. 裁剪K线到只含候选股 (保留最近180日) + kline_df = kline_df[kline_df['stock_code'].isin(candidates)].copy() + cut_date = trade_date - timedelta(days=365) + kline_df = kline_df[kline_df['trade_date'] >= pd.Timestamp(cut_date)] + + ctx.kline = kline_df + ctx.candidates = candidates + ctx.excluded = excluded + + # 5. 加载 StockInfo + stock_rows = (StockInfo + .select() + .where(StockInfo.code.in_([f'{c}.SH' for c in candidates] + + [f'{c}.SZ' for c in candidates] + + [f'{c}.BJ' for c in candidates])) + .dicts()) + ctx.stock_info = {r['code']: r for r in stock_rows} + + # 6. 加载行业映射: code → industry_name + ind_rows = (IndustryMapping + .select() + .where(IndustryMapping.code.in_(candidates)) + .dicts()) + ctx.industry_map = {r['code']: r['industry_name'] for r in ind_rows} + PrintLog(LogLevel.INFO, f'[validator] 行业映射: {len(ctx.industry_map)} 只') + + # 7. 加载 HS300 K线 + hs300_rows = (KlineIndex + .select() + .where( + (KlineIndex.index_code == HS300_CODE) & + (KlineIndex.trade_date >= start_180) & + (KlineIndex.trade_date <= trade_date) + ) + .order_by(KlineIndex.trade_date) + .dicts()) + ctx.hs300_kline = pd.DataFrame(hs300_rows) + if not ctx.hs300_kline.empty: + ctx.hs300_kline['trade_date'] = pd.to_datetime(ctx.hs300_kline['trade_date']) + + # 8. 加载市场状态 (180d) + mkt_rows = (MarketRegimeDaily + .select() + .where( + (MarketRegimeDaily.trade_date >= start_180) & + (MarketRegimeDaily.trade_date <= trade_date) + ) + .order_by(MarketRegimeDaily.trade_date) + .dicts()) + ctx.market_regime = pd.DataFrame(mkt_rows) + if not ctx.market_regime.empty: + ctx.market_regime['trade_date'] = pd.to_datetime(ctx.market_regime['trade_date']) + + # 9. 加载行业指数 (180d) + sec_rows = (SectorFeaturesDaily + .select() + .where( + (SectorFeaturesDaily.trade_date >= start_180) & + (SectorFeaturesDaily.trade_date <= trade_date) + ) + .order_by(SectorFeaturesDaily.trade_date) + .dicts()) + ctx.sector_features = pd.DataFrame(sec_rows) + if not ctx.sector_features.empty: + ctx.sector_features['trade_date'] = pd.to_datetime(ctx.sector_features['trade_date']) + + return ctx diff --git a/core/scoring/inference/__init__.py b/core/scoring/inference/__init__.py new file mode 100644 index 0000000..4fb257e --- /dev/null +++ b/core/scoring/inference/__init__.py @@ -0,0 +1,2 @@ +# 模型推理子包 +from core.scoring.inference.scorer import GridSeekerPipeline diff --git a/core/scoring/inference/scorer.py b/core/scoring/inference/scorer.py new file mode 100644 index 0000000..0914341 --- /dev/null +++ b/core/scoring/inference/scorer.py @@ -0,0 +1,217 @@ +""" +grid_seeker v6.6 三级模型推理管道 +Rank → Top → Stacking → stacking_probability (最终排序) +""" +import pickle +import numpy as np +import pandas as pd +from pathlib import Path +from datetime import date +from core.scoring.config import ( + get_model_path, RANK_MODEL, TOP_MODEL, STACKING_MODEL, + STACKING_THRESHOLD, +) +from core.scoring.features.pipeline import FeaturePipeline +from core.scoring.models import ScoringResult +from core.database import db +from core.logger import LogLevel, PrintLog + + +# ============================================================ +# Rank 模型输入特征 (52维, v3.4, 直接从模型文件的 selected_features 读取) +# ============================================================ +def _get_rank_features() -> list: + import pickle + from core.scoring.config import get_model_path + path = get_model_path(RANK_MODEL) + with open(path, 'rb') as f: + obj = pickle.load(f) + if isinstance(obj, dict): + sf = obj.get('selected_features', []) + if sf: + return sf + raise RuntimeError("无法从 rank.pkl 读取 selected_features") + +RANK_FEATURE_COLS = _get_rank_features() + + +class GridSeekerPipeline: + """ + grid_seeker v6.6 三级模型评分管道。 + + Usage: + engine = GridSeekerPipeline() + rankings = engine.run(trade_date=date.today()) + # 返回 DataFrame: stock_code, stacking_probability, rank 等 + """ + + def __init__(self, model_dir: Path = None): + self._rank_model = None + self._top_model = None + self._stacking_model = None + + # ---- 模型加载 ---- + + def _load_model(self, name: str): + """加载单个 .pkl 模型""" + path = get_model_path(name) + if not path.exists(): + raise FileNotFoundError(f'模型文件不存在: {path}') + with open(path, 'rb') as f: + obj = pickle.load(f) + # 支持 dict 格式 {"model": lgbm_model, ...} 或直接返回模型对象 + if isinstance(obj, dict): + return obj.get('model', obj) + return obj + + @property + def rank_model(self): + if self._rank_model is None: + self._rank_model = self._load_model(RANK_MODEL) + return self._rank_model + + @property + def top_model(self): + if self._top_model is None: + self._top_model = self._load_model(TOP_MODEL) + return self._top_model + + @property + def stacking_model(self): + if self._stacking_model is None: + self._stacking_model = self._load_model(STACKING_MODEL) + return self._stacking_model + + # ---- 预测 ---- + + def _predict_with_model(self, model, X: pd.DataFrame, feature_cols: list) -> np.ndarray: + """ + 使用模型预测。自动选择特征子集,兼容 sklearn API (predict/predict_proba)。 + """ + available = [c for c in feature_cols if c in X.columns] + missing = set(feature_cols) - set(available) + if missing: + PrintLog(LogLevel.WARNING, + f'[scorer] 缺少特征列 ({len(missing)}): {list(missing)[:5]}...') + + X_sub = X[available].fillna(0).values + + try: + if hasattr(model, 'predict_proba'): + proba = model.predict_proba(X_sub) + if proba.shape[1] >= 2: + return proba[:, 1] + return proba[:, 0] + elif hasattr(model, 'predict'): + return model.predict(X_sub) + else: + return model.predict(X_sub) + except Exception as e: + PrintLog(LogLevel.ERROR, f'[scorer] 模型预测失败: {e}') + raise + + # ---- 主流程 ---- + + def run(self, trade_date: date) -> pd.DataFrame: + """ + 执行完整的 3 级评分管道。 + + Returns: + DataFrame indexed by stock_code, 含 stacking_probability / rank 等列, + 按 stacking_probability 降序排列 + """ + PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.6 评分开始 ({trade_date}) =====') + + # 1. 特征工程 + pipeline = FeaturePipeline(trade_date) + feature_df = pipeline.run() + + if feature_df.empty: + PrintLog(LogLevel.WARNING, '[scorer] 无股票通过特征工程, 终止') + return pd.DataFrame() + + PrintLog(LogLevel.INFO, + f'[scorer] 特征工程完成: {len(feature_df)} stocks, ' + f'{len(feature_df.columns)} dims') + + # 2. Stage 1: Rank 模型 → rank_predicted_rounds (52维) + PrintLog(LogLevel.INFO, '[scorer] Stage 1/3: Rank 模型...') + feature_df['rank_predicted_rounds'] = self._predict_with_model( + self.rank_model, feature_df, RANK_FEATURE_COLS + ) + + # 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52 + rank_predicted_rounds) + PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...') + top_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds'] + feature_df['top_elite_prob'] = self._predict_with_model( + self.top_model, feature_df, top_cols + ) + + # 4. Stage 3: Stacking 模型 → stacking_probability (54维 = 52 + rank + top) + PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...') + stk_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds', 'top_elite_prob'] + feature_df['stacking_probability'] = self._predict_with_model( + self.stacking_model, feature_df, stk_cols + ) + + # 5. 排序(直接用 stacking_probability) + feature_df['score_rank'] = feature_df['stacking_probability'].rank( + ascending=False, method='min' + ).astype(int) + feature_df['candidate_count'] = len(feature_df) + feature_df = feature_df.sort_values('score_rank') + + n_above = (feature_df['stacking_probability'] >= STACKING_THRESHOLD).sum() + PrintLog(LogLevel.INFO, + f'[scorer] 评分完成: {len(feature_df)} 只候选, ' + f'{n_above} 只高于阈值 {STACKING_THRESHOLD}') + PrintLog(LogLevel.INFO, + f'[scorer] Top-5: ' + f'{feature_df.head(5)[["stacking_probability", "rank_predicted_rounds"]].to_dict("index")}') + + return feature_df + + def persist(self, rankings: pd.DataFrame, trade_date: date): + """将评分结果持久化到 ScoringResult 表""" + if rankings.empty: + return + + records = [] + for code, row in rankings.iterrows(): + records.append({ + 'stock_code': str(code), + 'trade_date': trade_date, + 'predicted_profit': float(row.get('stacking_probability', 0)), + 'rank_predicted_rounds': float(row.get('rank_predicted_rounds', 0)) + if 'rank_predicted_rounds' in row else None, + 'top_elite_prob': float(row.get('top_elite_prob', 0)) + if 'top_elite_prob' in row else None, + 'stacking_probability': float(row.get('stacking_probability', 0)) + if 'stacking_probability' in row else None, + 'score_rank': int(row.get('score_rank', 0)), + 'candidate_count': int(row.get('candidate_count', 0)), + }) + + with db.atomic(): + for batch in _chunked(records, 500): + ScoringResult.insert_many(batch).on_conflict_replace().execute() + + PrintLog(LogLevel.INFO, + f'[scorer] 评分结果已持久化: {len(records)} 条') + + def get_top_n(self, trade_date: date, n: int = 50) -> list[dict]: + """查询历史评分 Top-N""" + rows = (ScoringResult + .select() + .where( + (ScoringResult.trade_date == trade_date) & + (ScoringResult.score_rank <= n) + ) + .order_by(ScoringResult.score_rank) + .dicts()) + return list(rows) + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] \ No newline at end of file diff --git a/core/scoring/models.py b/core/scoring/models.py new file mode 100644 index 0000000..a1346e5 --- /dev/null +++ b/core/scoring/models.py @@ -0,0 +1,132 @@ +""" +grid_seeker v6.4 数据库模型 — 6 张数据表 + 1 张评分结果表 +所有模型继承 core.database.BaseModel,复用现有 SQLite 连接。 +""" +from peewee import ( + CharField, DateField, FloatField, IntegerField, + CompositeKey, TextField, +) +from core.database import BaseModel, db + + +# ============================================================ +# 1. kline_stock — 个股日K线 +# ============================================================ +class KlineStock(BaseModel): + stock_code = CharField(max_length=10) # 纯数字6位 + trade_date = DateField() + open = FloatField() + high = FloatField() + low = FloatField() + close = FloatField() + volume = FloatField() # 成交量(股) + + class Meta: + primary_key = CompositeKey('stock_code', 'trade_date') + indexes = ( + (('stock_code', 'trade_date'), False), + ) + + +# ============================================================ +# 2. stocks — 股票基础信息 +# ============================================================ +class StockInfo(BaseModel): + code = CharField(max_length=12, primary_key=True) # 带 SH/SZ/BJ 前缀 + name = CharField(max_length=32) + exchange = CharField(max_length=4) # SH / SZ / BJ + list_date = DateField(null=True) + listing_status = CharField(max_length=16, default='normal') # normal / delisted / ST + total_share = FloatField(null=True) # 总股本(股) + float_share = FloatField(null=True) # 流通股本(股) + share_updated_at = DateField(null=True) # 股本数据同步时间 + + +# ============================================================ +# 3. industry — 股票-行业映射 +# ============================================================ +class IndustryMapping(BaseModel): + code = CharField(max_length=6, primary_key=True) # 纯数字6位 + industry_name = CharField(max_length=64, index=True) + industry_classification = CharField(max_length=32) # 行业分类体系名称 + update_date = DateField() + + +# ============================================================ +# 4. kline_index — 指数日K线 +# ============================================================ +class KlineIndex(BaseModel): + index_code = CharField(max_length=10) # 指数代码(6位数字) + trade_date = DateField() + open = FloatField() + high = FloatField() + low = FloatField() + close = FloatField() + volume = FloatField() + + class Meta: + primary_key = CompositeKey('index_code', 'trade_date') + indexes = ( + (('index_code', 'trade_date'), False), + ) + + +# ============================================================ +# 5. market_regime_daily — 市场状态(本地计算) +# ============================================================ +class MarketRegimeDaily(BaseModel): + trade_date = DateField(primary_key=True) + advancers = FloatField(default=0) # 当日上涨家数 + decliners = FloatField(default=0) # 当日下跌家数 + advance_ratio = FloatField(default=0) # 涨跌比 = advancers/(advancers+decliners) + turnover = FloatField(default=0) # 全市场成交额 + turnover_avg_5d = FloatField(default=0) # 5日滚动均量 + turnover_ratio_5d = FloatField(default=0) # 量比 = turnover/turnover_avg_5d + source = CharField(max_length=32, default='qmt') + is_extreme_panic = IntegerField(default=0) # advance_ratio<0.2 且 turnover_ratio_5d>1.5 + + +# ============================================================ +# 6. sector_features_daily — 行业聚合指数(本地计算) +# ============================================================ +class SectorFeaturesDaily(BaseModel): + trade_date = DateField() + sector_name = CharField(max_length=64) # 与 industry.industry_name 对应 + sector_ret = FloatField(default=0) # 行业日收益率(均值) + sector_amplitude = FloatField(default=0) # 行业平均振幅 + close = FloatField(default=100) # 行业指数(基值100) + ema10 = FloatField(default=0) + ema20 = FloatField(default=0) + ema200 = FloatField(default=0) + score = IntegerField(default=0) # 趋势评分 0/1/2 + + class Meta: + primary_key = CompositeKey('trade_date', 'sector_name') + + +# ============================================================ +# 7. ScoringResult — 评分结果(模型输出写入表) +# ============================================================ +class ScoringResult(BaseModel): + stock_code = CharField(max_length=6) # 纯数字6位 + trade_date = DateField() # 评分日 + predicted_profit = FloatField(default=0) # 最终预测利润(=stacking_probability) + rank_predicted_rounds = FloatField(null=True) # Rank 模型输出 + top_elite_prob = FloatField(null=True) # Top 模型输出 + stacking_probability = FloatField(null=True) # Stacking 模型输出 + score_rank = IntegerField(default=0) # 排名 + candidate_count = IntegerField(default=0) # 候选股总数 + + class Meta: + primary_key = CompositeKey('stock_code', 'trade_date') + + +# ============================================================ +# 建表 +# ============================================================ +ALL_SCORING_TABLES = [ + KlineStock, StockInfo, IndustryMapping, KlineIndex, + MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, +] + +db.create_tables(ALL_SCORING_TABLES) diff --git a/core/scoring/sync/__init__.py b/core/scoring/sync/__init__.py new file mode 100644 index 0000000..6a9dc57 --- /dev/null +++ b/core/scoring/sync/__init__.py @@ -0,0 +1,7 @@ +# 数据同步子包 +from core.scoring.sync.base import BaseSync +from core.scoring.sync.kline_sync import KlineStockSync, KlineIndexSync +from core.scoring.sync.stocks_sync import StocksSync +from core.scoring.sync.industry_sync import IndustrySync +from core.scoring.sync.market_regime import MarketRegimeSync +from core.scoring.sync.sector_features import SectorFeaturesSync diff --git a/core/scoring/sync/base.py b/core/scoring/sync/base.py new file mode 100644 index 0000000..40aabf1 --- /dev/null +++ b/core/scoring/sync/base.py @@ -0,0 +1,41 @@ +""" +数据同步抽象基类 +""" +import abc +from datetime import datetime +from core.logger import LogLevel, PrintLog + + +class BaseSync(abc.ABC): + """数据同步抽象基类,所有同步操作遵循 _fetch → _upsert 模式""" + + def __init__(self): + self.stats = {'inserted': 0, 'updated': 0, 'skipped': 0, 'errors': 0} + + def run(self, **kwargs) -> dict: + """同步入口: 拉取数据 → 写入数据库 → 返回统计""" + name = self.__class__.__name__ + PrintLog(LogLevel.INFO, f'[sync] {name} 开始同步...') + t0 = datetime.now() + try: + data = self._fetch(**kwargs) + self._upsert(data) + elapsed = (datetime.now() - t0).total_seconds() + PrintLog( + LogLevel.INFO, + f'[sync] {name} 完成 ({elapsed:.1f}s) — ' + f'insert={self.stats["inserted"]} update={self.stats["updated"]} ' + f'skip={self.stats["skipped"]} err={self.stats["errors"]}' + ) + except Exception as e: + PrintLog(LogLevel.ERROR, f'[sync] {name} 失败: {e}') + raise + return self.stats + + @abc.abstractmethod + def _fetch(self, **kwargs): + """从数据源拉取原始数据。子类实现。""" + + @abc.abstractmethod + def _upsert(self, data): + """将数据写入数据库。子类实现。""" diff --git a/core/scoring/sync/industry_sync.py b/core/scoring/sync/industry_sync.py new file mode 100644 index 0000000..7c22842 --- /dev/null +++ b/core/scoring/sync/industry_sync.py @@ -0,0 +1,84 @@ +""" +行业映射同步 — 从 QMT get_sector_list + get_stock_list_in_sector 获取 +""" +from datetime import date +from core.scoring.sync.base import BaseSync +from core.scoring.models import IndustryMapping +from core.database import db +from core.logger import LogLevel, PrintLog + + +class IndustrySync(BaseSync): + """行业映射同步 — QMT 行业板块 → IndustryMapping 表(全量替换)""" + + def _fetch(self, **kwargs): + """从 QMT 拉取全部行业板块的成份股映射""" + from xtquant import xtdata + + all_sectors = xtdata.get_sector_list() + PrintLog(LogLevel.INFO, f'[sync] Industry: 共 {len(all_sectors)} 个板块') + + # 尝试使用 get_sector_info 过滤行业板块 + industry_sectors = [] + try: + sector_info = xtdata.get_sector_info() + if sector_info is not None and not sector_info.empty: + for _, row in sector_info.iterrows(): + cat = row.get('category', '') + if '行业' in str(cat): + industry_sectors.append(row['sector']) + except Exception: + pass + + # 如果 get_sector_info 无效,回退到名称过滤 + if not industry_sectors: + for s in all_sectors: + # 排除明显非行业的板块 + skip_markers = ['概念', '风格', '地域', '地区', '指数', '自定义', + 'ETF', 'LOF', '债券', '基金', '期货', '期权'] + if any(m in s for m in skip_markers): + continue + industry_sectors.append(s) + + PrintLog(LogLevel.INFO, f'[sync] Industry: 筛选出 {len(industry_sectors)} 个行业板块') + + # 构建 code → {industry_name, classification} 映射 + mapping = {} # code → (industry_name, classification) + today = date.today() + + for sector_name in industry_sectors: + try: + stocks = xtdata.get_stock_list_in_sector(sector_name) + for full_code in stocks: + code = full_code.split('.')[0] + if code not in mapping: + mapping[code] = { + 'code': code, + 'industry_name': sector_name, + 'industry_classification': 'QMT', + 'update_date': today, + } + except Exception: + self.stats['errors'] += 1 + + self.stats['inserted'] = len(mapping) + return list(mapping.values()) + + def _upsert(self, data: list): + """全量替换: 清空旧数据 → 批量插入新数据""" + if not data: + PrintLog(LogLevel.WARNING, '[sync] Industry: 无数据, 跳过') + return + + with db.atomic(): + IndustryMapping.delete().execute() + for batch in _chunked(data, 500): + IndustryMapping.insert_many(batch).execute() + + PrintLog(LogLevel.INFO, + f'[sync] Industry: 全量替换完成, {len(data)} 条映射') + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] diff --git a/core/scoring/sync/kline_sync.py b/core/scoring/sync/kline_sync.py new file mode 100644 index 0000000..5fb084e --- /dev/null +++ b/core/scoring/sync/kline_sync.py @@ -0,0 +1,222 @@ +""" +K线数据同步 — 个股日K + 指数日K +数据源: QMT xtdata +增量同步: 只拉 max(trade_date) 之后的增量数据 +线程锁: KlineStockSync / KlineIndexSync 各自内部锁 +""" +import pandas as pd +from datetime import date, timedelta +from core.scoring.sync.base import BaseSync +from core.scoring.models import KlineStock, KlineIndex +from core.scoring.config import TRACKED_INDICES +from core.database import db +from core.logger import LogLevel, PrintLog + +BATCH_SIZE = 50 +DEFAULT_COUNT = 300 + +# 全局同步状态标记(字典引用传递,可被外部轮询) +_sync_state = {"kline": False, "index": False, "stocks": False, + "industry": False, "market": False, "sector": False} + + +def is_syncing(key="kline") -> bool: + return _sync_state.get(key, False) + + +def _latest_date(model_cls) -> date | None: + from peewee import fn + row = model_cls.select(fn.MAX(model_cls.trade_date)).scalar() + if isinstance(row, date): + return row + return None + + +def _safe_get(df_dict, code, dt, default=0.0) -> float: + """安全获取 DataFrame 值""" + if df_dict is None: + return default + df = df_dict.get(code) + if df is None or code not in df.index: + return default + try: + val = df.loc[code, dt] + if pd.isna(val): + return default + return float(val) + except Exception: + return default + + +class KlineStockSync(BaseSync): + """个股日K线同步 — 增量:只拉 max(trade_date) 之后的增量数据""" + + def __init__(self, count: int = DEFAULT_COUNT): + super().__init__() + self.count = count + + def _fetch(self, **kwargs): + from xtquant import xtdata + + # 增量判断 + latest = _latest_date(KlineStock) + today = date.today() + if latest is not None and latest >= today: + PrintLog(LogLevel.INFO, f'[sync] KlineStock: 已最新 ({latest}),跳过') + self.stats['skipped'] = 0 + return self.stats + + # 增量起点 + start_date = (latest + timedelta(days=1)) if latest else None + start_str = start_date.strftime('%Y%m%d') if start_date else "" + PrintLog(LogLevel.INFO, + f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}') + + all_stocks = xtdata.get_stock_list_in_sector("沪深A股") + PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股') + field_list = ['open', 'high', 'low', 'close', 'volume'] + total = len(all_stocks) + inserted = 0 + + for i, code in enumerate(all_stocks): + if i > 0 and i % 50 == 0: + PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)') + + try: + xtdata.download_history_data(code, period='1d', start_time=start_str) + except Exception: + self.stats['errors'] += 1 + continue + + try: + result = xtdata.get_market_data( + field_list=field_list, stock_list=[code], period='1d', + count=self.count, dividend_type='none', fill_data=False) + inserted += self._upsert_incremental(code, result, start_date) + except Exception: + self.stats['errors'] += 1 + + self.stats['inserted'] = inserted + PrintLog(LogLevel.INFO, + f'[sync] KlineStock 完成: 新增={inserted} ' + f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}') + return self.stats + + def _upsert_incremental(self, full_code: str, result: dict, start_date) -> int: + if not result: + return 0 + close_df = result.get('close') + if close_df is None or close_df.empty: + return 0 + stock_code = full_code.split('.')[0] + records = [] + for td in close_df.columns: + td_date = td.date() if hasattr(td, 'date') else td + if start_date is not None and td_date <= start_date: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + close_val = close_df.loc[full_code, td] + if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)): + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + records.append({ + 'stock_code': stock_code, + 'trade_date': td_date, + 'open': _safe_get(result.get('open'), full_code, td), + 'high': _safe_get(result.get('high'), full_code, td), + 'low': _safe_get(result.get('low'), full_code, td), + 'close': float(close_val), + 'volume': _safe_get(result.get('volume'), full_code, td), + }) + if records: + with db.atomic(): + for batch in _chunked(records, 500): + KlineStock.insert_many(batch).on_conflict_replace().execute() + return len(records) + return 0 + + def _upsert(self, data): + pass + + +class KlineIndexSync(BaseSync): + """指数日K线同步 — 增量同步""" + + def __init__(self, indices: list = None, count: int = DEFAULT_COUNT): + super().__init__() + self.indices = indices or TRACKED_INDICES + self.count = count + + def _fetch(self, **kwargs): + from xtquant import xtdata + + index_codes = [] + for code in self.indices: + if code.startswith(('000', '001')): + index_codes.append(f'{code}.SH') + elif code.startswith('399'): + index_codes.append(f'{code}.SZ') + else: + index_codes.append(f'{code}.SH') + + latest = _latest_date(KlineIndex) + today = date.today() + if latest is not None and latest >= today: + PrintLog(LogLevel.INFO, f'[sync] KlineIndex: 已最新 ({latest}),跳过') + return {} + + start_date = (latest + timedelta(days=1)) if latest else None + start_str = start_date.strftime('%Y%m%d') if start_date else "" + PrintLog(LogLevel.INFO, + f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}') + + for code in index_codes: + try: + xtdata.download_history_data(code, period='1d', start_time=start_str) + except Exception: + self.stats['errors'] += 1 + + field_list = ['open', 'high', 'low', 'close', 'volume'] + result = xtdata.get_market_data( + field_list=field_list, stock_list=index_codes, period='1d', + count=self.count, dividend_type='none', fill_data=False) + return result or {} + + def _upsert(self, data): + if not data: + return + latest = _latest_date(KlineIndex) + records = [] + close_df = data.get('close') + if close_df is None or close_df.empty: + return + for full_code in close_df.index: + index_code = full_code.split('.')[0] + for td in close_df.columns: + td_date = td.date() if hasattr(td, 'date') else td + if latest is not None and td_date <= latest: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + close_val = close_df.loc[full_code, td] + if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)): + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + records.append({ + 'index_code': index_code, + 'trade_date': td_date, + 'open': _safe_get(data.get('open'), full_code, td), + 'high': _safe_get(data.get('high'), full_code, td), + 'low': _safe_get(data.get('low'), full_code, td), + 'close': float(close_val), + 'volume': _safe_get(data.get('volume'), full_code, td), + }) + if records: + with db.atomic(): + for batch in _chunked(records, 500): + KlineIndex.insert_many(batch).on_conflict_replace().execute() + self.stats['inserted'] = self.stats.get('inserted', 0) + len(records) + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] diff --git a/core/scoring/sync/market_regime.py b/core/scoring/sync/market_regime.py new file mode 100644 index 0000000..3491a86 --- /dev/null +++ b/core/scoring/sync/market_regime.py @@ -0,0 +1,98 @@ +""" +市场状态计算 — 从 kline_stock 聚合生成 market_regime_daily +纯本地计算,不依赖外部数据源。 +""" +import pandas as pd +from peewee import fn, Case +from core.scoring.sync.base import BaseSync +from core.scoring.models import KlineStock, MarketRegimeDaily +from core.scoring.config import PANIC_ADVANCE_RATIO, PANIC_TURNOVER_RATIO +from core.database import db +from core.logger import LogLevel, PrintLog + + +class MarketRegimeSync(BaseSync): + """市场状态同步 — kline_stock 聚合 → market_regime_daily""" + + def _fetch(self, **kwargs): + """从 KlineStock 逐日聚合涨跌家数和成交额""" + PrintLog(LogLevel.INFO, '[sync] MarketRegime: 开始聚合全市场数据...') + + # peewee 聚合查询: 逐日统计 advancers / decliners / turnover + query = (KlineStock + .select( + KlineStock.trade_date, + fn.SUM( + Case(None, [(KlineStock.close > KlineStock.open, 1)], 0) + ).alias('advancers'), + fn.SUM( + Case(None, [(KlineStock.close < KlineStock.open, 1)], 0) + ).alias('decliners'), + fn.SUM(KlineStock.close * KlineStock.volume).alias('turnover'), + ) + .group_by(KlineStock.trade_date) + .order_by(KlineStock.trade_date)) + + rows = list(query.dicts()) + if not rows: + PrintLog(LogLevel.WARNING, '[sync] MarketRegime: KlineStock 表为空') + return None + + df = pd.DataFrame(rows) + df['trade_date'] = pd.to_datetime(df['trade_date']) + df = df.sort_values('trade_date').reset_index(drop=True) + + # 计算涨跌比 + total = df['advancers'] + df['decliners'] + df['advance_ratio'] = (df['advancers'] / total.replace(0, 1)).round(4) + + # 5日滚动均量 + df['turnover_avg_5d'] = (df['turnover'] + .rolling(window=5, min_periods=1) + .mean() + .round(2)) + + # 量比 + df['turnover_ratio_5d'] = (df['turnover'] / + df['turnover_avg_5d'].replace(0, 1)).round(4) + + # 极端恐慌标记 + df['is_extreme_panic'] = ( + (df['advance_ratio'] < PANIC_ADVANCE_RATIO) & + (df['turnover_ratio_5d'] > PANIC_TURNOVER_RATIO) + ).astype(int) + + PrintLog(LogLevel.INFO, + f'[sync] MarketRegime: 聚合完成, {len(df)} 个交易日') + + return df + + def _upsert(self, df): + """写入 MarketRegimeDaily 表""" + if df is None or df.empty: + return + + records = [] + for _, row in df.iterrows(): + records.append({ + 'trade_date': row['trade_date'].date(), + 'advancers': int(row['advancers']), + 'decliners': int(row['decliners']), + 'advance_ratio': float(row['advance_ratio']), + 'turnover': float(row['turnover']), + 'turnover_avg_5d': float(row['turnover_avg_5d']), + 'turnover_ratio_5d': float(row['turnover_ratio_5d']), + 'source': 'qmt', + 'is_extreme_panic': int(row['is_extreme_panic']), + }) + + if records: + with db.atomic(): + for batch in _chunked(records, 500): + MarketRegimeDaily.insert_many(batch).on_conflict_replace().execute() + self.stats['inserted'] += len(records) + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] diff --git a/core/scoring/sync/sector_features.py b/core/scoring/sync/sector_features.py new file mode 100644 index 0000000..f3a203d --- /dev/null +++ b/core/scoring/sync/sector_features.py @@ -0,0 +1,129 @@ +""" +行业聚合指数计算 — 从 kline_stock + industry 生成 sector_features_daily +纯本地计算,不依赖外部数据源。 +""" +import pandas as pd +from core.scoring.sync.base import BaseSync +from core.scoring.models import KlineStock, IndustryMapping, SectorFeaturesDaily +from core.database import db +from core.logger import LogLevel, PrintLog + + +class SectorFeaturesSync(BaseSync): + """行业聚合指数同步 — kline_stock + industry → sector_features_daily""" + + def _fetch(self, **kwargs): + """从数据库加载原始数据, 计算行业指数特征""" + PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...') + + # 1. 加载行业映射: code → industry_name + industries = list(IndustryMapping.select().dicts()) + if not industries: + PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: IndustryMapping 表为空, 请先执行 industry sync') + return None + code_to_industry = {row['code']: row['industry_name'] for row in industries} + PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射') + + # 2. 加载 K 线数据 + kline_rows = (KlineStock + .select( + KlineStock.stock_code, + KlineStock.trade_date, + KlineStock.open, + KlineStock.high, + KlineStock.low, + KlineStock.close, + ) + .order_by(KlineStock.stock_code, KlineStock.trade_date) + .dicts()) + + if not kline_rows: + PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空') + return None + + df = pd.DataFrame(kline_rows) + df['trade_date'] = pd.to_datetime(df['trade_date']) + PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(df)} 条K线数据') + + # 3. 映射行业 + df['sector_name'] = df['stock_code'].map(code_to_industry) + df = df.dropna(subset=['sector_name']) + + # 4. 逐股计算日收益率和振幅 + df = df.sort_values(['stock_code', 'trade_date']) + df['prev_close'] = df.groupby('stock_code')['close'].shift(1) + df['pct_chg'] = (df['close'] - df['prev_close']) / df['prev_close'] * 100 + df['amplitude'] = (df['high'] - df['low']) / df['open'] * 100 + + # 清理无效值 + df = df.dropna(subset=['pct_chg', 'amplitude']) + + # 5. 按行业+日期聚合 + agg = (df.groupby(['trade_date', 'sector_name']) + .agg( + sector_ret=('pct_chg', 'mean'), + sector_amplitude=('amplitude', 'mean'), + ) + .reset_index()) + + # 6. 构建行业指数 (基值=100) + agg = agg.sort_values(['sector_name', 'trade_date']) + agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform( + lambda x: (1 + x / 100).cumprod() * 100 + ) + # 重新基值=100 (每行业独立) + for name, group in agg.groupby('sector_name'): + idx = group.index + first_val = group['sector_index'].iloc[0] + agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100 + + # 7. 计算 EMA 均线 + agg['ema10'] = (agg.groupby('sector_name')['sector_index'] + .transform(lambda x: x.ewm(span=10, min_periods=1).mean())) + agg['ema20'] = (agg.groupby('sector_name')['sector_index'] + .transform(lambda x: x.ewm(span=20, min_periods=1).mean())) + agg['ema200'] = (agg.groupby('sector_name')['sector_index'] + .transform(lambda x: x.ewm(span=200, min_periods=1).mean())) + + # 8. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分 + agg['score'] = ( + (agg['sector_index'] > agg['ema200']).astype(int) + + (agg['ema10'] > agg['ema20']).astype(int) + ) + + PrintLog(LogLevel.INFO, + f'[sync] SectorFeatures: 计算完成, {len(agg)} 行, ' + f'{agg["sector_name"].nunique()} 个行业') + + return agg + + def _upsert(self, agg): + """写入 SectorFeaturesDaily 表""" + if agg is None or agg.empty: + return + + records = [] + for _, row in agg.iterrows(): + records.append({ + 'trade_date': row['trade_date'].date() + if hasattr(row['trade_date'], 'date') else row['trade_date'], + 'sector_name': str(row['sector_name']), + 'sector_ret': round(float(row['sector_ret']), 4), + 'sector_amplitude': round(float(row['sector_amplitude']), 4), + 'close': round(float(row['sector_index']), 4), + 'ema10': round(float(row['ema10']), 4), + 'ema20': round(float(row['ema20']), 4), + 'ema200': round(float(row['ema200']), 4), + 'score': int(row['score']), + }) + + if records: + with db.atomic(): + for batch in _chunked(records, 500): + SectorFeaturesDaily.insert_many(batch).on_conflict_replace().execute() + self.stats['inserted'] += len(records) + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] diff --git a/core/scoring/sync/stocks_sync.py b/core/scoring/sync/stocks_sync.py new file mode 100644 index 0000000..6b46ddb --- /dev/null +++ b/core/scoring/sync/stocks_sync.py @@ -0,0 +1,100 @@ +""" +股票基础信息同步 — 从 QMT get_instrument_detail 获取 +""" +from datetime import date +from core.scoring.sync.base import BaseSync +from core.scoring.models import StockInfo +from core.database import db +from core.logger import LogLevel, PrintLog + +BATCH_SIZE = 200 + + +class StocksSync(BaseSync): + """股票基础信息同步 — QMT get_instrument_detail_list""" + + def _fetch(self, **kwargs): + """从 QMT 拉取全部A股基础信息""" + from xtquant import xtdata + + all_stocks = xtdata.get_stock_list_in_sector("沪深A股") + PrintLog(LogLevel.INFO, f'[sync] Stocks: 获取到 {len(all_stocks)} 只A股') + + result = {} + total = len(all_stocks) + for i in range(0, total, BATCH_SIZE): + batch = all_stocks[i:i + BATCH_SIZE] + try: + details = xtdata.get_instrument_detail_list(batch) + result.update(details) + except Exception as e: + PrintLog(LogLevel.ERROR, + f'[sync] Stocks batch {i}-{min(i + BATCH_SIZE, total)} failed: {e}') + self.stats['errors'] += len(batch) + + return result + + def _upsert(self, data: dict): + """写入 StockInfo 表""" + records = [] + today = date.today() + + for full_code, inst in data.items(): + if not inst: + self.stats['skipped'] += 1 + continue + + code_num = full_code.split('.')[0] + + # 判断交易所 + exchange = inst.get('ExchangeID', '') + if not exchange: + if '.SH' in full_code: + exchange = 'SH' + elif '.SZ' in full_code: + exchange = 'SZ' + elif '.BJ' in full_code: + exchange = 'BJ' + + # OpenDate 格式: 'YYYYMMDD' 或 int + open_date = inst.get('OpenDate', '') + if open_date and len(str(open_date)) == 8: + list_date_val = f'{str(open_date)[:4]}-{str(open_date)[4:6]}-{str(open_date)[6:8]}' + else: + list_date_val = None + + # InstrumentStatus 含义: + # 0/1 = 正常股票(含已退市但仍在QMT列表的) + # -1 = ST / *ST + # 30/31 = *ST + # 已退市股(名字含XD/退)K线不足120日,会在候选股过滤时被排除 + status = inst.get('InstrumentStatus', -1) + if status in (-1, 30, 31): + listing_status = 'ST' + else: + listing_status = 'normal' + + total_share = inst.get('TotalVolume', None) + float_share = inst.get('FloatVolume', None) + + records.append({ + 'code': full_code, + 'name': str(inst.get('InstrumentName', '')), + 'exchange': exchange, + 'list_date': list_date_val, + 'listing_status': listing_status, + 'total_share': float(total_share) if total_share else None, + 'float_share': float(float_share) if float_share else None, + 'share_updated_at': today, + }) + + if records: + with db.atomic(): + for batch in _chunked(records, 500): + StockInfo.insert_many(batch).on_conflict_replace().execute() + self.stats['inserted'] += len(records) + + +def _chunked(lst: list, n: int): + for i in range(0, len(lst), n): + yield lst[i:i + n] diff --git a/core/sfgrid/bus_events.py b/core/sfgrid/bus_events.py index b142206..d17a7d2 100644 --- a/core/sfgrid/bus_events.py +++ b/core/sfgrid/bus_events.py @@ -1,3 +1,7 @@ # 删除交易标的事件 EventTradeTargetUpdate = "trade_target_update" EventTradeTargetDeleted = "trade_target_deleted" + +# 评分系统事件 +EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'} +EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'} diff --git a/core/sfgrid/sfgrid_strategy.py b/core/sfgrid/sfgrid_strategy.py index 6a5bd1a..cd182b4 100644 --- a/core/sfgrid/sfgrid_strategy.py +++ b/core/sfgrid/sfgrid_strategy.py @@ -58,6 +58,7 @@ class SFGridStrategy: event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync) event_bus.subscribe(eBus.MarketOrderTraded, self.onOrderTrade) event_bus.subscribe(eBus.MarketOrderError, self.onOrderError) + event_bus.subscribe(eBus.EventMarketActiveSwitch, self.onMarketActiveSwitch) # 获取当日涨跌停价格(用于价格边界校验) self.todayUpStopPrice = qmtv.dailyUpStop(tradeTarget.stock_code) # type: ignore @@ -139,93 +140,97 @@ class SFGridStrategy: 每个方向都先检查是否已存在同价位订单,避免重复下单 """ # ── 前置检查:市场和策略状态 ── - if not qmtv.isMarketActive or not self.tradeTarget.enabled: - PrintLog(LogLevel.INFO, - f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} ' - f'{self.tradeTarget.enabled}, 不下单') - return - - # 获取当前该标的所有未成交订单 - orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore - - # ── 统一网格逻辑 ── - # grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖 - # grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1] - if self.tradeTarget.grid_index >= 0: - currentIdx = self.tradeTarget.grid_index # type: ignore - - # --- 上方挂卖出单(空单)--- - # 条件: grid_index > 0,即当前位置不是价格最低点,还有向下(卖出)空间 - if currentIdx > 0: - sellIdx = currentIdx - 1 # 向上一个网格 - sellPrice = self.tradeTarget.getPriceGrid()[sellIdx] - sell_remark = self._make_remark(OrderTypeSell, sellIdx) - - # 检查是否已存在同 remark 的卖单(避免重复挂单) - if not any(o.order_remark == sell_remark for o in orders): - # 卖单价格超过涨停价 → 今日无法成交,跳过下单 - if sellPrice > self.todayUpStopPrice: - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] ' - f'上方网格[{sellIdx}]卖价 {sellPrice:.3f} > 涨停价 {self.todayUpStopPrice:.3f},' - f'今日无法下卖单 (当前网格基准 grid-{currentIdx})') - else: - tmpOrderSeq = qmtv.orderAsync( - str(self.tradeTarget.stock_code), - self.tradeTarget.grid_volume, - xtconstant.STOCK_SELL, # 卖出 - sellPrice, - xtconstant.FIX_PRICE, - sell_remark, - self.getName(), - ) - self.orderGrid[sellIdx] = tmpOrderSeq - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' - f'下空单,价格: {sellPrice:.3f}') - else: - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' - f'已存在同价位空单,跳过下单') - - # --- 下方挂买入单(多单)--- - # 条件: grid_index < 价格网格长度-1,即当前位置不是价格最高点,还有向上(买入)空间 - if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1: - buyIdx = currentIdx + 1 # 向下一个网格 - buyPrice = self.tradeTarget.getPriceGrid()[buyIdx] - buy_remark = self._make_remark(OrderTypeBuy, buyIdx) - - # 检查是否已存在同 remark 的买单(避免重复挂单) - if not any(o.order_remark == buy_remark for o in orders): - # 买单价格低于跌停价 → 今日无法成交,跳过下单 - if buyPrice < self.todayDownStopPrice: - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] ' - f'下方网格[{buyIdx}]买价 {buyPrice:.3f} < 跌停价 {self.todayDownStopPrice:.3f},' - f'今日无法下买单 (当前网格基准 grid-{currentIdx})') - else: - tmpOrderSeq = qmtv.orderAsync( - str(self.tradeTarget.stock_code), - self.tradeTarget.grid_volume, - xtconstant.STOCK_BUY, # 买入 - buyPrice, - xtconstant.FIX_PRICE, - buy_remark, - self.getName(), - ) - self.orderGrid[buyIdx] = tmpOrderSeq - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' - f'下多单,价格: {buyPrice:.3f}') - else: - PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' - f'已存在同价位多单,跳过下单') - else: - # grid_index 已到达价格网格上边界,无法再挂买入单(价格已经到顶) + # 注意:这里用 dataUpdateLock 包裹检查和下单操作,防止竞态条件: + # 主线程在检查 isMarketActive 时,另一线程的行情回调可能同时将其设为 True, + # 导致部分标的通过检查下单,部分被拦截(表现为一票有单、一票无单) + with self.dataUpdateLock: + if not qmtv.isMarketActive or not self.tradeTarget.enabled: PrintLog(LogLevel.INFO, - f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' - f'已过下边界,停止多单交易') + f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} ' + f'{self.tradeTarget.enabled}, 不下单') + return + + # 获取当前该标的所有未成交订单 + orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore + + # ── 统一网格逻辑 ── + # grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖 + # grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1] + if self.tradeTarget.grid_index >= 0: + currentIdx = self.tradeTarget.grid_index # type: ignore + + # --- 上方挂卖出单(空单)--- + # 条件: grid_index > 0,即当前位置不是价格最低点,还有向下(卖出)空间 + if currentIdx > 0: + sellIdx = currentIdx - 1 # 向上一个网格 + sellPrice = self.tradeTarget.getPriceGrid()[sellIdx] + sell_remark = self._make_remark(OrderTypeSell, sellIdx) + + # 检查是否已存在同 remark 的卖单(避免重复挂单) + if not any(o.order_remark == sell_remark for o in orders): + # 卖单价格超过涨停价 → 今日无法成交,跳过下单 + if sellPrice > self.todayUpStopPrice: + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] ' + f'上方网格[{sellIdx}]卖价 {sellPrice:.3f} > 涨停价 {self.todayUpStopPrice:.3f},' + f'今日无法下卖单 (当前网格基准 grid-{currentIdx})') + else: + tmpOrderSeq = qmtv.orderAsync( + str(self.tradeTarget.stock_code), + self.tradeTarget.grid_volume, + xtconstant.STOCK_SELL, # 卖出 + sellPrice, + xtconstant.FIX_PRICE, + sell_remark, + self.getName(), + ) + self.orderGrid[sellIdx] = tmpOrderSeq + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' + f'下空单,价格: {sellPrice:.3f}') + else: + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' + f'已存在同价位空单,跳过下单') + + # --- 下方挂买入单(多单)--- + # 条件: grid_index < 价格网格长度-1,即当前位置不是价格最高点,还有向上(买入)空间 + if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1: + buyIdx = currentIdx + 1 # 向下一个网格 + buyPrice = self.tradeTarget.getPriceGrid()[buyIdx] + buy_remark = self._make_remark(OrderTypeBuy, buyIdx) + + # 检查是否已存在同 remark 的买单(避免重复挂单) + if not any(o.order_remark == buy_remark for o in orders): + # 买单价格低于跌停价 → 今日无法成交,跳过下单 + if buyPrice < self.todayDownStopPrice: + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] ' + f'下方网格[{buyIdx}]买价 {buyPrice:.3f} < 跌停价 {self.todayDownStopPrice:.3f},' + f'今日无法下买单 (当前网格基准 grid-{currentIdx})') + else: + tmpOrderSeq = qmtv.orderAsync( + str(self.tradeTarget.stock_code), + self.tradeTarget.grid_volume, + xtconstant.STOCK_BUY, # 买入 + buyPrice, + xtconstant.FIX_PRICE, + buy_remark, + self.getName(), + ) + self.orderGrid[buyIdx] = tmpOrderSeq + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' + f'下多单,价格: {buyPrice:.3f}') + else: + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' + f'已存在同价位多单,跳过下单') + else: + # grid_index 已到达价格网格上边界,无法再挂买入单(价格已经到顶) + PrintLog(LogLevel.INFO, + f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: ' + f'已过下边界,停止多单交易') # ── 标的管理 ────────────────────────────────────────────── diff --git a/core/ui/flet/app_v2.py b/core/ui/flet/app_v2.py index fb76c56..b33eb84 100644 --- a/core/ui/flet/app_v2.py +++ b/core/ui/flet/app_v2.py @@ -414,15 +414,21 @@ class _GridPanel: class _DrawerPanel: """右侧叠加抽屉:市场监控 + 委托 + 成交 + 未分类""" - def __init__(self, data: _DataStore, dialogs=None): + def __init__(self, data: _DataStore, dialogs=None, page=None): self._data = data self._dialogs = dialogs self._tab_orders = ft.Tab(label="当前委托") self._tab_trades = ft.Tab(label="当日成交") + self._tab_dataset = ft.Tab(label="数据集管理") + self._tab_scoring = ft.Tab(label="每日评分") self._order_table = None self._trade_table = None self._uncl_col = None self._market_table = None + self._dataset_table = None + self._scoring_table = None + self._sync_lock = False # 同步线程锁 + self._sync_btns = [] # 同步按钮引用列表 def build(self) -> ft.Control: """返回 overlay Container""" @@ -430,25 +436,32 @@ class _DrawerPanel: self._order_table = ft.ListView(expand=True) self._trade_table = ft.ListView(expand=True) self._market_table = ft.ListView(expand=True) + self._dataset_table = ft.ListView(expand=True) + self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True) + self._scoring_table = ft.ListView(expand=True) bar = ft.TabBar(tabs=[ ft.Tab(label="实时价格监控"), + self._tab_scoring, self._tab_orders, self._tab_trades, + self._tab_dataset, ft.Tab(label="未分类持仓"), ]) view = ft.TabBarView(controls=[ self._build_market_tab(), + self._build_scoring_tab(), self._order_table, self._trade_table, + self._build_dataset_tab(), self._uncl_col, ], expand=True) panel = ft.Container( ft.Column([ - ft.Tabs(ft.Column([bar, view], expand=True), length=4, expand=True), + ft.Tabs(ft.Column([bar, view], expand=True), length=6, expand=True), ], expand=True), - width=700, bgcolor=ft.Colors.SURFACE, + width=750, bgcolor=ft.Colors.SURFACE, ) backdrop = ft.Container(bgcolor='#44000000', expand=True) @@ -477,6 +490,8 @@ class _DrawerPanel: self._refresh_orders() self._refresh_trades() self._refresh_market() + self._refresh_dataset() + self._refresh_scoring() def _refresh_grid(self): # (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间 @@ -551,11 +566,16 @@ class _DrawerPanel: _data_cell(f"{d['last_price']:.3f}", *_C[2]), ] if already_in_pool: - cells.append(_data_cell("已在池", *_C[3], color='#AAAAAA', size=11)) + op_w, _ = _C[3] + cells.append(ft.Container( + ft.Row([ft.Text("已在池", color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)], + alignment=ft.MainAxisAlignment.CENTER), + width=op_w, padding=0)) else: cells.append(ft.Container( - ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓", - on_click=lambda e, code=sc: self._on_add_from_market(code)), + ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓", + on_click=lambda e, code=sc: self._on_add_from_market(code))], + alignment=ft.MainAxisAlignment.CENTER), width=_C[3][0], padding=0)) row = ft.Row(cells, spacing=0) rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2))) @@ -563,6 +583,307 @@ class _DrawerPanel: self._market_table.controls = [ft.Column(rows, spacing=0)] + # ── Tab 5: 数据集管理 ── + # 按依赖顺序: kline → stocks → industry → market → sector + _SYNC_DEPS = { + 'kline': ['kline'], + 'stocks': ['stocks'], + 'industry':['industry'], + 'market': ['market'], + 'sector': ['sector'], + 'all': ['kline', 'stocks', 'industry', 'market', 'sector'], + } + _SYNC_LABELS = { + 'kline': '同步K线', + 'stocks': '同步股票信息', + 'industry':'同步行业映射', + 'market': '计算市场状态', + 'sector': '计算行业指数', + } + + def _build_dataset_tab(self) -> ft.Control: + self._dataset_status = ft.Text("就绪", size=12, color='#888888') + return ft.Column([ + self._dataset_status, + self._dataset_col, + ], expand=True, spacing=6) + + def _run_sync(self, targets: list): + # 线程锁 + if self._sync_lock: + self._dataset_status.value = "同步中,跳过重复点击" + self._dataset_status.update() + return + self._sync_lock = True + self._set_sync_btns_disabled(True) + self._dataset_status.value = "同步中..." + self._dataset_status.update() + import threading + + def _do(): + from core.scoring.sync import ( + KlineStockSync, KlineIndexSync, StocksSync, + IndustrySync, MarketRegimeSync, SectorFeaturesSync, + ) + syncs = { + 'kline': [KlineStockSync(count=300), KlineIndexSync(count=300)], + 'stocks': [StocksSync()], + 'industry':[IndustrySync()], + 'market': [MarketRegimeSync()], + 'sector': [SectorFeaturesSync()], + } + order = [] + for t in targets: + for key in self._SYNC_DEPS.get(t, [t]): + order.extend(syncs.get(key, [])) + for s in order: + try: + s.run() + except Exception: + pass + self._sync_lock = False + self._set_sync_btns_disabled(False) + self._dataset_status.value = "同步完成" + self._dataset_status.update() + self._refresh_dataset() + + threading.Thread(target=_do, daemon=True).start() + + def _set_sync_btns_disabled(self, disabled: bool): + for btn in self._sync_btns: + btn.disabled = disabled + btn.update() + + def _refresh_dataset(self): + from core.scoring.models import ( + KlineStock, KlineIndex, StockInfo, IndustryMapping, + MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, + ) + tables = [ + ("kline_stock", KlineStock, "kline", "trade_date"), + ("kline_index", KlineIndex, "kline", "trade_date"), + ("stocks", StockInfo, "stocks", "share_updated_at"), + ("industry", IndustryMapping, "industry","update_date"), + ("market_regime", MarketRegimeDaily, "market", "trade_date"), + ("sector_features", SectorFeaturesDaily, "sector", "trade_date"), + ("ScoringResult", ScoringResult, "all", "trade_date"), + ] + # 与市场监控Tab一致的列宽布局: (width, expand) + _C = [(150, 0), (80, 0), (100, 0)] + H = ["表名", "记录数", "最新日期"] + + def _hcell(text, w, e): + if e > 0: + return ft.Container( + ft.Text(text, weight=ft.FontWeight.BOLD), padding=4, expand=e) + return ft.Container( + ft.Text(text, weight=ft.FontWeight.BOLD), width=w, padding=4) + + header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0) + self._sync_btns.clear() + if self._sync_lock: + for btn in self._sync_btns: + btn.disabled = True + rows = [header, ft.Divider(height=1, color='#e0e0e0')] + + for name, model_cls, dep_key, date_col in tables: + try: + cnt = model_cls.select().count() + df = getattr(model_cls, date_col, None) + if df is not None: + last = (model_cls.select(df).order_by(df.desc()).first()) + date_str = str(getattr(last, date_col, "")) if last else "" + else: + date_str = "" + except Exception: + cnt, date_str = "ERR", "" + + def _dcell(text, w, e=0): + if e > 0: + return ft.Container(ft.Text(text), padding=4, expand=e) + return ft.Container(ft.Text(text), width=w, padding=4) + + cells = [ + _dcell(name, *_C[0]), + _dcell(str(cnt), *_C[1]), + _dcell(date_str, *_C[2]), + ] + row = ft.Row(cells, spacing=0) + rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2))) + rows.append(ft.Divider(height=1, color='#f0f0f0')) + + self._dataset_col.controls = rows + + # ── Tab 6: 每日评分 ── + def _build_scoring_tab(self) -> ft.Control: + from datetime import date, timedelta + self._score_cur_date = date.today() + self._score_date_label = ft.Text(str(self._score_cur_date), size=14, weight=ft.FontWeight.BOLD) + self._score_status = ft.Text("无评分数据", size=12, color='#888888') + + def _prev_day(_): + self._score_cur_date -= timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._refresh_scoring() + + def _next_day(_): + self._score_cur_date += timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._refresh_scoring() + + def _today(_): + self._score_cur_date = date.today() + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._refresh_scoring() + + date_row = ft.Row([ + ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day), + self._score_date_label, + ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day), + ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today), + ft.Container(ft.Divider(height=20), width=2), + ft.ElevatedButton("同步数据", on_click=lambda e: self._run_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32), + ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), height=32), + self._score_status, + ], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER) + return ft.Column([ + date_row, + self._scoring_table, + ], expand=True, spacing=6) + + def _run_scoring(self): + self._score_status.value = "评分中..." + self._score_status.update() + import threading + + def _do(): + from core.scoring.inference.scorer import GridSeekerPipeline + trade_date = self._score_cur_date + + # 检查当日 K线数据是否已同步 + from peewee import fn + from core.scoring.models import KlineStock + latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar() + if latest_kline is None or latest_kline < trade_date: + self._score_status.value = f"K线未同步至 {trade_date},请先盘后同步" + self._score_status.color = '#F44336' + self._score_status.update() + return + + try: + engine = GridSeekerPipeline() + rankings = engine.run(trade_date) + if not rankings.empty: + engine.persist(rankings, trade_date) + self._score_status.value = f"{trade_date} 评分完成" + except FileNotFoundError: + self._score_status.value = "模型文件缺失" + except Exception as ex: + self._score_status.value = f"失败: {ex}" + self._score_status.update() + self._refresh_scoring() + + threading.Thread(target=_do, daemon=True).start() + + def _refresh_scoring(self): + from core.scoring.models import ScoringResult + from core.qmt import qmtv + + # (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32 + _C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)] + H = ["排名", "代码 / 名称", "Rank轮数", "Top概率", "Stacking概率", "操作"] + + def _hcell(text, w, e): + if e > 0: + return ft.Container(_text(text, bold=True), padding=4, expand=e) + return ft.Container(_text(text, bold=True), width=w, padding=4) + + def _dcell(text, w, e, color=None, size=None): + if e > 0: + return ft.Container(_text(text, color=color, size=size), padding=4, expand=e) + return ft.Container(_text(text, color=color, size=size), width=w, padding=4) + + header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0) + rows = [header, ft.Divider(height=1, color='#e0e0e0')] + + # 用导航日期查询 + trade_date = self._score_cur_date + scored_rows = list((ScoringResult + .select() + .where(ScoringResult.trade_date == trade_date) + .order_by(ScoringResult.score_rank) + .limit(200) + .dicts())) + + if not scored_rows: + self._score_status.value = f"{trade_date} 无评分数据" + self._score_status.color = '#F44336' + self._scoring_table.controls = rows + self._score_status.update() + return + + shown = 0 + for r in scored_rows: + code = r['stock_code'] + plain = code.split('.')[0] if '.' in code else code + # 过滤 ST + from core.scoring.models import StockInfo + if plain.startswith(('6', '5', '9')): + full_code = f'{plain}.SH' + else: + full_code = f'{plain}.SZ' + st = StockInfo.get_or_none(StockInfo.code == full_code) + if st and st.listing_status == 'ST': + continue + name = '' + try: + name = qmtv.getInstrumentName(plain) + except Exception: + pass + already_in = plain in self._data.stockCodeIdMap + shown += 1 + + cells = [ + _dcell(str(r.get("score_rank", "")), _C[0][0], _C[0][1]), + _dcell(f"{plain} {name}", _C[1][0], _C[1][1]), + _dcell(f"{r.get("rank_predicted_rounds", 0) or 0:.4f}", _C[2][0], _C[2][1]), + _dcell(f"{r.get("top_elite_prob", 0) or 0:.4f}", _C[3][0], _C[3][1]), + _dcell(f"{r.get("stacking_probability", 0) or 0:.4f}", _C[4][0], _C[4][1]), + ] + if already_in: + op_w = _C[5][0] + cells.append(ft.Container( + ft.Row([ft.Text('已添加', color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)], + alignment=ft.MainAxisAlignment.CENTER), + width=op_w, padding=0)) + else: + op_w = _C[5][0] + cells.append(ft.Container( + ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip='加入网格', + on_click=lambda e, c=plain, n=name: self._on_add_from_scoring(c, n))], + alignment=ft.MainAxisAlignment.CENTER), + width=op_w, padding=0)) + row = ft.Row(cells, spacing=0) + rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2))) + rows.append(ft.Divider(height=1, color='#f0f0f0')) + + self._scoring_table.controls = rows + self._score_status.value = f"{trade_date} 候选 {shown} 只" + self._score_status.color = '#4CAF50' + self._score_status.update() + + def _on_add_from_scoring(self, stock_code: str, stock_name: str): + """+ 按钮:从评分列表添加标的并打开网格配置""" + tid = self._data.add_from_market(stock_code, stock_name) + target = self._data.tradeTargets.get(tid) + if target is None: + return + self._dialogs.open_config(target) + self._refresh_scoring() # 刷新状态(显示"已添加") + def _on_add_from_market(self, stock_code: str): """+ 按钮:从市场监控添加标的并打开网格配置""" info = self._data.marketLog.get(stock_code) @@ -797,6 +1118,7 @@ class QmtApp: def __init__(self, page: ft.Page): self.page = page self.page.title = "神之一手" + self.page.theme_mode = ft.ThemeMode.LIGHT self.page.window.width = 1400 self.page.window.height = 800 self.page.padding = 0 diff --git a/core/ui/tkinter/sfgrid_view.py b/core/ui/tkinter/sfgrid_view.py index 67bd456..abbc83f 100644 --- a/core/ui/tkinter/sfgrid_view.py +++ b/core/ui/tkinter/sfgrid_view.py @@ -341,6 +341,19 @@ class TradeTargetUI(ttk.Frame): self.right_notebook.add(self.trade_tab, text="当日成交") self._create_trade_table(self.trade_tab) + # Tab 4: 数据集管理 + self.dataset_tab = ttk.Frame(self.right_notebook) + self.right_notebook.add(self.dataset_tab, text="数据集管理") + self._create_dataset_tab(self.dataset_tab) + + # Tab 5: 每日评分 + self.scoring_tab = ttk.Frame(self.right_notebook) + self.right_notebook.add(self.scoring_tab, text="每日评分") + self._create_scoring_tab(self.scoring_tab) + + # 评分数据缓存 + self._scoring_data: dict = {} # {stock_code: {score, rank, ...}} + # Tab 切换时自动刷新 self.right_notebook.bind("<>", self._on_tab_changed) @@ -506,10 +519,314 @@ class TradeTargetUI(ttk.Frame): self._refresh_orders() elif tab_text == "当日成交": self._refresh_trades() + elif tab_text == "数据集管理": + self._refresh_dataset_status() + elif tab_text == "每日评分": + self._refresh_scoring_table() except Exception: pass - def _refresh_orders(self): + # ================================================================ + # Tab 4: 数据集管理 + # ================================================================ + def _create_dataset_tab(self, parent): + """创建数据集管理 Tab""" + # 顶部按钮栏 + btn_frame = ttk.Frame(parent) + btn_frame.pack(fill=tk.X, pady=(0, 5)) + + syncs = [ + ("同步K线(个股+指数)", lambda: self._run_dataset_sync(['kline'])), + ("同步股票信息", lambda: self._run_dataset_sync(['stocks'])), + ("同步行业映射", lambda: self._run_dataset_sync(['industry'])), + ("计算市场状态", lambda: self._run_dataset_sync(['market'])), + ("计算行业指数", lambda: self._run_dataset_sync(['sector'])), + ("同步全部", lambda: self._run_dataset_sync(['all'])), + ] + for label, cmd in syncs: + ttk.Button(btn_frame, text=label, command=cmd, width=18).pack( + side=tk.LEFT, padx=2) + + # 数据表状态表格 + cols = ("表名", "记录数", "最新日期", "数据覆盖") + self.dataset_tree = ttk.Treeview(parent, columns=cols, show='headings', height=12) + widths = {"表名": 180, "记录数": 80, "最新日期": 100, "数据覆盖": 200} + for c in cols: + self.dataset_tree.heading(c, text=c) + self.dataset_tree.column(c, width=widths.get(c, 80), anchor=tk.W) + + sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.dataset_tree.yview) + self.dataset_tree.configure(yscrollcommand=sb.set) + self.dataset_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + sb.pack(side=tk.RIGHT, fill=tk.Y) + + # 状态栏 + self.dataset_status = ttk.Label(parent, text='就绪', foreground='gray') + self.dataset_status.pack(fill=tk.X, pady=(5, 0)) + + def _refresh_dataset_status(self): + """刷新数据集状态""" + from core.scoring.models import ( + KlineStock, KlineIndex, StockInfo, IndustryMapping, + MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, + ) + # (显示名, 模型类, 日期字段名) + tables = [ + ('kline_stock', KlineStock, 'trade_date'), + ('kline_index', KlineIndex, 'trade_date'), + ('stocks', StockInfo, 'share_updated_at'), + ('industry', IndustryMapping, 'update_date'), + ('market_regime', MarketRegimeDaily, 'trade_date'), + ('sector_features', SectorFeaturesDaily, 'trade_date'), + ('ScoringResult', ScoringResult, 'trade_date'), + ] + + self.dataset_tree.delete(*self.dataset_tree.get_children()) + for name, model, date_col in tables: + try: + count = model.select().count() + df = getattr(model, date_col, None) + if df is not None: + last = (model.select(df) + .order_by(df.desc()) + .first()) + last_date = str(getattr(last, date_col, '')) if last else '' + else: + last_date = '' + self.dataset_tree.insert('', tk.END, values=(name, count, last_date, '')) + except Exception as e: + self.dataset_tree.insert('', tk.END, values=(name, 'ERR', str(e), '')) + + def _run_dataset_sync(self, targets: list): + """后台线程执行数据同步""" + import threading + self.dataset_status.config(text='同步中...', foreground='orange') + + def _do_sync(): + from core.scoring.sync import ( + KlineStockSync, KlineIndexSync, StocksSync, + IndustrySync, MarketRegimeSync, SectorFeaturesSync, + ) + from core.eventbus import event_bus + from core.sfgrid.bus_events import EventSyncProgress + + all_targets = { + 'kline': [('K线-个股', KlineStockSync(count=300)), + ('K线-指数', KlineIndexSync(count=300))], + 'stocks': [('股票信息', StocksSync())], + 'industry': [('行业映射', IndustrySync())], + 'market': [('市场状态', MarketRegimeSync())], + 'sector': [('行业指数', SectorFeaturesSync())], + } + + if 'all' in targets: + order = (all_targets['kline'] + all_targets['stocks'] + + all_targets['industry'] + all_targets['market'] + + all_targets['sector']) + else: + order = [] + for t in targets: + order.extend(all_targets.get(t, [])) + + for label, sync in order: + self.after(0, lambda l=label: self.dataset_status.config( + text=f'同步中: {l}...', foreground='orange')) + try: + stats = sync.run() + event_bus.publish(EventSyncProgress, + {'source': label, 'status': 'ok', 'stats': stats}) + except Exception as e: + event_bus.publish(EventSyncProgress, + {'source': label, 'status': 'error', 'stats': str(e)}) + + self.after(0, lambda: self.dataset_status.config( + text='同步完成', foreground='green')) + self.after(100, self._refresh_dataset_status) + + threading.Thread(target=_do_sync, daemon=True).start() + + # ================================================================ + # Tab 5: 每日评分 + # ================================================================ + def _create_scoring_tab(self, parent): + """创建每日评分 Tab""" + # 顶部控制栏 + ctrl = ttk.Frame(parent) + ctrl.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(ctrl, text="评分日期:").pack(side=tk.LEFT, padx=(0, 5)) + self.score_date_var = tk.StringVar(value='') + self.score_date_entry = ttk.Entry(ctrl, textvariable=self.score_date_var, width=10) + self.score_date_entry.pack(side=tk.LEFT, padx=2) + + ttk.Button(ctrl, text="执行评分", command=self._run_scoring, width=10).pack( + side=tk.LEFT, padx=5) + ttk.Button(ctrl, text="刷新", command=self._refresh_scoring_table, width=6).pack( + side=tk.LEFT, padx=2) + ttk.Button(ctrl, text="加入网格", + command=self._add_scored_to_grid, width=10).pack( + side=tk.LEFT, padx=5) + + self.scoring_status = ttk.Label(ctrl, text='', foreground='gray') + self.scoring_status.pack(side=tk.LEFT, padx=10) + + # 评分结果表格 + cols = ("排名", "代码", "名称", "预测利润", "预测轮数", "堆叠概率") + self.scoring_tree = ttk.Treeview(parent, columns=cols, show='headings', height=14) + widths = {"排名": 50, "代码": 70, "名称": 80, "预测利润": 80, "预测轮数": 80, "堆叠概率": 80} + for c in cols: + self.scoring_tree.heading(c, text=c) + self.scoring_tree.column(c, width=widths.get(c, 60), anchor=tk.CENTER) + + sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.scoring_tree.yview) + self.scoring_tree.configure(yscrollcommand=sb.set) + self.scoring_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + sb.pack(side=tk.RIGHT, fill=tk.Y) + + # 双击加入网格 + self.scoring_tree.bind("", self._on_scoring_double_click) + + def _refresh_scoring_table(self): + """从 ScoringResult 表加载最新评分""" + from core.scoring.models import ScoringResult + from core.qmt import qmtv + + self.scoring_tree.delete(*self.scoring_tree.get_children()) + self._scoring_data.clear() + + try: + # 取最新评分日 + latest = (ScoringResult + .select(ScoringResult.trade_date) + .distinct() + .order_by(ScoringResult.trade_date.desc()) + .first()) + if not latest: + self.scoring_status.config(text='无评分数据', foreground='gray') + return + + trade_date = latest.trade_date + self.score_date_var.set(str(trade_date)) + + rows = (ScoringResult + .select() + .where(ScoringResult.trade_date == trade_date) + .order_by(ScoringResult.score_rank) + .limit(200) + .dicts()) + + for r in rows: + code = r['stock_code'] + # 尝试从 QMT 获取名称 + name = '' + try: + name = qmtv.getInstrumentName(code) + except Exception: + pass + + values = ( + r.get('score_rank', ''), + code, + name, + f'{r.get("predicted_profit", 0):.4f}', + f'{r.get("rank_predicted_rounds", 0) or 0:.2f}', + f'{r.get("stacking_probability", 0) or 0:.4f}', + ) + self.scoring_tree.insert('', tk.END, values=values) + self._scoring_data[code] = {'name': name, **r} + + self.scoring_status.config( + text=f'{trade_date} — {len(rows)} 只候选股', + foreground='green') + + except Exception as e: + self.scoring_status.config(text=f'加载失败: {e}', foreground='red') + + def _run_scoring(self): + """后台线程执行评分管道""" + import threading + from datetime import date, datetime + + date_str = self.score_date_var.get().strip() + if date_str: + try: + trade_date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + self.scoring_status.config(text='日期格式错误 (YYYY-MM-DD)', foreground='red') + return + else: + trade_date = date.today() + + self.scoring_status.config(text='评分中...', foreground='orange') + + def _do_score(): + from core.scoring.inference.scorer import GridSeekerPipeline + from core.eventbus import event_bus + from core.sfgrid.bus_events import EventScoringCompleted + + try: + engine = GridSeekerPipeline() + rankings = engine.run(trade_date) + if not rankings.empty: + engine.persist(rankings, trade_date) + event_bus.publish(EventScoringCompleted, + {'date': str(trade_date), 'count': len(rankings)}) + except FileNotFoundError as e: + self.after(0, lambda: self.scoring_status.config( + text=f'模型文件缺失: {e}', foreground='red')) + return + except Exception as e: + self.after(0, lambda: self.scoring_status.config( + text=f'评分失败: {e}', foreground='red')) + return + + self.after(100, self._refresh_scoring_table) + self.after(0, lambda: self.scoring_status.config( + text=f'{trade_date} — 评分完成, {len(rankings)} 只', foreground='green')) + + threading.Thread(target=_do_score, daemon=True).start() + + def _on_scoring_double_click(self, event): + """双击评分行 → 加入网格策略""" + self._add_scored_to_grid() + + def _add_scored_to_grid(self): + """将选中的评分股票加入网格策略""" + selected = self.scoring_tree.selection() + if not selected: + return + + for item in selected: + values = self.scoring_tree.item(item)['values'] + if not values: + continue + stock_code = str(values[1]) + stock_name = str(values[2]) if values[2] else stock_code + + # 检查是否已存在 + from core.sfgrid.model import SFGridTradeTarget + existing = SFGridTradeTarget.get_or_none( + SFGridTradeTarget.stock_code == stock_code) + if existing: + continue + + # 添加到未分类持仓 + from core.qmt import qmtv + pos = qmtv.getStockPosition(stock_code) + position = int(pos.volume) if pos else 0 + + target = SFGridTradeTarget.create( + stock_code=stock_code, + stock_name=stock_name, + current_position=position, + strategy_type=0, # 未分类 + enabled=False, + ) + self.tradeTargetData[target.id] = target + self.stockCodeIdMap[stock_code] = target.id + self.refresh_table() + + # ---- 原有方法继续 ---- """从 QMT 读取当日委托并刷新表格""" from core.qmt import qmtv try: diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..4cf3869 --- /dev/null +++ b/models/README.md @@ -0,0 +1,197 @@ +# v6.6 模型发布说明 + +**版本**: v6.6 +**发布日期**: 2026-06-16 +**状态**: 生产就绪 + +--- + +## 一、模型架构 + +v6.6 采用 **Stacking Calibrated** 三层融合架构: + +``` +输入特征 (52维 v3.4) + │ + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Rank 模型 │ │ Top 模型 │ +│ LGBMRegressor │ │ LGBMClassifier │ +│ 预测网格轮回次数 │ │ 分类精英股票 │ +│ CV MAE: 0.2053 │ │ CV PR-AUC: 0.53│ +│ CV R²: 0.2258 │ │ │ +└────────┬────────┘ └────────┬────────┘ + │ │ + └──────────┬───────────┘ + ▼ + ┌─────────────────────┐ + │ Stacking 模型 │ + │ LGBMClassifier │ + │ CV PR-AUC: 0.8207 │ + │ 最优阈值: 0.35 │ + └──────────┬──────────┘ + ▼ + 最终 top 概率 +``` + +--- + +## 二、训练数据 + +| 指标 | 数值 | +|------|------| +| 特征版本 | v3.4 (52维) | +| 原始股票数 | 4,358 | +| 过滤后股票数 | 1,533 | +| 训练样本数 | 205,491 | +| 观察窗口 | 120天 | +| 预测窗口 | 60天 | +| 零触碰率 | 71.1% | +| Elite率 | 20.5% | + +--- + +## 三、模型性能 + +### Rank模型 (回归) +| 指标 | 数值 | +|------|------| +| CV MAE | 0.2053 | +| CV R² | 0.2258 | +| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=7 | + +### Top模型 (分类) +| 指标 | 数值 | +|------|------| +| CV PR-AUC | 0.5333 | +| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=-1 | + +### Stacking融合模型 +| 指标 | 数值 | +|------|------| +| CV PR-AUC | **0.8207** | +| 最优阈值 | 0.35 | +| 最优参数 | num_leaves=31, min_child_samples=20, max_depth=-1 | + +--- + +## 四、Top 10 重要特征 + +### Rank模型 +| 排名 | 特征 | 重要性% | +|------|------|---------| +| 1 | dist_to_grid_upper | 4.38% | +| 2 | dist_to_grid_lower | 4.26% | +| 3 | price_cv | 4.09% | +| 4 | amount_mean_20d | 3.98% | +| 5 | range_compression_20d | 3.48% | + +### Stacking融合 +| 排名 | 特征 | 重要性% | +|------|------|---------| +| 1 | rank_predicted_rounds | 20.87% | +| 2 | top_elite_prob | 19.38% | +| 3 | ma20_deviation_pct | 5.35% | +| 4 | atr_pct | 4.93% | +| 5 | dist_to_grid_lower | 3.75% | + +--- + +## 五、周评分回测结果 (2023-04 ~ 2026-04) + +| 指标 | v6.6 数值 | +|------|-----------| +| **总收益率** | **+36.79%** | +| 年化夏普 | 0.8494 | +| 最大回撤 | -12.70% | +| 胜率(周) | 48.7% | +| 交易次数 | 277 (147买, 130卖) | +| 最大持仓 | 10只 | + +### 年度收益 +| 年度 | 收益 | +|------|------| +| 2023 | +2.09% | +| 2024 | +28.34% | +| 2025 | +7.32% | +| 2026 | +0.52% | + +--- + +## 六、与v6.5对比 + +| 指标 | v6.5 | v6.6 | 变化 | +|------|------|------|------| +| Stacking PR-AUC | 0.5234 | **0.8207** | +57% | +| 特征数 | 37维 | **52维** | +15维 | +| Top PR-AUC | 0.3923 | 0.5333 | +36% | +| 回测收益率 | 59.82% | 36.79%* | - | +| 回测最大回撤 | -33.47% | **-12.70%** | -62% | + +\* v6.6使用周评分回测(157周),v6.5使用日评分回测(723天),粒度不同 + +--- + +## 七、模型文件 + +| 文件 | 说明 | +|------|------| +| `rank.pkl` | Rank模型 (预测网格轮回次数) | +| `top.pkl` | Top模型 (分类精英股票) | +| `stacking.pkl` | Stacking融合模型 | +| `rank_feature_importance.csv` | Rank特征重要性 | +| `top_feature_importance.csv` | Top特征重要性 | +| `stacking_feature_importance.csv` | Stacking特征重要性 | +| `training_summary.json` | 训练摘要 | + +--- + +## 八、使用方式 + +```python +import pickle +import numpy as np + +# 加载模型 +with open("rank.pkl", "rb") as f: + rank_md = pickle.load(f) +with open("top.pkl", "rb") as f: + top_md = pickle.load(f) +with open("stacking.pkl", "rb") as f: + stacking_md = pickle.load(f) + +# 预测 +rank_pred = rank_md["model"].predict(features) +top_prob = top_md["model"].predict_proba(features)[:, 1] +stacking_prob = stacking_md["model"].predict_proba(features)[:, 1] + +# 选股 +threshold = 0.35 # stacking最优阈值 +top_picks = scores[scores["stacking_prob"] >= threshold] +``` + +--- + +## 九、Registry配置 + +```yaml +# grid_seeker/registry.yaml +production: + version: v6.6 + architecture: stacking_calibrated + models: + stacking: versions/6.6/output/stacking.pkl + rank: versions/6.6/output/rank.pkl + top: versions/6.6/output/top.pkl + feature_version: v3.4 + training_samples: 205491 +``` + +--- + +## 十、注意事项 + +1. **特征版本**: 必须使用 v3.4 特征(52维),与v6.5/v6.4不兼容 +2. **Stacking阈值**: 推荐使用 0.35 作为选股阈值 +3. **持股上限**: 建议不超过10只 +4. **价格区间**: 适合7-10元区间股票 \ No newline at end of file diff --git a/models/backtest_v66_portfolio.csv b/models/backtest_v66_portfolio.csv new file mode 100644 index 0000000..c934004 --- /dev/null +++ b/models/backtest_v66_portfolio.csv @@ -0,0 +1,38 @@ +date,cash,stock_value,total_value,positions,total_shares,month +2023-05-31,60000.0,0.0,60000.0,0,0,2023-05 +2023-06-30,60000.0,0.0,60000.0,0,0,2023-06 +2023-07-31,60000.0,0.0,60000.0,0,0,2023-07 +2023-08-31,60000.0,0.0,60000.0,0,0,2023-08 +2023-09-30,60000.0,0.0,60000.0,0,0,2023-09 +2023-10-31,60000.0,0.0,60000.0,0,0,2023-10 +2023-11-30,60000.0,0.0,60000.0,0,0,2023-11 +2023-12-31,60000.0,0.0,60000.0,0,0,2023-12 +2024-01-31,60000.0,0.0,60000.0,0,0,2024-01 +2024-02-29,60000.0,0.0,60000.0,0,0,2024-02 +2024-03-31,60000.0,0.0,60000.0,0,0,2024-03 +2024-04-30,60000.0,0.0,60000.0,0,0,2024-04 +2024-05-31,60000.0,0.0,60000.0,0,0,2024-05 +2024-06-30,60000.0,0.0,60000.0,0,0,2024-06 +2024-07-31,60000.0,0.0,60000.0,0,0,2024-07 +2024-08-31,60000.0,0.0,60000.0,0,0,2024-08 +2024-09-30,60000.0,0.0,60000.0,0,0,2024-09 +2024-10-31,60000.0,0.0,60000.0,0,0,2024-10 +2024-11-30,60000.0,0.0,60000.0,0,0,2024-11 +2024-12-31,60000.0,0.0,60000.0,0,0,2024-12 +2025-01-31,60000.0,0.0,60000.0,0,0,2025-01 +2025-02-28,60000.0,0.0,60000.0,0,0,2025-02 +2025-03-31,60000.0,0.0,60000.0,0,0,2025-03 +2025-04-30,60000.0,0.0,60000.0,0,0,2025-04 +2025-05-31,60000.0,0.0,60000.0,0,0,2025-05 +2025-06-30,60000.0,0.0,60000.0,0,0,2025-06 +2025-07-31,60000.0,0.0,60000.0,0,0,2025-07 +2025-08-31,60000.0,0.0,60000.0,0,0,2025-08 +2025-09-30,60000.0,0.0,60000.0,0,0,2025-09 +2025-10-31,60000.0,0.0,60000.0,0,0,2025-10 +2025-11-30,60000.0,0.0,60000.0,0,0,2025-11 +2025-12-31,60000.0,0.0,60000.0,0,0,2025-12 +2026-01-31,60000.0,0.0,60000.0,0,0,2026-01 +2026-02-28,60000.0,0.0,60000.0,0,0,2026-02 +2026-03-31,60000.0,0.0,60000.0,0,0,2026-03 +2026-04-30,60000.0,0.0,60000.0,0,0,2026-04 +2026-05-27,60000.0,0.0,60000.0,0,0,2026-05 diff --git a/models/backtest_v66_trades.csv b/models/backtest_v66_trades.csv new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/models/backtest_v66_trades.csv @@ -0,0 +1 @@ + diff --git a/models/backtest_v66_weekly.csv b/models/backtest_v66_weekly.csv new file mode 100644 index 0000000..f2b34a1 --- /dev/null +++ b/models/backtest_v66_weekly.csv @@ -0,0 +1,158 @@ +date,cash,stock_value,total_value,positions,year +2023-04-28,60000.0,0.0,60000.0,10,2023 +2023-05-05,58400.0,1618.0,60018.0,10,2023 +2023-05-12,55400.0,4570.0,59970.0,10,2023 +2023-05-19,52200.0,7428.0,59628.0,10,2023 +2023-05-26,45200.0,14102.0,59302.0,10,2023 +2023-06-02,49800.0,11204.0,61004.0,10,2023 +2023-06-09,55600.0,5996.0,61596.0,10,2023 +2023-06-16,55600.0,5988.0,61588.0,10,2023 +2023-06-23,53800.0,7522.0,61322.0,10,2023 +2023-06-30,49200.0,11602.0,60802.0,10,2023 +2023-07-07,51200.0,9542.0,60742.0,10,2023 +2023-07-14,48400.0,12576.0,60976.0,10,2023 +2023-07-21,50200.0,10406.0,60606.0,10,2023 +2023-07-28,50200.0,10268.0,60468.0,10,2023 +2023-08-04,50200.0,10622.0,60822.0,10,2023 +2023-08-11,50200.0,10078.0,60278.0,10,2023 +2023-08-18,47400.0,12614.0,60014.0,10,2023 +2023-08-25,46200.0,13572.0,59772.0,10,2023 +2023-09-01,46200.0,14042.0,60242.0,10,2023 +2023-09-08,46800.0,12942.0,59742.0,10,2023 +2023-09-15,45200.0,14398.0,59598.0,10,2023 +2023-09-22,42600.0,17276.0,59876.0,10,2023 +2023-09-29,42600.0,17430.0,60030.0,10,2023 +2023-10-06,42600.0,17430.0,60030.0,10,2023 +2023-10-13,42600.0,16750.0,59350.0,10,2023 +2023-10-20,41000.0,17366.0,58366.0,10,2023 +2023-10-27,39600.0,18870.0,58470.0,10,2023 +2023-11-03,42600.0,17234.0,59834.0,10,2023 +2023-11-10,45400.0,15998.0,61398.0,10,2023 +2023-11-17,45400.0,16098.0,61498.0,10,2023 +2023-11-24,47000.0,14652.0,61652.0,10,2023 +2023-12-01,45800.0,16434.0,62234.0,10,2023 +2023-12-08,48800.0,13898.0,62698.0,10,2023 +2023-12-15,50800.0,12312.0,63112.0,10,2023 +2023-12-22,45400.0,15928.0,61328.0,10,2023 +2023-12-29,41000.0,20256.0,61256.0,10,2023 +2024-01-05,42800.0,17966.0,60766.0,10,2024 +2024-01-12,40000.0,20254.0,60254.0,10,2024 +2024-01-19,37600.0,22196.0,59796.0,10,2024 +2024-01-26,35000.0,25716.0,60716.0,10,2024 +2024-02-02,25600.0,31044.0,56644.0,10,2024 +2024-02-09,19400.0,36784.0,56184.0,10,2024 +2024-02-16,19400.0,36784.0,56184.0,10,2024 +2024-02-23,29400.0,33418.0,62818.0,10,2024 +2024-03-01,35200.0,28470.0,63670.0,10,2024 +2024-03-08,36400.0,26802.0,63202.0,10,2024 +2024-03-15,40800.0,23672.0,64472.0,10,2024 +2024-03-22,48600.0,19494.0,68094.0,10,2024 +2024-03-29,48800.0,17936.0,66736.0,10,2024 +2024-04-05,44800.0,21374.0,66174.0,10,2024 +2024-04-12,42200.0,22450.0,64650.0,10,2024 +2024-04-19,35000.0,27966.0,62966.0,10,2024 +2024-04-26,36600.0,28114.0,64714.0,10,2024 +2024-05-03,39000.0,27576.0,66576.0,10,2024 +2024-05-10,42200.0,24400.0,66600.0,10,2024 +2024-05-17,39800.0,26702.0,66502.0,10,2024 +2024-05-24,38200.0,27704.0,65904.0,10,2024 +2024-05-31,38400.0,26486.0,64886.0,10,2024 +2024-06-07,32200.0,30776.0,62976.0,10,2024 +2024-06-14,32200.0,31802.0,64002.0,10,2024 +2024-06-21,31000.0,31084.0,62084.0,10,2024 +2024-06-28,29000.0,32180.0,61180.0,10,2024 +2024-07-05,29000.0,32292.0,61292.0,10,2024 +2024-07-12,27200.0,33578.0,60778.0,10,2024 +2024-07-19,27200.0,33172.0,60372.0,10,2024 +2024-07-26,24200.0,35970.0,60170.0,10,2024 +2024-08-02,24200.0,36992.0,61192.0,10,2024 +2024-08-09,26000.0,34954.0,60954.0,10,2024 +2024-08-16,26000.0,34780.0,60780.0,10,2024 +2024-08-23,24400.0,35044.0,59444.0,10,2024 +2024-08-30,23400.0,37774.0,61174.0,10,2024 +2024-09-06,23400.0,36744.0,60144.0,10,2024 +2024-09-13,23400.0,36430.0,59830.0,10,2024 +2024-09-20,25200.0,35478.0,60678.0,10,2024 +2024-09-27,30200.0,35280.0,65480.0,10,2024 +2024-10-04,37000.0,32120.0,69120.0,10,2024 +2024-10-11,40600.0,26824.0,67424.0,10,2024 +2024-10-18,40600.0,28430.0,69030.0,10,2024 +2024-10-25,43600.0,27342.0,70942.0,10,2024 +2024-11-01,49600.0,23190.0,72790.0,10,2024 +2024-11-08,53200.0,21184.0,74384.0,10,2024 +2024-11-15,56000.0,19820.0,75820.0,10,2024 +2024-11-22,59400.0,16790.0,76190.0,10,2024 +2024-11-29,63600.0,14610.0,78210.0,10,2024 +2024-12-06,63800.0,15148.0,78948.0,10,2024 +2024-12-13,70600.0,9146.0,79746.0,10,2024 +2024-12-20,67800.0,11830.0,79630.0,10,2024 +2024-12-27,63400.0,14588.0,77988.0,10,2024 +2025-01-03,55800.0,20000.0,75800.0,10,2025 +2025-01-10,54400.0,21344.0,75744.0,10,2025 +2025-01-17,57400.0,20314.0,77714.0,10,2025 +2025-01-24,57400.0,20416.0,77816.0,10,2025 +2025-01-31,57400.0,20178.0,77578.0,10,2025 +2025-02-07,57400.0,21214.0,78614.0,10,2025 +2025-02-14,63600.0,16530.0,80130.0,10,2025 +2025-02-21,65000.0,14804.0,79804.0,10,2025 +2025-02-28,62400.0,16588.0,78988.0,10,2025 +2025-03-07,62400.0,16784.0,79184.0,10,2025 +2025-03-14,62400.0,17590.0,79990.0,10,2025 +2025-03-21,62400.0,16742.0,79142.0,10,2025 +2025-03-28,62400.0,16480.0,78880.0,10,2025 +2025-04-04,59800.0,18538.0,78338.0,10,2025 +2025-04-11,55200.0,22474.0,77674.0,10,2025 +2025-04-18,58600.0,19992.0,78592.0,10,2025 +2025-04-25,58800.0,20036.0,78836.0,10,2025 +2025-05-02,57200.0,21798.0,78998.0,10,2025 +2025-05-09,59000.0,20540.0,79540.0,10,2025 +2025-05-16,59000.0,20316.0,79316.0,10,2025 +2025-05-23,60800.0,18556.0,79356.0,10,2025 +2025-05-30,60800.0,18834.0,79634.0,10,2025 +2025-06-06,60800.0,19254.0,80054.0,10,2025 +2025-06-13,62000.0,18528.0,80528.0,10,2025 +2025-06-20,63200.0,16976.0,80176.0,10,2025 +2025-06-27,63200.0,17596.0,80796.0,10,2025 +2025-07-04,63200.0,17758.0,80958.0,10,2025 +2025-07-11,63200.0,18198.0,81398.0,10,2025 +2025-07-18,64800.0,16488.0,81288.0,10,2025 +2025-07-25,64800.0,16934.0,81734.0,10,2025 +2025-08-01,66800.0,15168.0,81968.0,10,2025 +2025-08-08,66800.0,14908.0,81708.0,10,2025 +2025-08-15,66800.0,14766.0,81566.0,10,2025 +2025-08-22,71200.0,11406.0,82606.0,10,2025 +2025-08-29,71200.0,11004.0,82204.0,10,2025 +2025-09-05,68400.0,13804.0,82204.0,10,2025 +2025-09-12,70200.0,12362.0,82562.0,10,2025 +2025-09-19,68400.0,13864.0,82264.0,10,2025 +2025-09-26,67400.0,14434.0,81834.0,10,2025 +2025-10-03,67400.0,14374.0,81774.0,10,2025 +2025-10-10,65800.0,15922.0,81722.0,10,2025 +2025-10-17,65800.0,15226.0,81026.0,10,2025 +2025-10-24,67800.0,13844.0,81644.0,10,2025 +2025-10-31,67800.0,13968.0,81768.0,10,2025 +2025-11-07,67800.0,14122.0,81922.0,10,2025 +2025-11-14,67800.0,14130.0,81930.0,10,2025 +2025-11-21,66000.0,15954.0,81954.0,10,2025 +2025-11-28,66000.0,16312.0,82312.0,10,2025 +2025-12-05,66000.0,15682.0,81682.0,10,2025 +2025-12-12,66000.0,15122.0,81122.0,10,2025 +2025-12-19,66000.0,15268.0,81268.0,10,2025 +2025-12-26,66000.0,15350.0,81350.0,10,2025 +2026-01-02,67400.0,14250.0,81650.0,10,2026 +2026-01-09,69200.0,13998.0,83198.0,10,2026 +2026-01-16,73400.0,10432.0,83832.0,10,2026 +2026-01-23,73400.0,10660.0,84060.0,10,2026 +2026-01-30,72400.0,11408.0,83808.0,10,2026 +2026-02-06,71000.0,12648.0,83648.0,10,2026 +2026-02-13,71000.0,12800.0,83800.0,10,2026 +2026-02-20,71000.0,12800.0,83800.0,10,2026 +2026-02-27,69800.0,13764.0,83564.0,10,2026 +2026-03-06,68200.0,14498.0,82698.0,10,2026 +2026-03-13,68200.0,14434.0,82634.0,10,2026 +2026-03-20,66600.0,15290.0,81890.0,10,2026 +2026-03-27,63000.0,18856.0,81856.0,10,2026 +2026-04-03,62200.0,18540.0,80740.0,10,2026 +2026-04-10,60600.0,21340.0,81940.0,10,2026 +2026-04-17,60600.0,21260.0,81860.0,10,2026 +2026-04-24,62200.0,19876.0,82076.0,10,2026 diff --git a/models/rank.pkl b/models/rank.pkl new file mode 100644 index 0000000..ac70260 Binary files /dev/null and b/models/rank.pkl differ diff --git a/models/rank_feature_importance.csv b/models/rank_feature_importance.csv new file mode 100644 index 0000000..feb2813 --- /dev/null +++ b/models/rank_feature_importance.csv @@ -0,0 +1,53 @@ +feature,importance,importance_pct +dist_to_grid_upper,1319,4.38 +dist_to_grid_lower,1282,4.26 +price_cv,1231,4.09 +amount_mean_20d,1198,3.98 +range_compression_20d,1047,3.48 +rebound_from_low_60d,1019,3.38 +drawdown_60d,981,3.26 +ma20_deviation_pct,915,3.04 +grid_touch_count_60d,907,3.01 +volume_ratio,903,3.0 +price_entropy,877,2.91 +avg_daily_amp,857,2.85 +atr_pct,781,2.59 +wick_ratio_20d,780,2.59 +cross_freq_x_bb,743,2.47 +trend_slope_60d,743,2.47 +amount_trend_20d,741,2.46 +range_position_60d,735,2.44 +obv_slope,731,2.43 +volatility_20d,707,2.35 +volume_cv_20d,697,2.32 +amplitude_cv,673,2.24 +intraday_trend_strength,669,2.22 +amp_cv_x_entropy,661,2.2 +ma60_deviation_pct,655,2.18 +ma20_ma60_gap_pct,650,2.16 +grid_room_balance,630,2.09 +bb_width,629,2.09 +amount_cv_20d,598,1.99 +amp_x_grid_vol,588,1.95 +trend_slope_20d,568,1.89 +amp_x_grid,499,1.66 +trend_abs_slope_20d,448,1.49 +close_reversal_count_20d,395,1.31 +near_upper_boundary_risk,366,1.22 +grid_touch_count_20d,335,1.11 +near_grid_line_ratio_20d,293,0.97 +low_volume_days_20d,274,0.91 +turnover_proxy_20d,250,0.83 +rolling_grid_ratio_20d,248,0.82 +cross_freq_20d,206,0.68 +mv_vol_interact,190,0.63 +down_days_20d,180,0.6 +high_amp_days,172,0.57 +up_days_20d,165,0.55 +small_cap_premium,164,0.54 +ln_float_mv,162,0.54 +near_lower_boundary_risk,114,0.38 +trend_consistency_20d,103,0.34 +usable_grid_count_lower,13,0.04 +usable_grid_count_upper,12,0.04 +grid_cross_density_20d,0,0.0 diff --git a/models/stacking.pkl b/models/stacking.pkl new file mode 100644 index 0000000..6a02dad Binary files /dev/null and b/models/stacking.pkl differ diff --git a/models/stacking_feature_importance.csv b/models/stacking_feature_importance.csv new file mode 100644 index 0000000..e496783 --- /dev/null +++ b/models/stacking_feature_importance.csv @@ -0,0 +1,55 @@ +feature,importance,importance_pct +rank_predicted_rounds,1252,20.87 +top_elite_prob,1163,19.38 +ma20_deviation_pct,321,5.35 +atr_pct,296,4.93 +dist_to_grid_lower,225,3.75 +price_cv,190,3.17 +amount_mean_20d,183,3.05 +drawdown_60d,158,2.63 +obv_slope,145,2.42 +dist_to_grid_upper,142,2.37 +ma20_ma60_gap_pct,141,2.35 +range_position_60d,124,2.07 +trend_slope_60d,123,2.05 +rebound_from_low_60d,114,1.9 +intraday_trend_strength,98,1.63 +volume_ratio,98,1.63 +volume_cv_20d,86,1.43 +ma60_deviation_pct,81,1.35 +grid_room_balance,69,1.15 +range_compression_20d,69,1.15 +volatility_20d,64,1.07 +amount_trend_20d,56,0.93 +near_upper_boundary_risk,54,0.9 +up_days_20d,50,0.83 +avg_daily_amp,48,0.8 +amplitude_cv,48,0.8 +amount_cv_20d,44,0.73 +small_cap_premium,42,0.7 +near_lower_boundary_risk,42,0.7 +high_amp_days,40,0.67 +cross_freq_x_bb,37,0.62 +amp_x_grid,37,0.62 +bb_width,36,0.6 +amp_x_grid_vol,36,0.6 +ln_float_mv,33,0.55 +price_entropy,28,0.47 +turnover_proxy_20d,26,0.43 +trend_slope_20d,25,0.42 +mv_vol_interact,25,0.42 +wick_ratio_20d,22,0.37 +grid_touch_count_60d,21,0.35 +amp_cv_x_entropy,21,0.35 +grid_touch_count_20d,20,0.33 +rolling_grid_ratio_20d,14,0.23 +near_grid_line_ratio_20d,12,0.2 +usable_grid_count_upper,9,0.15 +down_days_20d,9,0.15 +trend_abs_slope_20d,7,0.12 +cross_freq_20d,6,0.1 +usable_grid_count_lower,5,0.08 +low_volume_days_20d,3,0.05 +close_reversal_count_20d,1,0.02 +trend_consistency_20d,1,0.02 +grid_cross_density_20d,0,0.0 diff --git a/models/top.pkl b/models/top.pkl new file mode 100644 index 0000000..69b1591 Binary files /dev/null and b/models/top.pkl differ diff --git a/models/top_feature_importance.csv b/models/top_feature_importance.csv new file mode 100644 index 0000000..5ff078d --- /dev/null +++ b/models/top_feature_importance.csv @@ -0,0 +1,54 @@ +feature,importance,importance_pct +rank_predicted_rounds,2126,6.86 +amount_mean_20d,1476,4.76 +price_cv,1376,4.44 +range_compression_20d,1165,3.76 +atr_pct,1105,3.56 +drawdown_60d,1090,3.52 +volume_ratio,1030,3.32 +amount_trend_20d,998,3.22 +price_entropy,888,2.86 +rebound_from_low_60d,872,2.81 +ma20_deviation_pct,868,2.8 +obv_slope,842,2.72 +trend_slope_60d,827,2.67 +wick_ratio_20d,800,2.58 +ma20_ma60_gap_pct,768,2.48 +amplitude_cv,767,2.47 +dist_to_grid_lower,757,2.44 +range_position_60d,747,2.41 +amp_cv_x_entropy,714,2.3 +bb_width,699,2.25 +ma60_deviation_pct,691,2.23 +volatility_20d,680,2.19 +volume_cv_20d,657,2.12 +intraday_trend_strength,650,2.1 +avg_daily_amp,643,2.07 +dist_to_grid_upper,629,2.03 +trend_slope_20d,626,2.02 +amp_x_grid_vol,551,1.78 +amount_cv_20d,550,1.77 +grid_room_balance,537,1.73 +trend_abs_slope_20d,504,1.63 +grid_touch_count_60d,480,1.55 +cross_freq_x_bb,399,1.29 +amp_x_grid,383,1.24 +near_grid_line_ratio_20d,277,0.89 +close_reversal_count_20d,267,0.86 +high_amp_days,250,0.81 +turnover_proxy_20d,243,0.78 +ln_float_mv,235,0.76 +low_volume_days_20d,228,0.74 +mv_vol_interact,223,0.72 +small_cap_premium,218,0.7 +grid_touch_count_20d,218,0.7 +up_days_20d,218,0.7 +down_days_20d,209,0.67 +near_upper_boundary_risk,165,0.53 +trend_consistency_20d,105,0.34 +near_lower_boundary_risk,87,0.28 +cross_freq_20d,86,0.28 +rolling_grid_ratio_20d,64,0.21 +usable_grid_count_lower,6,0.02 +usable_grid_count_upper,6,0.02 +grid_cross_density_20d,0,0.0 diff --git a/models/training_summary.json b/models/training_summary.json new file mode 100644 index 0000000..5f31e65 --- /dev/null +++ b/models/training_summary.json @@ -0,0 +1,46 @@ +{ + "version": "v6.6", + "feature_version": "v3.4", + "architecture": "stacking_calibrated", + "data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base", + "training_date": "2026-05-28T14:23:23.662118", + "n_stocks_total": 4358, + "n_stocks_after_filter": 1533, + "n_training_samples": 205491, + "window_days": 120, + "future_days": 60, + "step_days": 20, + "y_rounds_mean": 0.1998384357465777, + "y_rounds_median": 0.0, + "y_rounds_zero_rate": 0.7108340511263267, + "elite_rate": 20.52109338121864, + "rank": { + "cv_mae": 0.2053, + "cv_r2": 0.2258, + "best_params": { + "num_leaves": 63, + "min_child_samples": 30, + "max_depth": 7 + }, + "n_features": 52 + }, + "top": { + "cv_pr_auc": 0.5333, + "best_params": { + "num_leaves": 63, + "min_child_samples": 30, + "max_depth": -1 + }, + "n_features": 53 + }, + "stacking": { + "cv_pr_auc": 0.8207, + "best_params": { + "num_leaves": 31, + "min_child_samples": 20, + "max_depth": -1 + }, + "n_features": 54, + "optimal_threshold": 0.35 + } +} \ No newline at end of file diff --git a/starter.py b/starter.py index e66c5bc..75736fb 100644 --- a/starter.py +++ b/starter.py @@ -1,23 +1,20 @@ # coding:utf-8 """ -启动入口 — 自动探测 QMT 环境。 -默认使用 Tkinter UI,使用 --flet 参数切换到 Flet (Flutter) UI。 +启动入口 — 默认使用 Flet2 UI。 +使用 --tk 参数切换到 Tkinter UI。 """ import sys if __name__ == '__main__': - if '--flet2' in sys.argv: - from core.ui.flet.app_v2 import run - run() - elif '--flet' in sys.argv: - from core.ui.flet.app import run - run() - else: - from tkinter import messagebox + if '--tk' in sys.argv: from core.ui.tkinter.splash import SplashWindow + from tkinter import messagebox try: window = SplashWindow().run() if window: window.run() except Exception as e: messagebox.showerror("错误", f"系统初始化失败: {str(e)}") + else: + from core.ui.flet.app_v2 import run + run()