本篇位置 第五部分「从信号到交易」第四篇。第 21 篇把规则写完整,这一篇看规则里的「止损 60000」在交易所里到底发生了什么
用到的数据 BTCUSDT U 本位永续合约:1 分钟最新成交价、1 分钟标记价格(各 350 万根,2020-01 至 2026-08)、日线;SPY、AAPL 日线(跳空)
动手 新模块 talab.orderstouchedmarket_filllimit_fillstop_market_fillstop_limit_filloco_first,附 7 个测试
读完你能 说清楚止损单的三个独立选择;估出自己的止损在极端行情里会成交在哪;判断一份回测里「成交在止损价」这个假设值多少钱

一、先做一个决定

你在 Binance 做多 BTC 永续合约,止损挂在 60,000

2024 年 4 月 13 日(周六)晚上 20:09 UTC——就是第 20 篇那一夜,伊朗袭击以色列的消息传出之后的第 24 分钟:

                             最新价最低    最新价收盘     标记价最低     标记价收盘  成交额(百万)
time                                                                    
2024-04-13 20:05:00+00:00  63702.0  63823.2  63765.54  63775.80   105.51
2024-04-13 20:06:00+00:00  63200.0  63479.2  63290.11  63523.37   277.04
2024-04-13 20:07:00+00:00  61700.0  62074.7  62083.23  62083.23   614.06
2024-04-13 20:08:00+00:00  61200.0  61224.5  61583.66  61600.20   547.16
2024-04-13 20:09:00+00:00  59652.5  61734.9  60475.70  61416.91   798.05
2024-04-13 20:10:00+00:00  61462.5  61791.0  61486.79  61693.24   407.73
2024-04-13 20:11:00+00:00  61674.3  62414.2  61711.58  62408.59   273.82
2024-04-13 20:12:00+00:00  62152.9  62346.5  62238.35  62376.84   243.43
2024-04-13 20:13:00+00:00  61857.7  62124.8  61996.87  62135.85   187.25
2024-04-13 20:14:00+00:00  62101.4  62563.1  62142.70  62527.58   146.45

2024-04-13 20:09 UTC 的两套价格

  • 最新成交价在 20:09 这一分钟插到 59,652.5,比你的止损价低 347.5 美元;同一分钟的收盘价已经回到 61,734.9
  • 标记价格同一分钟的最低价是 60,475.7——比你的止损价还高 475.7 美元。
止损价 60,000:按最新成交价,2024-04-13 20:09:00+00:00 触发;按标记价格,整天都没触发
那一分钟:最新价最低 59,652.5,标记价格最低 60,475.70,差 823 美元
当天最新价最低 59,652.5,标记价格最低 60,475.70

这一天,标记价格一次都没跌破 60,000。

⚠️ 第 20 篇看的是现货(那一分钟最低 60,660),这一篇看的是永续合约(最低 59,652.5)。同一时刻、同一个交易所,两个市场的最低价差了一千美元——这本身就是这一篇要讲的事:「价格」不是一个数。

你的止损触发了吗?成交在哪个价格?

  • A. 触发了,成交在 60,000 附近(回测里就是这么算的)
  • B. 触发了,但成交价比 60,000 差很多——毕竟那一分钟成交了 8 亿美元
  • C. 没触发——交易所用的是标记价格

先写下你的选择。 第八节揭晓,答案比三个选项都更尴尬一点:取决于你下的是哪一种止损单。


二、打个比方:托人代买演唱会门票

你要出差,托朋友帮你买一张演唱会门票,于是发了条微信:

「跌到 800 以下就买。」

朋友照做的时候会遇到三个你没交代的问题:

朋友的问题 交易里的名字
我看哪个价格算「跌到 800」?官方票务的挂牌价,还是黄牛群里刚刚成交的那一单? 触发价格:标记价格还是最新成交价
跌破 800 之后,我是有票就抢,还是只肯出 795 触发之后下市价单还是限价单
抢到的时候价格已经变成 790 或者 830 了,算不算数? 成交价:触发 ≠ 成交

黄牛群里可能有人半夜恐慌甩卖,喊了一嗓子 600 就没影了。那一单是真实成交,但它不代表这张票现在值 600。 官方票务综合各个渠道算出来的价格才是「公允价」——这就是标记价格的含义:交易所拿多家现货交易所的指数价格,加上一段时间内的合约溢价,算出一个不容易被单笔成交带偏的价格。

「跌到 800 以下就买」这句话,和「止损 60000」一样,是一句话,但它在交易所里被拆成三个独立的选择。这一篇就是把这三个选择分别量一量。


三、四种订单,两半指令

任何一张挂在交易所的单子都由两半组成:什么时候动手(触发条件)和动手时怎么做(执行方式)。

订单 什么时候动手 动手时怎么做 保证什么
市价单 立刻 吃掉盘口上现有的挂单 一定成交,价格不保证
限价单 立刻挂上去 只在指定价格或更好的价格成交 价格保证,成交不保证
止损市价单 价格碰到触发价 变成市价单 一定成交,价格不保证
止损限价单 价格碰到触发价 变成限价单 价格保证,成交不保证

