用户 2026-08-28 确认资金规模不会更大,故仓位档改为 25k/50k/100k/200k (上下留档是为了读出局部斜率,单点看不出再大一倍会怎样)。该规模下两条 约束都不绑定:冲击占预算 0.1~11.1%,成交量效应使预算降幅不足 1%。 新增 queue_ahead:排队是唯一还没建模的成本项,exit_fill 假定我们能吃到该 价位的全部对手方成交量。10 万仓位相对最优档为 BTC 0.2 倍、ETH 0.7 倍、 SOL 69.7 倍。SOL 畸高是 tick 更细所致(同样的量摊到约 10 倍价位上), 对它应看 5bp 档(0.17 倍),但仍是三币中排队压力最大者。 同时在 shadow_depth 模块头标注:composite_fill 只算首次触及那一根的可成交 量,系统性偏悲观,不作为成交率结论——真实成交率见 lib/exit_fill.py。 Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
16 KiB
Python
349 lines
16 KiB
Python
"""从落盘的完整盘口与成交流,算资金容量、排队量与单根成交率。
|
||
|
||
## 结论先行(2026-08-28)
|
||
|
||
主口径仓位 **10 万 USDT**。该规模下两条约束都不绑定:
|
||
|
||
冲击 单边 0.01~1.87bp,占预算 0.1~11.1%,冲击反推上限 100~500 万
|
||
成交率 按逐根累积结算,预算降幅 BTC −0% / ETH −0% / SOL −1.1%
|
||
|
||
⚠ 本文件里的 `composite_fill` 曾给出「10 万仓位全额成交率仅 7~63%」这种数,
|
||
那个口径只算**首次触及那一根**的可成交量,系统性偏悲观,已不作为结论。
|
||
真实成交率见 lib/exit_fill.py(挂单常驻多根逐步成交)。
|
||
|
||
|
||
这两个数都不该等实盘暴露:
|
||
|
||
**容量**。预算 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 queue_ahead(books_path: Path, notional: float = 1e5) -> None:
|
||
"""限价单排在多少量之后。
|
||
|
||
这是唯一还没建模的成本项。exit_fill 假定我们能吃到该价位的全部对手方
|
||
成交量,实际上我们排在该价位既有挂单之后。
|
||
|
||
这里量不了 3ATR 处的排队——3ATR 约 30bp,而 50 档盘口只覆盖 1.4~4.5bp,
|
||
那个价位远在可见深度之外。但价格走到我们的限价时,我们的单就成了盘口
|
||
最优档附近的一员,所以「最优档通常趴着多少量」是个有用的上界参照。
|
||
"""
|
||
per: dict[str, dict[str, list[float]]] = {}
|
||
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"], {"top": [], "b1": [], "b5": []})
|
||
d["top"].append(asks[0][0] * asks[0][1])
|
||
for tag, bp in (("b1", 1.0), ("b5", 5.0)):
|
||
lim = mid * (1 + bp / 1e4)
|
||
d[tag].append(sum(p * a for p, a in asks if p <= lim))
|
||
if not per:
|
||
return
|
||
print(f"\n\n########## 限价单前方的排队量 ##########")
|
||
print(f" 主口径仓位 {notional:,.0f} USDT\n")
|
||
for sym, d in sorted(per.items()):
|
||
top = np.array(d["top"]); b1 = np.array(d["b1"])
|
||
b5 = np.array(d["b5"])
|
||
print(f" {sym} 最优档 {np.median(top):>10,.0f} · "
|
||
f"1bp 内累计 {np.median(b1):>10,.0f} · "
|
||
f"5bp 内累计 {np.median(b5):>10,.0f}")
|
||
print(f" 我们的 {notional:,.0f} 相当于最优档的 "
|
||
f"{notional / max(np.median(top), 1):.1f} 倍、"
|
||
f"1bp 内总量的 {notional / max(np.median(b1), 1):.2f} 倍")
|
||
print("\n 倍数远小于 1 则排队可忽略;接近或超过 1 则我们本身就是那一档的")
|
||
print(" 主要挂单,exit_fill 的成交量假设需要打折。")
|
||
print(" SOL 的最优档倍数畸高是 tick 更细所致(Bitget 的 SOL tick 比同类")
|
||
print(" 细约 10 倍,同样的量摊到 10 倍多的价位上),所以对 SOL 该看 5bp")
|
||
print(" 档而非最优档;但即便如此它仍是三个币里排队压力最大的一个")
|
||
|
||
|
||
def capacity(books_path: Path, budgets: dict[str, float],
|
||
pctl: float = 10.0) -> None:
|
||
"""报各币的深度曲线与「冲击吃掉预算多少」的资金上限。"""
|
||
grid = [1e4, 2.5e4, 5e4, 1e5, 2e5, 5e5, 1e6, 2e6]
|
||
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=(5e4, 1e5, 2e5)) -> 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=(5e4, 1e5, 2e5)) -> None:
|
||
"""把穿透深度分布与成交量曲线合并,出**单根**内的 maker 成交率。
|
||
|
||
⚠ 这个数只回答「限价单若仅有首次触及那一根可以成交,能否成交」。真实的
|
||
挂单是常驻的:它在那儿放最多 48 根,每根都在成交,且价格决定性穿过限价
|
||
时整根成交量都可用。所以本函数系统性**偏悲观**,不能当作成交率结论。
|
||
真实成交率见 lib/exit_fill.walk_filled 与 step43_fill_aware_budget.py,
|
||
那里按逐根累积结算,10 万仓位下预算降幅不足 1%。
|
||
"""
|
||
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"
|
||
books = Path(a.books) if a.books else d / "shadow_books.jsonl.gz"
|
||
capacity(books, BUDGET_BP)
|
||
queue_ahead(books)
|
||
maker_fill(tape)
|
||
composite_fill(tape, d / "penetration.csv",
|
||
Path(__file__).resolve().parent / "cache")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|