diff --git a/research/HANDOFF.md b/research/HANDOFF.md index ff40fc7..ea4782e 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -1261,6 +1261,67 @@ B4 跑出 PF 1.83~2.06 / t +8.5~+10.7。所以 fast B1 的负数不是管线接 **判定:一类线到此为止。** 病不在识别,在于「等笔确认」这个机制天然把信号 推到了结构失效之后,而这是 `find_all_bsp` 的固有属性,不是可调的参数。 +### 3.398 极值点的波动率:观察成立,但解释力有限(step61) + +用户提出:一二类长在极值点上,那个区域波动天然大,这可能就是确认慢的原因。 +拆成三条可证伪的子命题分别测,结论是**一条成立、一条只沾边、一条不成立**。 + +**① 一类确实长在高波动区 —— 成立,且很干净** + +`atr_z` = 该点 ATR / 其**之前** 200 根的中位 ATR(含当根会把要检验的插针算进去)。 + +| 5m | 样本 | atr_z 中位 | 高于基准占比 | +|---|---|---|---| +| 笔端点(基准) | 48219 | 1.003 | **50%** | +| **B1** | 623 | **1.224** | **74%** | +| **S1** | 644 | 1.173 | 68% | +| B3 / S3 | 2806 | 1.03~1.06 | 55~56% | +| **B2 / S2** | 293 | **0.94~0.97** | **39~44%** | + +基准恰好落在 50%,说明构造是对的。一类比随机笔端点显著偏高。 + +⚠️ **但二类是反过来的**:B2/S2 的 atr_z 中位 < 1,只有约四成高于基准 —— +**二类长在低波动区**。这符合结构:二类是首轮反转冲动之后的回抽,那时波动已 +平复。所以「一二类都在极值点」这个说法对一类成立、对二类不成立, +两者是镜像而非同类。 + +**② 波动大 → 确认慢 —— 只沾边,不是主因** + +| 5m · atr_z 四分位 | atr_z 中位 | 滞后中位 | 滞后均值 | +|---|---|---|---| +| Q1 低 | 0.85 | 8.0 | 9.3 | +| Q2 | 1.07 | **7.0** | 8.8 | +| Q3 | 1.34 | 8.0 | 9.6 | +| Q4 高 | 1.80 | 9.0 | 11.3 | + +`corr(atr_z, lag_bars) = +0.10`(15m 亦为 +0.107)。方向对,但只解释约 1% 的 +方差,滞后中位几乎不随波动变化。**滞后是结构性的**——笔要等分型形成再确认, +这个 8~9 根跟波动率基本无关(§3.393 已测得三类买卖点滞后完全一致)。 + +**③ 入场后波动率回落 → 目标够不着 —— 回落属实,但不是亏损的机制** + +`atr_fwd` = 入场后 48 根平均 ATR / 入场时 ATR:一类 5m 中位 **0.896**(69% 回落), +三类 0.945~0.957。即一类的 3/8 ATR 目标按一个虚高的 ATR 定,绝对价格上约高估 +10%。回落是真的。 + +**但它不是死因**: + +| 5m | 走到 3ATR 减仓 | 止损 | 超时 | 止盈 | +|---|---|---|---|---| +| **一类** | **9.4%** | **81.3%** | 17.9% | 0.8% | +| 三类 | 30.7% | — | — | — | + +**九成的一类根本走不到第一个目标,八成直接止损出场。** 若死因是「目标够不着」, +超时占比该很高;实际超时只有 17.9%。所以一类不是「走不动」,是**入场后立刻 +朝反方向走**。 + +按 atr_z 分档看表现也印证:PF 在 0.17~0.25 之间基本持平,高波动组并不更差 +也不更好 —— 波动率**不是**区分变量。 + +**小结**:极值点高波动这个观察是对的,但它既没造成滞后(结构性的),也没造成 +亏损(死于立即反向而非目标够不着)。它与 §3.397 的结论一致:一类的入场价已在 +结构底上方 2.9 ATR,那个位置本身就是负期望的。 + ### 3.4 alpha 的来源(step32 消融) 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, diff --git a/research/step61_vol_at_extreme.py b/research/step61_vol_at_extreme.py new file mode 100644 index 0000000..1875251 --- /dev/null +++ b/research/step61_vol_at_extreme.py @@ -0,0 +1,252 @@ +"""极值点的波动率:它是不是既拖慢了确认,又让目标够不着? + +用户的观察:一二类买卖点长在极值点上,那个区域波动天然很大,这可能正是确认 +慢的原因。 + +这个假设若成立会同时解释两件事,而且机制不同: + + 确认慢 波动大 -> 分型/笔要更多根才稳定下来 -> sure_time 更晚 + 赚不到 ATR 被造成极值的那根插针抬高 -> 止损 2×虚高ATR 其实宽松, + 但目标 3/8 ATR 变得够不着,因为入场后波动率会均值回复下来 + +第二条尤其要紧:它意味着交易不是「被打掉」,而是「永远走不到目标」, +与 §3.397 判定的「趋势继续」是**不同的失败模式**,应对办法也不同 +(该换波动率口径,而不是换方向)。 + +三个量: + atr_z 极值处 ATR / 该点之前 200 根的中位 ATR —— 是否真的偏高 + atr_fwd 入场后 48 根的平均 ATR / 入场时 ATR —— 是否均值回复 + 与滞后相关 atr_z 高的信号,lag_bars 是否更长 +""" +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" / "step61_vol.feather" +SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 +BASE_WIN = 200 + + +def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None: + from chanlun import TF_DF + from chanlun.core.ChanEnum import Chan_BSP_TYPE + 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) + # 基准 ATR 只用**该点之前**的窗口,含当根会把要检验的那根插针算进去 + base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50) + .median().shift(1).to_numpy()) + + rec = [] + # 对照组:所有笔端点,与 §3.397 同源 + for bi in chan.bi_list: + if not getattr(bi, "is_sure", False) or bi.end_klc is None: + continue + i = to_i(bi.end_klc.end_time) + if not (0 <= i < n) or not np.isfinite(base[i]) or base[i] <= 0: + continue + rec.append({"kind": "笔端点", "dir": 0, "i_ext": i, "i_sure": -1, + "atr_z": atr[i] / base[i], "lag_bars": -1}) + + 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), + Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)} + 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 + if not np.isfinite(base[i_ext]) or base[i_ext] <= 0: + continue + rec.append({"kind": name, "dir": d, "i_ext": i_ext, + "i_sure": i_sure, "atr_z": atr[i_ext] / base[i_ext], + "lag_bars": i_sure - i_ext}) + + r = pd.DataFrame(rec) + # 入场后的实现波动率:目标够不够得着,取决于入场**之后**的 ATR + ok = r.i_sure >= 0 + fwd = np.full(len(r), np.nan) + for k, i in zip(np.where(ok)[0], r.i_sure[ok].values): + j = min(int(i) + 1 + MAXB, n) + if j > int(i) + 1 and atr[int(i)] > 0: + fwd[k] = np.nanmean(atr[int(i) + 1:j]) / atr[int(i)] + r["atr_fwd"] = fwd + + sig = r[ok & (r.i_sure < n - 2)].copy() + sig = sig[np.isfinite(atr[sig.i_sure.values]) + & (atr[sig.i_sure.values] > 0)] + if not sig.empty: + cfg = cfg_name(SL, RUNNER, MAXB, RSTOP) + res = walk_exits(cdf, pd.DataFrame({ + "entry_idx": sig.i_sure.values, + "direction": sig.dir.values}), [SL], [RUNNER], [MAXB], + scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,)) + if len(res) == len(sig): + for c in ("g", "r", "c", "b"): + sig[c] = res[f"{cfg}_{c}"].to_numpy() + sig["atr_pct"] = (atr[sig.i_sure.values] + / cl[sig.i_sure.values]) + r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "b", + "atr_pct"]], on=["i_ext", "kind"], how="left") + r["sym"], r["tf"] = sym, tf + return r + except Exception as e: # noqa: BLE001 + print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True) + return None + + +def report(d: pd.DataFrame) -> None: + from lib.exit_model import fee_of, taker_notional + + for tf, x in d.groupby("tf"): + print("\n" + "#" * 96) + print(f"########## {tf} · 极值点的波动率 ##########") + + print("\n【一】极值处 ATR 是不是真的偏高(atr_z = 当点ATR / 前200根中位ATR)") + rows = [] + for kind in ["笔端点", "B1", "S1", "B2", "S2", "B3", "S3"]: + g = x[x.kind == kind] + if len(g) < 30: + continue + rows.append({ + "信号": kind, "样本": len(g), + "atr_z中位": round(g.atr_z.median(), 3), + "P75": round(g.atr_z.quantile(.75), 3), + "P90": round(g.atr_z.quantile(.90), 3), + "高于基准占比": f"{(g.atr_z > 1).mean()*100:.0f}%", + }) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n【二】波动大是不是确认更慢(按 atr_z 四分位看 lag_bars)") + y = x[x.kind.isin(["B1", "S1"]) & (x.lag_bars >= 0)].copy() + if len(y) >= 100: + q = pd.qcut(y.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"], + duplicates="drop") + print(pd.DataFrame([{ + "atr_z档": k, "笔数": len(g), + "atr_z中位": round(g.atr_z.median(), 2), + "滞后中位": round(g.lag_bars.median(), 1), + "滞后均值": round(g.lag_bars.mean(), 1), + } for k, g in y.groupby(q, observed=True)]).to_string(index=False)) + c = np.corrcoef(y.atr_z, y.lag_bars)[0, 1] + print(f" 相关系数 corr(atr_z, lag_bars) = {c:+.3f}") + print(" 正相关支持「波动大 -> 确认慢」;接近 0 则该假设不成立。") + + print("\n【三】入场后波动率是否回落(atr_fwd = 后48根平均ATR / 入场ATR)") + rows = [] + for kind in ["B1", "S1", "B3", "S3"]: + g = x[(x.kind == kind)].dropna(subset=["atr_fwd"]) + if len(g) < 30: + continue + rows.append({ + "信号": kind, "样本": len(g), + "atr_fwd中位": round(g.atr_fwd.median(), 3), + "回落占比(<1)": f"{(g.atr_fwd < 1).mean()*100:.0f}%", + }) + print(pd.DataFrame(rows).to_string(index=False)) + print(" <1 = 入场后波动率比入场那刻低,则按入场ATR定的 3/8 ATR 目标" + "\n 在绝对价格上被高估,会系统性够不着。") + + if "g" not in x.columns: + continue + print("\n【四】目标够不着的证据:出场原因分布(一类,按原方向)") + z = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]) + if len(z) >= 60: + print((z.r.value_counts(normalize=True) * 100).round(1) + .to_frame("占比%").to_string()) + print(f" 中位持仓 {z.b.median():.0f} 根 / 上限 {MAXB} 根") + + print("\n【五】按 atr_z 分档看一类表现(原方向)") + q = pd.qcut(z.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"], + duplicates="drop") + rows = [] + for k, g in z.groupby(q, observed=True): + net = g.g.values - fee_of(g.r.values, g.c.values) + gR = g.g.values / (SL * g.atr_pct.values) + tn = taker_notional(g.r.values, g.c.values) + w, o = net[net > 0].sum(), -net[net <= 0].sum() + rows.append({ + "atr_z档": k, "笔数": len(g), + "atr_z中位": round(g.atr_z.median(), 2), + "胜率": 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), + }) + print(pd.DataFrame(rows).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=3) + 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()