| 本篇位置 | 第七部分「策略原型」第一篇。前面六部分在造工具和检验工具,从这一篇开始讲被检验的东西 |
| 用到的数据 | BTC 现货日线、SPY、AAPL(全部沿用前面几篇,不需要新下载) |
| 动手 | 新模块 talab.trend:donchian、breakouts、follow_through、TurtlePlan + turtle(含 2N 止损、按 N 定仓位、金字塔加仓、多空)、r_profile、contribution、convexity,附 14 个测试 |
| 读完你能 | 完整实现一套 1983 年的海龟系统,并说清它为什么在七成信号都是假的情况下还能赚钱 |
一、先做一个决定
规则只有一条:**价格碰到 20 日通道的上轨就买;跌破 10 日通道的下轨就走。**进场价往下 2 个 ATR 放一个止损。
下面是 BTC 从 2022 年 9 月到 2023 年 10 月的全部十次信号:
进场日 第一个单位的价 出场日 出场价 原因 R 根数
2022-09-12 21900.00 2022-09-13 20046.573 止损 -1.000 1
2022-09-13 22488.00 2022-09-14 20173.620 止损 -1.247 1
2022-10-26 20456.60 2022-11-08 19089.497 止损 -1.000 13
2022-12-05 17324.00 2022-12-16 16678.830 通道出场 -0.501 11
2023-01-08 17061.27 2023-02-09 22500.000 通道出场 7.671 32
2023-02-15 24255.00 2023-02-24 22881.787 止损 -1.000 9
2023-03-14 24599.59 2023-04-20 28170.000 通道出场 1.808 37
2023-05-29 28331.42 2023-06-02 26548.976 止损 -1.000 4
2023-06-20 27835.51 2023-07-17 29701.020 通道出场 1.058 27
2023-10-01 27483.57 2023-10-11 26954.090 通道出场 -0.425 10
2022-09-12 到 2023-10-11,十三个月,十次突破:3 次走出去了、7 次是假的
合计 +4.36R —— 赚的三笔 +10.54R,亏的七笔 -6.17R
而这 +4.36R 里有 +7.67R 来自 2023-01-08 那一笔;把它拿掉,另外九笔合计 -3.31R
现在是 2023-10-16,BTC 又一次碰到 20 日通道上轨 28,580.00。买不买?

**十三个月,十次突破,七次是假的。**十笔加起来 +4.36R——而这 +4.36R 里有 +7.67R 来自 2023 年 1 月那一笔。把那一笔拿掉,另外九笔合计 −3.31R。
现在是 2023-10-16,BTC 又一次碰到 20 日通道上轨 28,580。
买不买?
二、打个比方:早期投资
趋势跟随的收益结构,和一支早期投资基金一模一样。
| 早期投资 | 趋势跟随 |
|---|---|
| 投 30 家,20 家会死 | 十次突破,七次是假的 |
| 死掉的那家,最多亏掉投进去的钱 | 2N 止损:最多亏 1R |
| 一家上市,把整只基金赚回来 | 一笔 +10R,把前面十次的亏损全填上 |
| 不知道哪一家会成,所以每一家都要投 | 不知道哪次突破是真的,所以每次都要进 |
| 跑得好的那家追加投资 | 金字塔加仓:顺着走就再加一个单位 |
| 不会因为「已经涨了三倍」就卖掉 | 不设固定止盈(第 23 篇已经量过) |
| 基金的全部收益集中在极少数几笔上 | 拿掉最赚的 15% 的交易,九年收益归零 |
这个比方里有一件事特别关键,它也是这一篇里最难接受的一条:
绝大多数交易都是亏的,而且你没法提前知道哪一笔不是。
一支基金如果只投「看起来最稳的那五家」,它的收益不会是原来的六分之一,而是零——因为那五家里很可能一家都不是最后那个赢家。趋势跟随也一样:放过任何一次信号,都是在赌你能分辨真假,而这一篇后面会证明你分辨不了。
三、唐奇安通道:一条画在前面的线
唐奇安通道就是两条线:过去 n 根的最高价和过去 n 根的最低价。
def donchian(high: pd.Series, low: pd.Series, n: int = 20) -> pd.DataFrame:
"""唐奇安通道:过去 n 根的最高价、最低价,以及两者的中点。
⚠️ 两条轨都 `shift(1)`:**第 i 根上用的是第 i−1 根为止的最高价**。
不推迟的话,「创 20 日新高」这个条件会永远成立(今天的最高价当然是包含今天的最高价之一)。
"""
return pd.DataFrame({
"上轨": high.rolling(n).max().shift(1),
"下轨": low.rolling(n).min().shift(1),
}).assign(中轨=lambda t: (t["上轨"] + t["下轨"]) / 2)
⚠️ 两条轨都要 shift(1)。不推迟的话,「创 n 日新高」这个条件永远成立——今天的最高价当然是包含今天在内的最高价之一。测试里专门钉了这一条:忘了 shift 的写法在一路上涨的数据上一个信号都给不出来。
3.1 假突破到底有多假
先不考虑止损和出场,只问一件最朴素的事:价格突破通道之后,二十根 K 线之后还在突破价上方吗?
rows = []
for name, (frame, periods) in MARKETS.items():
for n in (20, 55):
table = T.breakouts(frame, n, horizon=20)
stats = T.follow_through(table)
rows.append({"标的": name, "通道": f"{n} 日", "突破次数": int(stats["突破次数"]),
"假突破比例": stats["假突破比例"], "真的那些平均涨": stats["真的那些平均涨"],
"假的那些平均跌": stats["假的那些平均跌"], "全部平均": stats["全部平均"],
"最大顺势中位": stats["最大顺势中位"], "最大逆势中位": stats["最大逆势中位"]})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 通道 突破次数 假突破比例 真的那些平均涨 假的那些平均跌 全部平均 最大顺势中位 最大逆势中位
SPY 20 日 260 0.2885 0.0267 -0.0324 0.0097 0.0252 -0.0167
SPY 55 日 205 0.3220 0.0254 -0.0317 0.0070 0.0231 -0.0161
AAPL 20 日 206 0.3107 0.0729 -0.0554 0.0343 0.0597 -0.0240
AAPL 55 日 149 0.3289 0.0721 -0.0599 0.0287 0.0514 -0.0243
BTC 20 日 218 0.4174 0.1765 -0.0904 0.0672 0.0794 -0.0680
BTC 55 日 144 0.3264 0.1794 -0.0897 0.0955 0.0938 -0.0578

这已经是最宽松的口径了——只要二十根之后还比突破价高一分钱就算「真的」。结果:
| 标的 | 20 日通道突破次数 | 假突破比例 | 真的那些平均涨 | 假的那些平均跌 |
|---|---|---|---|---|
| SPY | 260 | 28.85% | +2.67% | −3.24% |
| AAPL | 206 | 31.07% | +7.29% | −5.54% |
| BTC | 218 | 41.74% | +17.65% | −9.04% |
⚠️ 注意这里的 29%–42% 和开头那句「十次里七次是假的」不是同一个数。差别在于:上面只问「二十根之后在哪」,而开头那十次是真的按海龟规则交易的结果——中间有 2N 止损,很多最终走出去的突破在半路就被止损打掉了。**止损把「假突破」的比例从四成推到了近七成,同时把每次假突破的代价从「跌 9%」压到了「亏 1R」。**这是同一枚硬币的两面,后面第八节会算这笔账。
真正值得看的是右边那一列:**BTC 上真的那些平均涨 17.65%,假的那些平均跌 9.04%。**赢的时候赢得比输的时候多——趋势跟随全部的生计就在这个不对称上。
四、揭晓:第十一次
2023-10-16 买在 28,580.00,2023-12-29 按 通道出场 卖在 41,637.60,拿了 74 根 K 线
R = +10.66,10 万美元的账户上赚了 17,392
这一笔在全部 67 笔里排第 2
同期 BTC:28,501 → 42,067
如果只有这一笔没做:九年合计从 76.2R 变成 65.6R

