本篇位置 第八部分「实盘」第一篇。前面三十二篇在问「这条规则值不值得做」,从这一篇开始问「你能不能照着做」
用到的数据 SPY、AAPL、BTC 日线(全部沿用前面几篇,不需要新下载)
动手 新模块 talab.journalstreaksexpected_longeststreak_distributionstreak_alarmshufflednormal_rangedetection_sizepause_after_lossespause_tableinterfereinterference_tableEntry + Journalreconcile,附 16 个测试
读完你能 算出你这条策略的「正常连亏」和「正常回撤」,并把实盘和回测的差额拆成五块

这一篇从头到尾用同一个比方:自动驾驶,和你那只悬在刹车上的脚。

车在开,你坐在驾驶位上。系统按它的规则变道、跟车、刹车,而你全程盯着——随时可以接管。

自动驾驶公司很早就知道,「系统开得好不好」这个问题没法靠感觉回答,所以他们做了一件很笨的事:每一次人类接管,都要记一笔。什么时候、在哪条路上、为什么接管、接管之后又怎么样了。这份记录叫脱离报告

这一篇要做的就是这件事,只不过换成你的账户。而第一个要记的,是你最想接管的那个时刻——


一、2022 年 7 月 26 日

你从 2017 年就开始在 BTC 上跑第 31 篇那套海龟法则。九年,一个参数没改过。

今天是 2022 年 7 月 26 日。你刚刚平掉第七笔连续亏损的交易:

       进场日        出场日  单位数   原因        盈亏     R
2021-11-08 2021-11-12    2   止损  -9678.42 -1.78
2021-12-27 2021-12-29    1   止损  -5354.53 -1.00
2022-02-07 2022-02-17    3   止损 -12025.37 -2.27
2022-03-24 2022-04-06    4 通道出场  -3652.43 -0.71
2022-05-31 2022-06-10    1 通道出场  -4299.06 -0.84
2022-07-08 2022-07-13    1   止损  -5101.22 -1.00
2022-07-18 2022-07-26    3   止损 -11733.77 -2.32

这 7 笔:合计 -9.91R,合计 -51,845 美元,历时 260 天
账户:峰值 574,350 → 现在 493,287,回撤 -14.11%
这条策略九年 70 笔,胜率 32.9%,到今天为止合计 192.68R

决策点

**七笔,一笔没赚。**从 2021 年 11 月 8 日买在 67,000 开始,八个半月,260 天。合计 −9.91R,账户从峰值 574,350 掉到 493,287,回撤 14.11%

更难受的是每一笔的样子:五笔是被止损打掉的,两笔是通道出场。没有哪一笔是灾难,每一笔都只亏一到两个 R——它们只是一笔接一笔,没完没了。

你手上有三个选项:

A. 什么都不做,第八笔来了照做。 B. 停一停,等策略连赚两笔再回来。 C. 认了,这套 1983 年的老规矩在 2022 年的 BTC 上已经不管用了,关掉。

脚已经悬在刹车上了。踩不踩?


二、先别揭晓:连亏七笔的概率是多少

回答这个问题之前,先算一个数。

这条策略九年做了 70 笔,胜率只有 32.9%——第 31 篇量过,趋势跟随的招牌就是低胜率高盈亏比。那么问题是:

一条胜率 32.9% 的策略,做 70 笔,「最长的一段连亏」应该是多少笔?

这个数有公式。把每一笔当成一次独立的下注,亏的概率是 67.1%,那么 n 次下注里最长连亏的期望是(Schilling 1990):

E[最长连击] ≈ log(n × (1 − p)) ÷ log(1/p) + γ ÷ ln(1/p) − 1/2

其中 p 是「亏」的概率,γ 是欧拉常数 0.5772

代进去:

公式给的期望(70 笔、胜率 32.9%):最长连亏 8.82 笔
笔数        70.000
胜率         0.329
平均最长连亏     8.796
公式给的期望     8.820
50% 分位     8.000
75% 分位    10.000
90% 分位    12.000
95% 分位    14.000
99% 分位    18.000

连亏的分布

8.82 笔。

不是 2 笔,不是 3 笔。**这条策略在九年里出现一段八九笔的连亏,是设计的一部分,不是出了事。**蒙特卡洛跑五千次得到的平均值是 8.796,和公式对到小数点后一位。而分布的右边比你想的还要长:90% 分位是 12 笔,99% 分位是 18 笔。

再看下面那张表,它回答的是另一个问题:「连亏 k 笔就停手」这条规则,在策略完全正常的时候会响几次?

 门槛(连亏几笔)  至少响一次的概率  平均响几次
        3    1.0000 6.9768
        4    0.9996 4.6162
        5    0.9858 3.0558
        6    0.9218 2.0054
        7    0.7916 1.3190
        8    0.6190 0.8610
       10    0.3294 0.3764
       12    0.1570 0.1640
  • 「连亏 3 笔就停」:九年里必然会响,平均响 6.98 次
  • 「连亏 5 笔就停」:98.6% 会响
  • 「连亏 7 笔就停」:79.2% 会响
  • 「连亏 12 笔就停」:15.7% 会响

⚠️ 这张表里没有一行是在说「策略坏了」。它们全都是在策略完全正常的假设下算出来的。

所以你现在手上这个「连亏 7 笔」,不是证据。它是一条胜率 32.9% 的策略在九年里大概率会发生的事——你只是终于攒够了笔数,撞上了它。

一条误报率 79% 的规则不叫谨慎,它只是保证了你一定会在某个时刻停手。

回到那辆车:连着几次急刹之后你想接管。但如果这条路上本来每公里就有两次急刹,那你接管的依据不是「系统出问题了」,而是:「我没料到这条路长这样」


三、揭晓

2022-07-29 2022-08-19    2   止损  -8097.95 -1.64
2022-09-12 2022-09-13    1   止损  -4851.89 -1.00
2022-09-13 2022-09-14    1   止损  -5990.62 -1.25
2022-10-26 2022-11-08    4   止损 -12017.43 -2.53
2022-12-05 2022-12-16    4   止损 -11813.89 -2.56
2023-01-08 2023-02-09    4 通道出场 134106.89 29.77

连亏没有在第 7 笔停下:一共 12 笔,合计 -18.89R、-94,617 美元,从 2021-11-08 一直亏到 2022-12-16,403 天
账户 574,350 → 450,515,回撤 -21.56%

然后是第 40 笔:2023-01-08 进场 17,061.27,2023-02-09 以 22,500.00 通道出场,+29.77R、+134,107 美元
这一笔一个人赚回了前面 12 笔亏掉的 94,617 美元,还多出 39,490;它在九年 70 笔里排第 4

揭晓

第一层:连亏没有在第七笔停下。

后面还有五笔,全是止损。一共 12 笔−18.89R−94,617 美元,从 2021 年 11 月 8 日一直亏到 2022 年 12 月 16 日,403 天。账户从 574,350 掉到 450,515,回撤 −21.56%——这就是这条策略九年的最大回撤,你正站在它的最底下。

如果你在第七笔停手,你确实躲过了后面那五笔的 −8.98R

第二层:然后是第 40 笔。

2023 年 1 月 8 日买在 17,061.27,2 月 9 日按 10 日通道下轨出在 22,500.00。+29.77R,+134,107 美元。

这一笔一个人赚回了前面十二笔亏掉的 94,617 美元,还多出 39,490。它在九年 70 笔里排第 4。

看那张图下半部分的账户曲线:从 2021 年 11 月一路往下磨了十三个月的那条线,在 2023 年 1 月被一根近乎垂直的线拉了回去。

这就是为什么「停不停手」是一个比它看起来危险得多的问题。第 31 篇已经量过一次:**拿掉最赚的 10 笔,BTC 九年的收益只剩 −2.3%。**而这一篇给出了那条结论的执行版本——

你在连亏之后停手,停掉的不是「亏损」,是「亏损之后的那一段」。而收益全在那一段里。


四、正常范围:把同一批交易重排一遍

上一节的公式只能算连亏。但你真正会看的东西不止连亏——还有回撤有多深、水下要待多久。

这两个量没有公式,但有一个很朴素的办法:把同一批交易的顺序重新排一遍。

同样这 70 笔交易,还是这 70 个结果,只是发生的先后换一换。合计 R 一个字不变(加法可交换),变的只有路径。而你会不会中途停手,只取决于路径。

