| 本篇位置 | 第二部分「读懂价格行为」的第二篇。后面所有同时用到两个周期的规则(比如用大周期过滤方向、用小周期入场),都要用这一篇的对齐方法 |
| 用到的数据 | BTCUSDT 现货 1 分钟线、日线(2017-08 至 2026-08),官方 4 小时线和周线(只用来核对);SPY 日线(2016-09 至 2026-09) |
| 动手 | 写 talab.timeframes 模块:正确的多周期对齐、正在形成的大周期 K 线,附 6 个测试;再写一个错误的对齐,对比两者的回测差距 |
| 读完你能 | 正确地合成和对齐不同周期的 K 线;知道周线和小时线「打架」有多常见,以及统计上该怎么看;说出 Elder 三重滤网的结构;在自己的回测里找出多周期前视偏差 |
⚠️ 课纲里这一篇的决策点原本用 SPY 的 4 小时线。免费的公开数据里没有美股的日内 K 线(第 6 篇也遇到了同样的限制),所以决策点改用 BTC;SPY 只在日线和周线层面参与统计。
一、先做一个决定
现在是 2025 年 10 月 30 日 UTC 16:00。你持有一笔 BTC 多单。
先看周线。最后一根已经收盘的周线是 10 月 20 日这一周,收在 114,559.40,高于 20 周均线 113,398.93(20 周均线就是最近 20 根周线收盘价的平均值,第 12 篇会细讲):

周线的结构看起来还不错:从 4 月的 74,000 附近一路涨到 10 月初的 126,000。10 月 10 日暴跌(第 2 篇的那一天)之后,10 月 13 日这一周收在了均线下方,但紧接着 10 月 20 日这一周又收回到均线上方。
再看 4 小时线。刚刚收盘的这一根(12:00 到 16:00)收在 107,711.56,跌破了过去 7 天(42 根 4 小时线)的最低价 107,925.06:

注意一个细节:7 天最低价 107,925.06 本身就是今天 04:00 那根 4 小时线的下影线创出来的。那一根盘中跌破了之前的低点,但收盘收回到 111,366.72。而这一根是收盘跌破。
周线说「上升趋势还在」,4 小时线说「破位了」。你会怎么做?
- A. 听周线的:4 小时的波动是噪声,继续持有
- B. 听 4 小时线的:破位就离场
- C. 折中:减仓一半,等周线收盘再决定
先写下你的选择。 第四节会揭晓之后的走势。
在揭晓之前,我们要先回答几个更基本的问题:周线和 4 小时线到底是什么关系?它们「打架」是罕见的信号,还是家常便饭?「听谁的」这个问题,在统计上该怎么回答?
二、三种时间尺度
打个比方:潮汐、波浪和涟漪
站在海边,你能同时看到三种水的运动:
- 潮汐:几个小时才完成一次涨落,决定了整片海面的高度
- 波浪:几秒钟一个,一波一波地冲上沙滩又退回去
- 涟漪:波浪表面上细小的起伏
涨潮的时候,每一个波浪仍然会退回去;但下一个波浪会冲得比上一个更远一点。一个浪退下去,不代表潮水在退。
这个比方来自道氏理论(第 18 篇会讲),Alexander Elder 设计三重滤网时也沿用了它(第五节)。放到这一篇里:周线是潮汐,日线是波浪,4 小时线是涟漪。
决策点的问题,就是「一个浪退下去了,潮水是不是也在退」。
周期之间是装进去的关系
第 6 篇讲过,大周期的 K 线由小周期的 K 线合成:开盘取第一根,最高取最大,最低取最小,收盘取最后一根。
对 BTC 来说:
- 1 根周线 = 7 根日线 = 42 根 4 小时线
- 1 根日线 = 6 根 4 小时线 = 1,440 根 1 分钟线
这种「一层装进一层」的关系有一个前提:边界必须对齐。 4 小时线的边界是 0、4、8、12、16、20 点,正好落在日线的边界(UTC 0 点)上;日线的边界又正好落在周线的边界(周一 0 点)上。如果你用 5 小时线,它的边界就不会和日线对齐,一根 5 小时线可能横跨两天。
美股的情况不一样:一周通常只有 5 个交易日,节假日所在的周更少。
周线从哪一天开始
这个问题听起来很简单,实际上是多周期分析里第一个坑。
default = day[cols + ["volume"]].resample("W").agg(B.OHLCV)
weekly = B.resample_ohlcv(day, "W-MON")
print("pandas 默认 \"W\":", [t.strftime("%Y-%m-%d %a") for t in default.index[:3]])
print("\"W-MON\" + 左闭左标:", [t.strftime("%Y-%m-%d %a") for t in weekly.index[:3]])
print("Binance 官方周线:", [t.strftime("%Y-%m-%d %a") for t in official_1w.index[:3]])
print(pd.concat([official_1w[cols].head(2), default[cols].head(2)], keys=["官方", "默认 W"]))
pandas 默认 "W": ['2017-08-20 Sun', '2017-08-27 Sun', '2017-09-03 Sun']
"W-MON" + 左闭左标: ['2017-08-14 Mon', '2017-08-21 Mon', '2017-08-28 Mon']
Binance 官方周线: ['2017-08-14 Mon', '2017-08-21 Mon', '2017-08-28 Mon']
open high low close
time
官方 2017-08-14 00:00:00+00:00 4261.48 4485.39 3850.0 4086.29
2017-08-21 00:00:00+00:00 4069.13 4453.91 3400.0 4310.01
默认 W 2017-08-20 00:00:00+00:00 4261.48 4485.39 3850.0 4086.29
2017-08-27 00:00:00+00:00 4069.13 4453.91 3400.0 4310.01
两种合成方式算出的数字完全一样:都是周一到周日这 7 天。区别只在标签:
- Binance 官方把这一周标在周一(开始时间),和第 6 篇讲的约定一致。
- pandas 默认的
"W"把同一周标在周日(最后一天)。"W"是"W-SUN"的简写,意思是「以周日为结尾的一周」,而且默认把标签放在区间的右端。
数字一样,标签不同,看起来无伤大雅。但第六节会展示:标签决定了回测「以为」这根周线在什么时候可用,用错了就会用到未来的数据。
resample_ohlcv(day, "W-MON") 用 label="left", closed="left" 把周线标在周一,和官方一致。拿官方周线核对:
W-MON 合成的周线和官方周线:共同的 456 周里对不上 0 周
(官方周线的月度文件目前只发布到 2026 年 6 月,所以只能核对 456 周。)
还有一个更隐蔽的写法。有人会用 index.floor("7D") 把每一天归到它所在的周:
floor("7D") 的结果: ['2017-08-17 Thu', '2017-08-17 Thu', '2017-08-24 Thu', '2017-08-24 Thu']
floor("7D") 以 1970 年 1 月 1 日为起点,每 7 天切一刀,而那一天是周四。所以它切出来的「周」是周四到下周三,和任何人理解的一周都不一样,而且不会报任何错误。
1 分钟线合成 4 小时线
用第 6 篇的方法,把 BTC 的 1 分钟线合成 4 小时线,和官方 4 小时线核对:
rebuilt = B.resample_ohlcv(minute, "4h", traded_only=True)
both = official_4h[cols].join(rebuilt[cols], rsuffix="_合成", how="outer")
wrong = pd.DataFrame({c: (both[c] - both[c + "_合成"]).abs() > 1e-8 for c in cols}).any(axis=1)
print("官方", len(official_4h), "根,合成", len(rebuilt), "根,对不上", wrong.sum(), "根:", wrong.groupby(both.index.year).sum()[lambda s: s > 0].to_dict())
offset = ((both.index >= "2017-12-04") & (both.index < "2017-12-19")) | ((both.index >= "2018-02-09") & (both.index < "2018-02-11"))
print("其中落在两段时间戳偏移时期的", (wrong & offset).sum(), "根;其余", [t.strftime("%Y-%m-%d %H:%M") for t in both.index[wrong & ~offset]])
官方 19794 根,合成 19794 根,对不上 89 根: {2017: 81, 2018: 6, 2020: 1, 2021: 1}
其中落在两段时间戳偏移时期的 87 根;其余 ['2020-12-21 12:00', '2021-04-22 00:00']
19,794 根里有 89 根对不上,其中 87 根落在第 3 篇发现的两段时间戳偏移时期。这一篇后面的 4 小时线都用这个合成结果,它和官方数据的差异可以忽略。
美股的一周有几天
days_per_week = spy["close"].groupby(pd.Grouper(freq="W-MON", label="left", closed="left")).count()
print(days_per_week.value_counts().sort_index().to_dict())
print(days_per_week[days_per_week < 5].tail(3))
{2: 2, 4: 97, 5: 424}
date
2026-06-29 4
2026-09-07 4
2026-09-14 2
Name: close, dtype: int64
十年里 SPY 有 424 周是完整的 5 天,97 周只有 4 天(这一周有节假日,比如 2026 年 7 月 3 日独立日补休、9 月 7 日劳动节)。2 天的两周是数据的第一周和最后一周。
这对周线有两个影响:
- 周线的收盘价不一定是周五的收盘价。 2026 年 6 月 29 日这一周的收盘价是 7 月 2 日(周四)的收盘价。
- 不同的周长度不同。 4 天的周,波动天然比 5 天的周小一些(第 5 篇的平方根法则)。
不同周期的尺度
第 5 篇讲过,波动率按时间的平方根放大。在三个周期上验证一下:
for name, df, periods in [("4 小时", four, 6 * 365), ("日线", day, 365), ("周线", weekly, 52)]:
r = df["close"].pct_change().dropna()
print(f"{name}:收益率标准差 {r.std():.2%},年化 {r.std() * math.sqrt(periods):.1%}")
4 小时:收益率标准差 1.46%,年化 68.2%
日线:收益率标准差 3.51%,年化 67.0%
周线:收益率标准差 9.35%,年化 67.4%
三个周期的年化波动率几乎相同(67% 到 68%)。换句话说:
- 一根周线的典型涨跌,是一根 4 小时线的 6.4 倍(9.35% ÷ 1.46%),大约是 √42 ≈ 6.5
- 4 小时线上一个「很大」的波动,放到周线上可能只是正常的起伏
记住这个比例,第四节会用到。
✋ 小检查 1
一根 4 小时 K 线从周三 08:00(UTC)开始,12:00 收盘。在它收盘的那一刻:
- (a) 能使用的最近一根已收盘的日线,是哪一天的?
- (b) 能使用的最近一根已收盘的周线,是从哪一天开始的那一周?
- (c) 如果换成周日 20:00 开始的那根 4 小时线呢?
答案在文末。
三、一根周阳线里装着什么
看一根真实的周线

