Files
Chan/research/step55_bsp12_probe.py
T
jackyu66gitandCursor be3602f5ba research: 一类反手(step57),六年五币双向全正,PF 2.3~3.0
用户问一类二类的识别是不是错了。查下来是代码没写错、信号该反着用。

先排除一个错误猜测:check_bi_div 只比 MACD 面积、不检查创新极值,等于把
缠论背驰定义丢了一半。看着像致命缺陷,实测 93% 的离开笔本来就创了新极值,
解释不了 PF 0.2。

真正的线索是胜率方向:B1 胜率 18.6%,而 SL2/TP3 随机入场约 40%,远低于
随机说明样本带信息只是指反了。拿相反方向重跑出场(路径依赖,不能取负号),
5m/15m 上 PF 2.35~3.03、t +10~+13。

step57 三关全过:5 币全正、前后半段 2.81/2.81 与 2.55/2.51 几乎不变、
2021~2026 连续六年全正、多空两边都正。余量 15m 33~76bp,B4 只有约 3bp。

机理是滞后把信号变成了相反的交易:信号在 leave_bi.sure_time 发出,中位滞后
8~9 根,此时价格已从低点反弹 1.68 ATR。引擎想说抄底,它给的时间戳对应的却是
反弹已走完——在下跌趋势里于此处做空是标准的顺势回调入场。与 B4 的突破延续
是不同的入场原型,可能互补。

三项验证未做完前不许上实盘:因果性(中枢右边缘已知会重画,需流式回放)、
消融(是不是反弹就做空都赚)、与 B4 的重叠度。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:12:11 +08:00