def shuffled(r, trials: int = 2000, seed: int = 0) -> pd.DataFrame:
    """把同一批交易**重新排一遍顺序**,看每一种顺序下的连亏、回撤和水下时间。

    这是第 29 篇自助法的一个特例,但问的问题不一样:那里问「这条策略的夏普可信吗」,
    这里问「**同样这批交易,换个顺序发生,我会看到多难看的一段**」。

    合计和顺序无关(加法可交换),所以每一次重抽的终点都一样——
    **变的只有路径。而你会不会中途停手,只取决于路径。**

    ⚠️ 这里**一律用加法**:曲线就是每笔结果的累加,相当于「每笔都按初始本金的固定比例下注」。
    喂 R 倍数进去,回撤的单位就是 R;喂每笔的账户收益率进去,单位就是百分点。
    不用复利是故意的——要看的是顺序效应,复利会把顺序效应和「越赚越大」搅在一起。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if len(r) < 2:
        raise ValueError("至少要有两笔交易")
    rng = np.random.default_rng(seed)
    rows = []
    for _ in range(trials):
        path = rng.permutation(r)
        curve = np.cumsum(path)
        peak = np.maximum.accumulate(np.concatenate([[0.0], curve]))[1:]
        under = curve < peak
        rows.append({"最长连亏": longest_streak(path), "最深回撤": float((curve - peak).min()),
                     "最长水下(笔)": int(_longest_true(under)), "合计": float(curve[-1])})
    return pd.DataFrame(rows)

跑两千次,把每一次的最长连亏、最深回撤、最长水下段记下来,就得到一张「正常范围表」:

—— SPY(海龟,67 笔,单位是 R)
            这一次    重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏      6.000   6.102       0.589   6.000     8.0   9.000  11.000
最深回撤    -12.557  -9.054       0.097  -8.585   -12.5 -13.882 -16.872
最长水下(笔)  29.000  16.619       0.060  15.000    26.0  29.000  37.000
—— AAPL(海龟,59 笔,单位是 R)
            这一次    重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏      5.000   5.748       0.786   5.000   8.000   9.000  11.000
最深回撤     -8.329 -10.300       0.741  -9.797 -14.138 -15.575 -18.839
最长水下(笔)  10.000   9.491       0.401   9.000  14.000  16.000  21.000
—— BTC(海龟,70 笔,单位是 R)
            这一次    重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏     12.000   8.640       0.115   8.000  12.000  13.000  16.000
最深回撤    -18.886 -15.683       0.197 -14.911 -21.403 -24.253 -30.206
最长水下(笔)  12.000  13.802       0.624  13.000  20.000  23.000  29.000

正常范围

读这张表最有用的是这一列——「比这一次还难看的比例」

真实这一次 比它还难看的重排占 95% 分位
SPY 最深回撤 −12.56R 9.7% −13.88R
AAPL 最深回撤 −8.33R 74.1% −15.58R
BTC 最深回撤 −18.89R 19.7% −24.25R
BTC 最长连亏 12 笔 11.5% 13 笔

**BTC 上那段让人绝望的十二连亏,在两千次重排里排在第 88.5 个百分位;−18.89R 的回撤排在第 80.3 个百分位。**难看,但没有难看到该关掉的程度——回撤的 95% 分位是 −24.25R,比你真的经历过的还要深 28%。

换句话说:你真正该准备好的那个数字,比你在回测报告上看到的那个数字更难看。

⚠️ 一个容易错的地方:这里的回撤一律用加法算(曲线就是 R 的累加),相当于「每笔都按初始本金的固定比例下注」。不用复利是故意的——复利会把顺序效应和「越赚越大」搅在一起,而这一节要看的就是纯粹的顺序效应。


五、你在回测里看到的回撤,未必是你会遇到的回撤

把同一张表算在主线策略 v4 上(第 26 篇定下来的那一版,一个字没改):

—— SPY(主线 v4,39 笔,单位是账户的百分点)
           这一次   重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏      3.00   5.40        1.00    5.00    8.00    8.00   10.00
最深回撤     -9.44 -16.87        0.98  -16.04  -23.48  -26.24  -31.71
最长水下(笔)  12.00  15.55        0.73   15.00   24.00   26.05   31.00
—— AAPL(主线 v4,37 笔,单位是账户的百分点)
           这一次   重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏      3.00   4.31        0.98    4.00    6.00    7.00    8.00
最深回撤    -22.75 -24.26        0.51  -22.87  -34.06  -38.09  -46.27
最长水下(笔)  15.00  10.92        0.18   10.00   17.00   19.00   23.01
—— BTC(主线 v4,30 笔,单位是账户的百分点)
          这一次   重抽平均  比这一次还难看的比例  50% 分位  90% 分位  95% 分位  99% 分位
最长连亏      3.0   4.94        1.00     5.0    7.00    8.00    9.00
最深回撤    -27.0 -38.58        0.86   -36.7  -54.16  -59.96  -70.63
最长水下(笔)   9.0   9.98        0.59     9.0   15.00   17.00   21.00

这张表比上一张更值得看,因为它指向一个相反方向的问题:

主线 v4 回测报告上的回撤 比它还难看的重排占 95% 分位
SPY −9.44 98% −26.24
AAPL −22.75 51% −38.09
BTC −27.00 86% −59.96

SPY 上主线 v4 的历史回撤只有 −9.44 个百分点,而两千次重排里有 98% 比它更难看。它的最长连亏只有 3 笔,而重排的中位数是 5 笔。

这不是说主线 v4 有问题。这是说:**它走过的那条路径,是所有可能路径里偏幸运的一条。**同样这 39 笔交易,换个顺序发生,95% 的把握下你该准备的是 −26 个百分点,不是 −9.4。

⚠️ 这是这一节唯一的结论,也是这一篇最该贴在屏幕上的一句话:**回测报告上的最大回撤是一个样本,不是一个上限。**第 29 篇教过「每台仪器都有测量误差」,这里是同一件事的另一个面——最大回撤这台仪器的误差,比夏普比率还大,因为它是一个极值统计量,只由一条路径的一小段决定。

三个标的六条策略,没有一个的真实回撤触到过 95% 分位。你从来没有见过你该准备的那个数。


六、「策略真的坏了」要多少笔才认得出来

到这里为止都是在说「别急着停手」。但总有真的坏掉的时候。那怎么认?

这个问题有一个精确的答案,而且这个答案是坏消息。

假设策略的期望值真的掉到了 0。要想以 80% 的把握、在 5% 的显著性水平上认出这件事,需要的笔数是:

笔数 ≈ [ (z₁₋α + z_power) × 每笔标准差 ÷ 每笔期望 ]²

注意分母被平方了一次。而交易这件事的特点恰恰是:单笔收益的标准差是期望的好几倍。

def detection_size(mean: float, std: float, drop: float = 1.0,
                   alpha: float = 0.05, power: float = 0.8) -> float:
    """要**多少笔**交易,才能以 `power` 的把握、在 `alpha` 的水平上,
    认出「每笔的期望值掉了 `drop` 倍」(`drop=1.0` 就是掉到 0):

    > 笔数 ≈ [ (z₁₋α + z_power) × 标准差 ÷ (期望 × drop) ]²

    这是第 29 篇「要跑多少年」的交易版本,结论也一样难看:
    **单笔收益的标准差通常是期望的好几倍,于是分母被平方一次,笔数就上了三位数。**
    连亏 7 笔提供的信息量,和这个数字比起来约等于 0。
    """
    if mean <= 0 or std <= 0 or drop <= 0:
        raise ValueError("期望、标准差、drop 都要是正数")
    z = NORMAL.inv_cdf(1 - alpha) + NORMAL.inv_cdf(power)
    return (z * std / (mean * drop)) ** 2
  标的          策略  笔数  每笔期望  每笔标准差  标准差是期望的几倍  认出「掉到 0」要几笔  认出「掉一半」要几笔  按历史速度要跑几年
 SPY       海龟(R)  67  0.71   2.61       3.68        83.57      334.29      11.23
 SPY 主线 v4(账户 %)  39  0.85   4.75       5.59       192.92      771.70      44.52
AAPL       海龟(R)  59  2.83   6.22       2.20        29.96      119.83       4.57
AAPL 主线 v4(账户 %)  37  3.03  10.58       3.49        75.21      300.83      18.29
 BTC       海龟(R)  70  4.23  12.93       3.06        57.86      231.43       7.44
 BTC 主线 v4(账户 %)  30  9.94  40.05       4.03       100.29      401.15      30.09
标准差是期望的几倍 认出「掉到 0」要几笔 按历史速度要跑几年
SPY 海龟 3.68 84 笔 11.2 年
AAPL 海龟 2.20 30 笔 4.6 年
BTC 海龟 3.06 58 笔 7.4 年
SPY 主线 v4 5.59 193 笔 44.5 年
BTC 主线 v4 4.03 100 笔 30.1 年

**最快的那个也要四年半。**而「只掉了一半」比「掉到 0」还难四倍——SPY 上的海龟要 334 笔。

这和第 29 篇那个「SPY 上的主线要跑 380 年才能证明夏普大于 0」是同一件事的两个说法。你手上这七笔连亏,提供的信息量和这些数字比起来约等于零。

所以「连亏几笔就认定策略失效」这条路是走不通的——不是因为它不严谨,是因为在你收集到足够证据之前,策略要么已经把你的账户毁了,要么已经赚回来了。

那还剩什么?三条,都不靠统计:

  1. 正常范围表(第四节那张)。落在范围里就继续,这不需要推断,只需要事先算好。
  2. 超出范围的时候降级——⚠️ 注意这句话的意思:超出 99% 分位不等于统计上证明了策略失效,它只是你上线之前和自己约好的那条线。约定不需要是对的,只需要是事先定好的。(怎么降级、降到哪一级,是第 34 篇的内容。)
  3. 机制还在不在。第 32 篇留下的那个数就是给这一步用的:趋势跟随赌的是方差比大于 1。方差比变了,前提就变了——这不需要等 58 笔。

回到那辆车:判断系统行不行,看的是每次接管之间跑了多少公里,不是你今天觉得它开得怎么样。而你今天觉得它开得不好,要攒到七年才够格变成一个统计结论。


七、连亏之后停手,值多少钱

说了这么多「不该停」,直接量一下。

规则很简单:**连亏 N 笔就停 M 笔,之后照旧。**被跳过的那几笔的结果照样记账——不然你永远不知道停手是赚了还是亏了。

def pause_after_losses(r, after: int = 3, pause: int = 3) -> np.ndarray:
    """「连亏 `after` 笔就停 `pause` 笔」这条规则,实际会跳过哪几笔。

    返回一串 True/False:True = 这一笔做了,False = 被规则拦下了。
    ⚠️ 被拦下的那几笔的**结果照样要记**——不然你永远不知道停手是赚了还是亏了。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if after < 1 or pause < 0:
        raise ValueError("after 至少是 1,pause 不能是负数")
    taken = np.ones(len(r), dtype=bool)
    losses = skip = 0
    for i, value in enumerate(r):
        if skip > 0:
            taken[i] = False
            skip -= 1
            continue
        losses = losses + 1 if value <= 0 else 0
        if losses >= after:
            skip, losses = pause, 0
    return taken

BTC 上十二种组合:

 连亏几笔  停几笔  做了几笔   合计 R  跳过的那些笔   最深回撤  比一笔不落差多少
    0    0    70 295.93    0.00 -18.89      0.00
    2    1    56 270.20   25.73 -16.01    -25.73
    2    3    40 143.81  152.12 -15.08   -152.12
    2    5    30 200.59   95.34 -10.72    -95.34
    3    1    61 267.51   28.42 -13.98    -28.42
    3    3    49 176.61  119.32 -15.04   -119.32
    3    5    45 201.11   94.82 -13.93    -94.82
    4    1    65 224.79   71.14 -16.80    -71.14
    4    3    55 187.02  108.91 -12.17   -108.91
    4    5    50 198.93   97.00 -12.08    -97.00
    5    1    66 280.04   15.89 -15.33    -15.89
    5    3    61 285.91   10.02 -13.92    -10.02
    5    5    55 267.49   28.44 -15.14    -28.44

停手规则

**十二条规则,没有一条是赚的。**最狠的那条(连亏 2 笔停 3 笔)把九年从 295.93R 砍到 143.81R——少了一半还多

看「跳过的那些笔」那一列:它们全是正数。连亏 2 停 3 那一行跳过的交易合计 +152.12R——你停手的那段时间里,市场把这些钱发给了别人。

回撤倒是真的变浅了(十二行里最浅的一行是 −10.72R,比 −18.89R 少了四成)。⚠️ 但那是因为你交易得更少了,不是因为你避开了坏的。用第 26 篇的话说,这等于把风险预算调小——调小风险预算当然能压回撤,而且有比「连亏之后停手」直接得多的办法(直接把每笔的风险从 1% 改成 0.5%),代价还更小。

三个标的两条策略的汇总:

  标的          策略   一笔不落  连亏 2 停 3  连亏 3 停 3  连亏 4 停 3  连亏 3 停 5
 SPY       海龟(R)  47.50     -9.38    -11.74     -6.18    -19.52
 SPY 主线 v4(账户 %)  33.19     -4.27      0.24      0.00     -5.23
AAPL       海龟(R) 166.85    -32.83     -0.48      1.26    -21.05
AAPL 主线 v4(账户 %) 112.21     -8.97     -9.37      0.00    -61.69
 BTC       海龟(R) 295.93   -152.12   -119.32   -108.91    -94.82
 BTC 主线 v4(账户 %) 298.30    -21.87    -18.67      0.00    -18.70

十二组里十一组是亏的,唯一的例外是 AAPL 上的「连亏 4 笔停 3 笔」,赚了 +1.26R(0.8%)——在这个量级上,它和 0 没有区别。

⚠️ 主线 v4 那三行的「连亏 4 停 3」全是 0.00,原因很实在:**主线 v4 九年的最长连亏只有 3 笔,这条规则一次都没触发过。**一条从来不响的规则当然没有代价——但它也没有用。

那按当天亏损停呢?「单日亏损超过 X% 就清仓,停 N 天」是另一条常见的执行规则。量一下:

  标的 单日亏多少就停  停几天  被触发几次  真正被砍掉的天数     年化    最大回撤
 SPY      不停    0      0         0 0.0468 -0.1711
 SPY      3%    5      3         2 0.0496 -0.1519
 SPY      3%   20      3         2 0.0496 -0.1519
 SPY      5%    5      0         0 0.0468 -0.1711
 SPY      5%   20      0         0 0.0468 -0.1711
AAPL      不停    0      0         0 0.1690 -0.1187
AAPL      3%    5      8        28 0.1550 -0.1187
AAPL      3%   20      7        85 0.1382 -0.1211
AAPL      5%    5      2         1 0.1703 -0.1187
AAPL      5%   20      2         1 0.1703 -0.1187
 BTC      不停    0      0         0 0.3160 -0.2156
 BTC      3%    5     30       107 0.2749 -0.2143
 BTC      3%   20     21       232 0.2968 -0.2143
 BTC      5%    5     13        37 0.3075 -0.2143
 BTC      5%   20     12       105 0.3111 -0.2170

SPY 那四行很有意思:「单日亏 3% 就停 5 天」九年只触发了 3 次,而且真正被砍掉的只有 2 天——因为触发的那一天止损已经把仓位平掉了,后面那几天本来就是空仓。「单日亏 5%」一次都没触发过。

**对一条日线波段策略来说,每日亏损上限基本是个摆设。**它是给日内高频交易设计的——那里一天可以开几十笔,一天的亏损才有可能失控。在这里,止损已经干了这件事。

BTC 上倒是真的会触发(3% 的门槛九年 30 次),但结果一样难看:年化从 31.60% 掉到 27.49%,最大回撤从 −21.56% 只压到 −21.43%——几乎没动,因为最大的那次回撤是十二笔交易慢慢磨出来的,不是哪一天砸出来的。


八、连亏之后的下一笔,有什么不一样吗

上一节说「停手是亏的」。但那只是总账。有没有可能,连亏之后的那几笔确实质量更差,只是被别的东西盖过去了?

直接查:

  标的           这一笔的处境  笔数   平均 R    胜率  最大一笔 R
 SPY               全部  67  0.709 0.448   7.651
 SPY           前一笔是亏的  37  0.889 0.405   7.624
 SPY       前面连亏 ≥ 3 笔  13  1.691 0.538   7.623
 SPY       前面连亏 ≥ 5 笔   3  2.755 0.667   7.623
 SPY 最赚的 10 笔,前一笔是亏的占  10  5.579 0.700   7.651
