Files
Chan/research/live/shadow_depth.py
T
jackandCursor 181bca303f 影子测量改用框架吃单原语,并补齐容量与 maker 成交率两项测算
吃单查询换成 Hummingbot 的 OrderBook.get_vwap_for_volume:手写的 walk_book
返回的是按计价币吃单的加权均价,但框架的 get_price_for_quote_volume 返回
边际价、get_vwap_for_volume 收基础币量,两者语义不同。改为按基础币下单
(真实委托与 PositionExecutor.amount 均是基础币计价),深度不足由
query_volume/result_volume 判定,框架此时返回 nan 而非一个看似正常的
部分成交均价。

落盘完整盘口(双边 50 档)。此前只记三个固定名义额的成交价,这批数据的
寿命就等于那几个档位的寿命;存完整深度后任意资金量级的冲击都能离线重算。
仓位档同时从 1k/5k/20k 提到十万量级,此前低估真实仓位约两个数量级。

订阅成交流,按根按价位聚合。买卖分开存——多头在目标位挂卖出靠主动买盘
成交,混在一起会把成交率高估约一倍。BTC 每根总成交额中位与 210 天历史
的 volume×close 差 0.3%,可确认采集完整。

新增两项测算:
- 冲击不是绑定约束。32 万仓位单边冲击 0.19~2.39bp,对 8.58~20.64bp 的
  预算只占 1.6~14.2%,冲击反推的资金上限 100~500 万。
- maker 成交率才是。止盈位被首次触及时,限价在该根价格区间中的位置
  中位 k=0.28(63.9 万次触及,三币一致);合并每根成交额后,32 万仓位
  的全额成交率仅 30.1%/15.6%/1.5%。要 80% 全额成交,仓位须 ≤ 4.7 万
  /1.4 万/0.26 万——比冲击反推的上限低 40~370 倍。

回测把这些止盈按「全额成交在目标价」计,故预算所依据的收益流本身需重估。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:39:47 +08:00

