Files
Chan/research/step69_mechanism.py
jackyu66gitandCursor 7585491481 research: 订正额外信号的机制——不是重画,是中枢终点在实时不可知
我先前把 B4 的额外信号归因为「中枢重画」,用户两次指出后逐条查证,归因是错的:

① 中枢边界不重画(用户对)。zg/zd 由前三笔定死,verify_window_sens/step39 验过。

② 中枢只由已确认的笔构成,这是结构性保证:cal_bi_zs_list_pure 要求
   bi1/bi2/bi3.is_sure,延伸时要求 leave_bi/back_bi.is_sure。
   step70 实测 789 个信号全部 z_sure=True,用户说的浅色 B4 在研究路径不存在。
   因此我提的「补一道 is_sure 门」是空操作,实测 PF 0.73→0.73,已标记不要再提。

③ 真机制是 available_ts 棘轮,§5.41 早写明:改动不是已确认的笔被推翻,
   而是中枢又吸收了新笔、bis[-1] 换人。available_ts 取末笔 sure_time,
   于是往后棘轮,find_fast_bsp3 的 200 根扫描窗口整体右移。
   消失的不是笔,是中枢的终点。

顺带澄清一个伪问题:中枢跨度 > 200 根不会导致突破落不进窗口,
因为扫描起点是中枢结束(末笔确认)而非起点,此时价格已在离开中枢。

仍未对上的是数量级:中枢层面 6.5% vs 信号层面 75%。假设是 max_per_zone=1
的放大(起点棘轮越过旧入场点,同一中枢反复产出「第一个」)。
step69_mechanism.py 已写好判据但三次后台运行被中断,标为下一步前置项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:31:46 +08:00

