模型,评分,修复网格策略市场状态监听

This commit is contained in:
2026-06-22 10:48:40 +08:00
parent 2e3202968d
commit f938c453e1
42 changed files with 3803 additions and 108 deletions
+3
View File
@@ -0,0 +1,3 @@
# 特征工程子包
from core.scoring.features.validator import load_candidates, DataContext
from core.scoring.features.pipeline import FeaturePipeline
+147
View File
@@ -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
@@ -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')
@@ -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')
+106
View File
@@ -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
+88
View File
@@ -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')
+203
View File
@@ -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
+130
View File
@@ -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
+109
View File
@@ -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')
+197
View File
@@ -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