diff --git a/research/HANDOFF.md b/research/HANDOFF.md index 1e2df75..fc97da0 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -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),据此改实盘前应先扩币或延长样本** diff --git a/research/step71_trace.py b/research/step71_trace.py new file mode 100644 index 0000000..5fbe2d7 --- /dev/null +++ b/research/step71_trace.py @@ -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()