AAPL               全部  59  2.828 0.458  18.051
AAPL           前一笔是亏的  31  3.223 0.516  17.649
AAPL       前面连亏 ≥ 3 笔   5  0.544 0.400   3.947
AAPL       前面连亏 ≥ 5 笔   1  1.667 1.000   1.667
AAPL 最赚的 10 笔,前一笔是亏的占  10 14.591 0.600  18.051
 BTC               全部  70  4.228 0.329  76.442
 BTC           前一笔是亏的  47  4.683 0.340  76.442
 BTC       前面连亏 ≥ 3 笔  22  6.342 0.273  76.442
 BTC       前面连亏 ≥ 5 笔  12  3.132 0.250  29.767
 BTC 最赚的 10 笔,前一笔是亏的占  10 30.289 0.700  76.442
BTC 笔数 平均 R 胜率
全部 70 4.228 32.9%
前一笔是亏的 47 4.683 34.0%
前面连亏 ≥ 3 笔 22 6.342 27.3%
前面连亏 ≥ 5 笔 12 3.132 25.0%

**连亏之后的那些笔,平均 R 不比别的差,甚至还高一点。**胜率略低、平均 R 略高,这正好是趋势跟随该有的样子——⚠️ 但样本只有十几二十笔,这个方向不能当成结论(第 29 篇:样本量是笔数不是根数)。

真正能当结论的是最后那一行:**BTC 上最赚的 10 笔里,70% 的「前一笔」是亏的。**而全样本里「前一笔是亏的」本来就占 67.1%(47/70)。

70% 对 67%。一点信息都没有。

SPY 上是 70% 对 55.2%,AAPL 上是 60% 对 52.5%——三个标的,没有一个方向一致、幅度够大。

**大赚的那几笔,前面是连亏还是连赚,完全没有规律。**所以不存在「先躲开坏的、再回来接好的」这种走法——你要么都做,要么都不做。

这就是自动驾驶那个比方里最不舒服的一条:**你接管的那几次,事后看绝大多数是不必要的。**而你没法事先知道哪几次是必要的。


九、六种手动调整的价目表

停手只是众多「接管」里的一种。把常见的几种一起标上价:

