Task5 策略回测学习笔记

1. 核心概念

1.1 什么是回测?

回测就是用历史数据,假装自己过去真的按照一套交易规则执行了一遍。

它的作用不是预测未来,而是帮助我们检查:

  • 这个策略过去有没有赚钱;
  • 赚钱过程是否稳定;
  • 中间最大亏损有多深;
  • 策略是否跑赢简单的买入持有或大盘基准。

我对这句话印象最深:

回测是体检,不是算命。

也就是说,回测结果好不代表未来一定赚钱,只能说明这个策略在历史样本里表现如何。


2. 买入持有和双均线策略

2.1 买入持有是什么?

买入持有就是一开始买入,然后中间不操作,一直拿到最后。

例如:

text 复制代码
第一天买入 NVDA
中间不管涨跌都不卖
最后一天看总收益

买入持有通常用来当作基准。如果一个策略频繁买卖,但最后还不如一直拿着,那么这个策略就需要重新评估。

2.2 双均线策略是什么?

双均线策略是用两条移动平均线判断趋势。

本次代码中使用的是:

python 复制代码
MA5 = 最近 5 天平均收盘价
MA20 = 最近 20 天平均收盘价

交易规则是:

text 复制代码
MA5 > MA20:短期趋势强于长期趋势,明天持仓
MA5 <= MA20:短期趋势弱于长期趋势,明天空仓

代码里最关键的一句是:

python 复制代码
df['position'] = df['signal'].shift(1).fillna(0).astype(int)

这里使用 shift(1) 是为了避免"偷看未来"。因为今天的均线信号要等今天收盘后才能知道,所以只能从下一个交易日开始执行。


3. 关键代码理解

3.1 计算每日收益率

python 复制代码
df['ret'] = df['Close'].pct_change().fillna(0)

这行代码计算股票每天的涨跌幅。

3.2 计算策略收益

python 复制代码
df['strategy_ret'] = df['position'] * df['ret']

含义是:

  • position = 1 时,策略持仓,吃到当天涨跌;
  • position = 0 时,策略空仓,当天收益为 0。

3.3 计算累计净值

python 复制代码
df['nav_strategy'] = (1 + df['strategy_ret']).cumprod()

cumprod() 的作用是把每天的收益连续乘起来,得到从 1 元钱开始的账户净值变化。


4. 最大回撤

最大回撤表示账户净值从历史最高点到后面最低点之间,最大跌了多少。

计算逻辑是:

python 复制代码
peak = nav_series.cummax()
drawdown = nav_series / peak - 1

最大回撤不是看最后赚没赚钱,而是看过程中最痛苦的时候亏了多少。

所以一个策略即使最后赚钱,如果中间最大回撤太大,也可能很难坚持执行。


5. 本次作业

挑战 1:把 TICKER 改成 NVDA,截一张策略 vs 大盘净值图,写一句话:跑赢还是跑输?

python 复制代码
# ========== 第四章通关作业:策略回测 ==========
import sys
import warnings
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yfinance as yf

warnings.filterwarnings('ignore')

if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
    sys.stderr.reconfigure(encoding='utf-8')

plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False

TICKER = 'NVDA'
BENCHMARK = 'SPY'
OUTPUT_DIR = Path(__file__).resolve().parent


def max_drawdown(nav_series):
    """返回最大回撤比例。"""
    peak = nav_series.cummax()
    drawdown = nav_series / peak - 1
    return drawdown.min(), drawdown


def run_backtest(period):
    """运行双均线策略回测,并返回带净值和回撤的数据。"""
    raw = yf.download(TICKER, period=period, progress=False, multi_level_index=False)
    if raw.empty:
        raise RuntimeError(f'{TICKER} 在 {period} 周期没有下载到数据')

    df = raw[['Close']].dropna().copy()
    df.columns = ['Close']

    df['MA5'] = df['Close'].rolling(5).mean()
    df['MA20'] = df['Close'].rolling(20).mean()
    df['signal'] = (df['MA5'] > df['MA20']).astype(int)
    df['position'] = df['signal'].shift(1).fillna(0).astype(int)

    df['ret'] = df['Close'].pct_change().fillna(0)
    df['strategy_ret'] = df['position'] * df['ret']
    df['buyhold_ret'] = df['ret']

    spy = yf.download(BENCHMARK, period=period, progress=False, multi_level_index=False)[['Close']]
    if spy.empty:
        raise RuntimeError(f'{BENCHMARK} 在 {period} 周期没有下载到数据')
    spy.columns = ['SPY_Close']

    df = df.join(spy, how='inner')
    df['market_ret'] = df['SPY_Close'].pct_change().fillna(0)

    df['nav_strategy'] = (1 + df['strategy_ret']).cumprod()
    df['nav_buyhold'] = (1 + df['buyhold_ret']).cumprod()
    df['nav_market'] = (1 + df['market_ret']).cumprod()

    df['mdd_strategy'], df['drawdown_strategy'] = max_drawdown(df['nav_strategy'])
    df['mdd_buyhold'], df['drawdown_buyhold'] = max_drawdown(df['nav_buyhold'])
    return df


