出场模型改为按成交量结算止盈限价单,并出预算对仓位规模的曲线

exit_model.walk_exits 给止盈记的毛收益是 target*a/entry,即假定限价单全额
成交在目标价。新增 lib/exit_fill.py:两张挂单常驻(半仓 3ATR、半仓 8ATR),
每根按该根在限价之上的可成交量逐步吃进,未成交部分继续持有,止损触发时
市价平掉剩余。可成交量 = 形状函数 f(k) × 该根主动买成交额,f 由影子成交流
实测(近似线性,即区间内均匀分布,故结论对形状假设不敏感)。

结果:预算对仓位规模远比预期稳健。到 100 万名义额,BTC 10.97→10.69、
ETH 15.34→15.20、SOL 17.21→15.83bp。原因是挂单常驻多根而非只在首次触及
那一根成交,且价格决定性穿过限价时整根成交量都可用。

首版实现有个静默 bug 值得记:avail_above 里有个 `hi <= 0` 的守卫,而空头
用「价格取负」处理,负价格空间里 hi 恒为负——所有空头挂单的可成交量一律
判 0,空头全被拖到 48 根超时收盘。下跌段里那比 3ATR 目标赚得多,于是预算
反而偏高 0.76bp,表现为「一个看似合理的模型差异」。已改为显式方向参数,
并加 assert_converges:仓位趋近 0 时必须逐笔收敛到 walk_exits,不符即抛错。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 02:39:47 +08:00
co-authored by Cursor
parent 181bca303f
commit 28075173af
3 changed files with 407 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
"""出场模拟,但止盈按限价单的**真实成交量**结算,而非假定全额成交。
## 为什么要另写一份
`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 = (highP)/(highlow) 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])
TAKER_SHARE = 0.5
def avail_at(price: float, hi: float, lo: float, vol_notional: float,
is_long: bool, kgrid=SHAPE_K, f=SHAPE_F) -> float:
"""该根里能打到限价 `price` 的对手方成交额。
多头在 `price` 挂卖出,靠价格 ≥ price 的主动买成交;空头挂买回,靠
价格 ≤ price 的主动卖成交。方向用显式参数而非「把价格取负」——取负会
让所有价格变成负数,任何对价格正负的假设都会静默失效。
"""
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)) * 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) -> pd.DataFrame:
"""前推每笔信号,返回按成交量结算的出场权重与毛收益。
每行的 `w_*` 是各出场去向占**全仓名义额**的比例,四者相加为 1。
"""
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)
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)
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)