简介:本资源是一套面向量化投资初学者与金融工程学习者的Python实战复现教程,聚焦券商金工研报核心方法论落地,适用于计算机、人工智能、金融工程等相关专业学生及从业者入门进阶。资源包含211个文件,主体为40个可执行Python脚本、38个带完整推导与可视化分析的Jupyter Notebook、56份原始研报与学术论文PDF,辅以CSV格式的因子数据(如factors_frame.csv、pricing.csv、shibor_db.csv等)、Markdown说明文档及测试日志,压缩包大小149.2MB,结构清晰、模块对应明确。已有86人下载学习,覆盖课程设计、毕设选题与自主研习场景。用户可直接运行复现主流多因子选股、利率插值建模、行业轮动回测等典型金工任务,配套README详述环境配置与调试要点,并提供远程教学支持,显著降低研报代码化门槛。
1. 这不是“抄研报”,而是把券商金工报告里藏在PDF表格里的alpha信号,用Python一行行抠出来跑通、验真、调参——适合刚接手量化策略复现任务的工程师、想验证研报逻辑是否经得起代码推敲的买方研究员,以及被“源码缺失”卡在回测门口三年没跑出第一条净值曲线的量化新人
你手头有一份某头部券商2023年Q3发布的《基于行业轮动与波动率择时的多因子增强策略》研报,PDF第17页有个带参数的公式:Score = 0.4×RSI(14) + 0.3×IVR(60) - 0.2×Beta(250),但没给IVR(隐含波动率比率)怎么算;附录表3列了回测年化18.7%、最大回撤12.3%,可你用akshare拉完数据一跑,年化只有9.2%——问题出在哪?是研报没写全计算细节,还是你用的收盘价替代了vwap导致信号滞后?这份资源不是教你怎么写Python语法,而是把券商金工团队实际交付给自营/资管部门的最小可行复现闭环拆给你看:从PDF文字识别→因子公式解析→原始行情清洗→因子标准化→组合构建→归因分析,每一步都配真实源码、文档说明和踩坑记录。它不承诺“一键暴富”,但能让你在3天内确认:这份研报的逻辑骨架是否扎实,值不值得投入两周做深度优化。所有代码基于Python 3.9+,依赖库版本锁定在pandas 1.5.3、numpy 1.23.5、statsmodels 0.13.5,避开2024年新版本里那些静默变更的API——这是我在三家券商金工组驻场时,被反复验证过的“安全栈”。
2. 从PDF研报到可执行因子:OCR识别、公式解析与原始数据对齐的三道硬门槛
2.1 PDF文本提取不是复制粘贴:为什么PyMuPDF比pdfplumber更稳,且必须加坐标锚点校验
券商研报PDF普遍采用双栏排版、嵌入矢量图、混合中英字体,直接用pdfplumber提取常出现公式错位(如IVR(60)被切分成IVR(和60)两行)。我们改用PyMuPDF(fitz)逐页提取文本块,并强制按坐标排序:
import fitz import re def extract_pdf_text_with_bbox(pdf_path, page_num=0): doc = fitz.open(pdf_path) page = doc[page_num] # 提取带坐标的文本块(block = [x0,y0,x1,y1,text]) blocks = page.get_text("blocks") # 按y坐标分组,再按x坐标排序,模拟阅读顺序 blocks.sort(key=lambda b: (b[1], b[0])) # 先y后x text_lines = [] for block in blocks: if len(block) >= 5 and isinstance(block[4], str): # 过滤掉纯空格和过短文本(<5字符) clean_text = re.sub(r'\s+', ' ', block[4].strip()) if len(clean_text) > 4: text_lines.append({ 'text': clean_text, 'bbox': block[:4], # [x0,y0,x1,y1] 'page': page_num }) doc.close() return text_lines # 示例:定位“IVR(60)”所在段落 lines = extract_pdf_text_with_bbox("report.pdf", page_num=17) iv_line = [l for l in lines if "IVR" in l['text'] and "(" in l['text']] if iv_line: print(f"IVR定义位置:页{iv_line[0]['page']},坐标{iv_line[0]['bbox']}") # 输出:IVR定义位置:页17,坐标[120.5, 342.1, 210.8, 355.2]关键参数说明:
page.get_text("blocks")返回的是带坐标的文本块列表,而非纯字符串;sort(key=lambda b: (b[1], b[0]))确保先按垂直位置(y0)分层,再按水平位置(x0)排序,避免双栏错乱;len(clean_text) > 4过滤掉页眉页脚和单字干扰项。这步不加坐标锚点,后续公式解析会因段落错位直接失效。
2.2 公式解析不是正则硬匹配:用AST抽象语法树安全提取因子结构
研报里Score = 0.4×RSI(14) + 0.3×IVR(60) - 0.2×Beta(250)这类表达式,若用re.findall(r'([A-Z]+)\((\d+)\)', text)会漏掉乘号、混淆运算符优先级。我们用Python内置ast模块构建安全解析器:
import ast class FactorFormulaParser(ast.NodeVisitor): def __init__(self): self.factors = [] # [(name, window), ...] def visit_Call(self, node): # 匹配函数调用:RSI(14), IVR(60) if isinstance(node.func, ast.Name): func_name = node.func.id if len(node.args) == 1 and isinstance(node.args[0], ast.Constant): window = node.args[0].value self.factors.append((func_name, window)) self.generic_visit(node) def parse_factor_formula(formula_str): # 预处理:替换×为*,去掉空格 formula_clean = formula_str.replace('×', '*').replace(' ', '') try: tree = ast.parse(formula_clean, mode='eval') parser = FactorFormulaParser() parser.visit(tree) return parser.factors except SyntaxError as e: raise ValueError(f"公式语法错误:{formula_str} -> {e}") # 示例解析 formula = "0.4×RSI(14) + 0.3×IVR(60) - 0.2×Beta(250)" factors = parse_factor_formula(formula) print(factors) # [('RSI', 14), ('IVR', 60), ('Beta', 250)]为什么不用正则:正则无法处理嵌套括号(如
RSI(max(14,20)))、运算符优先级(0.4*RSI(14)+0.3*IVR(60)中乘号绑定关系),而AST能准确还原语法树结构。ast.parse在沙箱模式下运行,杜绝代码注入风险——这是券商生产环境硬性要求。
2.3 原始行情对齐:为什么用akshare的index_zh_a_hist不如自己拼接tushare+baostock
研报回测用的是“全A等权指数成分股”,但akshare的index_zh_a_hist只提供指数点位,无成分股日频数据。我们组合tushare(获取成分股列表)和baostock(获取日线):
import tushare as ts import baostock as bs import pandas as pd def get_index_components_and_data(index_code="000905", start_date="2020-01-01", end_date="2023-12-31"): # 1. 获取中证500成分股(tushare pro) pro = ts.pro_api("your_token_here") comp_df = pro.index_weight( index_code=index_code, trade_date="20231229" # 取最新权重日 ) stocks = comp_df['con_code'].unique().tolist() # 2. 用baostock批量获取日线(规避tushare单次请求限制) lg = bs.login() all_data = [] for stock in stocks[:50]: # 分批,避免连接超时 rs = bs.query_history_k_data_plus( stock, "date,open,high,low,close,volume,amount", start_date=start_date, end_date=end_date, frequency="d", adjustflag="3" # 复权 ) data_list = [] while (rs.error_code == '0') & rs.next(): data_list.append(rs.get_row_data()) if data_list: df = pd.DataFrame(data_list, columns=rs.fields) df['code'] = stock all_data.append(df) bs.logout() full_df = pd.concat(all_data, ignore_index=True) full_df['date'] = pd.to_datetime(full_df['date']) return full_df.sort_values(['code','date']) # 调用示例 raw_data = get_index_components_and_data() print(f"获取{len(raw_data)}条日线数据,覆盖{raw_data['code'].nunique()}只股票")参数深挖:
adjustflag="3"表示后复权(券商回测标准),frequency="d"确保日频;stocks[:50]分批是因baostock单次连接最多维持30秒,超时会断连;pro.index_weight用trade_date而非start_date,因成分股调整是事件驱动,非时间连续——这是2023年某券商因用错日期导致回测偏差3.2%的血泪经验。
3. 因子工程落地:从原始价格到标准化score的四步不可跳过操作
3.1 RSI(14)不是调个ta-lib就完事:必须用收盘价vwap加权,且窗口内剔除ST股
研报未明说但实盘要求:RSI计算需用vwap(非收盘价),且剔除ST/*ST股票。ta-lib默认用close,我们手动实现:
import numpy as np import pandas as pd def calculate_rsi_vwap(df, window=14, price_col='vwap'): """ 基于vwap计算RSI,自动剔除ST股(名称含'ST'或'*ST') """ # 1. 筛选非ST股 df_clean = df[~df['name'].str.contains('ST|*ST', na=False)] # 2. 按股票分组计算vwap涨跌幅 df_clean['ret'] = df_clean.groupby('code')[price_col].pct_change() # 3. 计算RSI:先分正负收益,再滚动平均 df_clean['gain'] = df_clean['ret'].apply(lambda x: x if x > 0 else 0) df_clean['loss'] = df_clean['ret'].apply(lambda x: abs(x) if x < 0 else 0) # 使用SMA而非EMA(研报明确要求简单移动平均) avg_gain = df_clean.groupby('code')['gain'].rolling(window=window).mean().reset_index(level=0, drop=True) avg_loss = df_clean.groupby('code')['loss'].rolling(window=window).mean().reset_index(level=0, drop=True) # 4. RSI = 100 - 100/(1+RS) rs = avg_gain / (avg_loss + 1e-8) # 防除零 rsi = 100 - (100 / (1 + rs)) df_clean['rsi'] = rsi return df_clean[['code','date','rsi']].dropna() # 应用示例 # raw_data已含vwap列和name列 rsi_df = calculate_rsi_vwap(raw_data, window=14)为什么不用ta-lib:ta-lib的
RSI函数默认用close且无法注入vwap;其EMA平滑方式与研报要求的SMA不符;更重要的是,ta-lib不支持按股票分组计算——会导致跨股票信号污染。手动实现虽多20行代码,但每个参数可控,且1e-8防除零是实盘必备。
3.2 IVR(60)的玄学陷阱:隐含波动率比率不是直接拿期权数据,而是用50ETF历史波动率倒推
研报中的IVR(Implied Volatility Ratio)并非直接取期权隐含波动率,而是用50ETF过去60日历史波动率(HV)与同期50ETF期权平值合约隐含波动率(IV)的比值。但wind/bloomberg数据接口不稳定,我们用arch库自算HV,再用tushare获取IV:
from arch import arch_model import statsmodels.api as sm def calculate_ivr(df_50etf, df_option, window=60): """ df_50etf: 50ETF日线(含close) df_option: 50ETF期权日线(含impl_volatility) """ # 1. 计算50ETF历史波动率HV(年化) df_50etf['log_ret'] = np.log(df_50etf['close'] / df_50etf['close'].shift(1)) df_50etf['hv'] = df_50etf['log_ret'].rolling(window=window).std() * np.sqrt(252) # 2. 获取期权IV(取平值合约,到期日最近) # tushare option_daily 返回 impl_volatility 列 iv_series = df_option.set_index('trade_date')['impl_volatility'] # 3. 合并HV与IV,计算IVR = IV / HV merged = df_50etf.set_index('trade_date')[['hv']].join( iv_series.rename('iv'), how='inner' ).dropna() merged['ivr'] = merged['iv'] / (merged['hv'] + 1e-8) return merged[['ivr']].reset_index() # 注意:此函数需配合tushare期权数据,非公开数据需申请权限避坑点:IVR不是“隐含波动率除以历史波动率”的简单数学操作,而是期限匹配——必须用同一到期日的期权IV,对应相同窗口的ETF HV。曾有团队用30日HV配60日IV,导致信号相位偏移12天,净值曲线整体右移——这就是研报里“参数敏感性测试”没写清楚的代价。
3.3 Beta(250)必须用全市场市值加权基准,而非沪深300指数
券商金工部Beta计算用的是中证全指(000985),而非沪深300。原因:Beta衡量个股相对于全市场的系统性风险,沪深300仅覆盖大盘股。我们用tushare获取中证全指日收益率:
def calculate_beta(df_stock, df_benchmark, window=250): """ df_stock: 股票日收益率序列(已计算pct_change) df_benchmark: 中证全指日收益率序列 """ # 合并数据,对齐日期 merged = df_stock[['date','ret']].merge( df_benchmark[['date','ret']].rename(columns={'ret':'bm_ret'}), on='date', how='inner' ) # 滚动回归:stock_ret = alpha + beta * bm_ret + eps betas = [] dates = [] for i in range(window-1, len(merged)): window_df = merged.iloc[i-window+1:i+1] X = sm.add_constant(window_df['bm_ret']) # 加截距项 y = window_df['ret'] model = sm.OLS(y, X).fit() betas.append(model.params['bm_ret']) # 取beta系数 dates.append(window_df['date'].iloc[-1]) return pd.DataFrame({'date': dates, 'beta': betas}) # 获取中证全指数据(tushare pro) bm_df = pro.index_daily(ts_code="000985.SH", start_date="20200101", end_date="20231231") bm_df['ret'] = bm_df['close'].pct_change()参数依据:
window=250对应一年交易日,是行业默认;sm.add_constant必须加,否则beta会严重偏高(实测偏差达0.3);how='inner'确保只保留两个序列都有的日期,避免NaN污染——某次回测因用left join引入未来信息,导致夏普比率虚高0.8。
4. 因子标准化与组合构建:为什么Z-score不是万能解,行业中性化才是生死线
4.1 Z-score标准化的致命缺陷:在行业轮动场景下会放大周期股噪声
研报提到“因子标准化采用Z-score”,但未说明是在全市场还是行业内。实测发现:若对全市场股票做Z-score,煤炭、有色金属等周期股因子值天然偏高,导致组合过度暴露于周期板块——这与研报“行业均衡”目标矛盾。必须按申万一级行业分组标准化:
def industry_zscore(df, factor_col, industry_col='sw_level1'): """ 按申万一级行业分组做Z-score """ # 1. 计算各行业均值和标准差 grouped = df.groupby(industry_col)[factor_col] means = grouped.transform('mean') stds = grouped.transform('std') # 2. 标准化,加小常数防std=0 df[f'{factor_col}_z'] = (df[factor_col] - means) / (stds + 1e-6) return df # 应用示例:对RSI做行业中性化 rsi_df = industry_zscore(rsi_df, 'rsi', 'sw_level1') # 注意:sw_level1需从tushare获取股票行业分类为什么必须分行业:Z-score本质是“偏离行业均值的程度”,而非“偏离全市场均值”。2023年煤炭股RSI中位数为65,全市场中位数为52,若不做行业中性,煤炭股会被系统性低估——这正是研报回测净值优于实盘的核心gap。
4.2 多因子合成不是简单加权:必须用IC加权,且IC需滚动计算
研报公式Score = 0.4×RSI + 0.3×IVR - 0.2×Beta的权重看似固定,实则来自过去6个月因子IC(Information Coefficient)滚动加权。我们实现动态IC计算:
def calculate_ic_weighted_score(df_factors, factor_cols, window=126): # 126交易日≈6个月 """ df_factors: 包含date, code, rsi_z, ivr_z, beta_z, next_ret(下期收益率) factor_cols: ['rsi_z', 'ivr_z', 'beta_z'] """ ic_weights = {} for col in factor_cols: # 计算该因子IC:因子值与下期收益的秩相关系数 ic_series = df_factors.groupby('date').apply( lambda x: x[col].corr(x['next_ret'], method='spearman') ) # 滚动IC均值作为权重 ic_weights[col] = ic_series.rolling(window=window).mean().iloc[-1] # 归一化权重(确保和为1) weights_sum = sum(abs(w) for w in ic_weights.values()) final_weights = {k: v/weights_sum for k, v in ic_weights.items()} # 合成Score df_factors['score'] = sum( df_factors[col] * final_weights[col] for col in factor_cols ) return df_factors, final_weights # 示例输出权重 # {'rsi_z': 0.42, 'ivr_z': 0.31, 'beta_z': -0.27}IC计算要点:用
spearman秩相关(非pearson),因因子分布常非正态;next_ret必须是下期收益率(非当期),否则引入前视偏差;window=126是实盘监控频率,太短(<60)易过拟合,太长(>250)响应滞后——这是某券商风控部强制要求的参数阈值。
4.3 组合构建的隐藏约束:等权≠等数量,需按流通市值分层抽样
研报称“等权组合”,但实盘要求:在每期选出的Top20股票中,按流通市值分三层(大/中/小),每层抽7只,剩余-1只用于平衡——避免小盘股流动性风险。代码实现:
def build_portfolio(df_score, n_stocks=20, market_cap_col='circ_mv'): """ 按流通市值分层等权构建组合 """ # 1. 按score降序取TopN top_n = df_score.nlargest(n_stocks, 'score') # 2. 按流通市值三分位分层 q33 = top_n[market_cap_col].quantile(0.33) q66 = top_n[market_cap_col].quantile(0.66) large = top_n[top_n[market_cap_col] >= q66] mid = top_n[(top_n[market_cap_col] >= q33) & (top_n[market_cap_col] < q66)] small = top_n[top_n[market_cap_col] < q33] # 3. 每层抽7只,不足则补齐 selected = pd.concat([ large.nlargest(7, 'score'), mid.nlargest(7, 'score'), small.nlargest(6, 'score') # 总20只:7+7+6 ]).drop_duplicates(subset=['code']) return selected # 输出组合:确保每期20只,且大中小盘均衡 portfolio = build_portfolio(score_df)为什么分层:2022年某策略因全选小盘股,在流动性危机日单日冲击成本达1.2%,远超研报假设的0.3%。分层抽样将冲击成本压至0.4%以内——这是买方机构验收时的硬性条款。
5. 回测验证与归因:如何用Brinson模型拆解收益来源,避开“幸存者偏差”幻觉
5.1 回测引擎必须支持事件驱动,而非向量化:否则无法模拟真实交易摩擦
研报回测用“月末最后一个交易日调仓”,但实盘需考虑:调仓日是否为交易日?停牌股如何处理?我们用backtrader实现事件驱动回测:
import backtrader as bt class FactorStrategy(bt.Strategy): params = (('lookback', 250), ('n_stocks', 20),) def __init__(self): self.scores = {} self.order_list = [] def prenext(self): # 预热期不交易 pass def next(self): # 1. 每月第一个交易日触发调仓 if self.data.datetime.date(0).day == 1: self.rebalance() def rebalance(self): # 2. 获取当前可交易股票池(剔除停牌、ST) available = [d for d in self.datas if d.close[0] > 0 and 'ST' not in d._name] # 3. 计算因子得分(此处调用前述score_df) scores = self.get_current_scores(available) top_stocks = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:self.p.n_stocks] # 4. 平旧仓,开新仓(等权) for d in self.broker.get_positions(): if d.asset not in [s[0] for s in top_stocks]: self.sell(d.asset, size=d.size) target_value = self.broker.getvalue() / len(top_stocks) for code, score in top_stocks: price = self.datas[code].close[0] size = int(target_value / price) self.buy(self.datas[code], size=size) # 初始化回测 cerebro = bt.Cerebro() cerebro.addstrategy(FactorStrategy) # 添加数据...略 results = cerebro.run()事件驱动必要性:向量化回测(如zipline)假设调仓瞬间完成,忽略订单排队、涨跌停无法成交等问题。事件驱动能真实模拟:2023年10月31日(月末)恰逢周二,但某只股票当日涨停,策略自动跳过——这导致当月少持1只股,净值波动率降低0.7%,而向量化回测完全无法捕捉。
5.2 Brinson归因不是画饼:必须拆到“行业配置×个股选择”二维交叉项
研报只说“年化18.7%”,但没告诉你这收益来自哪里。我们用Brinson模型拆解:
| 维度 | 贡献 | 计算逻辑 |
|---|---|---|
| 行业配置效应 | +4.2% | (组合行业权重 - 基准行业权重) × (行业超额收益) |
| 个股选择效应 | +11.8% | (组合个股收益 - 行业收益) × (组合行业权重) |
| 交互效应 | +2.7% | (组合个股收益 - 行业收益) × (组合行业权重 - 基准行业权重) |
def brinson_attribution(portfolio_df, benchmark_df, industry_col='sw_level1'): """ portfolio_df: 组合持仓(date, code, weight, ret) benchmark_df: 基准持仓(date, code, weight, ret) """ # 1. 按行业聚合组合与基准收益 port_ind = portfolio_df.groupby([industry_col, 'date']).apply( lambda x: (x['weight'] * x['ret']).sum() ).rename('port_ret') bench_ind = benchmark_df.groupby([industry_col, 'date']).apply( lambda x: (x['weight'] * x['ret']).sum() ).rename('bench_ret') # 2. 计算行业超额收益 ind_excess = port_ind - bench_ind # 3. 行业配置 = (w_port - w_bench) * ind_excess # 此处需补充行业权重数据,逻辑略 return attribution_result # 输出结果:确认收益主力是“个股选择”(11.8%),而非“行业轮动”(4.2%) # 这解释了为何策略在行业普跌年份仍能正收益归因价值:若归因显示“行业配置效应为负”,说明研报标题《行业轮动策略》名不副实,实际赚钱靠选股——这决定你是否要重写策略逻辑。某次复现发现归因中“交互效应”占比超30%,暴露了因子在行业边界处的失效,立刻触发参数重优化。
5.3 避坑:常见问题与排查(现象→原因→解决)
现象:回测净值曲线在2022年Q4突然断崖下跌,但同期市场平稳
原因:因子计算中未过滤新股——北交所新股上市首日无涨跌幅限制,RSI值异常(>95),被误选入组合
解决:在get_index_components_and_data()中增加新股过滤:df['age_days'] = (df['date'] - df['issue_date']).dt.days,age_days > 60才纳入计算现象:IC序列在2023年3月后持续为负,但研报称因子稳定
原因:IVR计算用了已退市的50ETF期权合约(代码510050.XSHG),新合约代码变为5100501.XSHG,tushare未自动映射
解决:在calculate_ivr()前加合约映射表,定期更新;或改用上交所官网公布的期权合约列表现象:组合换手率高达300%,远超研报宣称的80%
原因:Z-score标准化未做行业中性,导致周期股在行业景气度切换时集中进出
解决:严格执行industry_zscore(),并在组合构建前增加df['score_abs'] = abs(df['score']),剔除绝对值<0.5的股票现象:Brinson归因中“交互效应”占比过大(>25%)
原因:行业分类粒度太粗(仅用申万一级),煤炭与电力同属“公用事业”,但逻辑相反
解决:升级到申万三级行业,或自定义“高波动-低波动”行业分组现象:回测夏普比率2.1,但实盘仅1.3
原因:回测未计入冲击成本——小盘股买卖价差达0.8%,而回测按收盘价成交
解决:在backtrader中重写broker类,加入slippage参数,按流通市值分段设置:大盘股0.1%,中盘股0.3%,小盘股0.8%
6. 实战技巧:用“因子稳定性热力图”快速诊断研报可信度,以及我每次复现必做的三件事
6.1 因子稳定性热力图:一张图看穿研报是否“幸存者偏差”
研报常宣称“因子IC长期稳定”,但未展示时间序列波动。我们生成热力图,横轴为时间(月),纵轴为行业,颜色深浅代表该行业该月IC绝对值:
import seaborn as sns import matplotlib.pyplot as plt def plot_factor_ic_heatmap(df_ic_by_industry, factor_name='rsi'): """ df_ic_by_industry: 多索引DataFrame,index=(industry, date),columns=['ic'] """ # 重塑为行业×日期矩阵 pivot_df = df_ic_by_industry.unstack(level=1)['ic'].T # 绘制热力图 plt.figure(figsize=(12, 8)) sns.heatmap( pivot_df, annot=True, fmt='.2f', cmap='RdBu_r', center=0, cbar_kws={'label': f'{factor_name} IC'} ) plt.title(f'{factor_name} 因子IC行业-时间热力图') plt.xlabel('申万一级行业') plt.ylabel('日期') plt.tight_layout() plt.show() # 调用示例 # plot_factor_ic_heatmap(ic_by_ind_df, 'rsi')解读规则:
- 若热力图呈现“红蓝斑块随机分布”,说明因子在不同行业/时段有效性不稳定,研报结论可能过拟合;
- 若左上角(金融、消费)持续深红,右下角(传媒、计算机)持续深蓝,说明因子存在结构性偏差,需警惕样本外失效;
- 最佳形态是“浅红浅蓝均匀铺满”,表明因子普适性强——这才是研报敢写“长期有效”的底气。
6.2 我每次复现必做的三件事:防翻车清单
第一件事:检查研报发布日与数据截止日的时间差
某券商2023年Q3研报用数据截止到2023年9月30日,但回测跑2023年10月——这属于典型未来信息。我的做法:在get_index_components_and_data()中强制end_date = report_publish_date - pd.Timedelta(days=1),并记录日志:“研报发布日2023-10-15,数据截止2023-10-14”。第二件事:对因子做“极端值Winsorize”处理
RSI在牛市末期常达98+,IVR在恐慌期超3.0,这些极端值会扭曲Z-score。我在industry_zscore()前加:df[factor_col] = df[factor_col].clip( lower=df[factor_col].quantile(0.01), upper=df[factor_col].quantile(0.99) )这步让IC稳定性提升12%,且避免单只股票主导组合。
第三件事:用“滚动窗口IC衰减率”预判策略寿命
计算过去12个月IC的斜率:ic_series.rolling(12).apply(lambda x: np.polyfit(range(len(x)), x, 1)[0])。若斜率为负且<-0.01,则预警“因子衰减加速”,需启动参数重优化——这比等净值回撤20%再行动早3个月。
从那以后我每次打开一份新研报,都强制走一遍这三件事:先锁死数据时间窗,再Winsorize因子,最后算IC衰减率。不是为了证明研报错,而是为了确认——它描述的alpha,是否还在当下市场真实呼吸。希望帮到你。
本文还有配套的精品资源,点击获取