| 本篇位置 | 第二部分「读懂价格行为」的最后一篇。前五篇各讲了一样工具,这一篇把它们放到同一根 K 线上用;第 12 篇起进入技术指标 |
| 用到的数据 | AAPL、SPY 日线(2016-09 至 2026-09);BTCUSDT 现货日线,以及用 1 分钟线合成的 4 小时线和 1 小时线(2017-08 至 2026-08) |
| 动手 | talab.structure 第三部分:市场状态、三类入场信号、「先碰到哪条线」的检验、两份标注的比较,附 7 个测试;一个逐根回放行情的小工具;实验一:结构标注 |
| 读完你能 | 在任何一根 K 线收盘时,说清楚市场处在哪种状态、依据是哪几个已经确认的价格;把回调、突破、失败突破写成程序能执行的规则;知道这些状态和入场在数据上有多少支持;分辨自己看图时的判断里,哪些用到了当时还不知道的信息 |
一、先做一个决定
这一篇的决策点和前面几篇不一样:你不知道这是哪个标的、哪一年。
下面是一段日线,一共 274 根。日期被隐藏了,价格按第一根的收盘价换算成 100。我会让行情一段一段地露出来,停四次,每次只给你看到那一根收盘为止的 K 线。
每次停下来,回答两个问题:
- 现在是什么状态? 上升趋势、下降趋势、震荡,还是正在转变(过渡)?
- 你做什么? A. 做多 B. 做空 C. 不动
先写下答案,再往下看。 后面的决定会露出前面决定之后的走势,所以请按顺序来。第六节揭晓这是哪段行情。
决定 1:第 114 根

前 75 根的收盘价在 95 到 108 之间来回,最低是第 45 根的 95.76。之后是一段上涨,涨到第 108 根的 125.45。3 根之后回落到 118.55(第 111 根)。今天(第 114 根)开在 118.77,收在 120.67,高于昨天的最高价 118.89。
决定 1:
第几根 open high low close
date
2022-01-10 113 116.53 118.89 115.91 118.68
2022-01-11 114 118.77 120.74 117.73 120.67
决定 2:第 167 根

决定 1 之后,价格跌到第 125 根的 109.74,反弹到第 134 根的 121.65,又跌到第 156 根的 103.94。然后是 11 天连续收涨,今天收在 123.50,第一次收在第 134 根那个 121.65 的上方。
决定 2:
第几根 open high low close
date
2022-03-28 166 118.82 121.27 118.70 121.18
2022-03-29 167 121.93 123.54 121.69 123.50
决定 3:第 169 根

只过了两天。第 168 根的连涨停了,今天(第 169 根)收在 120.50,几乎收在全天最低,又回到了 121.65 的下方。
决定 3:
第几根 open high low close
date
2022-03-30 168 123.22 123.95 121.94 122.68
2022-03-31 169 122.73 122.86 120.35 120.50
决定 4:第 213 根

