删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
"""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()
|