Files
Chan/research/lib/exit_model.py
T
jackyu66gitandCursor 32c18f0201 research: 1m 出场口径重定、费率修正,与影子测量的判据常数
起因是用户看图指出「止盈没做好」,查下来 TP=3.0 确实把右尾截早了,
而且 1m 不该沿用 5m/15m/30m 的参数——成本固定在 bp、目标随 ATR 缩放,
1m 的 3 ATR 只有 0.39% 而 30m 是 1.87%,成本占比差 5 倍。

step41(5m/15m/30m)与 step42(1m)跑同一张全网格:
SL × TP × MAX_BARS × 分批(3 ATR 减半 → 剩余目标 × 剩余半仓止损位)。

- 1m 最优 SL2 / 3ATR 减半 / 剩余止损保持 2.0 / 目标 8ATR / 48 根,
  样本外 8/8 币、7/7 年全面提升,均R/R夏普/回撤/剔10%PF 四项全赢
- 分批要做,但**减仓后不要动止损**。止损位 0/0.5/1/1.5/2 ATR 严格单调,
  越紧越差,三组初始 SL 全一致。保本损是全表最差的一档
- SL=1.0 在 1m 上是废的:剔10%PF 0.78~0.99、中位收益 −0.122%

费率此前写的 taker 3bp / maker 1bp 隐含「原始 taker 6bp」的错误前提,
实际是原始 taker 0.040% / maker 0.016%、返 50% 后 2.0 / 0.8bp。方向是保守的,
所以首轮跑出来的数字全部偏低。exit_model 已改,费率只在分析阶段套用,
不必重跑模拟。改完 1m 的均R +8%,5m/15m/30m 只动 2%——费率只对 1m 有杠杆。

顺带查证了用户的一个假设:余量逐年递减是不是跟波动率有关。成立,而且
r = +0.989。毛/ATR 七年在 2.26~2.67 之间没有趋势,衰减的是 ATR 本身
(2021 的 22.1bp 压到 2026 的 8.8bp)。**是波动率压缩,不是 alpha 衰减。**

由此引出 ATR 门控:低 ATR 桶的毛 R 其实最高(1.16 vs 高 ATR 桶的 0.94),
断崖只在扣费之后出现。所以阈值是**费率的函数**(约 5 + 1.1×taker费),
不是市场常数。当前费率下 ≥8bp,在 5m/15m/30m 上几乎不触发,可作全局规则。

lib/shadow_budget.py 放影子测量要对照的常数:逐币预算、门控阈值、
腿→maker/taker 映射、lag 阈值、判据。记录与报表归 research/live/,
分工的理由是这些数会变——今天预算就动了四次。

out/*.feather 转为 ignore:70MB+ 且重跑可得,摘要都在 HANDOFF。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 00:05:56 +08:00

237 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""出场模拟与费率模型,step415m/15m/30m)与 step421m)共用。
单独抽出来是因为两边必须用同一份实现——`fast_bsp3` 当初分散在两处的教训。
## 费率模型(用户 2026-08-27 指出)
原先所有数字都按「双边 taker」算(§5.3 的 6bp),这在两个方向上都错了:
费率档位记高了(见下方 FEE_TAKER 注释),且没有区分出场性质——
入场 信号在收盘出现,次根开盘市价单进场 → **taker + 滑点**
止盈 挂在目标价的限价单被动成交 → **maker,无滑点**
止损 stop-market,触发后市价成交 → **taker + 滑点**
超时 到点市价平 → **taker + 滑点**
止损做不成 makerstop-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}%"}