量化喵是独立第三方信息站,不是欧易(OKX)官方。站内含推广链接。 完整披露 →

欧易 · 量化实操

策略回测怎么做:新手用 ccxt 拉历史K线

回测是拿已经发生过的行情,把策略的买卖规则从头到尾算一遍,看这套规则在那段历史里会留下一条什么样的资金曲线。下面先用 ccxt 和 pandas 写一个能直接跑的双均线回测:拉欧易(OKX)的K线、分页拉更早的数据、算信号、扣手续费;然后是回测曲线好看、实盘却亏钱的五个常见原因,和一张跑完回测先过一遍的自查表。

代码里的均线参数和手续费率都是示意值,用来演示流程,不是参数建议。

回测和模拟盘有什么区别,先做哪个

先把两个概念分开。回测(Backtest)用的是历史数据,把策略逻辑套进去从头跑到尾,优点是快,几年的行情几秒到几分钟就能跑完;缺点是它是「事后诸葛亮」,跑在一个已经知道结局的世界里。模拟盘(Demo Trading)用的是实时行情,策略按真实节奏一天天跟着走,优点是更贴近真实执行,挂单、成交、网络延迟都是真的;缺点是慢,验证一个思路可能要等上好几周。

顺序是先回测筛掉明显不行的思路,再上模拟盘,最后才是小钱实盘,每一步做什么见回测通过以后做什么。

用 ccxt 拉欧易K线:单次 300 根,更早的分页拉

两样工具就够:ccxt 负责向交易所要K线,pandas 负责把数据整理成表格并计算。K线是公开行情,不需要 API Key。

ccxt 的 fetch_ohlcv 背后对着欧易的两个接口,带不带 since 走的不是同一个(判断逻辑在 ccxt 的 okx.py 里):

  • 不带 since:请求「获取交易产品K线数据」(/api/v5/market/candles),返回最近的一段,这个接口单次最多 300 根。
  • 带 since,而且起点比最近约 1,440 根K线更早:ccxt 自动改成请求「获取交易产品历史K线数据」(/api/v5/market/history-candles)。它和上一个接口一样,单次最多 300 根,不传 limit 时默认 100 根。下面的分页写法每页取 100 根,用的就是这个默认条数;想少发几次请求,可以把 page 调到 300。分页按实际返回的最后一根往后翻,每页取多少不影响结果。

先拉最近 300 根日线:

import ccxt
import pandas as pd

# K线是公开行情,不需要 API Key;enableRateLimit 打开 ccxt 内置的请求限速
okx = ccxt.okx({'enableRateLimit': True})

def to_df(rows):
    df = pd.DataFrame(rows, columns=['ts', 'open', 'high', 'low', 'close', 'volume'])
    df['ts'] = pd.to_datetime(df['ts'], unit='ms')
    return df.drop_duplicates('ts').set_index('ts').sort_index()

# 不带 since:走「获取交易产品K线数据」接口,单次最多 300 根
ohlcv = okx.fetch_ohlcv('BTC/USDT', timeframe='1d', limit=300)

df = to_df(ohlcv).iloc[:-1]   # 最后一根是今天还没走完的K线,回测不用它
print(df.tail())

300 根日线不到一年。要更长的样本,带上 since 从起点一页页往后拉,每一页的起点设成上一页最后一根之后:

def fetch_since(symbol, timeframe, since_ms, page=100):
    """从 since_ms 开始一页页往后拉,直到拉不到新的K线"""
    rows = []
    while True:
        batch = okx.fetch_ohlcv(symbol, timeframe=timeframe, since=since_ms, limit=page)
        if not batch:
            break
        rows += batch
        next_ms = batch[-1][0] + 1     # 下一页从本页最后一根之后开始
        if next_ms <= since_ms:        # 没往前走就停,防止死循环
            break
        since_ms = next_ms
    return rows

start = okx.parse8601('2023-01-01T00:00:00Z')   # 起点是示意,换成你要的日期
df = to_df(fetch_since('BTC/USDT', '1d', start)).iloc[:-1]
print(len(df), df.index[0], df.index[-1])

起点如果早于这个交易对的上线时间,第一页就是空的,循环会直接结束,这时把起点往后挪。拉下来的数据建议存成本地文件(df.to_csv('btc_1d.csv')),调策略时反复读本地文件,不用每次重新请求。

双均线回测示例:算信号、扣手续费

规则是教科书里最简单的一种:短期均线在长期均线上方就持有,否则空仓。下面这段接着上面的 df 跑,参数 10、30 是示意值:

SHORT, LONG = 10, 30
FEE = 0.001   # 假设值:每次买入或卖出按成交额的 0.1% 扣,换成你账户实际适用的费率

df['ma_short'] = df['close'].rolling(SHORT).mean()
df['ma_long']  = df['close'].rolling(LONG).mean()

# 这根K线收盘后才算得出的均线关系:短线在长线上方记 1(想持有),否则记 0
df['want'] = (df['ma_short'] > df['ma_long']).astype(int)

