用户指出「原来的计算是没有问题的,是你算错了」。改用逐笔对账(不做统计, 把两边中间量全摆出来),结论一半一半。 引擎与我的口径都没问题:36 个额外信号 100% 归为 A 类(全量扫描区间不覆盖 入场根,即 available_ts 棘轮),0% 属于我的 bug;索引逐行对齐(5001=5001, 时间戳全同);额外信号的中枢在全量里全都存在且 (zg,zd) 完全一致,中枢不重画。 棘轮幅度实测 32~234 根。 但同一张表暴露了真正算错的地方:全量每个中枢恰好产出 1.00 个信号,回放里 同一中枢平均触发 2.12 次、最多 5 次。max_per_zone=1 只保证每次扫描返回一个, 但扫描起点随棘轮后移、越过旧入场点,同一中枢会重新产出「第一个」。 所以先前的「额外信号占 74%」不成立——那是按信号条数统计的,按中枢算是 17/33≈52%。这也把数量级对上了:§5.41 的 6.5% 分母是全部中枢,而会产出信号的 中枢里被改过的占比自然更高,两个数不矛盾,先前直接对比也是错的。 仍成立:棘轮机制、额外信号确实亏钱。已推翻:「实盘会多开 74% 的仓」。 真实倍数取决于执行层是否按中枢去重/冷却,该层从未审计,现已提为最高优先级 的前置项——查清前不要据 PF 0.73 动实盘参数。 Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
8.6 KiB
Python
212 lines
8.6 KiB
Python
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
|
||
|
||
用户说「原来的计算是没有问题的,是你算错了」。之前几轮都是统计口径,
|
||
容易把自己的 bug 说成市场现象。这次不做统计,挑具体信号逐个对账。
|
||
|
||
对每个额外信号,把两边的中间量全摆出来:
|
||
|
||
回放侧 中枢 (zg,zd)、available_ts、扫描起点、入场根
|
||
全量侧 同一个中枢是否存在、它的 available_ts、扫描区间 [start, start+200]
|
||
入场根落不落在这个区间里、`diag` 记的拒绝原因
|
||
|
||
四种可能的结论,指向完全不同的处理:
|
||
|
||
A 全量里那个中枢的扫描区间**不覆盖**入场根
|
||
-> available_ts 棘轮,机制成立,不是 bug
|
||
B 中枢在全量里**不存在**
|
||
-> 中枢集合本身有差异,要查是不是我窗口用错了
|
||
C 区间覆盖了、全量却仍没出信号
|
||
-> 两边输入不同(我传错了 df/zones),**是我的 bug**
|
||
D 入场根索引对不上(差几根)
|
||
-> 索引口径错,`chan.dataframe` 与原始 df 不是 1:1,**是我的 bug**
|
||
|
||
D 尤其要查:回放里我用原始 df 的下标 i 当 key,全量用的是 `cdf` 的 entry_idx,
|
||
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
import warnings
|
||
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))
|
||
|
||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--sym", default="BTC")
|
||
ap.add_argument("--tf", default="5m")
|
||
ap.add_argument("--rows", type=int, default=45_000)
|
||
ap.add_argument("--steps", type=int, default=3_000)
|
||
ap.add_argument("--show", type=int, default=8)
|
||
args = ap.parse_args()
|
||
|
||
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
|
||
|
||
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
|
||
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
|
||
|
||
full = TF_DF(df, 1, args.tf)
|
||
cdf = ensure_timestamp(full.dataframe)
|
||
|
||
print("=" * 92)
|
||
print("【0】先查 D:索引口径是否 1:1")
|
||
print("=" * 92)
|
||
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
|
||
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
|
||
if len(df) == len(cdf):
|
||
t_df = pd.to_datetime(df["date"])
|
||
t_cd = pd.to_datetime(cdf["date"])
|
||
if t_df.dt.tz is not None:
|
||
t_df = t_df.dt.tz_localize(None)
|
||
if t_cd.dt.tz is not None:
|
||
t_cd = t_cd.dt.tz_localize(None)
|
||
same = int((t_df.values == t_cd.values).sum())
|
||
print(f"逐行时间戳相同 {same}/{len(df)} "
|
||
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
|
||
|
||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||
zf = zones_from_zs_list(zsf, cdf)
|
||
diag_full: dict = {}
|
||
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
|
||
fkeys = {(int(r.entry_idx), int(r.direction))
|
||
for r in sig_full.itertuples()}
|
||
ts = cdf["timestamp"].to_numpy()
|
||
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)} 个")
|
||
print(f"全量 diag:{diag_full}")
|
||
|
||
# ---- 回放 ----
|
||
rec = []
|
||
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, args.tf)
|
||
chan.init_stream(w, 1, args.tf)
|
||
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:
|
||
continue
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
for r in s.itertuples():
|
||
zi = int(r.zone_i)
|
||
rec.append({
|
||
"i": i, "d": int(r.direction),
|
||
"in_full": (i, int(r.direction)) in fkeys,
|
||
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
|
||
"avail_rt": int(z.available_ts.iloc[zi]),
|
||
"win_len": len(sub),
|
||
"bo": int(r.bo_idx), "last": last,
|
||
})
|
||
rp = pd.DataFrame(rec)
|
||
if rp.empty:
|
||
print("回放无信号")
|
||
return
|
||
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
|
||
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
|
||
|
||
# ---- 逐笔对账 ----
|
||
print("\n" + "=" * 92)
|
||
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
|
||
print("=" * 92)
|
||
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
|
||
fav = zf.available_ts.to_numpy()
|
||
n = len(cdf)
|
||
rows = []
|
||
for r in rp[~rp.in_full].head(args.show).itertuples():
|
||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||
if not m.any():
|
||
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
|
||
"结论": "B 中枢集合有差异"})
|
||
continue
|
||
j = int(np.argmax(m))
|
||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||
end = min(start + SCAN, n)
|
||
cover = start <= r.i < end
|
||
rows.append({
|
||
"入场根": r.i, "中枢在全量": "存在",
|
||
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
|
||
"全量avail根": start,
|
||
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
|
||
side="left")),
|
||
"全量扫描区间": f"[{start},{end})",
|
||
"覆盖入场根": "是" if cover else "否",
|
||
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
|
||
})
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
print("""
|
||
A = 全量扫描区间不覆盖入场根(available_ts 棘轮后移),机制成立
|
||
B = 中枢在全量里不存在 -> 中枢集合有差异,要查窗口
|
||
C = 区间覆盖了全量却没出信号 -> 两边输入不同,是我的 bug""")
|
||
|
||
print("\n" + "=" * 92)
|
||
print("【2】全体额外信号按结论归类")
|
||
print("=" * 92)
|
||
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
|
||
for r in rp[~rp.in_full].itertuples():
|
||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||
if not m.any():
|
||
cnt["B 中枢不存在"] += 1
|
||
continue
|
||
j = int(np.argmax(m))
|
||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
|
||
else "A 棘轮"] += 1
|
||
tot = max(sum(cnt.values()), 1)
|
||
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
|
||
for k, v in cnt.items()]).to_string(index=False))
|
||
|
||
print("\n" + "=" * 92)
|
||
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
|
||
print("=" * 92)
|
||
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
|
||
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
|
||
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
|
||
rows = []
|
||
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
|
||
if x.empty:
|
||
continue
|
||
nz = x.groupby(["zg", "zd"]).size()
|
||
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
|
||
"每中枢触发": round(len(x) / len(nz), 2),
|
||
"最多触发": int(nz.max())})
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
print("""
|
||
若「额外」组每中枢触发 >> 1 而「全量也有」组 ≈ 1,则先前那个「额外信号占 74%」
|
||
是**按条数**统计的放大结果,不等于实盘会多开 74% 的仓。
|
||
真实多开多少,取决于执行层对同一中枢是否去重/冷却 —— 那一层仍未审计。""")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|