删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
201 lines
7.0 KiB
Python
201 lines
7.0 KiB
Python
"""缠论买卖点信号有效性评估:事件研究(event study)。
|
|
|
|
核心口径约定:
|
|
- 入场时刻一律取 bsp.sure_time(笔被确认的那根K线收盘),而非分型时间 end_time。
|
|
分型时间在当时是不可知的,用它回测等于开了未来函数。
|
|
- BUY 视为做多,SELL 视为做空,收益按方向调整后统一为正=盈利。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
|
|
from chanlun import TF_DF
|
|
from chanlun.core.ChanEnum import Chan_BSP_DIR
|
|
|
|
DEFAULT_HORIZONS = (1, 3, 5, 10, 20, 40)
|
|
|
|
|
|
@dataclass
|
|
class BspEvent:
|
|
"""一个可交易的买卖点事件。"""
|
|
|
|
bsp_type: str
|
|
direction: int # +1 做多 / -1 做空
|
|
fx_time: pd.Timestamp # 分型时间(信号形态出现)
|
|
entry_time: pd.Timestamp # 确认时间(可交易)
|
|
entry_idx: int
|
|
entry_price: float
|
|
lag_bars: int # 确认滞后了多少根K线
|
|
|
|
|
|
def build_bi_zs(chan: TF_DF, zs_source: str) -> list:
|
|
"""构造笔中枢列表。
|
|
|
|
seg —— 在每个线段内部找笔中枢,中枢必须等所属线段成形,确认慢一层。
|
|
pure —— 直接在扁平笔序列上滚动,不依赖线段,确认更快。
|
|
"""
|
|
if zs_source == "seg":
|
|
return chan.cal_bi_zs(chan.seg_list)
|
|
if zs_source == "pure":
|
|
return chan.cal_bi_zs_list_pure(chan.bi_list)
|
|
raise ValueError(f"未知的中枢来源: {zs_source}")
|
|
|
|
|
|
def run_pipeline(df: pd.DataFrame, tf: str, zs_source: str = "seg") -> tuple[TF_DF, list]:
|
|
"""跑完整缠论 pipeline,返回引擎与买卖点列表。"""
|
|
chan = TF_DF(df, 1, tf)
|
|
bi_zs_list = build_bi_zs(chan, zs_source)
|
|
bsp_list = chan.find_all_bsp(chan.bi_list, bi_zs_list) if bi_zs_list else []
|
|
return chan, bsp_list
|
|
|
|
|
|
def _time_index_map(df: pd.DataFrame) -> dict[str, int]:
|
|
"""K线收盘时间 -> 行号。缠论内部把时间存成无时区字符串,按字符串对齐最稳。"""
|
|
keys = df["date"].dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
return {k: i for i, k in enumerate(keys)}
|
|
|
|
|
|
def to_events(bsp_list: list, df: pd.DataFrame) -> list[BspEvent]:
|
|
"""把 ChanBSP 转成以确认时刻为准的可交易事件。"""
|
|
idx_map = _time_index_map(df)
|
|
closes = df["close"].to_numpy(dtype=float)
|
|
events: list[BspEvent] = []
|
|
|
|
for bsp in bsp_list:
|
|
if not bsp.is_sure or bsp.sure_time is None:
|
|
continue
|
|
entry_key, fx_key = str(bsp.sure_time), str(bsp.end_time)
|
|
if entry_key not in idx_map or fx_key not in idx_map:
|
|
continue
|
|
entry_idx = idx_map[entry_key]
|
|
fx_idx = idx_map[fx_key]
|
|
entry_ts = pd.Timestamp(entry_key)
|
|
fx_ts = pd.Timestamp(fx_key)
|
|
events.append(
|
|
BspEvent(
|
|
bsp_type=str(bsp.type).replace("Chan_BSP_TYPE.", ""),
|
|
direction=1 if bsp.dir == Chan_BSP_DIR.BUY else -1,
|
|
fx_time=fx_ts,
|
|
entry_time=entry_ts,
|
|
entry_idx=entry_idx,
|
|
entry_price=float(closes[entry_idx]),
|
|
lag_bars=entry_idx - fx_idx,
|
|
)
|
|
)
|
|
return events
|
|
|
|
|
|
def forward_returns(
|
|
events: list[BspEvent], df: pd.DataFrame, horizons=DEFAULT_HORIZONS
|
|
) -> pd.DataFrame:
|
|
"""计算每个事件在各持有期的方向调整收益,以及 MFE/MAE。"""
|
|
closes = df["close"].to_numpy(dtype=float)
|
|
highs = df["high"].to_numpy(dtype=float)
|
|
lows = df["low"].to_numpy(dtype=float)
|
|
n = len(df)
|
|
rows = []
|
|
|
|
for ev in events:
|
|
row = {
|
|
"bsp_type": ev.bsp_type,
|
|
"direction": ev.direction,
|
|
"side": "LONG" if ev.direction == 1 else "SHORT",
|
|
"fx_time": ev.fx_time,
|
|
"entry_time": ev.entry_time,
|
|
"entry_idx": ev.entry_idx,
|
|
"entry_price": ev.entry_price,
|
|
"lag_bars": ev.lag_bars,
|
|
}
|
|
for h in horizons:
|
|
j = ev.entry_idx + h
|
|
if j >= n:
|
|
row[f"ret_{h}"] = np.nan
|
|
row[f"mfe_{h}"] = np.nan
|
|
row[f"mae_{h}"] = np.nan
|
|
continue
|
|
seg = slice(ev.entry_idx + 1, j + 1)
|
|
row[f"ret_{h}"] = ev.direction * (closes[j] - ev.entry_price) / ev.entry_price
|
|
if ev.direction == 1:
|
|
best, worst = highs[seg].max(), lows[seg].min()
|
|
else:
|
|
best, worst = lows[seg].min(), highs[seg].max()
|
|
row[f"mfe_{h}"] = ev.direction * (best - ev.entry_price) / ev.entry_price
|
|
row[f"mae_{h}"] = ev.direction * (worst - ev.entry_price) / ev.entry_price
|
|
rows.append(row)
|
|
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def baseline_stats(df: pd.DataFrame, horizons=DEFAULT_HORIZONS) -> pd.DataFrame:
|
|
"""基准:全样本每根K线无条件持有的收益分布(多头视角)。"""
|
|
closes = df["close"].to_numpy(dtype=float)
|
|
rows = []
|
|
for h in horizons:
|
|
fwd = (closes[h:] - closes[:-h]) / closes[:-h]
|
|
rows.append(
|
|
{
|
|
"horizon": h,
|
|
"base_mean_long": fwd.mean(),
|
|
"base_median_long": np.median(fwd),
|
|
"base_winrate_long": (fwd > 0).mean(),
|
|
"base_std": fwd.std(ddof=1),
|
|
}
|
|
)
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def _tstat(x: np.ndarray) -> float:
|
|
if len(x) < 2:
|
|
return np.nan
|
|
sd = x.std(ddof=1)
|
|
return np.nan if sd == 0 else float(x.mean() / (sd / np.sqrt(len(x))))
|
|
|
|
|
|
def summarize(
|
|
fwd: pd.DataFrame, df: pd.DataFrame, horizons=DEFAULT_HORIZONS, by_type: bool = True
|
|
) -> pd.DataFrame:
|
|
"""汇总各类买卖点在各持有期的表现,并给出对基准的超额。"""
|
|
base = baseline_stats(df, horizons).set_index("horizon")
|
|
groups: list[tuple[str, pd.DataFrame]] = [("ALL", fwd)]
|
|
if by_type:
|
|
groups += [("ALL_LONG", fwd[fwd.direction == 1]), ("ALL_SHORT", fwd[fwd.direction == -1])]
|
|
groups += [(t, g) for t, g in fwd.groupby("bsp_type")]
|
|
|
|
rows = []
|
|
for name, g in groups:
|
|
if g.empty:
|
|
continue
|
|
for h in horizons:
|
|
r = g[f"ret_{h}"].dropna().to_numpy()
|
|
if len(r) == 0:
|
|
continue
|
|
# 基准需按方向调整:做空的无条件期望是多头期望的相反数
|
|
dirs = g.loc[g[f"ret_{h}"].notna(), "direction"].to_numpy()
|
|
base_mean = float(np.mean(dirs) * base.loc[h, "base_mean_long"])
|
|
rows.append(
|
|
{
|
|
"group": name,
|
|
"horizon": h,
|
|
"n": len(r),
|
|
"mean": r.mean(),
|
|
"median": np.median(r),
|
|
"winrate": (r > 0).mean(),
|
|
"excess": r.mean() - base_mean,
|
|
"tstat": _tstat(r),
|
|
"mfe": g[f"mfe_{h}"].dropna().mean(),
|
|
"mae": g[f"mae_{h}"].dropna().mean(),
|
|
}
|
|
)
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def fmt_pct(x: float) -> str:
|
|
return "n/a" if pd.isna(x) else f"{x * 100:+.2f}%"
|