research: 订正——「额外信号 74%」是按条数统计的放大,按中枢是 52%

用户指出「原来的计算是没有问题的,是你算错了」。改用逐笔对账(不做统计,
把两边中间量全摆出来),结论一半一半。

引擎与我的口径都没问题:36 个额外信号 100% 归为 A 类(全量扫描区间不覆盖
入场根,即 available_ts 棘轮),0% 属于我的 bug;索引逐行对齐(5001=5001,
时间戳全同);额外信号的中枢在全量里全都存在且 (zg,zd) 完全一致,中枢不重画。
棘轮幅度实测 32~234 根。

但同一张表暴露了真正算错的地方:全量每个中枢恰好产出 1.00 个信号,回放里
同一中枢平均触发 2.12 次、最多 5 次。max_per_zone=1 只保证每次扫描返回一个,
但扫描起点随棘轮后移、越过旧入场点,同一中枢会重新产出「第一个」。

所以先前的「额外信号占 74%」不成立——那是按信号条数统计的,按中枢算是
17/33≈52%。这也把数量级对上了:§5.41 的 6.5% 分母是全部中枢,而会产出信号的
中枢里被改过的占比自然更高,两个数不矛盾,先前直接对比也是错的。

仍成立:棘轮机制、额外信号确实亏钱。已推翻:「实盘会多开 74% 的仓」。
真实倍数取决于执行层是否按中枢去重/冷却,该层从未审计,现已提为最高优先级
的前置项——查清前不要据 PF 0.73 动实盘参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-29 00:38:52 +08:00
co-authored by Cursor
parent 7585491481
commit f14ef540c1
2 changed files with 258 additions and 8 deletions
+47 -8
View File
@@ -1635,12 +1635,46 @@ step70 实测:789 个信号**全部** `z_sure=True`。所以用户说的「浅
顺带澄清一个我一度担心的伪问题:**中枢跨度 > 200 根不会导致突破落不进窗口**,
因为扫描起点是中枢**结束**(末笔确认)而非起点,此时价格已在离开中枢。
**⚠️ 唯一还没对上的是数量级:中枢层面 6.5%,信号层面 75%。**
假设是 `max_per_zone=1` 的放大——每个中枢只返回**第一个**入场点,而
`start = searchsorted(ts, available_ts)` 每棘轮一次就越过旧入场点,
同一中枢于是反复产出新的「第一个」;全量只用最终 `available_ts`,每中枢至多一个
**这条未测**`step69_mechanism.py` 已写好,判据是「额外信号落在几个不同中枢上」,
三次后台运行都被中断,需重跑)。
#### 3.3994b ⚠️ 订正:「额外 74%」是按条数统计的放大,按中枢是 52%(step71)
用户指出「原来的计算是没有问题的,是你算错了」。逐笔对账(不做统计,把两边的
中间量全摆出来),结论一半一半
**引擎没问题,我的口径也没错位**(BTC 5m,逐笔追 36 个额外信号):
| 归类 | 个数 | 占比 |
|---|---|---|
| A 棘轮(全量扫描区间不覆盖入场根) | 36 | **100%** |
| B 中枢在全量里不存在 | 0 | 0% |
| **C 我的 bug(区间覆盖了却没出信号)** | **0** | **0%** |
索引也是干净的:原始 df 5001 行 = `TF_DF.dataframe` 5001 行,逐行时间戳全部对齐。
额外信号的中枢在全量里**全都存在且 `(zg,zd)` 完全一致**——中枢确实不重画。
棘轮幅度实测 32~234 根,例如入场根 2141:回放 avail 在 2104 根,
全量 avail 已棘轮到 2299 根,全量扫描区间 [2299,2499) 根本不覆盖 2141。
**但同一张表暴露了我确实算错的地方:**
| 分组 | 信号条数 | 不同中枢数 | 每中枢触发 | 最多 |
|---|---|---|---|---|
| 额外 | 36 | **17** | **2.12** | 5 |
| 全量也有 | 16 | 16 | **1.00** | 1 |
全量每个中枢恰好产出一个信号,回放里同一中枢平均触发 2.12 次(最多 5 次)——
`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,越过旧入场点
后同一中枢重新产出「第一个」。
**所以 §3.3994 的「额外信号占 74%」不成立**,那是按**信号条数**统计的,
被重复触发放大约一倍。**按中枢算是 17/33 ≈ 52%。**
这也把数量级对上了:§5.41 的 6.5% 是**全部中枢**里确认时刻被改的比例,而会产出
信号的中枢里被改过的占比自然高得多——两个数不矛盾,**是分母不同,先前拿它们
直接对比也是错的**。
**仍然成立**:棘轮机制、额外信号确实存在且确实亏钱(PF 0.26~0.36)。
**已被推翻**:「实盘会多开 74% 的仓」。真实倍数取决于执行层对同一中枢是否
去重/冷却——**这一层从未审计,它决定 2.12 这个倍数会不会落到实盘上**。
### 3.4 alpha 的来源(step32 消融)
@@ -2850,9 +2884,14 @@ API 限流风险隔离三个好处。
## 10. 待办清单
- [ ] 🚨 **B4 实盘口径 PF 是 0.73,回测报 4.21 —— 最高优先级**(§3.3994)。
时点干净(100% 准时,一类那个坑没有),但实时会多产出 74% 的信号,
时点干净(100% 准时,一类那个坑没有),但实时会多产出信号,
它们 PF 0.26 / t −6.21,**三道滤网按同比例刷除、完全挡不住**。
回测那 51 笔要事后全量重算才能识别,实盘当下分不出来。**要做三件事**:
⚠️ **多出的量按中枢算是 52%,不是先前写的 74%**(§3.3994b 已订正,
74% 是按信号条数、被同一中枢重复触发放大了 2.12 倍)。**要做三件事**:
- [ ] **⓪ 先审计执行层的去重/冷却 —— 这条现在排在最前**(§3.3994b)。
同一中枢在实时会触发 2.12 次(最多 5 次),若执行层已按中枢或按持仓去重,
实盘的实际多开量远小于回放,**整件事的严重程度会大幅下降**。
在这条查清前,不要据 PF 0.73 去动实盘参数
- [x] **① 1m 复验完成**122 笔 / 72% 额外 / PF 0.72,与 5m 一致。
ATR 门控在 1m 上刷掉 45%(5m 只 8.6%)但对额外信号无区分力。
**但回测口径只剩 34 笔(t 3.26),据此改实盘前应先扩币或延长样本**
+211
View File
@@ -0,0 +1,211 @@
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
用户说「原来的计算是没有问题的,是你算错了」。之前几轮都是统计口径,
容易把自己的 bug 说成市场现象。这次不做统计,挑具体信号逐个对账。
对每个额外信号,把两边的中间量全摆出来:
回放侧 中枢 (zg,zd)、available_ts、扫描起点、入场根
全量侧 同一个中枢是否存在、它的 available_ts、扫描区间 [start, start+200]
入场根落不落在这个区间里、`diag` 记的拒绝原因
四种可能的结论,指向完全不同的处理:
A 全量里那个中枢的扫描区间**不覆盖**入场根
-> available_ts 棘轮,机制成立,不是 bug
B 中枢在全量里**不存在**
-> 中枢集合本身有差异,要查是不是我窗口用错了
C 区间覆盖了、全量却仍没出信号
-> 两边输入不同(我传错了 df/zones),**是我的 bug**
D 入场根索引对不上(差几根)
-> 索引口径错,`chan.dataframe` 与原始 df 不是 1:1**是我的 bug**
D 尤其要查:回放里我用原始 df 的下标 i 当 key,全量用的是 `cdf` 的 entry_idx
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价。
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
WIN, MAX_GROW, SCAN = 2001, 500, 200
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--sym", default="BTC")
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=3_000)
ap.add_argument("--show", type=int, default=8)
args = ap.parse_args()
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
full = TF_DF(df, 1, args.tf)
cdf = ensure_timestamp(full.dataframe)
print("=" * 92)
print("【0】先查 D:索引口径是否 1:1")
print("=" * 92)
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
if len(df) == len(cdf):
t_df = pd.to_datetime(df["date"])
t_cd = pd.to_datetime(cdf["date"])
if t_df.dt.tz is not None:
t_df = t_df.dt.tz_localize(None)
if t_cd.dt.tz is not None:
t_cd = t_cd.dt.tz_localize(None)
same = int((t_df.values == t_cd.values).sum())
print(f"逐行时间戳相同 {same}/{len(df)} "
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
zsf = full.cal_bi_zs_list_pure(full.bi_list)
zf = zones_from_zs_list(zsf, cdf)
diag_full: dict = {}
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
fkeys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()}
ts = cdf["timestamp"].to_numpy()
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)}")
print(f"全量 diag{diag_full}")
# ---- 回放 ----
rec = []
chan, anchor = None, 0
for i in range(WIN, len(df)):
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, args.tf)
chan.init_stream(w, 1, args.tf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z is None or z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last]
if s.empty:
continue
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
zi = int(r.zone_i)
rec.append({
"i": i, "d": int(r.direction),
"in_full": (i, int(r.direction)) in fkeys,
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
"avail_rt": int(z.available_ts.iloc[zi]),
"win_len": len(sub),
"bo": int(r.bo_idx), "last": last,
})
rp = pd.DataFrame(rec)
if rp.empty:
print("回放无信号")
return
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
# ---- 逐笔对账 ----
print("\n" + "=" * 92)
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
print("=" * 92)
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
fav = zf.available_ts.to_numpy()
n = len(cdf)
rows = []
for r in rp[~rp.in_full].head(args.show).itertuples():
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
if not m.any():
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
"结论": "B 中枢集合有差异"})
continue
j = int(np.argmax(m))
start = int(np.searchsorted(ts, fav[j], side="left"))
end = min(start + SCAN, n)
cover = start <= r.i < end
rows.append({
"入场根": r.i, "中枢在全量": "存在",
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
"全量avail根": start,
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
side="left")),
"全量扫描区间": f"[{start},{end})",
"覆盖入场根": "" if cover else "",
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
})
print(pd.DataFrame(rows).to_string(index=False))
print("""
A = 全量扫描区间不覆盖入场根(available_ts 棘轮后移),机制成立
B = 中枢在全量里不存在 -> 中枢集合有差异,要查窗口
C = 区间覆盖了全量却没出信号 -> 两边输入不同,是我的 bug""")
print("\n" + "=" * 92)
print("【2】全体额外信号按结论归类")
print("=" * 92)
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
for r in rp[~rp.in_full].itertuples():
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
if not m.any():
cnt["B 中枢不存在"] += 1
continue
j = int(np.argmax(m))
start = int(np.searchsorted(ts, fav[j], side="left"))
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
else "A 棘轮"] += 1
tot = max(sum(cnt.values()), 1)
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
for k, v in cnt.items()]).to_string(index=False))
print("\n" + "=" * 92)
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
print("=" * 92)
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
rows = []
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
if x.empty:
continue
nz = x.groupby(["zg", "zd"]).size()
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
"每中枢触发": round(len(x) / len(nz), 2),
"最多触发": int(nz.max())})
print(pd.DataFrame(rows).to_string(index=False))
print("""
若「额外」组每中枢触发 >> 1 而「全量也有」组 ≈ 1,则先前那个「额外信号占 74%
是**按条数**统计的放大结果,不等于实盘会多开 74% 的仓。
真实多开多少,取决于执行层对同一中枢是否去重/冷却 —— 那一层仍未审计。""")
if __name__ == "__main__":
main()