简介:《技术交易系统新概念》为威尔斯·威尔德所著技术分析经典的中文PDF版本,面向期货、外汇及股票领域的技术分析初学者与职业交易者,意在提供一套可落地的概念、工具和指标,帮助读者构建并验证自己的交易系统。压缩包仅含1个PDF文件,约2.49MB,下载后可直接在电脑或平板上阅读,无须额外依赖。全书十章各自独立,除第一章基本概念需先行阅读外,其余可按兴趣跳读;内容覆盖抛物式时间/价格交易系统、波动交易系统、动向指标、动量概念与趋势平衡点、相对强弱指数、趋势·回调、摆动指标短线系统、期货品种选择指标CSI以及资金管理,并穿插LOP、HIP、SIP、SIC、SAR等基本工具定义与每日记录表格的使用示例。其中关于参数范围选择与钟型曲线的讨论,对理解止蚀位设置和系统稳健性尤有帮助,适合需要系统梳理经典指标原理与交易规则的读者反复查阅。目前已有1671人学习。
1. 技术交易系统的分水岭不在指标,而在规则能不能被复现
见过太多这样的场景:同一份策略说明,两个人各自实现,跑同一段历史行情,一条曲线年化 28%,另一条年化 3%。差异几乎从来不在用了哪个指标,而在复权口径、信号生效时点、滑点假设、止损倍数这些没人愿意写进说明书的地方。《技术交易系统新概念》这一类材料真正的价值,是把入场、出场、仓位、止损四件事绑成一个可以整体验证的结构,而不是再抛出一个神奇公式。适合读下去的有三类人:手里有主观规则想量化的交易者,负责给策略做技术评审的工程师,以及被「回测很美、实盘很惨」反复教育过的开发者。路径很明确——先固定数据口径和信号定义,再用可复现的代码把回测跑出来,最后处理参数与实盘之间的落差。
2. 技术交易系统的数据层与信号定义
一个能跑起来的交易系统,七成工程量花在数据层和信号定义上,剩下三成才是回测和调参。很多所谓的「策略失效」,追根溯源是复权口径换过一次、时区混过一次、或者入场价取的是当日收盘价却当成次日开盘价用。把这两层写死之后,后面所有讨论才有意义。
2.1 原始行情到可计算表:字段、时区与复权口径
行情文件常见的组织形式是一行一根 K 线,字段不多,但每个字段都有坑。第一件事是统一时间基准,把datetime全部转成同一时区并保证单调递增、无重复;第二件事是确定复权口径,前复权适合看形态,后复权适合算收益,两者混用会让长周期均线在除权日出现台阶,信号直接失真。
| 字段 | 类型 | 含义 | 常见坑 |
|---|---|---|---|
| datetime | datetime64[ns] | K 线起始时间 | 时区混用,跨日信号错位 |
| open/high/low/close | float64 | 同口径价格 | 前复权与后复权混用 |
| volume | float64 | 成交量 | 单位是股还是手不统一 |
| adj_factor | float64 | 复权因子 | 缺失后无法回算真实价 |
| halted | bool | 是否停牌 | 停牌日仍参与均线计算 |
统一口径的做法是入库时同时保留原始价与复权因子,计算指标用后复权价,展示图表再换回前复权。停牌日要么剔除,要么用零成交标记后跳过,绝不能留一根与前一日完全相同的假 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(), ], axis=1).max(axis=1) def atr(df: pd.DataFrame, n: int = 14) -> pd.Series: tr = true_range(df) # Wilder 平滑 = alpha 为 1/n 的 EMA,adjust=False 保证递推口径一致 return tr.ewm(alpha=1.0 / n, adjust=False, min_periods=n).mean()n=14是几十年流传下来的默认值,改到 10 会让止损更紧、换手更高,改到 21 则明显迟钝。min_periods=n保证前 n-1 根不产生有效值,避免用半截数据算出的 ATR 去开仓。adjust=False是必须的,adjust=True会在序列前段引入偏差,导致回测与实盘递推结果不一致。
2.2.2 通道突破信号与持仓状态机
有了 ATR 之后,用滚动高低点构造通道,再用一个显式状态机管理持仓,比用一堆布尔条件与运算更不容易出错。关键细节是通道上轨要shift(1),排除当日自身,否则当日高点必然包含当日收盘价,突破条件永远成立。
def build_signal(df, n_atr=14, n_channel=40, k_stop=2.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_channel=40对应约两个月的交易日。三个参数都不是拍脑袋定的,它们的取值区间和敏感性放在第 4 章展开。
2.3 信号表的最小字段与一致性校验
信号生成之后不要急着回测,先做一次结构校验。下面这张表是我习惯固化的最小字段集合,缺一个都会在后面某处咬人。
| 字段 | 类型 | 含义 | 校验规则 |
|---|---|---|---|
| signal | int8 | 1 持多、0 空仓 | 取值只能是 0 或 1 |
| atr | float64 | 当期 ATR | 非负且非 NaN 才能开仓 |
| stop | float64 | 当期止损价 | 持仓期内单调不减 |
| turnover | float64 | 本期换手 | 等于 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 日收盘后算出信号,T+1 开盘或下一根 K 线才能成交,中间的滑点和手续费都要扣。把这条时间链条写进代码,是回测能不能信的第一道门槛。
3.1 环境与数据准备的最小命令
依赖保持精简,够用就行,装太多包会让复现环境变成负担。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install "pandas>=2.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', index=False) print(df.dtypes, len(df)) "用 parquet 而不直接用 CSV,是因为回测过程中反复读取时,CSV 每次都要重新推断类型,浮点列可能被解析成 object,pct_change会直接报错或算出错误结果。落盘时打印 dtype 是个便宜的习惯,能提前挡掉这类问题。
3.2 向量化回测:从信号到净值曲线
能用向量化就不用循环,除了快,更重要的是逻辑一眼能看清。整段回测只有五个动作:延迟信号、算收益、算换手、扣成本、累乘净值。
def run_backtest(df, cost_bps=5.0, slippage_bps=2.0, init_cash=1.0): out = df.copy() # 信号在 T 日收盘生成,T+1 才可成交,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_year=244): 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_atr | 10~21 | 波动度量窗口 | 止损变宽,换手下降 |
| n_channel | 20~60 | 突破确认窗口 | 信号变少,单笔盈亏比上升 |
| k_stop | 1.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_atr=n_atr, n_channel=n_ch, k_stop=k_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("卡玛比率", ascending=False) 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(index="n_channel", columns="k_stop", values="卡玛比率"))换手次数 >= 30这条过滤很关键。样本内只有十几次交易的组合,统计上完全没有意义,但它们的卡玛比率往往高得诱人。透视表那一步是把二维参数面画成一张网格,看最优值是否落在一片同色区域里,落单的孤岛式最优几乎必然过拟合。
4.3 过拟合识别:参数平原与样本内外衰减
把数据按时间切成两段,前 70% 做样本内,后 30% 做样本外,然后比较同一组参数的表现。
| 判定维度 | 健康区间 | 危险信号 |
|---|---|---|
| 样本外/样本内年化 | 0.5~1.0 | 低于 0.3 |
| 最优参数邻域标准差 | 小于均值的 30% | 邻域崩塌 |
| 参数平移后卡玛变化 | 小于 20% | 平移即失效 |
| 样本外最大回撤 | 不高于样本内 1.5 倍 | 明显放大 |
样本外收益低于样本内是必然的,比值落在 0.5 到 1.0 之间都算正常;低于 0.3 说明参数确实在拟合噪声。参数邻域标准差这一项最容易被忽略:把最优的k_stop=2.0改成 1.8 或 2.2,如果卡玛比率立刻掉一半,这组参数就是踩在尖峰上,实盘里市场结构一变化就会失效。
4.4 走样验证的最小实现
比样本内外切分更严格的做法是滚动前推:用一段窗口调参,紧接其后的一段做验证,然后整体前移一格,重复多次。
def walk_forward(df, train=750, test=250, step=250): 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)train=750约三年、test=250约一年,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")], axis=1) # 缺失值不参与比较,避免停牌日产生假报警 cmp = cmp.dropna(how="any") 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(how="any")是为了排除停牌日,否则每个停牌日都会报一次不一致。
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'), indent=2) "bars_md5是整份行情文件的指纹,只要数据被追加或修正过,指纹就会变,能立刻发现「同一组参数为什么结果不一样」这类问题。rows记录行数,用于快速确认数据区间没被截断。上线后每次发现实盘与回测偏离,第一步就是核对这个文件里三个字段是否与当初一致——多数离奇问题在这一步就能定位,剩下的才轮到策略本身的失效分析。
本文还有配套的精品资源,点击获取