Files
Chan/research/lib/breakout.py
T
jackyu66gitandCursor 7f393b93ed refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:05:12 +08:00

240 lines
8.6 KiB
Python

"""突破跟随的事件驱动回测。
趋势跟随是低胜率高赔率,固定持有期会把大赢利截断、把小亏损放大,
必须用止损/止盈/时间三重出场才能测出真实期望。
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
FEE = 0.0008 # 双边
@dataclass
class Trade:
entry_idx: int
exit_idx: int
direction: int
entry: float
exit: float
ret: float # 已扣费
reason: str # sl / tp / time
bars_held: int
gross: float # 未扣费,便于事后做费率 what-if
risk_pct: float # 止损距离占入场价的比例,用于反推名义仓位
boosted: bool = False # 持仓期间是否等到了更大级别的同向确认
def run_trades(
df: pd.DataFrame,
entries: list[tuple[int, int]],
sl_atr: float = 1.5,
tp_atr: float = 3.0,
max_bars: int = 48,
trail: bool = False,
fee: float = FEE,
entry_delay: int = 0,
slippage: float = 0.0,
boost_dir: np.ndarray | None = None,
tp_boost: float = 2.0,
boost_breakeven: bool = False,
) -> pd.DataFrame:
"""按 (entry_idx, direction) 逐笔模拟。
出场优先级:同一根内若同时触及止损与止盈,保守地判为止损。
entry_delay=1 表示信号次根开盘成交,用来检验「收盘价入场」是否过于乐观。
boost_dir 给出每根K线上「趋势被更大级别确认」的方向(+1/-1/0)。持仓期间
一旦等到同向确认,就把止盈目标放大 tp_boost 倍;boost_breakeven 同时把
止损收到成本价。用来检验「新证据出现后该不该改单」。
"""
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
close = df["close"].to_numpy(dtype=float)
open_ = df["open"].to_numpy(dtype=float) if "open" in df.columns else close
atr = (
df["atr"].to_numpy(dtype=float)
if "atr" in df.columns
else pd.Series(close).rolling(14).std().bfill().to_numpy()
)
n = len(df)
out: list[Trade] = []
for e_idx, d in entries:
sig_idx = e_idx
e_idx = e_idx + entry_delay
if e_idx >= n - 1:
continue
entry = open_[e_idx] if entry_delay else close[e_idx]
a = atr[sig_idx]
if not np.isfinite(a) or a <= 0:
continue
sl = entry - d * sl_atr * a
tp = entry + d * tp_atr * a
best = entry
exit_idx, exit_px, reason = None, None, "time"
boosted = False
for j in range(e_idx + 1, min(e_idx + max_bars + 1, n)):
if boost_dir is not None and not boosted and boost_dir[j] == d:
tp = entry + d * tp_atr * tp_boost * a
if boost_breakeven:
sl = max(sl, entry) if d == 1 else min(sl, entry)
boosted = True
if trail:
best = max(best, high[j]) if d == 1 else min(best, low[j])
sl = max(sl, best - sl_atr * a) if d == 1 else min(sl, best + sl_atr * a)
hit_sl = low[j] <= sl if d == 1 else high[j] >= sl
hit_tp = high[j] >= tp if d == 1 else low[j] <= tp
if hit_sl:
exit_idx, exit_px, reason = j, sl, "sl"
break
if hit_tp:
exit_idx, exit_px, reason = j, tp, "tp"
break
if exit_idx is None:
exit_idx = min(e_idx + max_bars, n - 1)
exit_px = close[exit_idx]
gross = d * (exit_px - entry) / entry
out.append(Trade(sig_idx, exit_idx, d, entry, float(exit_px),
gross - fee - slippage, reason, exit_idx - e_idx,
gross, sl_atr * a / entry, boosted))
return pd.DataFrame([t.__dict__ for t in out])
def run_trades_dynamic(
df: pd.DataFrame,
entries: list[tuple[int, int]],
upgrades: dict[int, int],
sl_atr: float = 1.5,
tp_atr: float = 3.0,
tp_atr_up: float = 6.0,
max_bars: int = 48,
max_bars_up: int = 96,
lock_breakeven: bool = True,
fee: float = FEE,
entry_delay: int = 0,
slippage: float = 0.0,
) -> pd.DataFrame:
"""持仓中若出现更大级别的同向确认,就把目标放远、并把止损收到成本价。
upgrades: {K线索引: 方向},表示该根出现了大级别同向三买/中枢突破。
对应的交易逻辑是「小级别进场、大级别接力」——趋势被更高级别确认后,
原本 3 ATR 的目标就过早了,但同时不该再让这笔回到亏损。
"""
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
close = df["close"].to_numpy(dtype=float)
open_ = df["open"].to_numpy(dtype=float) if "open" in df.columns else close
atr = (
df["atr"].to_numpy(dtype=float)
if "atr" in df.columns
else pd.Series(close).rolling(14).std().bfill().to_numpy()
)
n = len(df)
out: list[dict] = []
for e_idx, d in entries:
sig_idx = e_idx
e_idx = e_idx + entry_delay
if e_idx >= n - 1:
continue
entry = open_[e_idx] if entry_delay else close[e_idx]
a = atr[sig_idx]
if not np.isfinite(a) or a <= 0:
continue
sl = entry - d * sl_atr * a
tp = entry + d * tp_atr * a
limit = max_bars
upgraded = False
exit_idx, exit_px, reason = None, None, "time"
j = e_idx + 1
while j < min(e_idx + limit + 1, n):
if not upgraded and upgrades.get(j) == d:
upgraded = True
tp = entry + d * tp_atr_up * a
limit = max_bars_up
if lock_breakeven:
sl = max(sl, entry) if d == 1 else min(sl, entry)
hit_sl = low[j] <= sl if d == 1 else high[j] >= sl
hit_tp = high[j] >= tp if d == 1 else low[j] <= tp
if hit_sl:
exit_idx, exit_px, reason = j, sl, "be" if upgraded and sl == entry else "sl"
break
if hit_tp:
exit_idx, exit_px, reason = j, tp, "tp_up" if upgraded else "tp"
break
j += 1
if exit_idx is None:
exit_idx = min(e_idx + limit, n - 1)
exit_px = close[exit_idx]
gross = d * (exit_px - entry) / entry
out.append({
"entry_idx": sig_idx, "exit_idx": exit_idx, "direction": d,
"entry": entry, "exit": float(exit_px),
"ret": gross - fee - slippage, "reason": reason,
"bars_held": exit_idx - e_idx, "gross": gross,
"risk_pct": sl_atr * a / entry, "upgraded": upgraded,
})
return pd.DataFrame(out)
def summarize_trades(tr: pd.DataFrame, label: str) -> dict:
if tr.empty:
return {"策略": label, "笔数": 0}
r = tr["ret"].to_numpy()
win = r[r > 0]
loss = r[r <= 0]
pf = win.sum() / abs(loss.sum()) if len(loss) and loss.sum() != 0 else np.inf
sd = r.std(ddof=1)
eq = np.cumprod(1 + r)
dd = float((1 - eq / np.maximum.accumulate(eq)).max()) if len(eq) else 0.0
return {
"策略": label,
"笔数": len(r),
"胜率": f"{(r > 0).mean() * 100:.1f}%",
"均收益": f"{r.mean() * 100:+.3f}%",
"赔率": f"{(win.mean() / abs(loss.mean())):.2f}" if len(win) and len(loss) else "—",
"盈亏比PF": f"{pf:.2f}",
"总收益": f"{(eq[-1] - 1) * 100:+.1f}%",
"最大回撤": f"{dd * 100:.1f}%",
"t值": f"{r.mean() / (sd / np.sqrt(len(r))):+.2f}" if sd else "—",
"均持有": f"{tr['bars_held'].mean():.0f}",
}
def find_breakout_entries(
sig: pd.DataFrame, df: pd.DataFrame, window: int = 10, mode: str = "fail"
) -> list[tuple[int, int]]:
"""分型突破入场点。
mode="fail" 分型失败 -> 顺势跟随:顶分型被向上突破则做多。
mode="reverse" 传统反转 -> 分型成立方向:顶分型做空(作为对照)。
"""
close = df["close"].to_numpy(dtype=float)
n = len(df)
entries: list[tuple[int, int]] = []
for _, r in sig.iterrows():
d_fx = int(r["direction"]) # +1 底分型 / -1 顶分型
lvl = float(r["price"])
c0 = int(r["confirm_idx"])
if mode == "reverse":
entries.append((c0, d_fx))
continue
# 突破方向与分型指向相反:顶分型(-1)被向上(+1)突破
d_bo = -d_fx
for j in range(c0 + 1, min(c0 + window + 1, n)):
broken = close[j] > lvl if d_bo == 1 else close[j] < lvl
if broken:
entries.append((j, d_bo))
break
return entries