我先前把 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>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。
|
||
|
||
用户指出:B4 有两种,**没确认的会出现然后消失,确认的不会**。而我在 step63/68
|
||
里统计的是 `find_fast_bsp3` 的全部输出,它的返回列里根本没有确认标志
|
||
(entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i),
|
||
**两种被混在一起了**,所以「额外信号 74%」这个数字不能直接拿来说实盘。
|
||
|
||
确认状态在更上游,`zones_from_zs_list`:
|
||
|
||
sure_key = str(getattr(key_bi, "sure_time", "") or "")
|
||
end_key = str(getattr(key_bi, "end_time", "") or "")
|
||
avail = ts_of.get(sure_key) or ts_of.get(end_key) # ← 静默退回 end_time
|
||
|
||
`ChanBI` 初始 `is_sure=False / sure_time=None`,确认时才 `set_is_sure(True, ...)`。
|
||
所以**末笔未确认时 available_ts 退回 end_time,而 end_time 随笔延伸而移动**,
|
||
中枢的可用时刻跟着漂 —— 这正是「出现然后消失」的那一种。笔一旦确认,
|
||
`sure_time` 固定,中枢不再动。
|
||
|
||
这也解释了 §5.41 的数量级:中枢层面确认时刻只被改 6.5%,而我在信号层面看到 74%。
|
||
|
||
本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组,分别看:
|
||
额外率 未确认组应显著高(会出现然后消失),已确认组应接近 0
|
||
收益 若亏损集中在未确认组,那么修法就是**信号侧加一道 is_sure 门**,
|
||
而不是动出场参数或放弃 B4
|
||
|
||
⚠️ 同时要查的第二件事:**实盘路径到底交易哪一种。** `shadow_signal.py` 的三道
|
||
滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure 这一道**。若未确认组确实是亏损源,
|
||
且实盘没有挡它,那这道门就是要补的东西。
|
||
"""
|
||
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" / "step70_sure.feather"
|
||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||
|
||
|
||
def _sure_map(zs_list) -> dict:
|
||
"""(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排,
|
||
索引对不上,所以用中枢边界当键接回去。"""
|
||
m = {}
|
||
for zs in zs_list:
|
||
bis = getattr(zs, "bi_list", []) or []
|
||
if not bis:
|
||
continue
|
||
m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \
|
||
bool(getattr(bis[-1], "is_sure", False))
|
||
return m
|
||
|
||
|
||
def replay(sym: str, ltf: str, htf: 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
|
||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||
|
||
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)
|
||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||
if not zsf:
|
||
return None
|
||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf))
|
||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||
else set()
|
||
|
||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||
tl = None
|
||
if dh is not None and len(dh) > 500:
|
||
ch = TF_DF(dh, 1, htf)
|
||
tl = htf_fx_timeline(
|
||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||
ch.dataframe)
|
||
|
||
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
|
||
sm = _sure_map(zl)
|
||
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))
|
||
z["z_sure"] = [
|
||
sm.get((round(float(a), 10), round(float(b), 10)), False)
|
||
for a, b in zip(z["zg"], z["zd"])]
|
||
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
|
||
s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]],
|
||
on="zone_i", how="left")
|
||
if tl is not None:
|
||
s = attach_htf_context(s, sub, tl, "h1")
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
for r in s.itertuples():
|
||
k = (i, int(r.direction))
|
||
if k in rec:
|
||
continue
|
||
push = getattr(r, "z_above" if r.direction == 1
|
||
else "z_below", None)
|
||
ag = getattr(r, "h1_agree", 0)
|
||
rec[k] = {
|
||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||
"in_full": k in full_keys,
|
||
"z_sure": bool(getattr(r, "z_sure", False)),
|
||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||
}
|
||
if not rec:
|
||
return None
|
||
r = pd.DataFrame(list(rec.values()))
|
||
|
||
from lib.exit_model import cfg_name, walk_exits
|
||
atr = cdf["atr"].to_numpy(float)
|
||
cl = cdf["close"].to_numpy(float)
|
||
r = r[(r.entry_idx < len(cdf) - 2)
|
||
& np.isfinite(atr[r.entry_idx.values])
|
||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||
if r.empty:
|
||
return None
|
||
res = walk_exits(cdf, pd.DataFrame({
|
||
"entry_idx": r.entry_idx.values,
|
||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||
if len(res) != len(r):
|
||
return None
|
||
for c in ("g", "r", "c"):
|
||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||
& (r.gate_ok == 1)).astype(int)
|
||
return r
|
||
except Exception as e: # noqa: BLE001
|
||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||
return None
|
||
|
||
|
||
def perf(g: pd.DataFrame) -> dict | None:
|
||
from lib.exit_model import fee_of, taker_notional
|
||
if len(g) < 15:
|
||
return None
|
||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||
gR = g.g.values / (SL * g.atr_pct.values)
|
||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||
return {
|
||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||
"余量bp": round(net.mean()
|
||
/ taker_notional(g.r.values, g.c.values).mean() * 1e4, 2),
|
||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||
}
|
||
|
||
|
||
def report(d: pd.DataFrame) -> None:
|
||
print("\n" + "=" * 92)
|
||
print("【一】判据:额外信号是不是集中在「末笔未确认」那一组")
|
||
print("=" * 92)
|
||
rows = []
|
||
for k, g in d.groupby(d.z_sure):
|
||
rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g),
|
||
"额外(全量没有)": int((~g.in_full).sum()),
|
||
"额外率": f"{(~g.in_full).mean()*100:.1f}%"})
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
print("""
|
||
用户的判断:「没确认的会出现然后消失,确认的不会」。
|
||
若已确认组额外率接近 0 -> 判断成立,之前 74% 是把两种混在一起统计的结果。""")
|
||
|
||
print("\n" + "=" * 92)
|
||
print("【二】亏损是不是也集中在未确认组")
|
||
print("=" * 92)
|
||
rows = []
|
||
for k, g in d.groupby(d.z_sure):
|
||
nm = "已确认" if k else "未确认"
|
||
for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]:
|
||
s = perf(x)
|
||
if s:
|
||
rows.append({"中枢末笔": nm, "口径": lab, **s})
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
print("\n" + "=" * 92)
|
||
print("【三】若补一道 is_sure 门,实盘口径会变成什么样")
|
||
print("=" * 92)
|
||
rows = []
|
||
for nm, x in [
|
||
("现状:三道滤网", d[d.pass_all == 1]),
|
||
("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]),
|
||
(" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]),
|
||
]:
|
||
s = perf(x)
|
||
if s:
|
||
rows.append({"口径": nm, **s})
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
print("""
|
||
⚠️ `shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure**。
|
||
若加上这道门 PF 明显回升,那它就是要补进信号路径的东西。""")
|
||
|
||
|
||
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("--htf", default="30m")
|
||
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))
|
||
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.htf, 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)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|