删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""Step 12:中枢突破深挖 —— Step 11 里唯一正期望的信号。
|
|
|
|
Step 11 中 4h 中枢突破 21 笔 PF 1.90,但样本太小无法定论。
|
|
本步把样本扩到多周期(15m/1h/4h,最长 2524 天),并区分:
|
|
|
|
A 突破即入场
|
|
B 突破 + 回抽不回中枢(三类买卖点的本质,但用突破确认而非等笔)
|
|
C 假突破反向(突破后重回中枢 -> 反向做)
|
|
|
|
同时统计假突破率,这决定了 B 相对 A 的价值。
|
|
"""
|
|
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")
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from lib.breakout import run_trades, summarize_trades
|
|
from lib.data import fetch_ohlcv
|
|
from lib.nested_level import build_htf_zones
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from chanlun import TF_DF
|
|
|
|
pd.set_option("display.width", 260)
|
|
|
|
|
|
def collect_breakouts(
|
|
df: pd.DataFrame, zones: pd.DataFrame, scan: int = 300, pullback: int = 20
|
|
) -> pd.DataFrame:
|
|
"""找出每个中枢的首次有效突破,并记录后续是否假突破 / 是否回抽确认。"""
|
|
if zones.empty:
|
|
return pd.DataFrame()
|
|
ts = df["timestamp"].to_numpy()
|
|
close = df["close"].to_numpy(dtype=float)
|
|
low = df["low"].to_numpy(dtype=float)
|
|
high = df["high"].to_numpy(dtype=float)
|
|
n = len(df)
|
|
rows = []
|
|
|
|
for _, z in zones.iterrows():
|
|
zg, zd = float(z["zg"]), float(z["zd"])
|
|
width = zg - zd
|
|
if width <= 0:
|
|
continue
|
|
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
|
|
was_inside = False
|
|
bo_idx = None
|
|
d = 0
|
|
for j in range(start, min(start + scan, n)):
|
|
c = close[j]
|
|
if zd <= c <= zg:
|
|
was_inside = True
|
|
continue
|
|
if not was_inside:
|
|
continue
|
|
bo_idx, d = j, (1 if c > zg else -1)
|
|
break
|
|
if bo_idx is None:
|
|
continue
|
|
|
|
# 突破后是否重回中枢(假突破)
|
|
fake_idx = None
|
|
for j in range(bo_idx + 1, min(bo_idx + pullback + 1, n)):
|
|
if zd <= close[j] <= zg:
|
|
fake_idx = j
|
|
break
|
|
|
|
# 回抽确认:回踩到边界附近但收盘未回中枢,随后再度顺势
|
|
edge = zg if d == 1 else zd
|
|
conf_idx = None
|
|
touched = False
|
|
for j in range(bo_idx + 1, min(bo_idx + pullback + 1, n)):
|
|
near = (low[j] <= edge * 1.002) if d == 1 else (high[j] >= edge * 0.998)
|
|
inside = zd <= close[j] <= zg
|
|
if inside:
|
|
break
|
|
if near:
|
|
touched = True
|
|
continue
|
|
if touched and ((close[j] > close[j - 1]) if d == 1 else (close[j] < close[j - 1])):
|
|
conf_idx = j
|
|
break
|
|
|
|
rows.append({
|
|
"zs_start_ts": z["start_ts"], "zg": zg, "zd": zd,
|
|
"width_pct": width / close[bo_idx],
|
|
"bo_idx": bo_idx, "dir": d,
|
|
"is_fake": fake_idx is not None,
|
|
"conf_idx": conf_idx,
|
|
})
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--symbol", default="BTC/USDT:USDT")
|
|
ap.add_argument("--tfs", default="15m,1h,4h")
|
|
args = ap.parse_args()
|
|
|
|
all_rows = []
|
|
for tf in args.tfs.split(","):
|
|
df = fetch_ohlcv(args.symbol, tf, 10**9)
|
|
chan = TF_DF(df, 1, tf)
|
|
cdf = chan.dataframe
|
|
zones = build_htf_zones(df, tf)
|
|
bo = collect_breakouts(cdf, zones)
|
|
if bo.empty:
|
|
continue
|
|
|
|
days = (cdf["date"].iloc[-1] - cdf["date"].iloc[0]).days
|
|
print(f"\n{'=' * 92}")
|
|
print(f"周期 {tf} K线 {len(cdf)} 跨度 {days} 天 中枢 {len(zones)} "
|
|
f"突破 {len(bo)} 假突破率 {bo['is_fake'].mean() * 100:.1f}%")
|
|
print(f" 回抽确认成功 {bo['conf_idx'].notna().sum()} / {len(bo)}")
|
|
|
|
entries_a = list(zip(bo["bo_idx"].astype(int), bo["dir"].astype(int)))
|
|
real = bo[~bo["is_fake"]]
|
|
entries_real = list(zip(real["bo_idx"].astype(int), real["dir"].astype(int)))
|
|
cf = bo.dropna(subset=["conf_idx"])
|
|
entries_b = list(zip(cf["conf_idx"].astype(int), cf["dir"].astype(int)))
|
|
fake = bo[bo["is_fake"]]
|
|
entries_c = list(zip(fake["bo_idx"].astype(int), -fake["dir"].astype(int)))
|
|
|
|
rows = [
|
|
summarize_trades(run_trades(cdf, entries_a, 1.5, 3.0, 48), "A 突破即入"),
|
|
summarize_trades(run_trades(cdf, entries_b, 1.5, 3.0, 48), "B 回抽确认"),
|
|
summarize_trades(run_trades(cdf, entries_c, 1.5, 3.0, 48), "C 假突破反向"),
|
|
summarize_trades(run_trades(cdf, entries_real, 1.5, 3.0, 48), "D 事后真突破*"),
|
|
]
|
|
print(pd.DataFrame(rows).to_string(index=False))
|
|
print(" * D 用了事后信息,仅作上界参考,不可交易。")
|
|
|
|
for _, r in bo.iterrows():
|
|
all_rows.append({**r.to_dict(), "tf": tf})
|
|
|
|
# 中枢宽度分层:窄中枢突破是否更有效
|
|
bo2 = bo.copy()
|
|
bo2["w_bin"] = pd.qcut(bo2["width_pct"], 3, labels=["窄", "中", "宽"], duplicates="drop")
|
|
rows = []
|
|
for name, g in bo2.groupby("w_bin", observed=True):
|
|
e = list(zip(g["bo_idx"].astype(int), g["dir"].astype(int)))
|
|
if len(e) >= 20:
|
|
rows.append(summarize_trades(run_trades(cdf, e, 1.5, 3.0, 48), f"宽度-{name}"))
|
|
if rows:
|
|
print(pd.DataFrame(rows).to_string(index=False))
|
|
|
|
# 多空拆分
|
|
rows = []
|
|
for d, nm in [(1, "向上突破"), (-1, "向下突破")]:
|
|
e = [(i, dd) for i, dd in entries_a if dd == d]
|
|
if len(e) >= 20:
|
|
rows.append(summarize_trades(run_trades(cdf, e, 1.5, 3.0, 48), nm))
|
|
if rows:
|
|
print(pd.DataFrame(rows).to_string(index=False))
|
|
|
|
if all_rows:
|
|
out = Path(__file__).parent / "out" / "step12_zs_breakouts.csv"
|
|
out.parent.mkdir(exist_ok=True)
|
|
pd.DataFrame(all_rows).to_csv(out, index=False)
|
|
print(f"\n明细已写入 {out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|