def interfere(r, kind: str, level: float = 2.0) -> np.ndarray:
    """在一串 R 倍数上施加一次常见的「手动调整」,返回调整之后的 R。

    前四种只动**仓位**或者**做不做**,所以是精确的:

    - `skip_after_loss`:亏一笔之后,下一笔不敢做了
    - `double_after_loss`:亏一笔之后,下一笔加倍下注(想一把捞回来)
    - `half_after_loss`:亏一笔之后,下一笔只做一半(怯场)
    - `double_after_win`:赚一笔之后,下一笔加倍下注(觉得手感来了)

    后两种动的是**出场**,只能给一个方向确定的估计:

    - `cap`:浮盈到 `level` 个 R 就提前走人 → `min(r, level)`。
      ⚠️ 这是**代价的上界**:真实的提前止盈还会把一部分最终亏损的交易救成小赚,
      所以真实代价比这里算出来的小。
    - `widen`:眼看要止损,把止损往外挪,最后照样止损 → 亏的那些乘以 `level`。
      ⚠️ 这也是**上界**:挪远之后会有一部分活过来变成盈利。
      想要不含上界的那个数,就直接改引擎里的止损参数重跑一遍——两个数一夹,真相在中间。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if kind not in KINDS:
        raise ValueError(f"kind 只能是 {KINDS} 之一,收到 {kind!r}")
    if kind == "cap":
        return np.minimum(r, level)
    if kind == "widen":
        return np.where(r <= 0, r * level, r)
    previous = np.concatenate([[np.nan], r[:-1]])
    after_loss, after_win = previous <= 0, previous > 0
    scale = np.ones(len(r))
    if kind == "skip_after_loss":
        scale[after_loss] = 0.0
    elif kind == "double_after_loss":
        scale[after_loss] = 2.0
    elif kind == "half_after_loss":
        scale[after_loss] = 0.5
    else:
        scale[after_win] = 2.0
    return r * scale
—— SPY(海龟)
            怎么改的  合计 R   最深回撤  差多少 R 按每笔冒 1% 折算
           一个字不改 47.50 -12.56   0.00      +0.0%
         亏了之后不敢做 14.61 -11.94 -32.89     -32.9%
          亏了之后加倍 80.39 -16.94  32.89     +32.9%
          亏了之后减半 31.05 -11.75 -16.45     -16.4%
          赚了之后加倍 58.20 -23.50  10.70     +10.7%
        浮盈 2R 就走  5.56 -18.02 -41.94     -41.9%
要止损了把止损挪远(亏的 ×2)  4.93 -39.11 -42.57     -42.6%
—— AAPL(海龟)
            怎么改的   合计 R   最深回撤   差多少 R 按每笔冒 1% 折算
           一个字不改 166.85  -8.33    0.00      +0.0%
         亏了之后不敢做  66.93  -8.84  -99.92     -99.9%
          亏了之后加倍 266.77 -15.14   99.92     +99.9%
          亏了之后减半 116.89  -5.63  -49.96     -50.0%
          赚了之后加倍 232.17 -11.26   65.33     +65.3%
        浮盈 2R 就走   0.65 -14.17 -166.20    -166.2%
要止损了把止损挪远(亏的 ×2) 117.07 -18.32  -49.78     -49.8%
—— BTC(海龟)
            怎么改的   合计 R   最深回撤   差多少 R 按每笔冒 1% 折算
           一个字不改 295.93 -18.89    0.00      +0.0%
         亏了之后不敢做  75.81 -15.74 -220.12    -220.1%
          亏了之后加倍 516.05 -36.00  220.12    +220.1%
          亏了之后减半 185.87 -10.33 -110.06    -110.1%
          赚了之后加倍 361.46 -20.66   65.53     +65.5%
        浮盈 2R 就走 -30.67 -40.37 -326.60    -326.6%
要止损了把止损挪远(亏的 ×2) 220.49 -37.77  -75.44     -75.4%

手动调整的代价

BTC 上的价目表(九年合计 295.93R,每笔冒账户 1%,所以 1R 大致就是 1 个百分点):

你做的事 合计 R 最深回撤 差多少
一个字不改 295.93 −18.89
亏了之后不敢做 75.81 −15.74 −220.12R
亏了之后加倍 516.05 −36.00 +220.12R
亏了之后减半 185.87 −10.33 −110.06R
赚了之后加倍 361.46 −20.66 +65.53R
浮盈 2R 就走 −30.67 −40.37 −326.60R
要止损了把止损挪远 220.49 −37.77 −75.44R

三件事值得说:

**第一,「浮盈 2R 就走」是全表最贵的一条。**它把九年从 +295.93R 变成 −30.67R——九年白干还倒亏。这不奇怪:第 31 篇量过,BTC 上最大的一笔是 +76.44R,而这条规则会在 +2R 的地方把它砍掉。趋势跟随的全部收益都在右尾上,提前止盈是专门用来剪右尾的剪刀。

⚠️ 这个数是代价的上界:真实的提前止盈还会把一部分最终亏损的交易救成小赚(浮盈到过 2R 又跌回去的那些),所以真实代价比 −326.60R 小。但方向没有疑问。

第二,「亏了之后加倍」在历史上是赚的(+220.12R),而「亏了之后不敢做」亏掉的正好是同一个数。这不是巧合,是算术:两者动的是同一批交易,一个乘 2 一个乘 0,相对基准的偏移必然大小相等方向相反。

所以千万别把 +220.12R 读成「报复性交易有道理」。**它赚的原因只是:这条策略每一笔的期望本来就是正的,你把任何一组交易加倍都会赚更多。**真正的代价在旁边那一列:最深回撤从 −18.89R 变成 −36.00R,差不多翻倍。用第 26 篇的话说——加倍不增加优势,它只增加杠杆,而且是在你账户最虚弱的时刻加杠杆。

第三,三个标的的形状完全一致(右边那张图):「不敢做」和「浮盈就走」在三个标的上都是大负数,「挪止损」都是负数。这三条是真正的执行失误。而「加倍」那两条的正号,在三个标的上都伴随着回撤的显著恶化。


十、「一开始就放远」和「临时挪远」是两件事

上表里「把止损挪远」只亏了 75.44R,看起来比「不敢做」轻得多。这个数容易被误读成「止损放远一点也没什么」。

放远和挪远是两件完全不同的事。直接把引擎里的止损参数改掉重跑一遍,就能看出区别:

  标的               止损  笔数     年化    最大回撤     合计 R
 SPY       2N(一开始就定好)  67 0.0468 -0.1711  47.5013
 SPY       3N(一开始就定好)  63 0.0485 -0.1025  48.7796
 SPY       4N(一开始就定好)  63 0.0432 -0.1027  43.2927
 SPY 临时挪远(亏的 ×1.5,上界)  67    NaN     NaN  26.2169
 SPY   临时挪远(亏的 ×2,上界)  67    NaN     NaN   4.9325
AAPL       2N(一开始就定好)  59 0.1690 -0.1187 166.8484
AAPL       3N(一开始就定好)  55 0.1250 -0.1028 123.8316
AAPL       4N(一开始就定好)  55 0.0933 -0.0752  92.4573
AAPL 临时挪远(亏的 ×1.5,上界)  59    NaN     NaN 141.9603
AAPL   临时挪远(亏的 ×2,上界)  59    NaN     NaN 117.0722
 BTC       2N(一开始就定好)  70 0.3160 -0.2156 295.9295
 BTC       3N(一开始就定好)  65 0.2009 -0.1693 188.2643
 BTC       4N(一开始就定好)  65 0.1480 -0.1563 138.0073
 BTC 临时挪远(亏的 ×1.5,上界)  70    NaN     NaN 258.2086
 BTC   临时挪远(亏的 ×2,上界)  70    NaN     NaN 220.4877
SPY 笔数 年化 最大回撤
2N(原版) 67 4.68% −17.11%
3N(一开始就定好) 63 4.85% −10.25%
4N(一开始就定好) 63 4.32% −10.27%

**SPY 上「一开始就把止损放到 3N」年化更高、回撤几乎减半。**这是一个合理的参数选择,不是错误。

而同一个账户上「临时挪远」的结果是:合计 R 从 47.50 掉到 4.93

区别在哪?仓位。

  • 一开始就放远:止损距离变大 → 按第 26 篇的固定风险公式,仓位自动变小 → 每笔的风险还是账户的 1%,一个字没变
  • 临时挪远:仓位在进场那一刻就定死了 → 止损距离变大 → 每笔的风险直接翻倍

**「挪止损」的真正问题不是止损放在哪里,是它在不改仓位的前提下改风险——而且只在你正在亏钱的那些笔上改。**它等于一条「只在坏情况下加倍下注」的规则,比上一节那条「亏了之后加倍」还要糟,因为它连"下一笔"都等不及。

⚠️ 读这张表要小心一个陷阱:不同止损宽度下 R 的定义是不一样的(1R 就是止损距离),所以「合计 R」那一列只能竖着和自己比,横着比要看年化和最大回撤。AAPL 和 BTC 上 3N/4N 的年化都明显更低(BTC 31.60% → 20.09% → 14.80%),说明 2N 在那两个标的上是更好的选择——这也说明第 31 篇那个「(20,10) 是高地不是针」的结论对止损宽度同样成立:换一个数不会塌,但也不是随便换。


十一、交易日志该记什么

到这里,所有该量的都量完了。剩下的问题是:你怎么知道自己有没有照做?

答案不能靠回忆。回忆这东西有一个特别恶劣的性质:你记得住那几次「幸亏我没做」,记不住那几十次「幸亏我做了」。

所以要记账。而交易日志的核心只有一件事:

计划和实际并排放。

只记实际成交价的日志是没有用的——它能告诉你赚了多少,不能告诉你为什么和回测不一样。券商对账单已经把「实际」那一半给你了,你要补的是另外那一半——计划,以及两者不一样时的原因。

@dataclass
class Entry:
    """日志里的一行。核心是一件事:**计划和实际并排放。**

    只记实际成交价的日志没有用——它能告诉你赚了多少,不能告诉你**为什么和回测不一样**。
    `reconcile` 要拆的那几块,全都藏在「计划」和「实际」的差里。

    `fill_*` 留空表示「和计划完全一样」。`qty` 为 0 的一行表示**这笔计划里的交易你没做**,
    照样要记——第四节的 `pause_table` 已经说明了为什么。
    """
    time: pd.Timestamp                    # 计划进场的那一根
    symbol: str                           # 标的
    plan_entry: float                     # 计划进场价
    plan_stop: float                      # 计划止损价
    plan_qty: float                       # 计划数量
    side: str = "long"
    plan_exit: float = np.nan             # 计划出场价(回测给的)
    fill_entry: float | None = None       # 实际成交价
    fill_qty: float | None = None         # 实际数量(0 = 没做)
    fill_exit: float | None = None
    exit_time: pd.Timestamp | None = None
    fee: float = 0.0                      # 实际付出的手续费
    plan_fee: float = 0.0
    basis: str = "规则"                   # 依据:规则 / 我觉得 / 别人说的
    note: str = ""                        # 偏离计划的原因,一句话

    def __post_init__(self):
        if self.side not in SIDES:
            raise ValueError(f"side 只能是 {tuple(SIDES)} 之一,收到 {self.side!r}")
        if not self.plan_qty > 0:
            raise ValueError("计划数量要大于 0")
        if SIDES[self.side] * (self.plan_stop - self.plan_entry) >= 0:
            raise ValueError("止损放错了方向:做多的止损要低于进场价,做空的要高于")
        if self.fill_qty is not None and self.fill_qty < 0:

就用决策点之后那三笔真实交易记一遍:

2023-01-08 BTC  多 17325.35 17325.35 16616.33 25.92 25.92 22500.00 22500.00 2023-02-09   0.0  0.0 134106.89 134106.89  规则                      
2023-02-15 BTC  多 24625.73 24639.13 23252.52 11.90 11.90 23486.09 23470.11 2023-02-17   0.0 44.0 -13564.07 -13957.75  规则 进场晚了 13 美元,出场滑了 16 美元
2023-03-14 BTC  多 25384.24 25384.24 23409.30 11.00  0.00 28170.00 28170.00 2023-04-20   0.0  0.0  30640.61      0.00 我觉得          刚被止损两次,这次不敢做

记了几笔              3.00
没做                1.00
成交价和计划不一样         1.00
数量和计划不一样          0.00
依据不是规则            1.00

第三行就是第一节那个人的选择:2023 年 3 月 14 日,刚被止损两次,这次不敢做。日志把它记成一行——计划数量 11.00,实际数量 0.00,依据那一栏写的不是「规则」,而是:「我觉得」;备注那一栏写的是理由。

这一笔计划里该赚 30,640.61 美元。三笔加起来,计划盈亏 151,183.44,实际盈亏 120,149.14,差了 31,034.29

summary() 给的那几个数里,最后一个是整本日志唯一不能从券商对账单里算出来的东西:

依据不是规则            1.00

「我觉得」这一类,只有你自己记才数得出来。这就是记日志的全部理由——它不是为了复盘行情,是为了让接管这件事有一个计数器

自动驾驶公司提交的脱离报告里最关键的字段从来不是速度和位置,而是:「为什么」


十二、对账:把差额拆干净

有了「计划」和「实际」两张表,就可以问那个最实在的问题:

我今年比回测少赚的那一截,到底是从哪来的?

这个问题有一个精确的答案。差额可以被完整地拆成五块,五块之和恰好等于总差额,一分钱不剩

def reconcile(planned: pd.DataFrame, actual: pd.DataFrame, key: str = "买入日",
              entry_col: str = "买入价", exit_col: str = "卖出价", qty_col: str = "数量",
              fee_col: str = "费用", side_col: str = "方向") -> dict:
    """实盘和回测差的那一截,是从哪来的。

    两张表按 `key`(默认进场日)对齐,把总差额拆成五块:

    | 这一块 | 怎么算 | 说的是 |
    |---|---|---|
    | 漏做 | −Σ 回测那笔的盈亏 | 计划里有、你没做 |
    | 多做 | +Σ 实盘那笔的盈亏 | 计划里没有、你做了 |
    | 进场滑点 | Σ 方向 × 计划量 ×(计划进场价 − 实际进场价) | 买贵了 / 卖便宜了 |
    | 出场滑点 | Σ 方向 × 计划量 ×(实际出场价 − 计划出场价) | 走早了 / 走晚了 |
    | 仓位差 | Σ 方向 ×(实际量 − 计划量)×(实际出场价 − 实际进场价) | 做大了 / 做小了 |
    | 费用差 | −Σ(实际费用 − 计划费用) | 手续费和回测里假设的不一样 |

    这不是近似:**六块之和恒等于「实盘合计 − 回测合计」**(`差额核对` 那一项就是用来看这个的,
    正常应该是 0)。代数上它是把 q(x−e) 的差分三步展开,每一步只动一个量。

    ⚠️ **拆开之后最该看的是「漏做」那一格。** 滑点是几个基点的事,
    漏做一笔是整整一笔的事——而第 31 篇已经量过,一笔可以占九年收益的 14%。
    """
    def prep(table: pd.DataFrame) -> pd.DataFrame:
        out = table.copy()
        if fee_col not in out:
            out[fee_col] = 0.0
        out["_side"] = (out[side_col].map(SIDES).astype(float) if side_col in out else 1.0)
        out["_pnl"] = out["_side"] * out[qty_col] * (out[exit_col] - out[entry_col]) - out[fee_col]
        return out.set_index(key)

    left, right = prep(planned), prep(actual)
    both = left.index.intersection(right.index)
    missing, extra = left.drop(both), right.drop(both)
    p, a = left.loc[both], right.loc[both]

    sign, q_p, q_a = p["_side"], p[qty_col], a[qty_col]
    slip_in = sign * q_p * (p[entry_col] - a[entry_col])
    slip_out = sign * q_p * (a[exit_col] - p[exit_col])
    size_gap = sign * (q_a - q_p) * (a[exit_col] - a[entry_col])
    fee_gap = -(a[fee_col] - p[fee_col])
    parts = pd.Series({
        "漏做": -float(missing["_pnl"].sum()), "多做": float(extra["_pnl"].sum()),
        "进场滑点": float(slip_in.sum()), "出场滑点": float(slip_out.sum()),
        "仓位差": float(size_gap.sum()), "费用差": float(fee_gap.sum()),
    })
    total = float(right["_pnl"].sum() - left["_pnl"].sum())
    parts["合计"] = float(parts.sum())
    parts["实盘 − 回测"] = total
    parts["差额核对"] = parts["合计"] - total
    by_trade = pd.DataFrame({"进场滑点": slip_in, "出场滑点": slip_out,
                             "仓位差": size_gap, "费用差": fee_gap,
                             "这一笔差多少": a["_pnl"] - p["_pnl"]})
    return {"归因": parts, "逐笔": by_trade.sort_index(),

拆法本身是一个代数恒等式:每一笔的盈亏是「方向 × 数量 × 价差 − 费用」,三步展开,每一步只动一个量。所以 差额核对 那一项必须是 0,不是 0 就是代码写错了。

现在造一个「实盘」来试。它和回测有三处不一样,外加一个人的决定:

  • 单边 0.05% 的滑点(买的时候买贵,卖的时候卖便宜)
  • 数量随机差 ±10%(手滑、凑整、余额不够)
  • 单边 0.10% 的手续费(Binance 现货 taker 的标准费率,而回测里假设是 0)
  • 外加第一节那个人的行为:连亏 4 笔就停 3 笔
SLIP_BP, FEE_BP = 5e-4, 1e-3


def as_table(trades: pd.DataFrame) -> pd.DataFrame:
    """把海龟的交易表整理成 `reconcile` 认识的样子(费率是 0,所以数量能精确反推)。"""
    sign = np.where(trades["方向"] == "多", 1.0, -1.0)
    qty = trades["盈亏"].to_numpy() / (sign * (trades["出场价"] - trades["平均价"]).to_numpy())
    return trades.assign(数量=qty, 费用=0.0)[["进场日", "方向", "平均价", "出场价", "数量", "费用"]]


planned = as_table(trades)
sign = np.where(planned["方向"] == "多", 1.0, -1.0)
rng = np.random.default_rng(33)
live = planned.assign(
    平均价=planned["平均价"] * (1 + sign * SLIP_BP),            # 买的时候买贵一点
    出场价=planned["出场价"] * (1 - sign * SLIP_BP),            # 卖的时候卖便宜一点
    数量=planned["数量"] * (1 + rng.normal(0, 0.10, len(planned))),
)
live["费用"] = live["数量"] * (live["平均价"] + live["出场价"]) * FEE_BP
taken = JN.pause_after_losses(trades["R"], after=4, pause=3)    # 决策点那个人的行为
live = live[taken]
「实盘」的三处不一样:单边 0.05% 滑点、数量随机差 ±10%、单边 0.10% 手续费,外加「连亏 4 笔就停 3 笔」跳过了 15 笔

漏做         -275,133
多做               +0
进场滑点         -7,003
出场滑点         -7,405
仓位差          +8,437
费用差         -29,069
合计         -310,175
实盘 − 回测    -310,175
差额核对             +0

把差额拆开

九年下来,实盘比回测少赚 310,175 美元。拆开:

金额 占差额
漏做 −275,133 88.7%
费用差 −29,069 9.4%
出场滑点 −7,405 2.4%
进场滑点 −7,003 2.3%
仓位差 +8,437 −2.7%
多做 +0 0%
合计 −310,175 100%

进场滑点加出场滑点一共 14,408 美元,占 4.6%。漏做那一格是 275,133,占 88.7%——是滑点的 19 倍。

看看被跳过的是哪几笔:

跳过的那 15 笔:
       进场日        出场日   原因        盈亏     R
2019-03-21 2019-06-04 通道出场  98354.06 76.44
2019-06-16 2019-07-02 通道出场   5580.90  2.46
2019-08-05 2019-08-14   止损  -4616.47 -1.98
2022-05-31 2022-06-10 通道出场  -4299.06 -0.84
2022-07-08 2022-07-13   止损  -5101.22 -1.00
2022-07-18 2022-07-26   止损 -11733.77 -2.32
2022-12-05 2022-12-16   止损 -11813.89 -2.56
2023-01-08 2023-02-09 通道出场 134106.89 29.77
2023-02-15 2023-02-17   止损 -13564.07 -2.32
2024-09-17 2024-10-01   止损 -22222.73 -2.37
2024-10-14 2024-12-05 通道出场 201393.91 21.95
2024-12-05 2024-12-19   止损 -28821.26 -2.58
2025-12-09 2025-12-14 通道出场  -9428.24 -0.86
2026-01-02 2026-01-20   止损 -27320.96 -2.51
2026-03-04 2026-03-19   止损 -25380.75 -2.39

十五笔里十一笔是亏的,合计 −164,302 美元——跳过它们确实省下了这些钱。

另外四笔是赚的,合计 +439,436 美元,其中三笔是:

被跳过的交易 盈亏 R 在九年 70 笔里排第几
2019-03-21 → 2019-06-04 +98,354 +76.44R 7
2023-01-08 → 2023-02-09 +134,107 +29.77R 5
2024-10-14 → 2024-12-05 +201,394 +21.95R 3

一减一加,净下来就是归因表上那个 −275,133

那条「保护你」的规则省下了十一笔小亏,代价是挡住了九年里排第 3、第 5、第 7 的那三笔。

这不是巧合,但也不是什么神秘规律。趋势跟随的大赚交易出现在一段趋势的开头,而趋势的开头前面通常是一段没有方向的震荡——而震荡正是连亏的来源(第 31 篇量过:BTC 的 20 日通道突破有 41.74% 是假的,真按规则交易亏钱的比例是 55%–67%)。连亏和大赚在时间上是挨着的。

⚠️ 但第八节已经证明了这件事的另一半:这种「挨着」没有方向性——最赚的 10 笔里前一笔是亏的占 70%,而基准就是 67.1%。它足以解释为什么停手一定会亏,但不足以让你反过来在连亏之后加仓。

「连亏之后停手」这条规则,是专门用来错过它们的。


十三、漏做一笔,相当于多少滑点

上一节的对比值得单独量一次,因为它决定了你该把精力花在哪。

  标的   九年成交名义金额     九年总盈亏  单边 1 个基点滑点  漏做赚得最多的那一笔  一笔顶多少个基点的滑点
 SPY 15129801.4   57179.1     -1513.0    -11034.9          7.3
AAPL 22951801.3  368098.0     -2295.2    -64022.4         27.9
 BTC 38297191.1 1079570.8     -3829.7   -217853.3         56.9
九年成交名义金额 单边 1 个基点滑点 漏做赚得最多的那一笔 一笔顶多少个基点
SPY 15,129,801 −1,513 −11,035 7.3
AAPL 22,951,801 −2,295 −64,022 27.9
BTC 38,297,191 −3,830 −217,853 56.9

在 BTC 上,漏做那一笔的代价,等于九年里每一笔交易都多付 56.9 个基点的滑点。

56.9 个基点是什么概念?第 28 篇量过:BTC 永续的真实买卖价差中位数只有 0.0328 个基点,现货 taker 费率是 10 个基点。也就是说,手一抖漏做一笔,等于把九年的交易成本变成原来的六倍还多。

⚠️ 而 BTC 上那一笔是哪一笔?2023 年 10 月 16 日买在 28,580、12 月 29 日出场,+36.43R、+217,853 美元——正是第 31 篇那个决策点里「前面十次突破七次是假的、你还做不做第十一次」的答案。

最后把四种偏差各自单独放进对账里,看它们各值多少:

  标的       实盘和回测哪里不一样           差额  占九年总盈亏  拆出来对不对得上
 SPY   只有滑点(单边 5 个基点)   -7564.9007 -0.1323       0.0
 SPY   只有仓位差(每笔 ±10%)    4572.5774  0.0800       0.0
 SPY      只漏做赚得最多的那一笔  -11034.9390 -0.1930       0.0
 SPY 只跳过「连亏 4 笔」之后的三笔   -9121.0623 -0.1595       0.0
AAPL   只有滑点(单边 5 个基点)  -11475.9007 -0.0312      -0.0
AAPL   只有仓位差(每笔 ±10%)   -8135.5451 -0.0221      -0.0
AAPL      只漏做赚得最多的那一笔  -64022.4361 -0.1739       0.0
AAPL 只跳过「连亏 4 笔」之后的三笔    1566.2606  0.0043      -0.0
 BTC   只有滑点(单边 5 个基点)  -19148.5956 -0.0177       0.0
 BTC   只有仓位差(每笔 ±10%)  -23250.9964 -0.0215      -0.0
 BTC      只漏做赚得最多的那一笔 -217853.3122 -0.2018      -0.0
 BTC 只跳过「连亏 4 笔」之后的三笔 -275133.3341 -0.2549      -0.0
占九年总盈亏 SPY AAPL BTC
只有滑点(单边 5 个基点) −13.2% −3.1% −1.8%
只有仓位差(每笔 ±10%) +8.0% −2.2% −2.2%
只漏做赚得最多的那一笔 −19.3% −17.4% −20.2%
只跳过「连亏 4 笔」之后的三笔 −16.0% +0.4% −25.5%

「拆出来对不对得上」那一列全是 0.0——十二次对账,一分钱都没有拆丢。

⚠️ 仓位差那一行在三个标的上符号不一致(SPY 是 +8.0%),因为它是随机的:做大了还是做小了,事后看是运气。这恰恰说明它不是一个该优先修的问题——该优先修的是那些符号永远为负的

按这张表排优先级,答案很清楚:

  1. 别漏做(−16% 到 −25%)
  2. 把手续费算进回测(上一节 9.4%)
  3. 滑点(−2% 到 −13%,⚠️ SPY 那 13.2% 是因为它九年只赚了 5.7 万美元,分母太小)
  4. 仓位精度(符号都不确定)

而第 1 条不是技术问题。


十四、talab.journal 的完整代码

"""talab.journal:执行纪律与交易日志。第 33 篇。

