"""出场模拟,但止盈按限价单的**真实成交量**结算,而非假定全额成交。 ## 为什么要另写一份 `exit_model.walk_exits` 给止盈记的毛收益是 `target * a / entry`,即假定挂在 目标价的限价单全额成交在目标价。影子交易的成交流数据显示这个假定在真实 仓位上不成立:止盈位被首次触及那一根,限价落在该根价格区间中的位置中位 k≈0.28,而该位置之上可供成交的主动买量,对 32 万仓位只够覆盖 30%/16%/1.5% (BTC/ETH/SOL)。 不成交不等于仓位消失——它继续持有,结果从「继续走下去」的分布里抽,其中 包含反转打到止损。所以这不是给预算打折能修的事,是出场规则变了。 ## 模型 两张挂单常驻:半仓在 `scale_at`、半仓在 `runner`。每根按该根在限价之上的 可成交量逐步吃进,未成交部分继续持有;整仓止损始终有效,触发时未成交的 部分市价平掉;到 `maxb` 根仍未了结的按收盘市价平。 某根在限价 P 之上的可成交量: P ≤ low 整根成交量都在限价之上 avail = V P > high 该根没到限价 avail = 0 否则 k = (high−P)/(high−low) avail = f(k) × V `V` 是该根的**主动买**成交额(多头出场靠主动买盘打上来)。实测买卖大致 均衡,取总成交额的一半;以 BTC 校验,历史 `volume×close` 中位与影子成交流 实测差 0.3%。`f` 由成交流定,实测几乎是线性(f(k)≈k,即区间内均匀分布), 所以结论对形状假设不敏感。 ## 仍然乐观的两处 1. **未计排队**。我们的单排在该价位既有挂单之后,真实成交更少。 2. **未计自身的流动性效应**。大单挂在 3ATR 会吸收本该冲到 8ATR 的买盘, 即两张挂单在真实市场里互相竞争,此处按独立处理。 两处都指向同一方向:真实成交率比本模型更低。 """ from __future__ import annotations import numpy as np import pandas as pd from lib.exit_model import FEE_MAKER, FEE_TAKER, SLIP # 成交流实测的区间内成交分布形状(BTC/ETH/SOL 均值,见 shadow_depth.tape_shape) SHAPE_K = np.linspace(0.0, 1.0, 21) # 多头止盈挂卖出,靠**主动买**打上来 —— 自区间顶部向下累积 SHAPE_F = np.array([0.044, 0.088, 0.110, 0.179, 0.204, 0.240, 0.282, 0.316, 0.390, 0.430, 0.465, 0.537, 0.583, 0.617, 0.662, 0.714, 0.761, 0.804, 0.857, 0.904, 1.000]) # 空头止盈挂买回,靠**主动卖**打下来 —— 自区间底部向上累积。 # 两侧并不对称:主动买集中在区间顶部(k=0.2 处已 40%),主动卖在底部只有 # 18%,BTC/ETH 平均偏差 0.21。原先两侧共用 SHAPE_F,等于把空头的可成交量 # 按多头的分布高估。 # ⚠ 这条曲线只有 45 根样本(每币 14~16),够说明「不对称」这个方向,不够 # 定具体数值——一周数据到手要重新导出。用它而非 SHAPE_F 的理由是方向正确 # 优于数值精确:结论对形状不敏感(见 step43_fill_aware_budget 的敏感性检查), # 但用错方向是系统性偏乐观。 SHAPE_F_SHORT = np.array([0.031, 0.076, 0.121, 0.193, 0.230, 0.265, 0.297, 0.356, 0.382, 0.420, 0.453, 0.485, 0.523, 0.552, 0.586, 0.629, 0.681, 0.754, 0.813, 0.871, 1.000]) TAKER_SHARE = 0.5 def avail_at(price: float, hi: float, lo: float, vol_notional: float, is_long: bool, kgrid=SHAPE_K, f=None, f_short=None) -> float: """该根里能打到限价 `price` 的对手方成交额。 多头在 `price` 挂卖出,靠价格 ≥ price 的主动买成交;空头挂买回,靠 价格 ≤ price 的主动卖成交。方向用显式参数而非「把价格取负」——取负会 让所有价格变成负数,任何对价格正负的假设都会静默失效。 两个方向查各自的成交分布曲线(实测二者不对称,见 SHAPE_F_SHORT)。 """ f = SHAPE_F if f is None else f f_short = SHAPE_F_SHORT if f_short is None else f_short if vol_notional <= 0 or not (np.isfinite(hi) and np.isfinite(lo)): return 0.0 if hi <= lo: # 该根无波动:只要限价被覆盖就算整根可成交 return vol_notional if (hi >= price if is_long else lo <= price) \ else 0.0 if is_long: if price > hi: return 0.0 # 该根没涨到限价 if price <= lo: return vol_notional # 整根都在限价之上 k = (hi - price) / (hi - lo) else: if price < lo: return 0.0 # 该根没跌到限价 if price >= hi: return vol_notional # 整根都在限价之下 k = (price - lo) / (hi - lo) return float(np.interp(k, kgrid, f if is_long else f_short)) * vol_notional def walk_filled(cdf: pd.DataFrame, sig: pd.DataFrame, notional: float, sl: float = 2.0, scale_at: float = 3.0, runner: float = 8.0, runner_stop: float = 2.0, maxb: int = 48, taker_share: float = TAKER_SHARE, f=None, f_short=None) -> pd.DataFrame: """前推每笔信号,返回按成交量结算的出场权重与毛收益。 每行的 `w_*` 是各出场去向占**全仓名义额**的比例,四者相加为 1。 `f` / `f_short` 是两个方向的区间内成交分布曲线,留出接口是为了能做敏感性 检查——这两条曲线目前只有 45 根样本,必须能验证结论对它们不敏感。 """ high = cdf["high"].to_numpy(float) low = cdf["low"].to_numpy(float) close = cdf["close"].to_numpy(float) open_ = cdf["open"].to_numpy(float) vol = cdf["volume"].to_numpy(float) atr = cdf["atr"].to_numpy(float) n = len(cdf) 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] cap = min(e + maxb, n - 1) p_stop = entry - d * sl * a p_scale = entry + d * scale_at * a p_run = entry + d * runner * a # 两张挂单各半仓,单位是「占全仓的比例」 rem_scale, rem_run = 0.5, 0.5 w_scale = w_run = w_stop = w_time = 0.0 scaled_any = False stop_bar = None for j in range(e, cap + 1): hi, lo = high[j], low[j] # 同根内止损优先,与 walk_exits 一致,宁可低估 hit_stop = (lo <= p_stop) if d == 1 else (hi >= p_stop) if hit_stop: stop_bar = j w_stop = rem_scale + rem_run rem_scale = rem_run = 0.0 break v = vol[j] * close[j] * taker_share is_long = d == 1 if rem_scale > 0: got = avail_at(p_scale, hi, lo, v, is_long, SHAPE_K, f, f_short) fill = min(rem_scale, got / notional) if notional > 0 else \ rem_scale if fill > 0: rem_scale -= fill w_scale += fill scaled_any = True if rem_run > 0: got = avail_at(p_run, hi, lo, v, is_long, SHAPE_K, f, f_short) fill = min(rem_run, got / notional) if notional > 0 else \ rem_run if fill > 0: rem_run -= fill w_run += fill if rem_scale <= 1e-12 and rem_run <= 1e-12: break # 减仓成交后,剩余半仓的止损位可以另设;此处 runner_stop 等于初始 SL # 即止损不动,与 step42 的 k=2.0 一致,故上面那个统一止损已覆盖 left = rem_scale + rem_run if left > 1e-12 and stop_bar is None: w_time = left r_scale = d * (p_scale - entry) / entry r_run = d * (p_run - entry) / entry r_stop = d * (p_stop - entry) / entry r_time = d * (close[cap] - entry) / entry gross = (w_scale * r_scale + w_run * r_run + w_stop * r_stop + w_time * r_time) # 入场整仓 taker;两张挂单成交的部分是 maker;止损与超时是 taker fee = (FEE_TAKER * 1.0 + FEE_MAKER * (w_scale + w_run) + FEE_TAKER * (w_stop + w_time)) tk = 1.0 + w_stop + w_time out.append({"sig_idx": s, "direction": d, "atr_pct": a / entry, "w_scale": w_scale, "w_run": w_run, "w_stop": w_stop, "w_time": w_time, "maker_share": w_scale + w_run, "gross": gross, "fee": fee, "taker_notional": tk, "scaled": int(scaled_any)}) return pd.DataFrame(out) def budget_bp(r: pd.DataFrame) -> float: """盈亏平衡的单边滑点上限(bp)。与 exit_model.slip_budget 同口径。""" if r.empty: return float("nan") net = r["gross"].mean() - r["fee"].mean() return net / r["taker_notional"].mean() * 1e4 def assert_converges(cdf: pd.DataFrame, sig: pd.DataFrame, sl: float = 2.0, scale_at: float = 3.0, runner: float = 8.0, runner_stop: float = 2.0, maxb: int = 48, tol: float = 1e-9) -> None: """仓位趋近 0 时必须逐笔收敛到 exit_model.walk_exits,否则抛错。 这个断言是必需的。首版实现里空头的可成交量恒为 0(负价格空间踩到了一个 `hi <= 0` 的守卫),后果是空头全被拖到超时收盘——而下跌段里那比 3ATR 目标赚得多,于是预算反而**偏高** 0.76bp,看上去像个合理的模型差异。 没有这条断言,这种错只会表现为「数字有点不一样」。 """ from lib.exit_model import cfg_name, walk_exits old = walk_exits(cdf, sig, [sl], [scale_at], [maxb], scale_at, [runner], [runner_stop]) c = cfg_name(sl, runner, maxb, runner_stop) new = walk_filled(cdf, sig, 1e-12, sl, scale_at, runner, runner_stop, maxb) j = old[["sig_idx"]].copy() j["g_old"] = old[f"{c}_g"].to_numpy() j = j.merge(new[["sig_idx", "gross"]], on="sig_idx") d = (j["gross"] - j["g_old"]).abs() bad = int((d > tol).sum()) if bad: worst = j.loc[d.idxmax()] raise AssertionError( f"仓位趋近 0 时应与 walk_exits 一致,但 {bad}/{len(j)} 笔不符;" f"最大差 {d.max():.3e}(sig_idx {int(worst['sig_idx'])}:" f"老 {worst['g_old']:.6f} 新 {worst['gross']:.6f})") def net_bp(r: pd.DataFrame, slip: float = SLIP) -> float: """扣掉手续费与滑点后的净均收益(bp)。""" if r.empty: return float("nan") net = r["gross"] - r["fee"] - slip * r["taker_notional"] return float(net.mean() * 1e4)