Files
Chan/research/live/verify_signal_path.py
T
jackandCursor 181bca303f 影子测量改用框架吃单原语,并补齐容量与 maker 成交率两项测算
吃单查询换成 Hummingbot 的 OrderBook.get_vwap_for_volume:手写的 walk_book
返回的是按计价币吃单的加权均价,但框架的 get_price_for_quote_volume 返回
边际价、get_vwap_for_volume 收基础币量,两者语义不同。改为按基础币下单
(真实委托与 PositionExecutor.amount 均是基础币计价),深度不足由
query_volume/result_volume 判定,框架此时返回 nan 而非一个看似正常的
部分成交均价。

落盘完整盘口(双边 50 档)。此前只记三个固定名义额的成交价,这批数据的
寿命就等于那几个档位的寿命;存完整深度后任意资金量级的冲击都能离线重算。
仓位档同时从 1k/5k/20k 提到十万量级,此前低估真实仓位约两个数量级。

订阅成交流,按根按价位聚合。买卖分开存——多头在目标位挂卖出靠主动买盘
成交,混在一起会把成交率高估约一倍。BTC 每根总成交额中位与 210 天历史
的 volume×close 差 0.3%,可确认采集完整。

新增两项测算:
- 冲击不是绑定约束。32 万仓位单边冲击 0.19~2.39bp,对 8.58~20.64bp 的
  预算只占 1.6~14.2%,冲击反推的资金上限 100~500 万。
- maker 成交率才是。止盈位被首次触及时,限价在该根价格区间中的位置
  中位 k=0.28(63.9 万次触及,三币一致);合并每根成交额后,32 万仓位
  的全额成交率仅 30.1%/15.6%/1.5%。要 80% 全额成交,仓位须 ≤ 4.7 万
  /1.4 万/0.26 万——比冲击反推的上限低 40~370 倍。

回测把这些止盈按「全额成交在目标价」计,故预算所依据的收益流本身需重估。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00