前面三十二篇在回答同一个问题:**这条规则值不值得做。**
这一篇换一个问题:**规则已经定好了,你能不能照着做。**

两个问题的难点完全不同。前一个难在统计,后一个难在——你会想改。
而「想改」几乎总是发生在同一个时刻:**连着亏了几笔之后。**

所以这一篇的第一件事,是把「连着亏几笔」从一种感受变成一个可以算的数:

| 你想知道的 | 这里用什么算 |
|---|---|
| 连亏 7 笔正常吗 | `expected_longest`、`streak_distribution` |
| 回撤 20% 正常吗 | `shuffled`、`normal_range` |
| 连亏几笔才算策略坏了 | `streak_alarm`(先看这条规则会误报多少次)、`detection_size` |
| 连亏之后停手划不划算 | `pause_after_losses`、`pause_table` |
| 我那几次「手动调整」花了多少钱 | `interfere`、`interference_table` |
| 实盘和回测差的那一截是从哪来的 | `Entry`、`Journal`、`reconcile` |

最后一行是这一篇真正的落点。**回测和实盘的差额可以被完整地拆开**——
拆成成交价、仓位、漏做、多做、费用五块,五块之和**恰好**等于总差额,一分钱不剩。
拆不开的差额不存在;拆开之后你会发现,最大的一块通常不是滑点。
"""
from __future__ import annotations

import math
from dataclasses import dataclass, field
from statistics import NormalDist

import numpy as np
import pandas as pd

NORMAL = NormalDist()
EULER = 0.5772156649015329
SIDES = {"long": 1, "short": -1, "多": 1, "空": -1}
KINDS = ("skip_after_loss", "double_after_loss", "half_after_loss",
         "double_after_win", "cap", "widen")


# ---------------------------------------------------------------------------
# 一、连亏:正常范围是算出来的,不是感觉出来的
# ---------------------------------------------------------------------------

def streaks(results) -> pd.DataFrame:
    """把一串交易结果切成连胜段和连亏段。

    `results` 可以是收益、R 倍数或者 True/False,**大于 0 算赚**(和 `trend.r_profile` 同一个口径,
    所以正好打平的那一笔算在亏的那一边)。返回每一段的方向、起止序号和长度。
    """
    wins = pd.Series(results).to_numpy()
    wins = wins if wins.dtype == bool else wins > 0
    if len(wins) == 0:
        return pd.DataFrame(columns=["方向", "起", "止", "长度"])
    edges = np.flatnonzero(np.diff(wins)) + 1
    starts = np.concatenate([[0], edges])
    ends = np.concatenate([edges - 1, [len(wins) - 1]])
    return pd.DataFrame({"方向": np.where(wins[starts], "赚", "亏"),
                         "起": starts, "止": ends, "长度": ends - starts + 1})


def longest_streak(results, winning: bool = False) -> int:
    """最长的一段连亏(`winning=True` 时是最长连胜)有多少笔。没有这样的段就是 0。"""
    table = streaks(results)
    picked = table[table["方向"] == ("赚" if winning else "亏")]
    return int(picked["长度"].max()) if len(picked) else 0


def expected_longest(n: int, p: float) -> float:
    """`n` 次独立的下注里,概率为 `p` 的那一面**最长连续出现多少次**(Schilling 1990 的近似):

    > E[最长连击] ≈ log(n·(1−p)) ÷ log(1/p) + γ ÷ ln(1/p) − 1/2

    γ 是欧拉常数 0.5772。这个式子说的事情很朴素:**笔数越多,最长连亏越长,而且是必然变长。**
    一条胜率 33% 的策略做 70 笔,最长连亏的期望接近 9——
    **你以为的「不正常」,只是笔数攒够了。**
    """
    if not 0 < p < 1:
        raise ValueError("p 要在 0 和 1 之间")
    if n < 1:
        raise ValueError("至少要有一次下注")
    log_inv = math.log(1 / p)
    return math.log(max(n * (1 - p), 1e-12)) / log_inv + EULER / log_inv - 0.5


def streak_distribution(n: int, win_rate: float, trials: int = 10_000, seed: int = 0,
                        levels=(0.5, 0.75, 0.9, 0.95, 0.99)) -> pd.Series:
    """蒙特卡洛:胜率 `win_rate` 的策略做 `n` 笔,**最长连亏**的分布。

    `expected_longest` 给的是期望,这里给的是整条分布——
    因为你关心的从来不是「平均会连亏几笔」,而是**「我这次碰上的这个数,算不算离谱」**。
    """
    rng = np.random.default_rng(seed)
    wins = rng.random((trials, n)) < win_rate
    # 逐行求最长的一段 False:把连续的 False 累加,遇到 True 清零,取每行最大值
    longest = np.zeros(trials, dtype=int)
    current = np.zeros(trials, dtype=int)
    for k in range(n):
        current = np.where(wins[:, k], 0, current + 1)
        longest = np.maximum(longest, current)
    out = {"笔数": float(n), "胜率": float(win_rate), "平均最长连亏": float(longest.mean()),
           "公式给的期望": expected_longest(n, 1 - win_rate)}
    out.update({f"{level:.0%} 分位": float(np.quantile(longest, level)) for level in levels})
    return pd.Series(out)


def streak_alarm(n: int, win_rate: float, thresholds=(3, 4, 5, 6, 7, 8, 10, 12),
                 trials: int = 10_000, seed: int = 0) -> pd.DataFrame:
    """「连亏 k 笔就认定策略坏了」——**这条规则在策略完全正常的时候,会误报多少次。**

    这是判断任何停用规则的第一步,也是唯一一步:先问它的误报率。
    一条误报率 90% 的规则不是谨慎,它只是**保证你会在某个时刻停手**。
    """
    rng = np.random.default_rng(seed)
    wins = rng.random((trials, n)) < win_rate
    rows = []
    for k in thresholds:
        current = np.zeros(trials, dtype=int)
        hits = np.zeros(trials, dtype=int)
        for j in range(n):
            current = np.where(wins[:, j], 0, current + 1)
            hits += (current == k)                      # 只在刚好凑满 k 笔那一下记一次
        rows.append({"门槛(连亏几笔)": k, "至少响一次的概率": float((hits > 0).mean()),
                     "平均响几次": float(hits.mean())})
    return pd.DataFrame(rows)


# ---------------------------------------------------------------------------
# 二、回撤:把交易的顺序重抽一遍
# ---------------------------------------------------------------------------

def shuffled(r, trials: int = 2000, seed: int = 0) -> pd.DataFrame:
    """把同一批交易**重新排一遍顺序**,看每一种顺序下的连亏、回撤和水下时间。

    这是第 29 篇自助法的一个特例,但问的问题不一样:那里问「这条策略的夏普可信吗」,
    这里问「**同样这批交易,换个顺序发生,我会看到多难看的一段**」。

    合计和顺序无关(加法可交换),所以每一次重抽的终点都一样——
    **变的只有路径。而你会不会中途停手,只取决于路径。**

    ⚠️ 这里**一律用加法**:曲线就是每笔结果的累加,相当于「每笔都按初始本金的固定比例下注」。
    喂 R 倍数进去,回撤的单位就是 R;喂每笔的账户收益率进去,单位就是百分点。
    不用复利是故意的——要看的是顺序效应,复利会把顺序效应和「越赚越大」搅在一起。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if len(r) < 2:
        raise ValueError("至少要有两笔交易")
    rng = np.random.default_rng(seed)
    rows = []
    for _ in range(trials):
        path = rng.permutation(r)
        curve = np.cumsum(path)
        peak = np.maximum.accumulate(np.concatenate([[0.0], curve]))[1:]
        under = curve < peak
        rows.append({"最长连亏": longest_streak(path), "最深回撤": float((curve - peak).min()),
                     "最长水下(笔)": int(_longest_true(under)), "合计": float(curve[-1])})
    return pd.DataFrame(rows)


def _longest_true(flags: np.ndarray) -> int:
    longest = current = 0
    for flag in flags:
        current = current + 1 if flag else 0
        longest = max(longest, current)
    return longest


def normal_range(r, trials: int = 2000, seed: int = 0,
                 levels=(0.5, 0.9, 0.95, 0.99)) -> pd.DataFrame:
    """一张「正常范围」表:真实这一次的连亏、回撤、水下时间,各自落在重抽分布的第几分位。

    这张表是这一篇唯一的可执行结论:**上线之前把它打印出来贴在屏幕上。**
    在范围内的难看是设计的一部分,超出范围才值得重新检查——
    ⚠️ 而且「超出范围」也只是**你事先约好的降级线**,不等于统计上证明了策略失效
    (要多少笔才能证明,看 `detection_size`)。
    """
    r = pd.Series(r).dropna().astype(float)
    table = shuffled(r, trials=trials, seed=seed)
    curve = r.cumsum().to_numpy()
    peak = np.maximum.accumulate(np.concatenate([[0.0], curve]))[1:]
    real = {"最长连亏": longest_streak(r), "最深回撤": float((curve - peak).min()),
            "最长水下(笔)": int(_longest_true(curve < peak))}
    rows = []
    for name, value in real.items():
        column = table[name]
        # 回撤是负数:越小越难看,所以分位要反过来数
        worse = (column <= value).mean() if "回撤" in name else (column >= value).mean()
        row = {"这一次": value, "重抽平均": float(column.mean()),
               "比这一次还难看的比例": float(worse)}
        for level in levels:
            q = 1 - level if "回撤" in name else level
            row[f"{level:.0%} 分位"] = float(np.quantile(column, q))
        rows.append(pd.Series(row, name=name))
    return pd.DataFrame(rows)


# ---------------------------------------------------------------------------
# 三、这是正常波动,还是策略真的坏了
# ---------------------------------------------------------------------------