2025 年 10 月 20 日这一周,周线是一根实体 5.4% 的阳线(108,642.77 → 114,559.40)。里面的 42 根 4 小时线,有 17 根是阴线。而且在周二到周三,4 小时线连续下跌,把价格从 113,000 附近拉到了 106,666.69,一度比周一开盘价还低 1.8%。
如果你在周三看 4 小时图,你看到的是「破位下跌」;到周日收盘看周线,你看到的是「一根漂亮的周阳线」。
全部历史
weekly["green"] = weekly["close"] > weekly["open"]
week_of = pd.Grouper(freq="W-MON", label="left", closed="left")
red_days = (day["close"] < day["open"]).groupby(week_of).sum()
n_days = day["close"].groupby(week_of).count()
full = weekly[n_days == 7]
g = full[full["green"]]
print(f"完整的周阳线 {len(g)} 根:平均含阴线日 {red_days[g.index].mean():.2f} 天;"
f"至少 3 天阴线 {(red_days[g.index] >= 3).mean():.1%};一天阴线都没有 {(red_days[g.index] == 0).mean():.1%}")
red_4h = (four["close"] < four["open"]).groupby(week_of).mean()
print(f"周阳线里 4 小时阴线的比例 {red_4h[g.index].mean():.1%};周阴线里 {red_4h[full[~full['green']].index].mean():.1%}")
dip = g["low"] / g["open"] - 1
print(f"周阳线从开盘向下最深跌了多少:中位数 {dip.median():.2%},超过 3% 的 {(dip < -0.03).mean():.1%}")
完整的周阳线 250 根:平均含阴线日 2.76 天;至少 3 天阴线 62.8%;一天阴线都没有 1.2%
周阳线里 4 小时阴线的比例 44.9%;周阴线里 52.9%
周阳线从开盘向下最深跌了多少:中位数 -2.28%,超过 3% 的 39.6%
- 一根周阳线里,平均有 2.76 天是阴线。 62.8% 的周阳线至少有 3 天阴线。7 天全是阳线的周阳线,250 根里只有 3 根(1.2%)。
- 周阳线里 44.9% 的 4 小时线是阴线,周阴线里是 52.9%。两者只差 8 个百分点。在 4 小时的尺度上,涨的一周和跌的一周看起来差不多。
- 周阳线在这一周里,价格从开盘向下最深的一次,中位数是 −2.28%,39.6% 的周阳线曾经跌破开盘价 3% 以上。
回到潮汐:涨潮的时候,差不多一半的波浪是在退的。
周线在涨,4 小时线在跌,有多常见
用一个最简单的方式定义「在涨」和「在跌」:收盘价在最近 20 根 K 线的均线上方,还是下方。周线用 20 周均线,4 小时线用 20 根 4 小时线的均线。
weekly["sma20"] = weekly["close"].rolling(20).mean()
weekly["up"] = (weekly["close"] > weekly["sma20"]).where(weekly["sma20"].notna())
four = four.copy()
four["sma20"] = four["close"].rolling(20).mean()
four["wk_up"] = T.align_higher(four.index, "4h", weekly[["up"]], "7D")["up"]
known = four.dropna(subset=["sma20", "wk_up"])
table = pd.crosstab(known["wk_up"].astype(bool), known["close"] > known["sma20"], normalize="index")
table.index, table.columns = ["周线在 20 周均线下", "周线在 20 周均线上"], ["4 小时线在 20 根均线下", "4 小时线在 20 根均线上"]
print(table.round(3))
(T.align_higher 把周线的状态对齐到每一根 4 小时线上,只使用当时已经收盘的周线。它是这一篇的核心函数,第六节会详细讲为什么必须这样写。)
4 小时线在 20 根均线下 4 小时线在 20 根均线上
周线在 20 周均线下 0.497 0.503
周线在 20 周均线上 0.464 0.536
周线在 20 周均线上方的时候,有 46.4% 的时间,4 小时线在自己的均线下方。
「周线在涨,小时线在跌」不是一个罕见的冲突信号,它几乎一半的时间都在发生。而且这张表还告诉你另一件事:周线在均线上方时,4 小时线在均线上方的比例是 53.6%;周线在均线下方时是 50.3%。知道周线的状态,几乎不能帮你猜出 4 小时线此刻的状态。
⚠️ 这里用的「在均线上方就算在涨」是一个非常粗糙的定义,只是为了能数出来。第 8 篇会讲更严格的趋势定义,结论的数字会变,但「不同周期经常不一致」这个事实不会变。
四、听谁的
把问题变成可以统计的问题
「听谁的」这个问题没法直接回答。能回答的是:周线向上的时候,4 小时线破位之后,价格通常怎么走?和不破位的时候相比有什么不同?
定义:
- 周线向上:最后一根已收盘的周线,收盘价在 20 周均线上方
- 4 小时破位:4 小时线收盘价低于前 42 根(7 天)的最低价,而且前 7 天内没有发生过破位(只数每一波的第一次,避免同一次下跌被重复计算)
然后看破位之后 7 天和 30 天的涨跌,和「所有时刻」做比较。
four["low_7d"] = four["low"].rolling(42).min().shift(1) # 前 42 根(7 天)的最低价
four["break"] = four["close"] < four["low_7d"]
four["event"] = four["break"] & (four["break"].rolling(42).sum().shift(1) == 0) # 7 天内第一次破位
c = four["close"]
for label, n in [("7 天", 42), ("30 天", 180)]:
four[label] = c.shift(-n) / c - 1
rows = []
for trend, name in [(True, "周线在均线上"), (False, "周线在均线下")]:
base = four[four["wk_up"] == trend]
ev = base[base["event"]]
for label in ["7 天", "30 天"]:
e, b = ev[label].dropna(), base[label].dropna()
t_stat = (e.mean() - b.mean()) / math.sqrt(e.var() / len(e) + b.var() / len(b))
rows.append({"周线": name, "之后": label, "破位次数": len(e), "破位后平均": e.mean(), "破位后上涨比例": (e > 0).mean(),
"所有时刻平均": b.mean(), "所有时刻上涨比例": (b > 0).mean(), "t": t_stat})
print(pd.DataFrame(rows).round(4).to_string(index=False))
depth = (four["low_7d"] - four["close"]) / four["close"]
print(f"破位时收盘价在 7 天最低价下方多少:中位数 {depth[four['event']].median():.2%}")
注意 rolling(42).min().shift(1) 里的 shift(1):「前 7 天最低价」不能包含当前这根 K 线自己,否则收盘价永远不可能低于它(最低价总是 ≤ 收盘价)。这和第 5 篇 standardize 里的 shift(1) 是同一个道理。
周线 之后 破位次数 破位后平均 破位后上涨比例 所有时刻平均 所有时刻上涨比例 t
周线在均线上 7 天 70 -0.0013 0.4857 0.0101 0.5209 -1.0317
周线在均线上 30 天 70 0.0210 0.4000 0.0535 0.5224 -1.1380
周线在均线下 7 天 57 0.0107 0.5789 0.0050 0.5324 0.4990
周线在均线下 30 天 56 0.0411 0.6429 0.0144 0.5507 1.0829
破位时收盘价在 7 天最低价下方多少:中位数 0.72%
整理一下「周线在均线上」的两行:
| 4 小时破位之后(70 次) | 所有时刻 | |
|---|---|---|
| 7 天后平均涨跌 | −0.13% | +1.01% |
| 7 天后上涨的比例 | 48.6% | 52.1% |
| 30 天后平均涨跌 | +2.10% | +5.35% |
| 30 天后上涨的比例 | 40.0% | 52.2% |
看起来,周线向上时的 4 小时破位,确实是一个偏弱的信号:之后 30 天只有 40% 的时候上涨,而所有时刻平均是 52%。
但看最后一列的 t 值:−1.03 和 −1.14。第 6 篇讲过,t 的绝对值小于 2,就不能排除差距只是随机造成的。70 次破位,样本太少,而 BTC 30 天的涨跌幅度太大(标准差约 20%),这点差距淹没在噪声里。
「周线在均线下」的两行,方向正好相反:破位之后反而涨得比平时多,但 t 值同样不显著。
答案取决于你的交易周期
统计没有给出「听周线」或「听 4 小时线」的明确答案。那么实际上该怎么想?
回到第二节的尺度:一根周线的典型涨跌是 9.35%,而 4 小时破位时,收盘价通常只比 7 天最低价低 0.72%。
这意味着「听谁的」其实是在问:你的这笔交易,是按哪个周期设计的?
- 如果你是按周线做的交易:入场理由是周线趋势,止损设在周线级别的位置(比如 20 周均线下方,距离当前价格可能有 10% 以上),预期持有几周到几个月。那么一次 0.72% 的 4 小时破位,只是周线里正常的波浪,它不在你的决策范围之内。
- 如果你是按 4 小时线做的交易:入场理由是 4 小时线的结构,止损设在 4 小时线的低点下方(距离可能只有 1% 到 3%),预期持有几天。那么 4 小时破位就是你的离场信号,周线向上并不能成为继续持有的理由。
最常见的错误,是用一个周期入场,再用另一个周期找理由不离场。 按 4 小时线入场,跌破止损之后说「周线还在涨,再拿一拿」,这笔交易的风险就从 2% 悄悄变成了 10% 以上(第 23 篇会专门讲止损)。
这就是这一篇对「听谁的」的回答:先确定你的交易属于哪个周期,然后只听这个周期的出场信号;大周期可以用来决定做不做、往哪个方向做,但不能用来推翻小周期的止损。
揭晓