def save_nav_chart(df, period):
    """保存策略 vs 标的 vs 大盘净值图。"""
    fig, ax = plt.subplots(figsize=(14, 6))

    ax.plot(df.index, df['nav_strategy'], linewidth=2.2, color='tab:purple', label=f'双均线策略 ({TICKER})')
    ax.plot(df.index, df['nav_buyhold'], linewidth=1.8, color='tab:blue', alpha=0.85, label=f'买入持有 ({TICKER})')
    ax.plot(df.index, df['nav_market'], linewidth=1.8, color='tab:gray', linestyle='--', label=f'买入持有 ({BENCHMARK} 大盘)')

    ax.axhline(1.0, color='black', linewidth=0.6, linestyle=':', alpha=0.5)
    ax.set_title(f'{TICKER} 回测净值曲线:策略 vs 标的 vs 大盘({period})', fontsize=14)
    ax.set_xlabel('日期')
    ax.set_ylabel('净值(起点=1)')
    ax.legend(loc='upper left')
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    chart_path = OUTPUT_DIR / f'homework_{TICKER}_{period}_nav_comparison.png'
    plt.savefig(chart_path, dpi=150)
    plt.close(fig)
    return chart_path


def summarize(period, df):
    """打印单个周期的关键回测结果。"""
    total_strategy = df['nav_strategy'].iloc[-1] - 1
    total_buyhold = df['nav_buyhold'].iloc[-1] - 1
    total_market = df['nav_market'].iloc[-1] - 1
    mdd_strategy = df['mdd_strategy'].iloc[-1]
    mdd_buyhold = df['mdd_buyhold'].iloc[-1]

    print(f'\n=== {period} 回测结果 ===')
    print(f'双均线策略 ({TICKER}) 累计收益:{total_strategy:+.2%}')
    print(f'买入持有 ({TICKER}) 累计收益:{total_buyhold:+.2%}')
    print(f'买入持有 ({BENCHMARK} 大盘) 累计收益:{total_market:+.2%}')
    print(f'双均线策略最大回撤:{mdd_strategy:.2%}')
    print(f'买入持有最大回撤:{mdd_buyhold:.2%}')
    return mdd_strategy


def main():
    print('第四章作业:NVDA 双均线策略回测 [OK]')

    df_1y = run_backtest('1y')
    chart_path = save_nav_chart(df_1y, '1y')
    print(f'\n作业1:策略 vs 大盘净值图已保存:{chart_path}')

    strategy_end = df_1y['nav_strategy'].iloc[-1]
    market_end = df_1y['nav_market'].iloc[-1]
    race_answer = '跑赢' if strategy_end > market_end else '跑输'
    print(f'一句话:1y 周期里,NVDA 双均线策略相对 SPY 大盘是{race_answer}。')
    summarize('1y', df_1y)


if __name__ == '__main__':
    main()

运行结果:

text 复制代码
双均线策略 (NVDA) 累计收益:-12.45%
买入持有 (NVDA) 累计收益:+22.21%
买入持有 (SPY 大盘) 累计收益:+19.87%
双均线策略最大回撤:-28.41%
买入持有最大回撤:-20.21%

结论:

1 年周期里,NVDA 双均线策略相对 SPY 大盘是跑输的。


挑战 2:对比 PERIOD='1y''5y',最大回撤哪个更深?我更能接受哪种?

根据运行结果:

text 复制代码
1y 双均线策略最大回撤:-28.41%
5y 双均线策略最大回撤:-52.50%

结论:

5y 的最大回撤更深。

如果只看最大回撤,我更能接受 1y 的结果,因为它从历史高点跌下来的幅度更小。5y 虽然样本更长,但过程中经历过更深的下跌,对执行策略的心理压力更大。


挑战 3:用三句话向朋友解释"回测是什么"

  1. 回测就是用历史数据,假装自己过去真的按照一套规则买入、持仓和卖出。
  2. 它能帮我们看清策略过去的收益、回撤和风险,但不能保证未来还会赚钱。
  3. 所以回测更像是给策略做体检,而不是预测未来。

6. 学习理解

量化策略不能只看"最后赚了多少",还要看过程中有没有大幅亏损,以及是否真的跑赢了简单基准。以后看策略时,我应该同时关注收益、最大回撤、基准比较等。