206 lines
8.6 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.
"""验证 live 信号路径与 step42 的批量过滤等价。
live 侧每根只看最后一根、且只喂 2000 根窗口;研究侧一次性跑全量。两者
用同一套滤网(同向 + 中枢阶梯 + ATR 门控),但**不保证逐笔一致**——
缠论结构依赖历史,2000 根窗口是 step39 定的命中率饱和点,不是无损截断。
每个信号根上比三种口径:
批量 全量历史 + 完整 5m。这是预算的来源,是基准
剔partial 窗口 2000 根 1m + 800 根**已收盘** 5m
含partial 窗口,5m 末尾保留那根**尚未收盘**的。这是 live 现行做法
## 结论:partial 根要保留,不能剔
live 的 `df_h[df_h["timestamp"] < kline_ts]` 里 kline_ts 是 1m 的收盘时刻,
而正在走的那根 5m 开盘更早,于是被保留下来——五根里有四根如此。乍看像是
「把未收盘的根当完整根用」的口径错误,实测**反过来**:
BTC 25/25、ETH 24/25、SOL 23/25 与批量一致(合计 96%
剔掉则只有 21/25、21/25、18/25(合计 80%
原因是批量口径里那根 5m 是存在的。缠论的包含处理与分型检测吃整条序列,
凭空少一根会把结构整体挪位;保留一根「开盘价正确、高低点尚不完整」的
近似根,比直接删掉更接近批量。
这也暴露了研究侧的一处残留:批量的 HTF 结构用到了那根 5m 的**最终**高低点,
而实盘在该时刻不可能知道。`htf_fx_timeline` 的 `confirm_ts += period` 只挡住了
分型**选取**上的未来函数,挡不住结构构建。live 用 partial 根逼近,落在 96%
差的那 4% 是这条残留的下界,不是可以修掉的 bug。
.venv/bin/python research/live/verify_signal_path.py --symbol BTC
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
sys.path.insert(0, str(HERE.parents[1]))
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
WINDOW_L, WINDOW_H = 2000, 800
HTF_MS = 300_000
def partial_htf_bar(df_l: pd.DataFrame, kline_ts: int) -> dict | None:
"""用 1m 合成「此刻正在走的那根 5m」,复现 live 曾经喂进去的 partial 根。"""
bucket = kline_ts // HTF_MS * HTF_MS
if bucket >= kline_ts: # 正好落在 5m 边界,没有未收盘的根
return None
part = df_l[(df_l["timestamp"] >= bucket) & (df_l["timestamp"] < kline_ts)]
if part.empty:
return None
date = pd.to_datetime(bucket, unit="ms", utc=True) \
.tz_convert("Asia/Shanghai")
return {"timestamp": bucket, "date": date,
"open": float(part["open"].iloc[0]),
"high": float(part["high"].max()), "low": float(part["low"].min()),
"close": float(part["close"].iloc[-1]),
"volume": float(part["volume"].sum())}
def load(sym: str, tf: str, days: int) -> pd.DataFrame:
f = HERE / "cache" / f"bitget_{sym}_{tf}_{days}d.feather"
if not f.exists():
raise SystemExit(f"缺数据 {f}")
return pd.read_feather(f)
def batch_flags(df_l: pd.DataFrame, df_h: pd.DataFrame) -> pd.DataFrame:
"""step42_exit_tp_1m.run_one 的滤网,逐行照搬。"""
from chanlun import TF_DF
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
from lib.shadow_budget import ATR_GATE_BP
chan_l = TF_DF(df_l, 1, "1m")
cdf = chan_l.dataframe
zones = build_htf_zones(cdf, "1m", chan=chan_l).reset_index(drop=True)
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))
chan_h = TF_DF(df_h, 1, "5m")
hdf = chan_h.dataframe
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
sig = find_fast_bsp3(cdf, zones)
sig = sig.merge(z[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
sig = attach_htf_context(sig, cdf, tl, "h1")
d = sig["direction"].astype(int)
push = np.where(d == 1, sig["z_above"], sig["z_below"])
idx = sig["entry_idx"].astype(int).to_numpy()
entry = cdf["open"].to_numpy(float)[np.minimum(idx + 1, len(cdf) - 1)]
atr_pct = cdf["atr"].to_numpy(float)[idx] / entry
out = pd.DataFrame({
"entry_idx": idx,
"ts": cdf["timestamp"].to_numpy()[idx],
"direction": d.to_numpy(),
"h1_agree": sig["h1_agree"].fillna(0).astype(int).to_numpy(),
"ladder_ok": pd.Series(push).fillna(False).astype(int).to_numpy(),
"atr_bp": atr_pct * 1e4,
})
out["gate_ok"] = (out["atr_bp"] >= ATR_GATE_BP).astype(int)
out["pass_all"] = ((out["h1_agree"] == 1) & (out["ladder_ok"] == 1)
& (out["gate_ok"] == 1)).astype(int)
return out, cdf
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbol", default="BTC")
ap.add_argument("--days", type=int, default=30)
ap.add_argument("--checks", type=int, default=12)
a = ap.parse_args()
df_l, df_h = load(a.symbol, "1m", a.days), load(a.symbol, "5m", a.days)
print(f"[{a.symbol}] 1m {len(df_l)} 根 / 5m {len(df_h)} 根,跑批量滤网…",
flush=True)
batch, cdf = batch_flags(df_l, df_h)
n = len(batch)
print(f" 原始 B4/S4 {n} 个 · 同向 {int((batch.h1_agree == 1).sum())}"
f" · 同向+阶梯 "
f"{int(((batch.h1_agree == 1) & (batch.ladder_ok == 1)).sum())}"
f" · 三项全过 {int(batch.pass_all.sum())}")
print(f" ATR 中位 {batch.atr_bp.median():.2f}bp · "
f"门控刷掉 {(1 - batch.gate_ok.mean()) * 100:.1f}%\n")
# 挑最近的若干个信号根做窗口复现
from shadow_signal import compute
cand = batch[batch["entry_idx"] >= WINDOW_L].tail(a.checks)
if cand.empty:
raise SystemExit("窗口内没有可核对的信号")
def run_window(r, with_partial: bool):
i = int(r["entry_idx"])
kline_ts = int(r["ts"]) + 60_000 # 信号根的收盘时刻
wl = df_l[df_l["timestamp"] < kline_ts].tail(WINDOW_L)
wh = df_h[df_h["timestamp"] + HTF_MS <= kline_ts].tail(WINDOW_H)
if with_partial:
p = partial_htf_bar(df_l, kline_ts)
if p is not None:
wh = pd.concat([wh, pd.DataFrame([p])], ignore_index=True)
entry_px = float(cdf["open"].to_numpy(float)[min(i + 1, len(cdf) - 1)])
res = compute(wl.reset_index(drop=True), wh.reset_index(drop=True),
entry_px)
if res.get("error"):
raise SystemExit(f"compute 报错,测试本身有问题:{res['error']}\n"
f"{res.get('traceback', '')}")
return next((h for h in res["hits"]
if h["direction"] == int(r["direction"])), None)
def fmt(h):
if h is None:
return f"{'未复现':>18}"
return (f"{h['h1_agree']:>6}{h['ladder_ok']:>5}{h['gate_ok']:>5}")
print(f"{'K线时刻':<15}{'方向':>4}{' 批量':>18}{' 窗口':>18}"
f"{' 含未收盘':>18}{' 截断':>7}{'partial':>9}")
print(f"{'':<15}{'':>4}{'同向 阶梯 门控':>20}{'同向 阶梯 门控':>20}"
f"{'同向 阶梯 门控':>20}")
n_trunc = n_part = n_ok = n_bad = 0
for _, r in cand.iterrows():
h_ok = run_window(r, False)
h_bad = run_window(r, True)
t = pd.to_datetime(r["ts"], unit="ms", utc=True) \
.tz_convert("Asia/Shanghai").strftime("%m-%d %H:%M")
ref = (int(r.h1_agree), int(r.ladder_ok), int(r.gate_ok))
got = None if h_ok is None else (h_ok["h1_agree"], h_ok["ladder_ok"],
h_ok["gate_ok"])
bad = None if h_bad is None else (h_bad["h1_agree"], h_bad["ladder_ok"],
h_bad["gate_ok"])
n_trunc += got != ref
n_part += bad != got
n_ok += got == ref
n_bad += bad == ref
print(f"{t:<15}{int(r.direction):>+4}"
f"{ref[0]:>6}{ref[1]:>5}{ref[2]:>5}"
f"{fmt(h_ok):>18}{fmt(h_bad):>18}"
f"{'' if got == ref else '差':>7}"
f"{'' if bad == got else '差':>9}")
n = len(cand)
print(f"\n 与批量(预算口径)一致:")
print(f" 剔掉未收盘 5m 根 {n_ok}/{n}")
print(f" 保留未收盘 5m 根 {n_bad}/{n} ← live 现行做法")
print(f" 两种窗口口径互不相同 {n_part}/{n}")
if __name__ == "__main__":
main()