7 天后:-5.29%
30 天后:-15.46%
之后的最低价: 57800.19 2026-07-01
决策点之后,BTC 第二天反弹了一下,然后一路下跌:7 天后跌 5.29%,30 天后跌 15.46%。那一周(10 月 27 日这一周)收在 110,540.68,跌破了 20 周均线;再下一周收在 104,722.96。这是第 4 篇那次跌幅 54.2% 的熊市中的一段,这轮下跌的最低点,就是第 6 篇 K 线 A 那天的 57,800.19。
回到三个选择:
- A(听周线):结果取决于你「听周线」时有没有离场规则。
- 如果规则是「周线收盘跌破 20 周均线就离场」:11 月 3 日 0 点周线收盘确认,在 110,540.68 附近离场,比 4 小时破位的价格还高 2.6%,因为中间有一次反弹。
- 如果没有离场规则,只是「周线的趋势还在,就拿着」:30 天后亏 15.46%,之后一路跌到 57,800。
- B(听 4 小时线,离场):在 107,711.56 附近离场,躲过了之后的下跌。
- C(减半,等周线收盘):一半在 107,711.56 附近离场,另一半在 110,540.68 附近离场,平均约 109,126。
事后看,只要有一条明确的离场规则,不管按周线还是按 4 小时线,这一次的结果都差不多。真正危险的是 A 的第二种:用「大周期还在涨」代替离场规则。
而且上一小节的统计说明,这只是 70 次破位里的一次。 70 次里有 40% 的破位,之后 30 天是上涨的,同样的「离场」会让你错过上涨。决定你该选哪个的,不是这一次的结果,而是你的交易是按哪个周期设计的。
还有一个细节会在第六节变得非常重要:10 月 27 日这一周要到 11 月 3 日 0 点才收盘。 在 10 月 30 日 16:00 这个时刻,你无论如何都不可能知道它会收在均线下方。如果你的回测「知道」了,回测就作弊了。
五、Elder 三重滤网
它是什么
三重滤网(Triple Screen)是 Alexander Elder 在 1986 年 4 月的《Futures》杂志上发表的交易方法,后来在他的书《Trading for a Living》里做了详细介绍。它是最有名的多周期交易框架。
它的出发点是:任何一个周期上的指标,都会时而给出好信号、时而给出坏信号;用几个周期互相过滤,可以去掉一部分坏信号。 具体是三层:
| 滤网 | 周期 | 比方 | 作用 | 原版用的工具 |
|---|---|---|---|---|
| 第一层 | 长周期(比如周线) | 潮汐 | 决定方向:只做和潮汐同向的交易 | 周线 MACD 柱状图的斜率 |
| 第二层 | 中周期(比如日线) | 波浪 | 找逆着潮汐的回调:潮汐向上时,等波浪退下去再买 | 振荡指标(比如强力指数、随机指标) |
| 第三层 | 短周期(或者日内) | 涟漪 | 决定入场时机:回调结束的迹象出现时才入场 | 在前一天最高价上方挂突破买入单 |
Elder 还给了一个选择周期的经验规则:相邻两个周期之间,大约相差 5 倍。 比如周线(5 个交易日)→ 日线 → 1 到 2 小时线。
这个框架的逻辑和第四节的结论是一致的:大周期决定做不做、往哪个方向做,小周期决定什么时候进场。 它没有用大周期去推翻小周期的离场信号。
做一个最简化的版本
MACD 和振荡指标要到第 13、14 篇才讲。这里先用最简单的规则代替,只保留三重滤网的结构:
| 滤网 | 我们的简化规则 |
|---|---|
| 第一层(周线) | 最后一根已收盘的周线,收盘价在 20 周均线上方 → 方向向上 |
| 第二层(日线) | 今天的收盘价是最近 5 天的最低收盘价 → 出现回调 |
| 第三层(入场) | 第二天价格涨过回调日的最高价,就按这个价格买入(如果开盘就跳过了这个价格,只能按开盘价买);BTC 用 4 小时线判断盘中第一次涨过的时刻 |
| 离场 | 买入之后第 10 天收盘卖出 |
为了看清楚第一层到底有没有用,不管周线方向,所有回调信号都执行,然后把结果按「周线向上」和「周线向下」分开统计。只做多,持仓期间不重复开仓,不计成本。
def triple_screen(daily, weekly_bars, intraday=None, hold=10):
wk = weekly_bars.copy()
wk["sma20"] = wk["close"].rolling(20).mean()
wk["up"] = (wk["close"] > wk["sma20"]).where(wk["sma20"].notna())
tide = T.align_higher(daily.index, "1D", wk[["up"]], "7D")["up"] # 第一层:周线方向
wave = daily["close"] <= daily["close"].rolling(5).min() # 第二层:日线回调到 5 天最低收盘
trades, busy_until = [], -1
for i in range(5, len(daily) - hold - 1):
if not wave.iloc[i] or pd.isna(tide.iloc[i]) or i <= busy_until:
continue
trigger = daily["high"].iloc[i] # 第三层:第二天突破信号日最高价才买入
next_day = daily.index[i + 1]
if intraday is not None:
bars = intraday.loc[next_day:next_day + pd.Timedelta("1D") - pd.Timedelta("1s")]
hit = bars[bars["high"] >= trigger]
if hit.empty:
continue
fill = max(trigger, hit["open"].iloc[0]) # 跳空越过触发价时,只能按开盘价成交
else:
if daily["high"].iloc[i + 1] < trigger:
continue
fill = max(trigger, daily["open"].iloc[i + 1])
exit_price = daily["close"].iloc[i + 1 + hold]
trades.append({"信号日": daily.index[i], "周线向上": bool(tide.iloc[i]), "收益": exit_price / fill - 1})
busy_until = i + 1 + hold
return pd.DataFrame(trades)
for name, daily, intraday in [("BTC", day, four), ("SPY", spy.assign(volume=spy["volume"].fillna(0)), None)]:
trades = triple_screen(daily, B.resample_ohlcv(daily, "W-MON"), intraday)
base = (daily["close"].shift(-10) / daily["close"] - 1).dropna()
print(f"{name}:任意一天买入持有 10 天,平均 {base.mean():+.2%},上涨比例 {(base > 0).mean():.1%}")
for label, part in [("全部信号", trades), ("周线向上", trades[trades["周线向上"]]), ("周线向下", trades[~trades["周线向上"]])]:
r = part["收益"]
print(f" {label}:{len(r):3d} 笔,平均 {r.mean():+.2%},中位数 {r.median():+.2%},上涨比例 {(r > 0).mean():.1%}")
up, down = trades.loc[trades["周线向上"], "收益"], trades.loc[~trades["周线向上"], "收益"]
print(f" 周线向上 vs 向下,平均收益差异的 t = {(up.mean() - down.mean()) / math.sqrt(up.var() / len(up) + down.var() / len(down)):.2f}")
几个细节:
tide用T.align_higher对齐,在信号日收盘时只能看到上一根已收盘的周线。- 第三层是突破买入单:价格涨过触发价时才成交。如果第二天一开盘就跳空到触发价上方,买单只能按开盘价成交,这就是
max(trigger, 开盘价)。 - BTC 用 4 小时线找「第二天第一次涨过触发价」的那一根,成交价取触发价和那一根开盘价中较高的一个。SPY 没有日内数据,只能用日线的最高价判断,用开盘价判断跳空。
busy_until保证持仓期间不会重复开仓,让每一笔交易互不重叠。
BTC:任意一天买入持有 10 天,平均 +1.54%,上涨比例 54.2%
全部信号:127 笔,平均 +2.80%,中位数 +1.43%,上涨比例 55.9%
周线向上: 64 笔,平均 +3.11%,中位数 +1.33%,上涨比例 57.8%
周线向下: 63 笔,平均 +2.49%,中位数 +2.24%,上涨比例 54.0%
周线向上 vs 向下,平均收益差异的 t = 0.33
SPY:任意一天买入持有 10 天,平均 +0.56%,上涨比例 65.0%
全部信号:101 笔,平均 +0.74%,中位数 +0.90%,上涨比例 65.3%
周线向上: 78 笔,平均 +0.43%,中位数 +0.85%,上涨比例 66.7%
周线向下: 23 笔,平均 +1.81%,中位数 +2.12%,上涨比例 60.9%
周线向上 vs 向下,平均收益差异的 t = -1.46
读这个结果
BTC:周线向上时的信号,平均赚 3.11%;周线向下时,平均赚 2.49%。看起来第一层滤网有一点用,但 t = 0.33,差距完全在随机范围内。而且中位数反过来了:周线向下时的中位数(+2.24%)比周线向上时(+1.33%)更高。
SPY:方向正好相反。周线向下时的信号平均赚 1.81%,比周线向上时的 0.43% 还高,t = −1.46,同样不显著。SPY 周线向下的信号只有 23 笔。
所以,在这个最简化的版本上,第一层滤网(周线方向)没有带来可以检验出来的改善。
这不等于三重滤网没用。这个检验有很多限制:
- 规则被大幅简化了。 原版用 MACD 柱状图的斜率判断潮汐、用振荡指标判断波浪,我们用的是均线和 5 天最低收盘价。
- 样本很少。 BTC 127 笔、SPY 101 笔,按周线方向分开之后,SPY 的一组只有 23 笔。
- 只看了 10 天固定持有这一种离场方式。 原版的离场和止损规则更复杂。
- 没有计算成本(第 28 篇)。
但它足以说明一件事:一个有名的、逻辑通顺的框架,不能因为它有名、逻辑通顺就直接拿来用。 「顺着大周期的方向做」听起来无懈可击,在这两个标的、这十年、这个版本上,却检验不出效果。第三部分学完 MACD 和振荡指标之后,练习 5 会让你用更接近原版的规则重新检验;第六部分会讲怎样系统地做这种检验,而不是只看一次的结果。
还有一个值得注意的现象:两个标的上,「全部信号」的平均收益(BTC +2.80%、SPY +0.74%)都高于「任意一天买入持有 10 天」(+1.54%、+0.56%)。也就是说,第二、三层(回调之后突破买入)本身可能有一点作用,而第一层没有。这同样需要更严格的检验,第 32 篇讲均值回归时会回到这个问题。
六、多周期回测里最常见的前视偏差
问题出在哪
回到第四节揭晓里的那个细节:10 月 27 日这一周要到 11 月 3 日 0 点才收盘。
把周线的数据放到 4 小时线上时,每一根 4 小时线都要回答一个问题:在我收盘、做决定的这一刻,我能看到哪一根周线?

