"""出场模拟与费率模型,step41(5m/15m/30m)与 step42(1m)共用。 单独抽出来是因为两边必须用同一份实现——`fast_bsp3` 当初分散在两处的教训。 ## 费率模型(用户 2026-08-27 指出) 原先所有数字都按「双边 taker」算(§5.3 的 6bp),这在两个方向上都错了: 费率档位记高了(见下方 FEE_TAKER 注释),且没有区分出场性质—— 入场 信号在收盘出现,次根开盘市价单进场 → **taker + 滑点** 止盈 挂在目标价的限价单被动成交 → **maker,无滑点** 止损 stop-market,触发后市价成交 → **taker + 滑点** 超时 到点市价平 → **taker + 滑点** 止损做不成 maker:stop-limit 在急跌里可能不成交,那种情况下的损失远大于 省下的 2bp。所以按出场原因分别计费,不是一刀切。 分批离场不额外增加费用:手续费按名义额收,入场 1.0、出场 0.5+0.5,总额不变。 但**第一批必然是止盈成交(maker)**,这正是分批在成本上占便宜的地方。 ## 出场配置 整仓 `s{SL}_tp{TP}_m{MAXB}` 分批 `s{SL}_so{目标}_k{剩余半仓止损}_m{MAXB}` 到 3 ATR 平一半,剩余半仓止损挪到「开仓价下方 k 个 ATR」。 k=0 即保本损,k 等于原 SL 即止损不动,k 更大则是主动放宽。 ## 实现 每笔只前推两遍:第一遍记录各价位的首次触及根,第二遍从减仓根起记录剩余 半仓各止损位的首次触及根。之后所有配置都是解析推导,不再重复走 K 线。 同一根内止损与目标并存时一律判止损先到,宁可低估。 """ from __future__ import annotations import numpy as np import pandas as pd # 单边费率。用户 2026-08-27 给的实际档位:返佣前 taker 0.040% / maker 0.016%, # API 返 50% → taker 0.020% / maker 0.008%。 # 注意:此前这里写的是 0.030/0.010,隐含「原始 taker 6bp」的错误前提, # 所以 step41/42 首轮跑出来的所有数字都偏保守(taker 高估 50%)。 FEE_TAKER = 0.00020 FEE_MAKER = 0.00008 SLIP = 0.00010 TP, SL_, TIME = 0, 1, 2 # 出场原因编码 def taker_notional(reason: np.ndarray, scaled: np.ndarray) -> np.ndarray: """每笔走 taker 的名义额(入场 1.0,加上非止盈出场的部分)。滑点只发生在这上面。""" exit_taker = np.where(reason == TP, 0.0, 1.0) exit_taker = np.where(scaled == 1, 0.5 * exit_taker, exit_taker) return 1.0 + exit_taker def fee_of(reason: np.ndarray, scaled: np.ndarray, fee_taker: float = FEE_TAKER, fee_maker: float = FEE_MAKER) -> np.ndarray: """只算手续费,不含滑点。分批时第一半必然是止盈成交(maker)。""" exit_fee = np.where(reason == TP, fee_maker, fee_taker) exit_fee = np.where(scaled == 1, 0.5 * fee_maker + 0.5 * exit_fee, exit_fee) return fee_taker + exit_fee def cost_of(reason: np.ndarray, scaled: np.ndarray, slip: float = SLIP) -> np.ndarray: """新口径:入场 taker、止盈 maker、止损/超时 taker,滑点只加在 taker 腿上。""" return fee_of(reason, scaled) + slip * taker_notional(reason, scaled) def all_taker_cost(reason: np.ndarray, scaled: np.ndarray) -> np.ndarray: """旧口径:进出都当 taker,双边费 + 双边滑点。用来对照新旧差多少。""" return np.full(len(reason), 2.0 * (FEE_TAKER + SLIP)) def flat_cost(reason: np.ndarray, scaled: np.ndarray) -> np.ndarray: """研究口径的固定 5bp,用于和 step28/35/37 的历史数字对齐。""" return np.full(len(reason), 0.0005) def slip_budget(gross: np.ndarray, reason: np.ndarray, scaled: np.ndarray) -> float: """盈亏平衡的单边滑点上限(bp):毛收益扣掉手续费后,摊到走 taker 的名义额上。""" net_of_fee = gross.mean() - fee_of(reason, scaled).mean() return net_of_fee / taker_notional(reason, scaled).mean() * 10000 def cfg_name(sl: float, target, maxb: int, rstop=None) -> str: """整仓 s{SL}_tp{T}_m{B};分批 s{SL}_so{T}_k{K}_m{B}。target=None 表示不设目标。""" t = f"{target:g}" if target is not None else "R" if rstop is None: return f"s{sl:g}_tp{t}_m{maxb}" return f"s{sl:g}_so{t}_k{rstop:g}_m{maxb}" def walk_exits(cdf: pd.DataFrame, sig: pd.DataFrame, sls, tps, maxbs, scale_at: float = 3.0, runners=(5.0, 6.0, 8.0, None), runner_stops=(0.0, 0.5, 1.0, 1.5, 2.0)) -> pd.DataFrame: """前推每笔信号,解析出全部出场配置的结果。 每个配置四列:`{cfg}_g` 毛收益率、`{cfg}_r` 出场原因、`{cfg}_c` 是否分批、 `{cfg}_b` 持仓根数。另有 `s{SL}_mfe` 各初始止损下的最大有利偏移。 """ high = cdf["high"].to_numpy(float) low = cdf["low"].to_numpy(float) open_ = cdf["open"].to_numpy(float) close = cdf["close"].to_numpy(float) atr = cdf["atr"].to_numpy(float) n = len(cdf) horizon = max(maxbs) ups = sorted(set(list(tps) + [scale_at] + [r for r in runners if r])) downs = sorted(set(list(sls) + list(runner_stops))) out = [] for s, d in zip(sig["entry_idx"].astype(int), sig["direction"].astype(int)): e = s + 1 if e >= n - 1: continue a = atr[s] if not np.isfinite(a) or a <= 0: continue entry = open_[e] end = min(e + horizon, n - 1) # 第一遍:各价位的首次触及根(与止损无关,纯价格事件) up_bar = {t: None for t in ups} dn_bar = {L: None for L in downs} mfe = {sl: 0.0 for sl in sls} alive = {sl: True for sl in sls} for j in range(e, end + 1): adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a for L in downs: if dn_bar[L] is None and ret >= L: dn_bar[L] = j for sl in sls: if alive[sl]: if dn_bar[sl] is not None and dn_bar[sl] == j: alive[sl] = False # 同根内止损优先,不更新 MFE elif adv > mfe[sl]: mfe[sl] = adv for t in ups: if up_bar[t] is None and adv >= t: up_bar[t] = j for sl in sls: mfe[sl] = mfe[sl] # 第二遍:从减仓根起,剩余半仓各止损位的首次触及根 j0 = up_bar[scale_at] dn_after = {L: None for L in runner_stops} if j0 is not None: for j in range(j0, end + 1): ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a for L in runner_stops: if dn_after[L] is None and ret >= L: dn_after[L] = j if all(v is not None for v in dn_after.values()): break row = {"sig_idx": s, "direction": d, "atr_pct": a / entry} row.update({f"s{sl:g}_mfe": mfe[sl] for sl in sls}) def resolve(target, stop_bar, stop_ret, cap, start): """在 cap 根之前,目标与止损谁先到。返回 (毛收益率, 原因, 出场根)。""" tb = up_bar[target] if target is not None else None if tb is not None and tb <= cap and (stop_bar is None or tb < stop_bar): return target * a / entry, TP, tb if stop_bar is not None and stop_bar <= cap: return stop_ret, SL_, stop_bar k = min(cap, n - 1) return d * (close[k] - entry) / entry, TIME, k for sl in sls: dead = dn_bar[sl] for b in maxbs: cap = e + b for t in tps: g, r, xb = resolve(t, dead, -sl * a / entry, cap, e) c = cfg_name(sl, t, b) row[f"{c}_g"], row[f"{c}_r"], row[f"{c}_c"], row[f"{c}_b"] = g, r, 0, xb - e + 1 # 分批:先看能否走到减仓点 scaled_ok = (j0 is not None and j0 <= cap and (dead is None or j0 < dead)) for rn in runners: for k in runner_stops: c = cfg_name(sl, rn, b, k) if not scaled_ok: g, r, xb = resolve(scale_at, dead, -sl * a / entry, cap, e) row[f"{c}_g"], row[f"{c}_r"] = g, r row[f"{c}_c"], row[f"{c}_b"] = 0, xb - e + 1 else: rg, rr, rxb = resolve(rn, dn_after[k], -k * a / entry, cap, j0) row[f"{c}_g"] = 0.5 * (scale_at * a / entry) + 0.5 * rg row[f"{c}_r"], row[f"{c}_c"] = rr, 1 row[f"{c}_b"] = rxb - e + 1 out.append(row) return pd.DataFrame(out) def stat(g: pd.DataFrame, cfg: str, label: str, cost_fn=cost_of, minn: int = 25) -> dict: """给一个配置出统计。`滑点余量bp` 是滑点的盈亏平衡上限。""" gross = g[f"{cfg}_g"].to_numpy() if len(gross) < minn: return {} reason, scaled = g[f"{cfg}_r"].to_numpy(), g[f"{cfg}_c"].to_numpy() r = gross - cost_fn(reason, scaled) w, o = r[r > 0], r[r <= 0] sd = r.std(ddof=1) t10 = r[r <= np.quantile(r, 0.90)] return {"口径": label, "笔数": len(r), "胜率": f"{(r > 0).mean() * 100:.1f}%", "毛bp": f"{gross.mean() * 10000:.2f}", "净均收益": f"{r.mean() * 100:+.3f}%", "中位": f"{np.median(r) * 100:+.3f}%", "PF": f"{w.sum() / abs(o.sum()):.2f}" if len(o) else "inf", "t值": f"{r.mean() / (sd / np.sqrt(len(r))):+.2f}", "剔10%PF": f"{t10[t10 > 0].sum() / abs(t10[t10 <= 0].sum()):.2f}" if (t10 <= 0).any() else "inf", "滑点余量bp": f"{slip_budget(gross, reason, scaled):.2f}", "止盈占比": f"{(reason == TP).mean() * 100:.0f}%", "均持仓": f"{g[f'{cfg}_b'].mean():.1f}"} def rstat(g: pd.DataFrame, cfg: str, label: str, sl: float, cost_fn=cost_of) -> dict: """R 倍数口径:固定风险仓位下,不同初始止损之间唯一可比的量。 SL 越宽,每笔风险越大、仓位越小,直接比百分比收益会把仓位差异算成策略优势。 """ gross = g[f"{cfg}_g"].to_numpy() reason, scaled = g[f"{cfg}_r"].to_numpy(), g[f"{cfg}_c"].to_numpy() net = gross - cost_fn(reason, scaled) risk = sl * g["atr_pct"].to_numpy() r = net / risk return {"口径": label, "笔数": len(r), "风险%": f"{risk.mean() * 100:.2f}%", "净均收益": f"{net.mean() * 100:+.3f}%", "均R": f"{r.mean():+.3f}", "中位R": f"{np.median(r):+.3f}", "R夏普": f"{r.mean() / r.std(ddof=1):.3f}", "最差1%R": f"{np.percentile(r, 1):.2f}", "胜率": f"{(r > 0).mean() * 100:.1f}%"}