def detection_size(mean: float, std: float, drop: float = 1.0,
                   alpha: float = 0.05, power: float = 0.8) -> float:
    """要**多少笔**交易,才能以 `power` 的把握、在 `alpha` 的水平上,
    认出「每笔的期望值掉了 `drop` 倍」(`drop=1.0` 就是掉到 0):

    > 笔数 ≈ [ (z₁₋α + z_power) × 标准差 ÷ (期望 × drop) ]²

    这是第 29 篇「要跑多少年」的交易版本,结论也一样难看:
    **单笔收益的标准差通常是期望的好几倍,于是分母被平方一次,笔数就上了三位数。**
    连亏 7 笔提供的信息量,和这个数字比起来约等于 0。
    """
    if mean <= 0 or std <= 0 or drop <= 0:
        raise ValueError("期望、标准差、drop 都要是正数")
    z = NORMAL.inv_cdf(1 - alpha) + NORMAL.inv_cdf(power)
    return (z * std / (mean * drop)) ** 2


# ---------------------------------------------------------------------------
# 四、执行规则:连亏之后停手,值多少钱
# ---------------------------------------------------------------------------

def pause_after_losses(r, after: int = 3, pause: int = 3) -> np.ndarray:
    """「连亏 `after` 笔就停 `pause` 笔」这条规则,实际会跳过哪几笔。

    返回一串 True/False:True = 这一笔做了,False = 被规则拦下了。
    ⚠️ 被拦下的那几笔的**结果照样要记**——不然你永远不知道停手是赚了还是亏了。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if after < 1 or pause < 0:
        raise ValueError("after 至少是 1,pause 不能是负数")
    taken = np.ones(len(r), dtype=bool)
    losses = skip = 0
    for i, value in enumerate(r):
        if skip > 0:
            taken[i] = False
            skip -= 1
            continue
        losses = losses + 1 if value <= 0 else 0
        if losses >= after:
            skip, losses = pause, 0
    return taken


def pause_table(r, afters=(2, 3, 4, 5), pauses=(1, 3, 5)) -> pd.DataFrame:
    """把「连亏几笔停几笔」的各种组合摆在一起,和「一笔不落地做」比。

    `跳过的那些笔` 这一列是全表最值得看的一列:它是**你停手的那段时间里,市场发给别人的钱**。
    """
    r = pd.Series(r).dropna().astype(float)
    total = float(r.sum())
    rows = [{"连亏几笔": 0, "停几笔": 0, "做了几笔": len(r), "合计 R": total,
             "跳过的那些笔": 0.0, "最深回撤": _depth(r.to_numpy())}]
    for after in afters:
        for pause in pauses:
            taken = pause_after_losses(r, after, pause)
            kept = r.to_numpy() * taken
            rows.append({"连亏几笔": after, "停几笔": pause, "做了几笔": int(taken.sum()),
                         "合计 R": float(kept.sum()),
                         "跳过的那些笔": float(r.to_numpy()[~taken].sum()),
                         "最深回撤": _depth(kept)})
    return pd.DataFrame(rows)


def _depth(r: np.ndarray) -> float:
    curve = np.cumsum(r)
    peak = np.maximum.accumulate(np.concatenate([[0.0], curve]))[1:]
    return float((curve - peak).min()) if len(curve) else 0.0


# ---------------------------------------------------------------------------
# 五、手动调整的代价
# ---------------------------------------------------------------------------

def interfere(r, kind: str, level: float = 2.0) -> np.ndarray:
    """在一串 R 倍数上施加一次常见的「手动调整」,返回调整之后的 R。

    前四种只动**仓位**或者**做不做**,所以是精确的:

    - `skip_after_loss`:亏一笔之后,下一笔不敢做了
    - `double_after_loss`:亏一笔之后,下一笔加倍下注(想一把捞回来)
    - `half_after_loss`:亏一笔之后,下一笔只做一半(怯场)
    - `double_after_win`:赚一笔之后,下一笔加倍下注(觉得手感来了)

    后两种动的是**出场**,只能给一个方向确定的估计:

    - `cap`:浮盈到 `level` 个 R 就提前走人 → `min(r, level)`。
      ⚠️ 这是**代价的上界**:真实的提前止盈还会把一部分最终亏损的交易救成小赚,
      所以真实代价比这里算出来的小。
    - `widen`:眼看要止损,把止损往外挪,最后照样止损 → 亏的那些乘以 `level`。
      ⚠️ 这也是**上界**:挪远之后会有一部分活过来变成盈利。
      想要不含上界的那个数,就直接改引擎里的止损参数重跑一遍——两个数一夹,真相在中间。
    """
    r = pd.Series(r).dropna().astype(float).to_numpy()
    if kind not in KINDS:
        raise ValueError(f"kind 只能是 {KINDS} 之一,收到 {kind!r}")
    if kind == "cap":
        return np.minimum(r, level)
    if kind == "widen":
        return np.where(r <= 0, r * level, r)
    previous = np.concatenate([[np.nan], r[:-1]])
    after_loss, after_win = previous <= 0, previous > 0
    scale = np.ones(len(r))
    if kind == "skip_after_loss":
        scale[after_loss] = 0.0
    elif kind == "double_after_loss":
        scale[after_loss] = 2.0
    elif kind == "half_after_loss":
        scale[after_loss] = 0.5
    else:
        scale[after_win] = 2.0
    return r * scale


def interference_table(r, cap: float = 2.0, widen: float = 2.0) -> pd.DataFrame:
    """把几种手动调整一次算完:合计 R、最深回撤、以及**和「一个字不改」差了多少**。

    这张表的用法是把最后一列读成钱:每笔冒账户 1%,差 10R 就是差 10 个百分点。
    """
    r = pd.Series(r).dropna().astype(float)
    base = float(r.sum())
    names = {"skip_after_loss": "亏了之后不敢做", "double_after_loss": "亏了之后加倍",
             "half_after_loss": "亏了之后减半", "double_after_win": "赚了之后加倍",
             "cap": f"浮盈 {cap:g}R 就走", "widen": f"要止损了把止损挪远(亏的 ×{widen:g})"}
    rows = [{"怎么改的": "一个字不改", "合计 R": base, "最深回撤": _depth(r.to_numpy()),
             "差多少 R": 0.0}]
    for kind, label in names.items():
        changed = interfere(r, kind, cap if kind == "cap" else widen)
        rows.append({"怎么改的": label, "合计 R": float(changed.sum()),
                     "最深回撤": _depth(changed), "差多少 R": float(changed.sum() - base)})
    return pd.DataFrame(rows)


# ---------------------------------------------------------------------------
# 六、交易日志:一行记两遍
# ---------------------------------------------------------------------------

@dataclass
class Entry:
    """日志里的一行。核心是一件事:**计划和实际并排放。**

    只记实际成交价的日志没有用——它能告诉你赚了多少,不能告诉你**为什么和回测不一样**。
    `reconcile` 要拆的那几块,全都藏在「计划」和「实际」的差里。

    `fill_*` 留空表示「和计划完全一样」。`qty` 为 0 的一行表示**这笔计划里的交易你没做**,
    照样要记——第四节的 `pause_table` 已经说明了为什么。
    """
    time: pd.Timestamp                    # 计划进场的那一根
    symbol: str                           # 标的
    plan_entry: float                     # 计划进场价
    plan_stop: float                      # 计划止损价
    plan_qty: float                       # 计划数量
    side: str = "long"
    plan_exit: float = np.nan             # 计划出场价(回测给的)
    fill_entry: float | None = None       # 实际成交价
    fill_qty: float | None = None         # 实际数量(0 = 没做)
    fill_exit: float | None = None
    exit_time: pd.Timestamp | None = None
    fee: float = 0.0                      # 实际付出的手续费
    plan_fee: float = 0.0
    basis: str = "规则"                   # 依据:规则 / 我觉得 / 别人说的
    note: str = ""                        # 偏离计划的原因,一句话

    def __post_init__(self):
        if self.side not in SIDES:
            raise ValueError(f"side 只能是 {tuple(SIDES)} 之一,收到 {self.side!r}")
        if not self.plan_qty > 0:
            raise ValueError("计划数量要大于 0")
        if SIDES[self.side] * (self.plan_stop - self.plan_entry) >= 0:
            raise ValueError("止损放错了方向:做多的止损要低于进场价,做空的要高于")
        if self.fill_qty is not None and self.fill_qty < 0:
            raise ValueError("实际数量不能是负数")

    def actual(self, name: str):
        """实际值;没填就是和计划一样。"""
        filled = getattr(self, f"fill_{name}")
        return getattr(self, f"plan_{name}") if filled is None else filled

    def pnl(self, planned: bool = False) -> float:
        """这一行的盈亏。`planned=True` 给的是回测里那一笔。"""
        sign = SIDES[self.side]
        if planned:
            return sign * self.plan_qty * (self.plan_exit - self.plan_entry) - self.plan_fee
        return sign * self.actual("qty") * (self.actual("exit") - self.actual("entry")) - self.fee

    def row(self) -> dict:
        return {"时间": self.time, "标的": self.symbol,
                "方向": "多" if SIDES[self.side] > 0 else "空",
                "计划进场价": self.plan_entry, "实际进场价": self.actual("entry"),
                "计划止损价": self.plan_stop,
                "计划数量": self.plan_qty, "实际数量": self.actual("qty"),
                "计划出场价": self.plan_exit, "实际出场价": self.actual("exit"),
                "出场时间": self.exit_time, "计划费用": self.plan_fee, "费用": self.fee,
                "计划盈亏": self.pnl(planned=True), "实际盈亏": self.pnl(),
                "依据": self.basis, "备注": self.note}


@dataclass
class Journal:
    """一本日志:`Entry` 的列表,加上一张能直接喂给 `reconcile` 的表。"""
    entries: list = field(default_factory=list)

    def add(self, entry: Entry) -> "Journal":
        self.entries.append(entry)
        return self

    def frame(self) -> pd.DataFrame:
        columns = ["时间", "标的", "方向", "计划进场价", "实际进场价", "计划止损价",
                   "计划数量", "实际数量", "计划出场价", "实际出场价", "出场时间",
                   "计划费用", "费用", "计划盈亏", "实际盈亏", "依据", "备注"]
        return pd.DataFrame([e.row() for e in self.entries], columns=columns)

    def summary(self) -> pd.Series:
        """日志自己的体检:有多少笔没按计划走,以及**有多少笔的依据不是「规则」**。

        最后那个数是这本日志唯一不能从券商对账单里算出来的东西,
        也是记日志的全部理由——**「我觉得」这一类,只有你自己记才数得出来。**
        """
        table = self.frame()
        if not len(table):
            return pd.Series(dtype=float)
        skipped = table["实际数量"] == 0
        off_price = table["实际进场价"] != table["计划进场价"]
        off_qty = (table["实际数量"] != table["计划数量"]) & ~skipped
        return pd.Series({
            "记了几笔": float(len(table)), "没做": float(skipped.sum()),
            "成交价和计划不一样": float(off_price.sum()),
            "数量和计划不一样": float(off_qty.sum()),
            "依据不是规则": float((table["依据"] != "规则").sum()),
            "计划盈亏合计": float(table["计划盈亏"].sum()),
            "实际盈亏合计": float(table["实际盈亏"].sum()),
            "差额": float(table["实际盈亏"].sum() - table["计划盈亏"].sum()),
        })


# ---------------------------------------------------------------------------
# 七、对账:把差额拆干净
# ---------------------------------------------------------------------------

def reconcile(planned: pd.DataFrame, actual: pd.DataFrame, key: str = "买入日",
              entry_col: str = "买入价", exit_col: str = "卖出价", qty_col: str = "数量",
              fee_col: str = "费用", side_col: str = "方向") -> dict:
    """实盘和回测差的那一截,是从哪来的。

    两张表按 `key`(默认进场日)对齐,把总差额拆成五块:

    | 这一块 | 怎么算 | 说的是 |
    |---|---|---|
    | 漏做 | −Σ 回测那笔的盈亏 | 计划里有、你没做 |
    | 多做 | +Σ 实盘那笔的盈亏 | 计划里没有、你做了 |
    | 进场滑点 | Σ 方向 × 计划量 ×(计划进场价 − 实际进场价) | 买贵了 / 卖便宜了 |
    | 出场滑点 | Σ 方向 × 计划量 ×(实际出场价 − 计划出场价) | 走早了 / 走晚了 |
    | 仓位差 | Σ 方向 ×(实际量 − 计划量)×(实际出场价 − 实际进场价) | 做大了 / 做小了 |
    | 费用差 | −Σ(实际费用 − 计划费用) | 手续费和回测里假设的不一样 |

    这不是近似:**六块之和恒等于「实盘合计 − 回测合计」**(`差额核对` 那一项就是用来看这个的,
    正常应该是 0)。代数上它是把 q(x−e) 的差分三步展开,每一步只动一个量。

    ⚠️ **拆开之后最该看的是「漏做」那一格。** 滑点是几个基点的事,
    漏做一笔是整整一笔的事——而第 31 篇已经量过,一笔可以占九年收益的 14%。
    """
    def prep(table: pd.DataFrame) -> pd.DataFrame:
        out = table.copy()
        if fee_col not in out:
            out[fee_col] = 0.0
        out["_side"] = (out[side_col].map(SIDES).astype(float) if side_col in out else 1.0)
        out["_pnl"] = out["_side"] * out[qty_col] * (out[exit_col] - out[entry_col]) - out[fee_col]
        return out.set_index(key)

    left, right = prep(planned), prep(actual)
    both = left.index.intersection(right.index)
    missing, extra = left.drop(both), right.drop(both)
    p, a = left.loc[both], right.loc[both]

    sign, q_p, q_a = p["_side"], p[qty_col], a[qty_col]
    slip_in = sign * q_p * (p[entry_col] - a[entry_col])
    slip_out = sign * q_p * (a[exit_col] - p[exit_col])
    size_gap = sign * (q_a - q_p) * (a[exit_col] - a[entry_col])
    fee_gap = -(a[fee_col] - p[fee_col])
    parts = pd.Series({
        "漏做": -float(missing["_pnl"].sum()), "多做": float(extra["_pnl"].sum()),
        "进场滑点": float(slip_in.sum()), "出场滑点": float(slip_out.sum()),
        "仓位差": float(size_gap.sum()), "费用差": float(fee_gap.sum()),
    })
    total = float(right["_pnl"].sum() - left["_pnl"].sum())
    parts["合计"] = float(parts.sum())
    parts["实盘 − 回测"] = total
    parts["差额核对"] = parts["合计"] - total
    by_trade = pd.DataFrame({"进场滑点": slip_in, "出场滑点": slip_out,
                             "仓位差": size_gap, "费用差": fee_gap,
                             "这一笔差多少": a["_pnl"] - p["_pnl"]})
    return {"归因": parts, "逐笔": by_trade.sort_index(),
            "漏做的": missing.drop(columns=["_side"]), "多做的": extra.drop(columns=["_side"])}

十五、测试

"""talab.journal 的测试(第 33 篇)。交易序列全部手工构造,每个数都能自己算一遍。"""
import numpy as np
import pandas as pd
import pytest

from talab import journal as J


def frame(rows) -> pd.DataFrame:
    """一张最小的交易表,列名和第 27 篇 `backtest.run` 的输出一致。"""
    return pd.DataFrame(rows, columns=["买入日", "方向", "买入价", "卖出价", "数量", "费用"])


def test_streaks_cuts_a_sequence_into_runs():
    table = J.streaks([1.0, -1, -1, 2, 3, -1])
    assert list(table["方向"]) == ["赚", "亏", "赚", "亏"]
    assert list(table["长度"]) == [1, 2, 2, 1]
    assert list(table["起"]) == [0, 1, 3, 5]
    assert list(table["止"]) == [0, 2, 4, 5]
    assert len(J.streaks([])) == 0


def test_a_breakeven_trade_counts_as_a_loss():
    # 口径和 trend.r_profile 一致:大于 0 才算赚,正好打平算在亏的那一边
    assert J.longest_streak([-1, 0.0, -1, 5]) == 3
    assert J.longest_streak([-1, 0.0, -1, 5], winning=True) == 1
    assert J.longest_streak([1, 2, 3]) == 0                   # 一笔没亏过


def test_expected_longest_matches_the_monte_carlo():
    # 抛 200 次硬币,最长连续正面的期望:公式和一万次模拟应该对得上
    formula = J.expected_longest(200, 0.5)
    simulated = J.streak_distribution(200, 0.5, trials=5000, seed=1)["平均最长连亏"]
    assert abs(formula - simulated) < 0.3
    # 笔数翻一倍,最长连亏只多出 log2(2) = 1 笔左右——**它随笔数增长得很慢,但一定在长**
    assert 0.8 < J.expected_longest(400, 0.5) - formula < 1.2


def test_a_long_losing_streak_is_ordinary_when_the_win_rate_is_low():
    out = J.streak_distribution(70, 0.33, trials=5000, seed=2)
    assert out["平均最长连亏"] > 8                             # 胜率 33% 做 70 笔,期望就有八九笔
    assert out["90% 分位"] >= 11                               # 连亏 11 笔以上也在正常范围里
    assert out["50% 分位"] <= out["95% 分位"]


def test_streak_alarm_tells_you_the_false_alarm_rate():
    table = J.streak_alarm(70, 0.33, thresholds=(4, 8, 15), trials=3000, seed=3)
    probability = table.set_index("门槛(连亏几笔)")["至少响一次的概率"]
    assert probability.loc[4] > 0.98                           # 「连亏 4 笔就停」几乎必然会响
    assert probability.loc[4] > probability.loc[8] > probability.loc[15]
    assert (table["平均响几次"] >= table["至少响一次的概率"]).all()


def test_shuffling_keeps_the_total_and_changes_only_the_path():
    r = pd.Series([3.0, -1, -1, -1, 5, -1, -1, 2, -1, -1])
    table = J.shuffled(r, trials=200, seed=4)
    assert np.allclose(table["合计"], r.sum())                 # 加法可交换:终点永远一样
    assert table["最深回撤"].nunique() > 1                     # 但路径不一样
    assert (table["最深回撤"] <= 0).all()
    assert table["最长连亏"].max() >= 5                        # 六笔亏损挤到一起是可能的
    with pytest.raises(ValueError):
        J.shuffled([1.0])


def test_normal_range_locates_this_run_in_the_distribution():
    r = pd.Series([2.0, -1, -1, -1, -1, 4, -1, -1, 3, -1])
    table = J.normal_range(r, trials=300, seed=5)
    assert list(table.index) == ["最长连亏", "最深回撤", "最长水下(笔)"]
    assert table.loc["最长连亏", "这一次"] == 4
    assert 0 <= table.loc["最深回撤", "比这一次还难看的比例"] <= 1
    # 回撤那一行的分位是反着数的:99% 分位比 50% 分位更深(更负)
    assert table.loc["最深回撤", "99% 分位"] <= table.loc["最深回撤", "50% 分位"]


def test_detection_size_grows_with_the_square_of_the_noise():
    small = J.detection_size(mean=0.5, std=3.0)
    assert J.detection_size(mean=0.5, std=6.0) == pytest.approx(small * 4)
    assert J.detection_size(mean=1.0, std=3.0) == pytest.approx(small / 4)
    # 只想认出「掉了一半」,需要的笔数是「掉到 0」的四倍
    assert J.detection_size(0.5, 3.0, drop=0.5) == pytest.approx(small * 4)
    with pytest.raises(ValueError):
        J.detection_size(mean=-0.1, std=1.0)


def test_pause_after_losses_skips_exactly_the_right_trades():
    r = [-1.0, -1, 5, -1, -1, 3, 4, -1]
    taken = J.pause_after_losses(r, after=2, pause=2)
    # 前两笔连亏触发规则 → 跳过第 3、4 笔(其中第 3 笔是 +5R,这就是停手的代价)
    assert list(taken) == [True, True, False, False, True, True, True, True]
    assert list(J.pause_after_losses(r, after=2, pause=0)) == [True] * 8
    with pytest.raises(ValueError):
        J.pause_after_losses(r, after=0)


def test_pause_table_accounts_for_every_trade():
    r = pd.Series([-1.0, -1, 5, -1, -1, 3, 4, -1, -1, -1, 6])
    table = J.pause_table(r, afters=(2, 3), pauses=(1, 2))
    assert table.iloc[0]["合计 R"] == pytest.approx(r.sum())
    # 做了的 + 跳过的 = 全部,一笔都不许丢
    assert np.allclose(table["合计 R"] + table["跳过的那些笔"], r.sum())
    assert (table["做了几笔"] <= len(r)).all()


def test_interfere_only_touches_the_trade_after_the_trigger():
    r = [-2.0, 4, -1, 6]
    assert list(J.interfere(r, "skip_after_loss")) == [-2, 0, -1, 0]
    assert list(J.interfere(r, "double_after_loss")) == [-2, 8, -1, 12]
    assert list(J.interfere(r, "half_after_loss")) == [-2, 2, -1, 3]
    assert list(J.interfere(r, "double_after_win")) == [-2, 4, -2, 6]
    # 第一笔永远不变:它前面没有交易
    assert all(J.interfere(r, kind)[0] == -2 for kind in ("double_after_loss", "double_after_win"))
    with pytest.raises(ValueError):
        J.interfere(r, "去掉最差的那笔")


def test_cap_and_widen_are_both_upper_bounds_on_the_damage():
    r = np.array([-2.0, 4, -1, 6, 0.5])
    assert list(J.interfere(r, "cap", 2.0)) == [-2, 2, -1, 2, 0.5]
    assert list(J.interfere(r, "widen", 2.0)) == [-4, 4, -2, 6, 0.5]   # 赚的那几笔一个字不动
    # 两种改法都只会让每一笔变差或者不变,所以算出来的代价是上界
    assert (J.interfere(r, "cap", 2.0) <= r).all()
    assert (J.interfere(r, "widen", 2.0) <= r).all()


def test_entry_rejects_a_stop_on_the_wrong_side():
    ok = dict(time=pd.Timestamp("2024-01-02"), symbol="BTC", plan_entry=100.0,
              plan_stop=95.0, plan_qty=2.0, plan_exit=110.0)
    assert J.Entry(**ok).pnl(planned=True) == pytest.approx(20.0)
    with pytest.raises(ValueError):
        J.Entry(**{**ok, "plan_stop": 105.0})                  # 做多的止损放到了进场价上面
    with pytest.raises(ValueError):
        J.Entry(**{**ok, "plan_qty": 0.0})
    with pytest.raises(ValueError):
        J.Entry(**{**ok, "side": "两边都做"})
    # 做空:止损在上面才对
    short = J.Entry(**{**ok, "side": "short", "plan_stop": 105.0, "plan_exit": 90.0})
    assert short.pnl(planned=True) == pytest.approx(20.0)


def test_journal_counts_the_rows_you_did_not_follow():
    book = J.Journal()
    common = dict(symbol="SPY", plan_entry=100.0, plan_stop=95.0, plan_qty=10.0, plan_exit=110.0)
    book.add(J.Entry(time=pd.Timestamp("2024-01-02"), **common))                    # 完全照做
    book.add(J.Entry(time=pd.Timestamp("2024-02-02"), fill_entry=101.0, **common))  # 买贵了
    book.add(J.Entry(time=pd.Timestamp("2024-03-02"), fill_qty=0.0,
                     basis="我觉得", note="连亏三笔,这次不敢做", **common))          # 干脆没做
    out = book.summary()
    assert out["记了几笔"] == 3 and out["没做"] == 1
    assert out["成交价和计划不一样"] == 1 and out["依据不是规则"] == 1
    assert out["计划盈亏合计"] == pytest.approx(300.0)
    assert out["实际盈亏合计"] == pytest.approx(100.0 + 90.0 + 0.0)
    assert out["差额"] == pytest.approx(-110.0)
    assert list(book.frame().columns)[:3] == ["时间", "标的", "方向"]


def test_reconcile_splits_the_gap_into_pieces_that_add_up_exactly():
    planned = frame([
        [pd.Timestamp("2024-01-01"), "多", 100.0, 110.0, 10.0, 1.0],    # +99
        [pd.Timestamp("2024-02-01"), "空", 200.0, 190.0, 5.0, 2.0],     # +48
        [pd.Timestamp("2024-03-01"), "多", 100.0, 90.0, 4.0, 0.0],      # −40,实盘没做
    ])
    actual = frame([
        [pd.Timestamp("2024-01-01"), "多", 101.0, 109.0, 12.0, 2.0],    # 买贵、走早、做大
        [pd.Timestamp("2024-02-01"), "空", 201.0, 191.0, 5.0, 2.0],
        [pd.Timestamp("2024-04-01"), "多", 100.0, 105.0, 3.0, 0.0],     # 计划里没有
    ])
    out = J.reconcile(planned, actual)
    parts = out["归因"]
    assert parts["实盘 − 回测"] == pytest.approx(50.0)
    assert parts["差额核对"] == pytest.approx(0.0, abs=1e-9)             # 拆得干干净净
    assert parts["漏做"] == pytest.approx(40.0)                          # 躲过一笔亏损,反而是加分项
    assert parts["多做"] == pytest.approx(15.0)
    assert parts["进场滑点"] == pytest.approx(-5.0)
    assert parts["出场滑点"] == pytest.approx(-15.0)
    assert parts["仓位差"] == pytest.approx(16.0)
    assert parts["费用差"] == pytest.approx(-1.0)
    assert len(out["漏做的"]) == 1 and len(out["多做的"]) == 1
    assert out["逐笔"]["这一笔差多少"].sum() == pytest.approx(-5.0)      # 两笔共同的交易差了多少


def test_reconcile_is_exact_on_random_tables():
    # 随机造两张表,六块之和必须永远等于总差额——这是代数恒等式,不是近似
    rng = np.random.default_rng(33)
    days = pd.date_range("2024-01-01", periods=40, freq="D")
    def build(pick):
        return frame([[days[i], "多" if i % 3 else "空", 100 + rng.normal(),
                       100 + rng.normal(0, 5), 1 + rng.random() * 9, rng.random()]
                      for i in pick])
    planned, actual = build(range(0, 30)), build(range(10, 40))
    parts = J.reconcile(planned, actual)["归因"]
    assert parts["差额核对"] == pytest.approx(0.0, abs=1e-9)
    assert parts["合计"] == pytest.approx(parts["实盘 − 回测"])
16 passed in 0.23s
351 passed in 0.92s

没装 TA-Lib 的环境里:

319 passed, 32 skipped in 1.29s

十六、小检查

  1. 一条胜率 40% 的策略做 100 笔。用第二节那个公式估一估,它在这 100 笔里「最长的一段连亏」大概是几笔?
  2. 第二节那张误报率表里,「连亏 6 笔就停」在九年里至少响一次的概率是 92.2%。这个数字是在什么前提下算出来的?它能不能用来说明「这条策略很危险」?
  3. 主线 v4 在 SPY 上的历史最大回撤是 9.44 个百分点,而重排之后的 95% 分位是 26.24 个百分点。你在上线之前该按哪个数准备资金?为什么两个数会差这么多?
  4. 「一开始就把止损放到 3N」和「眼看要被止损时把止损挪到 3N」,在账户层面最关键的区别是哪一个量?
  5. 一次对账拆出来的五块里,「仓位差」是 +8,437 美元(正的)。这能说明你的仓位管理比回测做得好吗?

十七、常见误用

把连亏当成「策略失效」的证据。第二节那张表是专门用来堵这条路的:一条胜率 32.9% 的策略做 70 笔,「连亏 7 笔」在策略完全正常的前提下也有 79.2% 的概率至少发生一次,最长连亏的期望本来就是 8.82 笔。而第六节给出了代价:真要在统计上认出「期望值掉到 0」,SPY 上的海龟需要 84 笔、11.2 年你手上那几笔连亏携带的信息量,在这个尺度面前约等于零。

用「连亏之后暂停」来控制回撤。第七节量过十二种组合,十一种是亏的,最狠的一种把 BTC 九年从 295.93R 砍到 143.81R。回撤确实变浅了(最浅的一行 −10.72R),但那是因为你交易得更少——想要同样的效果,把每笔风险从 1% 改成 0.5% 更直接、代价更小。⚠️ 而第十二节展示了真正的账单:那条规则精确地挡住了九年里赚得最多的三笔。

**把「亏了之后加倍」在历史上赚钱,读成它有道理。**第九节的表里这一行是 +220.12R,看上去很诱人。它赚的唯一原因是这条策略每笔的期望本来就是正的——**把任何一组交易加倍都会赚更多。**真正的代价在旁边那一列:**最深回撤从 −18.89R 变成 −36.00R。**加倍不增加优势,它只增加杠杆,而且是在账户最虚弱的时刻加。

**把回测报告上的最大回撤当成上限。**第五节里,主线 v4 在 SPY 上的历史回撤只有 9.44 个百分点,而 98% 的重排比它更难看,95% 分位是 26.24。最大回撤是一个极值统计量,只由一条路径里的一小段决定——它是一个样本,不是一个上限。三个标的六条策略,没有一条的真实回撤触到过自己的 95% 分位。

以为「止损放远一点」和「临时挪止损」差不多。第十节把两者分开量了:SPY 上一开始就用 3N 止损,年化从 4.68% 升到 4.85%、回撤从 −17.11% 降到 −10.25%;而同一个账户上临时挪远,合计 R 从 47.50 掉到 4.93。区别在仓位——事先放远会让仓位自动变小、每笔风险不变;临时挪远时仓位已经定死了,风险直接翻倍,而且只在你正在亏钱的那些笔上翻

只记实际成交价的日志。券商对账单已经把「实际」那一半给你了。第十一节那张表里唯一算不出来的是这一格——「依据不是规则」——它是整本日志的全部理由。少了「计划」那一列,第十二节的五块归因一块也拆不出来。

**把精力全花在滑点上。**第十二节的九年对账里,进场加出场滑点一共 14,408 美元,占总差额的 4.6%漏做那一格是 275,133 美元,占 88.7%,是滑点的 19 倍。第十三节把它换算成了同一把尺子:BTC 上漏做最赚的那一笔,等于九年每一笔都多付 56.9 个基点。

**拿「每日最大亏损」当保险。**第七节量过:SPY 上「单日亏 3% 就停 5 天」九年只触发 3 次真正被砍掉的只有 2 天(触发那天止损已经平仓了);「单日亏 5%」九年一次都没响过。BTC 上触发得多,结果是年化从 31.60% 掉到 27.49%,而最大回撤只从 −21.56% 动到 −21.43%——因为那次回撤是十二笔交易慢慢磨出来的,不是哪一天砸出来的。每日亏损上限是给日内设计的。


十八、小结

  • **连亏的长度是算得出来的。**胜率 p 的策略做 n 笔,最长连亏的期望 ≈ log(n(1−p))/log(1/p) + γ/ln(1/p) − 1/2。BTC 海龟九年 70 笔、胜率 32.9%,期望就是 8.82 笔,90% 分位 12 笔,99% 分位 18 笔。你以为的「不正常」,通常只是笔数攒够了。
  • 任何停用规则的第一步是算它的误报率。「连亏 7 笔就停」在策略完全正常时有 79.2% 的概率会响。一条误报率 79% 的规则不叫谨慎,它只是保证了你一定会停手。
  • **回撤和水下时间没有公式,但可以重排。**同一批交易换个顺序,合计一个字不变,路径完全不同。BTC 海龟真实的 −18.89R 落在第 88 个百分位,95% 分位是 −24.25R
  • ⚠️ **回测报告上的最大回撤是一个样本,不是一个上限。**主线 v4 在 SPY 上的 −9.44 个百分点,比 98% 的重排都浅。三个标的六条策略,没有一个的真实回撤触到过自己的 95% 分位
  • **「策略真的坏了」认不出来。**要以 80% 的把握认出期望值掉到 0,SPY 海龟要 84 笔(11.2 年)、SPY 主线 v4 要 193 笔(44.5 年)。剩下能做的只有三件:事先算好正常范围、事先约定降级线、检查机制(方差比)还在不在。
  • 连亏之后停手是亏的。三个标的 × 四条规则,十二组里 十一组是亏的;BTC 上最狠的一条砍掉一半收益。被跳过的交易合计全是正数
  • **原因是结构性的,不是运气。**大赚的交易必然出现在趋势的开头,而趋势的开头必然紧跟在震荡后面——**而震荡正是连亏的来源。**连亏和大赚是相邻的。九年对账里那条规则挡住的三笔,正是赚得最多的三笔。
  • 手动调整的价目表(BTC 海龟,九年 295.93R):浮盈 2R 就走 −326.60R(九年倒亏)、亏了之后不敢做 −220.12R、临时挪止损 −75.44R。「亏了之后加倍」+220.12R 是个陷阱——它把最深回撤从 −18.89R 变成 −36.00R
  • **交易日志的核心是「计划和实际并排放」。**只记实际的日志算不出归因,也数不出有多少笔写的其实是「我觉得」。
  • 实盘和回测的差额可以被完整拆开:漏做、多做、进场滑点、出场滑点、仓位差、费用差,六块之和恒等于总差额(代数恒等式,差额核对 必须是 0)。九年 −310,175 美元的差额里,漏做占 88.7%,滑点只占 4.6%
  • 按这张表排优先级:别漏做 > 把手续费算进回测 > 滑点 > 仓位精度。⚠️ 而第一条不是技术问题——它就是这一篇从头到尾在讲的那件事。

下一篇(第 34 篇)把这些串成一条流程:回测 → 模拟盘 → 小资金实盘 → 逐步加资金,每一步的升级条件和降级条件,以及上线之前就得写死的停用规则。这一篇算出来的那张「正常范围表」,就是那些条件的输入。


练习

  1. expected_longest 算一算:胜率 50%、60%、70% 的策略各做 200 笔,最长连亏的期望分别是多少?把三个数画在一张图上,横轴是胜率。你会发现它在胜率很低的那一段涨得特别快——解释一下为什么。
  2. streak_alarm 用在你自己关心的一条策略上(或者用第 32 篇的均值回归,胜率 70%–76%)。找出一个「误报率低于 10%」的连亏门槛。它是几笔?这个门槛实用吗?
  3. normal_range 默认重抽 2000 次。改成 200 次和 20000 次各跑一遍,看 99% 分位那一列抖动多大。分位越靠尾部,需要的重抽次数越多——给出一个你能接受的次数,并说明理由。
  4. 第五节发现主线 v4 走过的是一条偏幸运的路径。把三个标的六条策略的「比这一次还难看的比例」列成一张表,看看有没有哪一条是偏不幸的(比例低于 20%)。如果一条策略的历史路径特别不幸,这对你的期待意味着什么?
  5. pause_after_losses 现在是「连亏 N 笔停 M 笔」。改写一个 pause_after_drawdown(r, depth, pause):回撤超过 depth 个 R 就停 pause 笔。在三个标的上量一遍——它比按连亏停更好还是更差?为什么?
  6. interfere 里的 cap 是「代价的上界」。用第 27 篇的事件驱动引擎把真正的提前止盈跑出来(backtest.Plan(target_r=2.0)),和 cap 的结果比一比,看上界高估了多少。
  7. reconcile 加一个新的归因项:时间差。实盘比回测晚一根 K 线进场时,这一块该怎么算,才能保证六块之和仍然恰好等于总差额?(提示:先写出这一笔盈亏的表达式,再看你新加的量该从哪一步里分出来。)

小检查答案

  1. 大约 5 笔。代进公式:p(亏的概率)= 0.6,n = 100。log(100 × 0.4) / log(1/0.6) = log(40)/log(1.667) = 3.689/0.511 = 7.22;γ/ln(1/0.6) = 0.577/0.511 = 1.13;再减 0.5。合计约 7.85 笔。⚠️ 如果你算成 5,多半是把 p 当成了胜率——公式里的 p 是亏的概率。顺带一提,胜率 40% 比胜率 32.9% 只高了 7 个百分点,最长连亏的期望就从 8.82 掉到 7.85,而笔数还多了 30 笔。
  2. 前提是「策略完全正常」。那张表里的每一个数都是用「胜率恒为 32.9% 的独立下注」模拟出来的,里面没有任何「策略变坏」的成分。所以 92.2% 说的是误报率,它不能用来说明策略危险——**它说明的是这条规则危险。**一条在正常情况下就有九成概率会响的警报,响了之后不携带任何信息。
  3. 按 26.24 准备。两个数差这么多,是因为最大回撤是一个极值统计量:它只由整条路径里最难看的那一小段决定,所以对交易发生的顺序极其敏感。39 笔交易的顺序有天文数字种可能,历史只实现了其中一种,而这一种恰好比 98% 的其他顺序都温和。⚠️ 注意 26.24 也不是上限,它只是 95% 分位——99% 分位是 31.71。
  4. **仓位。**按第 26 篇的固定风险公式,仓位 = 账户 × 风险比例 ÷ 止损距离。一开始就把止损放到 3N,止损距离变大 → 仓位自动变小 → 每笔的风险仍然是账户的 1%。而临时挪远时仓位在进场那一刻已经定死,止损距离变大 → 每笔风险直接翻倍。前者是参数选择(SPY 上还更好:年化 4.85% vs 4.68%),后者是执行失误(SPY 上 47.50R → 4.93R)。
  5. **不能。**仓位差是随机的:第十三节那张表里,同一条「每笔 ±10%」的扰动在 SPY 上是 +8.0%、在 AAPL 和 BTC 上都是 −2.2%。**做大了还是做小了,事后看是运气。**这恰恰说明它不该被优先处理——该优先修的是那些符号永远为负的(滑点、费用、漏做)。一个符号随机的偏差项,正号那一次不是成绩,负号那一次也不是失误。