"""从落盘的完整盘口与成交流,算资金容量与 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()