11. 回测系统与性能评估

11.回测系统与性能评估

训练出一个交易模型只是第一步,真正考验它是否能在真实市场生存,靠的是回测。FinRL 的回测系统设计得相当务实,没有自己造轮子,而是直接集成了量化圈久经考验的 Pyfolio 库。这种选择很明智——Pyfolio 源自 Quantopian,在策略评估领域沉淀多年,它的报告格式几乎成了行业通用语言。

回测的本质是让模型在历史数据上重新"交易"一遍,记录下每一天的持仓变化和盈亏情况。FinRL 把训练好的智能体放在测试环境跑完一个周期后,会得到一个账户价值的时间序列。这个序列是后续所有分析的原材料,Pyfolio 接手后,会自动计算出十几项专业指标,并生成一套完整的业绩图表。

回测引擎执行流程

FinRL 的回测流程可以拆解成三个清晰阶段:数据准备、策略重放、报告生成。整个过程不需要手动干预,框架已经封装好了所有细节。

从账户价值到收益率序列

训练完成后,智能体在测试环境逐日交易,框架会把每天的账户总价值记录下来,通常是一个 pandas DataFrame,包含日期和账户价值两列。Pyfolio 需要的是收益率序列,所以第一步是计算每日收益率:

df_total_value.columns = ['account_value']
df_total_value['daily_return'] = df_total_value['account_value'].pct_change(1)

这行代码背后做了两件事:先重命名列让数据更规范,然后用 pct_change(1) 计算相邻两天的变化百分比。第一天的收益率会是 NaN,因为前面没有数据,Pyfolio 会自动处理这个缺失值。

得到收益率序列后,需要把索引转换成时间格式。Pyfolio 对时间索引要求很严格,必须是带时区信息的 DatetimeIndex:

strategy_ret = df.copy()
strategy_ret['Date'] = pd.to_datetime(strategy_ret['Date'])
strategy_ret.set_index('Date', drop=False, inplace=True)
strategy_ret.index = strategy_ret.index.tz_localize('UTC')
del strategy_ret['Date']
ts = pd.Series(strategy_ret['daily_return'].values, index=strategy_ret.index)

这段代码把日期列转换成标准时间格式,设为索引,并强制指定为 UTC 时区。最后转换成 Series,因为 Pyfolio 的核心函数只接受这种格式。虽然看起来繁琐,但 FinRL 的教程里把这些步骤封装成了 backtest_strat 函数,实际使用时一行代码就能搞定。

Pyfolio 的核心调用

数据格式整理好后,调用 Pyfolio 生成报告只需要两行:

import pyfolio
pyfolio.create_full_tear_sheet(returns=DRL_strat, benchmark_rets=dow_strat, set_context=False)

create_full_tear_sheet 是 Pyfolio 的旗舰函数,它会自动计算所有绩效指标并绘制全套图表。第一个参数 returns 是策略的收益率序列,第二个 benchmark_rets 是基准策略的收益率,用来对比。set_context=False 是为了在 Jupyter 里避免样式冲突。

这个函数执行时,背后会跑几十个子计算:从简单的年化收益、波动率,到复杂的回撤分析、滚动夏普比率、alpha/beta 分解。最终输出一个完整的 HTML 报告,包含表格和十几张图。FinRL 的示例代码里通常会用 %matplotlib inline 让图表直接显示在 Notebook 中。

回测中的细节处理

FinRL 在回测时保留了一些实用设计。比如交易成本已经内置在环境里了,智能体每步执行买卖操作,环境会自动扣除佣金和滑点。回测时这些成本会真实反映在账户价值变化中,不需要额外设置。

另一个细节是持仓记忆。环境会把每天的持仓比例记下来,Pyfolio 可以读取这些信息分析仓位变化,但 FinRL 的默认示例为了简化,通常只传收益率序列。如果需要更深入的持仓分析,可以把 positions 和 transactions 参数也传给 Pyfolio。

夏普比率与最大回撤计算

Pyfolio 虽然能自动计算所有指标,但理解这些指标的手动实现很有必要。FinRL 的教程里经常展示手动计算版本,这能帮助理解指标的本质。

夏普比率的两种计算方式

夏普比率衡量的是单位风险带来的超额收益。FinRL 里最常见的实现是:

sharpe = (252**0.5) * df_total_value['daily_return'].mean() / df_total_value['daily_return'].std()

这行代码很简洁,但背后有假设。252**0.5 是年化系数,因为美股一年大约 252 个交易日。分子是日均收益率,分母是日收益率标准差,也就是波动率。整个公式把日度指标年化处理,得到标准的夏普比率。

