Files
Chan/research/step36_bias.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

222 lines
9.2 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 36Sharpe 8 的剩余嫌疑——中枢生效时刻的回退分支、多空对称性、时间样本外。
step35 已排除未来函数(后移入场平滑衰减)、滑点脆弱、持仓重叠虚高 t 值。
剩下三个必须查:
1 build_htf_zones 里 available_ts = sure_time or end_time。
一旦回退到 end_time 就是未来函数(笔端点早于笔确认)。统计回退比例,
并给出「只保留 sure_time 可用」的严格口径下的结果。
2 多空对称性。三个币六年整体上涨,若收益几乎全来自做多,那是 beta 不是 alpha。
3 时间样本外。tol、级别对、SL/TP 都是在全样本上挑的。
用 2019~2022 当样本内,2023~2026 当样本外,看衰减多少。
"""
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
BEST = {"5m": "30m", "15m": "1h", "30m": "2h"}
SPLIT = pd.Timestamp("2023-01-01", tz="Asia/Shanghai")
def strict_zones(chan, cdf: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
"""重算中枢表,区分 available_ts 来自 sure_time 还是回退到 end_time。"""
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
ts_of = dict(zip(cdf["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), cdf["timestamp"]))
rows = []
cnt = {"总数": 0, "用sure_time": 0, "回退end_time": 0, "两者皆无": 0}
for zs in zs_list:
bis = getattr(zs, "bi_list", [])
if not bis:
continue
cnt["总数"] += 1
last = bis[-1]
s_ts = ts_of.get(str(getattr(last, "sure_time", "") or ""))
e_ts = ts_of.get(str(getattr(last, "end_time", "") or ""))
if s_ts is not None:
cnt["用sure_time"] += 1
src = "sure"
avail = s_ts
elif e_ts is not None:
cnt["回退end_time"] += 1
src = "fallback"
avail = e_ts
else:
cnt["两者皆无"] += 1
continue
rows.append({"zg": float(zs.zg), "zd": float(zs.zd),
"available_ts": int(avail), "src": src})
out = pd.DataFrame(rows)
if not out.empty:
out = out.sort_values("available_ts").reset_index(drop=True)
return out, cnt
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.breakout import run_trades
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
sym, ltf = 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, cnt = strict_zones(chan_l, cdf)
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)
if df_h is None or len(df_h) < 300:
return None
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)
out = []
for name, zsub in (("全部中枢", z),
("仅sure_time中枢", z[z["src"] == "sure"])):
if zsub.empty:
continue
zz = zsub.reset_index(drop=True)
zz["zone_i"] = np.arange(len(zz))
sig = find_fast_bsp3(cdf, zz, tol=-1.0)
if sig.empty or len(sig) < 20:
continue
sig = sig.merge(zz[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
sig = attach_htf_context(sig, cdf, tl, "h1")
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
m = sig.set_index("entry_idx")
tr["mode"], tr["symbol"], tr["ltf"] = name, sym, ltf
tr["date"] = cdf["date"].to_numpy()[tr["entry_idx"].to_numpy()]
for c in ("h1_agree", "z_above", "z_below", "direction"):
tr[c if c != "direction" else "dir_sig"] = tr["entry_idx"].map(m[c])
out.append(tr)
if not out:
return None
return {"task": f"{sym} {ltf}", "cnt": {"task": f"{sym} {ltf}", **cnt},
"trades": pd.concat(out, ignore_index=True)}
except Exception as e:
return {"task": f"{sym} {ltf}", "error": repr(e)[:250]}
def stat(g: pd.DataFrame, label: str, minn: int = 25) -> dict:
r = g["gross"].to_numpy() - FEE - SLIP
if len(r) < minn:
return {}
w, o = r[r > 0], r[r <= 0]
sd = r.std(ddof=1)
t10 = r[r <= np.quantile(r, 0.90)]
return {"分组": label, "笔数": len(r),
"胜率": f"{(r > 0).mean() * 100:.1f}%",
"中位": 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}",
"剔10%PF": f"{t10[t10 > 0].sum() / abs(t10[t10 <= 0].sum()):.2f}"}
def main() -> None:
tasks = [(s, l) for l in BEST for s in ("BTC", "ETH", "SOL")]
print(f"[偏差审计] {len(tasks)} 个任务\n", flush=True)
res = []
with ProcessPoolExecutor(max_workers=5) 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)
print(f" [{i}/{len(tasks)}] {r['task']}", flush=True)
if not res:
return
allt = pd.concat([r["trades"] for r in res], ignore_index=True)
allt["date"] = pd.to_datetime(allt["date"])
allt["push"] = np.where(allt["dir_sig"] == 1, allt["z_above"], allt["z_below"])
allt["push"] = allt["push"].fillna(False).astype(bool)
fin = allt[(allt["h1_agree"] == 1) & allt["push"]]
allt.to_csv(HERE / "out" / "step36_bias.csv", index=False)
print("=" * 110)
print("########## 1. 中枢生效时刻:有多少走了 end_time 回退分支 ##########")
c = pd.DataFrame([r["cnt"] for r in res])
c["级别"] = c["task"].str.split().str[1]
g = c.groupby("级别")[["总数", "用sure_time", "回退end_time", "两者皆无"]].sum()
g["回退占比"] = (g["回退end_time"] / g["总数"] * 100).round(1).astype(str) + "%"
print(g.to_string())
print("\n########## 2. 剔掉回退中枢后结果是否站得住 ##########")
print(pd.DataFrame([r for r in [stat(g_, m) for m, g_ in fin.groupby("mode")] if r]
).to_string(index=False))
strict = fin[fin["mode"] == "仅sure_time中枢"]
print("\n########## 3. 多空对称性(严格口径)##########")
rows = [stat(strict[strict["dir_sig"] == d], n)
for d, n in ((1, "做多(三买)"), (-1, "做空(三卖)"))]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print(" 若做空也显著为正,说明不是单纯吃上涨 beta。")
print("\n########## 4. 时间样本外:2019~2022 挑参数,2023~2026 验证 ##########")
rows = [stat(strict[strict["date"] < SPLIT], "样本内 2019~2022"),
stat(strict[strict["date"] >= SPLIT], "样本外 2023~2026")]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print("\n########## 5. 样本外分级别与分品种 ##########")
oos = strict[strict["date"] >= SPLIT]
rows = [stat(x, f"OOS {k}", minn=20) for k, x in oos.groupby("ltf")]
rows += [stat(x, f"OOS {k}", minn=20) for k, x in oos.groupby("symbol")]
print(pd.DataFrame([r for r in rows if r]).to_string(index=False))
print("\n########## 6. 样本外资金曲线(固定名义1倍,不复利)##########")
for label, g_ in (("样本内", strict[strict["date"] < SPLIT]),
("样本外", oos)):
g_ = g_.sort_values("date")
r = g_["gross"].to_numpy() - FEE - SLIP
yrs = (g_["date"].max() - g_["date"].min()).days / 365.25
eq = 1 + np.cumsum(r)
dd = (np.maximum.accumulate(eq) - eq).max()
print(f" {label}: n={len(r):>4}{len(r) / yrs:>3.0f}笔 "
f"年化 {(eq[-1] - 1) / yrs * 100:+6.1f}% 回撤 {dd * 100:5.1f}% "
f"Sharpe {r.mean() / r.std(ddof=1) * np.sqrt(len(r) / yrs):5.2f}")
if __name__ == "__main__":
main()