Files
Chan/research/step32_ablation.py
T
jackyu66gitandCursor 66061f79a1 research: 低滞后信号口径定稿与实盘前偏差审计
fast_bsp3 改用 tol=-1 + require_touch=False,信号滞后从 5.8 根降到 2.2 根。
滞后与收益严格单调(年化 370% -> 906%,同一份数据同一套成本),
这是本轮提升的主因,也意味着实盘延迟会直接侵蚀收益。

新增 step31~39 验证策略能否落地:
- 跨品种样本外——8 个未参与调参的币,PF 2.73 / t 28.5,无一为负
- 时点重建——只喂到信号那一根重算,同根命中 100%,确认无未来函数;
  1m 在 2000 根窗口即饱和,计算耗时 0.20s
- 偏差审计——多空对称、中枢生效时刻零回退、滑点稳健至 30bp、持仓几乎不重叠
- 消融——alpha 来自缠论中枢的上下文定位,而非「收盘转强」这个触发动作

补 research/HANDOFF.md:记录确切口径与参数、已排除的偏差、
已验证无效因而不必重做的方向,以及下一步用影子交易器实测执行滑点的方案。

清理 step1~20 的输出:早期方法论已被推翻(存在未来函数偏差),
其结论不再被引用;脚本保留,需要时可重跑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:47:41 +08:00

