Files
Chan/research/step29_funnel.py
T
jackyu66gitandCursor 7f393b93ed refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:05:12 +08:00

211 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Step 29:信号为什么这么少——中枢到成交的漏斗,以及放宽参数能救回多少。
30m/2h 六年三个币只有 177 笔,年均 26 笔。先定位卡在哪一步,
再对回抽容差、回抽窗口、扫描窗口做敏感性测试,看样本量与质量的取舍。
"""
from __future__ import annotations
import argparse
import os
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")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 320)
SL, TP, MAXB = 1.5, 3.0, 48
FEE, SLIP = 0.0004, 0.0001
BEST = {"5m": "30m", "15m": "1h", "30m": "2h"}
# (扫描窗口, 回抽窗口, 回抽容差)
GRID = [
(200, 30, 0.003), # 现状
(200, 30, 0.010),
(200, 60, 0.003),
(200, 60, 0.010),
(400, 60, 0.010),
(400, 90, 0.020),
(600, 120, 0.030),
]
def run_one(task: tuple) -> dict | None:
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from lib.breakout import run_trades
from lib.data import fetch_ohlcv
from lib.fast_bsp3 import find_fast_bsp3
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
from lib.nested_level import build_htf_zones
sym, ltf = task
htf = BEST[ltf]
pair = f"{sym}/USDT:USDT"
try:
df_l = fetch_ohlcv(pair, ltf, 10**9)
if df_l is None or len(df_l) < 3000:
return None
chan_l = TF_DF(df_l, 1, ltf)
cdf = chan_l.dataframe
zones = build_htf_zones(cdf, ltf, chan=chan_l).reset_index(drop=True)
if zones.empty:
return None
z = zones.copy()
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
df_h = fetch_ohlcv(pair, htf, 10**9)
if df_h is None:
return None
chan_h = TF_DF(df_h, 1, htf)
s = signals_to_frame(extract_fx_signals(chan_h, chan_h.dataframe))
tl = htf_fx_timeline(s, chan_h.dataframe)
diag0: dict = {}
frames = []
for scan, pw, tol in GRID:
d = diag0 if (scan, pw, tol) == GRID[0] else None
sig = find_fast_bsp3(cdf, zones, scan=scan, pullback_win=pw, tol=tol, diag=d)
if sig.empty:
continue
sig = sig.merge(z[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
sig = attach_htf_context(sig, cdf, tl, "h1")
entries = list(zip(sig["entry_idx"].astype(int), sig["direction"].astype(int)))
tr = run_trades(cdf, entries, SL, TP, MAXB, fee=0.0, entry_delay=1)
if tr.empty:
continue
m = sig.drop_duplicates("entry_idx").set_index("entry_idx")
tr["symbol"], tr["ltf"] = sym, ltf
tr["cfg"] = f"scan{scan}/pb{pw}/tol{tol * 100:g}%"
tr["date"] = cdf["date"].to_numpy()[tr["entry_idx"].to_numpy()]
for c in ("h1_agree", "z_above", "z_below", "direction"):
tr[c if c != "direction" else "dir_sig"] = tr["entry_idx"].map(m[c])
frames.append(tr)
if not frames:
return None
return {"task": f"{sym} {ltf}", "diag": diag0,
"trades": pd.concat(frames, ignore_index=True)}
except Exception as e:
return {"task": f"{sym} {ltf}", "error": repr(e)[:250]}
def stat(r: np.ndarray) -> dict:
if len(r) < 30:
return {}
w, o = r[r > 0], r[r <= 0]
sd = r.std(ddof=1)
return {"笔数": len(r), "胜率": round((r > 0).mean() * 100, 1),
"中位": round(np.median(r) * 100, 2),
"PF": round(w.sum() / abs(o.sum()), 2) if len(o) else np.inf,
"偏度": round(pd.Series(r).skew(), 2),
"t值": round(r.mean() / (sd / np.sqrt(len(r))), 2)}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL")
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
cache = HERE / "out" / "step29_funnel.csv"
dcache = HERE / "out" / "step29_diag.csv"
if args.reuse and cache.exists():
allt = pd.read_csv(cache, parse_dates=["date"])
diag = pd.read_csv(dcache) if dcache.exists() else pd.DataFrame()
print(f"[复用] {len(allt)}\n")
else:
syms = [s.strip() for s in args.symbols.split(",")]
tasks = [(s, l) for l in BEST for s in syms]
print(f"[漏斗] {len(tasks)} 个任务 × {len(GRID)} 组参数\n", flush=True)
res = []
with ProcessPoolExecutor(max_workers=6) as ex:
futs = {ex.submit(run_one, t): t for t in tasks}
for i, f in enumerate(as_completed(futs), 1):
r = f.result()
if r is None or "error" in (r or {}):
print(f" [{i}] 跳过 {(r or {}).get('error', '')}", flush=True)
continue
res.append(r)
print(f" [{i}/{len(tasks)}] {r['task']}", flush=True)
if not res:
return
allt = pd.concat([r["trades"] for r in res], ignore_index=True)
allt.to_csv(cache, index=False)
diag = pd.DataFrame([{"task": r["task"], **r["diag"]} for r in res if r.get("diag")])
diag.to_csv(dcache, index=False)
allt["date"] = pd.to_datetime(allt["date"])
allt["r"] = allt["gross"] - FEE - SLIP
allt["push"] = np.where(allt["dir_sig"] == 1, allt["z_above"], allt["z_below"])
allt["push"] = allt["push"].fillna(False).astype(bool)
base = allt["cfg"] == f"scan200/pb30/tol0.3%"
if not diag.empty:
print("=" * 118)
print("########## 1. 现状参数下,每个中枢卡在哪一步 ##########")
diag["级别"] = diag["task"].str.split().str[1]
cols = [c for c in diag.columns if c.startswith(("×", "√", "中枢"))]
g = diag.groupby("级别")[cols].sum()
tot = g["中枢总数"]
out = g.copy()
for c in cols:
if c != "中枢总数":
out[c] = g[c].astype(int).astype(str) + " (" + \
(g[c] / tot * 100).round(0).astype(int).astype(str) + "%)"
print(out.to_string())
print(" 成交率 = √成交 / 中枢总数。占比最大的那一项就是瓶颈。")
print("\n########## 2. 放宽参数:样本量 vs 质量 ##########")
for ltf, g in allt.groupby("ltf"):
rows = []
for cfg, x in g.groupby("cfg"):
sub = x[(x["h1_agree"] == 1) & x["push"]]
s = stat(sub["r"].to_numpy())
if s:
rows.append({"级别": ltf, "参数": cfg, **s})
if rows:
df = pd.DataFrame(rows)
df["_k"] = df["参数"].map({f"scan{a}/pb{b}/tol{c * 100:g}%": i
for i, (a, b, c) in enumerate(GRID)})
print(df.sort_values("_k").drop(columns="_k").to_string(index=False))
print("\n########## 3. 最宽参数下的年频率变化 ##########")
rows = []
for (ltf, cfg), g in allt.groupby(["ltf", "cfg"]):
sub = g[(g["h1_agree"] == 1) & g["push"]]
if len(sub) < 30:
continue
yrs = (sub["date"].max() - sub["date"].min()).days / 365.25
r = sub["r"].to_numpy()
lev = np.clip(0.01 / np.clip(sub["risk_pct"].to_numpy(), 0.002, None), 0, 20)
pnl = r * lev
eq = np.cumprod(1 + pnl)
rows.append({"级别": ltf, "参数": cfg, "笔数": len(r),
"年笔数": round(len(r) / yrs), "PF": round(
r[r > 0].sum() / abs(r[r <= 0].sum()), 2),
"年化": f"{(eq[-1] ** (1 / yrs) - 1) * 100:+.0f}%",
"回撤": f"{(1 - eq / np.maximum.accumulate(eq)).max() * 100:.1f}%",
"Sharpe": round(pnl.mean() / pnl.std(ddof=1) * np.sqrt(len(r) / yrs), 2)})
print(pd.DataFrame(rows).to_string(index=False))
if __name__ == "__main__":
main()