完成模型更新

This commit is contained in:
2026-06-24 12:19:08 +08:00
parent ee8fc60eae
commit 6494f43ddd
22 changed files with 4029 additions and 271 deletions
+2 -2
View File
@@ -50,5 +50,5 @@ RANK_MODEL = 'rank'
TOP_MODEL = 'top'
STACKING_MODEL = 'stacking'
# Stacking 选股阈值
STACKING_THRESHOLD = 0.35
# Stacking 选股阈值 (v6.7r3 最优阈值 0.331)
STACKING_THRESHOLD = 0.33
+25 -1
View File
@@ -1,9 +1,10 @@
"""
扩展特征 v3.4 (16维)
扩展特征 v3.4 + v6.7新增 (20维: 16维 v3.4 + 4维 v6.7新增)
"""
import numpy as np
import pandas as pd
from core.scoring.features.v3_2_features import _ols_slope
from core.scoring.features.v3_3_features import _grid_touch_count
from core.scoring.config import GRID_LOW, GRID_HIGH
@@ -104,6 +105,29 @@ def calculate_features_v3_4(ctx) -> pd.DataFrame:
feat['wick_ratio_20d'] = np.mean(
wick_len / np.where(total_len > 0, total_len, 1)) * 100
# ── v6.7 新增 4 维特征 ──────────────────────────────
# 53. vol_decay_5d: 近5日波动率 / 近20日波动率 (波动率用对数收益std)
log_ret = np.diff(np.log(np.maximum(closes, 1e-10)))
vol_5d = np.std(log_ret[-5:], ddof=1) if len(log_ret) >= 5 else 0
vol_20d = np.std(log_ret[-20:], ddof=1) if len(log_ret) >= 20 else vol_5d
feat['vol_decay_5d'] = float(vol_5d / vol_20d) if vol_20d > 0 else 0.0
# 54. grid_touch_relative_10d: 10日振幅比 / 60日振幅比
range_10d = float(np.max(highs[-10:]) - np.min(lows[-10:]))
range_60d = float(np.max(highs[-60:]) - np.min(lows[-60:])) if len(highs) >= 60 else range_10d
close_now = float(closes[-1])
close_60d_mean = float(np.mean(closes[-60:])) if len(closes) >= 60 else close_now
if close_now > 0 and close_60d_mean > 0 and range_60d > 0:
feat['grid_touch_relative_10d'] = (range_10d / close_now) / (range_60d / close_60d_mean)
else:
feat['grid_touch_relative_10d'] = 0.0
# 55. vol_decay_x_grid_balance: vol_decay × 网格均衡度
feat['vol_decay_x_grid_balance'] = feat['vol_decay_5d'] * feat.get('grid_room_balance', 0.0)
# 56. vol_decay_x_dist_lower: vol_decay × 下轨距离
feat['vol_decay_x_dist_lower'] = feat['vol_decay_5d'] * feat.get('dist_to_grid_lower', 0.0)
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
+19 -10
View File
@@ -1,5 +1,5 @@
"""
grid_seeker v6.6 三级模型推理管道
grid_seeker v6.7r3 三级模型推理管道
Rank → Top → Stacking → stacking_probability (最终排序)
"""
import pickle
@@ -18,7 +18,7 @@ from core.logger import LogLevel, PrintLog
# ============================================================
# Rank 模型输入特征 (52维, v3.4, 直接从模型文件的 selected_features 读取)
# Rank 模型输入特征 (56维 v6.7/v3.4, 同时支持 feat_names 和 selected_features)
# ============================================================
def _get_rank_features() -> list:
import pickle
@@ -27,17 +27,26 @@ def _get_rank_features() -> list:
with open(path, 'rb') as f:
obj = pickle.load(f)
if isinstance(obj, dict):
sf = obj.get('selected_features', [])
# v6.7r3 使用 feat_names, v6.6 使用 selected_features
sf = obj.get('feat_names', []) or obj.get('selected_features', [])
if sf:
return sf
raise RuntimeError("无法从 rank.pkl 读取 selected_features")
raise RuntimeError("无法从 rank.pkl 读取 feat_names 或 selected_features")
RANK_FEATURE_COLS = _get_rank_features()
# Top/Stacking 模型只用 52 维基础特征(不含 v6.7 新增的4维)
# v6.7 新增: vol_decay_5d, grid_touch_relative_10d, vol_decay_x_grid_balance, vol_decay_x_dist_lower
_V67_NEW_FEATS = {
'vol_decay_5d', 'grid_touch_relative_10d',
'vol_decay_x_grid_balance', 'vol_decay_x_dist_lower'
}
BASE_52_COLS = [f for f in RANK_FEATURE_COLS if f not in _V67_NEW_FEATS]
class GridSeekerPipeline:
"""
grid_seeker v6.6 三级模型评分管道。
grid_seeker v6.7r3 三级模型评分管道。
Usage:
engine = GridSeekerPipeline()
@@ -120,7 +129,7 @@ class GridSeekerPipeline:
DataFrame indexed by stock_code, 含 stacking_probability / rank 等列,
按 stacking_probability 降序排列
"""
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.6 评分开始 ({trade_date}) =====')
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.7r3 评分开始 ({trade_date}) =====')
# 1. 特征工程
pipeline = FeaturePipeline(trade_date)
@@ -140,16 +149,16 @@ class GridSeekerPipeline:
self.rank_model, feature_df, RANK_FEATURE_COLS
)
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52 + rank_predicted_rounds)
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52基础 + rank)
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
top_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds']
top_cols = BASE_52_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)
# 4. Stage 3: Stacking 模型 → stacking_probability (55维 = 52基础 + rank + top)
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
stk_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds', 'top_elite_prob']
stk_cols = BASE_52_COLS + ['rank_predicted_rounds', 'top_elite_prob']
feature_df['stacking_probability'] = self._predict_with_model(
self.stacking_model, feature_df, stk_cols
)
+10 -3
View File
@@ -5,7 +5,7 @@ K线数据同步 — 个股日K + 指数日K
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
"""
import pandas as pd
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, KlineIndex
from core.scoring.config import TRACKED_INDICES
@@ -111,7 +111,11 @@ class KlineStockSync(BaseSync):
stock_code = full_code.split('.')[0]
records = []
for td in close_df.columns:
td_date = td.date() if hasattr(td, 'date') else td
# xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date
if isinstance(td, str):
td_date = datetime.strptime(td, '%Y%m%d').date()
else:
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
@@ -193,7 +197,10 @@ class KlineIndexSync(BaseSync):
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 isinstance(td, str):
td_date = datetime.strptime(td, '%Y%m%d').date()
else:
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
+73 -41
View File
@@ -13,7 +13,7 @@ class SectorFeaturesSync(BaseSync):
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
def _fetch(self, **kwargs):
"""从数据库加载原始数据, 计算行业指数特征"""
"""从数据库加载原始数据, 计算行业指数特征(分块处理避免内存溢出)"""
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
# 1. 加载行业映射: code → industry_name
@@ -24,49 +24,81 @@ class SectorFeaturesSync(BaseSync):
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())
# 2. 分块加载 K 线数据,避免内存溢出
# 聚合结果: {(trade_date, sector_name): [sum_pct_chg, sum_amp, count]}
sector_daily_agg = {} # key: (date, sector) -> {'ret_sum': float, 'amp_sum': float, 'count': int}
CHUNK_SIZE = 50000
last_date_per_stock = {} # stock_code -> prev_close
if not kline_rows:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空')
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 分块处理K线数据...')
chunk_num = 0
while True:
chunk_num += 1
rows = list(KlineStock
.select(
KlineStock.stock_code,
KlineStock.trade_date,
KlineStock.open,
KlineStock.high,
KlineStock.low,
KlineStock.close,
)
.order_by(KlineStock.stock_code, KlineStock.trade_date)
.offset((chunk_num - 1) * CHUNK_SIZE)
.limit(CHUNK_SIZE)
.dicts())
if not rows:
break
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 处理块 {chunk_num} ({len(rows)} 行)...')
for row in rows:
code = str(row['stock_code'])
td = row['trade_date']
open_p = float(row['open'])
high = float(row['high'])
low = float(row['low'])
close = float(row['close'])
sector = code_to_industry.get(code)
if sector is None:
continue
# 计算日收益率和振幅
prev_close = last_date_per_stock.get(code)
if prev_close is not None and prev_close > 0 and open_p > 0 and close > 0:
pct_chg = (close - prev_close) / prev_close * 100
amp = (high - low) / open_p * 100
key = (td, sector)
if key not in sector_daily_agg:
sector_daily_agg[key] = {'ret_sum': 0.0, 'amp_sum': 0.0, 'count': 0}
sector_daily_agg[key]['ret_sum'] += pct_chg
sector_daily_agg[key]['amp_sum'] += amp
sector_daily_agg[key]['count'] += 1
last_date_per_stock[code] = close
if not sector_daily_agg:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: 无有效K线数据')
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线数据')
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 聚合完成, {len(sector_daily_agg)} 个行业-日组合')
# 3. 映射行业
df['sector_name'] = df['stock_code'].map(code_to_industry)
df = df.dropna(subset=['sector_name'])
# 3. 构建聚合 DataFrame
agg_data = []
for (td, sector), vals in sector_daily_agg.items():
agg_data.append({
'trade_date': td,
'sector_name': sector,
'sector_ret': vals['ret_sum'] / vals['count'],
'sector_amplitude': vals['amp_sum'] / vals['count'],
})
agg = pd.DataFrame(agg_data)
agg = agg.sort_values(['sector_name', 'trade_date'])
agg['trade_date'] = pd.to_datetime(agg['trade_date'])
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(agg)} 行, {agg["sector_name"].nunique()} 个行业')
# 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)
# 4. 构建行业指数 (基值=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
@@ -77,7 +109,7 @@ class SectorFeaturesSync(BaseSync):
first_val = group['sector_index'].iloc[0]
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
# 7. 计算 EMA 均线
# 5. 计算 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']
@@ -85,7 +117,7 @@ class SectorFeaturesSync(BaseSync):
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分
# 6. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
agg['score'] = (
(agg['sector_index'] > agg['ema200']).astype(int) +
(agg['ema10'] > agg['ema20']).astype(int)
+6
View File
@@ -173,6 +173,9 @@ class SFGridStrategy:
# 检查是否已存在同 remark 的卖单(避免重复挂单)
if not any(o.order_remark == sell_remark for o in orders):
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
if not hasattr(self, 'todayUpStopPrice') or self.todayUpStopPrice is None:
self.todayUpStopPrice = qmtv.dailyUpStop(self.tradeTarget.stock_code) # type: ignore
if sellPrice > self.todayUpStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
@@ -207,6 +210,9 @@ class SFGridStrategy:
# 检查是否已存在同 remark 的买单(避免重复挂单)
if not any(o.order_remark == buy_remark for o in orders):
# 买单价格低于跌停价 → 今日无法成交,跳过下单
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
if not hasattr(self, 'todayDownStopPrice') or self.todayDownStopPrice is None:
self.todayDownStopPrice = qmtv.dailyDownStop(self.tradeTarget.stock_code) # type: ignore
if buyPrice < self.todayDownStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
+69 -6
View File
@@ -324,9 +324,9 @@ class _GridPanel:
return self._col
def _rebuild(self):
# (width, expand): 0=固定宽, >0=弹性比重
_C = [(35, 0), (0, 2), (70, 1), (0, 2), (50, 1), (55, 1), (60, 1), (0, 1)]
H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "状态", "操作"]
# (width, expand): 0=固定宽, >0=弹性比重; 新增排名列(50px)
_C = [(35, 0), (0, 2), (70, 1), (0, 2), (50, 1), (55, 1), (50, 0), (60, 1), (0, 1)]
H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "排名", "状态", "操作"]
header = []
for h, (w, e) in zip(H, _C):
if e > 0:
@@ -335,6 +335,22 @@ class _GridPanel:
header.append(ft.Container(_text(h, bold=True), width=w, padding=4))
rows = [ft.Row(header, spacing=0), ft.Divider(height=1, color='#e0e0e0')]
# 获取最新评分排名
rank_map = {} # stock_code -> score_rank
try:
from core.scoring.models import ScoringResult
from peewee import fn
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
if latest_date:
scored = (ScoringResult
.select(ScoringResult.stock_code, ScoringResult.score_rank)
.where(ScoringResult.trade_date == latest_date)
.dicts())
for r in scored:
rank_map[r['stock_code']] = r['score_rank']
except Exception:
pass
for tid, t in self._data.tradeTargets.items():
if t.strategy_type != STRATEGY_TYPE_GRID: continue
pg = t.getPriceGrid()
@@ -358,6 +374,22 @@ class _GridPanel:
))
gcell = ft.Row(gcells, spacing=3) if len(gcells) > 1 else gcells[0]
# 排名列
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
rank = rank_map.get(plain, None)
if rank is not None and rank <= 50:
rank_str = f'#{rank}'
rank_color = '#4CAF50'
elif rank is not None and rank <= 100:
rank_str = f'#{rank}'
rank_color = '#2196F3'
elif rank is not None:
rank_str = f'#{rank}'
rank_color = '#888888'
else:
rank_str = ''
rank_color = '#888888'
# 操作按钮
_ICON = 22 # 图标大小,比默认 18 好按
if t.enabled:
@@ -385,6 +417,7 @@ class _GridPanel:
f'{mp:.3f}', gcell,
str(t.current_position),
f'{self._data.avgPrices.get(tid, 0):.3f}',
rank_str,
'▶运行中' if t.enabled else '⏸已暂停',
btns]
row_cells = []
@@ -392,6 +425,8 @@ class _GridPanel:
content = cells_text[i]
if i == 2: # 市场价用颜色
content = _text(cells_text[i], color=pcolor, bold=True)
elif i == 6: # 排名用颜色
content = _text(cells_text[i], color=rank_color, bold=True)
elif isinstance(content, str):
content = _text(content)
elif isinstance(content, list):
@@ -495,8 +530,25 @@ class _DrawerPanel:
def _refresh_grid(self):
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
_C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (65, 0)]
H = ["ID", "股票", "市场价", "持仓", "成本", "操作"]
# 新增: 排名列 (50px)
_C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (50, 0), (65, 0)]
H = ["ID", "股票", "市场价", "持仓", "成本", "排名", "操作"]
# 获取最新评分排名
rank_map = {} # stock_code -> score_rank
try:
from core.scoring.models import ScoringResult
from peewee import fn
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
if latest_date:
rows = (ScoringResult
.select(ScoringResult.stock_code, ScoringResult.score_rank)
.where(ScoringResult.trade_date == latest_date)
.dicts())
for r in rows:
rank_map[r['stock_code']] = r['score_rank']
except Exception:
pass # 数据库未就绪时忽略
def _cell(text, w, e, color=None):
if e > 0:
@@ -509,15 +561,26 @@ class _DrawerPanel:
for tid, t in self._data.tradeTargets.items():
if t.strategy_type == STRATEGY_TYPE_GRID: continue
mp = self._data.marketPrices.get(tid, 0) or 0
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
rank = rank_map.get(plain, None)
rank_str = f"#{rank}" if rank is not None else ""
# 排名颜色: top50 绿色, top100 蓝色, 其他灰色
if rank is not None and rank <= 50:
rank_color = '#4CAF50'
elif rank is not None and rank <= 100:
rank_color = '#2196F3'
else:
rank_color = '#888888'
cells = [
_cell(str(tid), *_C[0]),
_cell(f'{t.stock_code} {t.stock_name}', *_C[1]),
_cell(f'{mp:.3f}', *_C[2]),
_cell(str(t.current_position), *_C[3]),
_cell(f'{self._data.avgPrices.get(tid, 0):.3f}', *_C[4]),
_cell(rank_str, *_C[5], color=rank_color),
ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=20, tooltip="网格配置",
on_click=lambda e, tt=t: self._dialogs.open_config(tt)),
width=_C[5][0], padding=0),
width=_C[6][0], padding=0),
]
row = ft.Row(cells, spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))