242 lines
9.5 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 32:「收盘重新转强」这个判据,到底是判据有用还是上下文有用。
质疑:如果 close > high[-1] 就能抓住启动,那不就能判断任意笔的端点了?
本步用消融实验回答,逐层剥掉前置条件:
E 完整 中枢存在 + 突破 + 回抽触边界 + 未跌回 + 收盘转强
D 去掉中枢 用近20根高点冒充「阻力位」,其余照旧
C 去掉回抽 突破后不要求回抽触及边界,转强即入
B 只要回调 近10根内创新低后收盘转强(无任何中枢/突破概念)
A 裸判据 close > high[-1] 就买,别的都不管
另外直接检验:这些入场点前的回抽极值,有多少真的是引擎认定的笔端点。
"""
from __future__ import annotations
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
def variant_signals(cdf: pd.DataFrame, zones: pd.DataFrame, mode: str) -> pd.DataFrame:
"""按消融档位产生信号。方向统一为做多/做空对称处理。"""
close = cdf["close"].to_numpy(dtype=float)
high = cdf["high"].to_numpy(dtype=float)
low = cdf["low"].to_numpy(dtype=float)
n = len(cdf)
rows = []
if mode == "A":
# 裸判据:收盘高于前一根最高价 -> 做多;低于前一根最低价 -> 做空
for j in range(1, n - 1):
if close[j] > high[j - 1]:
rows.append((j, 1))
elif close[j] < low[j - 1]:
rows.append((j, -1))
return pd.DataFrame(rows, columns=["entry_idx", "direction"])
if mode == "B":
# 近10根创新低后转强(有回调概念,无中枢)
for j in range(11, n - 1):
if low[j - 1] == low[j - 11:j].min() and close[j] > high[j - 1]:
rows.append((j, 1))
elif high[j - 1] == high[j - 11:j].max() and close[j] < low[j - 1]:
rows.append((j, -1))
return pd.DataFrame(rows, columns=["entry_idx", "direction"])
if mode == "D":
# 用近20根极值冒充阻力位,走完整流程
for j in range(21, n - 1):
edge = high[j - 21:j - 1].max()
broke = close[j - 1] > edge
if broke and low[j] <= edge * 1.003 and close[j] > high[j - 1]:
rows.append((j, 1))
edge2 = low[j - 21:j - 1].min()
if close[j - 1] < edge2 and high[j] >= edge2 * 0.997 and close[j] < low[j - 1]:
rows.append((j, -1))
return pd.DataFrame(rows, columns=["entry_idx", "direction"])
# C / E 都要用真中枢
ts = cdf["timestamp"].to_numpy()
for _, z in zones.iterrows():
zg, zd = float(z["zg"]), float(z["zd"])
if zg <= zd:
continue
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
if start >= n - 2:
continue
was_inside = False
bo_idx, d = None, 0
for j in range(start, min(start + 200, 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
edge = zg if d == 1 else zd
touched = False
entry_idx = None
for j in range(bo_idx + 1, min(bo_idx + 31, n)):
if zd <= close[j] <= zg:
break
near = (low[j] <= edge * 1.003) if d == 1 else (high[j] >= edge * 0.997)
if near:
touched = True
continue
need = touched if mode == "E" else True # C 不要求回抽触及
if need:
go = close[j] > high[j - 1] if d == 1 else close[j] < low[j - 1]
if go:
entry_idx = j
break
if entry_idx is not None:
rows.append((entry_idx, d))
return pd.DataFrame(rows, columns=["entry_idx", "direction"])
def run_one(sym: str) -> 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.nested_level import build_htf_zones
ltf = "30m"
try:
df_l = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, 10**9)
if df_l is None:
return None
chan = TF_DF(df_l, 1, ltf)
cdf = chan.dataframe
zones = build_htf_zones(cdf, ltf, chan=chan).reset_index(drop=True)
# 引擎认定的笔端点,用于检验「能否判断端点」
idx_map = {k: i for i, k in
enumerate(cdf["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))}
bi_ends = set()
for bi in chan.bi_list:
for attr in ("end_time",):
k = str(getattr(bi, attr, "") or "")
if k in idx_map:
bi_ends.add(idx_map[k])
out, hits = [], []
for mode in ("A", "B", "D", "C", "E"):
sig = variant_signals(cdf, zones, mode)
if sig.empty or len(sig) < 30:
continue
sig = sig.drop_duplicates("entry_idx")
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
tr["mode"], tr["symbol"] = mode, sym
out.append(tr)
# 入场点前一根(回抽极值处)是否命中笔端点
e = sig["entry_idx"].to_numpy()
hit = np.mean([any((x - 1 + k) in bi_ends for k in (-2, -1, 0, 1, 2))
for x in e])
hits.append({"symbol": sym, "mode": mode, "笔数": len(e),
"命中笔端点±2根": hit * 100})
if not out:
return None
return {"sym": sym, "trades": pd.concat(out, ignore_index=True),
"hits": pd.DataFrame(hits),
"n_bi": len(bi_ends), "n_bar": len(cdf)}
except Exception as e:
return {"sym": sym, "error": repr(e)[:250]}
def stat(g: pd.DataFrame, label: str) -> dict:
r = g["gross"].to_numpy() - FEE - SLIP
if len(r) < 20:
return {}
w, o = r[r > 0], r[r <= 0]
sd = r.std(ddof=1)
return {"档位": label, "笔数": len(r),
"胜率": f"{(r > 0).mean() * 100:.1f}%",
"均收益": f"{r.mean() * 100:+.3f}%",
"中位": f"{np.median(r) * 100:+.3f}%",
"PF": f"{w.sum() / abs(o.sum()):.2f}" if len(o) else "inf",
"t值": f"{r.mean() / (sd / np.sqrt(len(r))):+.2f}"}
NAMES = {"A": "A 裸判据 close>high[-1]", "B": "B 创新低后转强",
"D": "D 近20根高点当阻力", "C": "C 真中枢突破但不要求回抽",
"E": "E 完整(现用版本)"}
def main() -> None:
res = []
with ProcessPoolExecutor(max_workers=3) as ex:
futs = {ex.submit(run_one, s): s for s in ("BTC", "ETH", "SOL")}
for f in as_completed(futs):
r = f.result()
if r is None or "error" in (r or {}):
print(f" {futs[f]} 跳过 {(r or {}).get('error', '')}", flush=True)
continue
res.append(r)
print(f" {r['sym']} ok{r['n_bar']} 根K线,{r['n_bi']} 个笔端点)", flush=True)
if not res:
return
allt = pd.concat([r["trades"] for r in res], ignore_index=True)
hits = pd.concat([r["hits"] for r in res], ignore_index=True)
print("\n" + "=" * 100)
print("########## 1. 逐层剥掉前置条件(30m,无大级别过滤)##########")
rows = [stat(allt[allt["mode"] == m], NAMES[m]) for m in ("A", "B", "D", "C", "E")]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print(" 判据不变,只改上下文。PF 的落差就是上下文的贡献。")
print("\n########## 2. 这些点是不是笔端点 ##########")
g = hits.groupby("mode").agg(笔数=("笔数", "sum"),
命中率=("命中笔端点±2根", "mean")).reset_index()
g["档位"] = g["mode"].map(NAMES)
g["命中率"] = g["命中率"].round(1).astype(str) + "%"
print(g[["档位", "笔数", "命中率"]].to_string(index=False))
print(" 命中率高不代表能预测端点——笔端点在K线里本就密集,需与随机基准比较。")
print("\n########## 3. 随机基准:同样数量的随机点能命中多少 ##########")
rng = np.random.default_rng(42)
base = []
for r in res:
n_bar, n_bi = r["n_bar"], r["n_bi"]
# 笔端点±2根覆盖的K线占全样本比例,即随机命中概率上界
cover = min(1.0, n_bi * 5 / n_bar)
base.append({"品种": r["sym"], "K线数": n_bar, "笔端点数": n_bi,
"±2根覆盖占比": f"{cover * 100:.1f}%"})
print(pd.DataFrame(base).to_string(index=False))
print(" 若各档命中率都接近覆盖占比,说明判据对端点没有任何识别能力。")
if __name__ == "__main__":
main()