# 实际持仓晚一根:这根收盘定下的信号,下一根才生效,避免未来函数
df['pos'] = df['want'].shift(1).fillna(0)

df['ret']   = df['close'].pct_change().fillna(0)
df['gross'] = df['ret'] * df['pos']              # 未扣费的每日收益

# 持仓从 0 变 1、从 1 变 0 各算一次成交,每次扣一次 FEE
df['trade'] = df['pos'].diff().abs().fillna(0)
df['net']   = df['gross'] - df['trade'] * FEE    # 扣费后的每日收益

print('成交次数:', int(df['trade'].sum()))
print('拿着不动的净值:', (1 + df['ret']).cumprod().iloc[-1])
print('策略净值(未扣费):', (1 + df['gross']).cumprod().iloc[-1])
print('策略净值(扣费后):', (1 + df['net']).cumprod().iloc[-1])

输出的三个净值都以 1 为起点。FEE 要换成你账户在欧易费率页上实际适用的费率,挂单和吃单的费率不同,拿不准就按较高的那个算。这段代码仍然没算滑点,也假设每次都能按收盘价成交,所以扣费后的净值还是偏乐观的。把「拿着不动」一起打印出来,是因为一个策略如果连持币不动都跑不赢,后面的步骤就不用做了。

没用过 ccxt 的话,连接和下单的基础在 API 量化入门 里。

风险提示:上面的代码只用来演示流程,不构成投资建议,回测结果不保证未来能赚钱,别照抄参数直接上实盘。拉K线用的是公开接口,不需要 API Key;之后要接自己账户做实时验证,建 Key 时也只勾「读取」权限,交易权限留到真正准备下单那一步再开。

回测很漂亮、实盘却亏钱的五个原因

回测是在一个已经知道结局的世界里做的。历史行情走完了,你在上面反复试参数、改规则、加条件,等于往答案上凑,凑得越贴合曲线越好看。所以回测结果越漂亮,越该先怀疑。下面五个原因,前两个是回测本身写错了,后三个是数据和成本没选对。

过拟合:把这段历史里的偶然当成了规律

过拟合(overfitting)往往是一步步滑进去的。第一版跑出来一般,把周期参数调了调,好看一点;加一个过滤条件,又好看一点;发现某段回撤难看,补一条「这种情况不开仓」的规则。每一步都说得通,连起来就是照着这段历史量身定做。三个自查动作:

  • 样本外测试。把数据切成两段,只用前一段调参数,调完拿后一段跑一次,而且只跑一次。看了后一段的结果又回去改参数,它就不再是样本外了。
  • 参数敏感度。把参数上下挪一点再跑。表现缓慢变化是正常的;稍微一动收益就塌,说明找到的只是一个孤立的点。
  • 数参数个数。两三个参数的策略,和带十几个开关、阈值、例外条件的策略放在同一段数据上,后者跑出漂亮曲线容易得多。参数越多,越需要更长、更杂的样本来支撑。

未来函数:用了当时还拿不到的数据

未来函数(look-ahead bias)指策略做决定的那一刻,用到了当时还不存在的信息。典型写法是用当根K线的收盘价决定当根开仓:收盘价要等这根K线走完才知道,那时已经回不到中间的价格去成交了。同类的还有:用整段数据算完均值和标准差再回头做标准化;做位移时方向弄反,把后面的行填进了当前行。这些写法都不报错,回测照样跑完,而且结果特别好看。上面示例里的 shift(1) 就是在防这个:这根收盘定下的信号,下一根才生效。检查时对每一个下单决定问同一句话:做决定的那一刻,手上真的有这个数吗?

幸存者偏差:下架和归零的币没进样本

挑回测标的时,顺手的做法是打开今天的行情列表选几个熟悉的交易对。今天还在列表里的,都是活下来的;已经下架的、流动性枯掉的不会出现在样本里。策略在一组事后证明都没死的标的上跑,自然显得稳健。只要策略属于「在全市场里筛标的」那一类,这个偏差就存在。拿不到含已下架标的的历史数据时,至少做两件事:把回测标的固定成少数几个长期存在、流动性好的交易对,别拿「筛出来的最优组合」当结论;在结论里写明「本次回测未包含已下架标的」。

手续费、滑点和资金费没算进去

成本至少三层:成交手续费、滑点(想要的价格和真正成交的价格之差),测的是合约还要加上资金费。交易越频繁,成本被乘的次数越多。按上面代码里假设的每次 0.1% 算,成交 100 次,净值要乘以 0.999 的 100 次方,约等于 0.905,光手续费就去掉约 9.5%。做法是先按实际费率扣手续费,再给滑点一个明显偏保守的假设(取多少看交易对的流动性和你的下单量),看曲线还剩多少。扣完成本收益就没了的策略,本来就不成立。想先粗算成本占比,可以用工具页的 手续费计算器。

样本期只覆盖了一种行情

