"""二类买卖点:它只是一类的延迟版,还是一类里被确认过的那个子集? 用户提出的悖论:二类按定义是「一买之后回调、再继续趋势」,可一类已经证否了, 二类凭什么会好? 代码上悖论成立——`find_all_bsp` 里 B2 确实被 B1 门控(`if first_bsp_bi_div`)。 但 B2 比 B1 多要求两件事:价格**确实反弹了**(bounce_bi 向上), 且回踩**守住了** B1 的低点(`second_bsp_bi.end_klc.low > leave_bi.end_klc.low`)。 **这两条正是「那个底是真的」的事后确认。** 而 §3.397 的诊断恰恰是:一类只有 30% 落在真反转上,那 30% 的 PF 是 0.70~0.83, 另外 70% 是 0.08。所以「按确认筛掉假底」正是一类缺的东西。 于是悖论变成一个可测的问题: B2 命中线段顶点的比例,是否显著高于 B1 的 30%? 是 -> B2 是 B1 的**已验证子集**,悖论解除,值得继续查 否 -> B2 只是 B1 的延迟版,用户的悖论成立,直接关掉 同时量三件与一类可比的东西(口径完全对齐,才能横向比): 几何 入场价到结构止损位有多远(一类是 2.90 ATR,止损落在结构内侧 88.6%) 波动 atr_z(一类 1.22 偏高,二类 0.94 偏低,画像相反) 反手 一类反手在全量口径上曾看似很好,二类是否也有这个现象 """ from __future__ import annotations import argparse import sys import warnings from concurrent.futures import ProcessPoolExecutor, as_completed 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)) OUT = HERE / "out" / "step64_b2.feather" SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 BASE_WIN, TOL = 200, 2 def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None: from chanlun import TF_DF from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR from lib.data import fetch_ohlcv from lib.exit_model import cfg_name, walk_exits try: df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows) if df is None or len(df) < 5_000: return None chan = TF_DF(df, 1, tf, lean=False) cdf = chan.dataframe bz = chan.cal_bi_zs_list_pure(chan.bi_list) if not bz: return None bsp = chan.find_all_bsp(chan.bi_list, bz) or [] dser = pd.to_datetime(cdf["date"]) if dser.dt.tz is not None: dser = dser.dt.tz_localize(None) didx = pd.DatetimeIndex(dser) n = len(cdf) def to_i(ts) -> int: t = pd.Timestamp(ts) return int(didx.searchsorted(t.tz_localize(None) if t.tz else t)) atr = cdf["atr"].to_numpy(float) cl = cdf["close"].to_numpy(float) op = cdf["open"].to_numpy(float) base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50) .median().shift(1).to_numpy()) seg_bot, seg_top = [], [] for sg in getattr(chan, "seg_list", []) or []: if sg.end_time is None: continue i = to_i(sg.end_time) if 0 <= i < n: (seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i) if not seg_bot or not seg_top: return None truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))} def near(i: int, d: int) -> bool: a = truth[d] k = int(np.searchsorted(a, i)) return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL for j in (k - 1, k)) # 一类按中枢建索引,好给二类找到它自己那个一类的低点 —— # 二类的天然止损位是**一类的极值**,不是它自己的极值 one_ext: dict[int, float] = {} for b in bsp: if b.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1) and b.zs: d = 1 if b.type == Chan_BSP_TYPE.B1 else -1 one_ext[id(b.zs)] = float(b.klc.low if d == 1 else b.klc.high) want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1), Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1)} rec = [] for b in bsp: tag = want.get(b.type) if tag is None or b.sure_time is None: continue name, d = tag i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time) if not (0 <= i_ext < n and 0 <= i_sure < n): continue a = atr[i_ext] if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]): continue own = float(b.klc.low if d == 1 else b.klc.high) # 二类的结构止损用一类的极值(回踩不破的就是那个点); # 一类用自己的极值。这样两者的「入场离结构位多远」才可比 struct = own if name in ("B2", "S2") and b.zs is not None: struct = one_ext.get(id(b.zs), own) rec.append({ "sym": sym, "tf": tf, "type": name, "dir": d, "i_ext": i_ext, "i_sure": i_sure, "lag_bars": i_sure - i_ext, "hit": near(i_ext, d), "atr_z": a / base[i_ext], "own_ext": own, "struct_px": struct, "entry_px": op[min(i_sure + 1, n - 1)], }) if not rec: return None r = pd.DataFrame(rec) r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values]) & (atr[r.i_sure.values] > 0)].reset_index(drop=True) if r.empty: return None r["atr_at_entry"] = atr[r.i_sure.values] r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values] r["to_struct"] = ((r.entry_px - r.struct_px) * r.dir / r.atr_at_entry) cfg = cfg_name(SL, RUNNER, MAXB, RSTOP) for sfx, sgn in (("", 1), ("f_", -1)): res = walk_exits(cdf, pd.DataFrame({ "entry_idx": r.i_sure.values, "direction": sgn * r.dir.values}), [SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,)) if len(res) != len(r): return None for c in ("g", "r", "c", "b"): r[sfx + c] = res[f"{cfg}_{c}"].to_numpy() return r except Exception as e: # noqa: BLE001 print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True) return None def perf(g: pd.DataFrame, pre: str = "") -> dict: from lib.exit_model import fee_of, taker_notional gg, rr, cc = g[pre + "g"].values, g[pre + "r"].values, g[pre + "c"].values net = gg - fee_of(rr, cc) gR = gg / (SL * g.atr_pct.values) tn = taker_notional(rr, cc) w, o = net[net > 0].sum(), -net[net <= 0].sum() return { "笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%", "毛R": round(gR.mean(), 3), "PF": round(w / o, 2) if o > 0 else np.inf, "余量bp": round(net.mean() / tn.mean() * 1e4, 2), "t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2), } def report(d: pd.DataFrame) -> None: for tf, x in d.groupby("tf"): print("\n" + "#" * 96) print(f"########## {tf} ##########") print("\n【一】悖论的判据:二类命中线段顶点的比例是否高于一类") rows = [] for t in ["B1", "S1", "B2", "S2"]: g = x[x.type == t] if len(g) < 30: continue rows.append({ "类型": t, "样本": len(g), "命中线段顶点": f"{g.hit.mean()*100:.1f}%", "atr_z中位": round(g.atr_z.median(), 3), "滞后中位": int(g.lag_bars.median()), "入场到结构位(ATR)": round(g.to_struct.median(), 2), "止损2ATR落在结构内侧": f"{(g.to_struct > 2).mean()*100:.0f}%", }) print(pd.DataFrame(rows).to_string(index=False)) print("\n 「入场到结构位」是入场价离天然止损位多少个 ATR。二类的结构位取" "\n 它那个一类的极值(回踩不破的就是那点)。>2 表示 2ATR 的止损挂在" "\n 结构位以内,价格不用回踩到前低就出局 —— 一类实测 88.6%。") print("\n【二】按原方向做(抄底/摸顶)") print(pd.DataFrame([{"类型": t, **perf(g)} for t, g in x.groupby("type") if len(g) >= 30]).to_string(index=False)) print("\n【三】反手做") print(pd.DataFrame([{"类型": t, **perf(g, "f_")} for t, g in x.groupby("type") if len(g) >= 30]).to_string(index=False)) print("\n【四】二类拆命中/未命中 —— 一类的对应数字是 0.08 vs 0.70") two = x[x.type.isin(["B2", "S2"])] if len(two) >= 60: print(pd.DataFrame([ {"命中线段顶点": "是" if k else "否", **perf(g)} for k, g in two.groupby(two.hit) if len(g) >= 20 ]).to_string(index=False)) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE") ap.add_argument("--tfs", default="5m,15m") ap.add_argument("--rows", type=int, default=200_000) ap.add_argument("--workers", type=int, default=2) ap.add_argument("--reuse", action="store_true") args = ap.parse_args() if args.reuse and OUT.exists(): report(pd.read_feather(OUT)) return syms = [s.strip() for s in args.symbols.split(",")] tfs = [t.strip() for t in args.tfs.split(",")] parts = [] with ProcessPoolExecutor(max_workers=args.workers) as ex: fut = {ex.submit(collect, s, t, args.rows): (s, t) for s in syms for t in tfs} for i, f in enumerate(as_completed(fut), 1): r = f.result() s, t = fut[f] print(f" [{i}/{len(fut)}] {s} {t} " f"{0 if r is None else len(r)}", flush=True) if r is not None: parts.append(r) if not parts: print("无结果") return d = pd.concat(parts, ignore_index=True) OUT.parent.mkdir(exist_ok=True) d.to_feather(OUT) report(d) if __name__ == "__main__": main()