文章转至知乎里的郭少侠背景探索交易策略的极致——通过大规模历史数据回测我们将揭示止损(SL)、跟踪止损(TS)和获利了结(TP)信号在交易中的真实影响力。为了确保我们的研究具有广泛的适用性和深度我们精心设计了一个三维实验框架多样化的交易工具、跨越时间的广度和细致的超参数调整。我们将精选10种市值领先的加密货币排除稳定币如USDT深入分析它们过去三年的日线价格走势。我们特别选择了2018年至2021年这一关键时期它不仅见证了市场的剧烈波动——从2017年底的历史最高点(ATH)到2020年3月新冠疫情引发的市场修正再到2020年底的惊人复苏——这为我们的分析提供了一个多维度的市场环境。接下来我们将这三年的时间划分为400个为期六个月的重叠时间窗口每个窗口都是我们策略测试的独立舞台。在每个窗口内我们将精心设计入场信号并探索在不同止损配置下的退出策略。我们将以1%的精细步长测试100个不同的止损水平并将它们的表现与同一时间窗口内的随机交易和持仓策略进行对比。这是一场规模宏大的实验——200万次回测将为我们提供坚实的数据支持揭示在波动的加密货币市场中策略选择的重要性。准备好随着我们的研究深入一起见证交易策略的力量。一、定义参数首先我们需要设定分析流程的参数。如前所述我们将对三年的定价数据进行回测涵盖400个时间窗口、10种加密货币以及100个止损值。同时我们将交易费用和滑点均设定为0.25%并将初始资本设定为100美元具体的金额并非关键重要的是为了可比性所有资产的初始资本必须保持一致。如果对任何参数感兴趣欢迎随时进行调整。这里用vbt.settings.portfolio设置“初始资金”“费用”“滑点”import vectorbt as vbt import numpy as np import pandas as pd import itertools from datetime import datetime, timedelta import pytz from numba import njit import ipywidgets seed 42 symbols [ BTC-USD, ETH-USD, XRP-USD, BCH-USD, LTC-USD, BNB-USD, EOS-USD, XLM-USD, XMR-USD, ADA-USD ] start_date datetime(2018, 1, 1, tzinfopytz.utc) end_date datetime(2021, 1, 1, tzinfopytz.utc) time_delta end_date - start_date window_len timedelta(days180) #时间间隔 window_count 400 exit_types [SL, TS, TP, Random, Holding] step 0.01 # 1% stops np.arange(step, 1 step, step) vbt.settings.array_wrapper[freq] d vbt.settings.plotting[layout][template] vbt_dark vbt.settings.portfolio[init_cash] 100. # 100$ vbt.settings.portfolio[fees] 0.0025 # 0.25% vbt.settings.portfolio[slippage] 0.0025 # 0.25% print(pd.Series({ Start date: start_date, End date: end_date, Time period (days): time_delta.days, Assets: len(symbols), Window length: window_len, Windows: window_count, Exit types: len(exit_types), Stop values: len(stops), Tests per asset: window_count * len(stops) * len(exit_types), Tests per window: len(symbols) * len(stops) * len(exit_types), Tests per exit type: len(symbols) * window_count * len(stops), Tests per stop type and value: len(symbols) * window_count, Tests total: len(symbols) * window_count * len(stops) * len(exit_types) }))第一步代码运行后pd.Series设定参数精准分析--我们的配置能够产生具有足够统计效力的样本量以便我们分析四个关键变量资产每种资产进行20万次测试、时间每个时间窗口5万次测试、退出类型每种退出类型40万次测试以及止损值每种止损类型及值4万次测试。类似于Tableau处理维度和度量的方式我们将能够根据这些变量对性能进行分组但我们的主要关注点将集中在五种退出类型上止损(SL)退出、跟踪止损(TS)退出、获利了结(TP)退出、随机退出以及持仓退出放置在最后一个价格条上。通过这种细致的分析我们将深入理解每种退出策略的实际效果为您的投资决策提供坚实的数据支持。二、数据下载1、使用yfinance库获取每种加密货币的日线价格数据是一件非常简单的事情cols [Open, Low, High, Close, Volume] yfdata vbt.YFData.download(symbols, startstart_date, endend_date) print(yfdata.data.keys()) print(yfdata.data[BTC-USD].shape)2、现在字典ohlcv_by_symbol已经包含了按加密货币名称组织的OHLCV数据。每个DataFrame包含1083行天和5列开盘价(O)、最高价(H)、最低价(L)、收盘价(C)和成交量(V)。您可以按照以下方式绘制DataFrame图表yfdata.data[BTC-USD].vbt.ohlcv.plot().show_svg()资产分析数据整合的艺术 由于我们希望深入分析资产这一维度vectorbt要求我们将它们作为列打包到一个单独的DataFrame中并相应地进行标记。为此我们只需简单地交换资产和特征的位置就能得到一个以特征名称为键例如“开盘价”的DataFrame字典其中资产现在成为了列。这样的数据整合不仅提升了分析的效率也使得后续的操作更加直观和灵活ohlcv yfdata.concat() print(ohlcv.keys()) print(ohlcv[Open].shape)三、生成时间窗口接下来我们将在整个时间跨度上移动一个为期6个月的滑动窗口并在此窗口内对每个价格DataFrame进行400次“快照”捕捉。每一次快照都对应于一个应独立进行回测的数据子集。与资产和其他变量的处理方式相同这些快照也需要水平堆叠为列。最终我们将得到一个包含180行窗口长度以天计和4000列10种资产 x 400个窗口的DataFrame也就是说每一列将对应于特定时间窗口内某一资产的价格。通过这种方法我们能够详尽地捕捉和分析市场在不同时间段的表现为深入理解市场动态提供了强大的数据支持。split_ohlcv {} for k, v in ohlcv.items(): split_df, split_indexes v.vbt.range_split(range_lenwindow_len.days, nwindow_count) split_ohlcv[k] split_df ohlcv split_ohlcv print(ohlcv[Open].shape)vectorbt的层次化索引之美 vectorbt的一个亮点在于它巧妙地利用层次化索引来存储每一次回测的宝贵信息。它还确保了这一列的层次结构在整个回测流程中——从信号生成到性能建模——得到保留并且可以轻松扩展。目前我们的列具有如下层次结构print(ohlcv[Open].columns)多级索引的艺术 这个多级索引巧妙地捕捉了三个关键参数标的、时间窗口的开始日期以及结束日期。稍后我们将进一步扩展这个多级索引加入退出类型和止损值使得每一次回测——总共200万次——都拥有其独特的价格序列。这种方法不仅为我们的分析提供了精确的时间框架还确保了数据的组织性和易于访问性让每一次回测都清晰可追溯。四、生成入场信号与大多数其他回测库不同信号不是以有符号整数数组的形式存储而是被分解为两个布尔数组入场和出场。这样的设计大幅简化了信号的操控性。在每个时间窗口的起始时刻让我们生成一个入场信号表示买入指令。为此我们将创建一个与价格数据框形状、索引和列完全相同的数据框以便vectorbt能够将它们的元素相互关联。这种方法不仅确保了数据处理的连贯性也使得入场信号的生成变得直观而高效。定义入场条件entries pd.DataFrame.vbt.signals.empty_like(ohlcv[Open]) entries.iloc[0, :] True print(entries.shape)五、生成出场信号对于我们生成的每一个入场信号我们将根据我们的五种出场类型来寻找对应的出场信号止损(SL)、跟踪止损(TS)、获利了结(TP)、随机出场以及持仓。我们还将它们的DataFrames拼接成一个单一的庞大的DataFrame拥有180行和200万列每一列代表一个独立的回测。由于出场信号是布尔类型的它们占用的内存空间是可以接受的。首先让我们根据止损条件来生成出场信号。我们想要测试100个不同的止损值以1%的增量开始以100%结束即找到一个价格超过入场价格100%的时间点。通常在检查OHLC数据是否满足这些条件时头寸会在触及特定止损点时或之后不久关闭但我们将简化处理使用“收盘价”来退出任何头寸。这种方法不仅提高了操作的便捷性也为我们的回测提供了一致的退出标准。这里用vbt.OHLCSTX.run设置出场条件sl_exits vbt.OHLCSTX.run( entries, ohlcv[Open], ohlcv[High], ohlcv[Low], ohlcv[Close], sl_stoplist(stops), stop_typeNone, stop_priceNone ).exits ts_exits vbt.OHLCSTX.run( entries, ohlcv[Open], ohlcv[High], ohlcv[Low], ohlcv[Close], sl_stoplist(stops), sl_trailTrue, stop_typeNone, stop_priceNone ).exits tp_exits vbt.OHLCSTX.run( entries, ohlcv[Open], ohlcv[High], ohlcv[Low], ohlcv[Close], tp_stoplist(stops), stop_typeNone, stop_priceNone ).exits print(sl_exits.shape, ts_exits.shape, tp_exits.shape)扩展列层级统一止损值 此外我们在列层级中新增了一个表示止损值的层级我们只需确保所有DataFrames中这一层次的一致性通过在列层级中加入表示止损值的新层级我们不仅丰富了数据的结构还为分析提供了更多的细节。现在关键在于确保这一新增层级在所有DataFrames中保持一致以便我们能够准确地追踪和比较不同止损值下的表现。这样的细致设计将为我们的回测结果带来更高的准确性和可靠性。sl_exits.vbt.rename_levels({ohlcstx_sl_stop: stop_value}, inplaceTrue) ts_exits.vbt.rename_levels({ohlcstx_sl_stop: stop_value}, inplaceTrue) tp_exits.vbt.rename_levels({ohlcstx_tp_stop: stop_value}, inplaceTrue) ts_exits.vbt.drop_levels(ohlcstx_sl_trail, inplaceTrue) print(tp_exits.columns)vectorbt的一个显著特点是它对数据科学的高度关注这使得我们能够将流行的分析工具应用于回测流程的几乎任何部分。例如让我们探究一下出场信号的数量是如何依赖于止损类型和值的print(pd.Series({ SL: sl_exits.vbt.signals.total().mean(), TS: ts_exits.vbt.signals.total().mean(), TP: tp_exits.vbt.signals.total().mean() }, nameavg_num_signals)) pd.DataFrame({ Stop Loss: sl_exits.vbt.signals.total().groupby(stop_value).mean(), Trailing Stop: ts_exits.vbt.signals.total().groupby(stop_value).mean(), Take Profit: tp_exits.vbt.signals.total().groupby(stop_value).mean() }).vbt.plot(xaxis_titleStop value, yaxis_titleAvg number of signals).show_svg()观察结果跟踪止损(TS)信号在70%止损之前是占优的跟踪止损(TS)信号远远超过其他类型的出场信号。止损(SL)和获利了结(TP)曲线在止损值达到50%时仍然紧密相连之后则开始分道扬镳TP信号逐渐占据优势。虽然从表面上看尤其是在较大的价格波动中多头似乎占据主导地位但请记住实现50%的利润比承受50%的亏损要容易得多因为后者需要100%的利润才能恢复因此负面的价格急剧下跌似乎主导了中小幅度的价格波动并可能淘汰掉弱势持有者这些是众所周知的加密货币市场动态。为了简化后续的分析我们需要确保每一列至少有一个出场信号来关闭头寸这意味着如果某一列目前还没有出场信号它应该在最后一个时间戳处获得一个。这是通过将止损出场与最后一根K线的出场信号结合使用OR规则来完成的并选择最先出现的那个信号sl_exits.iloc[-1, :] True ts_exits.iloc[-1, :] True tp_exits.iloc[-1, :] True # Select one exit between two entries sl_exits sl_exits.vbt.signals.first(reset_byentries, allow_gapsTrue) ts_exits ts_exits.vbt.signals.first(reset_byentries, allow_gapsTrue) tp_exits tp_exits.vbt.signals.first(reset_byentries, allow_gapsTrue) print(pd.Series({ SL: sl_exits.vbt.signals.total().mean(), TS: ts_exits.vbt.signals.total().mean(), TP: tp_exits.vbt.signals.total().mean() }, nameavg_num_signals))生成剩余两种出场信号随机出场和持仓出场 接下来我们将生成剩余两种出场信号随机出场和持仓出场——它们将作为比较止损(SL)、跟踪止损(TS)和获利了结(TP)的基准。“持仓出场”信号被放置在每个时间序列的最后一个价格条上。在大多数情况下我们不需要费心去放置这些信号因为我们可以直接评估未平仓的头寸。我们之所以还是要放置它们是为了保持一致性——我们希望确保每一列都有恰好一个信号。另一个考虑因素是形状和列的匹配它们应该与止损信号的形状相匹配这样我们之后就可以拼接所有的DataFrames。hold_exits pd.DataFrame.vbt.signals.empty_like(sl_exits) hold_exits.iloc[-1, :] True print(hold_exits.shape) rand_exits hold_exits.vbt.shuffle(seedseed) print(rand_exits.shape)最后一步整合全部出场方法exits pd.DataFrame.vbt.concat( sl_exits, ts_exits, tp_exits, rand_exits, hold_exits, keyspd.Index(exit_types, nameexit_type) ) print(exits.shape)分组信号高效分析 这种设计使我们能够根据一个或多个层级对信号进行分组并一次性方便地分析它们。例如让我们通过比较不同出场类型和止损值的平均出场信号到入场信号的距离以天为单位来进行比较avg_distance entries.vbt.signals.between_ranges(otherexits)\ .duration.mean()\ .groupby([exit_type, stop_value])\ .mean()\ .unstack(levelexit_type) print(avg_distance.mean()) avg_distance[exit_types].vbt.plot( xaxis_titleStop value, yaxis_titleAvg distance to entry ).show_svg()这个散点图为我们提供了出场信号分布的详细视图。正如预期的那样单纯持仓的出场信号在入场后有确切的179天距离最大可能值而随机出场信号则在时间窗口内均匀分布并且不依赖于任何止损值。但我们更感兴趣的是止损曲线它们呈平坦状态从而暗示了我们时间框架内价格波动的高波动性——曲线越低触及止损的机会就越高。举个例子一个20%的跟踪止损(TS)平均在30天后就被触及而对于止损(SL)需要72天获利了结(TP)则需要81天。但是早期出场真的有好处吗六、运行模拟现在我们来到了实际回测的部分from tqdm.auto import tqdm import gc total_returns [] for i in tqdm(range(len(exit_types))): chunk_mask exits.columns.get_level_values(exit_type) exit_types[i] chunk_exits exits.loc[:, chunk_mask] chunk_pf vbt.Portfolio.from_signals(ohlcv[Close], entries, chunk_exits) total_returns.append(chunk_pf.total_return()) del chunk_pf gc.collect() total_return pd.concat(total_returns) print(total_return.shape) total_return_by_type total_return.unstack(levelexit_type)[exit_types] print(total_return_by_type[Holding].describe(percentiles[])) total_return_by_type[Holding].vbt.histplot( xaxis_titleTotal return, xaxis_tickformat%, yaxis_titleCount, trace_kwargsdict(marker_colorvbt.settings[plotting][color_schema][purple]) ).show_svg()持仓表现在不同时间窗口的分布呈现出明显的左偏态。一方面这表明在我们的时间框架内存在着长时间的横向整理和熊市行情。另一方面任何资产的价格理论上可以无限上涨但实际上却受限于0——这使得分布自然地在左侧更加密集而在右侧则相对稀疏。每两次回报中就有超过7%的亏损但由于牛市行情的存在该策略仍然能够实现平均7%的利润。接下来让我们将其他策略纳入分析中通过将持仓策略与其他策略进行比较我们可以更全面地评估不同交易策略在市场不同阶段的表现。这不仅有助于我们识别在特定市场条件下哪些策略可能更为有效还能够让我们更好地理解市场动态从而优化我们的投资组合。通过这种多元化的策略分析我们可以更加稳健地应对市场的不确定性提高我们的交易策略的适应性和盈利能力。print(pd.DataFrame({ Mean: total_return_by_type.mean(), Median: total_return_by_type.median(), Std: total_return_by_type.std(), })) total_return_by_type.vbt.boxplot( yaxis_titleTotal return, yaxis_tickformat% ).show_svg()没有任何一种策略的平均水平超过了基线的平均回报。然而获利了结(TP)策略是其中最稳定的一个——尽管它引入了一个限制巨大利润的上限参见缺失的异常值但其交易回报的波动性较小且大多数为正。止损(SL)和跟踪止损(TS)策略在顶部没有界限的原因是有些止损点并未被触及因此它们的列回落到了简单的持仓策略。随机策略也很有趣虽然在平均回报方面表现较差但在中位数回报和回报波动性方面仅次于TP策略位列第二。为了确认上述情况让我们计算每种策略的胜率print((total_return_by_type 0).mean().rename(win_rate))接近57%的获利了结(TP)交易是盈利的——这与其他策略形成了鲜明对比。但是高胜率并不一定能保证长期交易成功因为如果您的盈利交易通常远小于亏损交易那么高胜率也就失去了意义。因此让我们按止损类型和值进行聚合并计算期望值期望值是衡量交易策略盈利能力的一个关键指标它考虑了每次交易的平均盈利或亏损。通过计算不同止损类型和值的期望值我们可以更全面地评估策略的盈利潜力而不仅仅是依赖胜率这一指标。这种分析将帮助我们深入理解策略在不同市场情况下的表现从而做出更明智的交易决策。init_cash vbt.settings.portfolio[init_cash] def get_expectancy(total_return_by_type, level_name): grouped total_return_by_type.groupby(level_name, axis0) win_rate grouped.apply(lambda x: (x 0).mean()) avg_win grouped.apply(lambda x: init_cash * x[x 0].mean()).fillna(0) avg_loss grouped.apply(lambda x: init_cash * x[x 0].mean()).fillna(0) return win_rate * avg_win - (1 - win_rate) * np.abs(avg_loss) expectancy_by_stop get_expectancy(total_return_by_type, stop_value) print(expectancy_by_stop.mean()) expectancy_by_stop.vbt.plot( xaxis_titleStop value, yaxis_titleExpectancy ).show_svg()长期来看每种策略都能逐渐增加我们的账户余额其中持仓策略显然是最大的赢家——我们可以预期每半年持仓后每投资100美元平均能增加7美元。唯一超过基线的配置是止损(TS)其止损值范围在20%到40%之间。表现最差的配置分别是止损(SL)和跟踪止损(TS)其止损值分别在40%和55%左右这两种策略似乎在大多数回调找到底部后就被触发这甚至比随机出场还要糟糕。另一方面获利了结(TP)策略在止损值达到30%之后超过了随机出场策略。总的来说对于加密货币来说耐心等待似乎是一种有效的策略。最后让我们看看我们的策略在不同市场条件下的表现如何。我们将考虑一种简化形式的市场状态分类它将持仓回报分为20个区间并计算每个策略在每个区间边界内的期望值为了图表的可读性我们省略了最新的区间。请注意由于持仓回报的分布高度偏斜我们需要考虑到观测值的密度并使区间大小相等。return_values np.sort(total_return_by_type[Holding].values) idxs np.ceil(np.linspace(0, len(return_values) - 1, 21)).astype(int) bins return_values[idxs][:-1] def bin_return(total_return_by_type): classes pd.cut(total_return_by_type[Holding], binsbins, rightTrue) new_level pd.Index(np.array(classes.apply(lambda x: x.right)), namebin_right) return total_return_by_type.vbt.stack_index(new_level, axis0) binned_total_return_by_type bin_return(total_return_by_type) expectancy_by_bin get_expectancy(binned_total_return_by_type, bin_right) expectancy_by_bin.vbt.plot( trace_kwargsdict(modelines), xaxis_titleTotal return of holding, xaxis_tickformat%, yaxis_titleExpectancy ).show_svg()上述图表证实了止损订单行为背后的一般直觉在下跌趋势中止损(SL)和跟踪止损(TS)限制了交易者的损失获利了结(TP)对于希望从价格快速上涨中获利的短期交易者来说是有益的而在增长型市场中持仓策略表现最佳。令人惊讶的是尽管随机出场在横向和牛市市场中表现不佳但它们在熊市中与止损出场相匹配甚至往往表现更佳。这一发现强调了市场条件对交易策略选择的重要性。在不同的市场环境中某些策略可能比其他策略更为有效。例如在熊市中市场普遍下跌随机出场可能因为避免了过早卖出而意外地表现良好。相反在牛市中止损策略可能会因为频繁触发而导致过早退出盈利机会。因此了解市场动态并根据市场条件调整交易策略对于实现最佳交易表现至关重要。