**+10.66R。**这一笔在九年 67 笔里排第 2。十三个月的忍耐,一次性还清。
左边那张图是刚才那笔:一个单位,进场 28,580,74 根 K 线之后按 10 日通道下轨出在 41,637。右边是同一笔,按海龟原版的金字塔规则加到四个单位——R 从 +10.66 变成 +36.43。加仓怎么加、为什么不是无脑放大风险,第九节专门讲。
而最后那一行才是这一篇真正的主题:
如果只有这一笔没做,九年合计从 76.2R 变成 65.6R。
一笔交易,九年收益的 14%。而在做出这个决定的那一刻,你能掌握的全部信息就是:过去十次里有七次是假的。
五、海龟法则
1983 年,Richard Dennis 和 Bill Eckhardt 打了个赌:交易能不能教会。他们登广告招人、培训两周,把一套写死的规则交出去。这就是海龟法则,而它的核心可以完整地装进一个 dataclass:
@dataclass
class TurtlePlan:
"""海龟交易法则,1983 年那一版的核心设定。
原版有两套系统:系统一是 20 日突破进、10 日突破出,系统二是 55 日进、20 日出。
这里用同一个 dataclass 表示,改两个数字就换系统。
"""
entry: int = 20 # 进场:突破几日通道
exit: int = 10 # 出场:反向几日通道
atr_period: int = 20 # N(原版就叫 N,其实是 20 日 ATR)
stop_atr: float = 2.0 # 止损放在进场价外几个 N
risk: float = 0.01 # 一个「单位」冒账户的百分之几
max_units: int = 4 # 最多加到几个单位
add_atr: float = 0.5 # 每走几个 N 加一个单位
side: str = "long" # long / short / both
fill: str = "touch" # touch:碰到通道就成交;next_open:等下一根开盘
fee_rate: float = 0.0 # 单边费率(第 28 篇)
def __post_init__(self):
if self.side not in SIDES:
raise ValueError(f"side 只能是 {SIDES} 之一,收到 {self.side!r}")
if self.fill not in FILLS:
raise ValueError(f"fill 只能是 {FILLS} 之一,收到 {self.fill!r}")
if self.entry < 2 or self.exit < 2 or self.atr_period < 2:
raise ValueError("三个窗口都要至少是 2")
if self.max_units < 1:
raise ValueError("至少要允许一个单位")
def describe(self) -> pd.Series:
return pd.Series({
"进场": f"突破 {self.entry} 日通道",
"出场": f"反向突破 {self.exit} 日通道",
"N": f"{self.atr_period} 日 ATR",
"初始止损": f"{self.stop_atr} 个 N",
"一个单位的风险": f"账户的 {self.risk:.1%}",
"加仓": f"每走 {self.add_atr} 个 N 加一个单位,最多 {self.max_units} 个",
"方向": {"long": "只做多", "short": "只做空", "both": "多空都做"}[self.side],
"成交": {"touch": "碰到通道就成交", "next_open": "等下一根开盘"}[self.fill],
})
两套系统,改两个数字就换:
进场 突破 20 日通道
出场 反向突破 10 日通道
N 20 日 ATR
初始止损 2.0 个 N
一个单位的风险 账户的 1.0%
加仓 每走 0.5 个 N 加一个单位,最多 4 个
方向 只做多
成交 碰到通道就成交
六条规则,一条一条说:
- 进场:碰到 20 日通道上轨就买。不看基本面,不看别的指标,不等确认。
- 出场:跌破 10 日通道下轨就走。出场窗口比进场窗口短,这样才能在趋势转向时比进场更快地反应。
- N:20 日 ATR。它是这套系统的度量衡——止损、仓位、加仓间距全部用 N 表示,所以同一套参数能直接搬到波动率差十倍的另一个市场上。
- 止损:进场价往下 2 个 N。
- 仓位:一个「单位」冒账户 1% 的风险。于是单位数量 = 账户 × 1% ÷ (2N)——这正是第 26 篇的固定风险仓位。
- 加仓:价格每顺着走 0.5 个 N 就加一个单位,最多四个;每加一次,把全部止损上移到最新那个单位往下 2N。
一套 1983 年定下来的参数,这一篇要拿 2016–2026 年的数据去跑。这是这门课里最干净的一次样本外检验——参数比数据早了四十年,不可能是在这份数据上挑出来的(第 30 篇那条尖峰再也不可能出现在这里)。
六、时钟规矩的一个例外
第 27 篇立了一条规矩:**收盘算出来的信号,最早下一根成交。**而这一篇的进场是「盘中碰到通道就买」,看起来像在违规。
不是。判据始终是同一句话:这个数,在那一刻能不能算出来?
- 均线、RSI、「收盘创 20 日新高」——都要等当根收盘才知道,所以只能下一根成交。
- 通道那条线是用前面 20 根的最高价算的,开盘之前就已经画好了。价格在这一根里碰到它,是当时就看得见的事。
话虽如此,一个假设值不值得信,要拿数据说话。把成交方式换成「等下一根开盘」再跑一遍:
rows = []
for name, (frame, periods) in MARKETS.items():
for how in ("touch", "next_open"):
result = T.turtle(frame, T.TurtlePlan(fill=how))
curve = result["资金曲线"]
rows.append({"标的": name, "成交方式": {"touch": "碰到通道就成交", "next_open": "等下一根开盘"}[how],
"年化": RP.annual_return(curve, periods),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods),
"最大回撤": RP.max_drawdown(curve), "笔数": len(result["交易"]),
"合计 R": result["交易"]["R"].sum(), "记账误差": result["记账误差"]})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 成交方式 年化 夏普 最大回撤 笔数 合计 R 记账误差
SPY 碰到通道就成交 0.0468 0.6822 -0.1711 67 47.5013 0.0
SPY 等下一根开盘 0.0467 0.6788 -0.0955 67 47.1247 0.0
AAPL 碰到通道就成交 0.1690 1.2604 -0.1187 59 166.8484 0.0
AAPL 等下一根开盘 0.1344 1.0549 -0.2176 59 136.4348 0.0
BTC 碰到通道就成交 0.3160 1.3669 -0.2156 70 295.9295 0.0
BTC 等下一根开盘 0.2980 1.3508 -0.2276 64 282.0023 0.0
| 标的 | 碰到通道就成交 | 等下一根开盘 | 差 |
|---|---|---|---|
| SPY | 4.68% | 4.67% | −0.01 个百分点 |
| AAPL | 16.90% | 13.44% | −3.46 个百分点 |
| BTC | 31.60% | 29.80% | −1.80 个百分点 |
**这个假设值 0 到 3.5 个百分点,不是 0,但也远不是收益的来源。**如果晚一根就塌掉一大半,那说明这条策略赚的是「那一瞬间的价格」而不是趋势,该扔掉;现在它没塌,说明赚的确实是后面那段路。
⚠️ 记账误差那一列全程是 0——现金 + 持仓 × 价格 = 权益,一分不差(第 27 篇那条规矩)。
七、三个标的上跑一遍
runs, rows = {}, []
for name, (frame, periods) in MARKETS.items():
result = T.turtle(frame, T.TurtlePlan())
runs[name] = result
curve, trades = result["资金曲线"], result["交易"]
rows.append({"标的": name, "年化": RP.annual_return(curve, periods),
"最大回撤": RP.max_drawdown(curve),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods),
"卡玛": RP.calmar(curve, periods), "笔数": len(trades),
"在场比例": float(trades["根数"].sum()) / len(curve),
"期末": float(curve.iloc[-1]), "记账误差": result["记账误差"]})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 年化 最大回撤 夏普 卡玛 笔数 在场比例 期末 记账误差
SPY 0.0468 -0.1711 0.6822 0.2737 67 0.5355 157179.1026 0.0
AAPL 0.1690 -0.1187 1.2604 1.4234 59 0.5016 468098.0406 0.0
BTC 0.3160 -0.2156 1.3669 1.4657 70 0.3785 1179570.7508 0.0
| 标的 | 年化 | 最大回撤 | 夏普 | 卡玛 | 笔数 | 在场比例 |
|---|---|---|---|---|---|---|
| SPY | 4.68% | −17.11% | 0.682 | 0.274 | 67 | 53.6% |
| AAPL | 16.90% | −11.87% | 1.260 | 1.423 | 59 | 50.2% |
| BTC | 31.60% | −21.56% | 1.367 | 1.466 | 70 | 37.9% |
先记住两件事,后面都要用:
- **在场比例只有 38%–54%。**一多半时间这套系统是空仓的——这是它回撤浅的全部原因。BTC 买入持有的最大回撤是 −83.19%,它只有 −21.56%。
- **这是原版口径:一个单位冒 1%,最多四个,总风险 4%。**海龟当年同时交易几十个市场,1% 是给一个组合定的;放在单个市场上它只用了账户很小的一部分。后面和主线策略对照时会换成同样的风险预算。
八、收益结构:低胜率、高盈亏比
print(pd.DataFrame({name: T.r_profile(result["交易"]["R"]) for name, result in runs.items()}).round(3).to_string())
SPY AAPL BTC
笔数 67.000 59.000 70.000
胜率 0.448 0.458 0.329
平均盈利 R 3.002 8.023 16.147
平均亏损 R 1.151 1.556 1.605
盈亏比 2.610 5.158 10.059
期望 R 0.709 2.828 4.228
中位 R -0.358 -0.812 -1.000
偏度 1.171 1.204 3.369
最大一笔 R 7.651 18.051 76.442
最差一笔 R -3.149 -4.259 -2.613
合计 R 47.501 166.848 295.929

这张表值得逐行读:
| SPY | AAPL | BTC | |
|---|---|---|---|
| 胜率 | 44.8% | 45.8% | 32.9% |
| 平均盈利 | 3.00R | 8.02R | 16.15R |
| 平均亏损 | 1.15R | 1.56R | 1.61R |
| 盈亏比 | 2.61 | 5.16 | 10.06 |
| 中位数一笔 | −0.36R | −0.81R | −1.00R |
| 偏度 | 1.17 | 1.20 | 3.37 |
| 最大一笔 | 7.65R | 18.05R | 76.44R |
**三个标的上,中位数那一笔全是负的。**BTC 上正好是 −1.00R——也就是说,这套系统最典型的一次交易,就是碰到通道、买进去、被 2N 止损打掉。
而平均亏损全部贴在 1.6R 以下,这不是运气,是 2N 止损定义出来的:你能亏多少,在进场那一刻就写死了。至于能赚多少,没有上限——BTC 上最大的一笔 76.44R。
**止损做的是「把亏损这一头变成可预料的」。**盈利那一头之所以不可预料,正是因为你没有给它设上限。第 23 篇量过:给趋势跟随加固定止盈,胜率会上升,总收益会下降。
8.1 收益集中在几笔上
for name, result in runs.items():
print(f"--- {name} ---")
print(T.contribution(result["交易"]["R"]).round(4).to_string(index=False))
--- BTC ---
拿掉最赚的几笔 剩下多少 R 占原来的 占总笔数
0 295.9295 1.0000 0.0000
1 219.4870 0.7417 0.0143
3 145.2090 0.4907 0.0429
5 88.1760 0.2980 0.0714
10 -6.9578 -0.0235 0.1429
| 拿掉最赚的几笔 | SPY 剩下 | AAPL 剩下 | BTC 剩下 |
|---|---|---|---|
| 0 笔 | 100% | 100% | 100% |
| 1 笔(约 1.5%) | 83.9% | 89.2% | 74.2% |
| 3 笔(约 4.5%) | 51.8% | 68.1% | 49.1% |
| 5 笔(约 7.5%) | 24.7% | 50.2% | 29.8% |
| 10 笔(约 15%) | −17.5% | 12.6% | −2.3% |
在 SPY 和 BTC 上,拿掉最赚的十笔——不到 15% 的交易——九年的全部收益变成负数。
这条性质有一个很实际的后果:你不能挑着做。「这次形态不好看」「这次成交量不配合」「上次刚亏完这次先观望一下」——只要你放过的那几次里有一次是那十笔之一,九年的成绩就没了。
九、凸性:它长得像一份期权
把市场按「最近 20 根涨了多少」分成五组,看策略在每一组里赚多少:
for name, (frame, periods) in MARKETS.items():
for label, plan in [("只做多", T.TurtlePlan()), ("多空都做", T.TurtlePlan(side="both"))]:
curve = runs[name]["资金曲线"] if label == "只做多" else T.turtle(frame, plan)["资金曲线"]
strategy = curve.pct_change().dropna()
market = frame["close"].pct_change().reindex(strategy.index)
print(f"--- {name}({label})---")
print(T.convexity(strategy, market, buckets=5, window=20).round(4).to_string())
--- BTC(只做多)---
根数 市场中位 策略中位 策略平均 策略赚钱的比例
按市场 20 根涨跌分组
第 1 组 653 -0.1601 0.0000 -0.0105 0.0046
第 2 组 652 -0.0444 -0.0086 -0.0126 0.0123
第 3 组 652 0.0095 -0.0021 -0.0096 0.0951
第 4 组 652 0.0814 0.0009 0.0113 0.5245
第 5 组 652 0.2482 0.0834 0.1062 0.8911

