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