Files
jackandCursor 75fbf4167b 修掉 gzip 追加会毁掉整个文件的数据丢失,并分开买卖两侧的成交分布曲线
两件事,都是「静默出错」那一类。

一、BookLog/TapeLog 追加到同一个 .gz,进程被 SIGKILL 时当前成员停在 deflate
块中间,下一轮追加的新成员接在垃圾字节之后。顺序解压在损坏点抛 invalid
block type,该点之后全部读不出来——包括后续每轮写进去的。而读侧的异常处理
把这个当成「正常的尾部截断」静默跳过,于是只读出 21 行还不报错。
原 docstring 里写的「只丢最后一个缓冲块,不会毁掉整个文件」是错的,已证伪。

写侧改成每轮运行一个文件;读侧按 gzip 成员边界扫描、坏成员单独跳过并出声
报告,同时把同前缀的多轮文件一并读入。旧损坏文件因此多恢复出 31/21 条
(tape)与 132/95 条(books)。

二、tape_shape 只统计主动买、只自区间顶部累积,这条曲线只适用于多头止盈。
exit_fill 两侧共用它,等于把空头的可成交量按多头分布高估。实测二者不对称:
主动买在顶部 20% 内已占 40%,主动卖在底部 20% 内只有 18%。分成 SHAPE_F 与
SHAPE_F_SHORT,avail_at 按方向查各自曲线。

两条曲线只有 45 根成交流样本,所以补了 --sensitivity:把空头可成交量砍一半,
10 万仓位下 BTC/ETH 预算完全不动、SOL 动 0.07bp。结论不依赖这 45 根样本。
顺带撤掉 step43 docstring 里已作废的「成交率 30%/16%/1.5%」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:23:07 +08:00

240 lines
11 KiB
Python
Raw Permalink 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])
# 空头止盈挂买回,靠**主动卖**打下来 —— 自区间底部向上累积。
# 两侧并不对称:主动买集中在区间顶部(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)