refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""缠论买卖点信号有效性评估:事件研究(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}%"
|
||||
Reference in New Issue
Block a user