refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块

删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-27 01:05:12 +08:00
co-authored by Cursor
parent 5c10e35b76
commit 7f393b93ed
360 changed files with 140008 additions and 41167 deletions
+200
View File
@@ -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}%"