删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
240 lines
8.6 KiB
Python
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
|