step55 判定引擎 B1/S1 不可用后,照 B3→B4 的路子做了实时版:把「中枢已成 / 向下离开 / 背驰 / 触发」四步全换成当根可判的代理,滞后从 8~9 根压到 ≤1 根。 背驰能实时判的关键是进入段面积在中枢确认时已是历史,只有离开段需要逐根累加。 没救回来。5m 上 fastB1 一买 PF 0.38 / 一卖 0.36,15m 同量级,比引擎版的 0.20~0.24 有改善但离 1.0 很远。 真正的价值在对照组和滞后分档两处: - 同数据同出场同管线,fastB3 跑出 PF 1.83~2.06 / t +8.5~+10.7,排除了 「管线接错所以为负」。 - lag ≤1 根(≈买在极值上)那一档 PF 仍只有 0.40。即滞后清零也救不活, §3.393 里「等 8 根导致几何劣势」的解释只对了一半。 结论与 §3.4 消融、§3.391 的 mom60 相互印证:这族信号的 alpha 在顺势延续上, 不在反转上。把反转信号提前,它还是反转信号。一类到此为止。 Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
9.1 KiB
Python
219 lines
9.1 KiB
Python
"""快速一类买卖点(fast B1/S1)——把引擎的 B1/S1 改写成当根可判的形式。
|
||
|
||
**动机**:step55 实测引擎 `find_all_bsp` 的 B1/S1 在 5m/15m 上是重亏的
|
||
(PF 0.20~0.24、胜率 15~21%、t −17~−22),而且原因是几何性的:引擎在
|
||
`leave_bi.sure_time` 才发信号,中位滞后 8~9 根,此时价格已朝反弹方向跑了
|
||
1.68 ATR。逆势信号上,这个滞后是加倍惩罚——新入场价往下 2 ATR 的止损落在
|
||
比原始低点还低 0.3 ATR 处,几乎贴着极值。
|
||
|
||
B3 当年也是同样的病(PF 0.66),解法不是调参而是**重新定义成实时判据**
|
||
(B4,见 `chanlun/analysis/fast_bsp.py`)。本模块对一类做同样的事。
|
||
|
||
引擎 B1 是三个条件的合取(`bsp.py: find_all_bsp` + `check_bi_div`):
|
||
|
||
中枢已成 -> 一笔向下离开中枢 -> 该笔 MACD 面积 < 进入笔面积(底背驰)
|
||
|
||
三条都有当根可判的代理,滞后来源只有一处——`leave_bi.is_sure`:
|
||
|
||
中枢已成 zs.is_sure -> zones.available_ts(与 B4 同口径,已解决)
|
||
向下离开 leave_bi 端点在 zd 下 -> **收盘** < zd,当根可判
|
||
背驰 整笔面积对比 -> 进入笔面积在中枢确认时已是历史,
|
||
离开段面积从突破根起逐根累加,当根可判
|
||
触发 等笔确认(滞后之源) -> 收盘反向越过前一根极值(沿用 B4 的转强判据)
|
||
|
||
**与三类的方向关系相反**:三类顺着突破方向做,一类逆着做。中枢向下突破后,
|
||
B4 会做空,而 fast B1 是在同一段下跌里找力竭然后**做多**。
|
||
|
||
⚠️ 先验不利,落地前请先看数:§3.391 实测 `mom60` 最高的四分位是最差的
|
||
(余量只剩 1.44bp),而一类按定义就住在一段已走完的行情末端。本模块是用来
|
||
证伪这个先验的,不是用来假定它不成立的。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from chanlun.analysis.fast_bsp import _resolve_avail_bi, timestamps_ms
|
||
|
||
|
||
def zones_with_enter_area(zs_list, src: pd.DataFrame,
|
||
avail_bi: int | None = None) -> pd.DataFrame:
|
||
"""区间表 + 进入笔的 MACD 面积与方向。
|
||
|
||
这两列**不引入滞后**:进入笔是中枢第一笔的前一笔,中枢确认时它早已收尾。
|
||
背驰判据里唯一需要等待的是离开段,而那一段可以逐根累加。
|
||
|
||
面积口径必须和 `ChanBI.cal_macd_hist` 一致:只累加顺方向那一侧的 hist
|
||
并取绝对值(上升笔累加正值,下降笔累加负值的绝对值),故恒非负。
|
||
"""
|
||
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||
timestamps_ms(src)))
|
||
i = _resolve_avail_bi(avail_bi)
|
||
|
||
rows = []
|
||
for zs in zs_list:
|
||
bis = getattr(zs, "bi_list", [])
|
||
if not bis:
|
||
continue
|
||
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
|
||
avail = (ts_of.get(str(getattr(key_bi, "sure_time", "") or ""))
|
||
or ts_of.get(str(getattr(key_bi, "end_time", "") or "")))
|
||
if avail is None:
|
||
continue
|
||
enter_bi = getattr(bis[0], "pre", None)
|
||
if enter_bi is None:
|
||
continue
|
||
# dir 用 +1/−1 表示,避免把引擎枚举漏进研究侧
|
||
e_dir = 1 if str(enter_bi.dir).endswith("UP") else -1
|
||
rows.append({
|
||
"zg": float(zs.zg), "zd": float(zs.zd),
|
||
"start_ts": ts_of.get(str(bis[0].start_time), avail),
|
||
"available_ts": int(avail),
|
||
"enter_area": abs(float(enter_bi.macd_hist)),
|
||
"enter_dir": e_dir,
|
||
})
|
||
out = pd.DataFrame(rows)
|
||
if out.empty:
|
||
return out
|
||
return out.sort_values("available_ts").reset_index(drop=True)
|
||
|
||
|
||
def find_fast_bsp1(
|
||
df: pd.DataFrame,
|
||
zones: pd.DataFrame,
|
||
scan: int = 200,
|
||
leave_win: int = 60,
|
||
min_leave_bars: int = 3,
|
||
max_div: float = 1.0,
|
||
max_per_zone: int = 1,
|
||
diag: dict | None = None,
|
||
) -> pd.DataFrame:
|
||
"""扫描每个中枢,找「离开中枢后力度背驰、随即转向」的入场点。
|
||
|
||
zones 需含 zg / zd / available_ts / enter_area / enter_dir,
|
||
即 `zones_with_enter_area` 的输出。
|
||
|
||
`min_leave_bars` 要求离开段至少走这么多根才允许触发。没有它的话,转强判据
|
||
会在突破后第一根小反弹就打进去,那时离开段的面积还没累起来,背驰条件几乎
|
||
自动成立——会退化成「随便一个反弹就抄底」。
|
||
|
||
`max_div` 是背驰的松紧:离开段面积 / 进入段面积 < max_div 才算背驰。
|
||
1.0 等于引擎的 `macdhist_div < 0`;调小则只认力度衰减更明显的。
|
||
|
||
返回列:
|
||
entry_idx 实时可下单的K线
|
||
direction +1 一买 / −1 一卖(**与突破方向相反**)
|
||
bo_idx 离开中枢的突破根
|
||
ext_idx 离开段的极值根
|
||
lag entry_idx − ext_idx,即相对理想入场点的滞后
|
||
div 离开段面积 / 进入段面积,越小背驰越强
|
||
depth 离开段极值越过中枢边界的幅度(相对边界)
|
||
"""
|
||
need = {"zg", "zd", "available_ts", "enter_area", "enter_dir"}
|
||
if zones.empty or not need <= set(zones.columns):
|
||
return pd.DataFrame()
|
||
|
||
ts = df["timestamp"].to_numpy()
|
||
close = df["close"].to_numpy(float)
|
||
high = df["high"].to_numpy(float)
|
||
low = df["low"].to_numpy(float)
|
||
hist = df["macdhist"].to_numpy(float)
|
||
n = len(df)
|
||
rows = []
|
||
|
||
def note(key: str) -> None:
|
||
if diag is not None:
|
||
diag[key] = diag.get(key, 0) + 1
|
||
|
||
for zone_i, (_, z) in enumerate(zones.iterrows()):
|
||
note("中枢总数")
|
||
zg, zd = float(z["zg"]), float(z["zd"])
|
||
e_area, e_dir = float(z["enter_area"]), int(z["enter_dir"])
|
||
if zg <= zd or not np.isfinite(e_area) or e_area <= 0:
|
||
note("×无效中枢或进入段无面积")
|
||
continue
|
||
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
|
||
if start >= n - 2:
|
||
note("×中枢太靠后")
|
||
continue
|
||
scan_end = min(start + scan * max_per_zone, n)
|
||
cursor = start
|
||
|
||
for occ in range(1, max_per_zone + 1):
|
||
if cursor >= n - 2:
|
||
break
|
||
was_inside = False
|
||
bo_idx, d = None, 0
|
||
for j in range(cursor, scan_end):
|
||
c = close[j]
|
||
if zd <= c <= zg:
|
||
was_inside = True
|
||
continue
|
||
if not was_inside:
|
||
continue
|
||
bo_idx, d = j, (1 if c > zg else -1)
|
||
break
|
||
if bo_idx is None:
|
||
if occ == 1:
|
||
note("×窗口内未离开中枢")
|
||
break
|
||
|
||
# 引擎要求 enter_bi.dir == leave_bi.dir 才比面积,否则背驰无从谈起
|
||
if e_dir != d:
|
||
note("×进入段与离开段不同向")
|
||
cursor = bo_idx + 1
|
||
continue
|
||
|
||
area = 0.0
|
||
ext = low[bo_idx] if d == -1 else high[bo_idx]
|
||
ext_idx = bo_idx
|
||
entry_idx, div_at = None, np.nan
|
||
for j in range(bo_idx, min(bo_idx + leave_win + 1, n)):
|
||
h = hist[j]
|
||
# 与 ChanBI.cal_macd_hist 同口径:只取顺方向一侧,取绝对值
|
||
if d == -1 and h < 0:
|
||
area -= h
|
||
elif d == 1 and h > 0:
|
||
area += h
|
||
if (low[j] < ext) if d == -1 else (high[j] > ext):
|
||
ext, ext_idx = (low[j] if d == -1 else high[j]), j
|
||
if j < bo_idx + min_leave_bars:
|
||
continue
|
||
# 收盘回到中枢内 -> 这不是一次有效的离开,弃掉
|
||
if zd <= close[j] <= zg:
|
||
note("×离开段收盘回到中枢内")
|
||
break
|
||
if area >= e_area * max_div:
|
||
continue # 力度未衰减,不是背驰
|
||
go = close[j] > high[j - 1] if d == -1 else close[j] < low[j - 1]
|
||
if go:
|
||
entry_idx, div_at = j, area / e_area
|
||
break
|
||
if entry_idx is None:
|
||
if occ == 1:
|
||
note("×未在窗口内背驰转向")
|
||
cursor = bo_idx + 1
|
||
continue
|
||
if occ == 1:
|
||
note("√成交")
|
||
|
||
edge = zd if d == -1 else zg
|
||
rows.append({
|
||
"entry_idx": entry_idx, "direction": -d,
|
||
"bo_idx": bo_idx, "ext_idx": ext_idx,
|
||
"lag": entry_idx - ext_idx,
|
||
"div": div_at,
|
||
"depth": abs(ext - edge) / edge,
|
||
"zg": zg, "zd": zd,
|
||
"width_pct": (zg - zd) / close[bo_idx],
|
||
"occ": occ, "zone_i": zone_i,
|
||
})
|
||
cursor = entry_idx + 1
|
||
|
||
out = pd.DataFrame(rows)
|
||
if out.empty:
|
||
return out
|
||
return (out.sort_values(["entry_idx", "occ", "zone_i"])
|
||
.drop_duplicates("entry_idx", keep="first")
|
||
.reset_index(drop=True))
|