还有几个每天都会用到的开关:

  • OCO(二选一):两张单子挂在一起,一张成交,另一张自动撤销。典型用法是同时挂止盈和止损。
  • 括号单:入场单成交之后,自动挂上止盈和止损。本质是 OCO 加一个入场腿。
  • reduce-only(只减仓):这张单只能减少仓位,不能反手开新仓。止损单一定要勾上它,否则在一些情况下(比如仓位已经被平掉)它会把你从多头变成空头。
  • post-only(只做挂单):这张限价单如果会立刻成交(也就是变成吃单),就直接撤销。用来确保拿到挂单手续费(第 1 篇讲过挂单和吃单的区别)。

⚠️ Binance 合约的三个默认值不一样

同一个交易所、同一个账户,触发价格的默认值取决于你怎么下单:

下单方式 触发价格的默认值
网页 / App 的止损市价单 标记价格
网页 / App 的止损限价单 最新成交价
API(workingType 参数) 最新成交价(文档写明 Default: CONTRACT_PRICE

这不是笔误,就是这样。 手动下的止损市价单和程序下的止损市价单,默认看的是两个不同的价格。第一节那个问题之所以没有唯一答案,原因就在这里。

出处:Binance 帮助中心《什么是止损单》写明止损限价单「默认用最新价触发」、止损市价单「默认用标记价格触发」;Binance 合约 API 文档的 workingType 参数写明 Default: CONTRACT_PRICE


四、第一半:用什么价格触发

两套价格平时差多少

both = last.join(mark, rsuffix="_mark", how="inner")
gap = both["low"] / both["low_mark"] - 1
print(f"每分钟「最新价最低 ÷ 标记价最低 - 1」的分布({len(gap):,} 分钟,单位是百分点,负数 = 最新价更低):")
print((gap.describe([0.0001, 0.001, 0.01, 0.5, 0.99, 0.999]).drop("count") * 100).round(4).to_string())
print(f"\n偏离超过 0.5% 的分钟:{int((gap <= -0.005).sum()):,} 个(占 {(gap <= -0.005).mean():.3%});"
      f"超过 1% 的:{int((gap <= -0.01).sum()):,} 个")
worst = gap.nsmallest(5)
print("最极端的五分钟:")
print(pd.DataFrame({"最新价最低": both["low"][worst.index], "标记价最低": both["low_mark"][worst.index],
                    "偏离": worst}).round(4).to_string())
print("\n按年份,偏离超过 0.5% 的分钟数:", (gap <= -0.005).groupby(gap.index.year).sum().to_dict())
每分钟「最新价最低 ÷ 标记价最低 - 1」的分布(3,493,385 分钟,单位是百分点,负数 = 最新价更低):
mean    -0.0094
std      0.0385
min     -7.5022
0.01%   -1.4486
0.1%    -0.2636
1%      -0.0967
50%     -0.0051
99%      0.0709
99.9%    0.1494
max      2.4201

偏离超过 0.5% 的分钟:1,188 个(占 0.034%);超过 1% 的:543 个
最极端的五分钟:
                              最新价最低       标记价最低      偏离
time                                                   
2020-03-12 10:48:00+00:00   5199.17   5620.8563 -0.0750
2020-03-13 02:40:00+00:00   4580.00   4915.5400 -0.0683
2021-05-19 14:13:00+00:00  32620.00  34743.9896 -0.0611
2021-05-19 13:07:00+00:00  30453.00  32353.0165 -0.0587
2021-12-04 05:27:00+00:00  40888.89  43403.6606 -0.0579

按年份,偏离超过 0.5% 的分钟数: {2020: 792, 2021: 175, 2022: 86, 2023: 69, 2024: 47, 2025: 13, 2026: 6}

两套价格的偏离

  • 平时两套价格几乎重合:中位数只差 0.005 个百分点,99% 的分钟落在 ±0.07 个百分点以内。
  • 但尾巴很长:万分之一的分钟(约 350 个)偏离超过 1.4 个百分点,最极端的一分钟差 7.5%(2020 年 3 月 12 日,全市场踩踏)。
  • 这种极端分钟在变少:2020 年有 792 分钟偏离超过 0.5%,2024 年 47 个,2026 年到 8 月只有 6 个。市场越深,最新成交价越不容易被单笔砸穿。

同一个止损,两种触发价格差几天

把止损放在每天开盘价下方 1% / 3% / 5%,看这一天会不会被打掉:

rows = []
for distance in [0.01, 0.03, 0.05]:
    hit_last = hit_mark = only_last = 0
    for date, group in both.groupby(both.index.date):
        level = group["open"].iloc[0] * (1 - distance)                # 每天开盘价下方 distance 的止损
        by_last_hit = bool((group["low"] <= level).any())
        by_mark_hit = bool((group["low_mark"] <= level).any())
        hit_last += by_last_hit
        hit_mark += by_mark_hit
        only_last += by_last_hit and not by_mark_hit
    rows.append({"止损距离": f"-{distance:.0%}", "最新价触发的天数": hit_last, "标记价格触发的天数": hit_mark,
                 "只有最新价触发": only_last, "占最新价触发的": only_last / hit_last})
print(f"把止损放在每天开盘价下方,看这一天会不会被打掉(共 {both.index.normalize().nunique():,} 天):")
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f"{v:.3f}"))
把止损放在每天开盘价下方,看这一天会不会被打掉(共 2,426 天):
止损距离  最新价触发的天数  标记价格触发的天数  只有最新价触发  占最新价触发的
 -1%      1558       1525       34    0.022
 -3%       601        574       27    0.045
 -5%       249        235       14    0.056
  • 止损离得越近,触发越频繁(-1% 的止损在 2,426 天里被打掉 1,558 天),但两种触发价格的差别占比越小(2.2%)。
  • 止损离得越远,「只有最新价触发」的占比越高:-5% 的止损里有 5.6% 的触发只发生在最新价上。你放得越远、越是为了躲开噪声,被一根针打掉的相对代价就越大。

