因子审查器 · factor-audit
你算出一个因子,IC 漂亮、t 值很高。但那个 t 值可能是假的。
You built a factor. The IC looks good, the t-stat is high. That t-stat may be fake.
② Newey-West 调整(IC 自相关会放大 t)
h=20 IC +0.0464 t朴素 +6.47 → t_NW +2.06 ★高估 3.1x
⑥ 成本可行性(10 只 / h=20 / 往返 50.2bp)
毛收益 +14.37%/年 换手 93.5%/期 成本 11.83%/年
毛/成本 = 1.21x 门槛 3.00x ❌ 不过
这个因子自己算是 t=6.47,看着很稳。调整后只剩 2.06,而且扣完成本根本不过门。
中文
一、这东西有什么用
一句话:它检查你的因子检验有没有出错,而不是告诉你买什么。
打个比方。你做了一道菜,尝一口觉得好吃。这个工具不评价菜好不好吃 —— 它检查你的秤准不准、温度计有没有坏、食材有没有过期。
因为量化研究里最常见的失败不是"想错了",而是算的时候有个静默的坑, 而结果恰好朝好的方向偏。你不会收到任何警告。
它会替你抓这些
| 问题 | 通俗说 | 实测量级 |
|---|---|---|
| t 值虚高 | 同一份信息被数了 20 次 | 高估 1.8~3.1 倍 |
| 生存者偏差 | 退市股不在你的数据里 | 毛收益虚高 45.88pp |
| 前视污染 | 因子里混了未来信息 | 正常 IC 0.046,混了未来 0.362 |
| 换皮因子 | 12 个因子其实只有 3 个方向 | 波动率四兄弟相关 0.73~0.93 |
| 成本吃光 | 毛收益不够付手续费 | 门槛是 3 倍成本,不是 1 倍 |
它不做三件事
| 不做 | 为什么 |
|---|---|
| 不告诉你买什么 | 判断"能买"需要预期收益,而预期收益只能来自你的因子。我们看不到 |
| 不替你部署 | 实测「构造优化」在多数因子上只有 ±17bp、「摩擦削减」组合层只剩 0.112bp/月 —— 这两件事不值得卖 |
| ★不上传任何东西 | 全本地运行。因子值只在你自己的进程里,报告只写到你自己的磁盘 |
二、为什么有用
因为这些坑不是"高级问题",而是绝大多数人默认就踩着的。
① t 值虚高:同一份信息被数了 20 次
你算 20 日持有期的 IC。今天的 IC 和明天的 IC,共用了 19 天的收益 —— 它们不是两个独立证据,几乎是同一个。
不做调整,就等于把一份信息数了 20 次,t 值自然虚高。 Newey-West 调整就是修这个。实测高估 1.8~3.1 倍。
⇒ 一个 t=6.47 的因子,真实值 t=2.06。前者能上,后者不能。
② 生存者偏差:这条最隐蔽,因为它几乎不改 IC
同一个因子,两份数据(真实面板 954 万行、2018-2026、退市股占 11.5%):
含退市股 IC +0.0657 t_NW +6.56 毛收益 −50.44%
纯生存者 IC +0.0696 t_NW +6.83 毛收益 −4.56%
IC 只差 5.9%,但毛收益差 45.88pp —— 把「年化亏 50%」显示成「亏 4.6%」。
只看 IC 的人完全发现不了。 而免费数据源默认就不给你退市股:
tdx (信通达) 退市股行情返回 {"code":0,"data":[],"message":"success"}
—— 空数据 + "成功",5628 只清单里一只退市股都没有
baostock query_all_stock(今天) 返回 0 条,且 error_code=0
「成功但为空」是数据源最危险的失败模式。 你拿到一份纯生存者的数据, 零警告,而偏差恰好让结果变好看。
③ 换皮因子:Bonferroni 的分母要算对
你测了 12 个因子,其中 5 个是波动率的不同写法
(实测 vol20 / vol60 / hl_range20 / downside_vol20 相关 0.73~0.93)。
多重比较门槛随检验数变严:
1 个方向 → |t| > 1.96
3 个方向 → |t| > 2.39
12 个方向 → |t| > 2.87
59 个方向 → |t| > 3.34
按 12 算是过度惩罚(会误杀真因子),但你得知道自己实际只测了 3 个方向。 本工具先并簇再定门槛,并在同簇多个因子都过门时提醒你:那是同一个发现。
④ 信号时效:报"衰减形状",不报"最优持有期"
你想知道"我这个信号能撑多久"。直觉做法是找 IC 曲线的峰 —— 但那个做法是错的。
实测 6 个因子里 5 个的 IC 单调上升,峰在扫描范围之外:
低波(取负) 1.00 → 1.17 → 1.36 → 1.57 → 1.67 → 1.79 持续增强
低换手(取负) 1.00 → 1.23 → 1.50 → 1.79 → 1.96 → 2.16 持续增强
动量(20日) 1.00 → 1.11 → 1.24 → 1.15 → 0.99 → 0.65 快速衰减
短期反转(5日) 1.00 → 0.82 → 0.91 → 0.89 → 0.80 → 0.44 快速衰减
argmax 在单调曲线上必然返回扫描边界 —— 报出来的"最优持有期 120 天"
反映的是我们扫到哪,不是因子性质。
⇒ 改报衰减形状后区分度才出现,且 5/6 个因子的形状在样本外保持。 形状不稳定时,本工具只说不可靠、不给持有期数字 —— 先否定自己再给建议,等于没否定。
⑤ 这些判据不是查文献抄的
阈值来自把 59 个公开因子(国泰君安 GTJA191 的 32 个 + WorldQuant Alpha101 的 27 个) 全部走完六关的实测:
- 成本安全系数 = 3,不是 1 —— 实测有因子毛/成本 = 2.2 倍,仍然亏 87%
- 前视告警线 = 0.15 —— 实测正常因子 IC 0.046、故意前视 0.362、纯噪声 0.002
- 并簇阈值 = 0.60 —— 落在"同源 0.73~0.93"与"真独立 <0.3"之间
那 59 个因子最后一个都没过门。 最接近的 alpha_070 反向连过四关,
死在样本外(OOS t=1.90 < 1.96)和换手率一致性上。
三、怎么用
装
pip install factor-audit # 只依赖 pandas / numpy / scipy
pip install "factor-audit[parquet]" # 要读 parquet 面板时
跑
factor-audit --panel mypanel.parquet --demo # 先看报告长什么样
factor-audit --panel mypanel.parquet --factor myfactor.csv # 单因子
factor-audit --panel mypanel.parquet --factors myfactors.csv # 多因子(含并簇)
也可以在自己的研究流程里 import:
from factor_audit import check_panel, audit_factor, audit_factors
print(check_panel(my_panel).text()) # 第一步:先体检数据
r = audit_factor(my_panel, my_factor_df) # 第二步:再审因子
print(r.text())
r.nw[20]["t_nw"] # 可直接引用的数字,不用解析文本
r.nw[20]["inflation"] # t 值虚高倍数
r.lookahead_suspected # 前视告警
r.passes_cost_gate # 是否过成本门
数据格式
面板最低只要 date / code / close 三列。列名不用改,常见别名自动映射:
列名映射:trade_date→date, ts_code→code, 收盘→close, 换手率→turn, is_st→isST
支持 tushare(trade_date/ts_code)、baostock(date/code)、Wind 中文导出。
映射结果会打印出来让你核对 —— 手工改列名容易把 high/low 弄反。
可选列缺了不报错,只会降级并告诉你影响:
turn(算成本)、isST(ST 涨跌停是 ±5% 不是 ±10%)、
suspended(停牌)、n_days_listed(识别新股)、suspect_resume(识别复牌)。
因子 CSV 只要三列,不需要给我们公式:
date,code,value
2026-08-10,600030,0.0123
2026-08-10,000001,-0.0456
多因子就是每个因子一列,列名随你(我们不需要知道它是什么):
date,code,myvol,mymom,myrev
2026-08-10,600030,-0.021,0.043,-0.008
数据从哪来 —— 你自己接
我们不内置数据源,也不分发行情。三个理由,前两个是实测踩出来的:
- 免费源不是可依赖的基础设施 —— 测并发时我把自己的 IP 弄进了 baostock
黑名单(
10001011 黑名单用户)。若这段代码在你机器上,你会认为是产品坏了 - baostock 非线程安全 —— 多线程共用全局 socket,直接
'utf-8' codec can't decode byte 0xf5然后卡死 - 不分发行情就不碰版权 —— 免费 API 通常只授权自用,不授权再分发
你自己的数据源大概率比免费源好(Wind / 聚源 / 自建库),而且它挂了不是我们的问题。
四、报告长什么样
第一步:面板体检(必须在审因子之前)
====================================================================
面板体检
====================================================================
207,416 行 / 287 只 / 2023-08-11 ~ 2026-08-10
⚠ 退市股偏少
仅 2 只(0.7%)已退市
⇒ A 股近年年均退市率约 1%,多年样本应更高。可能只含部分退市股
✅ 异常跳空 无单日涨跌超 35% 的跳空
✅ 每日覆盖 无瘦日(按年内中位数判定)
✅ 停牌标记 suspended 完整,其中停牌 368 行
✅ 时间连续性 725 个交易日无异常缺口
✅ ST 标记 isST 覆盖,ST 占 4.3%
✅ 无 BLOCK 项,可以继续审查因子
顺序有意义:先看到漂亮的 IC 再看到"你的数据有问题",人会倾向于相信前者。
第二步:因子审查
① 可执行口径(T 日算、T+1 买)
h=20 含当日 IC +0.0483 → 可执行 +0.0464 缩水 3.9%
② Newey-West 调整
h=20 IC +0.0464 t朴素 +6.47 → t_NW +2.06 ★高估 3.1x (n=684)
③ 分年度符号一致性
2023 IC -0.0567 ← 符号翻转
2024 IC +0.0566
2025 IC +0.0737
2026 IC +0.0351
⇒ ★样本期依赖
④ log(price) 中性化
原始 IC +0.0464 → 中性化后 +0.0433 掉 6.6%
⑤ 信号时效(IC 随持有期的衰减形状)
h=5 h=10 h=20 h=40 h=60 h=120
相对强度 1.00 1.08 1.08 1.32 1.13 0.92
⇒ 形状:平稳
样本内 持续增强 / 样本外 快速衰减 ★样本外改变
⇒ 时效建议不可靠:形状本身不稳定
⑥ 成本可行性
毛/成本 = 1.21x 门槛 3.00x ❌ 不过
多因子:你以为测了 5 个方向,其实只测了 3 个
★ 独立方向计数(截面秩相关 ≥ 0.6 视为同源)
[低波20] 低波20, 低波60, 低振幅 ← 同源,只算 1 个方向
[动量20] 动量20
[反转5] 反转5
你交来 5 个因子 → 实际只有 3 个独立方向
Bonferroni 门(按 3 个独立方向):|t_NW| > 2.39
因子 IC t朴素 t_NW 高估 年符号 过门
低波20 +0.0811 +8.76 +2.49 3.5x 一致 ✅
低波60 +0.0748 +7.33 +2.05 3.6x ★翻转 ❌
低振幅 +0.0747 +7.70 +2.16 3.6x 一致 ❌
动量20 -0.0464 -6.47 -2.06 3.1x ★翻转 ❌
反转5 +0.0281 +4.40 +2.43 1.8x ★翻转 ❌
1/5 个因子过门
注意「低波20 过门、低波60 不过」:它们同属一簇。真实结论是 "波动率这个方向有东西,但只算一个发现" —— 不是"我找到了一个能用的因子"。
五、我们自己踩过的坑(校准记录)
在 954 万行真实 A 股面板上跑,暴露了体检自己的三个误报,都已修:
| 误报 | 根因 | 修法 |
|---|---|---|
| 119 个交易日被判 BLOCK | 用全期中位数比。A 股上市数从 2018 的 3268 只长到 2026 的 5421 只,2018 全年都"偏少" | 改按年内中位数 |
suspended 全 False 报 ✅ |
只查了 null,漏了"列存在但全 False" | 全 False 单独告警 |
| 152 行跳空全报"复权错误" | 94 行是复牌、43 行是新股上市初期(前 5 日无涨跌幅限制),都是真实价格变动 | 有标记的先扣掉 |
修完后 152 → 15 行。剩下 15 行是真问题:价格 0.240.97 的退市轨股票,
在 3858 天未标注停牌后复牌。
⇒ 误报会让你忽略真告警,所以校准比多加检查更重要。 一个报 119 天 BLOCK 的工具,你第一次跑完就再也不会信它。
六、已知限制
- 报告说的是**「这个因子的检验有没有问题」**,不是「这个因子能不能赚钱」
- 成本口径按 A 股散户实测(往返 50.2bp / 10 只持仓)。机构成本低得多,
可传
n_hold调整持仓数,但成本率目前是常数 - 独立簇 + Bonferroni 需要一次传入多个因子;单因子模式不算这一项
- 面板体检的阈值在 A 股上标定。其他市场需要重新标定,尤其涨跌停相关的判据
七、测试
pip install "factor-audit[dev]"
pytest # 78 passed
测试锁住了几条不能退化的行为:Newey-West 必须压低 t 而不是抬高、
前视告警在正常因子上不能误报、成本门安全系数必须是 3、
lag1 不能含 T 日到 T+1 的收益、上市数自然增长不能被判成采集中断。
已在干净虚拟环境验证:pandas 3.0.5 / numpy 2.5.2,78 测试全过,零数据源依赖。
许可
MIT
English
What it does
In one line: it checks whether your factor test is sound — not what to buy.
An analogy. You cooked a dish and it tastes good. This tool does not judge the taste. It checks whether your scale is calibrated, your thermometer works, and your ingredients are past their date.
Because the most common failure in quant research isn't bad thinking — it's a silent defect in the computation that happens to bias results favourably. You get no warning.
What it catches
| Problem | Plain words | Measured magnitude |
|---|---|---|
| Inflated t-stat | The same information counted 20 times | 1.8–3.1× overstated |
| Survivorship bias | Delisted names missing from your data | Gross return inflated 45.88pp |
| Lookahead leakage | Future information mixed into the factor | Normal IC 0.046, leaky 0.362 |
| Relabelled factors | 12 factors are really 3 directions | Volatility siblings correlate 0.73–0.93 |
| Costs eat it | Gross return can't cover fees | Gate is 3× cost, not 1× |
Three things it does not do
| Not this | Why |
|---|---|
| Tell you what to buy | That needs expected return, which can only come from your factor. We never see it |
| Deploy for you | Measured: "construction optimisation" moves ±17bp on most factors; "friction reduction" leaves 0.112bp/month at portfolio level. Not worth selling |
| ★Upload anything | Fully local. Factor values stay in your process; the report is written to your disk |
Why it works
1. Inflated t-stats: one piece of information counted 20 times
You compute IC over a 20-day holding period. Today's IC and tomorrow's IC share 19 days of returns — they are not two independent pieces of evidence.
Skipping the adjustment counts one observation twenty times. Newey-West fixes it. Measured overstatement: 1.8–3.1×.
⇒ A factor at t=6.47 is really t=2.06. The first ships; the second doesn't.
2. Survivorship bias: the sneakiest, because it barely moves IC
Same factor, two datasets (real panel, 9.54M rows, 2018–2026, 11.5% delisted):
With delisted IC +0.0657 t_NW +6.56 gross −50.44%
Survivors only IC +0.0696 t_NW +6.83 gross −4.56%
IC differs by 5.9%; gross return differs by 45.88pp — turning "−50% a year" into "−4.6%".
Anyone reading only IC misses it entirely. And free data sources omit delisted names by default:
tdx delisted quotes return {"code":0,"data":[],"message":"success"}
— empty data plus "success"; zero delisted names among 5,628 listed
baostock query_all_stock(today) returns 0 rows with error_code=0
"Success but empty" is the most dangerous failure mode a data source has.
3. Relabelled factors: get the Bonferroni denominator right
The multiple-testing bar tightens with the number of tests:
1 direction → |t| > 1.96
3 directions → |t| > 2.39
12 directions → |t| > 2.87
59 directions → |t| > 3.34
Counting 12 when you only tested 3 over-penalises and kills real factors. But you need to know you only tested 3. This tool clusters first, then sets the bar, and warns when several passing factors sit in the same cluster: that is one finding, not several.
4. Signal decay: report the shape, not the "optimal horizon"
You want to know how long your signal lasts. The obvious approach is to find the peak of the IC curve — and that approach is wrong.
Measured: 5 of 6 factors have monotonically rising IC, with the peak outside the scan range:
Low volatility 1.00 → 1.17 → 1.36 → 1.57 → 1.67 → 1.79 sustained
Low turnover 1.00 → 1.23 → 1.50 → 1.79 → 1.96 → 2.16 sustained
Momentum (20d) 1.00 → 1.11 → 1.24 → 1.15 → 0.99 → 0.65 decaying
Reversal (5d) 1.00 → 0.82 → 0.91 → 0.89 → 0.80 → 0.44 decaying
On a monotone curve argmax always returns the scan boundary — an "optimal
horizon of 120 days" reflects where we stopped looking, not the factor.
⇒ Reporting the decay shape instead produces real discrimination, and 5 of 6 shapes hold out-of-sample. When the shape is unstable the tool says only that, and gives no horizon number — contradicting yourself immediately after a warning cancels the warning.
5. These thresholds are measured, not cited
They come from running 59 public factors (32 from GTJA191, 27 from WorldQuant Alpha101) through the full gate set:
- Cost safety factor = 3, not 1 — a factor with gross/cost = 2.2× still lost 87%
- Lookahead line = 0.15 — normal IC 0.046, deliberate leakage 0.362, pure noise 0.002
- Cluster threshold = 0.60 — between "same source 0.73–0.93" and "independent <0.3"
None of the 59 passed. The closest, reversed alpha_070, cleared four gates
and died out-of-sample (OOS t=1.90 < 1.96) and on turnover consistency.
How to use it
pip install factor-audit # depends only on pandas / numpy / scipy
pip install "factor-audit[parquet]" # to read parquet panels
factor-audit --panel mypanel.parquet --demo
factor-audit --panel mypanel.parquet --factor myfactor.csv
factor-audit --panel mypanel.parquet --factors myfactors.csv
from factor_audit import check_panel, audit_factor
print(check_panel(my_panel).text()) # step 1: check the data
r = audit_factor(my_panel, my_factor_df) # step 2: audit the factor
print(r.text())
r.nw[20]["t_nw"] # numbers you can use directly, no text parsing
r.lookahead_suspected
r.passes_cost_gate
The panel needs only date / code / close. Column names are mapped
automatically (tushare, baostock, Wind Chinese exports), and the mapping is
printed so you can check it. Optional columns degrade gracefully rather than
erroring.
Factor CSV needs three columns — no formula required:
date,code,value
2026-08-10,600030,0.0123
For several factors, one column each; name them whatever you like — we don't need to know what they are.
You bring the data
We bundle no data source and distribute no market data. Two of the three reasons were learned the hard way:
- Free sources are not dependable infrastructure — testing concurrency got
our IP blacklisted by baostock (
10001011). Shipped to you, that reads as our product being broken - baostock is not thread-safe — threads share a global socket and it dies
with
'utf-8' codec can't decode byte 0xf5 - Distributing no quotes means no copyright exposure
Your own source is probably better than a free one (Wind, Gildata, in-house), and when it breaks it isn't our problem.
Calibration record
Running against a 9.54M-row real A-share panel exposed three false positives in our own checks, all fixed:
| False positive | Root cause | Fix |
|---|---|---|
| 119 trading days flagged BLOCK | Thin-day used a whole-period median; A-share listings grew 3,268 → 5,421, so all of 2018 looked thin | Compare within year |
suspended all-False reported OK |
Only nulls were checked, not "column present but never True" | Separate warning |
| 152 jumps all called "unadjusted split" | 94 were flagged resumptions, 43 were new listings (no price limit for the first 5 days) — both real price moves | Exclude when flagged |
152 → 15 after the fixes. The remaining 15 are genuine: delisting-track names priced 0.24–0.97 resuming after 38–58 day unflagged suspensions.
⇒ False positives make you ignore real warnings, so calibration matters more than adding checks. A tool that flags 119 days of BLOCK loses your trust on first run.
Known limits
- The report says whether the test is sound, not whether the factor makes money
- Cost assumptions follow A-share retail (50.2bp round trip, 10 positions); institutional costs are far lower and the rate is currently a constant
- Clustering and Bonferroni need several factors passed in at once
- Panel-check thresholds are calibrated on A-shares; other markets need recalibration, especially the price-limit logic
Tests
pip install "factor-audit[dev]"
pytest # 78 passed
Verified in a clean virtual environment: pandas 3.0.5 / numpy 2.5.2, 78 tests passing, zero data-source dependencies.
License
MIT
Metadata
Release files for factor-audit 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| factor_audit-0.1.0.tar.gz | 54.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| factor_audit-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 91.2 kB
Release files / factor_audit-0.1.0.tar.gz
| Download URL | factor_audit-0.1.0.tar.gz |
|---|---|
| Size | 54.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3d8ddbb87873ec919fe9dafde29d4a6ec5e116d59013e7b75a9c9c6e8e9fb69a
|
|
BLAKE2b-256 checksum How to use checksums |
56884f7135342ec70efeb1a317b934a0ebc7e00f14cfbe1a729134493c182d6b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.10
|
Release files / factor_audit-0.1.0-py3-none-any.whl
| Download URL | factor_audit-0.1.0-py3-none-any.whl |
|---|---|
| Size | 37.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4379f747452a0436db0e7c8c8f407d79f8ca4beb52ae1c157809bade0fdd8fcb
|
|
BLAKE2b-256 checksum How to use checksums |
4fef12bee3ce0bfb16b94e8ccabc0a692f9d4a7dabefae83889cc65cf985c57e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.10
|