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>
209 lines
8.7 KiB
Python
209 lines
8.7 KiB
Python
"""Step 38:point-in-time 重建——回测的中枢,在当时那一刻真的存在吗。
|
||
|
||
incremental.py 开头写明「最后一笔 is_sure 允许收回」。而中枢生效时刻
|
||
available_ts 取的正是笔的 sure_time,那是全量重算得出的。
|
||
此前所有审计(后移入场、剔尾、多空、时间样本外、跨品种)都共用同一份
|
||
全量结构,无法发现这类偏差。
|
||
|
||
本步对每个信号做严格的时点重建:只喂到信号那一根为止的数据,
|
||
重跑 TF_DF -> 中枢 -> fast_bsp3,看该信号是否真的在那一刻出现。
|
||
|
||
召回 全量口径的信号,在时点口径下同一根也出现
|
||
偏移 出现了但不在同一根(早/晚几根)
|
||
消失 时点口径下完全没有
|
||
另外在随机非信号点上做同样重建,量化时点口径的假阳性——
|
||
那是实盘会真的下单、而回测里根本不存在的交易。
|
||
"""
|
||
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)
|
||
|
||
BEST = {"15m": "1h", "30m": "2h"}
|
||
WINDOW = 4000 # 时点重建时回看多少根,实盘也不会带六年历史
|
||
TOLERANCE = 3 # 判定「同一时刻」允许的根数偏移
|
||
|
||
|
||
def pit_signals(df_slice: pd.DataFrame, ltf: str) -> set[int]:
|
||
"""只用给定切片重建,返回该切片内的信号在切片中的下标集合。"""
|
||
from chanlun import TF_DF
|
||
from lib.fast_bsp3 import find_fast_bsp3
|
||
from lib.nested_level import build_htf_zones
|
||
|
||
chan = TF_DF(df_slice, 1, ltf)
|
||
cdf = chan.dataframe
|
||
zones = build_htf_zones(cdf, ltf, chan=chan)
|
||
if zones.empty:
|
||
return set()
|
||
sig = find_fast_bsp3(cdf, zones.reset_index(drop=True))
|
||
if sig.empty:
|
||
return set()
|
||
return set(sig["entry_idx"].astype(int).tolist())
|
||
|
||
|
||
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.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, n_probe = task
|
||
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, BEST[ltf], 10**9)
|
||
chan_h = TF_DF(df_h, 1, BEST[ltf])
|
||
s = signals_to_frame(extract_fx_signals(chan_h, chan_h.dataframe))
|
||
tl = htf_fx_timeline(s, chan_h.dataframe)
|
||
|
||
# 全量口径(回测用的那一份)
|
||
full = find_fast_bsp3(cdf, zones)
|
||
full = full.merge(z[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
|
||
full = attach_htf_context(full, cdf, tl, "h1")
|
||
full["push"] = np.where(full["direction"] == 1, full["z_above"], full["z_below"])
|
||
fin = full[(full["h1_agree"] == 1) & full["push"].fillna(False).astype(bool)]
|
||
idx_all = fin["entry_idx"].astype(int).to_numpy()
|
||
idx_all = idx_all[idx_all >= WINDOW]
|
||
if len(idx_all) < 20:
|
||
return None
|
||
|
||
rng = np.random.default_rng(7)
|
||
probe = (rng.choice(idx_all, size=min(n_probe, len(idx_all)), replace=False)
|
||
if len(idx_all) > n_probe else idx_all)
|
||
|
||
rows = []
|
||
for i in sorted(probe):
|
||
sl = cdf.iloc[i - WINDOW + 1: i + 1].reset_index(drop=True)
|
||
pit = pit_signals(sl, ltf)
|
||
last = len(sl) - 1 # 信号那一根在切片中的位置
|
||
hit_exact = last in pit
|
||
near = [p - last for p in pit if abs(p - last) <= TOLERANCE]
|
||
rows.append({"idx": int(i), "exact": hit_exact,
|
||
"near": bool(near), "shift": min(near, key=abs) if near else np.nan})
|
||
|
||
# 假阳性:随机非信号点,看时点口径是否在当根给出信号
|
||
pool = np.setdiff1d(np.arange(WINDOW, len(cdf) - 1), idx_all)
|
||
fp_probe = rng.choice(pool, size=min(len(probe), len(pool)), replace=False)
|
||
fp = 0
|
||
for i in sorted(fp_probe):
|
||
sl = cdf.iloc[i - WINDOW + 1: i + 1].reset_index(drop=True)
|
||
if (len(sl) - 1) in pit_signals(sl, ltf):
|
||
fp += 1
|
||
|
||
return {"task": f"{sym} {ltf}", "sym": sym, "ltf": ltf,
|
||
"probe": pd.DataFrame(rows), "n_full": len(idx_all),
|
||
"fp": fp, "n_fp": len(fp_probe)}
|
||
except Exception as e:
|
||
return {"task": f"{sym} {ltf}", "error": repr(e)[:250]}
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--symbols", default="BTC,ETH,SOL,BNB,ADA,LINK")
|
||
ap.add_argument("--probe", type=int, default=120)
|
||
ap.add_argument("--workers", type=int, default=6)
|
||
args = ap.parse_args()
|
||
|
||
syms = [s.strip() for s in args.symbols.split(",")]
|
||
tasks = [(s, l, args.probe) for l in BEST for s in syms]
|
||
print(f"[时点重建] {len(tasks)} 个任务 × 每个抽 {args.probe} 个信号"
|
||
f"(窗口 {WINDOW} 根)\n", flush=True)
|
||
res = []
|
||
with ProcessPoolExecutor(max_workers=args.workers) 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)
|
||
p = r["probe"]
|
||
print(f" [{i}/{len(tasks)}] {r['task']} 命中 "
|
||
f"{p['exact'].mean() * 100:.0f}% / 容差内 {p['near'].mean() * 100:.0f}%",
|
||
flush=True)
|
||
if not res:
|
||
return
|
||
|
||
print("\n" + "=" * 104)
|
||
print("########## 1. 全量口径的信号,在时点口径下还在吗 ##########")
|
||
rows = []
|
||
for r in res:
|
||
p = r["probe"]
|
||
rows.append({"品种": r["sym"], "级别": r["ltf"], "全量信号数": r["n_full"],
|
||
"抽检": len(p),
|
||
"同根命中": f"{p['exact'].mean() * 100:.1f}%",
|
||
f"±{TOLERANCE}根内": f"{p['near'].mean() * 100:.1f}%",
|
||
"完全消失": f"{(~p['near']).mean() * 100:.1f}%",
|
||
"中位偏移": (f"{np.nanmedian(p['shift']):+.1f}"
|
||
if p["near"].any() else "—")})
|
||
d = pd.DataFrame(rows).sort_values(["级别", "品种"])
|
||
print(d.to_string(index=False))
|
||
|
||
print("\n########## 2. 分级别汇总 ##########")
|
||
for ltf in BEST:
|
||
sub = [r for r in res if r["ltf"] == ltf]
|
||
if not sub:
|
||
continue
|
||
p = pd.concat([r["probe"] for r in sub], ignore_index=True)
|
||
print(f" {ltf}: 抽检 {len(p)} 同根命中 {p['exact'].mean() * 100:.1f}% "
|
||
f"±{TOLERANCE}根内 {p['near'].mean() * 100:.1f}% "
|
||
f"完全消失 {(~p['near']).mean() * 100:.1f}%")
|
||
|
||
print("\n########## 3. 假阳性:实盘会下、回测里没有的单 ##########")
|
||
rows = []
|
||
for r in res:
|
||
rows.append({"品种": r["sym"], "级别": r["ltf"], "抽检非信号点": r["n_fp"],
|
||
"当根却给信号": r["fp"],
|
||
"假阳性率": f"{r['fp'] / max(r['n_fp'], 1) * 100:.1f}%"})
|
||
print(pd.DataFrame(rows).sort_values(["级别", "品种"]).to_string(index=False))
|
||
|
||
p_all = pd.concat([r["probe"] for r in res], ignore_index=True)
|
||
fp_tot = sum(r["fp"] for r in res)
|
||
fp_n = sum(r["n_fp"] for r in res)
|
||
print("\n########## 结论 ##########")
|
||
print(f" 同根命中 {p_all['exact'].mean() * 100:.1f}%,"
|
||
f"±{TOLERANCE}根内 {p_all['near'].mean() * 100:.1f}%,"
|
||
f"完全消失 {(~p_all['near']).mean() * 100:.1f}%,"
|
||
f"假阳性 {fp_tot / max(fp_n, 1) * 100:.1f}%")
|
||
print(" 同根命中率若接近 100%,说明结构在当时就已确定,回测口径可信;")
|
||
print(" 若大量消失或偏移,则回测收益里有一部分实盘永远拿不到。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|