⚠️ 这不是说标记价格触发一定更好。标记价格滞后于真实成交,行情真的一路下跌时它会晚一点触发,你会亏得更多一点。它挡掉的是「一分钟内插一下就回来」的那种针——第 20 篇量过,这种针里有一半当天收盘就收回去了。


五、第二半:触发之后成交在哪里

回测里写「止损 60000」,默认的意思是「成交在 60000」。用 1 分钟线看看这个假设有多乐观:把每天的止损放在开盘价下方 3%,统计每次触发的四种成交口径。

triggers = []
for date, group in both.groupby(both.index.date):
    level = group["open"].iloc[0] * 0.97
    if not (group["low"] <= level).any():
        continue
    fills = {name: O.stop_market_fill(group, level, fill=name)["滑点"] for name in O.FILLS}
    when = O.touched(group, level, "sell")
    position = group.index.get_loc(when)
    later = group["open"].iloc[min(position + 5, len(group) - 1)]
    triggers.append(fills | {"5 分钟后": (level - later) / level, "日期": date})
slippage = pd.DataFrame(triggers).set_index("日期")
print(f"{len(slippage)} 次触发,各种成交口径的滑点(单位是百分点,正数 = 比止损价更不利):")
print((slippage.describe([0.5, 0.9, 0.99]).drop("count") * 100).round(3).to_string())
print("\n换算成钱:10 万美元的仓位,中位滑点和 99% 分位的滑点各是多少美元")
print((slippage.median() * 100_000).round(0).to_string(), "\n")
print((slippage.quantile(0.99) * 100_000).round(0).to_string())
601 次触发,各种成交口径的滑点(单位是百分点,正数 = 比止损价更不利):
      trigger  bar_close  next_open  worst  5 分钟后
mean      0.0     -0.054     -0.054  0.255 -0.103
std       0.0      0.371      0.371  0.414  0.703
min       0.0     -2.833     -2.836  0.000 -2.099
50%       0.0     -0.051     -0.048  0.123 -0.146
90%       0.0      0.236      0.237  0.561  0.465
99%       0.0      1.153      1.174  1.840  2.355
max       0.0      3.065      3.031  5.757  6.780

换算成钱:10 万美元的仓位,中位滑点和 99% 分位的滑点各是多少美元
trigger        0.0
bar_close    -51.0
next_open    -48.0
worst        123.0
5 分钟后       -146.0 

trigger         0.0
bar_close    1153.0
next_open    1174.0
worst        1840.0
5 分钟后        2355.0

成交口径不同,滑点差多少

一半的时候,成交价比止损价还好。 「下一分钟开盘价」这个口径的中位滑点是 -0.048 个百分点——因为触发往往发生在一根下影线的最低处,下一分钟通常已经弹回去了。

但另一半不是。 看 99% 分位:

成交口径 中位滑点 99% 分位 10 万美元仓位的 99% 分位损失
成交在止损价(回测假设) 0 0 0 美元
下一分钟开盘价 -0.048% +1.17% 1,174 美元
这一分钟最差的价格 +0.123% +1.84% 1,840 美元
5 分钟后的价格 -0.146% +2.36% 2,355 美元

止损的滑点不是一个平均值问题,是一个尾部问题。 一百次里有九十九次它只差几十美元,剩下那一次差一两千——而那一次恰恰是行情最糟糕、你最需要它老实成交的时候。

⚠️ 真实的止损市价单在触发后几百毫秒内就成交,介于「这一分钟最差的价格」和「下一分钟开盘价」之间。1 分钟线看不到这一分钟里价格走过的顺序(第 6 篇讲过 K 线是有损压缩),所以这里只能给出一个区间,给不出精确值。


六、止损限价单:价格有保证,成交没保证

止损限价单的诱惑很直接:我不想被插针成交在一个离谱的价格上,那我挂个限价好了。 代价是什么?

