删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
7.0 KiB
Python
167 lines
7.0 KiB
Python
"""Step 2:Walk-forward 重放,检验缠论买卖点在实时环境下是否真实可交易。
|
||
|
||
与 Step 1 的区别:入场时刻不再取引擎自报的 sure_time,而是取信号在滑动窗口重放中
|
||
「第一次真正可被观测到」的时刻。这才是没有未来函数的口径。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
from lib.bsp_eval import DEFAULT_HORIZONS, baseline_stats, run_pipeline
|
||
from lib.data import fetch_ohlcv
|
||
from lib.walkforward import analyze_stability, replay
|
||
|
||
pd.set_option("display.width", 220)
|
||
|
||
from chanlun.core.ChanEnum import Chan_BSP_DIR
|
||
|
||
|
||
def realtime_event_study(life: pd.DataFrame, df: pd.DataFrame, horizons=DEFAULT_HORIZONS) -> pd.DataFrame:
|
||
"""以 first_seen 为入场点做事件研究(无未来函数)。"""
|
||
closes = df["close"].to_numpy(dtype=float)
|
||
n = len(df)
|
||
rows = []
|
||
for _, r in life.iterrows():
|
||
i = int(r["first_seen_idx"])
|
||
d = int(r["direction"])
|
||
entry = closes[i]
|
||
row = {"bsp_type": r["bsp_type"], "direction": d,
|
||
"side": "LONG" if d == 1 else "SHORT", "entry_idx": i}
|
||
for h in horizons:
|
||
j = i + h
|
||
row[f"ret_{h}"] = d * (closes[j] - entry) / entry if j < n else np.nan
|
||
rows.append(row)
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def summarize_rt(fwd: pd.DataFrame, df: pd.DataFrame, horizons=DEFAULT_HORIZONS) -> pd.DataFrame:
|
||
base = baseline_stats(df, horizons).set_index("horizon")
|
||
groups = [("ALL", fwd), ("ALL_LONG", fwd[fwd.direction == 1]), ("ALL_SHORT", fwd[fwd.direction == -1])]
|
||
groups += [(t, g) for t, g in fwd.groupby("bsp_type")]
|
||
rows = []
|
||
for name, g in groups:
|
||
if g.empty:
|
||
continue
|
||
for h in horizons:
|
||
r = g[f"ret_{h}"].dropna().to_numpy()
|
||
if len(r) == 0:
|
||
continue
|
||
dirs = g.loc[g[f"ret_{h}"].notna(), "direction"].to_numpy()
|
||
base_mean = float(np.mean(dirs) * base.loc[h, "base_mean_long"])
|
||
sd = r.std(ddof=1) if len(r) > 1 else np.nan
|
||
rows.append({
|
||
"group": name, "horizon": h, "n": len(r),
|
||
"mean": r.mean(), "winrate": (r > 0).mean(),
|
||
"excess": r.mean() - base_mean,
|
||
"tstat": r.mean() / (sd / np.sqrt(len(r))) if sd else np.nan,
|
||
})
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--symbol", default="BTC/USDT:USDT")
|
||
ap.add_argument("--tf", default="1h")
|
||
ap.add_argument("--limit", type=int, default=50000)
|
||
ap.add_argument("--window", type=int, default=3000)
|
||
ap.add_argument("--step", type=int, default=6)
|
||
ap.add_argument("--tail", type=int, default=0, help="只用最后N根做快速验证,0=全量")
|
||
args = ap.parse_args()
|
||
|
||
df = fetch_ohlcv(args.symbol, args.tf, args.limit)
|
||
if args.tail:
|
||
df = df.tail(args.tail).reset_index(drop=True)
|
||
print(f"[data] {args.symbol} {args.tf} rows={len(df)} {df['date'].iloc[0]} -> {df['date'].iloc[-1]}")
|
||
|
||
_, final_bsp = run_pipeline(df, args.tf)
|
||
final_keys = {
|
||
(str(b.type).replace("Chan_BSP_TYPE.", ""),
|
||
1 if b.dir == Chan_BSP_DIR.BUY else -1,
|
||
str(b.end_time))
|
||
for b in final_bsp if b.is_sure and b.sure_time is not None
|
||
}
|
||
print(f"[final] 全量口径买卖点 = {len(final_keys)}")
|
||
|
||
n_windows = len(range(args.window, len(df) + 1, args.step))
|
||
print(f"[replay] window={args.window} step={args.step} 共 {n_windows} 个窗口")
|
||
t0 = time.time()
|
||
result = replay(
|
||
df, args.tf, window=args.window, step=args.step,
|
||
cache_key=f"{args.symbol.replace('/', '_').replace(':', '-')}_{args.tf}",
|
||
)
|
||
print(f"[replay] 完成,用时 {time.time() - t0:.0f}s")
|
||
|
||
life = analyze_stability(result, final_keys, df, window=args.window)
|
||
out_dir = Path(__file__).parent / "out"
|
||
out_dir.mkdir(exist_ok=True)
|
||
tag = f"{args.symbol.split('/')[0]}_{args.tf}" + (f"_tail{args.tail}" if args.tail else "")
|
||
life.to_csv(out_dir / f"step2_life_{tag}.csv", index=False)
|
||
|
||
valid = life[~life["truncated"]].copy()
|
||
mature = valid[~valid["immature"]].copy()
|
||
print(f"\n[信号总数] 重放中出现 = {len(life)};剔除左截断后 = {len(valid)};其中已成熟 = {len(mature)}")
|
||
if valid.empty:
|
||
return
|
||
|
||
print("\n=== 信号稳定性 ===")
|
||
print(f"[幻影率] 实时报出但完整历史上不存在: {(~mature['in_final']).mean() * 100:.1f}%"
|
||
f" ({(~mature['in_final']).sum()}/{len(mature)})")
|
||
print(f"[撤销率] 窗口内报出后又被抹掉 : {(~mature['alive_at_scope_end']).mean() * 100:.1f}%"
|
||
f" ({(~mature['alive_at_scope_end']).sum()}/{len(mature)})")
|
||
print(f"[存活度] persist_ratio 均值 : {mature['persist_ratio'].mean():.3f}")
|
||
print(
|
||
mature.groupby("bsp_type")
|
||
.agg(n=("in_final", "size"),
|
||
phantom=("in_final", lambda s: 1 - s.mean()),
|
||
revoked=("alive_at_scope_end", lambda s: 1 - s.mean()),
|
||
persist=("persist_ratio", "mean"))
|
||
.assign(phantom=lambda d: (d["phantom"] * 100).map(lambda v: f"{v:.1f}%"),
|
||
revoked=lambda d: (d["revoked"] * 100).map(lambda v: f"{v:.1f}%"),
|
||
persist=lambda d: d["persist"].map(lambda v: f"{v:.3f}"))
|
||
.to_string()
|
||
)
|
||
|
||
print("\n=== 真实滞后 ===")
|
||
idx_of = {t: i for i, t in enumerate(df["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))}
|
||
sure_lag = {}
|
||
for b in final_bsp:
|
||
if b.is_sure and b.sure_time is not None and str(b.sure_time) in idx_of:
|
||
k = str(b.end_time)
|
||
if k in idx_of:
|
||
sure_lag[k] = idx_of[str(b.sure_time)] - idx_of[k]
|
||
valid["sure_lag"] = valid["fx_time"].map(sure_lag)
|
||
print(f"[实时首见滞后] 中位数 = {valid['observed_lag'].median():.0f} 根"
|
||
f" 均值 = {valid['observed_lag'].mean():.1f} 根")
|
||
both = valid.dropna(subset=["sure_lag"])
|
||
if not both.empty:
|
||
print(f"[引擎自报滞后] 中位数 = {both['sure_lag'].median():.0f} 根"
|
||
f" 均值 = {both['sure_lag'].mean():.1f} 根")
|
||
print(f"[被低估的滞后] 中位数 = {(both['observed_lag'] - both['sure_lag']).median():.0f} 根"
|
||
f" 均值 = {(both['observed_lag'] - both['sure_lag']).mean():.1f} 根")
|
||
|
||
print("\n=== 无未来函数事件研究(以实时首见时刻入场)===")
|
||
fwd = realtime_event_study(valid, df)
|
||
s = summarize_rt(fwd, df)
|
||
out = s.copy()
|
||
for c in ("mean", "excess"):
|
||
out[c] = out[c].map(lambda v: f"{v * 100:+.2f}%")
|
||
out["winrate"] = out["winrate"].map(lambda v: f"{v * 100:.0f}%")
|
||
out["tstat"] = out["tstat"].map(lambda v: f"{v:+.2f}")
|
||
print(out.to_string(index=False))
|
||
|
||
fwd.to_csv(out_dir / f"step2_rt_events_{tag}.csv", index=False)
|
||
s.to_csv(out_dir / f"step2_rt_summary_{tag}.csv", index=False)
|
||
print(f"\n明细已写入 {out_dir}/step2_*_{tag}.csv")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|