这里有个细节:夏普比率通常要减去无风险利率。FinRL 的简化实现里省略了 rf 项,默认无风险利率为零。这在回测中很常见,因为短期回测里无风险利率影响较小。如果需要精确计算,可以手动减去国债收益率。

另一个版本来自单股票交易教程:

sharpe = (252**0.5) * test['daily_return'].mean() / test['daily_return'].std()
annual_return = ((test['daily_return'].mean() + 1)**252 - 1) * 100

这段代码同时计算了夏普比率和年化收益。年化收益的计算用了复利公式 (1+r)^252 - 1,其中 r 是日均收益率。乘以 100 是为了转换成百分比。

最大回撤的手动实现

最大回撤反映的是策略可能面临的最大亏损幅度。Pyfolio 会自动计算,但手动实现能更好理解其含义:

def calculate_max_drawdown(account_values):
    peak = account_values.expanding().max()
    drawdown = (account_values - peak) / peak
    return drawdown.min()

逻辑很直观:用 expanding().max() 计算历史最高点,然后计算当前值从高点回撤的百分比,最后取最小值就是最大回撤。FinRL 的文档里没有展示这个手动版本,但在 FAQ 里明确提到这是最重要的指标之一。

最大回撤的意义在于它衡量了极端风险。一个策略可能夏普比率很高,但最大回撤达到 50%,这意味着账户价值可能腰斩,对大多数投资者来说不可接受。FinRL 的实践中,通常会设置回撤阈值作为早停条件,避免模型过度冒险。

其他关键指标解读

FinRL 的 FAQ 里列出了推荐关注的指标集合:年化收益、累计收益、年化波动率、夏普比率、卡尔马比率、稳定性、最大回撤。这些指标在 Pyfolio 报告里都有体现。

卡尔马比率是年化收益除以最大回撤,衡量的是承受每单位最大亏损能换来多少收益。这个比率在 FinRL 的 Benchmark 文档里有数学定义,比夏普比率更关注极端风险。

稳定性指标衡量收益的一致性,计算方式是收益率对时间回归的 R² 值。越接近 1 说明收益越稳定,没有大起大落。FinRL 的示例代码里没直接展示计算,但 Pyfolio 报告里会包含。

理解这些指标后,再看 Pyfolio 的输出就不会迷茫。报告里的表格会列出所有数值,图表则展示了指标的时间序列变化。

基准策略(买入持有)对比

回测必须有参照物。FinRL 的 FAQ 明确建议用买入持有(Buy and Hold, BH)作为基准,因为它简单且在任何市场都适用。多股票教程里对比的是道琼斯指数,单股票教程里对比的是个股本身的买入持有收益。

买入持有策略的实现

实现买入持有策略的代码极其简单:

def get_buy_and_hold_sharpe(test):
    test['daily_return'] = test['adjcp'].pct_change(1)
    sharpe = (252**0.5) * test['daily_return'].mean() / test['daily_return'].std()
    annual_return = ((test['daily_return'].mean() + 1)**252 - 1) * 100
    return sharpe, annual_return

逻辑只有两步:计算收盘价收益率,然后算夏普和年化收益。这个策略假设初始时刻全仓买入,之后不再操作。它的绩效完全取决于市场本身,在牛市里很难被击败。

FinRL 的 FAQ 里有个很实在的回答:模型没跑赢买入持有不代表代码错了。在波动率低、稳定上涨的市场,买入持有几乎不可战胜。这提醒我们要理性看待回测结果,不能只看数字,要结合市场环境分析。

多基准对比实践

除了买入持有,FinRL-Meta 的 Benchmark 文档还提到了其他基准:均值方差策略、最小方差策略、等权重策略。这些在组合优化场景更有用。

等权重策略的实现:

def equal_weight_strategy(stock_data):
    n = len(stock_data.columns)
    weights = np.array([1/n] * n)
    returns = stock_data.pct_change().dropna()
    portfolio_return = (returns * weights).sum(axis=1)
    return portfolio_return

这个策略每天把资金平均分配到所有股票上,避免了集中风险。在 FinRL 的组合优化教程里,等权重常作为简单基准,用来检验 DRL 模型是否真的有增值。

均值方差策略需要计算协方差矩阵和预期收益,FinRL 没有内置实现,但可以用 PyPortfolioOpt 库快速构建。这种策略基于现代投资组合理论,在有效市场假设下是最优的,但现实中对输入参数敏感,表现不稳定。

对比的统计学意义

FinRL 的实践中,对比不只是看数字大小。Pyfolio 的报告会输出 alpha 和 beta 值,这是 CAPM 模型的参数。Alpha 衡量策略相对于基准的超额收益,beta 衡量市场相关性。一个理想的策略应该有显著为正 alpha,同时 beta 不太高,说明收益来自自身能力而非单纯赌市场涨跌。