rows = []
for gap_below in [0.0, 0.001, 0.005]:
    filled = {1: 0, 10: 0, 60: 0}
    total = 0
    for date, group in both.groupby(both.index.date):
        level = group["open"].iloc[0] * 0.97
        if not (group["low"] <= level).any():
            continue
        total += 1
        for horizon in filled:
            out = O.stop_limit_fill(group, level, level * (1 - gap_below), expire=horizon)
            filled[horizon] += bool(out["成交"])
    rows.append({"限价放在触发价下方": f"{gap_below:.1%}", "触发次数": total}
                | {f"{h} 分钟内成交": filled[h] / total for h in filled})
print("止损限价单:触发之后价格要回到限价才成交")
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f"{v:.3f}"))

misses = []
for date, group in both.groupby(both.index.date):
    level = group["open"].iloc[0] * 0.97
    if not (group["low"] <= level).any():
        continue
    out = O.stop_limit_fill(group, level, level, expire=60)
    if not out["成交"]:
        after = group.loc[out["触发时间"]:]
        misses.append({"日期": date, "触发价": level, "当天之后最低": after["low"].min(),
                       "又跌了": after["low"].min() / level - 1, "收盘": after["close"].iloc[-1],
                       "收盘时还差": after["close"].iloc[-1] / level - 1})
missed = pd.DataFrame(misses)
print(f"\n一小时内没成交的 {len(missed)} 天:触发之后价格又跌了多少(中位数 {missed['又跌了'].median():.2%},"
      f"最差 {missed['又跌了'].min():.2%})")
print(missed.sort_values("又跌了").head(5).to_string(index=False, float_format=lambda v: f"{v:.4f}"))
止损限价单:触发之后价格要回到限价才成交
限价放在触发价下方  触发次数  1 分钟内成交  10 分钟内成交  60 分钟内成交
     0.0%   601    0.879     0.947     0.962
     0.1%   601    0.940     0.967     0.982
     0.5%   601    0.982     0.990     0.992

一小时内没成交的 23 天:触发之后价格又跌了多少(中位数 -3.90%,最差 -15.69%)
        日期        触发价     当天之后最低     又跌了         收盘   收盘时还差
2022-11-08 19961.1450 16830.0000 -0.1569 18534.0000 -0.0715
2024-08-05 56400.1650 48888.0000 -0.1332 54003.1000 -0.0425
2022-06-18 19843.5810 17593.2000 -0.1134 18959.7000 -0.0445
2020-08-02 11457.0871 10490.0000 -0.0844 11079.7200 -0.0329
2022-08-19 22489.4500 20760.0000 -0.0769 20824.9000 -0.0740

止损限价单没成交的那些天

  • 限价就挂在触发价上:1 分钟内成交 87.9%,一小时内 96.2%。也就是说 3.8% 的触发,一小时之后你的仓位还在
  • 限价往下让 0.5%(更容易成交):一小时内成交率 99.2%,代价是每次多亏 0.5%。

那没成交的 23 天发生了什么?

  • 触发之后价格中位数又跌了 3.9%,最差的一次跌了 15.7%(2022 年 11 月 8 日,FTX 崩盘那天)。
  • 第二名是 2024 年 8 月 5 日(日元套息交易平仓那天),又跌了 13.3%

止损限价单在最需要它的那几天最容易失效。 这不是巧合:价格一路不回头,正是「真的出事了」的样子。用限价止损,等于在赌「跌下去还会弹回来」——而你本来设止损就是为了防「跌下去不弹回来」。


七、动手:talab.orders

"""talab.orders:订单类型,以及止损到底成交在哪里。第 22 篇。

回测里写「止损 60000」,默认的意思是「价格碰到 60000,你就以 60000 成交」。真实世界里这句话
拆成三个独立的问题:

1. **用什么价格触发**:最新成交价,还是交易所算出来的标记价格?
2. **触发之后下什么单**:市价单(一定成交,价格不保证)还是限价单(价格保证,成交不保证)?
3. **成交在哪里**:触发那一刻之后的第一笔成交,可能比触发价好,也可能差很多。

这个模块把这三个问题变成参数,好让你量出它们各自值多少钱。所有函数都在一组 K 线上重放,
K 线越细越接近真实(第 22 篇用的是 1 分钟线)。
"""

触发:可以换一套价格

def touched(bars: pd.DataFrame, level: float, side: str = "sell",
            prices: pd.DataFrame | None = None) -> pd.Timestamp | None:
    """价格第一次碰到 level 的那根 K 线。

    side="sell"(卖出止损)看最低价跌到 level;side="buy"(买入止损、突破挂单)看最高价涨到 level。
    prices 可以换成另一套价格(比如标记价格)来判断触发,成交仍然发生在 bars 上。
    """
    _check(side)
    source = bars if prices is None else prices
    hit = source["low"] <= level if side == "sell" else source["high"] >= level
    hit = hit.reindex(bars.index).fillna(False)
    return bars.index[hit][0] if hit.any() else None

prices 这个参数就是这一篇的核心:判断触发用一套价格,计算成交用另一套。 传标记价格的 K 线进去,就是交易所默认的止损市价单;不传,就是最新成交价。

成交:四种口径

