408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""
|
|
进阶多因子+动态加权+估值择时 A股量化中低频策略
|
|
主策略实现,遵循vn.py CtaStrategy接口
|
|
"""
|
|
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, TickData
|
|
|
|
# 导入我们的因子和工具
|
|
import sys
|
|
sys.path.append('..')
|
|
from factors import (
|
|
PEFactor, PBFactor, ROEFactor,
|
|
Momentum1MFactor, Momentum3MFactor,
|
|
VolatilityFactor, SizeFactor,
|
|
SectorStrengthFactor
|
|
)
|
|
from utils import FactorCombiner, DynamicWeightAdjuster, MarketValuationTiming
|
|
|
|
|
|
class MultiFactorDynamicStrategy(CtaTemplate):
|
|
"""
|
|
进阶多因子策略
|
|
特点:
|
|
1. 多因子复合选股
|
|
2. 动态加权(根据IC调整)
|
|
3. 估值择时(调整整体仓位)
|
|
4. 中低频调仓(月频/周频)
|
|
"""
|
|
|
|
# 策略参数
|
|
author = "翼德"
|
|
parameters = [
|
|
"rebalance_freq", # 调仓频率,'M'月频 'W'周频
|
|
"holding_size", # 持股数量
|
|
"top_select", # 选前N%的股票
|
|
"dynamic_weight", # 是否开启动态加权
|
|
"ic_window", # IC观测窗口
|
|
"market_timing", # 是否开启估值择时
|
|
"min_position", # 最小仓位
|
|
"max_position", # 最大仓位
|
|
"max_sector_pct", # 单板块最大仓位占比(相对于总仓位)
|
|
]
|
|
|
|
# 策略变量
|
|
variables = [
|
|
"current_factor_scores", # 当前因子得分
|
|
"current_weights", # 当前因子权重
|
|
"target_position", # 当前目标仓位
|
|
"last_rebalance_date", # 上次调仓日期
|
|
"max_sector_pct", # 单板块最大仓位
|
|
]
|
|
|
|
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.holding_size = getattr(self, 'holding_size', 50) # 默认持有50只
|
|
self.top_select = getattr(self, 'top_select', 0.1) # 选前10%
|
|
self.dynamic_weight = getattr(self, 'dynamic_weight', True)
|
|
self.ic_window = getattr(self, 'ic_window', 12)
|
|
self.market_timing = getattr(self, 'market_timing', True)
|
|
self.min_position = getattr(self, 'min_position', 0.3)
|
|
self.max_position = getattr(self, 'max_position', 1.0)
|
|
self.max_sector_pct = getattr(self, 'max_sector_pct', 0.20) # 单板块最大20%仓位
|
|
|
|
# 初始化因子
|
|
self._init_factors()
|
|
|
|
# 初始化因子合成器
|
|
# 调整后的初始权重(适配结构化行情):
|
|
# 总权重100%,趋势因子从15%→20%,新增板块强度10%
|
|
default_weights = {
|
|
'pe': 0.15,
|
|
'pb': 0.15,
|
|
'roe': 0.15,
|
|
'momentum_1m': 0.08,
|
|
'momentum_3m': 0.12, # 趋势合计 0.08+0.12=0.20 → 20%
|
|
'volatility_3m': 0.15,
|
|
'size': 0.15,
|
|
'sector_strength': 0.10, # 新增板块强度 10%
|
|
}
|
|
# 检查是否所有因子都覆盖
|
|
factor_dict = {f.name: f for f in self.factors_list}
|
|
self.factor_combiner = FactorCombiner(factor_dict, default_weights)
|
|
|
|
# 初始化动态加权
|
|
if self.dynamic_weight:
|
|
factor_names = list(factor_dict.keys())
|
|
self.dynamic_adjuster = DynamicWeightAdjuster(
|
|
factor_names,
|
|
window_size=self.ic_window
|
|
)
|
|
|
|
# 初始化估值择时
|
|
if self.market_timing:
|
|
self.market_timer = MarketValuationTiming(
|
|
min_position=self.min_position,
|
|
max_position=self.max_position
|
|
)
|
|
|
|
# 策略状态变量
|
|
self.current_factor_scores = None
|
|
self.current_weights = self.factor_combiner.get_weights()
|
|
self.target_position = self.max_position # 默认最大仓位
|
|
self.last_rebalance_date = None
|
|
|
|
def _init_factors(self):
|
|
"""初始化所有因子列表
|
|
调整后权重(适配结构化行情):
|
|
- PE: ~15%
|
|
- PB: ~15%
|
|
- ROE: ~15%
|
|
- Momentum1M: 10% → 提高到 ~12%
|
|
- Momentum3M: 10% → 提高到 ~13% (合计趋势因子20%)
|
|
- Volatility: ~15%
|
|
- Size: ~15%
|
|
- SectorStrength: +10%
|
|
"""
|
|
self.factors_list = [
|
|
PEFactor(),
|
|
PBFactor(),
|
|
ROEFactor(),
|
|
Momentum1MFactor(),
|
|
Momentum3MFactor(),
|
|
VolatilityFactor(),
|
|
SizeFactor(),
|
|
SectorStrengthFactor() # 新增板块强度因子,适配结构化行情
|
|
]
|
|
|
|
def on_init(self):
|
|
"""策略初始化"""
|
|
self.write_log("策略初始化完成")
|
|
self.load_bar(1000) # 加载1000天数据用于热身
|
|
|
|
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()
|
|
|
|
# 更新上次调仓日期
|
|
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':
|
|
# 周频调仓:不同周就调仓
|
|
current_week = current_date.isocalendar()[1]
|
|
last_week = self.last_rebalance_date.isocalendar()[1]
|
|
if current_week != last_week:
|
|
return True
|
|
|
|
return False
|
|
|
|
def calculate_factors(self, data: pd.DataFrame) -> pd.Series:
|
|
"""
|
|
计算所有因子,合成最终得分
|
|
参数:
|
|
data: 所有股票的行情财务数据,columns需要包含因子所需列
|
|
返回:
|
|
最终得分,降序排列
|
|
"""
|
|
# 合成因子得分
|
|
final_scores = self.factor_combiner.combine(data)
|
|
|
|
# 排序,得分高在前
|
|
final_scores = final_scores.sort_values(ascending=False)
|
|
|
|
return final_scores
|
|
|
|
def select_stocks(self, scores: pd.Series) -> List:
|
|
"""
|
|
根据因子得分选股票
|
|
参数:
|
|
scores: 因子得分降序排列
|
|
返回:
|
|
选中的股票列表
|
|
"""
|
|
n_total = len(scores.dropna())
|
|
|
|
if self.holding_size:
|
|
# 固定持股数量
|
|
n_select = self.holding_size
|
|
else:
|
|
# 按比例选
|
|
n_select = int(n_total * self.top_select)
|
|
|
|
# 选前n个
|
|
selected = scores.head(n_select).index.tolist()
|
|
|
|
return selected
|
|
|
|
def calculate_weights_for_selected(self, selected: List, data: pd.DataFrame) -> Dict[str, float]:
|
|
"""
|
|
计算选中股票的目标权重
|
|
考虑整体择时仓位
|
|
考虑板块限制:单板块最高不超过max_sector_pct
|
|
等权分配给选中股票,然后调整板块超限
|
|
"""
|
|
n = len(selected)
|
|
if n == 0:
|
|
return {}
|
|
|
|
# 获取选中股票的板块信息
|
|
selected_df = data.loc[data['symbol'].isin(selected)]
|
|
|
|
# 初步等权分配
|
|
single_weight = self.target_position / n
|
|
weights = {symbol: single_weight for symbol in selected}
|
|
|
|
# 检查板块仓位,超过限制就平均压缩
|
|
# 按板块汇总
|
|
if 'sector' in selected_df.columns:
|
|
sector_weights = {}
|
|
for symbol in selected:
|
|
sector = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
|
|
w = weights[symbol]
|
|
if sector not in sector_weights:
|
|
sector_weights[sector] = 0
|
|
sector_weights[sector] += w
|
|
|
|
# 检查超限
|
|
max_allowed = self.target_position * self.max_sector_pct
|
|
for sector, total_w in sector_weights.items():
|
|
if total_w > max_allowed:
|
|
# 需要压缩,计算压缩比例
|
|
ratio = max_allowed / total_w
|
|
# 压缩该板块所有股票
|
|
for symbol in selected:
|
|
s = selected_df[selected_df['symbol'] == symbol]['sector'].iloc[0]
|
|
if s == sector:
|
|
weights[symbol] *= ratio
|
|
|
|
# 重新归一化,保证总权重还是target_position
|
|
total = sum(weights.values())
|
|
if total > 0:
|
|
ratio = self.target_position / total
|
|
for symbol in weights:
|
|
weights[symbol] *= ratio
|
|
|
|
return weights
|
|
|
|
def update_dynamic_weights(self, data: pd.DataFrame, forward_returns: pd.Series):
|
|
"""
|
|
更新动态权重(基于IC)
|
|
"""
|
|
if not self.dynamic_weight:
|
|
return
|
|
|
|
# 计算每个因子当期IC并更新历史
|
|
# 获取当前因子得分
|
|
factor_scores = {}
|
|
for name, factor in self.factor_combiner.factors.items():
|
|
factor_scores[name] = factor.process(data)
|
|
|
|
factor_df = pd.DataFrame(factor_scores)
|
|
self.dynamic_adjuster.update_ic(factor_df, forward_returns)
|
|
|
|
# 计算新权重
|
|
new_weights = self.dynamic_adjuster.calculate_weights()
|
|
self.factor_combiner.update_weights(new_weights)
|
|
self.current_weights = new_weights
|
|
|
|
self.write_log(f"动态权重更新完成: {new_weights}")
|
|
|
|
def update_market_timing(self, market_pe: float):
|
|
"""
|
|
更新估值择位
|
|
"""
|
|
if not self.market_timing:
|
|
return
|
|
|
|
self.market_timer.update_valuation(market_pe)
|
|
self.target_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_position:.2f}")
|
|
|
|
def rebalance(self):
|
|
"""
|
|
执行调仓
|
|
这是主调仓逻辑,实际回测中vn.py会调用这里
|
|
"""
|
|
# 获取最新数据(实际使用中从数据接口获取)
|
|
# 这里留出接口,实际回测时填充
|
|
data = self.get_current_market_data()
|
|
|
|
if data is None or len(data) == 0:
|
|
self.write_log("没有可用数据,跳过调仓")
|
|
return
|
|
|
|
# 如果有动态加权,更新权重
|
|
if self.dynamic_weight and hasattr(self, 'last_data'):
|
|
# 计算上期到这期的收益率
|
|
forward_returns = self.calculate_forward_returns(self.last_data, data)
|
|
self.update_dynamic_weights(self.last_data, forward_returns)
|
|
|
|
# 计算因子得分
|
|
scores = self.calculate_factors(data)
|
|
|
|
# 选股
|
|
selected = self.select_stocks(scores)
|
|
|
|
# 计算权重(包含择时仓位,包含板块限制)
|
|
target_weights = self.calculate_weights_for_selected(selected, data)
|
|
|
|
# 执行调仓(调用vn.py接口)
|
|
self.rebalance_portfolio(target_weights)
|
|
|
|
# 保存数据供下次动态加权使用
|
|
self.last_data = data
|
|
|
|
self.write_log(f"调仓完成,选中{len(selected)}只股票,目标仓位{self.target_position:.2f}")
|
|
|
|
def get_current_market_data(self) -> Optional[pd.DataFrame]:
|
|
"""
|
|
获取当前市场数据(包含收盘价、财务指标等)
|
|
需要根据实际数据源实现
|
|
这里留出接口
|
|
"""
|
|
# 实际使用中,从你的数据接口获取
|
|
# 返回格式:
|
|
# index = (date, symbol) 或者 MultiIndex
|
|
# columns 需要包含: pe, pb, roe, close, market_cap 等
|
|
return None
|
|
|
|
def calculate_forward_returns(self, last_data: pd.DataFrame, current_data: pd.DataFrame) -> pd.Series:
|
|
"""计算远期收益率(用于IC计算)"""
|
|
# 获取价格计算收益率
|
|
last_close = last_data.groupby(level='symbol')['close'].last()
|
|
current_close = current_data.groupby(level='symbol')['close'].last()
|
|
|
|
forward_returns = (current_close - last_close) / last_close
|
|
|
|
return forward_returns
|
|
|
|
def rebalance_portfolio(self, target_weights: Dict[str, float]):
|
|
"""
|
|
实际执行调仓,调整每个股票仓位
|
|
"""
|
|
# 获取当前持仓
|
|
current_holds = self.get_all_holds()
|
|
|
|
# 平掉不在目标中的持仓
|
|
for symbol in current_holds:
|
|
if symbol not in target_weights:
|
|
self.sell(symbol, current_holds[symbol].price, 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股最小买100股
|
|
target_volume = int(target_value / current_price / 100) * 100
|
|
|
|
if target_volume <= 0:
|
|
continue
|
|
|
|
# 获取当前持仓
|
|
current_hold = current_holds.get(symbol, None)
|
|
current_volume = current_hold.volume if current_hold 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()
|