测试区间如果正好落在一段持续上涨里,几乎任何「买了拿着」的策略都好看;一段长期横盘里跑出来的高胜率,换到单边行情里可能连着止损。看样本期,先看它有没有覆盖过性格不同的市场:涨过、跌过、横着磨过,最好还包含一两次剧烈波动。测了多久倒在其次。

回测跑完先问自己的五个问题

回测跑完先别看收益数字,按这张表过一遍。先看了曲线,人会倾向于替它找理由。

问自己没过关的信号
参数是在哪段数据上调的?调完有没有拿没看过的数据跑过?全程只有一段数据,调参和验证用的是同一批
每一个下单决定,当时手上真的有那个数吗?用当根收盘价决定当根开仓;指标用整段数据算出来
测试标的是怎么选出来的?从今天的行情列表里挑表现好的那几个
手续费和滑点按什么口径扣的?没扣,或者按一个说不清出处的数扣
样本期里有没有出现过跟现在完全不同的行情?只覆盖了一段单边行情

五个问题里有一个答不上来,这份回测就先别拿去支撑真金决策。

回测报告怎么看:收益曲线、最大回撤、胜率和盈亏比

看回测报告,总收益率放到最后看。同样是「赚了 30%」,过程可能是一路平稳向上,也可能是先亏掉一半再拉回来,这两种策略你能不能拿得住,是完全不同的问题。看回测报告,至少要过这三项:

  • 收益曲线的形状。别只看终点,看整段走势是平滑上升,还是大起大落、中间坐了几次过山车才勉强打平。曲线的形状比终点数字更能说明这策略实际拿着是什么体验。
  • 最大回撤(Max Drawdown)。从曲线的最高点到之后的最低点,跌了多少。这是回测报告里最该重视的一项,它约等于「你在最惨的时候要扛住多少浮亏」。回撤太大,就算终点收益好看,大概率也拿不住。这块和 仓位、止损怎么写进代码 直接相关,值得一起看。
  • 胜率 × 盈亏比。胜率高不代表赚钱,还要看赢的时候平均赚多少、输的时候平均亏多少(盈亏比)。胜率 70% 但每次赢一点、亏很多,照样长期亏钱;胜率 40% 但赢的时候赚得多、亏的时候亏得少,反而可能是笔好账。两个数字要一起看,单看任何一个都会被误导。

回测通过以后做什么

回测里没有下单排队、网络延迟和断线,它能回答的是「这套规则在那段历史里会怎样」,用处是尽早筛掉明显不行的想法。通过之后先去 模拟盘 把同一套逻辑按真实节奏跑一遍,确认下单、断线重连、异常处理都没问题;再用一笔亏了也能接受的小钱上实盘,并盯住实盘成交价和回测假设之间的差。上实盘之前,仓位上限和止损 要先写进代码。

常见问题

回测和模拟盘有什么区别,该先做哪个?

回测是拿历史行情数据快进跑策略逻辑,几分钟就能看完几年的数据,用来快速筛掉明显不行的思路;模拟盘是实时跟着当前盘口走,速度和真实交易一样、但用的是虚拟资金,更能反映真实的挂单和延迟。正常顺序是先回测筛选思路,思路过了再去模拟盘按真实节奏走一遍,最后才考虑小资金实盘。

怎么判断自己的策略是不是过拟合了?

三个动作:把数据切成两段,只用前一段调参数,调完拿后一段跑一次而且只跑一次;把参数上下挪一点再跑,看表现是缓慢变化还是直接塌掉;数一数策略里到底有多少参数、阈值和例外条件,数量越多越可疑。三条里有两条不对劲,就按过拟合处理。

回测里的未来函数是什么意思?

未来函数指策略在做决策时,偷偷用到了当时还不可能拿到的数据,比如用当根K线的收盘价,去决定这根K线开盘时该不该买卖。现实中收盘价要等这根K线走完才知道,回测代码却提前用上了它,结果收益率会被明显高估。避免的办法是决策只能用已经走完的、更早的K线数据。

回测里手续费和滑点该怎么算?

手续费按你自己账户实际适用的费率代进去,挂单和吃单的费率不同;滑点没有通用数值,取决于交易对流动性和你的下单量,做一个明显偏保守的假设即可,别抄别人的数。交易频率越高,这两项被乘的次数越多。扣完成本收益就消失的策略,本来就不成立。

回测样本期要多长才够?

时长本身说明不了什么,关键看这段样本里有没有出现过性格不同的行情:涨过、跌过、横着磨过,最好还包含一两次剧烈波动。只覆盖一段单边上涨的回测证明不了策略,只说明那段时间行情好。

ccxt 一次能拉多少根欧易K线,更早的数据怎么拿?

不带 since 调 fetch_ohlcv 时,ccxt 请求的是欧易「获取交易产品K线数据」接口,单次最多 300 根。要更早的数据,就带上 since 从起点一页页往后拉;起点比最近约 1,440 根 K 线更早时,ccxt 会自动改走「获取交易产品历史K线数据」接口,它同样单次最多 300 根,不传 limit 时默认 100 根。两个接口都是公开行情,不需要 API Key。