597 lines
23 KiB
Python
597 lines
23 KiB
Python
"""
|
||
结构化适配动态多因子策略
|
||
完整方案:
|
||
1. 因子池: 价值20% + 质量20% + 成长15% + 中国特色25% + 技术趋势10% + 板块强度10%
|
||
2. 因子预处理: 行业中性 + 市值中性 + zscore标准化
|
||
3. 动态加权: 每月更新IC,IC越高权重越高
|
||
4. 选股: top20-30,只保留站上年线的
|
||
5. 仓位: 估值择时定总仓位,单票不超5%,单板块不超20%
|
||
"""
|
||
import pandas as pd
|
||
import numpy as np
|
||
from typing import Dict, List, Optional
|
||
|
||
from vnpy.trader.app.ctaStrategy import CtaTemplate
|
||
from vnpy.trader.object import BarData
|
||
|
||
# 导入因子和工具
|
||
import sys
|
||
sys.path.append('.')
|
||
from factors import (
|
||
PEFactor, PBFactor,
|
||
ROEFactor,
|
||
RevenueGrowthFactor, ProfitGrowthFactor,
|
||
SizeFactor, ReversalFactor,
|
||
Momentum1MFactor, Momentum3MFactor,
|
||
SectorStrengthFactor
|
||
)
|
||
from utils import (
|
||
batch_process_factors,
|
||
prepare_market_cap_neutral,
|
||
DynamicICWeightAdjuster,
|
||
MarketValuationTiming
|
||
)
|
||
from data import (
|
||
FactorDataProcessor,
|
||
SectorStrengthEngine
|
||
)
|
||
from strategies import (
|
||
TechnicalTimingSignals,
|
||
MarketRegimeWeightAllocator
|
||
)
|
||
|
||
|
||
class StructuredDynamicMultiFactorStrategy(CtaTemplate):
|
||
"""
|
||
结构化适配动态多因子策略
|
||
适配当前A股结构化板块轮动行情
|
||
"""
|
||
|
||
# 策略参数 - 全部开放配置,方便参数遍历优化
|
||
author = "翼德"
|
||
parameters = [
|
||
# 调仓设置
|
||
"rebalance_freq", # 调仓频率: 'M'月频, 'W'周频
|
||
"top_n", # 选股数量
|
||
# 过滤条件
|
||
"require_above_ma", # 是否要求站上年线
|
||
"ma_period", # 年线周期
|
||
# 因子权重(初始权重,动态加权会更新)
|
||
"value_weight", # 价值因子权重(PE+PB)
|
||
"quality_weight", # 质量因子权重(ROE)
|
||
"growth_weight", # 成长因子权重(增速)
|
||
"china_special_weight", # 中国特色权重(市值+反转)
|
||
"trend_weight", # 趋势因子权重(动量)
|
||
"sector_strength_weight", # 板块强度权重
|
||
# 板块强度计算权重
|
||
"sector_tech_weight", # 板块强度技术面权重
|
||
"sector_fund_weight", # 板块强度资金面权重
|
||
"sector_basic_weight", # 板块强度基本面权重
|
||
"sector_sentiment_weight", # 板块强度情绪面权重
|
||
# 仓位限制
|
||
"max_single_pct", # 单票最大仓位占比
|
||
"max_sector_pct", # 单板块最大仓位占比(相对于总仓位)
|
||
# 动态加权设置
|
||
"dynamic_weight_enabled", # 是否开启动态加权
|
||
"ic_window", # IC滚动窗口月数
|
||
# 市场状态权重
|
||
"regime_weight_enabled", # 是否开启牛熊市不同权重分配
|
||
# 估值择时设置
|
||
"market_timing_enabled", # 是否开启估值择时
|
||
"min_total_position", # 最小总仓位
|
||
"max_total_position", # 最大总仓位
|
||
# 技术择时过滤
|
||
"tech_filter_enabled", # 是否开启技术择时二次过滤
|
||
"min_bullish_score", # 最小看多得分才能入选
|
||
]
|
||
|
||
# 策略变量
|
||
variables = [
|
||
"current_weights", # 当前因子权重
|
||
"target_total_position", # 当前目标总仓位
|
||
"last_rebalance_date", # 上次调仓日期
|
||
"current_ic", # 当前各因子IC
|
||
]
|
||
|
||
def __init__(self, cta_engine, strategy_name, setting_dict):
|
||
super().__init__(cta_engine, strategy_name, setting_dict)
|
||
|
||
# ========== 默认参数 ==========
|
||
# 调仓设置
|
||
self.rebalance_freq = getattr(self, 'rebalance_freq', 'M')
|
||
self.top_n = getattr(self, 'top_n', 25)
|
||
|
||
# 过滤条件
|
||
self.require_above_ma = getattr(self, 'require_above_ma', True)
|
||
self.ma_period = getattr(self, 'ma_period', 250)
|
||
|
||
# 因子初始权重
|
||
self.value_weight = getattr(self, 'value_weight', 0.20)
|
||
self.quality_weight = getattr(self, 'quality_weight', 0.20)
|
||
self.growth_weight = getattr(self, 'growth_weight', 0.15)
|
||
self.china_special_weight = getattr(self, 'china_special_weight', 0.25)
|
||
self.trend_weight = getattr(self, 'trend_weight', 0.10)
|
||
self.sector_strength_weight = getattr(self, 'sector_strength_weight', 0.10)
|
||
|
||
# 板块强度计算权重
|
||
self.sector_tech_weight = getattr(self, 'sector_tech_weight', 0.40)
|
||
self.sector_fund_weight = getattr(self, 'sector_fund_weight', 0.30)
|
||
self.sector_basic_weight = getattr(self, 'sector_basic_weight', 0.20)
|
||
self.sector_sentiment_weight = getattr(self, 'sector_sentiment_weight', 0.10)
|
||
|
||
# 仓位限制
|
||
self.max_single_pct = getattr(self, 'max_single_pct', 0.05)
|
||
self.max_sector_pct = getattr(self, 'max_sector_pct', 0.20)
|
||
|
||
# 动态加权
|
||
self.dynamic_weight_enabled = getattr(self, 'dynamic_weight_enabled', True)
|
||
self.ic_window = getattr(self, 'ic_window', 12)
|
||
|
||
# 市场状态权重
|
||
self.regime_weight_enabled = getattr(self, 'regime_weight_enabled', True)
|
||
|
||
# 估值择时
|
||
self.market_timing_enabled = getattr(self, 'market_timing_enabled', True)
|
||
self.min_total_position = getattr(self, 'min_total_position', 0.3)
|
||
self.max_total_position = getattr(self, 'max_total_position', 1.0)
|
||
|
||
# 技术择时二次过滤
|
||
self.tech_filter_enabled = getattr(self, 'tech_filter_enabled', True)
|
||
self.min_bullish_score = getattr(self, 'min_bullish_score', 0.4)
|
||
|
||
# ========== 初始化因子 ==========
|
||
self.factors = self._init_factors()
|
||
|
||
# ========== 初始化板块强度引擎 ==========
|
||
self.sector_strength_engine = SectorStrengthEngine(
|
||
weight_tech=self.sector_tech_weight,
|
||
weight_fund=self.sector_fund_weight,
|
||
weight_basic=self.sector_basic_weight,
|
||
weight_sentiment=self.sector_sentiment_weight
|
||
)
|
||
|
||
# ========== 初始化技术择时 ==========
|
||
self.tech_timing = TechnicalTimingSignals()
|
||
|
||
# ========== 初始化市场状态权重 ==========
|
||
if self.regime_weight_enabled:
|
||
self.regime_allocator = MarketRegimeWeightAllocator()
|
||
# 根据当前市场状态获取权重
|
||
index_data = self.get_market_index_data()
|
||
if index_data is not None:
|
||
default_weights = self.regime_allocator.expand_to_factor_names(
|
||
self.regime_allocator.get_weights(index_data)
|
||
)
|
||
else:
|
||
# 默认震荡市权重
|
||
default_weights = self.regime_allocator.expand_to_factor_names(
|
||
self.regime_allocator.weights['range_bound']
|
||
)
|
||
else:
|
||
# 固定权重按配置比例分配
|
||
# 价值类两个因子平分value_weight
|
||
# 成长类两个因子平分growth_weight
|
||
# 中国特色两个因子平分china_special_weight
|
||
# 趋势两个因子平分trend_weight
|
||
default_weights = {
|
||
'pe': self.value_weight * 0.5,
|
||
'pb': self.value_weight * 0.5,
|
||
'roe': self.quality_weight,
|
||
'revenue_growth': self.growth_weight * 0.5,
|
||
'profit_growth': self.growth_weight * 0.5,
|
||
'size': self.china_special_weight * 0.5,
|
||
'reversal_1m': self.china_special_weight * 0.5,
|
||
'momentum_1m': self.trend_weight * 0.3,
|
||
'momentum_3m': self.trend_weight * 0.7,
|
||
'sector_strength': self.sector_strength_weight,
|
||
}
|
||
|
||
# 因子字典 {名称: 实例}
|
||
self.factor_dict = {f.name: f for f in self.factors}
|
||
|
||
# ========== 初始化动态加权 ==========
|
||
if self.dynamic_weight_enabled:
|
||
factor_names = list(self.factor_dict.keys())
|
||
self.ic_adjuster = DynamicICWeightAdjuster(
|
||
factor_names,
|
||
window_size=self.ic_window
|
||
)
|
||
|
||
# ========== 初始化估值择时 ==========
|
||
if self.market_timing_enabled:
|
||
self.market_timer = MarketValuationTiming(
|
||
min_position=self.min_total_position,
|
||
max_position=self.max_total_position
|
||
)
|
||
self.target_total_position = self.max_total_position
|
||
else:
|
||
self.target_total_position = self.max_total_position
|
||
|
||
# 状态变量
|
||
self.current_weights = default_weights
|
||
self.last_rebalance_date = None
|
||
self.current_ic = None
|
||
self.last_data = None # 保存上期数据用于IC计算
|
||
|
||
def _init_factors(self):
|
||
"""初始化所有因子实例"""
|
||
factors = [
|
||
PEFactor(),
|
||
PBFactor(),
|
||
ROEFactor(),
|
||
RevenueGrowthFactor(),
|
||
ProfitGrowthFactor(),
|
||
SizeFactor(),
|
||
ReversalFactor(),
|
||
Momentum1MFactor(),
|
||
Momentum3MFactor(),
|
||
SectorStrengthFactor(),
|
||
]
|
||
return factors
|
||
|
||
def on_init(self):
|
||
"""策略初始化"""
|
||
self.write_log(
|
||
f"结构化动态多因子初始化完成: "
|
||
f"选股{self.top_n}只,单票{self.max_single_pct*100:.0f}%,"
|
||
f"单板块{self.max_sector_pct*100:.0f}%,"
|
||
f"动态加权={'开' if self.dynamic_weight_enabled else '关'},"
|
||
f"估值择时={'开' if self.market_timing_enabled else '关'}"
|
||
)
|
||
# 加载足够数据
|
||
required_bars = max(self.ma_period, 252)
|
||
self.load_bar(required_bars)
|
||
|
||
def on_start(self):
|
||
self.write_log("策略启动")
|
||
|
||
def on_stop(self):
|
||
self.write_log("策略停止")
|
||
|
||
def on_bar(self, bar: BarData):
|
||
"""K线推送,判断调仓"""
|
||
current_date = bar.datetime.date()
|
||
|
||
if not self._need_rebalance(current_date):
|
||
return
|
||
|
||
self.rebalance(current_date)
|
||
self.last_rebalance_date = current_date
|
||
|
||
def _need_rebalance(self, current_date) -> bool:
|
||
"""判断是否需要调仓"""
|
||
if self.last_rebalance_date is None:
|
||
return True
|
||
|
||
if self.rebalance_freq == 'M':
|
||
# 不同月份调仓
|
||
if current_date.month != self.last_rebalance_date.month:
|
||
return True
|
||
elif self.rebalance_freq == 'W':
|
||
# 不同周调仓
|
||
curr_week = current_date.isocalendar()[1]
|
||
last_week = self.last_rebalance_date.isocalendar()[1]
|
||
if curr_week != last_week:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _filter_above_ma(self, candidate_symbols: List[str], data: pd.DataFrame) -> List[str]:
|
||
"""过滤出站上年线的股票"""
|
||
if not self.require_above_ma:
|
||
return candidate_symbols
|
||
|
||
filtered = []
|
||
for symbol in candidate_symbols:
|
||
# 获取股票数据,计算年线
|
||
symbol_data = data[data['symbol'] == symbol]
|
||
if len(symbol_data) < self.ma_period:
|
||
continue # 数据不够,跳过
|
||
|
||
# 计算年线
|
||
ma = symbol_data['close'].rolling(self.ma_period).mean().iloc[-1]
|
||
current_close = symbol_data['close'].iloc[-1]
|
||
|
||
if current_close > ma:
|
||
filtered.append(symbol)
|
||
|
||
return filtered
|
||
|
||
def calculate_total_score(self, processed_factors: pd.DataFrame) -> pd.Series:
|
||
"""根据因子权重计算总得分"""
|
||
total_score = None
|
||
|
||
for name, weight in self.current_weights.items():
|
||
if name not in processed_factors.columns:
|
||
continue
|
||
|
||
score = processed_factors[name] * weight
|
||
|
||
if total_score is None:
|
||
total_score = score
|
||
else:
|
||
total_score = total_score.add(score, fill_value=0)
|
||
|
||
# 降序排列,得分越高越好
|
||
if total_score is not None:
|
||
total_score = total_score.sort_values(ascending=False)
|
||
|
||
return total_score
|
||
|
||
def select_stocks(self, total_score: pd.Series, data: pd.DataFrame) -> List[str]:
|
||
"""选股:
|
||
1. 因子打分选topN*2
|
||
2. 年线过滤(如果开启)
|
||
3. 技术择时二次过滤(如果开启),只保留多头信号够的
|
||
"""
|
||
# 选topN*2得分最高
|
||
top_candidates = total_score.head(self.top_n * 3).index.tolist() # 多选一些给两轮过滤
|
||
|
||
# 第一步过滤站上年线
|
||
if self.require_above_ma:
|
||
filtered = self._filter_above_ma(top_candidates, data)
|
||
else:
|
||
filtered = top_candidates
|
||
|
||
# 第二步技术择时二次过滤
|
||
if self.tech_filter_enabled and len(filtered) > 0:
|
||
# 拿到每个股票的K线数据
|
||
symbol_data = {}
|
||
for symbol in filtered:
|
||
symbol_df = data[data['symbol'] == symbol].sort_values('date')
|
||
if len(symbol_df) > self.ma_period:
|
||
symbol_data[symbol] = symbol_df
|
||
|
||
filtered = self.tech_timing.filter_stocks_by_timing(
|
||
symbol_data,
|
||
min_bullish_score=self.min_bullish_score
|
||
)
|
||
|
||
# 如果过滤后太少,放宽再选
|
||
if len(filtered) < self.top_n // 2:
|
||
top_candidates = total_score.head(self.top_n * 4).index.tolist()
|
||
if self.require_above_ma:
|
||
filtered = self._filter_above_ma(top_candidates, data)
|
||
else:
|
||
filtered = top_candidates
|
||
|
||
if self.tech_filter_enabled and len(filtered) > 0:
|
||
symbol_data = {}
|
||
for symbol in filtered:
|
||
symbol_df = data[data['symbol'] == symbol].sort_values('date')
|
||
symbol_data[symbol] = symbol_df
|
||
filtered = self.tech_timing.filter_stocks_by_timing(
|
||
symbol_data,
|
||
min_bullish_score=self.min_bullish_score
|
||
)
|
||
|
||
# 保证不超过topN
|
||
selected = filtered[:self.top_n]
|
||
|
||
return selected
|
||
|
||
def calculate_target_weights(
|
||
self,
|
||
selected: List[str],
|
||
data: pd.DataFrame
|
||
) -> Dict[str, float]:
|
||
"""计算目标权重,考虑:
|
||
1. 整体目标仓位
|
||
2. 单票不超max_single_pct
|
||
3. 单板块不超max_sector_pct
|
||
"""
|
||
n = len(selected)
|
||
if n == 0:
|
||
return {}
|
||
|
||
# 初步等权分配,考虑单票最大限制
|
||
max_single_allowed = self.target_total_position * self.max_single_pct
|
||
equal_weight = self.target_total_position / n
|
||
|
||
# 如果等权已经超过单票最大,按最大单票
|
||
if equal_weight > max_single_allowed:
|
||
equal_weight = max_single_allowed
|
||
|
||
# 初步分配
|
||
weights = {symbol: equal_weight for symbol in selected}
|
||
|
||
# 检查板块限制
|
||
if 'sector' in data.columns:
|
||
# 获取选中股票板块
|
||
selected_data = data[data['symbol'].isin(selected)]
|
||
|
||
# 按板块汇总
|
||
sector_total = {}
|
||
for symbol in selected:
|
||
row = selected_data[selected_data['symbol'] == symbol]
|
||
sector = row['sector'].iloc[0]
|
||
if sector not in sector_total:
|
||
sector_total[sector] = 0
|
||
sector_total[sector] += weights[symbol]
|
||
|
||
# 检查超限,压缩
|
||
max_allowed = self.target_total_position * self.max_sector_pct
|
||
for sector, total_w in sector_total.items():
|
||
if total_w > max_allowed:
|
||
# 压缩比例
|
||
ratio = max_allowed / total_w
|
||
# 压缩该板块所有股票
|
||
for symbol in selected:
|
||
s_row = selected_data[selected_data['symbol'] == symbol]
|
||
s_sector = s_row['sector'].iloc[0]
|
||
if s_sector == sector:
|
||
weights[symbol] *= ratio
|
||
|
||
# 重新归一,保证总权重还是target_total_position
|
||
total = sum(weights.values())
|
||
if total > 0:
|
||
ratio = self.target_total_position / total
|
||
for symbol in weights:
|
||
weights[symbol] *= ratio
|
||
|
||
return weights
|
||
|
||
def update_dynamic_weights(self, last_data: pd.DataFrame, current_data: pd.DataFrame):
|
||
"""更新动态权重(基于IC)"""
|
||
if not self.dynamic_weight_enabled:
|
||
return
|
||
|
||
# 计算远期收益率
|
||
last_close = last_data.groupby('symbol')['close'].last()
|
||
current_close = current_data.groupby('symbol')['close'].last
|
||
forward_returns = (current_close - last_close) / last_close
|
||
|
||
# 处理因子得到当前因子得分
|
||
prepared_data = prepare_market_cap_neutral(current_data)
|
||
factor_df = batch_process_factors(self.factor_dict, prepared_data)
|
||
|
||
# 更新IC
|
||
self.current_ic = self.ic_adjuster.update_monthly_ic(factor_df, forward_returns)
|
||
|
||
# 计算新权重
|
||
new_weights = self.ic_adjuster.calculate_weights()
|
||
self.current_weights = new_weights
|
||
|
||
self.write_log(f"动态权重已更新,当前IC: {self.current_ic}")
|
||
|
||
def update_market_timing(self, market_pe: float):
|
||
"""更新估值择位"""
|
||
if not self.market_timing_enabled:
|
||
return
|
||
|
||
self.market_timer.update_monthly(market_pe)
|
||
self.target_total_position = self.market_timer.calculate_target_position()
|
||
|
||
q = self.market_timer.get_current_quantile()
|
||
if q is not None:
|
||
self.write_log(
|
||
f"估值择时更新: 分位数={q:.2f}, 目标仓位={self.target_total_position:.2f}"
|
||
)
|
||
|
||
def rebalance(self, current_date):
|
||
"""执行调仓"""
|
||
# 获取最新全市场数据
|
||
data = self.get_current_market_data()
|
||
if data is None or len(data) == 0:
|
||
self.write_log("没有数据,跳过调仓")
|
||
return
|
||
|
||
# 预处理:添加log市值用于中性化
|
||
data = prepare_market_cap_neutral(data)
|
||
|
||
# 如果开启市场状态权重分配,根据当前市场更新因子权重
|
||
if self.regime_weight_enabled:
|
||
index_data = self.get_market_index_data()
|
||
if index_data is not None:
|
||
new_weights = self.regime_allocator.get_weights(index_data)
|
||
expanded_weights = self.regime_allocator.expand_to_factor_names(new_weights)
|
||
self.current_weights = expanded_weights
|
||
current_regime = self.regime_allocator.classify_market_regime(index_data)
|
||
self.write_log(f"市场状态识别: {current_regime}, 权重已更新")
|
||
|
||
# 如果有上期数据,更新动态IC权重
|
||
if self.dynamic_weight_enabled and self.last_data is not None:
|
||
self.update_dynamic_weights(self.last_data, data)
|
||
|
||
# 如果开启择时,更新整体仓位
|
||
if self.market_timing_enabled:
|
||
market_pe = self.calculate_market_pe(data)
|
||
self.update_market_timing(market_pe)
|
||
|
||
# 批量处理所有因子(中性化+标准化+rank)
|
||
processed_factors = batch_process_factors(self.factor_dict, data)
|
||
|
||
# 计算总得分
|
||
total_score = self.calculate_total_score(processed_factors)
|
||
if total_score is None or len(total_score.dropna()) == 0:
|
||
self.write_log("计算得分失败,跳过调仓")
|
||
return
|
||
|
||
# 选股
|
||
selected = self.select_stocks(total_score, data)
|
||
|
||
if len(selected) == 0:
|
||
self.write_log("没有选中股票,跳过调仓")
|
||
return
|
||
|
||
# 计算目标权重(考虑板块和单票限制)
|
||
target_weights = self.calculate_target_weights(selected, data)
|
||
|
||
# 执行调仓
|
||
self.execute_rebalance(target_weights)
|
||
|
||
# 保存数据供下次IC计算
|
||
self.last_data = data
|
||
|
||
self.write_log(
|
||
f"调仓完成: 日期{current_date}, 选中{len(selected)}只股票, "
|
||
f"目标总仓位{self.target_total_position:.2f}"
|
||
)
|
||
|
||
def get_current_market_data(self) -> Optional[pd.DataFrame]:
|
||
"""
|
||
获取当前全市场数据
|
||
需要包含列:
|
||
- symbol: 股票代码
|
||
- date: 日期
|
||
- close: 收盘价
|
||
- pe: 市盈率
|
||
- pb: 市净率
|
||
- roe: 净资产收益率
|
||
- revenue_yoy: 营收同比增速
|
||
- profit_yoy: 利润同比增速
|
||
- market_cap: 市值
|
||
- sector: 板块
|
||
- industry_code: 行业代码(中性化用)
|
||
"""
|
||
# 这里需要对接你的数据源,接口留出
|
||
return None
|
||
|
||
def calculate_market_pe(self, data: pd.DataFrame) -> float:
|
||
"""计算全市场平均PE用于估值择时"""
|
||
pes = data['pe'].dropna()
|
||
pes = pes[pes > 0]
|
||
if len(pes) == 0:
|
||
return 15 # 默认中性值
|
||
|
||
# 中位数PE
|
||
return pes.median()
|
||
|
||
def execute_rebalance(self, target_weights: Dict[str, float]):
|
||
"""实际执行调仓"""
|
||
# 获取当前持仓
|
||
current_positions = self.get_all_positions()
|
||
|
||
# 平掉不在目标中的持仓
|
||
for pos in current_positions:
|
||
symbol = pos.vt_symbol
|
||
if symbol not in target_weights and pos.volume > 0:
|
||
self.sell(symbol, self.get_last_price(symbol), 0)
|
||
|
||
# 调整目标持仓
|
||
for symbol, target_weight in target_weights.items():
|
||
target_value = self.balance * target_weight
|
||
current_price = self.get_last_price(symbol)
|
||
if current_price <= 0:
|
||
continue
|
||
|
||
# A股整手
|
||
target_volume = int(target_value / current_price / 100) * 100
|
||
if target_volume <= 0:
|
||
continue
|
||
|
||
# 获取当前持仓
|
||
current_pos = self.get_position(symbol)
|
||
current_volume = current_pos.volume if current_pos else 0
|
||
|
||
if target_volume > current_volume:
|
||
# 加仓
|
||
volume = target_volume - current_volume
|
||
self.buy(symbol, current_price, volume)
|
||
elif target_volume < current_volume:
|
||
# 减仓
|
||
volume = current_volume - target_volume
|
||
self.sell(symbol, current_price, volume)
|
||
|
||
self.put_order()
|