十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python量化交易系统回测:ATR通道突破、参数调优与滑点验证

Python量化交易系统回测:ATR通道突破、参数调优与滑点验证 简介《技术交易系统新概念》为威尔斯·威尔德所著技术分析经典的中文PDF版本面向期货、外汇及股票领域的技术分析初学者与职业交易者意在提供一套可落地的概念、工具和指标帮助读者构建并验证自己的交易系统。压缩包仅含1个PDF文件约2.49MB下载后可直接在电脑或平板上阅读无须额外依赖。全书十章各自独立除第一章基本概念需先行阅读外其余可按兴趣跳读内容覆盖抛物式时间/价格交易系统、波动交易系统、动向指标、动量概念与趋势平衡点、相对强弱指数、趋势·回调、摆动指标短线系统、期货品种选择指标CSI以及资金管理并穿插LOP、HIP、SIP、SIC、SAR等基本工具定义与每日记录表格的使用示例。其中关于参数范围选择与钟型曲线的讨论对理解止蚀位设置和系统稳健性尤有帮助适合需要系统梳理经典指标原理与交易规则的读者反复查阅。目前已有1671人学习。1. 技术交易系统的分水岭不在指标而在规则能不能被复现见过太多这样的场景同一份策略说明两个人各自实现跑同一段历史行情一条曲线年化 28%另一条年化 3%。差异几乎从来不在用了哪个指标而在复权口径、信号生效时点、滑点假设、止损倍数这些没人愿意写进说明书的地方。《技术交易系统新概念》这一类材料真正的价值是把入场、出场、仓位、止损四件事绑成一个可以整体验证的结构而不是再抛出一个神奇公式。适合读下去的有三类人手里有主观规则想量化的交易者负责给策略做技术评审的工程师以及被「回测很美、实盘很惨」反复教育过的开发者。路径很明确——先固定数据口径和信号定义再用可复现的代码把回测跑出来最后处理参数与实盘之间的落差。2. 技术交易系统的数据层与信号定义一个能跑起来的交易系统七成工程量花在数据层和信号定义上剩下三成才是回测和调参。很多所谓的「策略失效」追根溯源是复权口径换过一次、时区混过一次、或者入场价取的是当日收盘价却当成次日开盘价用。把这两层写死之后后面所有讨论才有意义。2.1 原始行情到可计算表字段、时区与复权口径行情文件常见的组织形式是一行一根 K 线字段不多但每个字段都有坑。第一件事是统一时间基准把datetime全部转成同一时区并保证单调递增、无重复第二件事是确定复权口径前复权适合看形态后复权适合算收益两者混用会让长周期均线在除权日出现台阶信号直接失真。字段类型含义常见坑datetimedatetime64[ns]K 线起始时间时区混用跨日信号错位open/high/low/closefloat64同口径价格前复权与后复权混用volumefloat64成交量单位是股还是手不统一adj_factorfloat64复权因子缺失后无法回算真实价haltedbool是否停牌停牌日仍参与均线计算统一口径的做法是入库时同时保留原始价与复权因子计算指标用后复权价展示图表再换回前复权。停牌日要么剔除要么用零成交标记后跳过绝不能留一根与前一日完全相同的假 K 线那会让真实波幅算成零ATR 被系统性低估。2.2 把交易规则写成可验证的指标2.2.1 ATR 的真实波幅与 Wilder 平滑真实波幅TR取三者的最大值当日最高减最低、最高减前收的绝对值、最低减前收的绝对值。这一步的意义是把跳空缺口纳入波动度量而不是只看日内振幅。平滑方式上经典做法是 Wilder 平滑它等价于alpha 1/n的指数加权而不是简单移动平均——两者在趋势段差别不大在震荡段会明显改变止损宽度。import numpy as np import pandas as pd def true_range(df: pd.DataFrame) - pd.Series: prev_close df[close].shift(1) return pd.concat([ df[high] - df[low], (df[high] - prev_close).abs(), (df[low] - prev_close).abs(), ], axis1).max(axis1) def atr(df: pd.DataFrame, n: int 14) - pd.Series: tr true_range(df) # Wilder 平滑 alpha 为 1/n 的 EMAadjustFalse 保证递推口径一致 return tr.ewm(alpha1.0 / n, adjustFalse, min_periodsn).mean()n14是几十年流传下来的默认值改到 10 会让止损更紧、换手更高改到 21 则明显迟钝。min_periodsn保证前 n-1 根不产生有效值避免用半截数据算出的 ATR 去开仓。adjustFalse是必须的adjustTrue会在序列前段引入偏差导致回测与实盘递推结果不一致。2.2.2 通道突破信号与持仓状态机有了 ATR 之后用滚动高低点构造通道再用一个显式状态机管理持仓比用一堆布尔条件与运算更不容易出错。关键细节是通道上轨要shift(1)排除当日自身否则当日高点必然包含当日收盘价突破条件永远成立。def build_signal(df, n_atr14, n_channel40, k_stop2.0): out df.copy() out[atr] atr(out, n_atr) # shift(1) 让通道只由历史 K 线构成避免自我确认 out[upper] out[high].rolling(n_channel).max().shift(1) out[lower] out[low].rolling(n_channel).min().shift(1) entry out[close] out[upper] exit_ out[close] out[lower] pos, sig, stop 0, [], np.nan for i in range(len(out)): c, a out[close].iloc[i], out[atr].iloc[i] if pos 0 and entry.iloc[i] and not np.isnan(a): pos, stop 1, c - k_stop * a # 以 ATR 倍数设初始止损 elif pos 1: stop max(stop, c - k_stop * a) # 跟踪止损只上移不下移 if exit_.iloc[i] or c stop: pos 0 sig.append(pos) out[signal] sig return outk_stop是 ATR 倍数2.0意味着止损宽度约为两倍日均真实波幅n_channel40对应约两个月的交易日。三个参数都不是拍脑袋定的它们的取值区间和敏感性放在第 4 章展开。2.3 信号表的最小字段与一致性校验信号生成之后不要急着回测先做一次结构校验。下面这张表是我习惯固化的最小字段集合缺一个都会在后面某处咬人。字段类型含义校验规则signalint81 持多、0 空仓取值只能是 0 或 1atrfloat64当期 ATR非负且非 NaN 才能开仓stopfloat64当期止损价持仓期内单调不减turnoverfloat64本期换手等于 signal 差分绝对值def validate(sig: pd.DataFrame) - None: assert sig[signal].isin([0, 1]).all(), 信号出现非法取值 assert sig.index.is_monotonic_increasing, 时间索引必须单调递增 # 检查未来函数任何一行的信号都不允许依赖后续数据 leaked sig[signal].corr(sig[close].pct_change().shift(-1).fillna(0)) assert abs(leaked) 0.5, f疑似未来函数相关系数 {leaked:.3f}最后那段相关性检查是个粗糙但有效的烟雾报警器如果当期信号和下一期收益的相关性高得离谱基本可以断定某处用了未来信息。正常策略这个相关系数通常在 0.05 到 0.2 之间超过 0.5 要立刻回头查shift方向。提示把校验函数写成回测入口的强制断言而不是可选调用。参数扫描会跑成百上千次任何一次静默的口径污染都会污染整张结果表。3. 用 Python 把技术交易系统回测跑通回测代码最容易犯的错不是写错公式而是把「信号出现」和「可以成交」当成同一时刻。真实的链路是T 日收盘后算出信号T1 开盘或下一根 K 线才能成交中间的滑点和手续费都要扣。把这条时间链条写进代码是回测能不能信的第一道门槛。3.1 环境与数据准备的最小命令依赖保持精简够用就行装太多包会让复现环境变成负担。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install pandas2.0 numpy pyarrow matplotlib # 把行情落成 parquet读取速度比 CSV 快一个量级且保留 dtype python -c import pandas as pd df pd.read_csv(bars.csv, parse_dates[datetime]) df df.sort_values(datetime).drop_duplicates(datetime) df.to_parquet(bars.parquet, indexFalse) print(df.dtypes, len(df)) 用 parquet 而不直接用 CSV是因为回测过程中反复读取时CSV 每次都要重新推断类型浮点列可能被解析成 objectpct_change会直接报错或算出错误结果。落盘时打印 dtype 是个便宜的习惯能提前挡掉这类问题。3.2 向量化回测从信号到净值曲线能用向量化就不用循环除了快更重要的是逻辑一眼能看清。整段回测只有五个动作延迟信号、算收益、算换手、扣成本、累乘净值。def run_backtest(df, cost_bps5.0, slippage_bps2.0, init_cash1.0): out df.copy() # 信号在 T 日收盘生成T1 才可成交shift(1) 是防未来函数的关键 out[pos] out[signal].shift(1).fillna(0) out[ret] out[close].pct_change().fillna(0) # 持仓变化即为交易开仓和平仓各算一次换手 out[turnover] out[pos].diff().abs().fillna(out[pos].abs()) fee (cost_bps slippage_bps) / 10000.0 out[net_ret] out[pos] * out[ret] - out[turnover] * fee out[equity] (1 out[net_ret]).cumprod() * init_cash return outcost_bps是佣金加印花税的单边基点slippage_bps是滑点A 股日内流动性好的标的合计给到 5 到 10 个基点比较接近实际。turnover用差分绝对值而不是布尔判断是因为策略可能在空仓与持多之间来回切换用布尔统计会漏掉平仓那一次成本。equity用累乘而不是累加避免不同价格水平下的收益被错误加权。3.3 交易成本、滑点与撮合时点的建模成本建模的粒度决定了回测可信度的上限。下面这张表是几个常见成本项的合理取值可以按自己交易的品种调整。成本项典型取值建模方式影响方向佣金2~3 bps按换手单边扣高频策略杀伤最大印花税5 bps仅卖出方向扣换手越高越吃亏滑点2~10 bps与波动率挂钩突破策略偏大冲击成本1~5 bps按仓位规模线性大资金必须加撮合时点的选择上保守做法是一律用下一根 K 线的开盘价成交乐观做法用当根收盘价。两者在小资金短周期上差距不大但在突破类策略上能差出十几个百分点的年化。我一般两种都跑一遍看结果对时点的敏感度如果换个时点结论就反转说明策略本身太脆弱。注意滑点不要设成固定常数。突破策略在波动放大时成交质量最差把滑点和 ATR 挂钩例如slippage base 0.05 * atr / close更接近真实也能顺便过滤掉那些靠低滑点假设撑起来的策略。3.4 回测结果自检五个必须看的指标净值曲线好看不代表结论可用至少要把下面五个数字一起看。def summarize(bt, periods_per_year244): n len(bt) ann bt[equity].iloc[-1] ** (periods_per_year / n) - 1 dd bt[equity] / bt[equity].cummax() - 1 return pd.Series({ 年化收益: ann, 最大回撤: dd.min(), 换手次数: bt.loc[bt[turnover] 0, turnover].sum(), 卡玛比率: ann / abs(dd.min()) if dd.min() ! 0 else np.nan, 持仓占比: bt[pos].mean(), })periods_per_year按品种调整股票约 244 个交易日商品期货约 250。卡玛比率低于 0.5 的策略实盘基本熬不过一次像样的回撤持仓占比长期低于 0.2 说明信号过于稀疏交易成本占比会异常高换手次数要和资金规模一起看同样的换手在小资金上是优势在大资金上就是成本黑洞。4. 技术交易系统的参数调优与过拟合边界参数调优是整条链路上最容易自我欺骗的环节。几百组参数里总能找到一组曲线惊人的组合但那往往是噪声的胜利。判断标准不是最优参数有多好而是最优参数周围一片区域是否都还不错。4.1 三个必调参数与它们的物理含义参数建议区间物理含义调大的效果n_atr10~21波动度量窗口止损变宽换手下降n_channel20~60突破确认窗口信号变少单笔盈亏比上升k_stop1.5~3.0止损的 ATR 倍数抗震荡增强单笔亏损放大这三个参数不是独立的。n_atr和k_stop的乘积才决定实际止损宽度n_channel决定入场频率。调参时应该固定一到两个、只动一个否则很容易把两组互相抵消的参数组合当成新发现。我通常先把n_channel定在 40 附近因为它在大部分品种上对应的是两个月左右的记忆窗口符合趋势跟踪的基本假设。4.2 网格搜索代码把参数扫描跑成一张表扫描本身不难难的是把结果组织成能看出「平原」而不是「尖峰」的形状。from itertools import product def param_scan(df, n_atr_list, n_ch_list, k_stop_list): rows [] for n_atr, n_ch, k_stop in product(n_atr_list, n_ch_list, k_stop_list): sig build_signal(df, n_atrn_atr, n_channeln_ch, k_stopk_stop) bt run_backtest(sig) s summarize(bt).to_dict() rows.append({n_atr: n_atr, n_channel: n_ch, k_stop: k_stop, **s}) res pd.DataFrame(rows) # 同时过滤掉交易次数过少的结果避免小样本的偶然高收益 return res[res[换手次数] 30].sort_values(卡玛比率, ascendingFalse) res param_scan(bars, [10, 14, 21], [20, 30, 40, 50, 60], [1.5, 2.0, 2.5, 3.0]) print(res.head(10)) print(res.pivot_table(indexn_channel, columnsk_stop, values卡玛比率))换手次数 30这条过滤很关键。样本内只有十几次交易的组合统计上完全没有意义但它们的卡玛比率往往高得诱人。透视表那一步是把二维参数面画成一张网格看最优值是否落在一片同色区域里落单的孤岛式最优几乎必然过拟合。4.3 过拟合识别参数平原与样本内外衰减把数据按时间切成两段前 70% 做样本内后 30% 做样本外然后比较同一组参数的表现。判定维度健康区间危险信号样本外/样本内年化0.5~1.0低于 0.3最优参数邻域标准差小于均值的 30%邻域崩塌参数平移后卡玛变化小于 20%平移即失效样本外最大回撤不高于样本内 1.5 倍明显放大样本外收益低于样本内是必然的比值落在 0.5 到 1.0 之间都算正常低于 0.3 说明参数确实在拟合噪声。参数邻域标准差这一项最容易被忽略把最优的k_stop2.0改成 1.8 或 2.2如果卡玛比率立刻掉一半这组参数就是踩在尖峰上实盘里市场结构一变化就会失效。4.4 走样验证的最小实现比样本内外切分更严格的做法是滚动前推用一段窗口调参紧接其后的一段做验证然后整体前移一格重复多次。def walk_forward(df, train750, test250, step250): results, start [], 0 while start train test len(df): tr df.iloc[start:start train] te df.iloc[start train:start train test] best param_scan(tr, [10, 14, 21], [20, 40, 60], [1.5, 2.0, 3.0]).iloc[0] bt run_backtest(build_signal(te, int(best.n_atr), int(best.n_channel), best.k_stop)) results.append({ test_start: te.index[0], test_ret: bt[equity].iloc[-1] - 1, params: (best.n_atr, best.n_channel, best.k_stop), }) start step return pd.DataFrame(results)train750约三年、test250约一年step决定前推的密度。真正值得看的是test_ret的序列是否稳定而不是平均值。如果只有某一两段验证期贡献了全部收益说明策略依赖特定市场状态需要额外加一层状态过滤比如用 ADX 或波动率分位决定是否允许开仓。5. 实盘落地技术交易系统的信号对齐与滑点验证回测和实盘之间的落差八成来自信号口径不一致而不是市场变了。上线前最有价值的一件事是拿一段已经过去的实盘区间用同一份行情重新跑一遍回测然后逐根 K 线比对两边的信号。5.1 回测信号与实盘信号的对齐脚本def align_signals(bt_signal: pd.Series, live_signal: pd.Series) - pd.DataFrame: cmp pd.concat([bt_signal.rename(backtest), live_signal.rename(live)], axis1) # 缺失值不参与比较避免停牌日产生假报警 cmp cmp.dropna(howany) cmp[diff] cmp[backtest].astype(int) ! cmp[live].astype(int) return cmp[cmp[diff]] diff align_signals(bt[signal], live_log[signal]) print(f不一致根数{len(diff)}占比 {len(diff) / len(bt):.2%})不一致占比低于 0.5% 通常可以接受多半来自开盘瞬间数据源的定价差异超过 2% 就必须逐条归因常见原因有三个复权因子在实盘数据源里更新时机不同、实盘用的是最新价而回测用的是收盘价、以及实盘程序对NaN的处理默认为空仓而回测默认为持有。dropna(howany)是为了排除停牌日否则每个停牌日都会报一次不一致。5.2 把参数、信号与代码版本绑在一起调参调到最后最容易丢失的信息是「当时那组结果是用哪份数据、哪版代码跑出来的」。把三者写进一个版本文件成本极低。# 每次上线前生成一份快照 python -c import json, hashlib, pandas as pd snap { n_atr: 14, n_channel: 40, k_stop: 2.0, bars_md5: hashlib.md5(open(bars.parquet,rb).read()).hexdigest(), rows: len(pd.read_parquet(bars.parquet)), } json.dump(snap, open(strategy_version.json,w), indent2) bars_md5是整份行情文件的指纹只要数据被追加或修正过指纹就会变能立刻发现「同一组参数为什么结果不一样」这类问题。rows记录行数用于快速确认数据区间没被截断。上线后每次发现实盘与回测偏离第一步就是核对这个文件里三个字段是否与当初一致——多数离奇问题在这一步就能定位剩下的才轮到策略本身的失效分析。本文还有配套的精品资源点击获取
返回列表