FAQ 里提到,如果模型表现异常,首先要检查是否用了正确的基准。单股票和多股票环境的基准不同,组合优化又有自己的基准体系。选错基准会导致完全错误的结论。

结果可视化与报告生成

Pyfolio 的完整报告包含太多图表,FinRL 的教程通常只展示关键几张。理解这些图表的生成逻辑,能帮助我们定制自己的监控面板。

核心图表的手动实现

累计收益曲线是最直观的图表,Pyfolio 会自动绘制,但手动实现也很简单:

import matplotlib.pyplot as plt

def plot_cumulative_returns(daily_returns, benchmark_returns=None):
    cumulative = (1 + daily_returns).cumprod()
    plt.figure(figsize=(12, 6))
    plt.plot(cumulative, label='Strategy')
    
    if benchmark_returns is not None:
        benchmark_cumulative = (1 + benchmark_returns).cumprod()
        plt.plot(benchmark_cumulative, label='Benchmark', alpha=0.7)
    
    plt.title('Cumulative Returns')
    plt.xlabel('Date')
    plt.ylabel('Cumulative Return')
    plt.legend()
    plt.grid(True)
    return plt.gcf()

这段代码把日收益率累乘,得到从 1 开始的累计值。对比基准用同样方法处理,画在同一张图上。FinRL 的示例里虽然没展示这个手动版本,但理解了它,就能看懂 Pyfolio 输出的第一张图。

回撤图展示账户从峰值回落的过程:

def plot_drawdown(account_values):
    peak = account_values.expanding().max()
    drawdown = (account_values - peak) / peak
    
    plt.figure(figsize=(12, 4))
    plt.fill_between(drawdown.index, drawdown * 100, 0, alpha=0.3, color='red')
    plt.title('Drawdown Over Time')
    plt.xlabel('Date')
    plt.ylabel('Drawdown (%)')
    plt.grid(True)
    return plt.gcf()

用 fill_between 填充面积,直观展示回撤的深度和持续时间。Pyfolio 的回撤图还会标注出最大回撤发生的区间,帮助定位问题时段。

Pyfolio 报告的定制

FinRL 的默认调用会生成完整报告,但有时候我们只需要部分图表。Pyfolio 提供了模块化函数:

import pyfolio.timeseries as timeseries

# 只计算绩效统计表
perf_stats = timeseries.perf_stats(returns=DRL_strat, 
                                   factor_returns=benchmark_strat)

# 只绘制收益分布
pyfolio.plot_return_quantiles(DRL_strat)

# 只绘制滚动夏普比率
pyfolio.plot_rolling_sharpe(DRL_strat)

这种模块化调用在调试时很有用。比如想快速比较几个模型的夏普比率,没必要生成完整报告,直接调用 perf_stats 返回的 DataFrame 里就有所有指标。

FinRL 的 FAQ 提到,回测最重要的是年化收益、累计收益、波动率、夏普、卡尔马、稳定性和最大回撤。定制报告时,可以重点展示这几个指标,避免信息过载。

结果保存与复现

FinRL 的教程里经常把回测结果保存成 CSV:

df_total_value.to_csv('account_value.csv')
df_rewards = pd.DataFrame(env.rewards_memory)
df_rewards.to_csv('account_rewards_trade.csv')

保存账户价值和每日奖励,方便后续分析。特别是在批量测试不同超参数时,把结果存下来能方便地做对比表格。

另一个实用技巧是保存交易记录:

actions_df = pd.DataFrame(env.actions_memory, columns=stock_list)
actions_df.to_csv('trading_actions.csv')

分析交易行为模式时,这个动作记录很有价值。比如可以统计换手率、平均持仓周期、偏好交易的股票等,帮助理解模型的行为特征。

FinRL 的文档里没强调这点,但在实际研究中,这些中间数据是诊断模型过拟合的关键。如果一个模型在训练集上频繁交易,在测试集上却几乎不动,那很可能是过拟合了。

总结

回测是量化策略的试金石,FinRL 通过集成 Pyfolio 让这个过程变得标准化。理解回测引擎的执行流程,知道每个指标的计算原理,掌握基准对比的方法,才能正确解读报告里的数字。Pyfolio 的完整报告提供了全方位视角,但关键还是抓住年化收益、夏普比率和最大回撤这几个核心指标。买入持有策略作为基准看似简单,却是检验模型是否创造了真实价值的标尺。下一章将探讨如何让模型学习专家的交易行为,进入模仿学习领域。