Files
Chan/research/lib/exit_fill.py
T
jackandCursor 28075173af 出场模型改为按成交量结算止盈限价单,并出预算对仓位规模的曲线
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>
2026-08-28 02:39:47 +08:00

219 lines
9.4 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.
"""出场模拟,但止盈按限价单的**真实成交量**结算,而非假定全额成交。
## 为什么要另写一份
`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)