288 lines
12 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.
"""从落盘的完整盘口与成交流,算资金容量与 maker 腿成交率。
这两个数都不该等实盘暴露:
**容量**。预算 20bp 意味着存在一个资金上限,超过它策略就不工作。既然完整
深度已落盘,任意仓位的冲击都能重算——一次采集回答所有资金量级,换个规模
不必重测一周。绑定约束是**薄盘时段**而非中位盘口,所以按分位数报。
**maker 成交率**。回测假设 3ATR / 8ATR 的限价单全额成交。深度回答不了这个
问题:深度说的是「现在挂着多少」,成交率问的是「之后打过来多少」。只有
成交流能回答,而且买卖必须分开——多头在 3ATR 挂卖出,靠主动买盘成交。
读 gzip 时必须容忍末尾成员不完整:采集进程还在写,最后一个 gzip 成员没有
结尾标记,直接遍历会在文件尾抛 EOFError 而丢掉**全部**已读记录。
"""
from __future__ import annotations
import argparse
import gzip
import json
from pathlib import Path
import numpy as np
import pandas as pd
def out_dir() -> Path:
p = Path("/out")
return p if p.is_dir() else Path(__file__).resolve().parents[1] / "out"
def read_jsonl_gz(path: Path):
"""逐行读 gzip JSONL,末尾截断则静默停止。
采集仍在进行时,最后一个 gzip 成员缺结尾标记;不接这个异常的话,
整个分析会因为文件尾而失败,前面几万条完好记录一起丢掉。
"""
if not path.exists():
return
n_ok = 0
try:
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
try:
rec = json.loads(line)
except json.JSONDecodeError:
break # 半行,说明写到这里被打断
n_ok += 1
yield rec
except (EOFError, OSError, gzip.BadGzipFile):
# 采集进程正在写,尾部不完整属正常
pass
def impact_bp(levels: list, notional: float, mid: float) -> float | None:
"""吃掉 notional 计价币后的加权均价相对中间价,bp。深度不足返回 None。"""
need = notional
cost = 0.0
qty = 0.0
for px, amt in levels:
avail = px * amt
take = min(avail, need)
q = take / px
cost += q * px
qty += q
need -= take
if need <= 1e-9:
break
if need > 1e-9 or qty <= 0:
return None
return (cost / qty / mid - 1.0) * 1e4
def capacity(books_path: Path, budgets: dict[str, float],
pctl: float = 10.0) -> None:
"""报各币的深度曲线与「冲击吃掉预算多少」的资金上限。"""
grid = [1e4, 5e4, 1e5, 2e5, 3.2e5, 5.3e5, 1e6, 2e6, 5e6]
per: dict[str, dict[float, list[float]]] = {}
n = 0
for r in read_jsonl_gz(books_path):
asks, bids = r["asks"], r["bids"]
if not asks or not bids:
continue
mid = (asks[0][0] + bids[0][0]) / 2.0
d = per.setdefault(r["sym"], {g: [] for g in grid})
for g in grid:
v = impact_bp(asks, g, mid)
d[g].append(np.nan if v is None else v)
n += 1
if not n:
print("没有盘口快照,先跑采集")
return
print(f"\n########## 资金容量 ##########")
print(f" 基于 {n:,} 份完整盘口快照(单边买入方向)\n")
for sym, d in per.items():
b = budgets.get(sym)
print(f" {sym} 预算 {b:.2f}bp" if b else f" {sym}")
print(f" {'名义额':>12} {'冲击中位':>10} {'冲击P90':>10} "
f"{'吃满深度率':>10} {'占预算':>8}")
for g in grid:
a = np.array(d[g], dtype=float)
fill = float(np.isfinite(a).mean())
if fill == 0:
print(f" {g:>12,.0f} {'—— 50 档吃不下 ——':>30}")
continue
med = float(np.nanmedian(a))
p90 = float(np.nanpercentile(a, 90))
share = f"{med / b * 100:6.1f}%" if b else " na"
print(f" {g:>12,.0f} {med:>10.2f} {p90:>10.2f} "
f"{fill * 100:>9.1f}% {share:>8}")
if b:
# 上限:冲击的 P90(薄盘时段)吃掉预算三成为止。三成是留给
# 漂移与价差的余地——它们才是主项,冲击不该独占预算
cap = None
for g in grid:
a = np.array(d[g], dtype=float)
if not np.isfinite(a).any():
break
if float(np.nanpercentile(a, 90)) > b * 0.30:
break
cap = g
if cap is None:
print(f" → 连最小档 {grid[0]:,.0f} 的薄盘冲击都超预算三成")
else:
print(f" → 资金上限约 {cap:,.0f} USDT"
f"(薄盘 P90 冲击 ≤ 预算 30%")
print()
def maker_fill(tape_path: Path, mults=(3.0, 8.0),
notionals=(1e5, 3.2e5, 5.3e5)) -> None:
"""限价单挂在离场目标位,本根内有多少主动量打到那里。
这里只回答「量够不够」。真实成交还要看排队位置——我们的单排在该价位
已有挂单之后,所以这是**上界**:量不够则必然不能全成交,量够也未必成交。
"""
rows = list(read_jsonl_gz(tape_path))
if not rows:
print("没有成交流数据,先跑采集")
return
print(f"\n########## maker 腿成交量上界 ##########")
print(f" 基于 {len(rows):,} 根的逐价位成交聚合")
print(f" 多头在目标位挂卖出,成交靠主动**买**盘,故只计买方向\n")
# 限价单只能被**价格 ≥ 限价**的主动买成交打到。而止盈位被触及的那一根,
# 限价往往就落在该根价格区间的顶部——最高价刚好碰到目标位是最典型的
# 情形。所以按「限价距最高价多近」分层:depth=0 表示限价正好在最高价
# (只有打在最高价那一档的量算数),depth=0.25 表示限价在区间顶部 25% 处
depths = (0.0, 0.10, 0.25, 1.0)
per: dict[str, dict[float, list[float]]] = {}
for r in rows:
buys = {float(p): v for p, v in r["buys"].items()}
d = per.setdefault(r["sym"], {k: [] for k in depths})
if not buys:
for k in depths:
d[k].append(0.0)
continue
hi, lo = max(buys), min(buys)
rng = hi - lo
for k in depths:
floor_px = hi - k * rng
d[k].append(sum(p * v for p, v in buys.items() if p >= floor_px))
for sym, d in per.items():
print(f" {sym} ≥ 限价的主动买成交额(USDT),按限价所处位置分层")
print(f" {'限价位置':>16} {'中位':>12} {'P25':>12} "
+ " ".join(f"{n:>9,.0f}全仓" for n in notionals))
for k in depths:
a = np.array(d[k], dtype=float)
where = ("正好在最高价" if k == 0 else
"整根全部成交" if k == 1.0 else
f"区间顶部 {k * 100:.0f}%")
cells = " ".join(f"{float((a >= n).mean()) * 100:8.1f}%"
for n in notionals)
print(f" {where:>16} {np.median(a):>12,.0f} "
f"{np.percentile(a, 25):>12,.0f} {cells}")
print()
print(" 「正好在最高价」那一行才是止盈被刚好触及时的真实处境;")
print(" 「整根全部成交」是最宽松的上界。两行差多少,就是回测那个")
print(" 「限价单全额成交」假设虚了多少。而且这仍未计排队——我们的单")
print(" 排在该价位既有挂单之后,所以真实成交率比表里更低")
def tape_shape(tape_path: Path, kgrid: np.ndarray) -> dict[str, np.ndarray]:
"""成交流给「形状」:一根的主动买成交额里,有多少比例落在区间顶部 k 之内。
形状与规模分开是为了绕开成交流样本小的限制——形状是微观结构性质,
几十根就相当稳定;规模(每根成交多少钱)则由 210 天历史成交量提供。
"""
acc: dict[str, list[np.ndarray]] = {}
for r in read_jsonl_gz(tape_path):
buys = {float(p): v for p, v in r["buys"].items()}
if len(buys) < 2:
continue
hi, lo = max(buys), min(buys)
rng = hi - lo
if rng <= 0:
continue
tot = sum(p * v for p, v in buys.items())
if tot <= 0:
continue
frac = np.array([sum(p * v for p, v in buys.items()
if p >= hi - k * rng) / tot for k in kgrid])
acc.setdefault(r["sym"], []).append(frac)
return {s: np.mean(np.vstack(v), axis=0) for s, v in acc.items() if v}
def composite_fill(tape_path: Path, pen_path: Path, cache: Path,
notionals=(1e5, 3.2e5, 5.3e5)) -> None:
"""把穿透深度分布与成交量曲线合并,出真实 maker 成交率。"""
if not pen_path.exists():
print("\n没有 penetration.csv,先跑 penetration.py")
return
kgrid = np.linspace(0.0, 1.0, 51)
shape = tape_shape(tape_path, kgrid)
if not shape:
print("\n成交流样本不足,无法定形状")
return
pen = pd.read_csv(pen_path)
print(f"\n\n########## maker 腿真实成交率 ##########")
print(f" 穿透深度分布(历史 63 万次触及)× 每根成交额(210 天)")
print(f" × 区间内成交分布形状(影子成交流)\n")
for sym in sorted(shape):
cands = sorted(cache.glob(f"bitget_{sym}_1m_*.feather"),
key=lambda p: p.stat().st_size, reverse=True)
if not cands:
continue
bars = pd.read_feather(cands[0])
# 每根的主动买成交额。取总成交额的一半——买卖大致均衡,且这与
# 成交流实测的买卖比一致
bar_notional = (bars["volume"].to_numpy(float)
* bars["close"].to_numpy(float)) * 0.5
bar_notional = bar_notional[np.isfinite(bar_notional)
& (bar_notional > 0)]
f = shape[sym]
for tgt in sorted(pen["target_atr"].unique()):
k = pen[(pen["sym"] == sym)
& (pen["target_atr"] == tgt)]["k"].to_numpy(float)
if not k.size:
continue
# 独立配对:穿透位置与该根成交额各自抽样。真实触及根多为放量根,
# 故此处偏**保守**(低估可成交量)
rng = np.random.default_rng(0)
m = 200_000
ks = rng.choice(k, m)
ns = rng.choice(bar_notional, m)
avail = np.interp(ks, kgrid, f) * ns
print(f" {sym} · 目标 {tgt:g}ATR · 每根主动买额中位 "
f"{np.median(bar_notional):,.0f} USDT")
for nt in notionals:
full = float((avail >= nt).mean())
half = float((avail >= nt / 2).mean())
print(f" 仓位 {nt:>9,.0f}:全额成交 {full * 100:5.1f}%"
f" · 至少半额 {half * 100:5.1f}%"
f" · 可成交额中位 {np.median(avail):>10,.0f}")
# 成交率反推的资金上限。这才是绑定约束——它比冲击反推的上限
# 低一到两个数量级,而后者才是通常被当作「容量」的那个数
for want in (0.80, 0.90):
cap = float(np.quantile(avail, 1.0 - want))
print(f" → 要 {want * 100:.0f}% 的止盈全额成交,"
f"仓位须 ≤ {cap:,.0f} USDT")
print()
print(" 未计排队(我们的单排在该价位既有挂单之后),故仍是上界。")
print(" 回测把这些止盈按「全额成交在目标价」计,差多少就是收益虚多少")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--books", default=None)
ap.add_argument("--tape", default=None)
a = ap.parse_args()
d = out_dir()
from lib.shadow_budget import BUDGET_BP
tape = Path(a.tape) if a.tape else d / "shadow_tape.jsonl.gz"
capacity(Path(a.books) if a.books else d / "shadow_books.jsonl.gz",
BUDGET_BP)
maker_fill(tape)
composite_fill(tape, d / "penetration.csv",
Path(__file__).resolve().parent / "cache")
if __name__ == "__main__":
main()