241 lines
11 KiB
Python
Raw Permalink 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.
"""那 72~77% 的「额外信号」到底怎么来的:中枢重画,还是扫描窗口错位?
我在 §3.3994 里把它归因为「中枢重画」,**这个归因没验证过,而且与已有结论矛盾**
step39 的假阳性 0%、`verify_window_sens` 的窗口 +200/+500/+1000 逐字段一致)。
用户指出中枢不重画,代码注释也支持他:
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
# 于是 available_ts 跟着漂——这是右边缘重画的根因
—— chanlun/analysis/fast_bsp.py:47
漂的不是中枢**边界**(zg/zd),是它的**可用时刻**。而 `find_fast_bsp3` 只从
`available_ts` 往后扫 `scan=200` 根。两种口径的窗口因此错位:
实时 中枢没吸收完,available_ts 偏早 -> 窗口开得早
全量 中枢吸收完了,available_ts 偏晚(§5.41 实测中位晚 62 分钟)-> 窗口开得晚
落在「实时窗口内、全量窗口外」的信号,全量根本没扫到那个时段,于是显示为「额外」。
两种机制的修法完全不同,所以必须分清:
边界重画 结构本身不稳,只能用滞后换稳定性,代价大
窗口错位 中枢是同一个真中枢,信号也是真信号,只是**开得太早、确认不足**
—— 这正好解释它们为什么亏(PF 0.26~0.36),且修法是调 available_ts
判据:逐个额外信号,去全量中枢表里按 (zg, zd) 找它的中枢。
找得到且边界一致 -> 窗口错位(用户是对的,我的归因错了)
找不到或边界不同 -> 边界重画(我的归因成立)
"""
from __future__ import annotations
import argparse
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")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
OUT = HERE / "out" / "step69_mech.feather"
WIN, MAX_GROW, SCAN = 2001, 500, 200
TOL = 1e-6
def replay(sym: str, ltf: str, rows: int, steps: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
ensure_timestamp,
find_fast_bsp3,
zones_from_zs_list,
)
from lib.data import fetch_ohlcv
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
if df is None or len(df) < WIN + steps + 100:
return None
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
full = TF_DF(df, 1, ltf)
cdf = ensure_timestamp(full.dataframe)
zf = zones_from_zs_list(full.cal_bi_zs_list_pure(full.bi_list), cdf)
if zf is None or zf.empty:
return None
sig_full = find_fast_bsp3(cdf, zf)
full_keys = {(int(r.entry_idx), int(r.direction))
for r in sig_full.itertuples()} if not sig_full.empty \
else set()
fzg = zf["zg"].to_numpy(float)
fzd = zf["zd"].to_numpy(float)
fav = zf["available_ts"].to_numpy()
ts_all = cdf["timestamp"].to_numpy()
rec: dict[tuple, dict] = {}
chan, anchor = None, 0
for i in range(WIN, len(df)):
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, ltf)
chan.init_stream(w, 1, ltf)
anchor = i
else:
chan.append_bar(df.iloc[i])
try:
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
if not zl:
continue
sub = ensure_timestamp(chan.dataframe)
z = zones_from_zs_list(zl, sub)
if z is None or z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last]
if s.empty or "zone_i" not in s.columns:
continue
# find_fast_bsp3 的输出自带 zg/zd,直接 merge 会加后缀,
# 所以中枢侧的列全部改名再接
zc = z[["zg", "zd", "available_ts"]].rename(columns={
"zg": "z_zg", "zd": "z_zd", "available_ts": "z_av"})
zc["zone_i"] = np.arange(len(z))
s = s.drop(columns=[c for c in ("z_zg", "z_zd", "z_av")
if c in s.columns])
s = s.merge(zc, on="zone_i", how="left")
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
k = (i, int(r.direction))
if k in rec:
continue
# 该中枢在全量表里是否存在(按边界匹配,边界是不该漂的量)
zg_, zd_ = float(r.z_zg), float(r.z_zd)
m = (np.abs(fzg - zg_) <= TOL * max(1.0, abs(zg_))) \
& (np.abs(fzd - zd_) <= TOL * max(1.0, abs(zd_)))
j = int(np.argmax(m)) if m.any() else -1
rec[k] = {
"sym": sym, "entry_idx": i, "direction": int(r.direction),
"in_full": k in full_keys,
"zone_found": bool(m.any()),
"z_zg": zg_, "z_zd": zd_,
"avail_rt": int(r.z_av),
"avail_full": int(fav[j]) if j >= 0 else -1,
"ts_entry": int(ts_all[i]) if i < len(ts_all) else -1,
}
return pd.DataFrame(list(rec.values())) if rec else None
except Exception as e: # noqa: BLE001
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
return None
def report(d: pd.DataFrame, ltf: str) -> None:
ex = d[~d.in_full]
print("\n" + "=" * 92)
print("【一】判据:额外信号所在的中枢,在全量表里找得到吗")
print("=" * 92)
print(f"回放信号 {len(d)} · 额外 {len(ex)}")
print(f"**额外信号中,其中枢按 (zg,zd) 在全量表里找得到的:"
f"{ex.zone_found.mean()*100:.1f}%**")
print(f"(对照:非额外信号 {d[d.in_full].zone_found.mean()*100:.1f}%")
print("""
≈100% -> 中枢边界没变,是**扫描窗口错位**,用户对、我的「重画」归因错
明显偏低 -> 中枢确实消失或改边界,「重画」成立""")
print("\n" + "=" * 92)
print("【一b】数量级对账:中枢层面只有 6.5% 被改,信号层面却 74% 是额外的")
print("=" * 92)
print("§5.41 的 A/B 实测:`bis[-1]` 口径下确认时刻被改 6.5%、中枢消失 9.0%。")
print("若信号层面的 74% 成立,必然有放大机制。怀疑是 `max_per_zone=1`")
print(" 每个中枢只返回**第一个**入场点,而扫描起点随 available_ts 棘轮后移,")
print(" 越过旧入场点后,同一中枢会重新产出一个「第一个」——全量只用最终值,")
print(" 所以每中枢至多一个信号,实时却能反复触发。")
for nm, x in [("额外信号", ex), ("非额外信号", d[d.in_full])]:
if x.empty:
continue
nz = x.groupby(["sym", "z_zg", "z_zd"]).size() \
if "z_zg" in x.columns else None
if nz is None:
print(" (缺 z_zg/z_zd 列,跳过)")
break
print(f"\n{nm}{len(x)} 个信号,落在 {len(nz)} 个不同中枢上 "
f"-> 每中枢 {len(x)/len(nz):.2f} 次")
print(f" 同一中枢触发次数分布 中位 {nz.median():.0f} "
f"P90 {nz.quantile(.9):.0f} 最大 {nz.max()}")
print("""
若「额外信号」的每中枢次数显著 > 1 而「非额外」≈ 1,放大机制坐实:
6.5% 的中枢改动通过棘轮重扫,放大成信号层面的几百个。""")
g = ex[ex.zone_found & (ex.avail_full > 0)].copy()
if g.empty:
return
bar_ms = {"1m": 60_000, "5m": 300_000, "15m": 900_000}.get(ltf, 300_000)
g["drift_bars"] = (g.avail_full - g.avail_rt) / bar_ms
g["from_rt"] = (g.ts_entry - g.avail_rt) / bar_ms
g["from_full"] = (g.ts_entry - g.avail_full) / bar_ms
print("\n" + "=" * 92)
print("【二】available_ts 漂了多少,以及入场落在谁的扫描窗口里")
print("=" * 92)
print(f"avail 漂移(全量 − 实时,根) 中位 {g.drift_bars.median():.0f} "
f"P25 {g.drift_bars.quantile(.25):.0f} "
f"P75 {g.drift_bars.quantile(.75):.0f}")
print(f" §5.41 记的是中位晚 62 分钟,本表 {ltf} 下即 "
f"{62*60_000/bar_ms:.0f} 根,可交叉验证")
print(f"\n入场距实时 avail(根) 中位 {g.from_rt.median():.0f} "
f"(应落在 0~{SCAN} 内,否则实时也扫不到)")
print(f"入场距全量 avail(根) 中位 {g.from_full.median():.0f}")
out = ((g.from_full < 0) | (g.from_full > SCAN)).mean()
print(f"\n**入场落在全量扫描窗口 [0,{SCAN}] 之外的比例:{out*100:.1f}%**")
print("""
这是机制的直接证据:比例高 -> 全量根本没扫到那个时段,所以「没有」这个信号,
与中枢是否重画无关。其中 from_full < 0 表示入场早于全量的可用时刻——
即**实时抢跑了**,中枢还没吸收完就下单。""")
early = (g.from_full < 0).mean()
print(f" 其中抢跑(早于全量 avail):{early*100:.1f}%")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--ltf", default="5m")
ap.add_argument("--rows", type=int, default=45_000)
ap.add_argument("--steps", type=int, default=20_000)
ap.add_argument("--workers", type=int, default=5)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
report(pd.read_feather(OUT), args.ltf)
return
syms = [s.strip() for s in args.symbols.split(",")]
print(f"[机制判定] {len(syms)}× {args.steps} 根 · {args.ltf}\n",
flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex_:
fut = {ex_.submit(replay, s, args.ltf, args.rows, args.steps): s
for s in syms}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
print(f" [{i}/{len(syms)}] {fut[f]} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
OUT.parent.mkdir(exist_ok=True)
d.to_feather(OUT)
report(d, args.ltf)
if __name__ == "__main__":
main()