def stop_market_fill(bars: pd.DataFrame, level: float, side: str = "sell",
                     prices: pd.DataFrame | None = None, fill: str = "next_open") -> dict:
    """止损市价单:触发之后按市价成交,一定成交,价格不保证。

    fill 是成交价的口径,用来量「回测有多乐观」:
      "trigger"   :成交在止损价(回测最常见的假设)
      "bar_close" :成交在触发那根 K 线的收盘价
      "next_open" :成交在下一根 K 线的开盘价(默认)
      "worst"     :成交在触发那根 K 线最不利的价格(这一根能有多差就有多差)
    滑点为正表示比止损价更不利(卖出成交得更低、买入成交得更高)。
    """
    _check(side)
    _check(fill, FILLS)
    when = touched(bars, level, side, prices)
    if when is None:
        return {"触发时间": None, "成交时间": None, "成交价": np.nan, "滑点": np.nan}
    position = bars.index.get_loc(when)
    if fill == "trigger":
        price, at = float(level), when
    elif fill == "bar_close":
        price, at = float(bars["close"].iloc[position]), when
    elif fill == "worst":
        price, at = float(bars["low"].iloc[position] if side == "sell" else bars["high"].iloc[position]), when
    else:
        nxt = min(position + 1, len(bars) - 1)
        price, at = float(bars["open"].iloc[nxt]), bars.index[nxt]
    slippage = (level - price) / level if side == "sell" else (price - level) / level
    return {"触发时间": when, "成交时间": at, "成交价": price, "滑点": slippage}

限价单可能不成交

def stop_limit_fill(bars: pd.DataFrame, level: float, limit: float, side: str = "sell",
                    prices: pd.DataFrame | None = None, expire: int | None = None) -> dict:
    """止损限价单:触发之后挂一张限价单,价格有保证,成交没保证。

    卖出止损限价单的 limit 通常略低于触发价(给一点余地)。价格一路往下不回头,这张单就挂在那里,
    仓位还在——这正是「止损限价单最危险的地方」。

    ⚠️ 触发发生在那根 K 线中间的某一刻,这根 K 线剩下的部分还能不能成交,从 K 线上看不出来,
    所以这里从**下一根**开始找成交机会。K 线越细,这个保守假设的代价越小。
    """
    _check(side)
    when = touched(bars, level, side, prices)
    if when is None:
        return {"触发时间": None, "成交时间": None, "成交价": np.nan, "成交": False}
    after = bars.iloc[bars.index.get_loc(when) + 1:]
    result = limit_fill(after, limit, "sell" if side == "sell" else "buy", expire=expire)
    return {"触发时间": when} | result

测试

7 个测试,都能手算。其中这两个最要紧:

def test_touched_can_use_another_price_series():
    mark = PATH.copy()
    mark["low"] = [99.5, 99.2, 95.0, 95.0, 99.0]                    # 标记价格没跌到 99
    assert O.touched(PATH, 99.0, "sell") == PATH.index[1]           # 最新价第 2 根就触发
    assert O.touched(PATH, 99.0, "sell", prices=mark) == PATH.index[2]   # 标记价格晚一根


def test_stop_market_fill_prices_by_hand():
    at_trigger = O.stop_market_fill(PATH, 99.0, fill="trigger")
    assert at_trigger["成交价"] == 99.0 and at_trigger["滑点"] == 0    # 回测的乐观假设
    at_close = O.stop_market_fill(PATH, 99.0, fill="bar_close")
    assert at_close["成交价"] == 98.0 and at_close["滑点"] == pytest.approx((99 - 98) / 99)
    worst = O.stop_market_fill(PATH, 99.0, fill="worst")
    assert worst["成交价"] == 97.0                                   # 触发那根的最低价
    next_open = O.stop_market_fill(PATH, 99.0)                       # 默认口径:下一根开盘
    assert next_open["成交价"] == 98.0 and next_open["成交时间"] == PATH.index[2]
def test_stop_limit_may_never_fill():
    ok = O.stop_limit_fill(PATH, 99.0, 98.5)                         # 第 2 根触发,从第 3 根开始找成交机会
    assert ok["成交"] and ok["成交时间"] == PATH.index[2]            # 第 3 根最高 98.5,正好够到限价
    assert ok["成交价"] == 98.5
    missed = O.stop_limit_fill(bars([(100, 101, 99.5, 100.5), (99.4, 99.4, 90.0, 90.5),
                                     (90.5, 91.0, 85.0, 85.5)]), 99.0, 98.5)
    assert missed["触发时间"] is not None and not missed["成交"]     # 触发了,但价格再没回到 98.5
    assert np.isnan(missed["成交价"])                                 # 仓位还在,这才是止损限价单的风险
194 passed in 0.65s

没装 TA-Lib 的环境里是 162 passed、32 skipped。


八、揭晓

同一个 60,000 的止损,四种写法在那一夜的结果:
           订单     成交价口径                      触发时间        成交价      滑点
止损市价单(标记价格触发)   trigger                       NaT        NaN     NaN
止损市价单(标记价格触发) next_open                       NaT        NaN     NaN
止损市价单(标记价格触发)     worst                       NaT        NaN     NaN
 止损市价单(最新价触发)   trigger 2024-04-13 20:09:00+00:00 60000.0000  0.0000
 止损市价单(最新价触发) next_open 2024-04-13 20:09:00+00:00 61718.3000 -0.0286
 止损市价单(最新价触发)     worst 2024-04-13 20:09:00+00:00 59652.5000  0.0058