只做多的时候,它长得像一份看涨期权:市场大涨时赚很多(BTC 第 5 组中位 +8.34%、89.1% 的时候赚钱),市场平静或下跌时小亏(第 1–3 组中位 0 到 −0.86%)。那点小亏就是权利金——你每年付出一点点,换的是「大行情来的时候一定在场」。
多空都做的时候,左边那一头会抬起来:
--- BTC(多空都做)---
根数 市场中位 策略中位 策略平均 策略赚钱的比例
按市场 20 根涨跌分组
第 1 组 653 -0.1601 0.0145 0.0250 0.6110
第 2 组 652 -0.0444 -0.0165 -0.0179 0.1028
第 3 组 652 0.0095 -0.0160 -0.0175 0.1258
第 4 组 652 0.0814 -0.0045 0.0050 0.4264
第 5 组 652 0.2482 0.0804 0.1006 0.8313
BTC 上第 1 组(20 根跌了 16.01%)策略中位 +1.45%、61.1% 的时候赚钱——这才是一条真正的微笑曲线,形状和「买了一份跨式期权」一样。
⚠️ 但图上另外两张说了同样重要的话:**SPY 和 AAPL 上那条线的左半边没有抬起来,反而更低了。**在长期向上的标的上做空趋势,是在逆着漂移做。第十一节会把这笔账算清楚。
十、金字塔加仓值不值
海龟最有特色的一条是加仓:每顺走 0.5 个 N 加一个单位,最多四个。它到底带来了什么?
为了公平,把总风险固定在 4%:一个单位冒 4%、两个各冒 2%、四个各冒 1%。
rows = []
for name, (frame, periods) in MARKETS.items():
for units, risk in [(1, 0.04), (2, 0.02), (4, 0.01)]:
result = T.turtle(frame, T.TurtlePlan(max_units=units, risk=risk))
curve, trades = result["资金曲线"], result["交易"]
rows.append({"标的": name, "最多几个单位": units, "每个单位冒的风险": risk,
"总风险": units * risk, "年化": RP.annual_return(curve, periods),
"最大回撤": RP.max_drawdown(curve),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods),
"平均用了几个单位": trades["单位数"].mean(), "最大一笔 R": trades["R"].max()})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 最多几个单位 每个单位冒的风险 总风险 年化 最大回撤 夏普 平均用了几个单位 最大一笔 R
SPY 1 0.04 0.04 0.0726 -0.1189 0.9020 1.0000 7.4601
SPY 2 0.02 0.04 0.0634 -0.1165 0.8220 1.5606 7.4601
SPY 4 0.01 0.04 0.0468 -0.1711 0.6822 2.4478 7.6509
AAPL 1 0.04 0.04 0.1954 -0.1314 1.3156 1.0000 6.8565
AAPL 2 0.02 0.04 0.1921 -0.1270 1.3474 1.8214 9.4455
AAPL 4 0.01 0.04 0.1690 -0.1187 1.2604 3.0847 18.0509
BTC 1 0.04 0.04 0.3212 -0.3466 1.3148 1.0000 17.7823
BTC 2 0.02 0.04 0.3207 -0.2681 1.3492 1.7761 37.0366
BTC 4 0.01 0.04 0.3160 -0.2156 1.3669 2.9714 76.4425
| 标的 | 最多单位数 | 年化 | 最大回撤 | 夏普 |
|---|---|---|---|---|
| BTC | 1 | 32.12% | −34.66% | 1.315 |
| BTC | 2 | 32.07% | −26.81% | 1.349 |
| BTC | 4 | 31.60% | −21.56% | 1.367 |
| AAPL | 1 | 19.54% | −13.14% | 1.316 |
| AAPL | 4 | 16.90% | −11.87% | 1.260 |
| SPY | 1 | 7.26% | −11.89% | 0.902 |
| SPY | 4 | 4.68% | −17.11% | 0.682 |
在 BTC 上,加仓几乎不改变年化(32.12% → 31.60%),却把最大回撤砍掉了三分之一(−34.66% → −21.56%)。
原因很朴素:**加仓让你「一开始小、确认对了才大」。**四分之一的仓位在进场那一刻就位,剩下四分之三要等价格真的顺着走了 0.5、1.0、1.5 个 N 才加上去。假突破在第一个单位上就被打掉,永远等不到后面三个。
⚠️ 但这不是普适的:AAPL 上加仓略微变差,SPY 上明显变差(年化 7.26% → 4.68%,回撤反而从 −11.89% 深到 −17.11%)。SPY 的日波动小、20 日 ATR 也小,0.5 个 N 的加仓间距被走得太轻易,结果是在震荡里反复加满又反复止损。加仓是一个和标的波动结构有关的选择,不是一条普适的改进。
十一、做空那一半
海龟原版是多空都做的(他们交易的是期货)。把三个标的分开量一量:
rows = []
for name, (frame, periods) in MARKETS.items():
for side in ("long", "short", "both"):
result = T.turtle(frame, T.TurtlePlan(side=side, risk=0.025))
curve, trades = result["资金曲线"], result["交易"]
rows.append({"标的": name, "方向": {"long": "只做多", "short": "只做空", "both": "多空都做"}[side],
"年化": RP.annual_return(curve, periods), "最大回撤": RP.max_drawdown(curve),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods), "笔数": len(trades),
"胜率": float((trades["R"] > 0).mean()), "合计 R": trades["R"].sum()})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 方向 年化 最大回撤 夏普 笔数 胜率 合计 R
SPY 只做多 0.0688 -0.1156 0.8299 64 0.5469 37.5090
SPY 只做空 -0.1502 -0.8064 -0.5075 58 0.1034 -69.1582
SPY 多空都做 -0.0903 -0.6592 -0.2157 121 0.3388 -30.6492
AAPL 只做多 0.2237 -0.1517 1.3730 56 0.5357 88.2173
AAPL 只做空 -0.1479 -0.8184 -0.5657 51 0.1176 -59.2419
AAPL 多空都做 0.0427 -0.4036 0.2904 107 0.3364 28.9754
BTC 只做多 0.4691 -0.4238 1.2817 69 0.3478 179.4161
BTC 只做空 0.0498 -0.6789 0.3047 67 0.2836 32.2142
BTC 多空都做 0.5465 -0.4973 1.1749 135 0.3185 212.6303
| 标的 | 只做多 | 只做空 | 多空都做 |
|---|---|---|---|
| SPY | +6.88% | −15.02% | −9.03% |
| AAPL | +22.37% | −14.79% | +4.27% |
| BTC | +46.91% | +4.98% | +54.65% |
在 SPY 和 AAPL 上,做空那一半是纯亏损——胜率只有 10.3% 和 11.8%,把整条策略从正收益拖成负收益。BTC 上做空只赚了 4.98%(九年),但它不是拖累:加上之后总年化从 46.91% 升到 54.65%。
这和第 26 篇的发现是同一件事:股票和股指有长期向上的漂移,做空趋势等于逆着漂移下注;BTC 的大跌够深够快,做空才勉强收支平衡。
**原型不等于配方。**海龟的多空对称是为了几十个期货品种的组合设计的,照搬到长期向上的股票上就是亏钱。
十二、什么时候有效
趋势跟随需要趋势。把每一笔交易按进场那天的效率比(第 8 篇:这 20 根走了多远 ÷ 走了多少路)分成三组:
for name, (frame, periods) in MARKETS.items():
trades = runs[name]["交易"].copy()
trades["效率比"] = ST.efficiency_ratio(frame["close"], 20).reindex(trades["进场日"]).to_numpy()
groups = pd.qcut(trades["效率比"], 3, labels=["低(来回震荡)", "中", "高(一直朝一个方向)"])
print(f"--- {name} ---")
print(trades.groupby(groups, observed=True)["R"].agg(
笔数="size", 胜率=lambda x: float((x > 0).mean()), 平均="mean", 中位="median", 合计="sum"
).round(3).to_string())
笔数 胜率 平均 中位 合计
效率比
低(来回震荡) 24 0.208 0.576 -1.000 13.824
中 23 0.348 5.139 -0.856 118.190
高(一直朝一个方向) 23 0.435 7.127 -0.705 163.915
BTC 上这个关系非常干净:
| 进场时的效率比 | 笔数 | 胜率 | 平均 R | 合计 R |
|---|---|---|---|---|
| 低(来回震荡) | 24 | 20.8% | 0.58 | 13.8 |
| 中 | 23 | 34.8% | 5.14 | 118.2 |
| 高(一直朝一个方向) | 23 | 43.5% | 7.13 | 163.9 |
在「一直朝一个方向」的环境里进场,九年合计 163.9R;在「来回震荡」的环境里进场,只有 13.8R。
⚠️ 但 SPY 和 AAPL 上看不出这个规律(SPY 上反而是低效率比那一组合计最高)。每组只有二十笔出头——第 29 篇讲过,二十笔什么都证明不了。所以这一节的诚实版本是:BTC 上这个关系成立;美股上样本量不够,别下结论。
十三、动手:talab.trend
"""talab.trend:趋势跟随与突破。第 31 篇。
第六部分讲完了怎么检验,这一篇开始讲**被检验的东西**:一类真实存在、公开了四十年、
到现在还有人靠它管钱的策略原型。
趋势跟随的全部内容可以写成一句话:**价格创新高就买,跌破就走。**
它没有预测,没有估值,没有「这次不一样」。它唯一的假设是——
> 大的行情会持续得比大多数人愿意相信的更久,而小的行情会来回震荡。
这个假设**大部分时候是错的**(第 31 篇实测:七成的突破是假的),
但它错的时候亏得少、对的时候赚得多。这个模块就是把这句话拆成可以测量的零件:
1. **通道与突破**:`donchian`、`breakouts`、`follow_through`
2. **海龟系统**:`TurtlePlan` + `turtle`(含 2N 止损、按 N 定仓位、金字塔加仓)
3. **收益结构**:`r_profile`、`contribution`、`convexity`
⚠️ **通道突破是这门课里唯一可以「当根成交」的信号**:通道那条线是用**前面几根**的
最高价算出来的,开盘之前就已经画好了,价格在这一根里碰到它是一件**当时就能看见**的事。
这和第 27 篇「收盘算出来的信号只能下一根成交」不矛盾——那条规矩管的是用到**当根收盘价**
的信号(均线、RSI、收盘价创新高)。判据始终是同一句:**这个数,在那一刻能不能算出来。**
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from talab.backtest import Account
SIDES = ("long", "short", "both")
突破与假突破
def breakouts(df: pd.DataFrame, n: int = 20, horizon: int = 20) -> pd.DataFrame:
"""每一次**向上**突破 n 日通道:突破时的价格,以及之后 horizon 根里发生了什么。
连续多根都在通道上方只算**一次**突破(第一次算,后面的不算),
否则一段大趋势会被数成几十次「突破」,假突破的比例就被稀释成假的。
"""
channel = donchian(df["high"], df["low"], n)
high, low, close = (df[x].to_numpy(float) for x in ["high", "low", "close"])
upper = channel["上轨"].to_numpy()
rows, armed = [], True
for i in range(len(close)):
if np.isnan(upper[i]):
continue
if high[i] > upper[i]:
if armed:
ahead = close[i + 1:i + 1 + horizon]
worst = low[i + 1:i + 1 + horizon]
rows.append({"时间": df.index[i], "突破价": upper[i], "收盘": close[i],
"之后最高收盘": float(ahead.max()) if len(ahead) else np.nan,
"之后最低价": float(worst.min()) if len(worst) else np.nan,
f"{horizon} 根后": float(ahead[-1]) if len(ahead) == horizon else np.nan})
armed = False
elif close[i] < upper[i]:
armed = True # 回到通道里面,下一次才算新的突破
out = pd.DataFrame(rows)
if len(out):
out["之后涨幅"] = out[f"{horizon} 根后"] / out["突破价"] - 1
out["最大顺势"] = out["之后最高收盘"] / out["突破价"] - 1
out["最大逆势"] = out["之后最低价"] / out["突破价"] - 1
return out
def follow_through(table: pd.DataFrame, threshold: float = 0.0) -> pd.Series:
"""一批突破里,有多少是「走出去了」的,有多少是假的。
`threshold` 是判定标准(`之后涨幅` 要超过它才算真)。默认 0:**只要没回到突破价以下就算真**——
这已经是最宽松的口径了,而第 31 篇实测下来仍然只有三成多能过。
"""
real = table["之后涨幅"] > threshold
return pd.Series({
"突破次数": float(len(table)), "走出去的": float(real.sum()),
"假突破比例": float((~real).mean()),
"真的那些平均涨": float(table.loc[real, "之后涨幅"].mean()) if real.any() else np.nan,
"假的那些平均跌": float(table.loc[~real, "之后涨幅"].mean()) if (~real).any() else np.nan,
"全部平均": float(table["之后涨幅"].mean()),
"最大顺势中位": float(table["最大顺势"].median()),
"最大逆势中位": float(table["最大逆势"].median()),
})
海龟引擎
def turtle(df: pd.DataFrame, plan: TurtlePlan | None = None, equity: float = 100_000.0) -> dict:
"""按海龟法则跑一遍历史。
每一根 K 线上按固定顺序做五件事(和第 27 篇的引擎同一套规矩):
0. **补成交**:`fill="next_open"` 时,上一根定下来的动作用这一根的开盘价成交
1. **出场**:先看止损,再看反向通道;两个都碰到时按**先止损**算(悲观口径)
2. **加仓**:价格又顺走了 `add_atr` 个 N 就加一个单位,并把**全部**止损上移
3. **估值**:按收盘价记权益
4. **进场**:空仓时,价格碰到 `entry` 日通道就进
通道线来自前面几根,所以 1、2、4 默认在**当根**成交(见模块开头那条 ⚠️)。
把 `fill` 换成 `"next_open"` 就能量出「当根成交」这个假设值多少钱——
如果它值很多,那说明这条策略靠的是那一瞬间的价格,不是趋势。
返回资金曲线、逐笔交易(一整个仓位算一笔,带 R 倍数)、每一次加仓的明细。
"""
from talab import indicators as I
plan = plan or TurtlePlan()
o, h, l, c = (df[x].to_numpy(float) for x in ["open", "high", "low", "close"])
n_value = I.atr(df["high"], df["low"], df["close"], plan.atr_period).shift(1).to_numpy()
enter = donchian(df["high"], df["low"], plan.entry)
leave = donchian(df["high"], df["low"], plan.exit)
up_in, down_in = enter["上轨"].to_numpy(), enter["下轨"].to_numpy()
up_out, down_out = leave["上轨"].to_numpy(), leave["下轨"].to_numpy()
# ⚠️ 空头的数量只记在 `units` 里,`account.shares` 全程是 0——
# 所以权益一律用下面的 `position_value` 算,不要用 `account.equity`。
account = Account(cash=float(equity))
curve, trades, adds, error = [], [], [], 0.0
side = 0 # +1 做多,−1 做空,0 空仓
units: list[tuple[float, float]] = [] # 每个单位的(成交价,数量)
stop = last_price = risk_amount = np.nan
opened_at = -1
def position_value(price: float) -> float:
"""权益:做多是「现金 + 持仓市值」,做空是「现金 − 买回来要花的钱」。
⚠️ 做空时卖出所得已经记在现金里了,所以**不要再减一次借来的钱**——
第一版就是这么写的,结果最大回撤算出 −106%(权益跑到 0 以下)。
「回撤超过 100%」永远是记账错了,不是策略真的这么惨。
"""
held = sum(q for _, q in units)
return account.cash + side * held * price
def close_all(i: int, price: float, reason: str) -> None:
nonlocal side, units, stop, opened_at
held = sum(q for _, q in units)
cost = sum(p * q for p, q in units)
if side > 0:
account.sell(price, held, plan.fee_rate)
profit = price * held - cost - abs(price * held) * plan.fee_rate
else:
account.cash -= price * held * (1 + plan.fee_rate) # 买回来平掉空头
account.fees += price * held * plan.fee_rate
profit = cost - price * held - abs(price * held) * plan.fee_rate
trades.append({"进场日": df.index[opened_at], "方向": "多" if side > 0 else "空",
"第一个单位的价": units[0][0], "平均价": cost / held if held else np.nan,
"单位数": len(units), "出场日": df.index[i], "出场价": price,
"原因": reason, "盈亏": profit, "R": profit / risk_amount,
"根数": i - opened_at})
side, units, stop, opened_at = 0, [], np.nan, -1
def open_unit(i: int, price: float, direction: int) -> bool:
nonlocal side, stop, last_price, risk_amount
step = plan.stop_atr * n_value[i]
want = position_value(price) * plan.risk / step # 一个单位:冒账户的 risk
if direction > 0:
shares = min(want, account.affordable(price, plan.fee_rate))
else:
shares = min(want, position_value(price) / (price * (1 + plan.fee_rate)))
if shares <= 1e-12:
return False # 买不起了:别再往下加
if direction > 0:
account.buy(price, shares, plan.fee_rate)
else:
account.cash += price * shares * (1 - plan.fee_rate)
account.fees += price * shares * plan.fee_rate
if not units:
side = direction
risk_amount = shares * step # 1R = 第一个单位的初始风险
units.append((price, shares))
last_price = price
stop = price - direction * step # 加仓之后全部止损跟着走
adds.append({"时间": df.index[i], "第几个单位": len(units), "成交价": price,
"数量": shares, "止损移到": stop})
return True
first = int(np.argmax(~np.isnan(n_value) & ~np.isnan(up_in) & ~np.isnan(down_in)))
pending: tuple | None = None # fill="next_open" 时挂着的动作
for i in range(first, len(c)):
# 第 0 步:上一根定下来的动作,用这一根的开盘价成交
if pending is not None:
what, extra = pending
pending = None
if what == "close" and side:
close_all(i, o[i], extra)
elif what == "add" and side and len(units) < plan.max_units:
open_unit(i, o[i], side)
elif what == "open" and not side:
opened_at = i
open_unit(i, o[i], extra)
# 第 1 步:出场
later = plan.fill == "next_open"
if side > 0:
if l[i] <= stop:
pending = ("close", "止损") if later else pending
if not later:
close_all(i, min(o[i], stop), "止损")
elif l[i] <= down_out[i]:
pending = ("close", "通道出场") if later else pending
if not later:
close_all(i, min(o[i], down_out[i]), "通道出场")
elif side < 0:
if h[i] >= stop:
pending = ("close", "止损") if later else pending
if not later:
close_all(i, max(o[i], stop), "止损")
elif h[i] >= up_out[i]:
pending = ("close", "通道出场") if later else pending
if not later:
close_all(i, max(o[i], up_out[i]), "通道出场")
# 第 2 步:加仓
while side and pending is None and len(units) < plan.max_units:
level = last_price + side * plan.add_atr * n_value[i]
if side > 0 and h[i] >= level:
if later:
pending = ("add", None)
break
if not open_unit(i, max(o[i], level), 1):
break
elif side < 0 and l[i] <= level:
if later:
pending = ("add", None)
break
if not open_unit(i, min(o[i], level), -1):
break
else:
break
# 第 3 步:估值
value = position_value(c[i])
held = sum(q for _, q in units)
error = max(error, abs(account.cash + side * held * c[i] - value))
curve.append(value)
# 第 4 步:进场
if not side and pending is None and n_value[i] > 0:
if plan.side in ("long", "both") and h[i] > up_in[i]:
if later:
pending = ("open", 1)
else:
opened_at = i
open_unit(i, max(o[i], up_in[i]), 1)
elif plan.side in ("short", "both") and l[i] < down_in[i]:
if later:
pending = ("open", -1)
else:
opened_at = i
open_unit(i, min(o[i], down_in[i]), -1)
if side: # 最后一根还拿着
close_all(len(c) - 1, c[-1], "未平仓")
index = df.index[first:]
columns = ["进场日", "方向", "第一个单位的价", "平均价", "单位数", "出场日", "出场价",
"原因", "盈亏", "R", "根数"]
return {"资金曲线": pd.Series(curve, index=index, name="权益"),
"交易": pd.DataFrame(trades, columns=columns),
"加仓": pd.DataFrame(adds, columns=["时间", "第几个单位", "成交价", "数量", "止损移到"]),
"手续费合计": account.fees, "记账误差": error,
"权益最低点": float(min(curve)) if curve else np.nan}
⚠️ position_value 上面那条注释是踩过的坑:做空时卖出所得已经记在现金里了,不要再减一次借来的钱。第一版这么写的结果是最大回撤算出 −106%——权益跑到 0 以下。「回撤超过 100%」永远是记账错了,不是策略真的这么惨。
收益结构
def r_profile(r) -> pd.Series:
"""一串 R 倍数的形状:胜率、盈亏比、期望,以及**偏度**。
趋势跟随的招牌是「胜率低、盈亏比高、偏度为正」。
第 23 篇的 `risk.expectancy` 给的是前两项,这里补上尾巴那一头——
**正偏度的意思是「亏损可以预料,盈利不可以」**,而策略的全部收益都藏在那个不可预料的尾巴里。
"""
r = pd.Series(r).dropna().astype(float)
wins, losses = r[r > 0], r[r <= 0]
return pd.Series({
"笔数": float(len(r)), "胜率": float((r > 0).mean()),
"平均盈利 R": float(wins.mean()) if len(wins) else 0.0,
"平均亏损 R": float(-losses.mean()) if len(losses) else 0.0,
"盈亏比": float(wins.mean() / -losses.mean()) if len(losses) and losses.mean() else np.inf,
"期望 R": float(r.mean()), "中位 R": float(r.median()),
"偏度": float(r.skew()), "最大一笔 R": float(r.max()), "最差一笔 R": float(r.min()),
"合计 R": float(r.sum()),
})
def contribution(r, tops=(1, 3, 5, 10)) -> pd.DataFrame:
"""把最赚钱的几笔拿掉,还剩多少。
这张表回答一个很不舒服的问题:**这条策略的收益,有多少集中在极少数几笔上?**
集中度高不是缺点,是趋势跟随的**定义**——但你必须知道它有多高,
因为那意味着「错过那几笔」和「策略失效」在账户上是同一件事。
"""
r = pd.Series(r).dropna().astype(float).sort_values(ascending=False)
total = r.sum()
rows = [{"拿掉最赚的几笔": 0, "剩下多少 R": total, "占原来的": 1.0, "占总笔数": 0.0}]
for n in tops:
if n >= len(r):
break
left = total - r.iloc[:n].sum()
rows.append({"拿掉最赚的几笔": n, "剩下多少 R": left, "占原来的": left / total if total else np.nan,
"占总笔数": n / len(r)})
return pd.DataFrame(rows)
def convexity(strategy: pd.Series, market: pd.Series, buckets: int = 5,
window: int = 20) -> pd.DataFrame:
"""凸性表:把市场按 `window` 根的涨跌分成几组,看策略在每一组里赚多少。
趋势跟随的收益应该是一条**微笑曲线**——市场大涨时赚、市场大跌时也赚(或者至少不怎么亏)、
市场不涨不跌时小亏。这个形状和「买了一份跨式期权」是一回事,
小亏就是权利金。第 32 篇的均值回归会给出正好相反的形状(凹性)。
"""
if not 0 < buckets <= 20:
raise ValueError("buckets 要在 1 和 20 之间")
both = pd.concat([strategy.rename("策略"), market.rename("市场")], axis=1).dropna()
rolled = pd.DataFrame({
"策略": (1 + both["策略"]).rolling(window).apply(np.prod, raw=True) - 1,
"市场": (1 + both["市场"]).rolling(window).apply(np.prod, raw=True) - 1,
}).dropna()
groups = pd.qcut(rolled["市场"], buckets, labels=False, duplicates="drop")
out = rolled.groupby(groups).agg(根数=("市场", "size"), 市场中位=("市场", "median"),
策略中位=("策略", "median"), 策略平均=("策略", "mean"),
策略赚钱的比例=("策略", lambda x: float((x > 0).mean())))
out.index = [f"第 {i + 1} 组" for i in range(len(out))]
out.index.name = f"按市场 {window} 根涨跌分组"
return out
测试
十四个测试里最该看的两条。一条钉住那条「必须 shift」的规矩:
def test_donchian_shifts_by_one_bar():
"""不推迟一根,「创 n 日新高」会永远成立——今天的最高价当然是包含今天在内的最高价之一。"""
bars = frame([1, 2, 3, 4, 5])
channel = T.donchian(bars["high"], bars["low"], 3)
assert np.isnan(channel["上轨"].iloc[2]) # 前三根凑不齐「前 3 根」
assert channel["上轨"].iloc[3] == 3 and channel["下轨"].iloc[3] == 1
assert channel["中轨"].iloc[3] == 2
assert (bars["high"] > channel["上轨"]).iloc[3:].all() # 一路新高:确实每根都突破
wrong = bars["high"].rolling(3).max() # 忘了 shift 的写法
assert not (bars["high"] > wrong).iloc[3:].any() # 永远不成立,信号一个都没有
另一条钉住「碰到通道就成交」和「等下一根开盘」的区别,顺便把横盘必须用锯齿那个坑写进了注释(真实波幅全是 0 的话 N 也是 0,海龟一笔都开不出来):
def test_turtle_next_open_fills_one_bar_later():
zigzag = frame([100 + (1 if i % 2 else -1) for i in range(14)], spread=0.02)
assert float(zigzag["high"].iloc[4:14].max()) == pytest.approx(103.02) # 10 日通道上轨
# 第 15 根:开在通道下方 102,盘中穿上去;第 16 根直接开在 107
extra = pd.DataFrame({"open": [102.0, 107.0, 110.0], "high": [106.0, 111.0, 114.0],
"low": [101.0, 106.0, 109.0], "close": [105.0, 110.0, 113.0]},
index=pd.date_range(zigzag.index[-1] + pd.Timedelta(days=1), periods=3))
bars = pd.concat([zigzag, extra])
common = dict(entry=10, exit=5, atr_period=5, max_units=1)
touch = T.turtle(bars, T.TurtlePlan(fill="touch", **common))["交易"].iloc[0]
later = T.turtle(bars, T.TurtlePlan(fill="next_open", **common))["交易"].iloc[0]
assert touch["第一个单位的价"] == pytest.approx(103.02) # 就在通道那条线上成交
assert later["进场日"] == touch["进场日"] + pd.Timedelta(days=1)
assert later["第一个单位的价"] == pytest.approx(107.0) # 等一根,开盘已经 107 了
十四、用第六部分的方法检验一遍
14.1 参数曲面与 walk-forward
GRID = [(e, x) for e in [10, 15, 20, 30, 40, 55, 80, 100] for x in [5, 10, 15, 20, 30, 40] if x < e]
DEFAULT = (20, 10)
print(f"网格 {len(GRID)} 组(进场 10–100 日 × 出场 5–40 日,出场必须比进场短)")
rows, matrices = [], {}
for name, (frame, periods) in MARKETS.items():
daily = np.array([T.turtle(frame, T.TurtlePlan(entry=e, exit=x))["资金曲线"]
.pct_change().reindex(frame.index).fillna(0).to_numpy() for e, x in GRID])
matrices[name] = daily
scores = daily.mean(axis=1) / daily.std(axis=1, ddof=1) * np.sqrt(periods)
table = pd.DataFrame({"进场": [e for e, x in GRID], "出场": [x for e, x in GRID], "夏普": scores})
face = V.surface(table, "进场", "出场", "夏普")
around = V.neighbourhood(face, DEFAULT)
splits = V.walk_forward(daily.shape[1], int(periods * 2), periods)
picked = V.walk_forward_run(daily, splits, names=GRID)
stitched = V.stitch(daily, splits, [GRID.index(p) for p in picked["选了谁"]])
rolling = RP.to_curve(pd.Series(stitched, index=pd.RangeIndex(len(stitched))))
held = daily[GRID.index(DEFAULT), splits[0][1].start:splits[-1][1].stop]
kept = RP.to_curve(pd.Series(held, index=pd.RangeIndex(len(held))))
rows.append({"标的": name, "1983 年那组 (20,10)": scores[GRID.index(DEFAULT)],
"排第几": f"{int((scores > scores[GRID.index(DEFAULT)]).sum()) + 1}/{len(GRID)}",
"邻居中位": around["邻居中位"], "落差": around["落差"],
"网格最好": scores.max(), "网格中位": float(np.median(scores)),
"亏钱的格子": float((daily.sum(axis=1) < 0).mean()),
"白捡的门槛": V.expected_max_sharpe(len(GRID), scores.std()),
"PBO": V.pbo(daily, 16)["过拟合概率 PBO"],
"每年重挑的年化": RP.annual_return(rolling, periods),
"一直用 (20,10) 的年化": RP.annual_return(kept, periods)})
print(pd.DataFrame(rows).round(4).to_string(index=False))
网格 33 组(进场 10–100 日 × 出场 5–40 日,出场必须比进场短)
标的 1983 年那组 (20,10) 排第几 邻居中位 落差 网格最好 网格中位 亏钱的格子 白捡的门槛 PBO 每年重挑的年化 一直用 (20,10) 的年化
SPY 0.6792 6/33 0.5268 0.1523 0.7580 0.5211 0.0 0.3354 0.5904 0.0330 0.0372
AAPL 1.2548 2/33 1.0521 0.2027 1.2860 0.8121 0.0 0.3920 0.0210 0.1815 0.1957
BTC 1.3623 1/33 1.1928 0.1695 1.3623 1.0496 0.0 0.3175 0.2082 0.2113 0.2429
| 标的 | (20,10) 的夏普 | 排第几 | 落差 | 网格中位 | 亏钱的格子 | PBO | 每年重挑 | 一直用 (20,10) |
|---|---|---|---|---|---|---|---|---|
| SPY | 0.6792 | 6/33 | +0.152 | 0.521 | 0% | 0.590 | 3.30% | 3.72% |
| AAPL | 1.2548 | 2/33 | +0.203 | 0.812 | 0% | 0.021 | 18.15% | 19.57% |
| BTC | 1.3623 | 1/33 | +0.170 | 1.050 | 0% | 0.208 | 21.13% | 24.29% |
四件事:
- 1983 年那组参数在 BTC 上排第 1、AAPL 上排第 2。⚠️ 排第一本身不重要(33 格里总有一个第一)。重要的是落差只有 +0.15 到 +0.20——整片曲面都在 1.0–1.37,这是一片高地,不是第 30 篇那根针(那根针的落差是 +1.11)。
- 33 组参数里,没有一组在任何一个标的上亏钱。不是「挑到了好参数」,是整个参数族都work。
- **每年重挑参数,三个标的上全部跑输「一直用 (20,10)」。**这是第 30 篇那个结论的又一次重演:walk-forward 是诚实的度量,不是提高收益的方法。
- SPY 上的 PBO 是 0.590——在 SPY 上挑参数依然是抛硬币。AAPL 的 0.021 和 BTC 的 0.208 则说明在这两个标的上参数排名确实带一点信息。
14.2 蒙特卡洛:把趋势拆掉
最关键的一刀。用块自助法造出「同样的日收益率分布、同样的波动率聚集,但没有那几段趋势」的假价格,然后把同一套规则原样跑一遍:
rows = []
for name, (frame, periods) in MARKETS.items():
real = runs[name]["资金曲线"]
real_annual = RP.annual_return(real, periods)
real_sharpe = RP.sharpe(real.pct_change().dropna(), periods)
fakes = []
for path in V.synthetic_close(frame["close"], n=N_PATHS, block=20, seed=31):
scale = path / frame["close"].to_numpy() # 四个价按同一个比例缩放,保住 K 线的形状
fake = pd.DataFrame({c: frame[c].to_numpy() * scale for c in ["open", "high", "low", "close"]},
index=frame.index)
curve = T.turtle(fake, T.TurtlePlan())["资金曲线"]
fakes.append((RP.annual_return(curve, periods),
RP.sharpe(curve.pct_change().dropna(), periods)))
annuals = np.array([a for a, s in fakes])
sharpes = np.array([s for a, s in fakes])
rows.append({"标的": name, "真实年化": real_annual, "假数据年化中位": float(np.median(annuals)),
"假数据 95% 分位": float(np.percentile(annuals, 95)),
"真实夏普": real_sharpe, "假数据夏普中位": float(np.median(sharpes)),
"假的比真的好的比例": float((annuals >= real_annual).mean())})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 真实年化 假数据年化中位 假数据 95% 分位 真实夏普 假数据夏普中位 假的比真的好的比例
SPY 0.0468 -0.0569 -0.0069 0.6822 -0.7168 0.0
AAPL 0.1690 -0.0424 0.0312 1.2604 -0.3362 0.0
BTC 0.3160 -0.0944 0.0001 1.3669 -0.6517 0.0

| 标的 | 真实年化 | 假数据年化中位 | 假数据 95% 分位 | 假的比真的好的比例 |
|---|---|---|---|---|
| SPY | +4.68% | −5.69% | −0.69% | 0/100 |
| AAPL | +16.90% | −4.24% | +3.12% | 0/100 |
| BTC | +31.60% | −9.44% | +0.01% | 0/100 |
把趋势拆掉之后,同一套规则在三个标的上都变成亏钱的,而且 300 条假数据里没有一条比真实的好。
对比第 30 篇那条「年化 173.74%」的曲线:它在假数据上的 p 值是 0.27。这里是 < 0.01。
⚠️ 两条诚实的说明:
- **块自助法保留了漂移。**假数据的期望日收益率和真实的一样,所以「假数据上亏钱」不是因为假市场不涨——它照样涨,只是涨法变成了没有方向的抖动。趋势跟随一多半时间空仓,接不住这种涨法。
- **块长 20 保留了一个月以内的走势,破坏的是一个月以上的。**换成块长 1、5、60、120 重做,假数据年化中位在 −13.5% 到 −7.4% 之间,比例全是 0/60——结论对块长不敏感。
十五、和主线策略 v4、买入持有并排
全部含成本(BTC 用第 28 篇的现货 taker + 半个价差 + 止损滑点,美股用现抓的监管费 + 1 个基点价差):
schedule = pd.Series({"SEC 占卖出金额": 20.60 / 1e6, "TAF 每股": 0.000166, "TAF 每笔上限": 8.30})
us_cost = C.us_stock(100.0, 100.0, "sell", spread_bp=1.0, sec_rate=schedule["SEC 占卖出金额"],
taf_per_share=schedule["TAF 每股"], taf_cap=schedule["TAF 每笔上限"])["占名义价值"]
btc_cost = C.crypto(1.0, "现货 taker", spread_bp=C.BTC_PERP_SPREAD_BP)["占名义价值"] + 0.00097 / 2
rows = []
for name, (frame, periods) in MARKETS.items():
price = frame["close"]
fee = btc_cost if name == "BTC" else us_cost
for label, plan in [("海龟(原版:每单位 1%,最多 4 个)", T.TurtlePlan(fee_rate=fee)),
("海龟(和主线同样的风险预算:2.5% × 4)", T.TurtlePlan(risk=0.025, fee_rate=fee))]:
result = T.turtle(frame, plan)
curve, trades = result["资金曲线"], result["交易"]
rows.append({"标的": name, "策略": label, "年化": RP.annual_return(curve, periods),
"最大回撤": RP.max_drawdown(curve),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods),
"卡玛": RP.calmar(curve, periods), "笔数": len(trades),
"逐笔的 t 值": RP.trade_metrics(trades["R"])["t 值"],
"手续费": result["手续费合计"]})
plan = BT.Plan(entry=((I.sma(price, 50) > I.sma(price, 200))
& (price >= price.rolling(20).max())).fillna(False),
exit=I.cross_below(I.sma(price, 50), I.sma(price, 200)).fillna(False),
stop="chandelier", k=3.0, trigger="close", sizing="risk",
risk_per_trade=0.10, fee_rate=fee)
main = BT.run(frame, plan, 100_000.0)
curve = main["资金曲线"]
rows.append({"标的": name, "策略": "主线 v4", "年化": RP.annual_return(curve, periods),
"最大回撤": RP.max_drawdown(curve),
"夏普": RP.sharpe(curve.pct_change().dropna(), periods),
"卡玛": RP.calmar(curve, periods), "笔数": len(main["交易"]),
"逐笔的 t 值": RP.trade_metrics(main["交易"]["收益"])["t 值"],
"手续费": main["手续费合计"]})
rows.append({"标的": name, "策略": "买入持有", "年化": RP.annual_return(price, periods),
"最大回撤": RP.max_drawdown(price),
"夏普": RP.sharpe(price.pct_change().dropna(), periods),
"卡玛": RP.calmar(price, periods), "笔数": 1, "逐笔的 t 值": np.nan, "手续费": 0.0})
print(pd.DataFrame(rows).round(4).to_string(index=False))
标的 策略 年化 最大回撤 夏普 卡玛 笔数 逐笔的 t 值 手续费
SPY 海龟(原版:每单位 1%,最多 4 个) 0.0459 -0.1735 0.6695 0.2648 67 2.2067 1088.4034
SPY 海龟(和主线同样的风险预算:2.5% × 4) 0.0679 -0.1164 0.8185 0.5832 64 2.9010 1297.3414
SPY 主线 v4 0.0291 -0.1242 0.4184 0.2343 39 1.1180 655.2938
SPY 买入持有 0.1346 -0.3410 0.7913 0.3945 1 NaN 0.0000
AAPL 海龟(原版:每单位 1%,最多 4 个) 0.1682 -0.1194 1.2552 1.4089 59 3.4832 1651.7188
AAPL 海龟(和主线同样的风险预算:2.5% × 4) 0.2228 -0.1519 1.3678 1.4665 56 3.6582 2786.3123
AAPL 主线 v4 0.0983 -0.2797 0.6975 0.3516 37 1.7917 1026.2642
AAPL 买入持有 0.2888 -0.3852 1.0168 0.7497 1 NaN 0.0000
BTC 海龟(原版:每单位 1%,最多 4 个) 0.3038 -0.2278 1.3221 1.3337 70 2.6989 53918.9576
BTC 海龟(和主线同样的风险预算:2.5% × 4) 0.4444 -0.4361 1.2311 1.0191 69 2.7759 213025.6608
BTC 主线 v4 0.2063 -0.3362 0.8577 0.6137 30 1.2769 30926.0862
BTC 买入持有 0.3794 -0.8319 0.8192 0.4561 1 NaN 0.0000

| 标的 | 策略 | 年化 | 最大回撤 | 夏普 | 卡玛 | 笔数 | 逐笔 t 值 |
|---|---|---|---|---|---|---|---|
| SPY | 海龟(原版) | 4.59% | −17.35% | 0.670 | 0.265 | 67 | 2.21 |
| SPY | 海龟(同风险) | 6.79% | −11.64% | 0.819 | 0.583 | 64 | 2.90 |
| SPY | 主线 v4 | 2.91% | −12.42% | 0.418 | 0.234 | 39 | 1.12 |
| SPY | 买入持有 | 13.46% | −34.10% | 0.791 | 0.395 | — | — |
| AAPL | 海龟(原版) | 16.82% | −11.94% | 1.255 | 1.409 | 59 | 3.48 |
| AAPL | 海龟(同风险) | 22.28% | −15.19% | 1.368 | 1.467 | 56 | 3.66 |
| AAPL | 主线 v4 | 9.83% | −27.97% | 0.698 | 0.352 | 37 | 1.79 |
| AAPL | 买入持有 | 28.88% | −38.52% | 1.017 | 0.750 | — | — |
| BTC | 海龟(原版) | 30.38% | −22.78% | 1.322 | 1.334 | 70 | 2.70 |
| BTC | 海龟(同风险) | 44.44% | −43.61% | 1.231 | 1.019 | 69 | 2.78 |
| BTC | 主线 v4 | 20.63% | −33.62% | 0.858 | 0.614 | 30 | 1.28 |
| BTC | 买入持有 | 37.94% | −83.19% | 0.819 | 0.456 | — | — |
三个结论:
**一,海龟在三个标的上全面优于主线 v4。**年化、夏普、卡玛、逐笔 t 值,没有一项输。主线 v4 的入场条件(金叉 + 创 20 日新高)其实就是一个更保守的突破,但它的出场靠死叉——一个滞后得多的信号,于是它把利润吐回去的速度慢得多。
**二,海龟是这门课里第一条逐笔 t 值全部超过 2 的策略。**2.21 / 3.48 / 2.70(原版)和 2.90 / 3.66 / 2.78(同风险),而主线 v4 是 1.12 / 1.79 / 1.28。第 29 篇那条线,它过了。
**三,它在年化上跑不赢买入持有,但在风险上赢很多。**SPY 4.59% 对 13.46%、AAPL 22.28% 对 28.88%,只有 BTC 的同风险版本(44.44%)超过了买入持有的 37.94%。但看回撤:BTC 上 −22.78% 对 −83.19%,卡玛 1.334 对 0.456。
第 30 篇结尾留了一个问题:**均线交叉在 BTC 上怎么挑参数都赢不了买入持有。**这一篇的答案是:**换一条规则可以,但赢的方式不是「赚得更多」,是「用四分之一的回撤赚到差不多的钱」。**如果你愿意为此加一点杠杆(把风险预算从 4% 提到 10%),年化就超过去了——代价是回撤回到 −43.61%。这是第 26 篇那条抛物线的又一次现身:收益和回撤是同一个旋钮的两端。
十六、小检查
- 「20 日通道突破」可以在当根成交,「收盘价创 20 日新高」只能下一根成交。这两句话的区别在哪里?
- 一套趋势跟随系统的胜率是 33%、中位数一笔是 −1R。有人说「胜率这么低肯定不行」。你会怎么回答?
- 你发现某次突破「量能不配合」,决定跳过这一次。这个决定有什么风险?
- 海龟在 SPY 上做空那一半九年亏了 15%。这说明「趋势跟随不适用于股指」吗?
- 蒙特卡洛对照里,假数据的期望日收益率和真实数据一样,可是同一套规则在假数据上亏钱。为什么?
十七、常见误用
挑着做。第 8.1 节:拿掉最赚的十笔(不到 15% 的交易),SPY 和 BTC 上九年收益变成负数。「这次看着不对」是这套系统最贵的一句话——你要么全做,要么别做。
**看到胜率 33% 就否定。**第八节:胜率和盈亏比是一枚硬币的两面。2N 止损把亏损那一头钉死在 1.6R 以下,赚的那一头没有上限(BTC 上最大一笔 76.44R)。该看的是期望值和偏度,不是胜率。
给它加固定止盈。第 23 篇量过,这一篇的收益分布再次说明为什么:收益全部集中在极少数几笔的尾巴上,而固定止盈专门砍掉尾巴。
**把加仓当成普适的改进。**第十节:BTC 上加仓把回撤砍掉三分之一,SPY 上反而让年化从 7.26% 掉到 4.68%。它是一个和波动结构有关的选择。
**照搬多空对称。**第十一节:SPY 和 AAPL 上做空那一半是纯亏损(胜率 10%–12%)。原型不等于配方。
**在震荡市里怪策略。**第十二节:BTC 上低效率比环境进场的 24 笔九年合计只有 13.8R。趋势跟随在震荡市里亏钱不是失效,那正是它买保险付的权利金。
用「整个参数族都赚钱」当成有效的证明。第 30 篇的实验三刚刚演示过反例:那条策略 1,575 组里 51.5% 的夏普都大于 1,照样样本外跑输买入持有。真正的证据是蒙特卡洛对照(第 14.2 节:0/300)和逐笔 t 值(第十五节:全部超过 2)。
**拿年化去比买入持有,然后得出「跑输了所以没用」。**第十五节:海龟在 BTC 上的年化低于买入持有,回撤只有它的四分之一。两条曲线要放在同一个风险水平上比。
十八、小结
- 趋势跟随的全部内容是一句话:价格创新高就买,跌破就走。它唯一的假设是「大的行情会持续得比大多数人愿意相信的更久」,而这个假设大部分时候是错的。
- 假突破:最宽松的口径下,20 日通道的突破有 28.85%(SPY)/ 31.07%(AAPL)/ 41.74%(BTC)二十根之后还在突破价下方;真的按规则交易(带 2N 止损),亏钱的比例升到 55%–67%。止损把假突破的代价从「跌 9%」压成「亏 1R」。
- 收益结构:胜率 44.8% / 45.8% / 32.9%,盈亏比 2.61 / 5.16 / 10.06,三个标的的中位数一笔全是负的(BTC 正好 −1.00R),偏度 1.17 / 1.20 / 3.37。
- 集中度:拿掉最赚的十笔(约 15% 的交易),SPY 剩 −17.5%、BTC 剩 −2.3%——九年收益归零。所以不能挑着做。
- 凸性:只做多长得像一份看涨期权;多空都做,BTC 上左边那一头抬起来(20 根跌 16% 的那一组策略中位 +1.45%),是一条真正的微笑曲线。SPY 和 AAPL 上抬不起来。
- 金字塔加仓:总风险固定在 4% 时,BTC 上年化几乎不变(32.12% → 31.60%)而回撤砍掉三分之一(−34.66% → −21.56%);SPY 上反而更差。
- 做空那一半:SPY −15.02%、AAPL −14.79%、BTC +4.98%。在长期向上的标的上做空趋势是逆着漂移下注。
- 检验:1983 年那组 (20,10) 在 BTC 上排 1/33、落差只有 +0.17(不是尖峰是高地);33 组参数没有一组亏钱;每年重挑参数三个标的全部跑输固定参数;蒙特卡洛把趋势拆掉之后,300 条假数据里 0 条比真实的好。
- 对照:海龟在三个标的上全面优于主线 v4,而且是这门课第一条逐笔 t 值全部超过 2 的策略(2.21 / 3.48 / 2.70)。它在年化上跑不赢买入持有,但 BTC 上用 −22.78% 的回撤换来了 30.38%(买入持有是 −83.19% 换 37.94%)。
- 新模块
talab.trend14 个测试,全课共 322 个(不装 TA-Lib 时 290 通过 + 32 跳过)。
十九、完整代码与测试
talab/trend.py
"""talab.trend:趋势跟随与突破。第 31 篇。
第六部分讲完了怎么检验,这一篇开始讲**被检验的东西**:一类真实存在、公开了四十年、
到现在还有人靠它管钱的策略原型。
趋势跟随的全部内容可以写成一句话:**价格创新高就买,跌破就走。**
它没有预测,没有估值,没有「这次不一样」。它唯一的假设是——
> 大的行情会持续得比大多数人愿意相信的更久,而小的行情会来回震荡。
这个假设**大部分时候是错的**(第 31 篇实测:七成的突破是假的),
但它错的时候亏得少、对的时候赚得多。这个模块就是把这句话拆成可以测量的零件:
1. **通道与突破**:`donchian`、`breakouts`、`follow_through`
2. **海龟系统**:`TurtlePlan` + `turtle`(含 2N 止损、按 N 定仓位、金字塔加仓)
3. **收益结构**:`r_profile`、`contribution`、`convexity`
⚠️ **通道突破是这门课里唯一可以「当根成交」的信号**:通道那条线是用**前面几根**的
最高价算出来的,开盘之前就已经画好了,价格在这一根里碰到它是一件**当时就能看见**的事。
这和第 27 篇「收盘算出来的信号只能下一根成交」不矛盾——那条规矩管的是用到**当根收盘价**
的信号(均线、RSI、收盘价创新高)。判据始终是同一句:**这个数,在那一刻能不能算出来。**
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from talab.backtest import Account
SIDES = ("long", "short", "both")
FILLS = ("touch", "next_open")
# ---------------------------------------------------------------------------
# 一、唐奇安通道与突破
# ---------------------------------------------------------------------------
def donchian(high: pd.Series, low: pd.Series, n: int = 20) -> pd.DataFrame:
"""唐奇安通道:过去 n 根的最高价、最低价,以及两者的中点。
⚠️ 两条轨都 `shift(1)`:**第 i 根上用的是第 i−1 根为止的最高价**。
不推迟的话,「创 20 日新高」这个条件会永远成立(今天的最高价当然是包含今天的最高价之一)。
"""
return pd.DataFrame({
"上轨": high.rolling(n).max().shift(1),
"下轨": low.rolling(n).min().shift(1),
}).assign(中轨=lambda t: (t["上轨"] + t["下轨"]) / 2)
def breakouts(df: pd.DataFrame, n: int = 20, horizon: int = 20) -> pd.DataFrame:
"""每一次**向上**突破 n 日通道:突破时的价格,以及之后 horizon 根里发生了什么。
连续多根都在通道上方只算**一次**突破(第一次算,后面的不算),
否则一段大趋势会被数成几十次「突破」,假突破的比例就被稀释成假的。
"""
channel = donchian(df["high"], df["low"], n)
high, low, close = (df[x].to_numpy(float) for x in ["high", "low", "close"])
upper = channel["上轨"].to_numpy()
rows, armed = [], True
for i in range(len(close)):
if np.isnan(upper[i]):
continue
if high[i] > upper[i]:
if armed:
ahead = close[i + 1:i + 1 + horizon]
worst = low[i + 1:i + 1 + horizon]
rows.append({"时间": df.index[i], "突破价": upper[i], "收盘": close[i],
"之后最高收盘": float(ahead.max()) if len(ahead) else np.nan,
"之后最低价": float(worst.min()) if len(worst) else np.nan,
f"{horizon} 根后": float(ahead[-1]) if len(ahead) == horizon else np.nan})
armed = False
elif close[i] < upper[i]:
armed = True # 回到通道里面,下一次才算新的突破
out = pd.DataFrame(rows)
if len(out):
out["之后涨幅"] = out[f"{horizon} 根后"] / out["突破价"] - 1
out["最大顺势"] = out["之后最高收盘"] / out["突破价"] - 1
out["最大逆势"] = out["之后最低价"] / out["突破价"] - 1
return out
def follow_through(table: pd.DataFrame, threshold: float = 0.0) -> pd.Series:
"""一批突破里,有多少是「走出去了」的,有多少是假的。
`threshold` 是判定标准(`之后涨幅` 要超过它才算真)。默认 0:**只要没回到突破价以下就算真**——
这已经是最宽松的口径了,而第 31 篇实测下来仍然只有三成多能过。
"""
real = table["之后涨幅"] > threshold
return pd.Series({
"突破次数": float(len(table)), "走出去的": float(real.sum()),
"假突破比例": float((~real).mean()),
"真的那些平均涨": float(table.loc[real, "之后涨幅"].mean()) if real.any() else np.nan,
"假的那些平均跌": float(table.loc[~real, "之后涨幅"].mean()) if (~real).any() else np.nan,
"全部平均": float(table["之后涨幅"].mean()),
"最大顺势中位": float(table["最大顺势"].median()),
"最大逆势中位": float(table["最大逆势"].median()),
})
# ---------------------------------------------------------------------------
# 二、海龟系统
# ---------------------------------------------------------------------------
@dataclass
class TurtlePlan:
"""海龟交易法则,1983 年那一版的核心设定。
原版有两套系统:系统一是 20 日突破进、10 日突破出,系统二是 55 日进、20 日出。
这里用同一个 dataclass 表示,改两个数字就换系统。
"""
entry: int = 20 # 进场:突破几日通道
exit: int = 10 # 出场:反向几日通道
atr_period: int = 20 # N(原版就叫 N,其实是 20 日 ATR)
stop_atr: float = 2.0 # 止损放在进场价外几个 N
risk: float = 0.01 # 一个「单位」冒账户的百分之几
max_units: int = 4 # 最多加到几个单位
add_atr: float = 0.5 # 每走几个 N 加一个单位
side: str = "long" # long / short / both
fill: str = "touch" # touch:碰到通道就成交;next_open:等下一根开盘
fee_rate: float = 0.0 # 单边费率(第 28 篇)
def __post_init__(self):
if self.side not in SIDES:
raise ValueError(f"side 只能是 {SIDES} 之一,收到 {self.side!r}")
if self.fill not in FILLS:
raise ValueError(f"fill 只能是 {FILLS} 之一,收到 {self.fill!r}")
if self.entry < 2 or self.exit < 2 or self.atr_period < 2:
raise ValueError("三个窗口都要至少是 2")
if self.max_units < 1:
raise ValueError("至少要允许一个单位")
def describe(self) -> pd.Series:
return pd.Series({
"进场": f"突破 {self.entry} 日通道",
"出场": f"反向突破 {self.exit} 日通道",
"N": f"{self.atr_period} 日 ATR",
"初始止损": f"{self.stop_atr} 个 N",
"一个单位的风险": f"账户的 {self.risk:.1%}",
"加仓": f"每走 {self.add_atr} 个 N 加一个单位,最多 {self.max_units} 个",
"方向": {"long": "只做多", "short": "只做空", "both": "多空都做"}[self.side],
"成交": {"touch": "碰到通道就成交", "next_open": "等下一根开盘"}[self.fill],
})
def turtle(df: pd.DataFrame, plan: TurtlePlan | None = None, equity: float = 100_000.0) -> dict:
"""按海龟法则跑一遍历史。
每一根 K 线上按固定顺序做五件事(和第 27 篇的引擎同一套规矩):
0. **补成交**:`fill="next_open"` 时,上一根定下来的动作用这一根的开盘价成交
1. **出场**:先看止损,再看反向通道;两个都碰到时按**先止损**算(悲观口径)
2. **加仓**:价格又顺走了 `add_atr` 个 N 就加一个单位,并把**全部**止损上移
3. **估值**:按收盘价记权益
4. **进场**:空仓时,价格碰到 `entry` 日通道就进
通道线来自前面几根,所以 1、2、4 默认在**当根**成交(见模块开头那条 ⚠️)。
把 `fill` 换成 `"next_open"` 就能量出「当根成交」这个假设值多少钱——
如果它值很多,那说明这条策略靠的是那一瞬间的价格,不是趋势。
返回资金曲线、逐笔交易(一整个仓位算一笔,带 R 倍数)、每一次加仓的明细。
"""
from talab import indicators as I
plan = plan or TurtlePlan()
o, h, l, c = (df[x].to_numpy(float) for x in ["open", "high", "low", "close"])
n_value = I.atr(df["high"], df["low"], df["close"], plan.atr_period).shift(1).to_numpy()
enter = donchian(df["high"], df["low"], plan.entry)
leave = donchian(df["high"], df["low"], plan.exit)
up_in, down_in = enter["上轨"].to_numpy(), enter["下轨"].to_numpy()
up_out, down_out = leave["上轨"].to_numpy(), leave["下轨"].to_numpy()
# ⚠️ 空头的数量只记在 `units` 里,`account.shares` 全程是 0——
# 所以权益一律用下面的 `position_value` 算,不要用 `account.equity`。
account = Account(cash=float(equity))
curve, trades, adds, error = [], [], [], 0.0
side = 0 # +1 做多,−1 做空,0 空仓
units: list[tuple[float, float]] = [] # 每个单位的(成交价,数量)
stop = last_price = risk_amount = np.nan
opened_at = -1
def position_value(price: float) -> float:
"""权益:做多是「现金 + 持仓市值」,做空是「现金 − 买回来要花的钱」。
⚠️ 做空时卖出所得已经记在现金里了,所以**不要再减一次借来的钱**——
第一版就是这么写的,结果最大回撤算出 −106%(权益跑到 0 以下)。
「回撤超过 100%」永远是记账错了,不是策略真的这么惨。
"""
held = sum(q for _, q in units)
return account.cash + side * held * price
def close_all(i: int, price: float, reason: str) -> None:
nonlocal side, units, stop, opened_at
held = sum(q for _, q in units)
cost = sum(p * q for p, q in units)
if side > 0:
account.sell(price, held, plan.fee_rate)
profit = price * held - cost - abs(price * held) * plan.fee_rate
else:
account.cash -= price * held * (1 + plan.fee_rate) # 买回来平掉空头
account.fees += price * held * plan.fee_rate
profit = cost - price * held - abs(price * held) * plan.fee_rate
trades.append({"进场日": df.index[opened_at], "方向": "多" if side > 0 else "空",
"第一个单位的价": units[0][0], "平均价": cost / held if held else np.nan,
"单位数": len(units), "出场日": df.index[i], "出场价": price,
"原因": reason, "盈亏": profit, "R": profit / risk_amount,
"根数": i - opened_at})
side, units, stop, opened_at = 0, [], np.nan, -1
def open_unit(i: int, price: float, direction: int) -> bool:
nonlocal side, stop, last_price, risk_amount
step = plan.stop_atr * n_value[i]
want = position_value(price) * plan.risk / step # 一个单位:冒账户的 risk
if direction > 0:
shares = min(want, account.affordable(price, plan.fee_rate))
else:
shares = min(want, position_value(price) / (price * (1 + plan.fee_rate)))
if shares <= 1e-12:
return False # 买不起了:别再往下加
if direction > 0:
account.buy(price, shares, plan.fee_rate)
else:
account.cash += price * shares * (1 - plan.fee_rate)
account.fees += price * shares * plan.fee_rate
if not units:
side = direction
risk_amount = shares * step # 1R = 第一个单位的初始风险
units.append((price, shares))
last_price = price
stop = price - direction * step # 加仓之后全部止损跟着走
adds.append({"时间": df.index[i], "第几个单位": len(units), "成交价": price,
"数量": shares, "止损移到": stop})
return True
first = int(np.argmax(~np.isnan(n_value) & ~np.isnan(up_in) & ~np.isnan(down_in)))
pending: tuple | None = None # fill="next_open" 时挂着的动作
for i in range(first, len(c)):
# 第 0 步:上一根定下来的动作,用这一根的开盘价成交
if pending is not None:
what, extra = pending
pending = None
if what == "close" and side:
close_all(i, o[i], extra)
elif what == "add" and side and len(units) < plan.max_units:
open_unit(i, o[i], side)
elif what == "open" and not side:
opened_at = i
open_unit(i, o[i], extra)
# 第 1 步:出场
later = plan.fill == "next_open"
if side > 0:
if l[i] <= stop:
pending = ("close", "止损") if later else pending
if not later:
close_all(i, min(o[i], stop), "止损")
elif l[i] <= down_out[i]:
pending = ("close", "通道出场") if later else pending
if not later:
close_all(i, min(o[i], down_out[i]), "通道出场")
elif side < 0:
if h[i] >= stop:
pending = ("close", "止损") if later else pending
if not later:
close_all(i, max(o[i], stop), "止损")
elif h[i] >= up_out[i]:
pending = ("close", "通道出场") if later else pending
if not later:
close_all(i, max(o[i], up_out[i]), "通道出场")
# 第 2 步:加仓
while side and pending is None and len(units) < plan.max_units:
level = last_price + side * plan.add_atr * n_value[i]
if side > 0 and h[i] >= level:
if later:
pending = ("add", None)
break
if not open_unit(i, max(o[i], level), 1):
break
elif side < 0 and l[i] <= level:
if later:
pending = ("add", None)
break
if not open_unit(i, min(o[i], level), -1):
break
else:
break
# 第 3 步:估值
value = position_value(c[i])
held = sum(q for _, q in units)
error = max(error, abs(account.cash + side * held * c[i] - value))
curve.append(value)
# 第 4 步:进场
if not side and pending is None and n_value[i] > 0:
if plan.side in ("long", "both") and h[i] > up_in[i]:
if later:
pending = ("open", 1)
else:
opened_at = i
open_unit(i, max(o[i], up_in[i]), 1)
elif plan.side in ("short", "both") and l[i] < down_in[i]:
if later:
pending = ("open", -1)
else:
opened_at = i
open_unit(i, min(o[i], down_in[i]), -1)
if side: # 最后一根还拿着
close_all(len(c) - 1, c[-1], "未平仓")
index = df.index[first:]
columns = ["进场日", "方向", "第一个单位的价", "平均价", "单位数", "出场日", "出场价",
"原因", "盈亏", "R", "根数"]
return {"资金曲线": pd.Series(curve, index=index, name="权益"),
"交易": pd.DataFrame(trades, columns=columns),
"加仓": pd.DataFrame(adds, columns=["时间", "第几个单位", "成交价", "数量", "止损移到"]),
"手续费合计": account.fees, "记账误差": error,
"权益最低点": float(min(curve)) if curve else np.nan}
# ---------------------------------------------------------------------------
# 三、收益结构
# ---------------------------------------------------------------------------
def r_profile(r) -> pd.Series:
"""一串 R 倍数的形状:胜率、盈亏比、期望,以及**偏度**。
趋势跟随的招牌是「胜率低、盈亏比高、偏度为正」。
第 23 篇的 `risk.expectancy` 给的是前两项,这里补上尾巴那一头——
**正偏度的意思是「亏损可以预料,盈利不可以」**,而策略的全部收益都藏在那个不可预料的尾巴里。
"""
r = pd.Series(r).dropna().astype(float)
wins, losses = r[r > 0], r[r <= 0]
return pd.Series({
"笔数": float(len(r)), "胜率": float((r > 0).mean()),
"平均盈利 R": float(wins.mean()) if len(wins) else 0.0,
"平均亏损 R": float(-losses.mean()) if len(losses) else 0.0,
"盈亏比": float(wins.mean() / -losses.mean()) if len(losses) and losses.mean() else np.inf,
"期望 R": float(r.mean()), "中位 R": float(r.median()),
"偏度": float(r.skew()), "最大一笔 R": float(r.max()), "最差一笔 R": float(r.min()),
"合计 R": float(r.sum()),
})
def contribution(r, tops=(1, 3, 5, 10)) -> pd.DataFrame:
"""把最赚钱的几笔拿掉,还剩多少。
这张表回答一个很不舒服的问题:**这条策略的收益,有多少集中在极少数几笔上?**
集中度高不是缺点,是趋势跟随的**定义**——但你必须知道它有多高,
因为那意味着「错过那几笔」和「策略失效」在账户上是同一件事。
"""
r = pd.Series(r).dropna().astype(float).sort_values(ascending=False)
total = r.sum()
rows = [{"拿掉最赚的几笔": 0, "剩下多少 R": total, "占原来的": 1.0, "占总笔数": 0.0}]
for n in tops:
if n >= len(r):
break
left = total - r.iloc[:n].sum()
rows.append({"拿掉最赚的几笔": n, "剩下多少 R": left, "占原来的": left / total if total else np.nan,
"占总笔数": n / len(r)})
return pd.DataFrame(rows)
def convexity(strategy: pd.Series, market: pd.Series, buckets: int = 5,
window: int = 20) -> pd.DataFrame:
"""凸性表:把市场按 `window` 根的涨跌分成几组,看策略在每一组里赚多少。
趋势跟随的收益应该是一条**微笑曲线**——市场大涨时赚、市场大跌时也赚(或者至少不怎么亏)、
市场不涨不跌时小亏。这个形状和「买了一份跨式期权」是一回事,
小亏就是权利金。第 32 篇的均值回归会给出正好相反的形状(凹性)。
"""
if not 0 < buckets <= 20:
raise ValueError("buckets 要在 1 和 20 之间")
both = pd.concat([strategy.rename("策略"), market.rename("市场")], axis=1).dropna()
rolled = pd.DataFrame({
"策略": (1 + both["策略"]).rolling(window).apply(np.prod, raw=True) - 1,
"市场": (1 + both["市场"]).rolling(window).apply(np.prod, raw=True) - 1,
}).dropna()
groups = pd.qcut(rolled["市场"], buckets, labels=False, duplicates="drop")
out = rolled.groupby(groups).agg(根数=("市场", "size"), 市场中位=("市场", "median"),
策略中位=("策略", "median"), 策略平均=("策略", "mean"),
策略赚钱的比例=("策略", lambda x: float((x > 0).mean())))
out.index = [f"第 {i + 1} 组" for i in range(len(out))]
out.index.name = f"按市场 {window} 根涨跌分组"
return out
talab/tests/test_trend.py
"""talab.trend 的测试(第 31 篇)。全部用手工构造的价格,每个数都能自己算一遍。"""
import numpy as np
import pandas as pd
import pytest
from talab import trend as T
def frame(closes, spread: float = 0.0) -> pd.DataFrame:
"""把一串收盘价变成 K 线:开盘 = 收盘,最高/最低按 spread 往两边撑开。"""
close = np.asarray(closes, dtype=float)
return pd.DataFrame({"open": close, "high": close * (1 + spread),
"low": close * (1 - spread), "close": close},
index=pd.date_range("2024-01-01", periods=len(close), freq="D"))
def ramp(flat: int = 30, up: int = 40, base: float = 100.0, step: float = 2.0) -> pd.DataFrame:
"""先横盘(锯齿),再一路上涨——足够海龟进场、加满四个单位、再被通道赶出来。"""
noise = [base + (1 if i % 2 else -1) for i in range(flat)]
rise = [base + step * (i + 1) for i in range(up)]
fall = [rise[-1] - step * 3 * (i + 1) for i in range(15)]
return frame(noise + rise + fall)
def test_donchian_shifts_by_one_bar():
"""不推迟一根,「创 n 日新高」会永远成立——今天的最高价当然是包含今天在内的最高价之一。"""
bars = frame([1, 2, 3, 4, 5])
channel = T.donchian(bars["high"], bars["low"], 3)
assert np.isnan(channel["上轨"].iloc[2]) # 前三根凑不齐「前 3 根」
assert channel["上轨"].iloc[3] == 3 and channel["下轨"].iloc[3] == 1
assert channel["中轨"].iloc[3] == 2
assert (bars["high"] > channel["上轨"]).iloc[3:].all() # 一路新高:确实每根都突破
wrong = bars["high"].rolling(3).max() # 忘了 shift 的写法
assert not (bars["high"] > wrong).iloc[3:].any() # 永远不成立,信号一个都没有
def test_breakouts_counts_one_per_episode():
"""连着十根都在通道上方只算一次突破,否则一段趋势会被数成十次「突破」。"""
bars = frame([10] * 5 + [11, 12, 13, 14, 15] + [9] * 5 + [16, 17])
table = T.breakouts(bars, n=3, horizon=2)
assert len(table) == 2 # 涨那一段一次,最后拉起来又一次
assert table["时间"].iloc[0] == bars.index[5]
assert table["突破价"].iloc[0] == 10
def test_breakouts_measures_what_happened_after():
bars = frame([10] * 5 + [12, 14, 11])
table = T.breakouts(bars, n=3, horizon=2)
row = table.iloc[0]
assert row["突破价"] == 10 and row["收盘"] == 12
assert row["之后涨幅"] == pytest.approx(11 / 10 - 1) # 两根之后收在 11
assert row["最大顺势"] == pytest.approx(14 / 10 - 1) # 中间最高收到 14
assert row["最大逆势"] == pytest.approx(11 / 10 - 1)
def test_follow_through_splits_real_from_fake():
table = pd.DataFrame({"之后涨幅": [0.1, -0.05, 0.2, -0.02],
"最大顺势": [0.3, 0.01, 0.25, 0.0],
"最大逆势": [-0.01, -0.08, -0.02, -0.05]})
out = T.follow_through(table)
assert out["突破次数"] == 4 and out["走出去的"] == 2
assert out["假突破比例"] == pytest.approx(0.5)
assert out["真的那些平均涨"] == pytest.approx(0.15)
assert out["假的那些平均跌"] == pytest.approx(-0.035)
assert out["全部平均"] == pytest.approx(0.0575)
def test_turtle_plan_validates_and_describes():
text = T.TurtlePlan().describe()
assert text["进场"] == "突破 20 日通道" and text["初始止损"] == "2.0 个 N"
assert T.TurtlePlan(entry=55, exit=20).describe()["出场"] == "反向突破 20 日通道"
for bad in [dict(side="随便"), dict(fill="随便"), dict(entry=1), dict(max_units=0)]:
with pytest.raises(ValueError):
T.TurtlePlan(**bad)
def test_turtle_books_balance_exactly():
"""现金 + 持仓 × 价格 = 权益,全程误差必须是 0(第 27 篇那条规矩)。"""
result = T.turtle(ramp(), T.TurtlePlan(atr_period=5, entry=10, exit=5))
assert result["记账误差"] == 0.0
assert result["权益最低点"] > 0 # 权益永远不该跌到 0 以下
assert len(result["交易"]) >= 1
def test_turtle_enters_at_the_channel_and_stops_two_n_away():
"""止损正好放在 2 个 N 之外,被打掉的那一笔正好是 −1R。
⚠️ 横盘那一段要用锯齿而不是一条直线:**真实波幅全是 0 的话 N 也是 0**,
除不了,海龟一笔都不会开——这是写这类测试时最容易踩的坑。
"""
zigzag = [100 + (1 if i % 2 else -1) for i in range(14)]
bars = frame(zigzag + [108, 60]) # 锯齿 → 跳上去 → 砸下来
plan = T.TurtlePlan(entry=10, exit=5, atr_period=5, stop_atr=2.0, max_units=1, risk=0.01)
result = T.turtle(bars, plan)
trade = result["交易"].iloc[0]
add = result["加仓"].iloc[0]
n = (trade["第一个单位的价"] - add["止损移到"]) / plan.stop_atr
assert trade["第一个单位的价"] == pytest.approx(108) # 开盘就跳过了通道,只能按开盘价成交
assert add["止损移到"] == pytest.approx(trade["第一个单位的价"] - 2 * n)
assert trade["原因"] == "止损"
assert trade["R"] < -1.0 # 跳空跳过了止损:亏得比 1R 还多
def test_turtle_pyramids_and_walks_the_stop_up():
result = T.turtle(ramp(), T.TurtlePlan(atr_period=5, entry=10, exit=5, max_units=4))
adds = result["加仓"]
first = adds[adds["第几个单位"] == 1].index[0]
block = adds.loc[first:first + 3]
assert list(block["第几个单位"]) == [1, 2, 3, 4] # 最多加到四个
assert block["成交价"].is_monotonic_increasing # 每一个单位都买得更贵
assert block["止损移到"].is_monotonic_increasing # 止损跟着往上走
assert (adds["第几个单位"] <= 4).all()
def test_turtle_max_units_changes_the_size_not_the_signal():
"""加仓只改仓位大小,不改进场出场的时点——笔数和进场日应该一模一样。"""
bars = ramp()
one = T.turtle(bars, T.TurtlePlan(atr_period=5, entry=10, exit=5, max_units=1))["交易"]
four = T.turtle(bars, T.TurtlePlan(atr_period=5, entry=10, exit=5, max_units=4))["交易"]
assert list(one["进场日"]) == list(four["进场日"])
assert (four["单位数"] >= one["单位数"]).all()
assert four["R"].sum() > one["R"].sum() # 涨上去的那一段,加仓赚得更多
def test_turtle_next_open_fills_one_bar_later():
zigzag = frame([100 + (1 if i % 2 else -1) for i in range(14)], spread=0.02)
assert float(zigzag["high"].iloc[4:14].max()) == pytest.approx(103.02) # 10 日通道上轨
# 第 15 根:开在通道下方 102,盘中穿上去;第 16 根直接开在 107
extra = pd.DataFrame({"open": [102.0, 107.0, 110.0], "high": [106.0, 111.0, 114.0],
"low": [101.0, 106.0, 109.0], "close": [105.0, 110.0, 113.0]},
index=pd.date_range(zigzag.index[-1] + pd.Timedelta(days=1), periods=3))
bars = pd.concat([zigzag, extra])
common = dict(entry=10, exit=5, atr_period=5, max_units=1)
touch = T.turtle(bars, T.TurtlePlan(fill="touch", **common))["交易"].iloc[0]
later = T.turtle(bars, T.TurtlePlan(fill="next_open", **common))["交易"].iloc[0]
assert touch["第一个单位的价"] == pytest.approx(103.02) # 就在通道那条线上成交
assert later["进场日"] == touch["进场日"] + pd.Timedelta(days=1)
assert later["第一个单位的价"] == pytest.approx(107.0) # 等一根,开盘已经 107 了
def test_turtle_short_side_is_the_mirror_image():
falling = ramp()
mirrored = falling.copy()
top = float(falling["high"].max()) + 10
mirrored["open"], mirrored["close"] = top - falling["open"], top - falling["close"]
mirrored["high"], mirrored["low"] = top - falling["low"], top - falling["high"]
plan = dict(atr_period=5, entry=10, exit=5)
up = T.turtle(falling, T.TurtlePlan(side="long", **plan))
down = T.turtle(mirrored, T.TurtlePlan(side="short", **plan))
assert len(down["交易"]) == len(up["交易"])
assert down["记账误差"] == 0.0
assert down["交易"]["R"].sum() > 0 # 镜像的下跌里,做空同样赚钱
def test_r_profile_matches_hand_arithmetic():
out = T.r_profile([-1, -1, -1, 3, 5])
assert out["笔数"] == 5 and out["胜率"] == pytest.approx(0.4)
assert out["平均盈利 R"] == pytest.approx(4.0)
assert out["平均亏损 R"] == pytest.approx(1.0)
assert out["盈亏比"] == pytest.approx(4.0)
assert out["期望 R"] == pytest.approx(1.0)
assert out["中位 R"] == pytest.approx(-1.0) # 典型的一笔是亏的
assert out["合计 R"] == pytest.approx(5.0)
assert out["偏度"] > 0 # 正偏:亏损可预料,盈利不可
def test_contribution_shows_how_concentrated_the_profit_is():
out = T.contribution([-1, -1, -1, -1, 10], tops=(1, 2))
assert out.loc[0, "剩下多少 R"] == pytest.approx(6.0)
assert out.loc[1, "拿掉最赚的几笔"] == 1
assert out.loc[1, "剩下多少 R"] == pytest.approx(-4.0) # 拿掉那一笔就亏钱了
assert out.loc[1, "占原来的"] < 0
assert out.loc[1, "占总笔数"] == pytest.approx(0.2)
def test_convexity_finds_the_smile():
"""造一个真的趋势跟随者:顺着最近 20 根的方向做。它该在两头赚、中间亏。"""
rng = np.random.default_rng(31)
drift = np.concatenate([[0.005] * 200, [-0.005] * 200,
[0.005 if i % 2 else -0.005 for i in range(200)]])
market = pd.Series(drift + rng.normal(0, 0.004, 600), # 加一点噪声,免得分位数挤在一起
index=pd.date_range("2022-01-01", periods=600))
direction = np.sign(market.rolling(20).sum().shift(1)).fillna(0.0)
strategy = market * direction - 0.0005 # 顺势做,外加一点点摩擦
out = T.convexity(strategy, market, buckets=5, window=20)
assert len(out) == 5 and out["根数"].sum() == 600 - 19
assert out["市场中位"].is_monotonic_increasing # 分组本身按市场涨跌排好了
assert out["策略中位"].iloc[0] > out["策略中位"].iloc[2] # 市场大跌那一组:做空赚钱
assert out["策略中位"].iloc[-1] > out["策略中位"].iloc[2] # 市场大涨那一组:做多赚钱
with pytest.raises(ValueError):
T.convexity(strategy, market, buckets=0)
两个 venv 的测试结果
322 passed in 1.16s
290 passed, 32 skipped in 1.14s
练习
- 海龟原版系统一有一条这里没实现的规则:上一次系统一的信号如果赚钱了,这一次就跳过(跳过的那次用系统二 55 日通道接住)。把它加进
TurtlePlan,在三个标的上量一量它值多少。 - 第六节量的是「等下一根开盘」。再加一种:要求收盘价站上通道才算突破(第 20 篇的收盘触发)。它会滤掉多少假突破,又会让你错过多少真的?
convexity现在按 20 根的市场涨跌分组。换成按第 11 篇的市场状态(趋势 / 震荡 / 过渡)分组,图会变成什么样?- 第十节的加仓间距固定是 0.5 个 N。把它当成一个参数扫一遍(0.25 到 2.0),在三个标的上看回撤和年化的取舍曲线——SPY 上有没有一个间距能让加仓变成正贡献?
- 把海龟搬到第 19 篇那 704 个永续合约上,一次持有成交额前 20 里发出信号的那些(等权)。组合的回撤会比单个标的浅多少?⚠️ 记得用第 28 篇的「当时的名单」,不是今天的。
- 第十五节的对照里,主线 v4 输在出场太慢。把主线的出场从「死叉」换成「跌破 10 日通道下轨」,其余不变,它能追上海龟多少?
- 写一个
stress(df, plan, ...):把 K 线的最高最低价按一个比例往外撑(模拟更宽的日内波动),看这套系统的成绩对「盘中路径」有多敏感(第 27 篇那个最贵的假设)。
小检查答案
- 区别在于这个数什么时候能算出来。通道上轨用的是前面 20 根的最高价,开盘之前就画好了,所以价格在这一根里碰到它是当时就看得见的事;而「收盘价创 20 日新高」要等当根收盘才知道,那时候这一根已经结束了,最早只能下一根成交。第六节还量了这个假设值多少钱:0 到 3.5 个百分点。
- 胜率低是设计的结果,不是缺陷。2N 止损把每次亏损钉死在 1R 左右,代价就是很多最终会走出去的突破也被打掉了;换来的是赚的那一头没有上限(BTC 盈亏比 10.06、最大一笔 76.44R)。该看的是期望值(BTC +4.23R/笔)和偏度(+3.37),以及第 29 篇的逐笔 t 值(2.70)。一个反问:如果有人给你一套胜率 90% 的系统,你要问的第一句话是「亏的那 10% 一次亏多少」。
- 风险是你跳过的那一次,可能正是撑起九年收益的那几笔之一。第 8.1 节:拿掉最赚的十笔(不到 15%),SPY 和 BTC 上九年收益变成负数。而在信号发出的那一刻,你手上没有任何能分辨真假的信息——第三节的分布宽得离谱,第一节那十次里七次是假的。要么全做,要么别做。
- **不能这么说,它说的是「在长期向上的标的上,趋势跟随的做空那一半不成立」。**只做多的版本在 SPY 上是 +6.88%(同风险口径)、夏普 0.819,并不差。海龟的多空对称是为几十个期货品种的组合设计的——那些品种没有股票这样的长期漂移。原型要按标的的性质裁剪,不能照搬。
- 因为趋势跟随赚的不是漂移,是序列相关性。块自助法保留了每一天的收益率分布和一个月内的波动率聚集,但把一个月以上的走势打散了;假市场照样涨,只是涨法变成没有方向的抖动。这套系统一多半时间空仓(在场比例 38%–54%),接不住这种涨法,还要一次次付止损的钱。这也正好说明它的收益来源是什么——把趋势拿掉,收益就没了。
第 32 篇讲均值回归,也就是趋势跟随的镜像。决策点是:SPY 连跌五天、偏离 20 日均线两个标准差,买不买?内容是布林带回归、RSI 回归、区间交易,以及它的收益结构——高胜率、偶发的大额亏损、凹性(和这一篇的微笑曲线正好反过来)。最后把两条策略放进同一个账户,看资金曲线会变成什么样。