Files
sanguo_quant_live/strategies/factors-dynamic-weight-timing-20260327/main_strategy_single_file.py
T
2026-04-02 08:55:07 +08:00

758 lines
25 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
进阶多因子+动态加权+估值择时 A股量化中低频策略
单文件版本,无需额外导入
集成单票止损风控模块
"""
import pandas as pd
import numpy as np
from vnpy.app.cta_strategy import CtaTemplate
from vnpy.trader.object import BarData, TickData
# 导入风控模块 - 关羽开发的单票止损+四层风控体系
from risk_control import RiskController, StockInfo, PortfolioInfo
# 自己实现spearman相关系数,不用依赖scipy
def spearmanr(a, b):
"""简单实现spearman秩相关系数"""
a_rank = a.rank()
b_rank = b.rank()
d = a_rank - b_rank
n = len(a.dropna())
if n <= 1:
return 0, None
rho = 1 - 6 * (d ** 2).sum() / (n * (n ** 2 - 1))
return rho, None
class BaseFactor:
"""因子基类 - 所有因子继承此类,定义统一的因子计算接口"""
def __init__(self, name):
self.name = name
self.data = None
def calculate_raw(self, df):
"""计算原始因子值"""
pass
def normalize(self, factor_values):
"""中位数去极值 + z-score标准化"""
values = factor_values.dropna()
if len(values) == 0:
return factor_values
median = values.median()
mad = (values - median).abs().median()
lower = median - 3 * mad
upper = median + 3 * mad
factor_clipped = factor_values.clip(lower, upper)
mean = factor_clipped.mean()
std = factor_clipped.std()
if std > 0:
factor_normalized = (factor_clipped - mean) / std
else:
factor_normalized = factor_clipped - mean
return factor_normalized
def cross_sectional_rank(self, factor_values):
"""横截面rank 0-1"""
if isinstance(factor_values.index, pd.MultiIndex):
ranked = factor_values.groupby(level=0).rank(pct=True)
else:
ranked = factor_values.rank(pct=True)
return ranked
def process(self, df):
"""完整处理流程: 原始计算 → 标准化 → rank"""
raw = self.calculate_raw(df)
normalized = self.normalize(raw)
ranked = self.cross_sectional_rank(normalized)
return ranked
class PEFactor(BaseFactor):
"""市盈率PE因子 - 越小越好"""
def __init__(self):
super().__init__("pe")
def calculate_raw(self, df):
if 'pe' not in df.columns:
raise ValueError("DataFrame中缺少pe列")
pe = df['pe']
pe = pe.where(pe > 0, pd.NA)
return -pe
class PBFactor(BaseFactor):
"""市净PB因子 - 越小越好"""
def __init__(self):
super().__init__("pb")
def calculate_raw(self, df):
if 'pb' not in df.columns:
raise ValueError("DataFrame中缺少pb列")
pb = df['pb']
pb = pb.where(pb > 0, pd.NA)
return -pb
class ROEFactor(BaseFactor):
"""ROE净资产收益率因子 - 越大越好"""
def __init__(self):
super().__init__("roe")
def calculate_raw(self, df):
if 'roe' not in df.columns:
raise ValueError("DataFrame中缺少roe列")
return df['roe']
class Momentum1MFactor(BaseFactor):
"""1个月动量因子 - 越大越好"""
def __init__(self, periods=21):
super().__init__("momentum_1m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
momentum = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
momentum = df.groupby('symbol')['close'].pct_change(self.periods)
return momentum
class Momentum3MFactor(BaseFactor):
"""3个月动量因子 - 越大越好"""
def __init__(self, periods=63):
super().__init__("momentum_3m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
momentum = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
momentum = df.groupby('symbol')['close'].pct_change(self.periods)
return momentum
class VolatilityFactor(BaseFactor):
"""波动率因子 - 越小越好,所以取负"""
def __init__(self, periods=63):
super().__init__("volatility_3m")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns:
raise ValueError("DataFrame缺少close列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
returns = df['close'].groupby(level='symbol').pct_change()
volatility = returns.groupby(level='symbol').rolling(self.periods).std()
volatility = volatility.droplevel(0)
else:
returns = df.groupby('symbol')['close'].pct_change()
volatility = returns.rolling(self.periods).std()
return -volatility
class SizeFactor(BaseFactor):
"""市值因子 - 中小盘偏好,市值越小越好"""
def __init__(self):
super().__init__("size")
def calculate_raw(self, df):
if 'market_cap' not in df.columns:
if 'close' in df.columns and 'circulating_cap' in df.columns:
market_cap = df['close'] * df['circulating_cap']
else:
raise ValueError("缺少market_cap列,也没有close+circulating_cap")
else:
market_cap = df['market_cap']
log_cap = np.log(market_cap)
return -log_cap
class SectorStrengthFactor(BaseFactor):
"""板块强度因子 - 板块强度越高越好,适配结构化行情"""
def __init__(self, periods=21):
super().__init__("sector_strength")
self.periods = periods
def calculate_raw(self, df):
if 'close' not in df.columns or 'sector' not in df.columns:
raise ValueError("需要close和sector列")
if isinstance(df.index, pd.MultiIndex) and 'symbol' in df.index.names:
returns = df['close'].groupby(level='symbol').pct_change(self.periods)
else:
returns = df.groupby('symbol')['close'].pct_change(self.periods)
if isinstance(df.index, pd.MultiIndex) and 'date' in df.index.names:
latest_date = df.index.get_level_values('date').max()
latest_df = df.xs(latest_date, level='date').copy()
latest_df['return_1m'] = returns.loc[latest_df.index]
else:
latest_date = df['date'].max()
latest_df = df[df['date'] == latest_date].copy()
latest_df['return_1m'] = returns.loc[latest_df.index]
latest_df = latest_df.dropna(subset=['return_1m'])
sector_avg_ret = latest_df.groupby('sector')['return_1m'].mean()
market_avg_ret = latest_df['return_1m'].mean()
sector_strength = sector_avg_ret / market_avg_ret
result = pd.Series(index=df.index, dtype=float)
for symbol, row in latest_df.iterrows():
sector = row['sector']
strength = sector_strength.loc[sector]
if symbol in result.index:
result.loc[symbol] = strength
else:
mask = df['symbol'] == symbol
result.loc[mask] = strength
return result
class FactorCombiner:
"""因子合成器 - 把多个因子按权重合成最终得分"""
def __init__(self, factors, weights=None):
self.factors = factors
if weights is None:
total = len(factors)
self.weights = {name: 1.0 / total for name in factors}
else:
total = sum(weights.values())
self.weights = {k: v / total for k, v in weights.items()}
def combine(self, data):
combined = None
for name, factor in self.factors.items():
weight = self.weights[name]
factor_score = factor.process(data)
weighted = factor_score * weight
if combined is None:
combined = weighted
else:
combined = combined.add(weighted, fill_value=0)
if combined is not None:
combined = combined.sort_values(ascending=False)
return combined
def update_weights(self, new_weights):
total = sum(new_weights.values())
self.weights = {k: v / total for k, v in new_weights.items()}
def get_weights(self):
return self.weights.copy()
class DynamicWeightAdjuster:
"""动态权重调整器 - 根据滚动IC自动调整因子权重"""
def __init__(
self,
factor_names,
window_size=12,
min_ic=-0.02,
base_weight=0.02
):
self.factor_names = factor_names
self.window_size = window_size
self.min_ic = min_ic
self.base_weight = base_weight
self.ic_history = {name: [] for name in factor_names}
def update_monthly_ic(
self,
factor_df,
forward_returns
):
current_ic = {}
combined = pd.concat([factor_df, forward_returns], axis=1).dropna()
for name in self.factor_names:
if name not in combined.columns:
continue
ic, _ = spearmanr(combined[name], combined[forward_returns.name])
current_ic[name] = ic
self.ic_history[name].append(ic)
if len(self.ic_history[name]) > self.window_size:
self.ic_history[name].pop(0)
return current_ic
def calculate_weights(self):
avg_ic = {}
for name in self.factor_names:
ics = self.ic_history[name]
if len(ics) > 0:
avg_ic[name] = np.mean(ics)
else:
avg_ic[name] = 0
weights = {}
for name in self.factor_names:
ic = avg_ic[name]
if ic < self.min_ic:
weights[name] = self.base_weight
else:
weights[name] = self.base_weight + max(0, ic)
total = sum(weights.values())
if total > 0:
weights = {k: v / total for k, v in weights.items()}
else:
n = len(self.factor_names)
weights = {k: 1.0 / n for k in self.factor_names}
return weights
def get_avg_ic(self):
avg = {}
for name, ics in self.ic_history.items():
if len(ics) > 0:
avg[name] = np.mean(ics)
else:
avg[name] = 0
return avg
class MarketValuationTiming:
"""全市场估值择时 - 根据PE分位数调整整体仓位"""
def __init__(
self,
min_position=0.3,
max_position=1.0,
quantile_low=0.2,
quantile_high=0.8,
lookback_months=60
):
self.min_position = min_position
self.max_position = max_position
self.quantile_low = quantile_low
self.quantile_high = quantile_high
self.lookback_months = lookback_months
self.history = []
def update_monthly(self, market_pe):
self.history.append(market_pe)
if len(self.history) > self.lookback_months:
self.history.pop(0)
def calculate_target_position(self):
if len(self.history) < 12:
return 0.8 * self.max_position
current_pe = self.history[-1]
history_arr = np.array(self.history)
quantile = np.mean(history_arr <= current_pe)
if quantile <= self.quantile_low:
return self.max_position
elif quantile >= self.quantile_high:
return self.min_position
else:
ratio = (quantile - self.quantile_low) / (self.quantile_high - self.quantile_low)
position = self.max_position - ratio * (self.max_position - self.min_position)
return position
def get_current_quantile(self):
if len(self.history) < 2:
return None
current = self.history[-1]
arr = np.array(self.history)
return np.mean(arr <= current)
class MultiFactorDynamicStrategy(CtaTemplate):
"""
进阶多因子动态加权策略
特点:
1. 多因子复合选股
2. 动态加权(根据IC调整)
3. 估值择时(调整整体仓位)
4. 板块强度适配结构化行情
5. 单板块仓位限制
"""
author = "翼德"
parameters = [
"rebalance_freq",
"holding_size",
"top_select",
"dynamic_weight",
"ic_window",
"market_timing",
"min_position",
"max_position",
"max_sector_pct",
]
variables = [
"current_factor_scores",
"current_weights",
"target_position",
"last_rebalance_date",
]
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)
self.top_select = getattr(self, 'top_select', 0.1)
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)
self.factors_list = [
PEFactor(),
PBFactor(),
ROEFactor(),
Momentum1MFactor(),
Momentum3MFactor(),
VolatilityFactor(),
SizeFactor(),
SectorStrengthFactor()
]
default_weights = {
'pe': 0.15,
'pb': 0.15,
'roe': 0.15,
'momentum_1m': 0.08,
'momentum_3m': 0.12,
'volatility_3m': 0.15,
'size': 0.15,
'sector_strength': 0.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.target_position = self.max_position
else:
self.target_position = self.max_position
self.current_factor_scores = None
self.current_weights = self.factor_combiner.get_weights()
self.last_rebalance_date = None
self.last_data = None
# 初始化风控控制器 - 关羽风控模块
self.risk_controller = RiskController()
def on_init(self):
self.write_log("策略初始化完成")
self.load_bar(1000)
def on_start(self):
self.write_log("策略启动")
def on_stop(self):
self.write_log("策略停止")
def on_bar(self, bar):
current_date = bar.datetime.date()
# 每日执行风控检查 - 单票止损
self._check_risk_control()
if not self._need_rebalance(current_date):
return
self.rebalance()
self.last_rebalance_date = current_date
def _check_risk_control(self):
"""风控检查:执行单票止损和组合降仓"""
# 获取当前持仓转换为风控数据结构
holds = self.get_all_holds()
if not holds:
return
portfolio = self._get_portfolio_info()
stocks = self._get_stock_info_list(holds)
# 执行风控检查
risk_result = self.risk_controller.post_trade_check(stocks, portfolio)
# 执行止损
if risk_result['stop_loss_required']:
for stop_item in risk_result['stop_loss_stocks']:
code = stop_item['code']
if code in holds:
hold = holds[code]
self.write_log(f"⚠️ 触发单票止损: {code}, 回撤: {stop_item['current_drawdown']:.2%}")
self.sell(code, hold.price, hold.volume)
# 执行组合降仓
if risk_result['rebalance_required']:
target_ratio = risk_result['target_position_ratio']
current_ratio = risk_result['current_position_ratio']
self.write_log(f"⚠️ 组合回撤触发降仓: 当前回撤 {risk_result['current_drawdown']:.2%}, 当前仓位 {current_ratio:.2%}, 目标仓位 {target_ratio:.2%}")
self.put_order()
def _need_rebalance(self, current_date):
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):
final_scores = self.factor_combiner.combine(data)
return final_scores
def select_stocks(self, scores):
n_total = len(scores.dropna())
if self.holding_size:
n_select = self.holding_size
else:
n_select = int(n_total * self.top_select)
selected = scores.head(n_select).index.tolist()
return selected
def calculate_weights_for_selected(self, selected, data):
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
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, last_data, current_data):
if not self.dynamic_weight:
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
factor_scores = {}
for name, factor in self.factor_combiner.factors.items():
factor_scores[name] = factor.process(current_data)
factor_df = pd.DataFrame(factor_scores)
self.dynamic_adjuster.update_monthly_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):
if not self.market_timing:
return
self.market_timer.update_monthly(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):
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') and self.last_data is not None:
self.update_dynamic_weights(self.last_data, data)
if self.market_timing:
market_pe = self.calculate_market_pe(data)
self.update_market_timing(market_pe)
scores = self.calculate_factors(data)
if scores is None or len(scores.dropna()) == 0:
self.write_log("计算得分失败,跳过调仓")
return
selected = self.select_stocks(scores)
if len(selected) == 0:
self.write_log("没有选中股票,跳过调仓")
return
target_weights = self.calculate_weights_for_selected(selected, data)
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):
return None
def calculate_market_pe(self, data):
pes = data['pe'].dropna()
pes = pes[pes > 0]
if len(pes) == 0:
return 15
return pes.median()
def rebalance_portfolio(self, target_weights):
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
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()
def _get_stock_info_list(self, holds):
"""转换持仓为风控StockInfo列表"""
stocks = []
for symbol, hold in holds.items():
# 获取最新价格,这里简化处理
current_price = self.get_last_price(symbol)
if current_price is None or current_price <= 0:
continue
# 创建StockInfo
# 这里假设已经完成了黑天鹅过滤,ST等风险已经在选股阶段排除
stock = StockInfo(
code=symbol,
name=symbol, # 回测中简化处理
cost_price=hold.price,
current_price=current_price,
is_st=False,
is_limit_down=False,
is_fraud=False,
volume=0.0
)
stocks.append(stock)
return stocks
def _get_portfolio_info(self):
"""转换组合信息为风控PortfolioInfo"""
total_capital = self.initial_balance
current_capital = self.balance
positions = {}
holds = self.get_all_holds()
for symbol, hold in holds.items():
current_price = self.get_last_price(symbol)
if current_price > 0:
positions[symbol] = hold.volume * current_price
return PortfolioInfo(
total_capital=total_capital,
current_capital=current_capital,
positions=positions
)