Files
sanguo_quant_live/strategies/structured-dynamic-factors-20260327/main_strategy.py
T
2026-03-28 00:14:34 +08:00

597 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
结构化适配动态多因子策略
完整方案:
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()