⚠️ 1 分钟线看不到那一分钟里价格走过的顺序:20:09 这根从 61,229.0 开始,最低 59,652.5,收在 61,734.9。止损市价单的真实成交价只能说在最低价和下一根开盘价之间。
止损限价单(最新价触发,限价 60,000):触发于 2024-04-13 20:09:00+00:00,按保守口径(从下一根开始算)成交在 61,718.3;那一分钟价格已经弹回 61,734.9,所以这一次几乎肯定成交得了。真正危险的是价格一路不回头的那些天——第六节数过 23 次。
那一夜之后:20:10 收盘 61,791.0,21:00 收盘 62,168.1,当天收盘 63,939.7

答案是:取决于你下的是哪一种止损单。

  • 止损市价单,网页下单(默认标记价格触发):标记价格那一分钟最低 60,475.7,整天都没跌破 60,000。仓位还在。
  • 止损市价单,API 下单(默认最新价触发):20:09 触发。成交价在 59,652.5(那一分钟最差)到 61,718.3(下一分钟开盘)之间——1 分钟线只能给出这个区间。
  • 止损限价单,网页下单(默认最新价触发):也在 20:09 触发;这一次价格立刻弹回 61,734.9,限价单几乎肯定能成交。但如果那天是 2022 年 11 月 8 日,它就会挂在那里,看着价格再跌 15.7%。

所以选项 A、B、C 全都可能是对的,取决于你三个月前点开下单框时那个下拉菜单选了什么——而你多半没注意过它。

这一夜之后:20:10 收盘 61,791,21:00 收盘 62,168,当天收盘 63,939.7如果你的止损在那一针上成交了,你是在当天最低点附近离场的。


九、把日线回测的止损,放到分钟线上重放

主线 v3 的 34 次止损

第 21 篇的主线 v3 在 BTC 永续合约上跑六年半,止损用的是「盘中最低价触发、成交在止损价」——回测里最常见的假设。把每一次止损那天的 1 分钟线调出来,看看实际成交在哪:

close = day["close"]
v3 = R.Rule(entry=(I.sma(close, 50) > I.sma(close, 200)) & (close >= close.rolling(20).max()),
            exit=I.cross_below(I.sma(close, 50), I.sma(close, 200)).fillna(False),
            fill="next_open", stop="chandelier", k=3.0, trigger="low")     # 盘中触发,好让止损价可比
returns, held, trades = R.run(day, v3)
stops = trades[trades["原因"] == "止损"]
rows = []
for _, trade in stops.iterrows():
    minutes = last.loc[str(trade["卖出日"].date())]
    level = float(trade["卖出价"])                                       # 回测记的成交价就是止损价
    if minutes.empty or not (minutes["low"] <= level).any():
        continue
    real = O.stop_market_fill(minutes, level, fill="next_open")
    rows.append({"日期": trade["卖出日"].date(), "止损价": level, "分钟线成交价": real["成交价"],
                 "滑点": real["滑点"], "触发时间": real["触发时间"].strftime("%H:%M")})
replay = pd.DataFrame(rows)
print(f"主线 v3 在 BTC 永续合约上共 {len(stops)} 次止损,其中 {len(replay)} 次能在分钟线上重放:")
print(replay.round(4).to_string(index=False))
print(f"\n滑点:中位数 {replay['滑点'].median():.3%},平均 {replay['滑点'].mean():.3%},最差 {replay['滑点'].max():.2%}")
print(f"把这些滑点加回去,{len(replay)} 笔止损一共多亏 {replay['滑点'].sum():.2%}(按满仓算),"
      f"年化影响约 {replay['滑点'].sum() / ((day.index[-1] - day.index[0]).days / 365.25):.2%}")
主线 v3 在 BTC 永续合约上共 34 次止损,其中 34 次能在分钟线上重放:
        日期         止损价    分钟线成交价      滑点  触发时间