- 正确的答案:只能看到已经收盘的周线。10 月 30 日 16:00,最后一根已收盘的周线是 10 月 20 日这一周(10 月 27 日 0 点收盘)。
- 错误的答案:看到了 10 月 27 日这一周。这一周的开始时间(10 月 27 日 0 点)确实在 10 月 30 日之前,但它的收盘价要 3 天多以后才出现。
用潮汐的比方:你站在沙滩上决定下午要不要下海,却拿到了一张「今天全天的潮位记录」,上面写着今晚最高潮位是多少。这张记录要到今天结束才能写出来,下午的你不可能拿到它。
一行代码的差别
错误的写法非常自然,也非常常见:
def align_wrong(lower_index, higher):
"""错误的对齐:用大 K 线的开始时间去匹配,会用到还没收盘的大 K 线。"""
return higher.reindex(lower_index, method="ffill")
reindex(..., method="ffill") 的意思是:对每一根 4 小时线,找到时间戳不晚于它的最后一根周线。周线的时间戳是开始时间,所以周三的 4 小时线会找到本周一开始的那根周线,而那根周线包含了周三之后才发生的价格。
正确的写法(talab.timeframes.align_higher)只多了两步:先把周线的时间戳挪到收盘时间(开始时间 + 7 天),再把 4 小时线的时间戳也挪到收盘时间(开始时间 + 4 小时),然后再做同样的匹配。
在决策点那一刻对比一下:
right = T.align_higher(four.index, "4h", weekly[cols + ["sma20", "up"]], "7D")
bad = align_wrong(four.index, weekly[cols + ["sma20", "up"]])
for name, view in [("正确对齐", right), ("错误对齐", bad)]:
row = view.loc[t]
print(f"{name}:周线收盘 {row['close']:,.2f},20 周均线 {row['sma20']:,.2f},在均线上方:{bool(row['up'])}")
dev = T.developing(four, "W-MON")
print("正在形成的周线:", dev.loc[t, cols].round(2).to_dict())
正确对齐:周线收盘 114,559.40,20 周均线 113,398.93,在均线上方:True
错误对齐:周线收盘 110,540.68,20 周均线 113,646.26,在均线上方:False
正在形成的周线: {'open': 114559.41, 'high': 116400.0, 'low': 107328.0, 'close': 107711.56}
错误对齐在 10 月 30 日就「知道」这一周会收在 110,540.68、跌破均线。 一个用它做回测的「周线过滤」策略,会在决策点那一刻干净利落地离场,看起来非常聪明。而你在实盘里永远做不到。
第三行是另一种合法的做法:正在形成的周线。到 10 月 30 日 16:00 为止,这一周的开盘价是 114,559.41,最高 116,400.00,最低 107,328.00,「当前收盘价」就是此刻的价格 107,711.56。这些数字在那一刻都是真实可知的,只是这根周线还没走完,收盘价还会变。第 12 篇以后,很多实盘规则都会用「正在形成的大周期 K 线」,它不是作弊,但它和「已收盘的周线」是两个不同的东西,回测和实盘必须用同一种。
回测差距有多大
用两个规则做回测,对比正确对齐和错误对齐。
规则一(4 小时线):这一周是阳线,就持有 BTC。
这是一个故意设计得很「直白」的规则,用来看清前视偏差的上限。
规则二(日线):最后一根周线收在 20 周均线上方,就持有。 这是一个非常常见的趋势过滤器。
def backtest(position, returns, periods_per_year):
"""position 是每根 K 线收盘时决定的仓位(1 持有,0 空仓),从下一根 K 线开始生效。"""
equity = (1 + position.shift(1).fillna(0) * returns.fillna(0)).cumprod()
growth = equity.iloc[-1]
return growth, growth ** (periods_per_year / len(returns)) - 1
weekly["green"] = weekly["close"] > weekly["open"]
default["green"] = default["close"] > default["open"]
r4 = four["close"].pct_change()
tests = {
"错误对齐(周一标签)": align_wrong(four.index, weekly[["green"]])["green"],
"错误对齐(pandas 默认 W,周日标签)": align_wrong(four.index, default[["green"]])["green"],
"正确对齐(上一根已收盘的周线)": T.align_higher(four.index, "4h", weekly[["green"]], "7D")["green"],
"正在形成的周线": T.developing(four, "W-MON").eval("close > open"),
}
correct = tests["正确对齐(上一根已收盘的周线)"].astype(float).fillna(0)
print("规则:这一周是阳线就持有 BTC,4 小时线")
for name, signal in tests.items():
pos = signal.astype(float).fillna(0)
growth, cagr = backtest(pos, r4, 6 * 365)
print(f" {name}:×{growth:,.2f}(年化 {cagr:.1%}),和正确对齐不同的 K 线 {int((pos != correct).sum())} 根")
print(f" 一直持有:×{four['close'].iloc[-1] / four['close'].iloc[0]:.2f}")
rd = day["close"].pct_change()
print("规则:周线收盘在 20 周均线上方就持有,日线")
for name, market, bars, ppy in [("BTC", day, weekly, 365), ("SPY", spy, B.resample_ohlcv(spy.assign(volume=spy["volume"].fillna(0)), "W-MON"), 252)]:
bars = bars.copy()
bars["sma20"] = bars["close"].rolling(20).mean()
bars["up"] = (bars["close"] > bars["sma20"]).where(bars["sma20"].notna())
ret = market["close"].pct_change()
good = T.align_higher(market.index, "1D", bars[["up"]], "7D")["up"].astype(float).fillna(0)
leak = align_wrong(market.index, bars[["up"]])["up"].astype(float).fillna(0)
g1, c1 = backtest(leak, ret, ppy)
g2, c2 = backtest(good, ret, ppy)
print(f" {name}:错误对齐 ×{g1:.2f}(年化 {c1:.1%}) 正确对齐 ×{g2:.2f}(年化 {c2:.1%}) "
f"不同的日子 {int((good != leak).sum())} 天 一直持有 ×{market['close'].iloc[-1] / market['close'].iloc[0]:.2f}")
backtest 里的 position.shift(1) 表示:第 t 根 K 线收盘时决定的仓位,从第 t + 1 根 K 线开始生效。这一步本身是正确的,它保证了不会用第 t 根的收盘价决定第 t 根的仓位。前视偏差藏在更早的地方:position 本身用到了未来的周线。这就是多周期前视偏差难以发现的原因,回测框架里所有常规的防护都做对了,偏差是从数据对齐那一步悄悄带进来的。
规则:这一周是阳线就持有 BTC,4 小时线
错误对齐(周一标签):×13,598,190.31(年化 515.5%),和正确对齐不同的 K 线 9923 根
错误对齐(pandas 默认 W,周日标签):×14.25(年化 34.2%),和正确对齐不同的 K 线 1210 根
正确对齐(上一根已收盘的周线):×7.27(年化 24.5%),和正确对齐不同的 K 线 0 根
正在形成的周线:×50.12(年化 54.2%),和正确对齐不同的 K 线 9944 根
一直持有:×18.06
规则:周线收盘在 20 周均线上方就持有,日线
BTC:错误对齐 ×374.13(年化 92.5%) 正确对齐 ×7.45(年化 24.9%) 不同的日子 246 天 一直持有 ×18.34
SPY:错误对齐 ×6.89(年化 21.4%) 正确对齐 ×2.27(年化 8.6%) 不同的日子 189 天 一直持有 ×3.52

