FinMarketPy / 回测可信度体检
回测结果可信吗:三层体检清单与一份可复现协议
这是本站最主要的差异化内容:官方资料里没有「怎么判断回测结果能不能信」这件事。README 只给了两个示例,PLANNED_FEATURES.md 里「更多交易结果统计」还列在待办。下面这份清单的每一条都能在 0.11.19 源码里找到对应依据(参数名、实现细节或示例注释),分成数据层、策略层、结果层三层;最后一节是一份可复现协议,把该写下来的东西列全。它不是官方标准,也不是投资建议。
BacktestRequest 参数、marketconstants.py 与官方示例注释整理,非官方标准);示意非官方流程图,请以仓库代码与实际运行结果为准。为什么一个「漂亮」的回测结果可能没有意义
同一份数据、同一个策略,只改几个口径参数,结论就能从「跑赢基准」变成「跑输基准」。下面这些都会真实改变结果,而且都不需要你写错代码。
| 被改的东西 | 变化方向 | 对结果的典型影响 | 在 finmarketpy 里对应哪个参数 |
|---|---|---|---|
| 交易成本从 2.5bp 改成 0 | 去掉摩擦 | 高换手策略收益被系统性抬高(趋势策略尤其明显) | spot_tc_bp、spot_rc_bp |
| 信号延后去掉(当日信号当日成交) | 引入未来函数 | 结果显著变好,且好得不真实 | signal_delay |
| 年化因子与数据频率不匹配 | 口径错位 | 年化收益、年化波动、Sharpe 同时失真 | ann_factor、resample_ann_factor |
| 用现货价而不是含 carry 的总收益指数 | 漏掉收益来源 | 外汇策略的收益被低估或错估(官方示例注释明确提过) | asset_df 的来源选择 |
| 不设最大杠杆 | 放开约束 | 低波动期仓位被放大,回撤结构完全改变 | signal_vol_max_leverage、max_net_exposure、max_abs_exposure |
| 累计方式从乘法换成加法 | 口径变化 | 长期收益被系统性低估(加法指数忽略复利) | cum_index('mult' / 'add') |
| 不加基准 | 缺少对照 | 无法判断策略是在创造收益还是在跟随市场 | include_benchmark |
数据层怎么查:先确认喂进去的数据是对的
这一层的问题往往在回测之前就存在,但会在结果里被放大成「策略问题」。
| 检查项 | 怎么查 | 不合格的典型症状 | 处置方向 |
|---|---|---|---|
| 日期是否连续 | 看索引的差分分布,与交易日历对照 | 某段「平坦」的净值曲线 | 用 findatapy 的 calendar.py 对齐交易日,而不是自然日 |
| 有没有重复日期 | df.index.duplicated().sum() | 收益被重复计入,波动率异常 | 去重后再进回测;查数据源是否按合并了多个频率 |
| 有没有未来日期 | 检查索引最大值是否超过今天 | 看起来「刚更新过」的数据 | 截断哨兵值/空行造成的时间戳 |
| 缺失值规模 | 按列统计 NaN 比例与最长缺口 | 某些品种整段没有数据却仍参与组合 | 要么补齐,要么在标的集合里剔除 |
| 时区与对齐口径 | 确认各数据源的时区是否统一(findatapy 有 timezone.py) | 跨市场组合的信号错位一天 | 统一到同一时区后再做横截面运算 |
| 价格字段是什么 | 确认 trading_field 与实际字段一致 | 把收盘价与结算价混用 | 明确写进可复现协议;不要靠默认值 |
| 异常跳变 | 看单日收益率分布的两端 | 某一天贡献了全部收益/全部回撤 | 核对是否数据错误、拆股、换月或单位问题 |
| 汇率交叉盘怎么来的 | 确认非直盘报价是「原币种取数」还是「按 USD 交叉推算」 | 同一货币对在不同数据源下收益不同 | findatapy 支持按 USD 交叉自动推算(如 AUDJPY),要记录你用哪种 |
策略层怎么查:信号与成交对不对得上
这一层是「未来函数」的重灾区。Backtest 引擎不会替你检查信号是否用了事后信息。
| 检查项 | 怎么查 | 不合格的典型症状 | 处置方向 |
|---|---|---|---|
| 信号是否含未来信息 | 用同一段数据、把信号整体延后一天重跑,看结果是否大幅衰减 | 延后一天后收益腰斩甚至转负 | 重构信号,只用当日及之前的信息 |
| 信号生成与成交是否错位 | 确认 signal_delay 的取值与实际交易日历一致 | 「当日收盘信号、当日收盘成交」这种不可能的执行 | 至少延后一个周期;写进协议 |
| 成本是否计入 | 检查 spot_tc_bp 是否为 0 | 高换手策略收益异常漂亮 | 设成与品种/频率相符的量级并说明依据 |
| 外汇展期成本 | 检查 spot_rc_bp 与是否使用总收益指数 | 长期持有的外汇策略收益系统性偏低或偏高 | 官方示例注释已提醒:优先用含 carry 的总收益指数 |
| 杠杆与敞口约束 | 检查 *_max_leverage、max_net_exposure、max_abs_exposure | 杠杆曲线出现不现实的尖峰 | 设上限;把实际杠杆分布画出来看一眼 |
| 调仓频率是否现实 | 检查 *_rebalance_freq 与 position_clip_* | 每日全额换手 | 低频再平衡 + 单次调仓裁剪 |
| 停牌/不可交易时段 | 确认这些日期在数据里是否被当作正常交易日 | 在不可能成交的日期产生交易 | 用交易日历剔除,或把该品种在该区间置为无信号 |
| 关键参数是否被硬编码 | 把回测用到的参数从代码里抽出来 | 换机器/换人复现结果不一致 | 全部外提为可配置项,写进协议 |
data_frame.shift(1).rolling(window=sma_period-1).sum() + data_frame_early 再除以周期,等于把历史部分整体错开一期;这是防未来函数的设计。而你传进去的自定义 signal_df 不会有任何检查——这是「越容易改的地方越容易出错」。结果层查哪些:数字算对了,不代表结论成立
前三层都对,结果仍可能因为「收益结构」而不可采信。
| 检查项 | 怎么查 | 不合格的典型症状 | 处置方向 |
|---|---|---|---|
| 收益是否集中在少数交易 | 用 findatapy 的 Calculations().calculate_individual_trade_gains(signals, returns) 看逐笔分布 | 去掉收益最高的 2–3 笔后策略转负 | 要么承认这是尾部驱动、要么放弃该结论 |
| Sharpe 的口径 | 确认年化因子、无风险利率、收益频率三处一致 | 同一策略在不同工具里 Sharpe 差一倍 | 写清口径;比较不同策略前先统一 |
| 最大回撤发生在哪 | 看回撤时间序列而不只看最大值 | 回撤集中在某个单一历史阶段 | 判断是结构性问题还是时代背景 |
| 是否与基准比较 | 打开 include_benchmark 或用 BacktestComparison.plot_pnl(diff=True) | 只有绝对收益,没有相对收益 | 至少补一个买入持有对照 |
| 是否做了样本外 | 把区间分成两段分别跑 | 样本内极好、样本外失效 | 参数在样本内定,结论只在样本外下 |
| 参数敏感性 | 把核心参数在小范围内上下扫一遍 | 只有某一个「神奇参数值」能赚 | 用参数平原(而非尖峰)判断稳健性 |
| 换手与实际容量 | 统计交易笔数与名义规模分布 | 策略容量远小于你的资金规模 | 把容量约束写进结论 |
可复现协议要写哪些字段
看到一个回测数字却不知道该数字怎么产生的,是最常见的问题。下面这份字段表的作用是「让别人能重跑」——里面对应到 finmarketpy 的具体参数,能填的尽量填。
| 字段 | 为什么要写 | 对应到 finmarketpy 的哪里 |
|---|---|---|
| finmarketpy / findatapy / chartpy 版本 | 依赖行为会随版本变化 | pip show 三个包;或记录安装方式(PyPI / git+) |
| Python 与 numpy / pandas 版本 | numpy 有 <2 硬约束,pandas 无上限 | 环境自检命令(见安装页) |
| 数据源与凭证路径 | 不同源的同名 ticker 可能不是同一序列 | data_source、vendor_tickers、vendor_fields |
| 数据下载时间与频率 | 历史会被修订(宏观数据尤其) | start_date / finish_date / freq、抓取日期 |
| 时区与交易日历 | 跨市场对齐差一天就出错 | findatapy 的时区与日历设置 |
| 价格字段与复权口径 | 收盘价/结算价/前复权不是一回事 | trading_field、fields |
| 信号生成时间与执行时间 | 未来函数的首要防线 | signal_delay、信号构造代码 |
| 成本与展期成本 | 决定策略是否还有超额收益 | spot_tc_bp、spot_rc_bp |
| 杠杆与敞口约束 | 决定回撤结构是否真实 | *_max_leverage、max_net_exposure、max_abs_exposure |
| 再平衡频率与缺失值处理 | 换手与信号序列都会变 | *_rebalance_freq、TechParams.fillna |
| 年化因子与累计方式 | 直接决定统计口径 | ann_factor、cum_index |
| 基准与样本外区间 | 没有对照就没有结论 | include_benchmark、区间切分方式 |
requirements 一起存档。这比「贴一张收益曲线截图」有用得多——曲线不能重跑,协议可以。关于回测可信度的高频问题
清单依据 0.11.19 源码与官方示例整理,不是官方标准;本站未实机运行验证。
「未来函数」到底指什么?怎么最简单查出来?
指回测里用了在当时还不可能知道的信息——最常见的是用当日收盘价生成信号、又用当日收盘价成交,或者用未来若干期的均值来构造当期信号。最省事的自查方法:把信号整体延后一期(或增强 signal_delay)重跑,如果收益大幅衰减甚至转负,就说明原结果的收益主要来自「提前知道」。这在 finmarketpy 里对应 br.signal_delay;引擎本身不会替你检查信号构造代码。
为什么官方示例特别强调「用总收益指数而不是现货价」?
官方 backtest_example.py 的注释原话意思是:这里用现货价算收益,但更好的做法是用包含 carry 的外汇总收益指数。原因是外汇持有本身会产生利差(carry)收益,只看现货价的涨跌会漏掉这一块。对趋势类策略来说,这会系统性改变结果。所以你在写外汇回测时,先确定收益口径是「现货」还是「总收益」,并把它写进可复现协议。
年化因子该用 252 还是 260?
看你的数据频率与市场日历。官方示例里设的是 br.ann_factor = 252,并配 signal_vol_obs_in_year = 252。关键在于年化因子、波动率估计窗、观测数三者要自洽:如果数据实际是每周 5 天而某段有节假日,实际观测数会少于 252;如果你在别的频率上复用 252,年化数字就会错。本站不给推荐值——写清你用的是哪个数、为什么。
我把成本设成 0 先看「纯策略收益」,可以吗?
作为探索可以,作为结论不行。高换手策略在零成本下的收益会明显偏高,一旦加回真实成本可能直接转负。比较稳妥的做法是:初始探索可以用零成本看信号方向,但任何要对外说的数字都必须带成本,并且说明成本取值依据。官方示例用的是 2.5bp。
回测看起来很好,但实盘完全不是这样,通常是哪里出问题?
常见于四类:①成本与滑点被低估(尤其是高换手策略);②执行时点与信号时点错位;③策略容量小于实际资金(冲击成本没算);④样本内过拟合。前两类能在回测配置里查到(spot_tc_bp、signal_delay),后两类属于方法论问题,需要样本外与容量分析。本站不提供实盘执行方案,也不会给出收益预期。
这些检查项能自动化吗?
一部分可以:数据层的重复日期、缺失率、异常跳变,结果层的逐笔收益集中度、参数敏感性扫描,都适合写成脚本定期跑。策略层的未来函数检查最难自动化(需要理解信号语义),目前最可靠的仍是对比「延后一期」的结果。注意本机环境的实测限制:marketconstants.py 里 Windows 的回测线程数默认是 1,大规模参数扫描在 Windows 上会很慢——这一条见报错与版本边界。