2020-08-02  10685.7729  10721.10 -0.0033 04:42
2020-08-25  11242.2824  11220.05  0.0020 17:47
2020-11-26  16896.6394  17025.50 -0.0076 08:28
2021-01-04  29626.7493  29221.75  0.0137 10:15
2021-01-11  34091.6419  34102.95 -0.0003 03:35
2021-02-22  48071.9645  48002.66  0.0014 14:16
2021-03-25  50551.4970  50545.31  0.0001 12:37
2021-04-18  56457.2939  56216.81  0.0043 03:20
2021-05-12  49287.7764  48705.44  0.0118 23:58
2021-10-28  57855.1721  57784.76  0.0012 18:39
2021-11-16  60434.7585  60919.25 -0.0080 03:29
2022-01-05  45063.9950  44971.09  0.0021 19:45
2023-03-03  22682.6940  22359.90  0.0142 01:31
2023-04-20  28213.2609  28373.20 -0.0057 16:03
2023-06-05  26034.7117  25991.90  0.0016 15:59
2023-07-24  29529.8086  29059.80  0.0159 09:50
2023-11-14  34933.5457  34788.10  0.0042 18:53
2023-12-11  40849.0929  40929.10 -0.0020 02:12
2024-01-03  41362.9866  40456.90  0.0219 12:09
2024-01-12  42391.0195  42529.90 -0.0033 22:04
2024-03-05  61232.2294  60617.50  0.0100 19:55
2024-03-19  64197.8517  64146.20  0.0008 07:09
2024-04-13  62143.8883  62070.90  0.0012 20:07
2024-06-11  66099.1487  66113.20 -0.0002 15:44
2024-08-01  62801.1627  62650.10  0.0024 15:31
2024-11-04  67326.3541  67470.10 -0.0021 17:20
2024-12-19  96345.6033  96888.10 -0.0056 19:47
2025-01-09  91637.9733  91679.90 -0.0005 20:13
2025-02-02  97740.5859  97692.50  0.0005 17:24
2025-06-05 103609.1482 103528.10  0.0008 16:52
2025-08-01 114914.6614 114867.10  0.0004 00:50
2025-08-18 115948.9977 116082.40 -0.0012 02:07
2025-09-24 111334.8536 111132.00  0.0018 04:11
2025-10-10 117963.5433 117829.50  0.0011 17:05

滑点:中位数 0.097%,平均 0.217%,最差 2.19%
把这些滑点加回去,34 笔止损一共多亏 7.37%(按满仓算),年化影响约 1.11%

主线 v3 的 34 次止损在分钟线上的成交价

  • 34 次止损里,14 次成交价比止损价好,20 次更差。 中位滑点 +0.097%,平均 +0.217%
  • 最差的一次是 2024 年 1 月 3 日(ETF 传闻引发的急跌),滑点 2.19%
  • 把这些滑点加回去,六年半一共多亏 7.37%年化大约 1.1 个百分点

对比一下第 21 篇里 v3 在 BTC 上的年化 18.5%:光是「止损成交在止损价」这一个假设,就吃掉了六分之一的收益,而且还没算手续费和资金费率(第 29 篇会把成本一次性算清楚)。

括号单:日线看不出先碰到哪个

止盈和止损挂在一起(OCO / 括号单)时,回测要回答「先碰到哪个」。日线上经常两个都碰到了,顺序看不出来:

hour = B.resample_ohlcv(last, "1h", traded_only=True)
rows = []
for name, frame in [("日线", day), ("1 小时线", hour), ("1 分钟线", last)]:
    outcomes = {"先到止损": 0, "先到目标": 0, "同一根": 0, "都没碰到": 0}
    for entry_day in day.index[::5]:                                   # 每 5 天开一次仓,共几百次
        start = entry_day + pd.Timedelta(days=1)
        piece = frame.loc[start:start + pd.Timedelta(days=10)]
        if piece.empty:
            continue
        price = float(piece["open"].iloc[0])
        outcomes[O.oco_first(piece, price * 0.97, price * 1.03)["结果"]] += 1
    total = sum(outcomes.values())
    rows.append({"K 线粒度": name, "样本": total} | {k: v / total for k, v in outcomes.items()})
print("买入之后 10 天内,先碰到 -3% 的止损还是 +3% 的目标(同一段行情,只换 K 线粒度):")
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f"{v:.3f}"))
买入之后 10 天内,先碰到 -3% 的止损还是 +3% 的目标(同一段行情,只换 K 线粒度):
K 线粒度  样本  先到止损  先到目标   同一根  都没碰到
   日线 487 0.452 0.493 0.039 0.016
1 小时线 487 0.466 0.513 0.000 0.021
1 分钟线 487 0.466 0.513 0.000 0.021
  • 日线上有 3.9% 的样本「同一根里两个价位都碰到了」,从日线看不出顺序——回测框架通常默认「先止损」(悲观)或者「先止盈」(乐观),一个 4% 的样本就这样被一行代码决定了。
  • 换成 1 小时线,这类样本降到 0;1 小时线和 1 分钟线给出完全一样的结论。做括号单的回测,至少要用小时线。

美股这边:跳空就是滑点

加密 24 小时交易,跳空几乎为零(第 15、20 篇);美股的止损则要面对开盘跳空:

rows = []
for name, frame in [("SPY", spy), ("AAPL", aapl)]:
    gaps = frame["open"] / frame["close"].shift(1) - 1
    down = gaps[gaps < 0]
    rows.append({"标的": name, "向下跳空的天数": len(down), "跳空中位数": down.median(), "最差的一次": down.min(),
                 "跳空超过 2% 的天数": int((down <= -0.02).sum()),
                 "这些天里开盘价比昨收低的中位数": down[down <= -0.02].median()})
print("止损价在昨天收盘和今天开盘之间时,成交价就是开盘价——跳空有多大,滑点就有多大:")
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f"{v:.4f}"))
worst_day = (spy["open"] / spy["close"].shift(1) - 1).idxmin()
print(f"SPY 最差的一次:{worst_day.date()} 开盘 {spy.loc[worst_day, 'open']:.2f},"
      f"前一天收盘 {spy['close'].shift(1)[worst_day]:.2f}{spy.loc[worst_day, 'open'] / spy['close'].shift(1)[worst_day] - 1:.1%})")