逐行读:
规则一,错误对齐(周一标签):1,360 万倍,年化 515.5%。 策略在每一周开始时就知道这一周会不会收阳线。这是前视偏差的极端情况,任何人看到都会怀疑。
规则一,pandas 默认 W(周日标签):14.25 倍,是正确结果 7.27 倍的将近两倍。 这个错误隐蔽得多。周线标在周日 0 点,而它真正收盘是周一 0 点,所以只有周日这一天的 6 根 4 小时线用到了未来:在周日 0 点到 20 点之间,策略已经知道这一周最后会不会收阳线。每周只偷看一天,九年下来,结果就翻了一倍。
有意思的是,同样的周日标签用在日线上不会造成前视偏差:周日这根日线收盘的时刻,正好就是这一周收盘的时刻。同一段对齐代码,用在日线上是对的,换到 4 小时线上就错了。 这是为什么 align_higher 必须同时知道大周期和小周期的长度。
规则一,正在形成的周线:50.12 倍。 这个结果没有前视偏差:它在每一根 4 小时线收盘时,用「这一周到目前为止的开盘价和当前价格」判断,这些都是当时可知的。它比「上一周是阳线」好得多,说明「这一周到目前为止在涨」本身包含了有用的信息。但它的仓位切换非常频繁(和正确对齐不同的 K 线有 9,944 根),扣除成本之后会怎样,要到第 28 篇才能回答。
规则二,BTC:错误对齐 374 倍,正确对齐 7.45 倍。 这是更现实的情况:一个普通的周线趋势过滤器。两者只有 246 天的仓位不同,占全部 3,302 天的 7.5%。这 246 天都是「这一周正在跌破(或者站上)均线」的日子,错误对齐提前几天知道了结果,在每一次趋势转折前精准地进出。
规则二,SPY:错误对齐 6.89 倍,正确对齐 2.27 倍。 错误对齐的结果远远跑赢一直持有(3.52 倍),正确对齐则明显跑输。一个结论(「这个周线过滤器能跑赢持有」)完全是由一行错误的对齐代码制造出来的。
怎样避免
- 每一个大周期数据,都要问「它在什么时刻才可知」。 大 K 线的可用时刻是它的收盘时间,不是开始时间,也不是 pandas 给它贴的标签。
- 只用一个对齐函数,并且给它写「不会用到未来」的测试。
talab.timeframes的测试test_align_higher_never_uses_the_future的做法是:把未来的周线改成 −999,检查之前的对齐结果一个都没有变。这种测试比检查具体数字更可靠,因为它直接检查了「不偷看未来」这个性质。 - 回测结果好得离谱时,先怀疑数据对齐。 年化 515% 当然离谱;但 SPY 的年化 21.4% 并不离谱,它才是真正危险的情况。一个在合理范围内的前视偏差,比一个离谱的前视偏差更难发现。
- 回测和实盘用同一种大周期数据。 如果实盘用的是「正在形成的周线」,回测也要用
developing;如果实盘等周线收盘,回测就用align_higher。
⚠️ 看盘软件里也有同样的问题。 很多图表平台允许在一个周期的图上引用另一个周期的数据,比如在日线图上画周线的均线。这类功能通常有一个选项,决定历史 K 线上显示的是「当时已收盘的大周期值」还是「这根大 K 线最终的值」。后一种在历史图上看起来非常准,实盘时却做不到。在任何平台上使用多周期指标之前,先用一段历史数据手动核对它在某一根 K 线上显示的值,是不是当时真的能知道。
✋ 小检查 2
一个策略在日线上运行,规则是「这个月是阳线就持有」。月线由日线合成。
- (a) 月线标在每月 1 日(开始时间),用
align_wrong对齐到日线上。在 3 月 5 日这一天,策略「看到」的是哪个月的月线?这根月线在什么时候才真正收盘? - (b) 月线改用 pandas 的
resample("ME")合成(标签在每月最后一天),仍然用align_wrong(也就是reindex(..., method="ffill"))对齐到日线上。前视偏差会消失吗? - (c) 月线和 (b) 完全一样,但策略改在 4 小时线上运行呢?
七、talab.timeframes:把这一篇写成模块
放在哪里
talab/
├── talab/
│ ├── __init__.py
│ ├── data.py
│ ├── plot.py
│ ├── stats.py
│ ├── bars.py
│ └── timeframes.py ← 新增
├── tests/
│ ├── test_data.py
│ ├── test_plot.py
│ ├── test_stats.py
│ ├── test_bars.py
│ └── test_timeframes.py ← 新增
├── scripts/
│ └── fetch_course_data.py
├── pyproject.toml
└── requirements.txt
align_wrong 故意不放进模块。它只在 docs/trade-analysis/analysis/07_timeframes.py 里用来做对比,模块里只保留正确的做法。
准备数据
import glob
import math
import numpy as np
import pandas as pd
from talab import bars as B, data as D, timeframes as T
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")))
D.download_binance_klines("BTCUSDT", "4h", "2017-08", "2026-08") # 官方的 4 小时线和周线,只用来核对
D.download_binance_klines("BTCUSDT", "1w", "2017-08", "2026-06") # 周线的月度文件目前只发布到 2026-06
official_4h = D.load_binance_klines(sorted(glob.glob("data/binance/spot/BTCUSDT/4h/*.zip")))
official_1w = D.load_binance_klines(sorted(glob.glob("data/binance/spot/BTCUSDT/1w/*.zip")))
spy = D.load_nasdaq_daily("data/nasdaq/SPY_historical.json").drop(pd.Timestamp("2026-04-20"))
print("官方 4 小时线", len(official_4h), "根,官方周线", len(official_1w), "根,最后一周", official_1w.index[-1].date())
官方 4 小时线 19794 根,官方周线 456 根,最后一周 2026-06-29
官方的 4 小时线和周线不在 scripts/fetch_course_data.py 里,只在这一篇用来核对合成结果,所以在这里单独下载。后面的分析都用自己合成的 four = B.resample_ohlcv(minute, "4h", traded_only=True) 和 weekly = B.resample_ohlcv(day, "W-MON"),决策点用的 t = pd.Timestamp("2025-10-30 12:00", tz="UTC")。
代码
"""talab.timeframes:多周期对齐。第 7 篇。"""
from __future__ import annotations
import pandas as pd
def align_higher(lower_index: pd.DatetimeIndex, lower_period: str,
higher: pd.DataFrame, higher_period: str) -> pd.DataFrame:
"""把大周期的数据对齐到小周期的每一根 K 线上,只使用当时已经收盘的大周期 K 线。
lower_index: 小周期 K 线的时间戳(开始时间)
lower_period: 小周期的长度,比如 "4h"、"1D"
higher: 大周期的数据,索引是每根大 K 线的开始时间
higher_period: 大周期的长度,比如 "1D"、"7D"
规则:小周期 K 线在它收盘的那一刻做决定;这一刻之前(含这一刻)已经收盘的大周期 K 线才能使用。
大 K 线的收盘时间 = 开始时间 + higher_period;小 K 线的收盘时间 = 开始时间 + lower_period。
返回的表和 lower_index 一一对应;还没有任何大 K 线收盘时为 NaN。
"""
available = higher.copy()
available.index = higher.index + pd.Timedelta(higher_period) # 每根大 K 线从收盘那一刻起才可用
decide_at = lower_index + pd.Timedelta(lower_period) # 每根小 K 线收盘、做决定的时刻
out = available.reindex(decide_at, method="ffill") # 取这一刻之前最近一根已收盘的大 K 线
out.index = lower_index
return out
def developing(lower: pd.DataFrame, rule: str) -> pd.DataFrame:
"""正在形成的大周期 K 线:在每一根小 K 线收盘时,这根大 K 线「到目前为止」的样子。
开盘 = 这个大周期第一根小 K 线的开盘价(固定不变)
最高 / 最低 = 到目前为止的最高 / 最低
收盘 = 当前这根小 K 线的收盘价(还会继续变)
成交量 = 到目前为止的累计
每个大周期最后一根小 K 线上的值,就等于这根大 K 线收盘后的最终值。
rule 和 resample 的写法一样,比如 "1D"、"W-MON"(周一开始的一周)。
"""
groups = pd.Grouper(freq=rule, label="left", closed="left")
return pd.DataFrame({
"open": lower["open"].groupby(groups).transform("first"),
"high": lower["high"].groupby(groups).cummax(),
"low": lower["low"].groupby(groups).cummin(),
"close": lower["close"],
"volume": lower["volume"].groupby(groups).cumsum(),
}, index=lower.index)
读一遍代码
align_higher 只有四行有效代码,每一行对应第六节的一个概念:
available.index = higher.index + pd.Timedelta(higher_period):大 K 线从收盘那一刻起才可用。decide_at = lower_index + pd.Timedelta(lower_period):小 K 线在收盘那一刻做决定。reindex(decide_at, method="ffill"):对每个决定时刻,找「可用时刻不晚于它」的最后一根大 K 线。ffill包含相等的情况,所以周日 20:00 那根 4 小时线(周一 0 点收盘)能用上刚刚收盘的这一周。out.index = lower_index:把索引换回小 K 线原来的开始时间,方便和其他数据对齐。
higher_period 要求所有大 K 线长度相同。BTC 的周线都是 7 天,没有问题。美股周线的最后一个交易日可能是周四或周五,按「开始时间 + 7 天」计算可用时刻,会比实际晚一到三天(比如周五收盘就已经确定的周线,要到下周一 0 点才被认为可用)。这只会让结果偏保守,不会造成前视偏差。练习 4 会让你改进这一点。
developing 用 pd.Grouper(freq=rule, label="left", closed="left") 按大周期分组,然后:
transform("first"):组内每一行都填上这一组第一根的开盘价cummax()/cummin():组内到当前为止的最高 / 最低cumsum():组内到当前为止的累计成交量- 收盘价直接用当前这根小 K 线的收盘价
累积函数只会用到当前行和它之前的行,所以天然不会用到未来。test_developing_only_uses_the_past 用修改未来数据的方式检查了这一点。
测试
"""talab.timeframes 的测试。"""
import numpy as np
import pandas as pd
import pytest
from talab import bars as B
from talab import timeframes as T
def daily(n=21, start="2024-01-01"):
"""从 2024-01-01(周一)开始的日线,收盘价依次是 1, 2, 3, ……"""
idx = pd.date_range(start, periods=n, freq="1D", tz="UTC")
close = np.arange(1, n + 1, dtype=float)
return pd.DataFrame({"open": close - 0.5, "high": close + 1, "low": close - 1,
"close": close, "volume": 1.0}, index=idx)
def test_weekly_bars_start_on_monday():
weeks = B.resample_ohlcv(daily(), "W-MON")
assert (weeks.index.dayofweek == 0).all()
assert weeks["close"].tolist() == [7, 14, 21] # 每周最后一天(周日)的收盘价
def test_align_higher_by_hand():
d = daily()
weeks = B.resample_ohlcv(d, "W-MON")
aligned = T.align_higher(d.index, "1D", weeks, "7D")
# 第一周(1 月 1 日到 7 日)在 1 月 8 日 0 点收盘。1 月 7 日(周日)收盘的时刻正好是 1 月 8 日 0 点,可以用
assert aligned["close"].iloc[:6].isna().all() # 1 月 1 日到 6 日:还没有任何一周收盘
assert aligned.loc["2024-01-07", "close"] == 7
assert aligned.loc["2024-01-10", "close"] == 7 # 周三只能看到上一周
assert aligned.loc["2024-01-14", "close"] == 14
def test_align_higher_never_uses_the_future():
d = daily(60)
weeks = B.resample_ohlcv(d, "W-MON")
before = T.align_higher(d.index, "1D", weeks, "7D")
changed = weeks.copy()
cutoff = pd.Timestamp("2024-01-29", tz="UTC") # 修改从这一周开始的所有周线
changed.loc[changed.index >= cutoff, "close"] = -999
after = T.align_higher(d.index, "1D", changed, "7D")
# 在修改的那一周收盘(2 月 5 日 0 点)之前做的决定,都不应该受影响
safe = d.index + pd.Timedelta("1D") < cutoff + pd.Timedelta("7D")
pd.testing.assert_series_equal(before.loc[safe, "close"], after.loc[safe, "close"])
assert (after.loc[~safe, "close"] == -999).any()
def test_align_4h_to_daily():
idx = pd.date_range("2024-01-01", periods=12, freq="4h", tz="UTC") # 两天,每天 6 根
four = pd.DataFrame({"close": np.arange(12.0)}, index=idx)
days = pd.DataFrame({"close": [100.0, 200.0]},
index=pd.DatetimeIndex(["2024-01-01", "2024-01-02"], tz="UTC"))
aligned = T.align_higher(four.index, "4h", days, "1D")
assert aligned["close"].iloc[:5].isna().all() # 第一天 0 点到 20 点开始的前 5 根:日线还没收盘
assert aligned["close"].iloc[5] == 100 # 20:00 这根在 24:00 收盘,正好等于日线收盘时刻
assert aligned["close"].iloc[11] == 200
def test_developing_ends_equal_to_finished_bar():
d = daily(21)
dev = T.developing(d, "W-MON")
weeks = B.resample_ohlcv(d, "W-MON")
last_of_week = dev.groupby(pd.Grouper(freq="W-MON", label="left", closed="left")).tail(1)
assert last_of_week[["open", "high", "low", "close", "volume"]].values.tolist() == \
weeks[["open", "high", "low", "close", "volume"]].values.tolist()
# 周三(第 3 天):开盘是周一的开盘价,最高是前三天的最高价,收盘是周三的收盘价
wed = dev.loc["2024-01-03"]
assert wed.tolist() == [0.5, 4.0, 0.0, 3.0, 3.0]
def test_developing_only_uses_the_past():
d = daily(21)
before = T.developing(d, "W-MON")
changed = d.copy()
changed.loc["2024-01-05":, ["high", "close"]] = 999 # 修改周五及以后
after = T.developing(changed, "W-MON")
pd.testing.assert_frame_equal(before.loc[:"2024-01-04"], after.loc[:"2024-01-04"])
几个测试的设计意图:
test_weekly_bars_start_on_monday:第二节的第一个坑,确认周线从周一开始。test_align_higher_by_hand:用 21 天的简单数据,手算周一到周日每一天能看到哪一周。特别检查了周日这一天:它收盘的时刻正好是这一周收盘的时刻,所以能用上这一周。test_align_higher_never_uses_the_future:第六节讲的性质测试。把某一周以及之后的周线全部改成 −999,检查在那一周收盘之前做的所有决定都没有变化,而之后的决定确实变了(后半句保证测试本身不是空的)。test_align_4h_to_daily:同一个函数用在 4 小时线和日线之间,检查每天最后一根 4 小时线(20:00 开始)正好能用上当天的日线,而之前的 5 根不能。test_developing_ends_equal_to_finished_bar:每个周期最后一根小 K 线上的「正在形成的 K 线」,必须等于这个周期最终合成出来的 K 线。
pytest -q
........................................... [100%]
43 passed in 0.37s
八、数据怎么说
| 结果 | |
|---|---|
| 用 W-MON 合成的周线 vs 官方周线 | 456 周全部一致 |
pandas 默认 "W" 的标签 |
周日(这一周的最后一天) |
| 1 分钟线合成的 4 小时线 vs 官方 | 19,794 根里 89 根不一致,87 根在时间戳偏移时期 |
| SPY 只有 4 个交易日的周 | 十年 97 周 |
| BTC 4 小时 / 日线 / 周线的年化波动率 | 68.2% / 67.0% / 67.4% |
| 周阳线里平均有几天是阴线 | 2.76 天(62.8% 的周阳线至少 3 天) |
| 周阳线里 4 小时阴线的比例(周阴线里) | 44.9%(52.9%) |
| 周线在 20 周均线上方时,4 小时线在均线下方的时间 | 46.4% |
| 周线向上时 4 小时破位,之后 30 天上涨的比例(所有时刻) | 40.0%(52.2%),t = −1.14,不显著 |
| 简化三重滤网,周线向上 vs 向下的收益差异 | BTC t = 0.33,SPY t = −1.46,都不显著 |
| 周线均线过滤,错误对齐 vs 正确对齐 | BTC ×374.13 vs ×7.45;SPY ×6.89 vs ×2.27 |
| 「本周阳线就持有」,pandas 默认 W 的一天偷看 | ×14.25 vs 正确的 ×7.27 |
从这张表可以读出这一篇的三个结论:
- 不同周期「打架」是常态。 周线向上时,接近一半的时间 4 小时线在跌;一根周阳线里平均有将近 3 天阴线。
- 大周期方向对小周期信号的过滤作用,在这些简单的检验里没有显著表现。 这不能证明它没用,但足以说明它不是一个可以不加检验就接受的规则。
- 多周期的前视偏差能把结果放大几倍到几十倍,而且可以藏在一个「合理」的数字里。 SPY 从 8.6% 变成 21.4%,没有任何一个数字看起来离谱。
九、常见误用
1. 用 pandas 默认的 "W" 合成周线,不检查标签。
默认标签在周日,和 Binance 官方的周一不一致。数字相同,但对齐到更小的周期时会提前一天用到这根周线。
2. 用 floor("7D") 来划分周。
它以 1970 年 1 月 1 日(周四)为起点,切出来的「周」是周四到下周三。
3. 用大 K 线的开始时间去匹配小 K 线。
higher.reindex(lower_index, method="ffill") 会让小 K 线用到还没收盘的大 K 线。BTC 周线均线过滤的回测从 7.45 倍变成 374 倍。
4. 以为同一段对齐代码在所有周期组合上都一样对。 周日标签的周线对齐到日线上没有问题,对齐到 4 小时线上就会偷看一天。对齐必须同时考虑大周期和小周期的长度。
5. 把「正在形成的大 K 线」和「已收盘的大 K 线」混用。 两者都合法,但回测用一种、实盘用另一种,回测结果就不能代表实盘。
6. 按小周期入场,再用大周期找理由不离场。 按 4 小时线设计的交易,止损被触发之后说「周线还在涨」,这笔交易的风险就不再是当初计划的风险了。
7. 把「顺着大周期做」当成不需要检验的真理。 在这一篇的简化检验里,BTC 和 SPY 上周线方向的过滤作用都不显著,SPY 甚至方向相反。
8. 认为美股的周线收盘价就是周五的收盘价。 十年里有 97 周只有 4 个交易日,这些周的收盘价可能来自周四。
十、这一篇能回答什么,不能回答什么
| 这一篇能帮你回答 | 这一篇不能回答 |
|---|---|
| 不同周期的 K 线怎么合成、怎么对齐才不会用到未来 | 你应该选择哪个周期来交易 |
| 周线和小时线的方向不一致有多常见 | 不一致的时候,接下来会涨还是会跌(统计上不显著) |
| 你的交易在「听谁的」这个问题上应该怎么保持一致 | 某一次破位之后该不该离场 |
| 三重滤网的结构是什么 | 用原版指标的三重滤网是否有效(练习 5、第六部分) |
| 一个回测有没有多周期前视偏差,以及偏差有多大 | 去掉偏差之后,策略扣除成本还能不能赚钱(第 28 篇) |
十一、小结
-
周期之间是「装进去」的关系。 1 根周线 = 7 根日线 = 42 根 4 小时线(BTC)。前提是边界对齐:4 小时线落在日线边界上,日线落在周线边界上。
-
周线从哪天开始,必须明确。 Binance 官方周线从周一开始、标在周一;pandas 默认的
"W"数字相同,但标在周日;floor("7D")从周四开始。用resample_ohlcv(df, "W-MON")合成的周线,和官方 456 周完全一致。 -
美股的周不一定是 5 天。 SPY 十年里有 97 周只有 4 个交易日。
-
不同周期的年化波动率几乎相同,所以周线的典型波动是 4 小时线的约 √42 ≈ 6.5 倍。4 小时线上的「大」波动,在周线上可能只是正常起伏。
-
大小周期「打架」是常态。 周阳线里平均有 2.76 天阴线、44.9% 的 4 小时阴线;周线在 20 周均线上方时,46.4% 的时间 4 小时线在均线下方。
-
「听谁的」取决于你的交易是按哪个周期设计的。 周线向上时 4 小时破位之后,30 天上涨的比例是 40%,低于平时的 52%,但 t = −1.14,不显著。大周期可以决定做不做、往哪个方向做,但不能用来推翻小周期交易的止损。
-
Elder 三重滤网用三个周期分别决定方向、回调和入场时机,相邻周期大约相差 5 倍。它的最简化版本在 BTC 和 SPY 上,没有检验出周线方向过滤的显著作用。
-
多周期前视偏差来自对齐。 大 K 线只有在收盘之后才可用。用开始时间去对齐,BTC 周线过滤回测从 7.45 倍变成 374 倍,SPY 从 2.27 倍变成 6.89 倍。
-
最危险的前视偏差是看起来合理的那种。 用「不会用到未来」的性质测试检查对齐函数,回测和实盘用同一种大周期数据。
最后回到海边。涨潮时,差不多一半的浪在往回退,所以一个浪退下去,并不能告诉你潮水在退(第三节)。但如果你是来捡贝壳的,只打算在沙滩上待十分钟,那么你该关心的是眼前这个浪,而不是几个小时后的潮位(第四节)。至于那张「今天全天的潮位记录」,它很有用,但只能在今天结束之后拿到(第六节)。
下一篇进入第 8 篇:趋势。这一篇一直在用「收盘价在 20 根均线上方」这个粗糙的定义。你和朋友看同一张图,一个说上升趋势、一个说震荡,怎么判断谁对?趋势能不能有一个算得出来、不依赖眼睛的定义?
练习
练习 1(手算) BTC 某一周(周一开始)的 7 根日线收盘价依次是:100、104、101、99、103、106、102,周一开盘价是 98。
- (a) 这一周的周线开盘价、收盘价是多少?是阳线还是阴线?
- (b) 在周三收盘时,「正在形成的周线」的开盘价和收盘价是多少?
- (c) 如果用
align_wrong把这根周线对齐到日线上,周三那一行看到的收盘价是多少?这个数字在什么时候才能真正知道?
练习 2(数据) 用 SPY 重复第三节的统计:一根周阳线里平均有几天阴线?「周线在 20 周均线上方时,日线在 20 日均线下方」的时间比例是多少?和 BTC 的结果比较。
练习 3(数据) 把第四节的「4 小时破位」换成「日线破位」(日线收盘价低于前 20 根日线的最低价),在 BTC 和 SPY 上重复统计。样本数有多少?t 值是多少?
练习 4(编程)
第七节说,align_higher 对美股周线偏保守:周五收盘就确定的周线,要到下周一 0 点才被认为可用。
- (a) 写一个新函数
align_by_close_time(lower_index, lower_period, higher, close_times),其中close_times是每根大 K 线真实的收盘时刻(比如这一周最后一个交易日的日期 + 1 天)。 - (b) 写一个性质测试,检查它不会用到未来。
- (c) 用它重新计算第六节 SPY 的规则二,结果和
align_higher相比变化有多大?
练习 5(编程,学完第 13、14 篇后再做) 用 MACD 柱状图的斜率代替 20 周均线作为第一层,用 RSI 或随机指标代替「5 天最低收盘价」作为第二层,重新做第五节的三重滤网检验。结论改变了吗?
练习 6(思考) 第六节的「正在形成的周线」规则得到了 50.12 倍,没有前视偏差。
- (a) 这个规则在一周之内可能会频繁地切换仓位。估算一下它平均每周切换几次(提示:用
pos.diff().abs().sum())。 - (b) 如果每次切换的成本是 0.1%(第 1 篇和第 2 篇用过这个数字),50.12 倍会变成多少?
- (c) 这说明「没有前视偏差」和「能赚钱」之间还隔着什么?
小检查答案
小检查 1
(a) 这根 4 小时线在周三 12:00 收盘。最近一根已收盘的日线是周二的日线(周三 0 点收盘)。周三的日线要到周四 0 点才收盘。
(b) 最近一根已收盘的周线是上周一开始的那一周(本周一 0 点收盘)。本周的周线要到下周一 0 点才收盘。
(c) 周日 20:00 开始的 4 小时线,在周一 0 点收盘。这一刻:
- 周日的日线正好收盘,可以用上周日的日线
- 本周(周一开始)的周线也正好收盘,可以用上本周的周线
这就是 align_higher 里 ffill 必须包含「相等」的原因,也是 test_align_4h_to_daily 里 20:00 那一根能用上当天日线的原因。
小检查 2
(a) align_wrong 用开始时间匹配,3 月 5 日会找到3 月 1 日开始的那根月线。这根月线要到 4 月 1 日 0 点才收盘。3 月 5 日的策略提前将近 4 周知道了 3 月是不是阳线。
(b) 不会完全消失,但会大大减轻。resample("ME") 把 3 月的月线标在 3 月 31 日。用 reindex(..., method="ffill") 对齐到日线上时:
- 3 月 1 日到 30 日,找到的是标在 2 月 28 日(或 29 日)的2 月月线,已经收盘,没有问题
- 3 月 31 日这一行,找到的是标在 3 月 31 日的 3 月月线。3 月 31 日这根日线收盘的时刻(4 月 1 日 0 点)正好是 3 月月线收盘的时刻,所以对日线来说没有前视偏差
这和第六节「周日标签对日线没有影响」是同一个道理。
(c) 在 4 小时线上就有问题了。3 月 31 日 0:00、4:00、……、16:00 开始的 5 根 4 小时线,都会找到标在 3 月 31 日 0 点的 3 月月线,而这 5 根在收盘时(4:00 到 20:00)3 月都还没结束。每个月偷看最后 20 个小时。只有 20:00 开始的那一根(4 月 1 日 0 点收盘)是合法的。