决定 3 之后,价格在第 171 根又收到过 123.14,然后一路下跌:第 187 根 108.05,反弹到第 192 根的 114.57,再跌到第 203 根的 94.92,这是整段行情到目前为止的最低收盘价。随后反弹到昨天(第 212 根)的 104.50。今天收在 100.47,低于昨天的最低价 101.50。
决定 4:
第几根 open high low close
date
2022-06-02 212 102.17 104.55 101.50 104.50
2022-06-03 213 101.53 102.26 99.84 100.47
四个答案都写下来了吗?第六节揭晓。
⚠️ 在书上做这个练习,你只能「看到」我停下来的四根。真实交易里每一根都要做决定,大部分时候的决定是不动。第十节有一个小工具,可以从历史数据里随机挑一段,一根一根地露出来让你练。
二、夜里开一条没走过的路
打个比方
想象你在夜里开车,走一条从来没走过的山路。
- 车灯只照亮前面一小段。 你知道已经开过的路,还有车灯照到的那几十米,更远的地方一片黑。K 线图也是这样:你知道所有已经收盘的 K 线,不知道下一根。
- 行车记录仪的回放是事后看图。回放的时候,哪里是弯道、哪里是直路一目了然,每个人看回放都像老司机。但开车的那一刻,你看不到回放。
- 路况有三种。 直路上可以跟着车流稳稳地走,这是趋势;市区里走走停停,在两个红绿灯之间来回挪,这是震荡;开到一个路口,原来的路到头了,下一段往哪边走还不知道,这是过渡。
- 仪表盘上有五个表,就是前五篇讲的工具。 第 6 篇的 K 线像车速表,告诉你刚才那一刻的读数,不告诉你这一秒里速度是怎么变的;第 7 篇的多周期像导航地图的缩放;第 8 篇的趋势是路的走向,可弯道要开过去一段才知道弯了多少;第 9 篇的关键位置是路牌和收费站;第 10 篇的成交量是路上的车流。
- 入场的时机也有三种。 直路上前车踩了一脚刹车,你等它重新加速再跟上去,这是回调;收费站抬杆,你冲出去,这是突破;冲出去发现是条断头路,赶紧掉头,这是失败突破后反向。
这一篇的每一节都会回到这条夜路上来。
这一篇要回答的三个问题
- 每一根 K 线收盘时,前五篇的工具能告诉你什么,还不能告诉你什么?
- 「趋势、震荡、过渡」和「回调、突破、失败突破」能不能写成不看未来的规则?写出来之后,数据支持它们多少?
- 你自己看图时的判断,和规则的判断差在哪里?差距里有多少是因为你用了「回放」?
三、每根 K 线收盘时,你知道什么
决定 2 那一刻的仪表盘
把前五篇的工具都用在决定 2 那根 K 线上。先准备数据:
import glob
from pathlib import Path
import numpy as np
import pandas as pd
from talab import bars as B, data as D, indicators as I, structure as X, timeframes as T
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 20)
cols = ["open", "high", "low", "close"]
day = D.load_binance_klines(glob.glob("data/binance/spot/BTCUSDT/1d/*.zip"))
minute = D.load_binance_klines(sorted(glob.glob("data/binance/spot/BTCUSDT/1m/*.zip")))
h4 = B.resample_ohlcv(minute, "4h", traded_only=True)
h1 = B.resample_ohlcv(minute, "1h", traded_only=True)
spy = D.load_nasdaq_daily("data/nasdaq/SPY_historical.json").drop(pd.Timestamp("2026-04-20"))
aapl = D.load_nasdaq_daily("data/nasdaq/AAPL_historical.json")
divs = D.load_nasdaq_dividends("data/nasdaq/AAPL_dividends.json")
aapl = D.adjust_total_return(D.unadjust_splits(aapl, D.SPLITS["AAPL"]), D.SPLITS["AAPL"], divs)
print(f"BTC 4 小时线 {len(h4):,} 根,1 小时线 {len(h1):,} 根")
def analyze(df, threshold):
"""一个标的的全套结构:摆动点、高低点状态、市场状态、ATR、三类入场信号。"""
c = df["close"]
swings = X.zigzag(c, threshold)
levels = X.trend_state(swings, c, c)
state = X.market_state(levels, c)
atr = X.wilder_smooth(X.true_range(df["high"], df["low"], c), 14)
signals = X.entries(c, df["high"], df["low"], swings, levels, state)
return swings, levels, state, atr, signals
analyze 把第 8 篇的 ZigZag 和趋势状态、第 9 篇的 ATR,连同这一篇新增的 market_state 和 entries 串在一起。AAPL 用 5% 的 ZigZag,和第 8 篇一样。
然后在每个决策点上,把各篇工具在那一刻的读数列出来:
swings, levels, state, atr, signals = analyze(aapl, 0.05)
anatomy = B.anatomy(aapl)
rvol = I.relative_volume(aapl["volume"], 20)
weekly = aapl.resample("W-MON", label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna()
weekly["ma10"] = weekly["close"].rolling(10).mean()
last_week = T.align_higher(aapl.index, "1D", weekly, "7D") # 已经收盘的上一周
this_week = T.developing(aapl, "W-MON") # 这一周到目前为止
pos = pd.Series(np.arange(len(aapl)), index=aapl.index)
rows = {}
for name, t in checkpoints.items():
t = pd.Timestamp(t)
upcoming = swings[swings["confirmed_at"] > t].iloc[0]
rows[name] = {
"收盘": f"{aapl.loc[t, 'close'] * scale:.2f}",
"收盘位置 clv": f"{anatomy.loc[t, 'clv']:.2f}",
"上周收盘": f"{last_week.loc[t, 'close'] * scale:.2f}",
"10 周均线": f"{last_week.loc[t, 'ma10'] * scale:.2f}",
"本周开盘": f"{this_week.loc[t, 'open'] * scale:.2f}",
"状态": state[t],
"最近的高点": f"{levels.loc[t, 'last_high'] * scale:.2f}",
"最近的低点": f"{levels.loc[t, 'last_low'] * scale:.2f}",
"离高点几个 ATR": f"{(aapl.loc[t, 'close'] - levels.loc[t, 'last_high']) / atr[t]:.2f}",
"ATR 占价格": f"{atr[t] / aapl.loc[t, 'close']:.2%}",
"相对成交量": f"{rvol[t]:.2f}",
"下一个摆动点": f"{upcoming['time'].date()} {'高' if upcoming['kind'] == 1 else '低'}点,"
f"{pos[upcoming['confirmed_at']] - pos[t]} 根之后确认",
}
print(pd.DataFrame(rows).to_string())
决定 1 决定 2 决定 3 决定 4
收盘 120.67 123.50 120.50 100.47
收盘位置 clv 0.98 0.98 0.06 0.26
上周收盘 118.66 120.57 120.57 103.42
10 周均线 114.21 114.70 114.70 110.16
本周开盘 116.53 118.82 118.82 103.02
状态 up transition_up range down
最近的高点 125.45 121.65 121.65 114.57
最近的低点 117.00 103.94 103.94 94.92
离高点几个 ATR -1.69 0.60 -0.39 -3.64
ATR 占价格 2.34% 2.48% 2.45% 3.86%
相对成交量 0.74 1.07 1.08 0.80
下一个摆动点 2022-01-27 低点,12 根之后确认 2022-03-29 高点,9 根之后确认 2022-03-29 高点,7 根之后确认 2022-06-02 高点,4 根之后确认
表里的价格都已经换算成第一根等于 100。align_higher 保证「上周收盘」只用已经收完的那一周(第 7 篇);developing 给出这一周到目前为止的样子。
知道的和不知道的
以决定 2 那一列为例:
| 工具 | 收盘那一刻知道的 | 还不知道的 |
|---|---|---|
| K 线(第 6 篇) | 收在全天最高附近(收盘位置 0.98) | 这一天里价格是怎么走的;明天会不会接着涨 |
| 多周期(第 7 篇) | 上周收盘 120.57,在 10 周均线 114.70 上方;这一周从 118.82 开盘,到今天涨了 3.9% | 这一周最后收在哪里。今天才周二 |
| 趋势(第 8 篇) | 最近一个已确认的高点 121.65、低点 103.94;状态是「过渡(向上)」 | 今天就是这一段上涨的最高收盘价。 这要等到 9 根之后(第 176 根)价格跌够 5%,才会被确认 |
| 关键位置(第 9 篇) | 收盘在 121.65 上方 0.60 个 ATR,第一次越过这个高点 | 这是真突破还是假突破。第 9 篇专门统计过收盘越过之后又很快收回的假跌破 |
| 成交量(第 10 篇) | 相对成交量 1.07,跟平时差不多 | 第 10 篇发现放量突破和普通突破之后的走势没有稳定的区别,所以这一栏本来也说明不了多少 |
最重要的是「趋势」那一行。决定 2 那一根就是整段反弹的顶点,但在那一刻,任何不看未来的规则都不可能知道。 摆动点要等价格反向走够一个阈值才会被确认。三个标的的确认滞后是这样的:
BTC 摆动点从出现到确认:中位数 5 根,平均 7.1 根,最长 35 根
SPY 摆动点从出现到确认:中位数 4 根,平均 5.3 根,最长 31 根
AAPL 摆动点从出现到确认:中位数 5 根,平均 5.7 根,最长 24 根
(代码在第四节的片段里,markets 也在那里定义:)
for market, (df, threshold) in markets.items():
sw = analyze(df, threshold)[0]
p = pd.Series(np.arange(len(df)), index=df.index)
lag = p[sw["confirmed_at"]].to_numpy() - p[sw["time"]].to_numpy()
print(f"{market} 摆动点从出现到确认:中位数 {np.median(lag):.0f} 根,平均 {lag.mean():.1f} 根,最长 {lag.max()} 根")
中位数 4 到 5 根,最长的要一个多月。这就是夜路上的弯道:你要开过去一段,才知道刚才那里是个弯。
事后看图的人多知道什么
回头看决定 2 的那张图,你会觉得第 167 根「明显是顶」。那是因为你已经看过决定 3 和决定 4 的图了。
这一篇后面所有的规则都遵守一条纪律:第 k 根 K 线上的判断,只能用第 k 根收盘之前(含)的数据。 第十节的测试会逐个截断数据来检查这一点:用前 k 根算出的结果,必须和用全部数据算出的前 k 根完全一样。
四、市场的三种状态:写成规则
第一个想法:只比较高点和低点
第 8 篇已经有了趋势状态:最近两个已确认的高点和低点都抬高是上升趋势,都降低是下降趋势,其余的是 0。
剩下的问题是,那个「其余」该怎么分成震荡和过渡?
一个自然的想法是继续比较高低点:高点持平、低点也持平,就是震荡。问题在于,两个摆动点几乎从来不会一样高:
for market, (df, threshold) in markets.items():
sw = analyze(df, threshold)[0]
for kind, label in [(1, "高点"), (-1, "低点")]:
gap = np.abs(np.diff(np.log(sw.loc[sw["kind"] == kind, "price"].to_numpy())))
print(f"{market} 相邻两个{label}:{len(gap)} 对,相差不到 1% 的 {(gap < 0.01).mean():.1%},"
f"不到 2% 的 {(gap < 0.02).mean():.1%}")
BTC 相邻两个高点:78 对,相差不到 1% 的 6.4%,不到 2% 的 10.3%
BTC 相邻两个低点:79 对,相差不到 1% 的 8.9%,不到 2% 的 13.9%
SPY 相邻两个高点:62 对,相差不到 1% 的 16.1%,不到 2% 的 24.2%
SPY 相邻两个低点:62 对,相差不到 1% 的 14.5%,不到 2% 的 25.8%
AAPL 相邻两个高点:61 对,相差不到 1% 的 4.9%,不到 2% 的 18.0%
AAPL 相邻两个低点:62 对,相差不到 1% 的 6.5%,不到 2% 的 11.3%
BTC 相邻两个高点相差不到 1% 的只有 6.4%。「持平」要么几乎不出现,要么就得引入一个容忍度(差多少算持平),而容忍度又是一个要调的参数。
换个角度:看收盘价在框里还是框外
最近一个已确认的高点和最近一个已确认的低点,在图上围出一个框。高点和低点方向不一致的时候,关键是收盘价在哪:
- 还在框里:价格在两个已知的价位之间来回,谁也没突破谁。这是震荡。
- 已经越过框:价格突破了其中一边,但另一边的结构还没跟上,新的方向没有被确认。这是过渡。
完整的规则:
| 状态 | 标签 | 条件(每根 K 线收盘时判断) |
|---|---|---|
| 上升趋势 | up |
高点抬高,低点也抬高(第 8 篇 trend_state 的 1) |
| 下降趋势 | down |
高点降低,低点也降低(trend_state 的 -1) |
| 震荡 | range |
高低点方向不一致,收盘价在最近的高点和低点之间 |
| 过渡(向上) | transition_up |
高低点方向不一致,收盘价高于最近一个已确认的高点 |
| 过渡(向下) | transition_down |
高低点方向不一致,收盘价低于最近一个已确认的低点 |

注意第四个小图:原本是下降趋势(高点和低点都在降低),今天的收盘价越过了最近的高点。第 8 篇的「更高已确定」逻辑会立刻把高点的方向改成抬高,而低点仍然是降低的,方向变得不一致,收盘又在框外,所以是过渡(向上)。
手算一遍:决定 2 和决定 3
决定 2 之前(第 166 根收盘时),已经确认的摆动点是:
- 高点:125.45(第 108 根)→ 121.65(第 134 根),降低
- 低点:109.74(第 125 根)→ 103.94(第 156 根),降低
两边都降低,是下降趋势。
决定 2(第 167 根)收盘 123.50。 它越过了最近的高点 121.65。新的高点在哪里还不知道(要等确认),但「这一段的高点比 121.65 高」已经确定了,所以高点的方向变成抬高。低点仍然是降低。方向不一致,收盘价 123.50 在框(103.94 到 121.65)的上方:过渡(向上)。
决定 3(第 169 根)收盘 120.50。 高点的方向仍然是抬高(第 167 根的 123.50 已经越过过 121.65,这个事实不会消失),低点仍然是降低。方向不一致,收盘价回到了框里:震荡。
两天之内,同一个框,状态从过渡变回了震荡。
✋ 小检查 1
最近两个已确认的高点是 50 → 54,最近两个已确认的低点是 45 → 43。在这之后还没有新的摆动点被确认,也没有哪根收盘价越过 54 或跌破 43。
(a) 今天收盘 52,是什么状态?
(b) 今天收盘 55 呢?
(c) 换一种情况:最近两个高点 50 → 54,最近两个低点 43 → 45,之后收盘价一直在 45 和 54 之间。今天收盘 44,是什么状态?
答案在文末。
三种状态各占多少时间
在三个标的的全部历史上跑一遍(BTC 用 10%、SPY 用 3%、AAPL 用 5% 的 ZigZag,和第 8 篇一样):
markets = {"BTC": (day, 0.10), "SPY": (spy, 0.03), "AAPL": (aapl, 0.05)}
names = ["up", "down", "range", "transition_up", "transition_down"]
for market, (df, threshold) in markets.items():
s = analyze(df, threshold)[2].dropna()
runs = s.groupby((s != s.shift()).cumsum()).agg(["first", "size"])
table = pd.DataFrame({"时间占比": s.value_counts(normalize=True),
"段数": runs["first"].value_counts(),
"中位长度": runs.groupby("first")["size"].median()}).reindex(names)
print(f"{market}(ZigZag {threshold:.0%})")
print(table.to_string(formatters={"时间占比": "{:.1%}".format, "中位长度": "{:.1f}".format}))
moves = pd.crosstab(runs["first"], runs["first"].shift(-1)).reindex(index=names, columns=names, fill_value=0)
print("下一段是什么(行是这一段,列是下一段):")
print(moves.to_string())
BTC(ZigZag 10%)
时间占比 段数 中位长度
up 34.0% 13 52.0
down 25.5% 20 25.5
range 20.8% 58 6.5
transition_up 14.0% 23 3.0
transition_down 5.8% 26 3.0
下一段是什么(行是这一段,列是下一段):
first up down range transition_up transition_down
first
up 0 3 2 0 8
down 2 0 7 11 0
range 11 16 0 12 18
transition_up 0 0 23 0 0
transition_down 0 1 25 0 0
SPY(ZigZag 3%)
时间占比 段数 中位长度
up 65.9% 16 62.5
down 8.8% 11 16.0
range 11.1% 35 5.0
transition_up 12.1% 16 7.5
transition_down 2.1% 11 4.0
下一段是什么(行是这一段,列是下一段):
first up down range transition_up transition_down
first
up 0 4 6 0 5
down 0 0 2 9 0
range 15 7 0 7 6
transition_up 0 0 16 0 0
transition_down 0 0 11 0 0
AAPL(ZigZag 5%)
时间占比 段数 中位长度
up 55.2% 15 91.0
down 17.7% 14 30.5
range 13.7% 27 11.0
transition_up 12.5% 12 22.5
transition_down 0.8% 4 5.0
下一段是什么(行是这一段,列是下一段):
first up down range transition_up transition_down
first
up 0 2 8 0 4
down 2 0 2 10 0
range 13 12 0 2 0
transition_up 0 0 12 0 0
transition_down 0 0 4 0 0
读这张表
趋势占了大部分时间,而且一段很长。 上升加下降,BTC 占 59.5%,SPY 占 74.7%,AAPL 占 72.9%。上升趋势一段的中位长度,BTC 52 天、SPY 62.5 天、AAPL 91 天。
震荡的段数最多,但每段很短。 BTC 有 58 段震荡,中位长度只有 6.5 天。价格在框里待几天,就越过了某一边。
过渡之后的下一段几乎都是震荡。 三个标的一共 92 段过渡(最后一段没有下一段的不算),91 段的下一段是震荡。这主要是定义造成的,不是市场规律:过渡(向上)时价格正在创新高,下一个被确认的摆动点一定是这一段的高点;而高点被确认的那一刻,价格已经从高点回落了一个阈值,框的上沿也换成了这个新高点,收盘价通常就回到了框里。唯一的例外是 BTC 在 2022 年 1 月 4 日:收盘跌破最近的低点 46,131.20,变成过渡(向下);第二天,2021 年 12 月 23 日那个 50,838.81 的高点刚好被确认,它比上一个高点 67,525.83 低,状态直接变成了下降趋势。⚠️ 用自己写的规则统计出一条「规律」时,先想一想它是不是规则本身决定的。
趋势结束之后去哪。 BTC 的 13 段上升趋势里,8 段下一段是过渡(向下),也就是收盘价直接跌破了最近的低点;3 段直接变成下降趋势;2 段变成震荡。一次典型的由涨转跌是这样走的:
上升趋势 → 过渡(向下):收盘跌破最近的低点
→ 震荡:新的低点被确认,价格反弹回框里
→ 下降趋势:反弹的高点被确认,比上一个高点低
用夜路来说:直路(趋势)走到头,先是一个路口(过渡),然后在路口附近来回挪一阵(震荡),最后才开上一条新的路。
五、三类入场:写成规则
三类入场
交易者常说的入场方式很多,归结起来大多是三类,正好对应三种状态:
| 入场 | 在什么状态里 | 赌的是什么 | 夜路上的样子 |
|---|---|---|---|
| 回调 | 趋势 | 趋势还会继续,回调只是暂停 | 前车踩了一脚刹车,等它重新加速再跟上 |
| 突破 | 震荡或反方向的趋势 | 价格离开框之后会继续走 | 收费站抬杆,冲出去 |
| 失败突破后反向 | 刚刚突破 | 突破是假的,价格会朝反方向走 | 冲出去发现是断头路,掉头 |
「回调」「突破」这些词每个人说的都不太一样。下面把它们写成程序能执行的规则,每条规则都只用信号那根 K 线收盘时已经知道的信息。
规则
回调(pullback):上升趋势里,一个摆动高点刚被确认。这说明价格已经从高点回落了至少一个阈值(ZigZag 5% 就是 5%),回调的深度够了。之后第一根收盘价高于前一根最高价的 K 线,是价格重新向上的信号,做多。在出现这根 K 线之前,如果状态已经不是上升趋势(比如跌破了最近的低点),这次回调作废。下降趋势里反过来,做空。
突破(breakout):收盘价第一次越过最近一个已确认的高点,而且前一根 K 线不在上升趋势里,做多。已经在上升趋势里再创新高,算趋势的延续,不算突破。低点反过来,做空。
失败突破(failed):突破之后 5 根 K 线以内,收盘价回到被突破的价位的另一侧,反方向入场。向上突破失败就做空,向下突破失败就做多。
两个细节:
- 每个价位只算第一次被越过。 价格经常在一个价位附近来回穿好几次。如果每次收盘越过都算一次突破,同一个价位会产生一串信号。⚠️ 这一篇写作时的第一版就是这样:BTC 日线 3,987.60 这个高点,在 2019 年 3 月 16 日到 27 日的 12 天里被记了 4 次突破和 3 次失败突破。
- 为什么失败突破只等 5 根。 等得越久,「失败」越可能只是正常的回调。5 根是一个约定,练习 4 会让你换一个数字看看结果变不变。
手算一遍
框的上沿是 110,下沿是 90。前 4 根的状态都是震荡,收盘价依次是:
| 第几根 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 收盘 | 100 | 105 | 100 | 104 | 112 | 111 | 108 | 95 | 89 | 113 |
- 第 4 根收盘 112,第一次越过 110,前一根是震荡:突破,做多,价位 110。
- 第 6 根收盘 108,是突破后的第 2 根,回到了 110 下方:失败突破,做空。
- 第 8 根收盘 89,第一次跌破 90:突破,做空,价位 90。
- 第 9 根收盘 113,突破后的第 1 根就回到了 90 上方:失败突破,做多。113 也在 110 上方,但 110 在第 4 根已经被越过了,不再算突破。
这正是第十节的一个测试。
每类入场,收盘那一刻不知道什么
三类入场都能在收盘时判断,但每一类都有一件关键的事在那一刻不知道:
- 回调:这是回调的结束,还是反转的开始?在信号那根 K 线上,两者长得一模一样。
- 突破:是真突破还是假突破?要等之后几根才知道。
- 失败突破:它比突破晚几根才出现,入场价通常更差;而且「收回来」之后,价格还可能再突破一次。
六、揭晓
这是哪段行情
这是 AAPL 的日线,2021 年 8 月 2 日到 2022 年 8 月 31 日(按拆股和分红调整后的价格)。换算系数 0.7056,也就是说真实价格 = 图上价格 ÷ 0.7056。

四个决定分别是:
| 日期 | talab 的状态 | talab 的信号 | 之后先碰到哪条 2 ATR 线 | 5 天后 | 10 天后 | 20 天后 | |
|---|---|---|---|---|---|---|---|
| 决定 1 | 2022-01-11 | 上升趋势 | 回调,做多 | 反向(亏) | -5.05% | -8.79% | +0.81% |
| 决定 2 | 2022-03-29 | 过渡(向上) | 突破,做多 | 反向(亏) | -2.18% | -6.31% | -12.51% |
| 决定 3 | 2022-03-31 | 震荡 | 失败突破,做空 | 顺向(赚) | -1.41% | -5.34% | -9.71% |
| 决定 4 | 2022-06-03 | 下降趋势 | 回调,做空 | 顺向(赚) | -5.67% | -9.51% | -2.63% |
「先碰到哪条 2 ATR 线」的意思是:从信号那根的收盘价出发,上下各放一条两倍 ATR 的线,看之后 20 根 K 线里价格先碰到哪一条。第八节会详细讲为什么用它来打分。
c = aapl["close"]
for (name, t), direction in zip(checkpoints.items(), [1, 1, -1, -1]):
t = pd.Timestamp(t)
i = pos[t]
hit = X.first_passage(c, aapl["high"], aapl["low"], atr, [t], [direction])[0]
later = {n: c.iloc[i + n] / c.iloc[i] - 1 for n in (5, 10, 20)}
print(f"{name} {t.date()}:状态 {state[t]},信号 {signals.loc[signals['time'] == t, 'kind'].tolist()},"
f"{'做多' if direction == 1 else '做空'}时先碰到 {'顺向' if hit == 1 else '反向'}的 2 ATR 线;"
+ ",".join(f"{n} 天后 {r:+.2%}" for n, r in later.items())
+ f";之后 20 天最高 {aapl['high'].iloc[i + 1:i + 21].max() * scale:.2f},"
f"最低 {aapl['low'].iloc[i + 1:i + 21].min() * scale:.2f}")
inside = signals[(signals["time"] >= segment.index[0]) & (signals["time"] <= segment.index[-1])].copy()
inside["level"] = (inside["level"] * scale).round(2)
inside["结果"] = X.first_passage(c, aapl["high"], aapl["low"], atr, inside["time"], inside["direction"])
print(inside.to_string(index=False))
决定 1 2022-01-11:状态 up,信号 ['pullback'],做多时先碰到 反向的 2 ATR 线;5 天后 -5.05%,10 天后 -8.79%,20 天后 +0.81%;之后 20 天最高 122.12,最低 106.62
决定 2 2022-03-29:状态 transition_up,信号 ['breakout'],做多时先碰到 反向的 2 ATR 线;5 天后 -2.18%,10 天后 -6.31%,20 天后 -12.51%;之后 20 天最高 123.95,最低 107.23
决定 3 2022-03-31:状态 range,信号 ['failed'],做空时先碰到 顺向的 2 ATR 线;5 天后 -1.41%,10 天后 -5.34%,20 天后 -9.71%;之后 20 天最高 123.18,最低 107.23
决定 4 2022-06-03:状态 down,信号 ['pullback'],做空时先碰到 顺向的 2 ATR 线;5 天后 -5.67%,10 天后 -9.51%,20 天后 -2.63%;之后 20 天最高 103.58,最低 89.18
time kind direction level 结果
2021-09-22 pullback 1 107.84 0.0
2021-12-21 pullback 1 123.68 1.0
2022-01-11 pullback 1 125.45 0.0
2022-01-19 breakout -1 117.00 1.0
2022-03-29 breakout 1 121.65 0.0
2022-03-31 failed -1 121.65 1.0
2022-05-09 breakout -1 108.05 1.0
2022-06-03 pullback -1 94.92 1.0
2022-06-28 pullback -1 89.89 0.0
2022-07-20 breakout 1 104.50 1.0
回头看这四个决定
决定 1:回调做多,亏了。 上升趋势、回调够了 5%、今天收盘越过昨天的最高价,所有条件都满足。但这次回调不是暂停,是一段下跌的开始:11 根之后跌到了 109.74。20 天后价格又回到了入场价附近(+0.81%),可先碰到的是下面那条线。在夜路上,这是前车踩了一脚刹车,你以为它要重新加速,其实它在准备拐弯。
决定 2:突破做多,亏了。 11 天连涨之后收盘越过前高,这一根正是整段反弹的最高收盘价。第三节说过,这件事要 9 根之后才能知道。
决定 3:失败突破后做空,赚了。 两天之后收回前高下方,规则给出做空信号。之后价格在第 171 根又收到过 123.14,但没有碰到上面那条线,20 天后跌了 9.71%。
决定 4:回调做空,赚了。 下降趋势里的反弹,第二天收盘跌破前一天的最低价。之后 9 天跌到了 89.89。
这一段 13 个月里,规则一共给出 10 个信号,6 个先碰到顺向的线。
⚠️ 一段行情里的 10 个信号,说明不了规则好坏。 同样的规则换一段行情,结果可能完全相反。你对这四个决定的答案也一样:答对了可能是运气,答错了也不代表方法错了。要判断规则,需要第八节那样成百上千次的统计。
七、状态是描述,不是预测
实时的状态,和回放里看到的行情段
规则给出的状态,是用已经确认的摆动点描述已经发生的结构。它和事后看到的走势差多少?
「事后的走势」用 ZigZag 的行情段来表示:从一个摆动低点到下一个摆动高点之间的每一根 K 线,都属于上涨段;从高点到低点,属于下跌段。这些行情段是在全部数据上画出来的,相当于行车记录仪的回放。
for market, (df, threshold) in markets.items():
close = df["close"]
sw, lv, st, a, sig = analyze(df, threshold)
p = pd.Series(np.arange(len(close)), index=close.index)
leg = pd.Series(np.nan, index=close.index, dtype=object)
for s0, s1 in zip(sw.itertuples(), sw.iloc[1:].itertuples()):
leg.iloc[p[s0.time] + 1:p[s1.time] + 1] = "上涨段" if s1.kind == 1 else "下跌段"
both = pd.concat([st.rename("状态"), leg.rename("事后")], axis=1).dropna()
print(f"{market}:上涨段占全部 K 线的 {(both['事后'] == '上涨段').mean():.1%}")
share = pd.crosstab(both["状态"], both["事后"], normalize="index").reindex(names)
share["K 线数"] = both["状态"].value_counts().reindex(names)
print(share.to_string(formatters={"上涨段": "{:.1%}".format, "下跌段": "{:.1%}".format}))
done, never, turns = [], 0, 0
for s0, s1 in zip(sw.itertuples(), sw.iloc[1:].itertuples()):
want = "up" if s1.kind == 1 else "down"
if st[s0.time] == want: # 这段开始时已经是同方向的趋势:不是转折
continue
turns += 1
part = st.iloc[p[s0.time]:p[s1.time] + 1]
hit = part.index[part == want]
if len(hit) == 0:
never += 1
continue
done.append(np.log(close[hit[0]] / s0.price) / np.log(s1.price / s0.price))
print(f" 开始时不是同方向趋势的行情段 {turns} 段:整段都没有显示成同方向趋势的 {never} 段({never / turns:.0%});"
f"其余 {len(done)} 段第一次显示时,已经走完 中位数 {np.median(done):.0%},"
f"四分位 {np.percentile(done, 25):.0%} ~ {np.percentile(done, 75):.0%}")
BTC:上涨段占全部 K 线的 57.5%
事后 上涨段 下跌段 K 线数
状态
up 63.8% 36.2% 1115
down 51.6% 48.4% 835
range 42.6% 57.4% 632
transition_up 88.4% 11.6% 458
transition_down 19.3% 80.7% 176
开始时不是同方向趋势的行情段 121 段:整段都没有显示成同方向趋势的 89 段(74%);其余 32 段第一次显示时,已经走完 中位数 63%,四分位 41% ~ 85%
SPY:上涨段占全部 K 线的 72.5%
事后 上涨段 下跌段 K 线数
状态
up 76.1% 23.9% 1517
down 49.3% 50.7% 207
range 62.7% 37.3% 260
transition_up 87.4% 12.6% 285
transition_down 26.0% 74.0% 50
开始时不是同方向趋势的行情段 97 段:整段都没有显示成同方向趋势的 70 段(72%);其余 27 段第一次显示时,已经走完 中位数 47%,四分位 31% ~ 95%
AAPL:上涨段占全部 K 线的 68.7%
事后 上涨段 下跌段 K 线数
状态
up 76.8% 23.2% 1263
down 57.9% 42.1% 413
range 43.9% 56.1% 319
transition_up 79.1% 20.9% 292
transition_down 16.7% 83.3% 18
开始时不是同方向趋势的行情段 97 段:整段都没有显示成同方向趋势的 68 段(70%);其余 29 段第一次显示时,已经走完 中位数 49%,四分位 31% ~ 81%

读这个结果
过渡状态和事后的方向最一致。 BTC 的过渡(向上)里,88.4% 的 K 线事后看处在上涨段;过渡(向下)里,80.7% 处在下跌段。这很自然:过渡的定义就是收盘价刚刚越过框,价格此刻正朝那个方向走。
趋势状态比全部 K 线好一些,但好得有限。 BTC 全部 K 线里 57.5% 处在上涨段,上升趋势状态里是 63.8%。下降趋势状态里,BTC 有 51.6% 的 K 线其实已经在上涨段里了,SPY 是 49.3%,AAPL 是 57.9%。原因是滞后:底部出现之后,要等新的高点和低点都被确认,状态才会从下降趋势变过来,而在这之前价格已经涨了一段。
大部分转折,状态根本来不及跟上。 看那些「开始时状态不是同方向趋势」的行情段,比如下降趋势里开始的一段上涨。三个标的里,70% 到 74% 的这种行情段,从头到尾都没有被标成同方向的趋势:等状态变过来的时候,这一段已经结束了。剩下的那些,状态第一次变过来时,这一段已经走完了中位数 47% 到 63%。
所以「现在是上升趋势」这句话的准确意思是:最近已经确认的高点和低点都在抬高。 它是对后视镜里那段路的描述,不是对前方的预测。第 8 篇的 ADX 也一样:ADX 在同方向超过 25 的时候,一段行情的中位数已经走完了 48.9%。
八、三类入场的数据
怎么打分
一个入场信号好不好,最直接的办法是回测:规定止损、止盈、仓位,算出收益。但那样会同时引入很多别的选择(止损放哪、拿多久),这些是第 21 到 23 篇的内容。
这一篇用一个更干净的问题来打分:从信号那根的收盘价出发,上下各放一条 2 倍 ATR 的线,之后 20 根 K 线以内,先碰到顺着入场方向的那条线,还是反方向的那条?
- 两条线离入场价一样远,所以价格如果是没有漂移的随机游走,先碰到顺向线的比例约为 50%。
- 同一根 K 线把两条线都碰到了,分不清先后,按对入场者不利的情况算。
- 20 根以内两条线都没碰到的,不计入比例。
这就是 talab.structure.first_passage。和前几篇一样,对照组是把 K 线顺序打乱 200 次:每根 K 线相对前一根收盘价的形状不变,只打乱先后,重新拼成价格,在上面跑同样的规则。打乱之后价格的整体涨跌幅、每根 K 线的大小分布都保留了,只有先后顺序带来的结构被打掉。表里的 p 是打乱后的结果离打乱平均值不比真实结果近的比例(双侧)。
日线:样本太少
def shuffle_bars(df, rng):
"""打乱 K 线顺序:保留每根 K 线相对前一天收盘价的形状,重新拼成价格。"""
rel = np.log(df[cols].div(df["close"].shift(1), axis=0))
rel = rel.dropna().iloc[rng.permutation(len(df) - 1)].reset_index(drop=True)
prev_close = df["close"].iloc[0] * np.exp(rel["close"].cumsum().shift(1, fill_value=0))
out = np.exp(rel[cols]).mul(prev_close, axis=0)
out.index = df.index[1:]
return out
def win_rates(df, threshold, distance=2.0, horizon=20):
"""每类信号「先碰到顺向的线」的比例(没碰到任何一条的不算)。"""
sw, lv, st, a, sig = analyze(df, threshold)
sig = sig[a.reindex(sig["time"]).notna().to_numpy()]
sig = sig.assign(win=X.first_passage(df["close"], df["high"], df["low"], a, sig["time"], sig["direction"],
distance, horizon))
g = sig.groupby(["kind", "direction"])["win"]
return pd.DataFrame({"次数": g.count(), "顺向": g.mean()})
def compare(df, threshold, distance=2.0, n=200):
rng = np.random.default_rng(0)
real = win_rates(df, threshold, distance)
sims = pd.concat([win_rates(shuffle_bars(df, rng), threshold, distance)["顺向"] for _ in range(n)], axis=1)
sims = sims.reindex(real.index)
real["打乱 2.5%"] = sims.quantile(0.025, axis=1)
real["打乱平均"] = sims.mean(axis=1)
real["打乱 97.5%"] = sims.quantile(0.975, axis=1)
center = sims.mean(axis=1)
real["p"] = [(np.abs(sims.loc[k] - center[k]) >= abs(real.loc[k, "顺向"] - center[k])).mean() for k in real.index]
return real
percent = {c: "{:.1%}".format for c in ["顺向", "打乱 2.5%", "打乱平均", "打乱 97.5%"]}
for market, (df, threshold) in markets.items():
print(f"{market} 日线(ZigZag {threshold:.0%},2 ATR,20 根)")
print(compare(df, threshold).to_string(formatters=percent | {"p": "{:.3f}".format}))
BTC 日线(ZigZag 10%,2 ATR,20 根)
次数 顺向 打乱 2.5% 打乱平均 打乱 97.5% p
kind direction
breakout -1 19 57.9% 25.0% 45.3% 66.7% 0.265
1 18 44.4% 32.0% 52.6% 71.5% 0.450
failed -1 11 45.5% 15.4% 45.6% 75.0% 1.000
1 13 15.4% 20.0% 52.9% 83.4% 0.020
pullback -1 13 46.2% 15.3% 45.3% 68.8% 0.960
1 13 53.8% 29.4% 53.3% 74.0% 0.965
SPY 日线(ZigZag 3%,2 ATR,20 根)
次数 顺向 打乱 2.5% 打乱平均 打乱 97.5% p
kind direction
breakout -1 12 66.7% 18.7% 41.6% 64.8% 0.040
1 16 62.5% 33.3% 56.4% 75.1% 0.615
failed -1 8 62.5% 10.0% 42.0% 75.1% 0.250
1 6 50.0% 25.0% 57.4% 90.1% 0.775
pullback -1 4 25.0% 14.2% 42.8% 66.8% 0.245
1 17 52.9% 35.3% 56.2% 77.8% 0.790
AAPL 日线(ZigZag 5%,2 ATR,20 根)
次数 顺向 打乱 2.5% 打乱平均 打乱 97.5% p
kind direction
breakout -1 12 75.0% 20.0% 39.2% 63.7% 0.000
1 13 76.9% 36.3% 58.9% 82.4% 0.075
failed -1 3 33.3% 8.1% 40.9% 75.1% 0.715
1 7 14.3% 30.8% 61.3% 100.0% 0.005
pullback -1 11 36.4% 8.3% 39.2% 66.7% 0.865
1 18 44.4% 36.4% 57.9% 76.0% 0.185
每一类信号只有 3 到 19 次,打乱后的 95% 范围大多有 40 个百分点以上。在这样的样本上,只有非常极端的结果才能被区分出来。有四个 p 小于 0.05 的结果:
- AAPL 突破做空 75.0%(p = 0.000,200 次打乱里没有一次偏离得这么远),SPY 突破做空 66.7%(p = 0.040)
- AAPL 失败突破后做多 14.3%(p = 0.005),BTC 失败突破后做多 15.4%(p = 0.020)
后两个是比随机差得多:向下突破收回来之后做多,大部分先碰到了下面的线。四个结果说的是同一件事,向下突破之后,价格倾向于继续往下,收回来也不算数。但每个只有十几次,还不能当真。
BTC 4 小时线和 1 小时线:样本多了
为了得到更多样本,用 1 分钟线合成 BTC 的 4 小时线(19,794 根)和 1 小时线(79,113 根)。阈值按时间的平方根缩小(第 5 篇):日线 10%,4 小时线约 10% ÷ √6 ≈ 4%,1 小时线约 10% ÷ √24 ≈ 2%。
for market, df, threshold in [("BTC 4 小时线", h4, 0.04), ("BTC 1 小时线", h1, 0.02)]:
for distance in [2.0, 1.0, 3.0]:
print(f"{market}(ZigZag {threshold:.0%},{distance:.0f} ATR,20 根)")
print(compare(df, threshold, distance).to_string(formatters=percent | {"p": "{:.3f}".format}))
2 ATR 的结果:
BTC 4 小时线(ZigZag 4%,2 ATR,20 根)
次数 顺向 打乱 2.5% 打乱平均 打乱 97.5% p
kind direction
breakout -1 117 54.7% 39.3% 48.2% 56.5% 0.135
1 100 57.0% 41.8% 50.2% 58.8% 0.115
failed -1 43 30.2% 36.2% 48.0% 61.1% 0.005
1 54 50.0% 37.3% 50.6% 61.8% 0.950
pullback -1 64 48.4% 38.1% 47.9% 57.0% 0.940
1 80 48.8% 39.2% 49.8% 60.2% 0.850
BTC 1 小时线(ZigZag 2%,2 ATR,20 根)
次数 顺向 打乱 2.5% 打乱平均 打乱 97.5% p
kind direction
breakout -1 438 55.7% 44.2% 48.7% 53.4% 0.000
1 390 55.4% 44.1% 48.9% 53.8% 0.005
failed -1 188 45.7% 42.7% 48.7% 54.8% 0.375
1 192 46.4% 43.3% 49.5% 55.7% 0.295
pullback -1 234 45.7% 44.3% 48.7% 53.7% 0.200
1 274 46.7% 44.6% 49.2% 54.2% 0.330

把线的距离换成 1 个和 3 个 ATR,完整的输出在脚本里,这里只列突破和失败突破:
| 1 小时线 1 ATR | 1 小时线 2 ATR | 1 小时线 3 ATR | 4 小时线 1 ATR | 4 小时线 2 ATR | 4 小时线 3 ATR | |
|---|---|---|---|---|---|---|
| 突破做多 | 51.6%(p 0.030) | 55.4%(p 0.005) | 58.9%(p 0.005) | 58.0%(p 0.010) | 57.0%(p 0.115) | 60.7%(p 0.065) |
| 突破做空 | 50.8%(p 0.070) | 55.7%(p 0.000) | 62.6%(p 0.000) | 56.0%(p 0.035) | 54.7%(p 0.135) | 54.1%(p 0.250) |
| 失败突破后做多 | 47.9%(p 0.860) | 46.4%(p 0.295) | 36.4%(p 0.000) | 46.5%(p 0.790) | 50.0%(p 0.950) | 48.5%(p 0.730) |
| 失败突破后做空 | 49.6%(p 0.545) | 45.7%(p 0.375) | 48.3%(p 0.735) | 50.0%(p 0.615) | 30.2%(p 0.005) | 42.9%(p 0.575) |
扣掉「那几个月本来就在涨」
打乱顺序把牛市和熊市也打乱了。如果向上突破大多发生在牛市里,向下突破大多发生在熊市里,那么突破「有效」可能只是因为那几个月整体在涨或在跌。
检查的办法:对每个信号,看同一个月里所有 K 线都按同一个方向入场时,先碰到顺向线的比例是多少,再把这些比例平均。
def month_baseline(df, threshold, distance=2.0):
"""对照:同一个月里每一根 K 线都按同一个方向入场,先碰到顺向线的比例。扣掉「那个月本来就在涨 / 跌」。"""
sw, lv, st, a, sig = analyze(df, threshold)
times = df.index[a.notna().to_numpy()]
month = {}
for direction in (1, -1):
hit = X.first_passage(df["close"], df["high"], df["low"], a, times, [direction] * len(times), distance)
month[direction] = pd.Series(hit, index=times).groupby(times.tz_localize(None).to_period("M")).mean()
sig = sig[a.reindex(sig["time"]).notna().to_numpy()]
sig = sig.assign(顺向=X.first_passage(df["close"], df["high"], df["low"], a, sig["time"], sig["direction"], distance))
sig = sig.dropna(subset=["顺向"])
sig["同月全部 K 线"] = [month[d][t.tz_localize(None).to_period("M")] for t, d in zip(sig["time"], sig["direction"])]
return sig.groupby(["kind", "direction"])[["顺向", "同月全部 K 线"]].mean()
for market, df, threshold in [("BTC 4 小时线", h4, 0.04), ("BTC 1 小时线", h1, 0.02)]:
print(f"{market}(2 ATR)和同一个月的全部 K 线比:")
print(month_baseline(df, threshold).to_string(float_format="{:.1%}".format))
BTC 4 小时线(2 ATR)和同一个月的全部 K 线比:
顺向 同月全部 K 线
kind direction
breakout -1 54.7% 54.2%
1 57.0% 51.1%
failed -1 30.2% 51.0%
1 50.0% 47.7%
pullback -1 48.4% 54.5%
1 48.8% 53.7%
BTC 1 小时线(2 ATR)和同一个月的全部 K 线比:
顺向 同月全部 K 线
kind direction
breakout -1 55.7% 51.6%
1 55.4% 49.7%
failed -1 45.7% 50.3%
1 46.4% 48.5%
pullback -1 45.7% 51.6%
1 46.7% 50.0%
1 小时线突破信号那根 K 线的 ATR 占价格:中位数 1.23%
1 小时线的突破,扣掉同月份的整体方向之后还多出 4.1 个(做空)和 5.7 个(做多)百分点。4 小时线的突破做空 54.7%,而同月份的全部 K 线就有 54.2%,几乎全部来自那几个月本来就在跌。
读这个结果
一个一致的方向:突破之后,价格倾向于顺着突破的方向走。 1 小时线上两个方向都成立,距离换成 1 个或 3 个 ATR 也大体成立,扣掉月份的整体方向之后还剩一部分。反过来,失败突破后反向入场没有一次显著好于随机,几个显著的结果(日线的 BTC 和 AAPL、4 小时线做空、1 小时线 3 ATR 做多)都是比随机差:收回来之后,原来的突破方向经常重新占上风。这和第 9 篇的发现一致,BTC 的支撑和阻力比打乱的数据更容易被突破。
回调入场没有优势。 三个日线标的、两个小时周期、三种距离,没有一个回调结果显著好于随机。2 ATR 时,1 小时线回调做空和做多的顺向比例是 45.7% 和 46.7%,还低于同月份的全部 K 线(51.6% 和 50.0%)。
要打几个折扣:
- 检验做了很多次。 日线 3 个标的,4 小时线和 1 小时线各 3 种距离,每次 6 类信号,一共 54 次检验。全是随机的话,p 小于 0.05 的平均会有 2.7 次。实际有 14 次,其中 13 次指向同一个方向(突破延续、失败突破反向无效),只有 1 小时线 3 ATR 的回调做多(43.4%,p 0.045)是另一回事。不过同一批信号换一个距离再检验一次,结果本来就相关,14 次不等于 14 个独立的证据。多重检验怎么处理,第 30 篇专门讲。
- 这是毛的,还没扣成本。 1 小时线突破信号那根 K 线的 ATR,中位数是价格的 1.23%,2 ATR 约 2.5%。顺向比例 55.5% 粗算下来,每次平均先碰到的那条线带来 (55.5% - 44.5%) × 2.5% ≈ 0.27%。这还假设了碰到线就能在线上成交,没算 20 根都没碰到的情况。Binance 现货一买一卖的吃单手续费就是 0.2%,突破时的滑点通常还更大。
- 只检验了一种写法。 换一种回调或突破的定义,结果可能不同。这一篇检验的是上面那几条规则,不是「突破」这个词。
回到夜路上:「收费站抬杆就冲」在 BTC 的小时线上比随便开车稍微好一点,「冲出去又退回来就掉头」并不好。 至于好的那一点够不够付过路费,要等第 21 到 23 篇加上止损和出场、第 28 篇加上真实成本之后才知道。
✋ 小检查 2
框的上沿 50、下沿 40,前一根是震荡。之后 6 根 K 线:
| 第几根 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 收盘 | 48 | 51.5 | 52 | 49.5 | 39 | 52.5 |
| 最高 | 49 | 51.8 | 52.5 | 52.2 | 48.3 | 52.8 |
| 最低 | 47 | 49.6 | 51.0 | 49.2 | 38.5 | 44 |
假设框一直没变,状态在收盘越过框之前都是震荡,失败突破只等 5 根。
(a) 哪几根给出信号?各是什么信号、什么方向?
(b) 第 2 根的信号入场,ATR 是 1.5。上下两条 2 ATR 的线在哪里?之后先碰到哪一条?
答案在文末。
九、实验一:结构标注
这个实验做什么
到目前为止,状态都是规则算出来的。可你看图时,心里也有一套「状态」。这个实验把两者放在一起比:
- 挑一段行情。 至少一年的日线,最好是你不太熟悉的一段。
- 先手工标注。 只看一张没有任何指标的 K 线图,把整段切成若干区间,每个区间标上
up、down、range、transition之一,写进一个 CSV 文件(start、end、label三列)。在这之前不要运行分类器,否则你的眼睛会被它带着走。 - 再自动标注。 用
market_state算出同一段的状态。过渡不分方向,两个过渡标签都合并成transition。 - 比较。 用
agreement算出相同的比例和 kappa;按你标的每一段,列出自动标注是什么、第几根开始相同。 - 找原因。 对每一段分歧,判断它属于哪一种:规则的滞后、看的尺度不同、两边对状态的定义不同,还是你用了事后的眼光。
- 一次只改一个参数,看哪些分歧消失了、哪些没有。
示范:SPY,2018 年 1 月到 2019 年 6 月
下面这份标注,是写这一篇时在运行分类器之前,只看一张没有任何标记的 SPY 日线图画出来的:
start,end,label
2018-01-02,2018-01-26,up
2018-01-29,2018-02-08,transition
2018-02-09,2018-05-03,range
2018-05-04,2018-09-20,up
2018-09-21,2018-10-04,range
2018-10-05,2018-10-29,transition
2018-10-30,2018-12-03,range
2018-12-04,2018-12-24,down
2018-12-26,2019-01-04,transition
2019-01-07,2019-05-03,up
2019-05-06,2019-06-03,transition
2019-06-04,2019-06-28,up
然后比较:
hand_segments = pd.read_csv(Path(__file__).with_name("11_spy_hand_labels.csv"))
window = spy.loc["2018-01-02":"2019-06-28"].index
hand = X.segments_to_labels(hand_segments, window)
simple = {"transition_up": "transition", "transition_down": "transition"}
for threshold in [0.02, 0.03, 0.05, 0.08]:
auto = analyze(spy, threshold)[2].reindex(window).replace(simple)
result = X.agreement(hand, auto)
print(f"ZigZag {threshold:.0%}:相同 {result['share']:.1%},kappa {result['kappa']:.2f}")
if threshold == 0.03:
print(result["table"].rename_axis(index="手工", columns="自动").to_string())
by_segment = []
for s in hand_segments.itertuples():
part = auto.loc[s.start:s.end]
same = (part == s.label).to_numpy()
by_segment.append({"开始": s.start, "结束": s.end, "手工": s.label, "根数": len(part),
"相同": f"{same.mean():.0%}",
"第几根开始相同": int(np.argmax(same)) + 1 if same.any() else "-",
"自动标注": ",".join(f"{k} {v}" for k, v in part.value_counts().items())})
print(pd.DataFrame(by_segment).to_string(index=False))
sw, lv, st, a, sig = analyze(spy, 0.03)
print(sw[(sw["time"] >= "2016-06-01") & (sw["time"] <= "2019-06-30")].round({"price": 2}).to_string(index=False))
print(pd.concat([spy["close"], lv, st], axis=1).loc[["2018-02-02", "2018-02-08", "2018-02-28", "2018-03-01", "2019-02-22",
"2019-02-25", "2019-04-30", "2019-05-13"]].round(2).to_string())
ZigZag 2%:相同 63.7%,kappa 0.32
ZigZag 3%:相同 40.0%,kappa 0.12
自动 down range transition up
手工
down 14 0 0 0
range 6 42 12 32
transition 11 15 5 22
up 33 53 41 89
开始 结束 手工 根数 相同 第几根开始相同 自动标注
2018-01-02 2018-01-26 up 18 100% 1 up 18
2018-01-29 2018-02-08 transition 9 0% - up 9
2018-02-09 2018-05-03 range 58 47% 14 range 27,up 22,transition 9
2018-05-04 2018-09-20 up 97 55% 45 up 53,range 43,transition 1
2018-09-21 2018-10-04 range 10 0% - up 10
2018-10-05 2018-10-29 transition 17 0% - up 13,down 4
2018-10-30 2018-12-03 range 24 62% 9 range 15,down 6,transition 3
2018-12-04 2018-12-24 down 14 100% 1 down 14
2018-12-26 2019-01-04 transition 7 0% - down 7
2019-01-07 2019-05-03 up 82 0% - transition 40,down 33,range 9
2019-05-06 2019-06-03 transition 20 25% 1 range 15,transition 5
2019-06-04 2019-06-28 up 19 95% 2 up 18,range 1
ZigZag 5%:相同 19.4%,kappa 0.05
ZigZag 8%:相同 18.2%,kappa 0.04

kappa 是什么。 两份标注相同的比例是 40.0%,但这个数字要打个折:即使两边都随便贴标签,只要各自的标签比例和现在一样,也会有一部分碰巧相同。Cohen’s kappa 扣掉了这部分:
kappa = (相同的比例 - 随便贴也会相同的比例) ÷ (1 - 随便贴也会相同的比例)
随便贴也会相同的比例 = 每个标签在两份标注里的比例相乘,再加起来
1 是完全一致,0 是和随便贴一样。3% 的 ZigZag 只有 0.12,比随便贴好不了多少。
逐段找原因
原因一:结构已经过时了(滞后)。 2018 年 1 月 29 日到 2 月 8 日,我标的是过渡:SPY 从 1 月 26 日的 286.58 跌到 2 月 8 日的 257.63,9 个交易日跌了 10.1%。自动标注这 9 根全是上升趋势。
原因在输出的最后两张表里:
time price kind confirmed_at
2016-09-22 217.18 1 2016-11-02
2016-11-04 208.55 -1 2016-11-09
2017-03-01 239.78 1 2017-04-13
2017-04-13 232.51 -1 2017-05-05
2018-01-26 286.58 1 2018-02-02
2018-02-08 257.63 -1 2018-02-13
2018-02-26 277.90 1 2018-03-01
2018-03-01 267.70 -1 2018-03-09
2018-03-09 278.87 1 2018-03-19
2018-04-02 257.47 -1 2018-04-05
2018-06-12 278.92 1 2018-06-27
2018-06-27 269.35 -1 2018-07-09
2018-09-20 293.58 1 2018-10-10
2018-10-11 272.17 -1 2018-10-16
2018-10-17 280.45 1 2018-10-24
2018-10-29 263.86 -1 2018-11-01
2018-11-07 281.01 1 2018-11-12
2018-11-23 263.25 -1 2018-11-28
2018-12-03 279.30 1 2018-12-04
2018-12-24 234.34 -1 2018-12-26
2019-05-03 294.03 1 2019-05-13
2019-06-03 274.57 -1 2019-06-05
close state last_high last_low market_state
date
2018-02-02 275.45 1.0 286.58 232.51 up
2018-02-08 257.63 1.0 286.58 232.51 up
2018-02-28 271.65 1.0 286.58 257.63 up
2018-03-01 267.70 0.0 277.90 257.63 range
2019-02-22 279.14 -1.0 279.30 234.34 down
2019-02-25 279.52 0.0 279.30 234.34 transition_up
2019-04-30 294.02 0.0 279.30 234.34 transition_up
2019-05-13 280.86 0.0 294.03 234.34 range
从 2017 年 4 月 13 日到 2018 年 1 月 26 日,SPY 的收盘价9 个多月里没有从高点回落过 3%,一个摆动点都没有产生。2 月 2 日高点 286.58 被确认时,最近一个已确认的低点还是 2017 年 4 月 13 日的 232.51。跌到 257.63 离这个低点还差得远,结构仍然是「高点抬高、低点抬高」。直到 3 月 1 日,一个更低的高点 277.90 被确认,状态才变成震荡。
夜路上,这就像开了 9 个多月的直路,最近的一个路标还是 9 个多月前的。
原因二:直线上涨确认不了新的低点(定义)。 2019 年 1 月 7 日到 5 月 3 日,我标的是上升趋势,82 根。自动标注一根都不同:下降趋势 33 根、过渡 40 根、震荡 9 根。
2018 年 12 月 24 日的低点 234.34 之后,SPY 一路涨到 2019 年 5 月 3 日的 294.03,中间收盘价没有回调过 3%,所以没有新的摆动点。高点仍然是 281.01 → 279.30(降低),低点仍然是 263.25 → 234.34(降低),状态一直是下降趋势。2 月 25 日收盘越过 279.30,变成过渡(向上),之后收盘价在 279.30 上下来回,过渡和震荡都出现过。「上升趋势」需要一个更高的低点,而一段不回调的上涨恰恰不会给出更高的低点。
原因三:看的尺度不同。 2018 年 2 月 9 日到 5 月 3 日,我把整段看成一个 257 到 280 之间的大震荡。自动标注里震荡 27 根、上升趋势 22 根、过渡 9 根:3% 的 ZigZag 在这个区间里看到了更小的结构,比如 3 月 1 日的低点 267.70 和 3 月 9 日的高点 278.87,组成了一小段「高点抬高、低点抬高」。
原因四:我的「过渡」和规则的「过渡」不是一回事(定义)。 我标了 4 段、53 根过渡,自动标注只有 5 根相同。我心里的过渡是「急跌或急涨的那一段,以及紧接着的混乱」;规则的过渡是「收盘越过了框,结构还没跟上」。2018 年 10 月 5 日到 29 日,我标的是过渡,自动标注前 13 根还是上升趋势(9 月 20 日的高点 293.58 到 10 月 10 日才被确认),10 月 24 日直接变成了下降趋势。
原因五:事后的眼光。 2018 年 9 月 21 日到 10 月 4 日,我标的是震荡,自动标注是上升趋势。在那两周里,价格只是在高位横着走了几天,我之所以把它看成震荡,是因为我已经看到了后面 10 月的大跌。2018 年 12 月 26 日到 2019 年 1 月 4 日也一样:我把 V 形底部标成过渡,是因为知道后面涨回去了;站在当时,自动标注的「下降趋势」并没有错。
换一个阈值
ZigZag 2%:相同 63.7%,kappa 0.32
ZigZag 3%:相同 40.0%,kappa 0.12
ZigZag 5%:相同 19.4%,kappa 0.05
ZigZag 8%:相同 18.2%,kappa 0.04
2% 的 ZigZag 和我的标注最接近:相同 63.7%,kappa 0.32。阈值变小,摆动点变多:从图上最下面那条色带可以看到,2019 年那段上涨在 2% 的阈值下大部分被标成了上升趋势。
⚠️ 不要因此就把阈值定成 2%。 这只是一个人、一段 18 个月的行情、一次标注。把参数调到和它最一致,是在拟合这一次标注,换一段行情、换一个人很可能就不是 2% 了。第 30 篇会专门讲这种过拟合。
这个实验真正的收获
实验的目的不是让程序学会你的眼睛,而是反过来:找出你的眼睛里,哪些判断用到了当时不知道的信息。
在这个示范里,五类分歧中,「事后的眼光」是纯粹的前视偏差,拿它去做回测会高估一切;「滞后」和「直线上涨确认不了低点」是规则真实的弱点,交易时要知道它们会在什么行情里出现;「尺度」和「定义」没有对错,只是要想清楚你要交易的是哪个尺度上的结构。
轮到你了:换一段你不熟悉的行情(比如 AAPL 2019 年,或者 BTC 2023 年),按上面六步做一遍。
十、talab.structure:第三部分
新增了什么
| 函数 | 作用 |
|---|---|
market_state |
在 trend_state 的基础上,把市场分成上升趋势、下降趋势、震荡、过渡(向上 / 向下) |
entries |
回调、突破、失败突破三类入场信号 |
first_passage |
从信号出发,先碰到顺向还是反向的 N 倍 ATR 线 |
agreement |
两份逐根标注的相同比例、kappa 和交叉表 |
segments_to_labels |
把手工标注的区间展开成逐根的标签 |
模块文件的第一行也改成了:
"""talab.structure:摆动点、趋势状态和趋势强度(第 8 篇);关键位置和缺口(第 9 篇);市场状态、入场和标注比较(第 11 篇)。"""
analyze、shuffle_bars、compare、month_baseline 这些只在这一篇分析用的函数不放进模块,只在 docs/trade-analysis/analysis/11_price_action.py 里。
代码
# ---------------------------------------------------------------------------
# 六、市场状态和三类入场(第 11 篇)
# ---------------------------------------------------------------------------
def market_state(levels: pd.DataFrame, close: pd.Series) -> pd.Series:
"""在每一根 K 线收盘时,把市场分成三种状态(趋势分上下,过渡分方向,一共五个标签)。
levels 是 trend_state 的结果(用到 state、last_high、last_low 三列):
up / down: 高点和低点同时抬高 / 同时降低(trend_state 的 1 / -1)
range(震荡): 高点和低点方向不一致,收盘价还在最近一个已确认的高点和低点之间
transition_up / transition_down: 高点和低点方向不一致,收盘价已经越过了最近一个已确认的高点 / 低点
还没有两个高点和两个低点时为缺失值(NaN)。
"""
state = levels["state"]
labels = np.select([state == 1, state == -1, close > levels["last_high"], close < levels["last_low"]],
["up", "down", "transition_up", "transition_down"], "range").astype(object)
labels[state.isna().to_numpy()] = np.nan
return pd.Series(labels, index=close.index, name="market_state")
def entries(close: pd.Series, high: pd.Series, low: pd.Series, swings: pd.DataFrame, levels: pd.DataFrame,
state: pd.Series, failed_within: int = 5) -> pd.DataFrame:
"""三类入场信号,每个信号都在那根 K 线收盘时就能知道。
pullback(回调):上升趋势里,一个摆动高点刚被确认(价格已经从高点回落了一个阈值),
之后第一根收盘价高于前一根最高价的 K 线,做多。下降趋势反过来,做空。
等到信号之前状态已经不是同方向的趋势,这次回调作废。
breakout(突破):收盘价第一次越过最近一个已确认的高点,而且前一根 K 线不在上升趋势里,做多。
低点反过来,做空。每个高点 / 低点只算第一次被收盘越过(不管那一次是不是信号)。
failed(失败突破):突破之后 failed_within 根 K 线以内,收盘价回到被突破的价位另一侧,反方向入场。
返回每个信号的 time、kind、direction(1 做多、-1 做空)、level(回调是那个摆动点的价格,
突破和失败突破是被突破的价位)。
"""
c, h, l = close.to_numpy(float), high.to_numpy(float), low.to_numpy(float)
idx, st = close.index, state.to_numpy(object)
last_high, last_low = levels["last_high"].to_numpy(float), levels["last_low"].to_numpy(float)
pos = {t: i for i, t in enumerate(idx)}
rows = []
confirmed = sorted((pos[s.confirmed_at], s.kind, s.price) for s in swings.itertuples())
for k, (j0, kind, price) in enumerate(confirmed):
trend = "up" if kind == 1 else "down" # 高点确认 = 上升趋势里的回调开始了
end = confirmed[k + 1][0] if k + 1 < len(confirmed) else len(c)
for j in range(j0 + 1, end):
if st[j] != trend:
break
if (kind == 1 and c[j] > h[j - 1]) or (kind == -1 and c[j] < l[j - 1]):
rows.append((idx[j], "pullback", kind, price))
break
broken_high = broken_low = np.nan # 已经被收盘越过的高点 / 低点
for j in range(1, len(c)):
direction = 0
if c[j] > last_high[j - 1] and last_high[j - 1] != broken_high:
broken_high, level = last_high[j - 1], last_high[j - 1]
direction = 1 if st[j - 1] != "up" else 0
elif c[j] < last_low[j - 1] and last_low[j - 1] != broken_low:
broken_low, level = last_low[j - 1], last_low[j - 1]
direction = -1 if st[j - 1] != "down" else 0
if direction == 0:
continue
rows.append((idx[j], "breakout", direction, level))
for k in range(j + 1, min(j + 1 + failed_within, len(c))):
if (c[k] - level) * direction < 0:
rows.append((idx[k], "failed", -direction, level))
break
out = pd.DataFrame(rows, columns=["time", "kind", "direction", "level"])
return out.sort_values("time", kind="stable").reset_index(drop=True)
def first_passage(close: pd.Series, high: pd.Series, low: pd.Series, atr: pd.Series, times, directions,
distance: float = 2.0, horizon: int = 20) -> np.ndarray:
"""从每个信号 K 线的收盘价出发,上下各放一条 distance 个 ATR 的线,看之后 horizon 根 K 线里先碰到哪一条。
顺着 direction 的那条先碰到记 1,反方向的先碰到记 0,都没碰到记 NaN。
同一根 K 线两条都碰到时,分不清先后,记 0(按对入场者不利的情况算)。
价格随机游走、没有漂移时,两条线距离相同,记 1 的比例约为 50%。
"""
c, h, l, a = (s.to_numpy(float) for s in (close, high, low, atr))
pos = {t: i for i, t in enumerate(close.index)}
out = np.full(len(times), np.nan)
for n, (t, direction) in enumerate(zip(times, directions)):
i = pos[t]
upper, lower = c[i] + distance * a[i], c[i] - distance * a[i]
for j in range(i + 1, min(i + 1 + horizon, len(c))):
hit_up, hit_down = h[j] >= upper, l[j] <= lower
if hit_up and hit_down:
out[n] = 0.0
elif hit_up or hit_down:
out[n] = float(hit_up == (direction == 1))
else:
continue
break
return out
# ---------------------------------------------------------------------------
# 七、两份标注的比较(第 11 篇,实验一)
# ---------------------------------------------------------------------------
def agreement(a: pd.Series, b: pd.Series) -> dict:
"""比较两份逐根 K 线的标注,只看两边都有标签的 K 线。
share: 标签相同的比例
kappa: Cohen's kappa,扣掉「两边各自按自己的比例随便贴标签也会碰上」的那部分之后的一致程度。
1 是完全一致,0 是和随便贴一样,负数是比随便贴还差
table: 交叉表,行是 a 的标签,列是 b 的标签
"""
both = pd.concat([a.rename("a"), b.rename("b")], axis=1).dropna()
share = float((both["a"] == both["b"]).mean())
pa, pb = both["a"].value_counts(normalize=True), both["b"].value_counts(normalize=True)
chance = float((pa * pb.reindex(pa.index, fill_value=0)).sum())
kappa = (share - chance) / (1 - chance) if chance < 1 else float("nan")
return {"share": share, "kappa": kappa, "table": pd.crosstab(both["a"], both["b"])}
def segments_to_labels(segments: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
"""把手工标注的区间(start、end、label 三列,含两端)展开成逐根 K 线的标签,没标到的 K 线为缺失值。"""
labels = pd.Series(np.nan, index=index, dtype=object)
for s in segments.itertuples():
labels[(index >= pd.Timestamp(s.start, tz=index.tz)) & (index <= pd.Timestamp(s.end, tz=index.tz))] = s.label
return labels
读一遍代码
market_state 只有几行。np.select 按顺序检查条件,先检查趋势(state == 1、state == -1),再检查收盘价是否越过框;前面的条件成立,后面的就不看了。所以「上升趋势里收盘价创了新高」仍然是上升趋势,不会被当成过渡。还没有两个高点和两个低点时,第 8 篇的 state 是 NaN,这里也标成缺失值。
entries 分成两个循环。
回调的循环按确认时间遍历摆动点。高点被确认,说明回调已经够深,从下一根开始找「收盘价高于前一根最高价」;一旦状态不再是上升趋势,或者下一个摆动点被确认了,就停止寻找。
突破的循环里,broken_high 和 broken_low 记住已经被收盘越过的价位。收盘越过一个价位时,不管这一次算不算突破(前一根可能已经是上升趋势),都先把它记为「已越过」,所以同一个价位不会再产生第二个突破信号。突破之后往后看最多 failed_within 根,第一次收回另一侧就记一个失败突破。
first_passage 的一个细节:同一根 K 线同时碰到两条线时,out[n] = 0.0,按对入场者不利来算。日线里一根 K 线的最高价和最低价哪个先出现,第 6 篇说过是不知道的。
agreement 里「随便贴也会相同的比例」是 pa * pb 按标签对齐之后相加:比如两份标注里 up 各占 60%,随便贴时两边同时是 up 的概率就是 0.36。
测试
def test_market_state_by_hand():
close = series([100, 104, 112, 95, 88, 100])
levels = pd.DataFrame({"state": [np.nan, 1, 0, 0, 0, -1],
"last_high": [np.nan, 110, 110, 110, 110, 110],
"last_low": [np.nan, 90, 90, 90, 90, 90]}, index=close.index)
# 结构不一致时看收盘价在框里还是框外:112 在 110 上方,95 在框里,88 在 90 下方
state = X.market_state(levels, close)
assert pd.isna(state.iloc[0])
assert state.iloc[1:].tolist() == ["up", "transition_up", "range", "transition_down", "down"]
def test_entries_breakout_and_failed_by_hand():
close = series([100, 105, 100, 104, 112, 111, 108, 95, 89, 113])
t = close.index
levels = pd.DataFrame({"state": 0.0, "last_high": 110.0, "last_low": 90.0}, index=t)
state = pd.Series(["range"] * 4 + ["transition_up", "transition_up", "range", "range", "transition_down", "range"], index=t)
swings = pd.DataFrame(columns=X.SWING_COLUMNS)
e = X.entries(close, close + 1, close - 1, swings, levels, state)
# 112 第一次收在 110 上方:突破做多;两根之后 108 收回 110 下方:失败突破,做空
# 89 第一次收在 90 下方:突破做空
# 最后 113 收回 90 上方:跌破失败,做多。113 也在 110 上方,但 110 已经被越过一次了,不再算突破
assert e.values.tolist() == [[t[4], "breakout", 1, 110.0], [t[6], "failed", -1, 110.0],
[t[8], "breakout", -1, 90.0], [t[9], "failed", 1, 90.0]]
def test_entries_breakout_needs_a_state_other_than_the_same_trend():
close = series([100, 112, 100, 88])
t = close.index
levels = pd.DataFrame({"state": 1.0, "last_high": 110.0, "last_low": 90.0}, index=t)
state = pd.Series(["up", "up", "range", "transition_down"], index=t)
e = X.entries(close, close, close, pd.DataFrame(columns=X.SWING_COLUMNS), levels, state)
# 112 越过 110 时前一根已经是上升趋势:只是创新高,不算突破;88 跌破 90 时前一根是震荡:算
assert e.values.tolist() == [[t[3], "breakout", -1, 90.0]]
def test_entries_pullback_by_hand():
close = series([110, 120, 107, 105, 108, 104, 109, 111])
t = close.index
swings = pd.DataFrame([(t[1], 120.0, 1, t[2]), (t[5], 104.0, -1, t[6])], columns=X.SWING_COLUMNS)
levels = pd.DataFrame({"state": 1.0, "last_high": np.nan, "last_low": np.nan}, index=t)
state = pd.Series(["up"] * 8, index=t)
e = X.entries(close, close + 1, close - 1, swings, levels, state)
# 高点 120 在下标 2 确认;下标 3 的 105 没有高于前一根最高价 108;下标 4 的 108 高于 106:回调做多
# 低点 104 在下标 6 确认,但状态是上升趋势,不是下降趋势,不产生做空的回调
assert e.values.tolist() == [[t[4], "pullback", 1, 120.0]]
def test_entries_and_market_state_never_use_the_future():
rng = np.random.default_rng(11)
close = series(100 * np.exp(np.cumsum(rng.normal(0, 0.02, 500))))
high, low = close * 1.01, close * 0.99
def run(n):
c, h, l = close.iloc[:n], high.iloc[:n], low.iloc[:n]
swings = X.zigzag(c, 0.05)
levels = X.trend_state(swings, c, c)
state = X.market_state(levels, c)
return state, X.entries(c, h, l, swings, levels, state)
full_state, full_entries = run(500)
assert full_entries["kind"].nunique() == 3 # 三类信号都出现过,这个测试才有意义
for n in [80, 191, 333, 499]:
state, e = run(n)
pd.testing.assert_series_equal(state, full_state.iloc[:n])
known = full_entries[full_entries["time"] <= close.index[n - 1]].reset_index(drop=True)
pd.testing.assert_frame_equal(e, known)
def test_first_passage_by_hand():
close = series([100, 100, 100, 100])
high = series([100, 105, 111, 112])
low = series([100, 95, 96, 88])
atr = series([5, 5, 5, 5])
t = close.index
# 上下两条线都是 100 ± 2 × 5 = 110 / 90
out = X.first_passage(close, high, low, atr, [t[0], t[1], t[2], t[3]], [1, -1, 1, 1])
# t0 做多:下标 2 先碰到 110,记 1;t1 做空:同样先碰到 110,记 0
# t2:下标 3 同时碰到 112 和 88,分不清先后,记 0;t3:后面没有 K 线,记 NaN
assert out[:3].tolist() == [1.0, 0.0, 0.0] and np.isnan(out[3])
assert np.isnan(X.first_passage(close, high, low, atr, [t[0]], [1], horizon=1)[0]) # 只看 1 根,没碰到
def test_agreement_and_segments_by_hand():
idx = pd.date_range("2024-01-01", periods=6, freq="1D", tz="UTC")
segments = pd.DataFrame({"start": ["2024-01-01", "2024-01-05"], "end": ["2024-01-03", "2024-01-05"],
"label": ["up", "range"]})
a = X.segments_to_labels(segments, idx)
assert a.iloc[[0, 1, 2, 4]].tolist() == ["up", "up", "up", "range"] and a.iloc[[3, 5]].isna().all()
a = pd.Series(["up", "up", "up", "range", "range", None], index=idx)
b = pd.Series(["up", "up", "range", "range", "up", "up"], index=idx)
result = X.agreement(a, b)
# 两边都有标签的 5 根里 3 根相同:0.6。两边都是 up 占 0.6、range 占 0.4,随便贴碰上的概率 0.36 + 0.16 = 0.52
assert result["share"] == pytest.approx(0.6)
assert result["kappa"] == pytest.approx((0.6 - 0.52) / (1 - 0.52))
assert result["table"].loc["up", "range"] == 1 and result["table"].loc["range", "up"] == 1
test_market_state_by_hand:五种标签各出现一次,外加一个缺失值。test_entries_breakout_and_failed_by_hand:就是第五节的算例,包括「同一个价位不再算第二次突破」。test_entries_breakout_needs_a_state_other_than_the_same_trend:上升趋势里创新高不算突破。test_entries_pullback_by_hand:包括一个状态不符、不应该产生信号的摆动点。test_entries_and_market_state_never_use_the_future:在一段 500 根的随机价格上,截取前 80、191、333、499 根分别计算,结果必须和用全部数据算出的对应部分完全一样。三类信号都要出现过,否则这个测试就没有检查到它们。test_first_passage_by_hand:顺向、反向、同一根碰到两条线、后面没有 K 线、horizon不够长,五种情况。test_agreement_and_segments_by_hand:kappa 的算例。
pytest -q
........................................................................ [100%]
72 passed in 0.40s
逐根回放
第一节说过,书上只能停四次。下面这个脚本可以从 BTC 日线里随机挑一段,隐藏日期、价格从 100 开始,每次多露出一根,让你决定做多、做空还是不动。结束之后揭晓日期,用 first_passage 给你的每个决定打分,并列出同一段时间里 talab 给出的状态和信号:
"""一根一根地看:从 BTC 日线里随机挑一段,隐藏日期、价格换算成从 100 开始,每次多露出一根 K 线,让你做决定。
在 talab 项目根目录运行:python 11_replay.py [种子] [先露出几根] [一共做几次决定]
每一步把图存成 replay.png(用会自动刷新的看图软件打开),然后在终端输入:
l 做多 s 做空 直接回车 不动 q 提前结束
结束后揭晓这段行情的日期,按第 11 篇的方法给每个做多、做空的决定打分,并列出同一段时间里 talab 的状态和信号。
"""
import glob
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from talab import data as D, plot as P, structure as X
defaults = [0, 120, 40]
seed, shown, steps = [int(v) for v in sys.argv[1:4]] + defaults[len(sys.argv[1:4]):]
cols = ["open", "high", "low", "close"]
P.use_chinese_font()
day = D.load_binance_klines(glob.glob("data/binance/spot/BTCUSDT/1d/*.zip"))
rng = np.random.default_rng(seed)
start = int(rng.integers(100, len(day) - shown - steps - 20)) # 前面留 100 根算结构,后面留 20 根打分
segment = day.iloc[start:start + shown + steps]
scale = 100 / segment["close"].iloc[0]
def draw(bars, path="replay.png"):
"""只画已经收盘的 K 线,横轴是第几根,不显示日期。"""
fig, ax = plt.subplots(figsize=(10, 5), dpi=100)
for x, (o, h, l, c) in enumerate(bars[cols].to_numpy() * scale):
color = "#26a69a" if c >= o else "#ef5350"
ax.vlines(x, l, h, color=color, linewidth=0.8)
ax.add_patch(plt.Rectangle((x - 0.35, min(o, c)), 0.7, max(abs(c - o), 1e-6), color=color))
ax.set_xlim(-1, shown + steps + 1)
ax.autoscale(axis="y")
ax.set_title(f"第 {len(bars)} 根收盘:{bars['close'].iloc[-1] * scale:.2f}")
ax.grid(alpha=0.3)
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
decisions = []
for k in range(shown, shown + steps):
draw(segment.iloc[:k])
answer = input(f"第 {k} 根收盘 {segment['close'].iloc[k - 1] * scale:.2f},l 做多 / s 做空 / 回车不动 / q 结束:")
if answer.strip() == "q":
break
if answer.strip() in ("l", "s"):
decisions.append((segment.index[k - 1], 1 if answer.strip() == "l" else -1))
c = day["close"]
swings = X.zigzag(c, 0.10)
levels = X.trend_state(swings, c, c)
state = X.market_state(levels, c)
atr = X.wilder_smooth(X.true_range(day["high"], day["low"], c), 14)
signals = X.entries(c, day["high"], day["low"], swings, levels, state)
first, last = segment.index[shown - 1], segment.index[shown + steps - 1]
print(f"\n这段行情是 {segment.index[0].date()} 到 {last.date()},你从 {first.date()} 那根开始做决定")
if decisions:
times, directions = zip(*decisions)
result = pd.DataFrame({"time": times, "方向": ["做多" if d == 1 else "做空" for d in directions],
"状态": state.reindex(list(times)).to_numpy(),
"先碰到顺向的 2 ATR 线": X.first_passage(c, day["high"], day["low"], atr, times, directions)})
print(result.to_string(index=False))
print(f"打分的 {result.iloc[:, 3].notna().sum()} 次里,顺向 {result.iloc[:, 3].mean():.0%}(没碰到任何一条线的不算)")
inside = signals[(signals["time"] >= first) & (signals["time"] <= last)]
print("同一段时间里 talab 的信号:")
print(inside.assign(level=(inside["level"] * scale).round(2)).to_string(index=False) if len(inside) else "(没有)")
print("状态的变化:")
part = state.loc[first:last]
print(part[part != part.shift()].to_string())
在 talab 项目根目录运行:
python ../analysis/11_replay.py 7 120 40
三个数字分别是随机种子、先露出几根、一共做几次决定。每一步的图存在 replay.png,用一个会自动刷新的看图软件打开它。
建议这样练:先按自己的感觉做一遍;换一个种子,再严格按这一篇的状态和入场规则做一遍;比较两次的结果,以及每次你想「违反规则」的时刻,当时图上是什么样子。
十一、常见误用
1. 用事后的眼光标注状态,再拿这些标注做回测。 实验一里,「事后的眼光」造成的分歧是纯粹的前视偏差。用回放画出来的路况去模拟开车,永远是老司机。
2. 把「现在是上升趋势」当成「接下来会涨」。 BTC 的上升趋势状态里,36.2% 的 K 线事后看处在下跌段;下降趋势状态里,51.6% 的 K 线处在上涨段。状态描述的是已经确认的结构。
3. 把规则本身决定的结果当成市场规律。 92 段过渡里有 91 段的下一段是震荡,这是定义决定的。自己写的规则统计出「规律」时,先看它是不是规则的必然结果。
4. 调参数让自动标注贴近自己的手工标注。 SPY 那一段,2% 的阈值和手工标注最一致。但这是在拟合一个人对一段行情的一次标注。
5. 同一个价位被来回穿越,每次都记一个信号。 这一篇的第一版就犯了这个错误:BTC 日线 3,987.60 这个高点,在 2019 年 3 月的 12 天里被记了 4 次突破、3 次失败突破。
6. 同一根 K 线碰到止损和止盈,按有利的一边算。 日线里分不清哪个先发生,要按不利的算,或者用小周期数据去查(第 6 篇)。
7. 看到顺向比例 55% 就以为能赚钱。 BTC 1 小时线突破的顺向比例是 55.5% 左右,粗算每次毛收益约 0.27%,还没有扣掉一买一卖 0.2% 的手续费和突破时的滑点。
8. 只在一段行情上检验一种入场。 决定 1 到 4 两对两错,AAPL 那 13 个月的 10 个信号 6 个顺向,这些数字都说明不了规则的好坏。
十二、这一篇能回答什么,不能回答什么
| 这一篇能帮你回答 | 这一篇不能回答 |
|---|---|
| 某一根 K 线收盘时,按一套明确的规则,市场处在哪种状态 | 这套规则是不是「正确」的状态定义(没有唯一正确的定义) |
| 回调、突破、失败突破的信号在什么时候出现 | 出现信号之后该下多大仓位、止损放在哪里、什么时候出场(第 21 到 26 篇) |
| 在 BTC、SPY、AAPL 上,这些信号之后先碰到顺向线的比例和随机相比有没有不同 | 扣掉成本之后能不能赚钱(第 21 到 29 篇) |
| 你看图时的判断里,哪些用到了当时不知道的信息 | 你在实时交易中会不会犯同样的错(要靠第 33 篇的交易日志) |
| 状态和事后的走势有多一致、滞后多少 | 下一段行情会怎么走 |
十三、小结
- 每根 K 线收盘时,前五篇的工具都有知道和不知道的部分。 最关键的一样是:摆动点要等价格反向走够一个阈值才会被确认,三个标的的确认滞后中位数是 4 到 5 根。决定 2 那一根是整段反弹的顶点,9 根之后才能知道。
- 两个摆动点几乎从来不会一样高。 BTC 相邻两个高点相差不到 1% 的只有 6.4%,所以「震荡」不适合用「高低点持平」来定义。
- 市场状态的规则: 高点和低点都抬高是上升趋势,都降低是下降趋势;方向不一致时,收盘价在最近的高点和低点围成的框里是震荡,越过框是过渡。
- 趋势占了大部分时间(BTC 59.5%、SPY 74.7%、AAPL 72.9%),震荡的段数多但很短。92 段过渡里有 91 段下一段是震荡,这是定义决定的。
- 状态是描述,不是预测。 下降趋势状态里,BTC 有 51.6% 的 K 线事后看已经在上涨段里;70% 到 74% 的转折行情,状态从头到尾都没来得及跟上。
- 三类入场写成了规则:回调等摆动点确认后第一根收盘越过前一根最高价;突破是收盘第一次越过已确认的高低点,每个价位只算一次;失败突破是 5 根以内收回。
- 数据上,突破之后价格倾向于顺着突破方向走:BTC 1 小时线突破的顺向比例约 55.5%,高于打乱后的范围,扣掉同月份的整体方向后还多 4 到 6 个百分点。失败突破后反向没有优势,几个显著结果反而更差;回调入场没有优势。日线样本太少,检验不出什么。
- 这些结果要打折扣:54 次检验、同一批信号换距离重复检验、没扣成本、只检验了一种写法。
- 实验一:手工标注和 3% 的自动标注只有 40.0% 相同,kappa 0.12。 分歧来自结构过时、直线上涨确认不了新低点、尺度、对过渡的定义不同,以及事后的眼光。只有最后一种是纯粹的前视偏差。
回到夜路上。开过的路,回放里清清楚楚;开车的那一刻,你只有车灯照到的一小段,和仪表盘上的几个读数。这一篇做的事情,是把「我觉得前面是直路」换成一句能检查的话:最近两个路标都显示在往上走。 它不能告诉你前面有没有弯,但能让你知道自己看到了什么、没看到什么,以及在过去成千上万个路口,冲出收费站的车有多少走对了路。
下一篇进入第三部分「技术指标」,从移动平均线开始,那条跟着课程一起长大的主线策略也在第 12 篇诞生。
练习
练习 1(手算) 最近两个已确认的高点 100 → 96,低点 90 → 85。之后收盘价依次是 92、97、95、99、93,期间没有新的摆动点被确认。
- (a) 写出每一根的市场状态。
- (b) 按第五节的规则,哪一根给出突破信号?之后有没有失败突破?
练习 2(数据) 第七节只看了事后的行情段方向。
- (a) 把「事后」换成「之后 20 根 K 线的收益率」,分别统计五种状态之后 20 根的平均收益率和上涨比例。
- (b) 和打乱 200 次的结果比较。哪种状态和随机数据的差别最大?
练习 3(编程)
第四节说「高低点持平」很少见。给 market_state 加一个 tolerance 参数:两个高点(或低点)相差不到 tolerance 时算持平;高点和低点都持平时标为震荡。
- (a) 写出这个版本,并补一个手算测试。
- (b) tolerance 取 0.5%、1%、2% 时,BTC 震荡状态的时间占比分别是多少?
练习 4(数据) 第八节的失败突破只等 5 根。
- (a) 改成 2 根、10 根,BTC 1 小时线失败突破后反向入场的顺向比例怎么变?
- (b) 把突破之后的第一根就收回的情况单独拿出来,它和其他失败突破有没有不同?
练习 5(数据) 第八节的同月对照扣掉了月份的整体方向。
- (a) 换成「同一周」的全部 K 线,1 小时线突破的优势还剩多少?
- (b) 把突破信号按那根 K 线的相对成交量(第 10 篇,和同一小时比)分成高低两组,顺向比例有没有差别?
练习 6(实验) 按第九节的六步,自己做一遍实验一。
- (a) 挑一段你不熟悉的行情(至少一年日线),先手工标注,再和
market_state比较。 - (b) 把你的分歧按五种原因归类,各占多少根 K 线?
- (c) 把你的标注里「事后的眼光」那几段改成你认为当时能判断出的状态,重新计算 kappa。
小检查答案
小检查 1
(a) 高点 50 → 54 抬高,低点 45 → 43 降低,方向不一致。收盘 52 在框(43 到 54)里:震荡。
(b) 收盘 55 越过了最近的高点 54。高点的方向仍然是抬高,低点仍然是降低,方向不一致;收盘在框的上方:过渡(向上)。
(c) 原本高点 50 → 54 抬高、低点 43 → 45 抬高,是上升趋势。今天收盘 44 跌破了最近的低点 45,「下一个低点比 45 低」已经确定,低点的方向变成降低。高点仍然抬高,方向不一致,收盘在框(45 到 54)的下方:过渡(向下)。上升趋势不需要等新的摆动点被确认,收盘价跌破最近的低点就结束了。
小检查 2
(a) 四个信号:
- 第 2 根收盘 51.5,第一次越过 50,前一根是震荡:突破,做多。
- 第 4 根收盘 49.5,是突破之后的第 2 根,回到 50 下方:失败突破,做空。
- 第 5 根收盘 39,第一次跌破 40,前一根(第 4 根收盘 49.5 在框里)是震荡:突破,做空。
- 第 6 根收盘 52.5,是向下突破之后的第 1 根,回到 40 上方:失败突破,做多。它也在 50 上方,但 50 在第 2 根已经被越过了,不再算突破。
(b) 2 ATR = 3,两条线是 51.5 + 3 = 54.5 和 51.5 - 3 = 48.5。
- 第 3 根:最高 52.5、最低 51.0,都没碰到。
- 第 4 根:最高 52.2、最低 49.2,都没碰到。
- 第 5 根:最低 38.5,碰到了 48.5;最高 48.3,没碰到 54.5。
先碰到的是反方向的线,记 0。