343 lines
16 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 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
用户提出把 B4 那套研究思路搬到一二类上。搬之前必须先过一道闸,理由写在
`Chan_BSP_TYPE` 的枚举注释里:**B4 存在的全部意义就是"几何位置同 B3/S3
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
而一类买卖点是最滞后的一种构造:它要中枢 `is_sure`、要离开笔 `is_sure`、
还要背驰判定(`check_bi_div` 读 `macd_hist`)。二类更靠后,要在一类之后再走
两笔。所以真正的问题不是"一二类赚不赚钱",而是:
你能在什么时候知道它,那时候价格还在不在。
`ChanBSP` 给了两个时刻,差值就是答案:
klc.end_time = leave_bi.end_klc 的收盘时刻,**极值所在**,理想入场点
sure_time = 笔被确认的时刻,**你最早能动手的时刻**
本步只回答三件事,跑得快、结论硬:
1. 一二类各有多少笔(对比 B4 的 5.3 笔/天 / 10 币)
2. 滞后多少根
3. 这段等待里价格跑掉多少个 ATR —— 这是"入场价漂移",直接从余量里扣
⚠️ 不在本步做收益回测。滞后若不可接受,收益怎么算都是假的:`walk_exits`
用的是信号根的次根开盘价,而那个价在一二类上根本拿不到。
"""
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", 340)
OUT = HERE / "out" / "step55_bsp12.feather"
# B4 的对照基线,来自 §3.31 / step4810 币 1m 实盘口径
B4_PER_DAY_10SYM = 5.3
# 出场结构与实盘完全一致,见 live/exit_params.py
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
def collect(sym: str, rows: int, tf: str = "1m") -> pd.DataFrame | 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 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
# 必须走 fullcheck_bi_div 读 macd_hist,那只在 full 下算。
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
# find_all_bsp 的,这里照抄那条链,口径才对得上
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bi_zs:
return None
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
if not bsp:
return None
# 索引映射有两个坑,都会静默给出错的下标:
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
# ② cdf.date 的单位是 datetime64[ms]`astype("int64")` 给的是毫秒,
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
if t.tz is not None:
t = t.tz_localize(None)
return int(didx.searchsorted(t))
op = cdf["open"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
atr = cdf["atr"].to_numpy(float)
n = len(cdf)
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)}
rec = []
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
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:
continue
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
px_ideal = cl[i_ext]
j = min(i_sure + 1, n - 1)
px_real = op[j]
rec.append({
"sym": sym, "tf": tf, "type": name, "dir": d,
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
"i_ext": i_ext, "i_sure": i_sure,
"lag_bars": i_sure - i_ext,
"atr_bp": a / px_ideal * 1e4,
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
"drift_atr": (px_real - px_ideal) * d / a,
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
})
if not rec:
return None
r = pd.DataFrame(rec)
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure**不是**
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
if r.empty:
return None
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": r.dir.values})
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
flush=True)
return None
for k in ("g", "r", "c", "b"):
r[k] = res[f"{cfg}_{k}"].to_numpy()
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": -r.dir.values})
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
if len(rf) == len(r):
for k in ("g", "r", "c", "b"):
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
return r
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
print("\n" + "=" * 100)
print(f"########## 一、笔数:够不够做 ##########")
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
# 等于凭空把速率打对折
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
(B4_PER_DAY_10SYM / 10)).round(2)
print(t.to_string())
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
print("\n" + "=" * 100)
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
rows = []
for name, g in d.groupby("type"):
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
rows.append({"类型": name, "笔数": len(g),
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
"最大": int(g.lag_bars.max())})
print(pd.DataFrame(rows).to_string(index=False))
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
print("\n" + "=" * 100)
print("########## 三、等待的代价:价格跑掉了多少 ##########")
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
"余量里扣")
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
rows = []
for name, g in d.groupby("type"):
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
rows.append({
"类型": name, "笔数": len(g),
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
"中位ATR(bp)": round(g.atr_bp.median(), 1),
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 100)
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
"而目标位还在原处——盈亏比被腰斩。")
rows = []
for name, g in d.groupby("type"):
rows.append({
"类型": name,
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
def profit(d: pd.DataFrame) -> None:
"""各类买卖点按实盘出场结构的实际表现。
B3 是校验锚:HANDOFF §4 已记引擎 `find_all_bsp` 的 B3/S3 是系统性亏损
PF 0.66、胜率 27.4%、t 18.76)。若这里 B3 跑出个漂亮数字,说明口径接错了,
先别信 B1/B2 的结果。
"""
from lib.exit_model import fee_of, taker_notional
if "g" not in d.columns:
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
"重跑可得)")
return
x = d.dropna(subset=["g"]).copy()
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
x["gR"] = x.g.values / (SL * x.atr_pct.values)
x["R"] = x.net.values / (SL * x.atr_pct.values)
x["tn"] = taker_notional(x.r.values, x.c.values)
print("\n" + "=" * 100)
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
rows = []
for name, g in x.groupby("type"):
if len(g) < 40:
continue
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
rows.append({
"类型": name, "笔数": len(g),
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
"中位持仓": int(g.b.median()),
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%。"
"这里若明显更好,先怀疑接错了再信 B1/B2。")
if "f_g" not in x.columns:
return
print("\n" + "=" * 100)
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
"已经过期,两边都吃滑点。")
y = x.copy()
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
rows = []
for name, g in y.groupby("type"):
if len(g) < 40:
continue
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
rows.append({
"类型(反手)": name, "笔数": len(g),
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
"毛R": round(g.fgR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
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",
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
"但每根值的钱不同,所以周期是关键变量")
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():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
print(f"[一二类可行性探针] {len(syms)}× {tfs} × {args.rows} 根"
f"full 模式,find_all_bsp 要 macd_hist\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows, t): (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)
d.to_feather(OUT)
d["date_ext"] = pd.to_datetime(d["date_ext"])
for tf, x in d.groupby("tf"):
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
print("\n" + "#" * 100)
print(f"########## {tf} —— {len(x)} 个买卖点 · "
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
report(x, span, x.sym.nunique())
profit(x)
if __name__ == "__main__":
main()