止损价在昨天收盘和今天开盘之间时,成交价就是开盘价——跳空有多大,滑点就有多大:
  标的  向下跳空的天数   跳空中位数   最差的一次  跳空超过 2% 的天数  这些天里开盘价比昨收低的中位数
 SPY     1083 -0.0029 -0.1045           25          -0.0296
AAPL     1170 -0.0044 -0.1296           82          -0.0279
SPY 最差的一次:2020-03-16 开盘 241.18,前一天收盘 269.32(-10.4%)

止损价落在昨天收盘和今天开盘之间时,成交价就是开盘价,跳空有多大,滑点就有多大。 SPY 十年里有 25 天向下跳空超过 2%,最差的一次是 2020 年 3 月 16 日:开盘比前一天收盘低 10.4%。那天所有挂在这个区间里的止损单,全部成交在开盘价。


十、常见误用

1. 以为「止损 60000」是一句完整的话。 它是三句:用哪个价格触发、触发后下什么单、成交在哪里。第一节那个问题的答案取决于第一句。

2. 没看过自己账户里触发价格的默认值。 Binance 网页的止损市价单默认标记价格、止损限价单默认最新价、API 默认最新价——三个默认值不一样。看一眼你常用的那个下单框。

3. 用止损限价单「防滑点」。 3.8% 的触发一小时内不成交,而这些天正是价格再跌 3.9%(中位数)到 15.7%(最差)的日子。止损限价单把小概率的滑点,换成了小概率的「根本没止损」。

4. 回测里假设止损成交在止损价。 在 BTC 上这个假设一年值 1.1 个百分点。要么在回测里加一个滑点假设,要么至少知道自己少算了多少。

5. 止损单不勾 reduce-only。 仓位已经平掉之后,一张没勾的止损单可能让你反手开出一个新仓位。

6. 用日线回测括号单。 3.9% 的样本在日线上分不出止盈和止损谁先到,而这 3.9% 全是大波动的日子。至少用小时线。

7. 把「标记价格触发」当成保险。 它挡掉插针,但也推迟真正的下跌——行情一路向下时,标记价格触发会让你亏得更多一点。它换来的是「不被一分钟的针打掉」,不是「更少的亏损」。


十一、小结

  1. 一张止损单有三个独立的选择:用什么价格触发、触发后下什么单、成交在哪里。三个都不写,就是三个默认值替你做了决定。
  2. 标记价格是交易所按现货指数加溢价算出来的公允价,不容易被单笔成交带偏;最新成交价就是最后一笔成交。平时两者中位数只差 0.005 个百分点,但极端分钟能差 7.5%。
  3. Binance 的三个默认值不一样:网页止损市价单用标记价格,网页止损限价单用最新价,API 用最新价
  4. 止损放得越远,「只有最新价触发」的占比越高(-5% 的止损里 5.6%)。
  5. 触发之后的滑点是尾部问题:中位数只有几十美元甚至是正的,99% 分位在 10 万美元仓位上是 1,174 到 2,355 美元。
  6. 止损限价单 3.8% 的触发一小时内不成交,而这些天价格中位数又跌了 3.9%,最差 15.7%(FTX 崩盘那天)。
  7. 主线 v3 的 34 次止损用分钟线重放:中位滑点 +0.097%,六年半合计 7.37%,年化约 1.1 个百分点——相当于 BTC 上年化收益的六分之一。
  8. 括号单在日线上有 3.9% 的样本分不出先后,换成小时线就没有了。

练习

  1. 换个止损距离:把第四、五节的 -3% 换成 -1% 和 -10%,滑点分布怎么变?触发次数和滑点尾部之间有没有取舍?
  2. 标记价格触发的代价:把第五节的统计按「标记价格触发」重做一遍,比较两种触发方式下的滑点和触发次数。标记价格挡掉的针值多少钱,晚触发又亏了多少?
  3. 限价的位置:止损限价单的限价从触发价下方 0% 调到 2%,画出「成交率」和「平均成交价」的取舍曲线。你会选哪里?
  4. 别的标的:换成 ETH 或者一个流动性差得多的合约(第 19 篇的筛选器能帮你挑),重做第五、六节。流动性和滑点尾部的关系有多强?
  5. 把滑点写进回测:给第 21 篇的 Rule 加一个 slippage 参数(止损成交价 = 止损价 × (1 - slippage)),用第九节量到的中位数和 99% 分位各跑一遍主线 v3,年化各是多少?
  6. 跳空:统计 SPY 十年里,止损放在前一天收盘下方 1%/3%/5% 时,有多少次是被跳空直接跳过去的(开盘价已经低于止损价)。

小检查答案

(这一篇的小检查就是第一节那个决定,答案在第八节。如果你选了 A、B 或 C 中的任何一个,都对了一部分——完整的答案是「取决于你下的是哪一种止损单」。)