影子测量改用框架吃单原语,并补齐容量与 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>
This commit is contained in:
jack
2026-08-28 02:39:47 +08:00
co-authored by Cursor
parent 6259f7380b
commit 181bca303f
10 changed files with 1503 additions and 174 deletions
+9 -6
View File
@@ -5,9 +5,11 @@
的标准差。入场方向上还有系统性追价(信号触发往往伴随同向动量),所以随机
漂移只是下限,真实成本更高——这也是为什么最终仍要用真实盘口测滑点。
余量(bitget_baseline.py 得出,Bitget 原生基线减去手续费后剩下的空间):
BTC -0.13bp ETH +4.02bp SOL +2.92bp
BTC 本就为负,留着只作延迟测量的参照物,不作交易标的。
预算从 `lib/shadow_budget` import,不在这里写死。曾经写死的
`{BTC: -0.13, ETH: 4.02, SOL: 2.92}` 是错的——那是 `bitget_baseline.py`
按「毛均 − 6bp 双边 taker」算的,六处口径叠加(费率档位记高、余量没除
taker 名义额、用了 5m~30m 的出场参数、只有同向没有阶梯与 ATR 门控)。
正确值是 8.58 / 20.64 / 16.83ETH 差了五倍。
.venv/bin/python research/live/latency_compare.py
"""
@@ -23,8 +25,9 @@ HERE = Path(__file__).resolve().parent
RESEARCH = HERE.parent
sys.path.insert(0, str(RESEARCH))
from lib.shadow_budget import budget_of # noqa: E402
OUT = RESEARCH / "out"
BUDGET_BP = {"BTC": -0.13, "ETH": 4.02, "SOL": 2.92}
SYMS = ("BTC", "ETH", "SOL")
@@ -67,8 +70,8 @@ def describe(df: pd.DataFrame, name: str, vols: dict) -> None:
med, p90 = float(np.median(v)), float(np.percentile(v, 90))
vol = vols.get(s)
d = drift_bp(med, vol) if vol else float("nan")
b = BUDGET_BP[s]
share = f"{d / b * 100:.0f}%" if b > 0 else "(负)"
b = budget_of(s)
share = f"{d / b * 100:.0f}%" if np.isfinite(b) and b > 0 else ""
print(f"{s:<5}{len(v):>5}{med:>9.0f}{p90:>9.0f}{v.max():>9.0f}"
f"{d:>12.2f}{b:>9.2f}{share:>9}")
+61
View File
@@ -14,9 +14,21 @@
再把最后一根交给基类走正常的 append 流程。
已向上游反馈前,本地用子类覆盖,不改动镜像。
## 为什么必须有启动断言
子类覆盖的失效方式是**静默**的:上游若把 `_parse_websocket_message` 改名、
或改走别的钩子,我们的覆盖就成了死代码,行情悄悄退回慢 1.06 秒,不崩、
不报错、不留日志,只会让收益慢慢变差,几周后才从统计里看出来。
`assert_patch_effective()` 不做名字检查——名字对不上未必失效,名字对得上
也未必生效。它喂一条合成的两元素消息,直接验证行为:基类返回首元素(bug
仍在、覆盖仍有必要),子类返回末元素(覆盖确实生效)。再加一条源码检查
确认基类的收包循环还在调这个钩子。任一不满足就在启动时抛错。
"""
from __future__ import annotations
import inspect
from typing import Any, Dict, Optional
import numpy as np
@@ -24,6 +36,7 @@ import numpy as np
from hummingbot.data_feed.candles_feed.bitget_perpetual_candles import (
BitgetPerpetualCandles,
)
from hummingbot.data_feed.candles_feed.candles_base import CandlesBase
def _row_to_dict(row: list, ensure_s) -> Dict[str, Any]:
@@ -63,3 +76,51 @@ class PatchedBitgetPerpetualCandles(BitgetPerpetualCandles):
d["volume"], d["quote_asset_volume"], d["n_trades"],
d["taker_buy_base_volume"], d["taker_buy_quote_volume"]]
).astype(float)
# 换根时 Bitget 推的就是这个形状:[上一根, 新一根]
_PROBE = {
"action": "update",
"arg": {"instType": "USDT-FUTURES", "channel": "candle1m",
"instId": "BTCUSDT"},
"data": [
["1700000040000", "1", "1", "1", "1", "1", "1", "1"],
["1700000100000", "2", "2", "2", "2", "2", "2", "2"],
],
}
def assert_patch_effective() -> None:
"""启动即验证覆盖真的生效,否则抛错。让静默失效变成启动失败。"""
src = inspect.getsource(CandlesBase._process_websocket_messages_task)
if "_parse_websocket_message" not in src:
raise RuntimeError(
"上游收包循环已不再调用 _parse_websocket_message"
"patched_candles 的覆盖失效。需重新定位钩子后再启动。")
stock = BitgetPerpetualCandles("BTC-USDT", "1m", 20)
ours = PatchedBitgetPerpetualCandles("BTC-USDT", "1m", 20)
got_stock = stock._parse_websocket_message(_PROBE)
got_ours = ours._parse_websocket_message(_PROBE)
head_ts = stock.ensure_timestamp_in_seconds(int(_PROBE["data"][0][0]))
tail_ts = stock.ensure_timestamp_in_seconds(int(_PROBE["data"][-1][0]))
if not got_ours or int(got_ours["timestamp"]) != int(tail_ts):
raise RuntimeError(
f"覆盖未生效:子类返回 {got_ours and got_ours.get('timestamp')}"
f"应为末元素 {tail_ts}")
if got_stock and int(got_stock["timestamp"]) == int(tail_ts):
# 上游自己修好了。此时覆盖无害但已多余,明确说出来,免得以后
# 有人以为那 1.06 秒还是靠我们拿回来的
print(" [补丁] 上游已自行修正换根解析,本地覆盖现为冗余,可移除",
flush=True)
elif not got_stock or int(got_stock["timestamp"]) != int(head_ts):
raise RuntimeError(
f"基类行为与预期不符:返回 "
f"{got_stock and got_stock.get('timestamp')}"
f"既非首元素 {head_ts} 也非末元素 {tail_ts}"
f"上游改了解析逻辑,补丁的前提需重新确认。")
else:
print(f" [补丁] 覆盖生效:基类取首元素 {int(head_ts)}"
f"本地取末元素 {int(tail_ts)}", flush=True)
+152
View File
@@ -0,0 +1,152 @@
"""止盈位被首次触及那一根,价格穿透了多深。
为什么要这个数:影子成交流显示,限价单若正好落在某根的最高价,该价位之上
的主动买成交额只有几十到几千美元——对十万量级的仓位等于不成交。但那是
最坏情形。真实成交率取决于**止盈位被穿透了多深**:若价格一路冲过目标,
成交没问题;若只是上影线点一下就回落,就成交不了。
这个分布不需要再采数据,历史 K 线里就有:给定入场价与 ATR,目标位是
`entry + T×ATR`,找到首次 `high ≥ target` 的那根,穿透深度就是
`high − target`。把它折成「占该根价格区间的比例」,就能直接对上成交流
那条「≥ 限价的成交额 vs 限价在区间中的位置」曲线。
口径与 lib/exit_model.walk_exits 对齐:入场取信号次根开盘价,ATR 取信号
根的 Wilder ATR-14,上限 48 根。
**取样方式的局限**:这里用全体 K 线做候选入场点,而非真实的三滤网信号。
真实信号是按结构条件挑出来的,入场时刻可能与波动率状态相关。以 ATR 归一
后的穿透深度对波动率状态应当不敏感,但要精确到信号级别,得重跑一次
step42 的缠论链路(单币约 370s、峰值 24.5GB)。
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
SCALE_AT = 3.0 # 分批减仓位
RUNNER = 8.0 # 剩余半仓目标
MAX_BARS = 48
def wilder_atr(high, low, close, period: int = 14) -> np.ndarray:
"""与 chanlun.indicators.ta.ATR 逐位一致的 Wilder ATR。"""
n = high.size
out = np.full(n, np.nan)
if n <= period:
return out
prev_close = close[:-1]
tr = np.maximum.reduce([high[1:] - low[1:],
np.abs(high[1:] - prev_close),
np.abs(low[1:] - prev_close)])
sm = np.empty(tr.size)
sm[period - 1] = tr[:period].mean()
a = 1.0 / period
for k in range(period, tr.size):
sm[k] = sm[k - 1] + a * (tr[k] - sm[k - 1])
out[period:] = sm[period - 1:]
return out
def penetration(df: pd.DataFrame, target_atr: float,
stride: int = 1) -> pd.DataFrame:
"""对每个候选入场点,求首次触及 `target_atr` 时的穿透深度。
只统计**触及了**的那些(未触及的属止损或超时出场,不涉及 maker 腿)。
"""
high = df["high"].to_numpy(float)
low = df["low"].to_numpy(float)
close = df["close"].to_numpy(float)
open_ = df["open"].to_numpy(float)
atr = wilder_atr(high, low, close)
n = len(df)
rows = []
for i in range(20, n - MAX_BARS - 2, stride):
a = atr[i]
if not np.isfinite(a) or a <= 0:
continue
e = i + 1
entry = open_[e]
# 多头:目标在上方。空头对称,穿透深度分布按对称性等价,故只算一边
target = entry + target_atr * a
end = e + MAX_BARS
seg_hi = high[e:end + 1]
hit = np.flatnonzero(seg_hi >= target)
if not hit.size:
continue
j = e + int(hit[0])
rng = high[j] - low[j]
if rng <= 0:
continue
pen = high[j] - target
rows.append({
"bar": j,
# 限价在该根价格区间中的位置:0 = 正好在最高价(最坏),
# 1 = 在最低价(该根全部成交都在限价之上)
"k": min(1.0, pen / rng),
"pen_bp": pen / target * 1e4,
"pen_atr": pen / a,
"range_bp": rng / target * 1e4,
})
return pd.DataFrame(rows)
def report(sym: str, df: pd.DataFrame) -> dict:
print(f"\n{'=' * 68}\n{sym}{len(df):,} 根 1m")
out = {}
for tgt, name in ((SCALE_AT, f"减仓位 {SCALE_AT:g}ATR"),
(RUNNER, f"目标位 {RUNNER:g}ATR")):
p = penetration(df, tgt)
if p.empty:
print(f" {name}: 无触及样本")
continue
k = p["k"].to_numpy()
print(f"\n {name} · 触及 {len(p):,}")
print(f" 穿透深度 中位 {p['pen_bp'].median():.2f}bp "
f"({p['pen_atr'].median():.2f} ATR) · "
f"P25 {p['pen_bp'].quantile(.25):.2f}bp · "
f"P75 {p['pen_bp'].quantile(.75):.2f}bp")
print(f" 限价在区间中的位置 k(0=正好在最高价,越大越靠下越易成交)")
print(f" 中位 {np.median(k):.3f} · P10 {np.percentile(k, 10):.3f}"
f" · P25 {np.percentile(k, 25):.3f}"
f" · P75 {np.percentile(k, 75):.3f}")
for thr in (0.05, 0.10, 0.25, 0.50):
print(f" k ≤ {thr:.2f}(限价挤在该根顶部 {thr * 100:.0f}% 内):"
f"{float((k <= thr).mean()) * 100:5.1f}% 的触及")
out[tgt] = p
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--syms", default="BTC,ETH,SOL")
ap.add_argument("--cache", default="research/live/cache")
ap.add_argument("--save", default="research/out/penetration.csv")
a = ap.parse_args()
root = Path(a.cache)
allp = []
for sym in a.syms.split(","):
cands = sorted(root.glob(f"bitget_{sym}_1m_*.feather"),
key=lambda p: p.stat().st_size, reverse=True)
if not cands:
print(f"{sym}: 找不到 1m 缓存,跳过")
continue
df = pd.read_feather(cands[0])
got = report(sym, df)
for tgt, p in got.items():
p = p.copy()
p["sym"], p["target_atr"] = sym, tgt
allp.append(p)
if allp:
out = pd.concat(allp, ignore_index=True)
Path(a.save).parent.mkdir(parents=True, exist_ok=True)
out.to_csv(a.save, index=False)
print(f"\n已存 {a.save}{len(out):,} 行),"
f"供 shadow_depth.py 合并成交量曲线")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""真实 Bitget 盘口在 BOOK_DEPTH 档内能不能吃下各个名义额档位。
shadow_hb 把吃单换成了框架的 get_vwap_for_volume,深度不足时它返回 nan、
整行标 depth_ok=0。所以「档数够不够」直接决定某个仓位档会不会整段丢失,
不是个可以事后补救的参数——先量出来再定 BOOK_DEPTH。
"""
from __future__ import annotations
import asyncio
import sys
import numpy as np
sys.path.insert(0, "/repo/research/live")
from shadow_hb import BOOK_DEPTH, NOTIONALS, SYMS, book_from
async def run() -> None:
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import (
BitgetPerpetualDerivative,
)
conn = BitgetPerpetualDerivative(
bitget_perpetual_api_key="", bitget_perpetual_secret_key="",
bitget_perpetual_passphrase="",
trading_pairs=[f"{s}-USDT" for s in SYMS], trading_required=False)
await conn.start_network()
print(f"连接器已启动,等盘口(档数上限 {BOOK_DEPTH}")
for _ in range(60):
await asyncio.sleep(1)
try:
if all(conn.get_order_book(f"{s}-USDT") is not None for s in SYMS):
break
except Exception:
continue
for s in SYMS:
ob = conn.get_order_book(f"{s}-USDT")
bids = np.array([(float(r.price), float(r.amount), i)
for i, (r, _) in enumerate(
zip(ob.bid_entries(), range(BOOK_DEPTH)))])
asks = np.array([(float(r.price), float(r.amount), i)
for i, (r, _) in enumerate(
zip(ob.ask_entries(), range(BOOK_DEPTH)))])
mid = (bids[0][0] + asks[0][0]) / 2.0
snap = book_from(bids, asks)
ask_notional = float((asks[:, 0] * asks[:, 1]).sum())
print(f"\n{s} 中价 {mid:.2f} · 取到 {len(asks)} 档 · "
f"卖盘 {len(asks)} 档合计 {ask_notional:,.0f} USDT")
print(f" 最深一档距中价 "
f"{(asks[-1][0] / mid - 1) * 1e4:.1f}bp")
for notional in NOTIONALS:
base = notional / mid
r = snap.get_vwap_for_volume(True, base)
px = float(r.result_price)
ok = float(r.result_volume) >= base * 0.999
if ok:
print(f" 名义 {notional:>7,.0f}{base:.6f} 币 · "
f"冲击 {(px / mid - 1) * 1e4:6.2f}bp · 吃得下")
else:
print(f" 名义 {notional:>7,.0f}{base:.6f} 币 · "
f"深度不足,仅 {r.result_volume:.6f} 币 · "
f"这一档会整段丢失")
await conn.stop_network()
if __name__ == "__main__":
asyncio.run(run())
+287
View File
@@ -0,0 +1,287 @@
"""从落盘的完整盘口与成交流,算资金容量与 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()
+458 -85
View File
@@ -10,8 +10,25 @@
为什么不能用 paper trade 的成交:那是 Hummingbot 自己的撮合模型模拟的,
测出来是模型行为不是市场行为。
口径对齐 aggregate_robustness.py:回测假设成交在**信号次根的开盘价**
口径对齐 step42_exit_tp_1m.py:回测假设成交在**信号次根的开盘价**
(entry_delay=1),所以基准价就是换根后新一根的 open。滑点为正表示比回测差。
滤网(同向 + 中枢阶梯 + ATR 门控)在 shadow_signal.py 里,必须与预算同源。
### 统计口径三条硬要求
1. **主口径只用 pass_all 的信号根**。未过滤的照记但只作提前读数——
在我们根本不会下单的根上测滑点会把判据算宽
2. **条件漂移与无条件漂移分开报**。所以每根 K 线都记一份漂移
(shadow_drift.csv),不只信号根。两者的差就是「系统性追价」的大小
3. **出场腿按 maker/taker 分开**。止盈挂限价不吃滑点,把那 60% 混进
平均值会低估真实成本。出场腿属持仓管理,尚未实现
### lag 探针:超阈值要停开仓,不能只打日志
补丁只防得住「上游代码变了」,防不住 Bitget 再改一次消息格式。每根记
本地接收 − K 线收盘,近 30 根取中位数,超 800ms 即判该币不健康。
要停开仓是因为这种退化是**经济性且静默**的:不崩不报错,只让收益慢慢
变差,几周后才从统计里看得出来。影子期不下单,故落到 lag_ok 字段上。
### 盘口滚动缓冲把延迟变成自变量
@@ -38,22 +55,32 @@ from __future__ import annotations
import argparse
import asyncio
import csv
import gzip
import json
import math
import time
from collections import deque
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures.process import BrokenProcessPool
from pathlib import Path
import numpy as np
import pandas as pd
from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
SYMS = ("BTC", "ETH", "SOL")
# 多存一根:deque 尾部是尚未收盘的当前根,剔除后正好剩 step39 定下的窗口
LTF_BARS, HTF_BARS = 2001, 801 # 有效窗口 2000 / 800,命中率在此饱和
BOOK_HZ = 10 # 盘口采样 10Hz
BOOK_KEEP_S = 30 # 缓冲保留 30 秒,够回查到 +5s
BOOK_DEPTH = 25
BOOK_TOL_MS = 250 # 回查容差:10Hz 正常 ≤100ms,留些余量
BOOK_DEPTH = 50 # 双边各 50 档,实测能撑 78 万~261 万美元
DELAYS_S = (0.5, 1.0, 2.0, 5.0) # 回查点
NOTIONALS = (1_000.0, 5_000.0, 20_000.0)
# 便利视图用的仓位档。**真正的答案在 shadow_books.jsonl.gz 里**——完整盘口
# 落了盘,任意资金量级的冲击都能离线算,换个规模不必重测。这里的档位只是
# 为了让 CSV 直接可读,覆盖到按 2ATR 止损反推的十万量级真实仓位
NOTIONALS = (50_000.0, 100_000.0, 320_000.0, 530_000.0)
NUM_COLS = ["timestamp", "open", "high", "low", "close", "volume"]
@@ -62,6 +89,119 @@ def out_dir() -> Path:
return p if p.is_dir() else Path(__file__).resolve().parents[1] / "out"
def _writer(path: Path, cols: list[str]):
"""追加模式打开;表头对不上就先把旧文件归档。
不校验的话,列一改,DictWriter 会按新顺序把行写到旧表头下面——
读出来整片错位,而且没有任何报错。长跑靠追加续命,这个校验是必需的。
"""
if path.exists() and path.stat().st_size > 0:
with path.open(newline="") as fh:
old = next(csv.reader(fh), [])
if old != cols:
arch = path.parent / "archive"
arch.mkdir(exist_ok=True)
dst = arch / f"{path.stem}_{time.strftime('%Y%m%d_%H%M%S')}.csv"
path.rename(dst)
print(f" [CSV] {path.name} 表头已变,旧数据归档为 {dst.name}",
flush=True)
fresh = not path.exists() or path.stat().st_size == 0
f = path.open("a", newline="")
w = csv.DictWriter(f, fieldnames=cols)
if fresh:
w.writeheader()
return f, w
class BookLog:
"""把完整盘口快照落成 gzip JSONL。
只记「某几个仓位档的成交价」的话,这批数据的寿命就等于那几个档位的寿命:
换一次资金规模就得重跑一周。存完整深度后,任意仓位的冲击都能离线重算,
一次采集回答所有资金量级的问题——包括容量上限那个必须现在就算、
不该等实盘暴露的数。
用 gzip 追加(多个 gzip 成员首尾相接仍可正常解压),进程被杀也只丢最后
一个缓冲块,不会毁掉整个文件。
"""
def __init__(self, path: Path) -> None:
self.path = path
self.fh = gzip.open(path, "at", encoding="utf-8")
self.n = 0
def write(self, sym: str, kline_ts: int, label: str, delay_ms: int,
target: int, book_ts: int, bids: np.ndarray,
asks: np.ndarray) -> None:
# 只留价与量两列,update_id 对离线分析没用。round 到 10 位避免
# float repr 把文件撑大一倍
rec = {"sym": sym, "kline_ts": kline_ts, "label": label,
"delay_ms": delay_ms, "target": target, "book_ts": book_ts,
"bids": [[round(float(p), 10), round(float(a), 10)]
for p, a, *_ in bids],
"asks": [[round(float(p), 10), round(float(a), 10)]
for p, a, *_ in asks]}
self.fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
self.n += 1
def flush(self) -> None:
self.fh.flush()
def close(self) -> None:
try:
self.fh.close()
except Exception:
pass
class TapeLog:
"""按 K 线、按价位聚合成交量,用来判 maker 腿能不能全额成交。
回测假设 3ATR 和 8ATR 的限价单全额成交。十万量级挂在那里,全成交还是
部分成交是完全不同的事——部分成交会把分批出场的收益结构改掉,而这个
问题盘口深度回答不了:深度说的是「现在有多少人挂着」,成交率问的是
「之后有多少人打过来」。只有成交流能回答。
**买卖必须分开存。** 多头在 3ATR 挂卖出止盈,成交靠的是主动**买盘**
打上来;把双边成交量合在一起会把成交率高估约一倍。
聚合到「根 × 价位」而不是逐笔:判据是「本根内有多少量在 ≥ 限价处成交」,
逐笔的时序对这个判据没有增量信息,而聚合能把体量压下两个数量级。
"""
def __init__(self, path: Path) -> None:
self.fh = gzip.open(path, "at", encoding="utf-8")
# sym -> side('b'/'s') -> price -> 累计基础币量
self.acc: dict[str, dict[str, dict[float, float]]] = {}
self.n_trades = 0
def add(self, sym: str, is_buy: bool, price: float, amount: float) -> None:
d = self.acc.setdefault(sym, {"b": {}, "s": {}})
side = d["b"] if is_buy else d["s"]
side[price] = side.get(price, 0.0) + amount
self.n_trades += 1
def flush_bar(self, sym: str, bar_ts: int) -> None:
"""一根走完就把这根的聚合结果落盘并清空。"""
d = self.acc.get(sym)
if not d or (not d["b"] and not d["s"]):
return
rec = {"sym": sym, "bar_ts": bar_ts,
"buys": {f"{p:.10g}": round(v, 10)
for p, v in sorted(d["b"].items())},
"sells": {f"{p:.10g}": round(v, 10)
for p, v in sorted(d["s"].items())}}
self.fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
self.fh.flush()
self.acc[sym] = {"b": {}, "s": {}}
def close(self) -> None:
try:
self.fh.close()
except Exception:
pass
def hb_to_research(cdf: pd.DataFrame) -> pd.DataFrame:
"""Hummingbot 的 candles_df 转成 research/lib/data.py 的列结构。
@@ -77,38 +217,32 @@ def hb_to_research(cdf: pd.DataFrame) -> pd.DataFrame:
.sort_values("timestamp").reset_index(drop=True)
def walk_book(levels: list[tuple[float, float]], notional: float
) -> tuple[float, float]:
"""吃单到 notional(计价币)为止,返回 (加权成交价, 实际吃到的额度)。
def book_from(bids: np.ndarray, asks: np.ndarray):
"""用缓冲里的快照临时搭一个 OrderBook,以便调用框架自带的吃单查询。
深度不足时返回吃到的部分,由调用方按 filled < notional 判断是否可信。
自己手写吃单曾经踩过两个坑,框架版都没有:`get_vwap_for_volume` 返回的
是真加权均价(市价单的实际成交价),而 `get_price_for_quote_volume` 返回
的是**边际价**,用后者会高估冲击;深度不足时框架返回 nan 而不是一个
「看起来很正常」的部分成交均价,靠 query_volume/result_volume 判断。
"""
if not levels:
return float("nan"), 0.0
got = 0.0
cost = 0.0
qty = 0.0
for px, sz in levels:
avail = px * sz
take = min(avail, notional - got)
if take <= 0:
break
q = take / px
cost += q * px
qty += q
got += take
if got >= notional - 1e-9:
break
if qty <= 0:
return float("nan"), 0.0
return cost / qty, got
from hummingbot.core.data_type.order_book import OrderBook
ob = OrderBook()
ob.apply_numpy_snapshot(bids, asks)
return ob
class BookBuffer:
"""每币一份滚动盘口。按时间戳回查,取第一个不早于目标时刻的快照。"""
"""每币一份滚动盘口。按时间戳回查,取第一个不早于目标时刻的快照。
回查必须有容差上界。10Hz 下正常落在目标后 100ms 内(所有延迟点同向
偏约 +50ms,不影响曲线形状),但采样一旦卡顿,标着「0.5s」的那行可能
用的是 +3s 的盘口——数据看不出异常,判读却已经错了。超容差宁可丢弃,
并且把快照实际时刻写进 CSV,让这件事事后可查。
"""
def __init__(self) -> None:
self.buf: dict[str, deque] = {s: deque() for s in SYMS}
self.n_stale = 0 # 因超容差被丢弃的回查次数
def push(self, sym: str, t_ms: int, bids: list, asks: list) -> None:
d = self.buf[sym]
@@ -118,12 +252,13 @@ class BookBuffer:
d.popleft()
def at(self, sym: str, t_ms: int) -> tuple | None:
best = None
for snap in self.buf[sym]:
if snap[0] >= t_ms:
best = snap
break
return best
if snap[0] - t_ms > BOOK_TOL_MS:
self.n_stale += 1
return None
return snap
return None
class Shadow:
@@ -136,30 +271,84 @@ class Shadow:
self.feeds_h: dict = {}
self.connector = None
self.stop = asyncio.Event()
# 持有 fire-and-forget 任务的强引用。只 create_task 不留引用的话,
# 任务可能在完成前被 GC 掉,asyncio 官方文档明确警告过这一点
self._tasks: set = set()
self.n_broken = 0
self._hb_last_bars = 0
# 成交监听:已挂上的币,以及必须持有的 forwarder 强引用
# (PubSub 只存弱引用,不持有的话监听会被 GC 静默摘掉)
self._hooked: set[str] = set()
self._trade_fwd: dict = {}
self.n_signal = 0
self.n_pass = 0
self.n_bars = 0
# lag 探针的滚动窗口,逐币独立:一个币的行情退化不该连累其他币
self.lag_hist: dict[str, deque] = {
s: deque(maxlen=LAG_WINDOW) for s in SYMS}
self.lag_ok: dict[str, bool] = {s: True for s in SYMS}
d = out_dir()
# 追加模式:长跑期间若重启,已收集的样本不该被清掉
p_sig = d / "shadow_signals.csv"
new_sig = not p_sig.exists() or p_sig.stat().st_size == 0
self.f_sig = p_sig.open("a", newline="")
self.w_sig = csv.DictWriter(self.f_sig, fieldnames=[
"sym", "kline_ts", "direction", "h1_agree",
self.f_sig, self.w_sig = _writer(d / "shadow_signals.csv", [
"sym", "kline_ts", "direction",
"h1_agree", "ladder_ok", "gate_ok", "pass_all", "lag_ok",
"atr_pct", "atr_bp",
"t_close_ms", "t_data_ms", "t_signal_ms",
"lag_data_ms", "lag_signal_ms",
"delay_label", "delay_ms", "notional",
"baseline_px", "mid", "best_px", "fill_px", "filled",
"delay_label", "delay_ms", "book_ts", "book_lag_ms",
"notional", "base_amt",
"baseline_px", "mid", "best_px", "fill_px", "filled", "depth_ok",
"slip_bp", "drift_bp", "spread_bp", "impact_bp"])
if new_sig:
self.w_sig.writeheader()
p_lat = d / "shadow_latency.csv"
new_lat = not p_lat.exists() or p_lat.stat().st_size == 0
self.f_lat = p_lat.open("a", newline="")
self.w_lat = csv.DictWriter(self.f_lat, fieldnames=[
self.f_lat, self.w_lat = _writer(d / "shadow_latency.csv", [
"sym", "kline_ts", "t_close_ms", "t_data_ms", "t_signal_ms",
"lag_data_ms", "lag_signal_ms", "compute_ms", "n_bars", "n_hits"])
if new_lat:
self.w_lat.writeheader()
"lag_data_ms", "lag_signal_ms", "compute_ms", "n_bars", "n_hits",
"n_pass", "atr_bp", "lag_med_ms", "lag_ok"])
# 无条件漂移:每根都记,用来和信号根上的条件漂移对照
self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [
"sym", "kline_ts", "delay_label", "delay_ms",
"book_ts", "book_lag_ms", "baseline_px", "mid", "drift_bp_long"])
# 完整深度。挂在无条件漂移那条路径上,所以每根 K 线的四个固定延迟点
# 都有一份,信号根上再补一份 actual 点
self.blog = BookLog(d / "shadow_books.jsonl.gz")
self.tape = TapeLog(d / "shadow_tape.jsonl.gz")
def _spawn(self, coro, what: str) -> None:
"""起一个后台任务,但异常要吼出来。
裸 create_task 的异常只在对象被 GC 时才由 asyncio 打一句
「Task exception was never retrieved」,很容易整晚没人发现。
这套东西最怕的就是不崩不报错的静默退化。
"""
async def guard():
try:
await coro
except asyncio.CancelledError:
raise
except Exception as e:
import traceback
print(f" [异常] {what}: {type(e).__name__}: {e}", flush=True)
traceback.print_exc()
t = asyncio.create_task(guard())
self._tasks.add(t)
t.add_done_callback(self._tasks.discard)
def _restart_pool(self) -> None:
"""进程池坏了之后重建。
用 spawn 而非 fork:此刻进程里已经有活跃的 WS 连接,fork 会把连接
状态一起复制进子进程。spawn 启动慢几秒,但只在故障时走这条路。
"""
import multiprocessing
self.n_broken += 1
try:
self.pool.shutdown(wait=False, cancel_futures=True)
except Exception:
pass
self.pool = ProcessPoolExecutor(
max_workers=self.workers,
mp_context=multiprocessing.get_context("spawn"))
print(f" [进程池] 已重建(第 {self.n_broken} 次)", flush=True)
# ---------- 启动 ----------
@@ -167,7 +356,11 @@ class Shadow:
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import (
BitgetPerpetualDerivative,
)
from patched_candles import PatchedBitgetPerpetualCandles
from patched_candles import (PatchedBitgetPerpetualCandles,
assert_patch_effective)
# 覆盖失效是静默的(悄悄退回慢 1.06 秒,不报错),所以在启动就验一次
assert_patch_effective()
for s in SYMS:
self.feeds_l[s] = PatchedBitgetPerpetualCandles(
@@ -187,6 +380,7 @@ class Shadow:
trading_required=False)
await self.connector.start_network()
print(" 连接器已启动,等盘口与历史回填", flush=True)
self._hook_trades()
t0 = time.time()
while time.time() - t0 < 600:
@@ -200,6 +394,39 @@ class Shadow:
f"1m {[len(self.feeds_l[s]._candles) for s in SYMS]} 根 · "
f"5m {[len(self.feeds_h[s]._candles) for s in SYMS]}", flush=True)
def _hook_trades(self) -> None:
"""给每个盘口挂成交监听。
盘口对象可能还没建好(订阅是异步的),所以挂不上的先记下来,由
watch_bars 那圈重试;一直挂不上会在心跳里显示成交笔数为 0。
"""
from hummingbot.core.event.event_forwarder import EventForwarder
from hummingbot.core.event.events import OrderBookEvent
from hummingbot.core.data_type.common import TradeType
def make(sym: str):
def cb(ev) -> None:
self.tape.add(sym, ev.type == TradeType.BUY,
float(ev.price), float(ev.amount))
return EventForwarder(cb)
for s in SYMS:
if s in self._hooked: # 重复挂会让同一笔成交被记两次
continue
try:
ob = self.connector.get_order_book(f"{s}-USDT")
except Exception:
ob = None
if ob is None:
continue
fwd = make(s)
ob.add_listener(OrderBookEvent.TradeEvent, fwd)
self._trade_fwd[s] = fwd
self._hooked.add(s)
miss = [s for s in SYMS if s not in self._hooked]
print(f" 成交流已挂 {sorted(self._hooked)}"
+ (f",待重试 {miss}" if miss else ""), flush=True)
def _snapshot(self, sym: str):
try:
ob = self.connector.get_order_book(f"{sym}-USDT")
@@ -207,25 +434,41 @@ class Shadow:
return None
if ob is None:
return None
bids = [(float(r.price), float(r.amount))
for r, _ in zip(ob.bid_entries(), range(BOOK_DEPTH))]
asks = [(float(r.price), float(r.amount))
for r, _ in zip(ob.ask_entries(), range(BOOK_DEPTH))]
if not bids or not asks:
# 存成 apply_numpy_snapshot 要的 [价, 量, update_id] 三列,
# 回查时才能直接搭 OrderBook 调框架的吃单查询
bids = np.array([(float(r.price), float(r.amount), i)
for i, (r, _) in enumerate(
zip(ob.bid_entries(), range(BOOK_DEPTH)))])
asks = np.array([(float(r.price), float(r.amount), i)
for i, (r, _) in enumerate(
zip(ob.ask_entries(), range(BOOK_DEPTH)))])
if not len(bids) or not len(asks):
return None
return bids, asks
# ---------- 三个循环 ----------
async def sample_books(self) -> None:
"""按截止时刻补睡,且对齐到墙钟 100ms 网格。
补睡是因为「干完活再睡固定时长」的实际周期是 100ms 加采样耗时,
名义 10Hz 到不了 10Hz。
对齐是因为回查目标都是 `kline_ts + n×500ms`,而 kline_ts 是整分钟,
所以目标必然落在墙钟 100ms 的整数倍上。采样相位若随启动时刻漂移,
每个回查点就会固定晚半个采样周期(实测 52ms)——四个固定延迟点
同向偏置,虽不改曲线形状,但白白多算了 50ms 的漂移。
"""
period = 1.0 / BOOK_HZ
nxt = math.ceil(time.time() / period) * period
while not self.stop.is_set():
t = int(time.time() * 1000)
for s in SYMS:
snap = self._snapshot(s)
if snap:
self.books.push(s, t, snap[0], snap[1])
await asyncio.sleep(period)
nxt += period
await asyncio.sleep(max(0.0, nxt - time.time()))
async def watch_bars(self) -> None:
last = {s: (int(self.feeds_l[s]._candles[-1][0])
@@ -239,7 +482,11 @@ class Shadow:
if last[s] is not None and newest > last[s]:
t_data = int(time.time() * 1000)
kts = newest * 1000 if newest < 1e12 else newest
asyncio.create_task(self.on_bar(s, kts, t_data))
# 刚收盘那根的成交聚合先落盘,再算信号
self.tape.flush_bar(s, kts)
if len(self._hooked) < len(SYMS):
self._hook_trades() # 换根时才重试,避免重复挂
self._spawn(self.on_bar(s, kts, t_data), f"on_bar {s}")
last[s] = newest
await asyncio.sleep(0.01)
@@ -252,15 +499,28 @@ class Shadow:
df_h = df_h[df_h["timestamp"] < kline_ts]
baseline = self._new_bar_open(sym, kline_ts)
lag_med, lag_ok = self._probe_lag(sym, t_data - kline_ts)
t0 = time.perf_counter()
payload = (df_l[NUM_COLS].values.tolist(),
df_h[NUM_COLS].values.tolist())
df_h[NUM_COLS].values.tolist(), baseline)
loop = asyncio.get_running_loop()
from shadow_signal import compute_packed
res = await loop.run_in_executor(self.pool, compute_packed, payload)
try:
res = await loop.run_in_executor(self.pool, compute_packed, payload)
except BrokenProcessPool as e:
# 不重建的话,之后每一根都会走到这里,采集静默停摆到跑完为止
print(f" [{sym}] 进程池损坏 {e},重建后跳过本根", flush=True)
self._restart_pool()
return
compute_ms = int((time.perf_counter() - t0) * 1000)
t_signal = int(time.time() * 1000)
hits = res.get("hits", [])
atr_pct = res.get("atr_pct")
atr_bp = round(atr_pct * 1e4, 3) if atr_pct else ""
n_pass = sum(h["pass_all"] for h in hits)
self.n_bars += 1
self.w_lat.writerow({
"sym": sym, "kline_ts": kline_ts, "t_close_ms": kline_ts,
@@ -268,13 +528,18 @@ class Shadow:
"lag_data_ms": t_data - kline_ts,
"lag_signal_ms": t_signal - kline_ts,
"compute_ms": compute_ms, "n_bars": res.get("n_bars", 0),
"n_hits": len(res.get("hits", []))})
"n_hits": len(hits), "n_pass": n_pass, "atr_bp": atr_bp,
"lag_med_ms": lag_med, "lag_ok": int(lag_ok)})
self.f_lat.flush()
if baseline is not None and np.isfinite(baseline):
# 无条件漂移:每根都记,不管有没有信号
self._spawn(self._drift_later(sym, kline_ts, baseline),
f"drift {sym}")
if res.get("error"):
print(f" [{sym}] 信号计算出错 {res['error']}", flush=True)
return
hits = res.get("hits", [])
if not hits:
return
if baseline is None or not np.isfinite(baseline):
@@ -283,20 +548,73 @@ class Shadow:
for h in hits:
self.n_signal += 1
print(f" ★ [{sym}] {kline_ts} 方向 {h['direction']:+d} "
f"h1_agree={h['h1_agree']} · 数据 {t_data - kline_ts}ms "
self.n_pass += h["pass_all"]
mark = "" if h["pass_all"] else "·"
print(f" {mark} [{sym}] {kline_ts} 方向 {h['direction']:+d} "
f"同向{h['h1_agree']} 阶梯{h['ladder_ok']} 门控{h['gate_ok']} "
f"(ATR {atr_bp or 'na'}bp) · 数据 {t_data - kline_ts}ms "
f"信号 {t_signal - kline_ts}ms", flush=True)
# 最远的回查点在 t_close+5s,此刻尚未发生;等它过去再一次性落盘
asyncio.create_task(
self._record_later(sym, kline_ts, h, t_data, t_signal, baseline))
self._spawn(
self._record_later(sym, kline_ts, h, t_data, t_signal,
baseline, atr_pct, lag_ok),
f"record {sym}")
async def _record_later(self, sym: str, kline_ts: int, hit: dict,
t_data: int, t_signal: int, baseline: float) -> None:
target = kline_ts + int(max(DELAYS_S) * 1000) + 500
wait = target / 1000.0 - time.time()
def _probe_lag(self, sym: str, lag_ms: int) -> tuple[float, bool]:
"""记一根的到达延迟,返回 (滚动中位数, 该币是否健康)。
不健康时应停止开新仓;影子期不下单,故只落到 lag_ok 字段并告警。
"""
self.lag_hist[sym].append(lag_ms)
ok = lag_healthy(self.lag_hist[sym])
med = float(np.median(self.lag_hist[sym]))
if ok != self.lag_ok[sym]:
state = "恢复" if ok else f"退化,超 {LAG_ALARM_MS:.0f}ms 阈值,停开新仓"
print(f" [lag] {sym} {state}:近 {len(self.lag_hist[sym])}"
f"中位 {med:.0f}ms", flush=True)
self.lag_ok[sym] = ok
return round(med, 1), ok
async def _wait_for_delays(self, kline_ts: int) -> None:
"""最远回查点是 t_close+5s,等它过去(多留 0.5s 给采样)。"""
wait = (kline_ts + int(max(DELAYS_S) * 1000) + 500) / 1000.0 - time.time()
if wait > 0:
await asyncio.sleep(wait)
self._record(sym, kline_ts, hit, t_data, t_signal, baseline)
async def _record_later(self, sym: str, kline_ts: int, hit: dict,
t_data: int, t_signal: int, baseline: float,
atr_pct: float | None, lag_ok: bool) -> None:
await self._wait_for_delays(kline_ts)
self._record(sym, kline_ts, hit, t_data, t_signal, baseline,
atr_pct, lag_ok)
async def _drift_later(self, sym: str, kline_ts: int,
baseline: float) -> None:
await self._wait_for_delays(kline_ts)
for label, delay_ms in self._points(None):
target = kline_ts + delay_ms
snap = self.books.at(sym, target)
if snap is None:
continue
book_ts, bids, asks = snap
mid = (float(bids[0][0]) + float(asks[0][0])) / 2.0
self.blog.write(sym, kline_ts, label, delay_ms, target,
book_ts, bids, asks)
self.w_drf.writerow({
"sym": sym, "kline_ts": kline_ts, "delay_label": label,
"delay_ms": delay_ms, "book_ts": book_ts,
"book_lag_ms": book_ts - target, "baseline_px": baseline,
"mid": mid,
"drift_bp_long": round((mid - baseline) / baseline * 1e4, 4)})
self.f_drf.flush()
self.blog.flush() # 每根冲刷一次,进程被杀最多丢一根
@staticmethod
def _points(t_signal_delay: int | None) -> list[tuple[str, int]]:
pts = [(f"{d}s", int(d * 1000)) for d in DELAYS_S]
if t_signal_delay is not None:
pts.insert(0, ("actual", t_signal_delay))
return pts
def _new_bar_open(self, sym: str, kline_ts: int) -> float | None:
"""次根开盘价 = 回测假设的成交价。"""
@@ -309,25 +627,56 @@ class Shadow:
return float(row[1]) if ts == kline_ts else None
def _record(self, sym: str, kline_ts: int, hit: dict,
t_data: int, t_signal: int, baseline: float) -> None:
points = [("actual", t_signal - kline_ts)]
points += [(f"{d}s", int(d * 1000)) for d in DELAYS_S]
t_data: int, t_signal: int, baseline: float,
atr_pct: float | None, lag_ok: bool) -> None:
d_sign = hit["direction"]
for label, delay_ms in points:
snap = self.books.at(sym, kline_ts + delay_ms)
for label, delay_ms in self._points(t_signal - kline_ts):
target = kline_ts + delay_ms
snap = self.books.at(sym, target)
if snap is None:
continue
_, bids, asks = snap
best_bid, best_ask = bids[0][0], asks[0][0]
book_ts, bids, asks = snap
best_bid, best_ask = float(bids[0][0]), float(asks[0][0])
mid = (best_bid + best_ask) / 2.0
# 多头吃卖盘,空头吃买盘
side = asks if d_sign > 0 else bids
best_px = best_ask if d_sign > 0 else best_bid
ob = book_from(bids, asks)
if label == "actual":
# 四个固定点已由无条件漂移那条路径落过,只补这一个
self.blog.write(sym, kline_ts, label, delay_ms, target,
book_ts, bids, asks)
for notional in NOTIONALS:
fill, filled = walk_book(side, notional)
# 名义额按基准价折成基础币再下单——真实委托是基础币计价的,
# 框架的 get_vwap_for_volume 也收基础币量。名义额那一栏留着
# 是为了跨币可比(1 BTC 和 1 SOL 没法横向比)
base_amt = notional / baseline
r = ob.get_vwap_for_volume(d_sign > 0, base_amt)
fill = float(r.result_price)
depth_ok = int(float(r.result_volume) >= base_amt * 0.999)
if not np.isfinite(fill):
# 25 档吃不下这个量,框架直接给 nan。记一行标明深度不足,
# 免得「某个仓位档在薄盘时段整段消失」看不出来
self.w_sig.writerow({
"sym": sym, "kline_ts": kline_ts, "direction": d_sign,
"h1_agree": hit["h1_agree"],
"ladder_ok": hit["ladder_ok"],
"gate_ok": hit["gate_ok"], "pass_all": hit["pass_all"],
"lag_ok": int(lag_ok),
"atr_pct": atr_pct if atr_pct else "",
"atr_bp": round(atr_pct * 1e4, 3) if atr_pct else "",
"t_close_ms": kline_ts, "t_data_ms": t_data,
"t_signal_ms": t_signal,
"lag_data_ms": t_data - kline_ts,
"lag_signal_ms": t_signal - kline_ts,
"delay_label": label, "delay_ms": delay_ms,
"book_ts": book_ts, "book_lag_ms": book_ts - target,
"notional": notional, "base_amt": round(base_amt, 8),
"baseline_px": baseline, "mid": mid,
"best_px": best_px, "fill_px": "",
"filled": round(float(r.result_volume), 8),
"depth_ok": 0, "slip_bp": "", "drift_bp": "",
"spread_bp": "", "impact_bp": ""})
continue
slip = d_sign * (fill - baseline) / baseline * 1e4
drift = d_sign * (mid - baseline) / baseline * 1e4
@@ -336,14 +685,21 @@ class Shadow:
self.w_sig.writerow({
"sym": sym, "kline_ts": kline_ts,
"direction": d_sign, "h1_agree": hit["h1_agree"],
"ladder_ok": hit["ladder_ok"], "gate_ok": hit["gate_ok"],
"pass_all": hit["pass_all"], "lag_ok": int(lag_ok),
"atr_pct": atr_pct if atr_pct else "",
"atr_bp": round(atr_pct * 1e4, 3) if atr_pct else "",
"t_close_ms": kline_ts, "t_data_ms": t_data,
"t_signal_ms": t_signal,
"lag_data_ms": t_data - kline_ts,
"lag_signal_ms": t_signal - kline_ts,
"delay_label": label, "delay_ms": delay_ms,
"notional": notional, "baseline_px": baseline,
"book_ts": book_ts, "book_lag_ms": book_ts - target,
"notional": notional, "base_amt": round(base_amt, 8),
"baseline_px": baseline,
"mid": mid, "best_px": best_px, "fill_px": fill,
"filled": round(filled, 2),
"filled": round(float(r.result_volume), 8),
"depth_ok": depth_ok,
"slip_bp": round(slip, 4), "drift_bp": round(drift, 4),
"spread_bp": round(spread, 4),
"impact_bp": round(impact, 4)})
@@ -353,8 +709,22 @@ class Shadow:
while not self.stop.is_set():
await asyncio.sleep(300)
depth = {s: len(self.books.buf[s]) for s in SYMS}
print(f" [心跳] 已处理 {self.n_bars} 根 · 命中 {self.n_signal}"
f"· 盘口缓冲 {depth}", flush=True)
lag = {s: (f"{np.median(h):.0f}ms" if h else "na")
+ ("" if self.lag_ok[s] else "!")
for s, h in self.lag_hist.items()}
print(f" [心跳] 已处理 {self.n_bars} 根 · 命中 {self.n_signal}"
f"(过全部滤网 {self.n_pass}) · lag {lag} · 盘口缓冲 {depth}"
f" · 回查超容差 {self.books.n_stale}"
f" · 在途任务 {len(self._tasks)}"
f" · 盘口落盘 {self.blog.n}"
f" · 成交 {self.tape.n_trades}{'' if self.tape.n_trades else ' ⚠监听未生效'}",
flush=True)
# 五分钟一根都没进来,说明管道断了。不喊一声就只能靠人翻日志
if self.n_bars == self._hb_last_bars:
print(f" ⚠ [停滞] 距上次心跳未处理任何 K 线"
f"(进程池重建 {self.n_broken} 次),管道可能已断",
flush=True)
self._hb_last_bars = self.n_bars
async def run(self) -> None:
await self.start()
@@ -370,9 +740,12 @@ class Shadow:
for f in list(self.feeds_l.values()) + list(self.feeds_h.values()):
f.stop()
await self.connector.stop_network()
self.f_sig.close()
self.f_lat.close()
print(f"\n收工:{self.n_bars} 根 · {self.n_signal} 个信号", flush=True)
for f in (self.f_sig, self.f_lat, self.f_drf):
f.close()
self.blog.close()
self.tape.close()
print(f"\n收工:{self.n_bars} 根 · {self.n_signal} 个信号"
f"(过全部滤网 {self.n_pass})", flush=True)
async def main_async(workers: int, hours: float, pool) -> None:
+179 -53
View File
@@ -1,27 +1,44 @@
"""影子交易器的报表:首日延迟门槛 + 滑点对延迟曲线。
"""影子交易器的报表:延迟门槛 + 滑点对延迟曲线。
两份产物对应计划里的两件事。
### 判据常数一律从研究侧 import,不在这里写死
### 延迟门槛(提前止损用)
`BUDGET_BP` 等常数留在 `research/lib/shadow_budget.py`。理由是这些数会变——
2026-08-27 一天之内预算就动了四次(3.91 → 11.06 → 14.25 → 15.19bp),
费率也改了一次。本文件曾经写死过 BTC 0.13 / ETH 4.02 / SOL 2.92,那三个数
由六处差异叠加而来(只有同向没有阶梯、费率按 6bp 双边 taker、TP=3.0、
余量没除 taker 名义额、无 ATR 门控,且 BTC/ETH/SOL 恰是 ATR 最低的三个币)。
正确值是 8.58 / 20.64 / 16.83——**ETH 差了五倍**。
跑满 24 小时先看这个。若**总延迟已令预期漂移超过余量**,说明方案在这台机器
上就不成立,不必等两周样本再停。余量取 bitget_baseline.py 的实测值:
BTC 0.13bp(本就为负,只作参照)、ETH +4.02bp、SOL +2.92bp。
这件事要紧是因为下面的判读是自动停机开关:用 4.02 当 ETH 的预算,真实滑点
只要到 2.4bp 就会报「需要压延迟或放弃」,会误杀一个可行的策略。
漂移按随机游走折算:σ_1m · √(t/60)。这是下限——入场条件是「收盘突破转强」,
那一刻价格正朝我们方向跑,延迟造成的是系统性追价,不会正负抵消。所以实测
滑点理应比这个折算值更差,两者对照本身就是个校验。
### 什么时候能判什么
| | 一天的样本量 | 够不够 |
|---|---|---|
| 延迟 | 1440 根/币 | 够,统计上很厚 |
| 滑点 | 门控后 4~6 笔/天 | **不够**,判据要 30 笔以上,即一周起步 |
所以首日只能判延迟和管道通不通。滑点那一节在样本不足时会明说。
### 延迟门槛(提前止损用)
若**总延迟已令预期漂移超过预算**,说明方案在这台机器上就不成立,不必等
两周样本再停。漂移按随机游走折算 σ_1m · √(t/60)。这是下限——入场条件是
「收盘突破转强」,那一刻价格正朝我们方向跑,延迟造成的是系统性追价,
不会正负抵消。所以实测滑点理应比折算值更差,两者对照本身就是个校验。
### 滑点对延迟曲线
把延迟当自变量:0.5s / 1s / 2s / 5s 各一个滑点值,外加「actual」= 本机实际
算完的时刻。这样即便本机算得慢,也能读出「若延迟压到 X 秒,滑点是多少」,
算完的时刻。同信号内的受控对比,能直接读出「若延迟压到 X 秒滑点是多少」,
决策不被当前实现拖累。
.venv/bin/python research/live/shadow_report.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
@@ -29,8 +46,16 @@ import pandas as pd
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
sys.path.insert(0, str(HERE.parent))
from lib.shadow_budget import ( # noqa: E402
ATR_GATE_BP, BUDGET_PORTFOLIO_2026, LAG_ALARM_MS, budget_of, lag_healthy,
verdict,
)
SYMS = ("BTC", "ETH", "SOL")
BUDGET_BP = {"BTC": -0.13, "ETH": 4.02, "SOL": 2.92}
ORDER = ["0.5s", "1.0s", "2.0s", "5.0s", "actual"]
MIN_N = 30 # 滑点判据的最低笔数,低于此只报数不下结论
def vol_bp() -> dict[str, float]:
@@ -46,13 +71,38 @@ def vol_bp() -> dict[str, float]:
return v
def lag_health(lat: pd.DataFrame) -> None:
"""运行时 lag 探针的回看。补丁后实测 506~642ms,理论下限约 500ms。"""
print("\n\n########## 二、lag 探针(>%.0fms 该停开仓)##########"
% LAG_ALARM_MS)
print(f"{'':<5}{'根数':>6}{'中位ms':>9}{'P90ms':>8}{'最差30根中位':>14}"
f"{'超阈根数':>10}{'判定':>8}")
for s in SYMS:
g = lat[lat["sym"] == s].sort_values("kline_ts")
if g.empty:
continue
x = g["lag_data_ms"].to_numpy(float)
roll = pd.Series(x).rolling(30).median()
worst = float(np.nanmax(roll)) if roll.notna().any() else float("nan")
ok = lag_healthy(x)
print(f"{s:<5}{len(g):>6}{np.median(x):>9.0f}"
f"{np.percentile(x, 90):>8.0f}{worst:>14.0f}"
f"{int((x > LAG_ALARM_MS).sum()):>10}"
f"{'健康' if ok else '退化':>8}")
if "lag_ok" in lat.columns:
bad = int((lat["lag_ok"] == 0).sum())
if bad:
print(f"\n 采集期间有 {bad} 根被判不健康,那些根上的信号"
f"(lag_ok=0)在真实运行下不会开仓,统计时应排除")
def latency_gate(lat: pd.DataFrame, vols: dict) -> None:
print("########## 一、延迟门槛 ##########")
span_h = (lat["t_close_ms"].max() - lat["t_close_ms"].min()) / 3.6e6
print(f"样本跨度 {span_h:.1f} 小时 · 共 {len(lat)}\n")
print(f"{'':<5}{'根数':>6}{'数据ms':>9}{'计算ms':>9}{'总延迟ms':>10}"
f"{'P90ms':>8}{'折算漂移bp':>12}{'余量bp':>9}{'余量':>9}")
verdicts = {}
f"{'P90ms':>8}{'折算漂移bp':>12}{'预算bp':>9}{'预算':>9}")
rows = {}
for s in SYMS:
g = lat[lat["sym"] == s]
if g.empty:
@@ -63,48 +113,110 @@ def latency_gate(lat: pd.DataFrame, vols: dict) -> None:
p90 = float(np.percentile(g["lag_signal_ms"], 90))
vol = vols.get(s)
drift = vol * np.sqrt(t / 60_000) if vol else float("nan")
b = BUDGET_BP[s]
share = drift / b if b > 0 else float("nan")
verdicts[s] = (drift, b)
txt = f"{share * 100:.0f}%" if b > 0 else "(负)"
b = budget_of(s)
share = drift / b if np.isfinite(b) and b > 0 else float("nan")
rows[s] = (drift, b)
txt = f"{share * 100:.0f}%" if np.isfinite(share) else ""
print(f"{s:<5}{len(g):>6}{d:>9.0f}{c:>9.0f}{t:>10.0f}{p90:>8.0f}"
f"{drift:>12.2f}{b:>9.2f}{txt:>9}")
print("\n判读:")
for s, (drift, b) in verdicts.items():
if b <= 0:
print(f" {s}: 余量本就为负,不作交易标的,仅作延迟参照")
print("\n判读(预算来自 lib/shadow_budget2026 年口径)")
for s, (drift, b) in rows.items():
if not np.isfinite(b):
print(f" {s}: 当前环境预算不足,不作交易标的,仅作延迟参照")
elif not np.isfinite(drift):
# 不特判的话 nan > b 是 False,会一路落到「尚有空间」说反话
print(f" {s}: 缺 1m 波动率缓存,折算不出漂移,无法判读")
elif drift > b:
print(f" {s}: 折算漂移 {drift:.2f}bp 已超余量 {b:.2f}bp —— 停下改方案")
print(f" {s}: 折算漂移 {drift:.2f}bp 已超预算 {b:.2f}bp —— 停下改方案")
elif drift > b * 0.6:
print(f" {s}: 折算漂移 {drift:.2f}bp 吃掉余量 {b:.2f}bp 的六成以上,"
print(f" {s}: 折算漂移 {drift:.2f}bp 吃掉预算 {b:.2f}bp 的六成以上,"
f"需要压延迟或放弃")
else:
print(f" {s}: 折算漂移 {drift:.2f}bp 对余量 {b:.2f}bp 尚有空间,继续收集")
print(f" {s}: 折算漂移 {drift:.2f}bp 对预算 {b:.2f}bp 尚有空间,继续收集")
def book_quality(sig: pd.DataFrame, drf: pd.DataFrame) -> None:
"""回查到的盘口比目标时刻晚多少。晚太多的已在采集侧丢弃,这里做复核。"""
frames = [d for d in (sig, drf) if not d.empty and "book_lag_ms" in d]
if not frames:
return
x = pd.concat([d["book_lag_ms"] for d in frames]).astype(float)
print("\n\n########## 二·五、盘口回查质量 ##########")
print(f" 回查 {len(x)} 次 · 中位 {x.median():.0f}ms · "
f"P90 {np.percentile(x, 90):.0f}ms · 最大 {x.max():.0f}ms")
print(f" 10Hz 采样下这个值应在 0~100ms。它是所有延迟点的同向偏置,"
f"不改变曲线形状,但要确认没有异常长尾")
def drift_split(sig: pd.DataFrame, drf: pd.DataFrame) -> None:
"""条件漂移 vs 无条件漂移。两者的差就是「系统性追价」的大小。"""
print("\n\n########## 三、条件漂移 vs 无条件漂移 ##########")
if drf.empty:
print(" 尚无逐根漂移数据(shadow_drift.csv 由本轮起才开始记)")
return
print(" 无条件 = 每根 K 线,方向未知故取 |漂移|;"
"条件 = 信号根按下单方向定号")
print(f"\n{'延迟':<8}{'无条件n':>9}{'无条件|漂移|':>14}"
f"{'条件n':>7}{'条件漂移':>10}{'追价差':>9}")
cond = sig[(sig["notional"] == sig["notional"].min())] if not sig.empty \
else sig
for lb in ORDER:
u = drf[drf["delay_label"] == lb]["drift_bp_long"].abs()
c = cond[cond["delay_label"] == lb]["drift_bp"] if not cond.empty \
else pd.Series(dtype=float)
if u.empty and c.empty:
continue
um = u.mean() if not u.empty else float("nan")
cm = c.mean() if not c.empty else float("nan")
print(f"{lb:<8}{len(u):>9}{um:>14.2f}{len(c):>7}{cm:>10.2f}"
f"{cm - um:>9.2f}")
if len(cond) and len(cond[cond["delay_label"] == "1.0s"]) < MIN_N:
print(f"\n 条件侧样本不足 {MIN_N},差值还读不出方向")
def slippage_curve(sig: pd.DataFrame) -> None:
print("\n\n########## 、滑点对延迟曲线 ##########")
print("\n\n########## 、滑点对延迟曲线 ##########")
if sig.empty:
print(" 尚无信号样本")
return
n_sig = sig.groupby(["sym", "kline_ts", "direction"]).ngroups
n_agree = sig[sig["h1_agree"] == 1].groupby(
["sym", "kline_ts", "direction"]).ngroups
print(f"信号总数 {n_sig}(其中 h1_agree=1 的 {n_agree} 个)\n")
order = ["0.5s", "1.0s", "2.0s", "5.0s", "actual"]
for scope, sub in (("全部信号", sig),
("仅 h1_agree=1(主口径)", sig[sig["h1_agree"] == 1])):
def n_of(df):
return df.groupby(["sym", "kline_ts", "direction"]).ngroups
has_flags = "pass_all" in sig.columns
if not has_flags:
print(" ⚠ 数据来自旧版采集(只有 h1_agree,无阶梯与 ATR 门控)。"
"这批不是我们要交易的那批信号,只能作管道验证,不能对预算判读。\n")
main_scope, main_name = sig[sig["h1_agree"] == 1], "仅 h1_agree=1(旧口径)"
else:
# depth_ok=0 是 25 档吃不满该仓位,均价按部分成交算会**低估**冲击
ok = (sig["pass_all"] == 1) & (sig["lag_ok"] == 1)
if "depth_ok" in sig.columns:
thin = int((sig["depth_ok"] == 0).sum())
ok &= sig["depth_ok"] == 1
if thin:
print(f" {thin} 行深度吃不满,已排除;这些行会低估冲击)")
print(f"信号总数 {n_of(sig)} · 同向 {n_of(sig[sig['h1_agree'] == 1])}"
f" · 同向+阶梯 "
f"{n_of(sig[(sig['h1_agree'] == 1) & (sig['ladder_ok'] == 1)])}"
f" · 三项全过 {n_of(sig[sig['pass_all'] == 1])}"
f" · 再要求 lag 健康 {n_of(sig[ok])}")
print(f"(门控阈值 ATR ≥ {ATR_GATE_BP:.0f}bp,是费率的函数不是市场常数)\n")
main_scope = sig[ok]
main_name = "三项滤网全过 + lag 健康 + 深度吃满(主口径)"
scopes = [("全部信号(含不会下单的,仅作提前读数)", sig),
(main_name, main_scope)]
for scope, sub in scopes:
if sub.empty:
print(f"--- {scope} ---\n 尚无样本\n")
continue
print(f"--- {scope} ---")
print(f"{'延迟':<8}{'仓位':>9}{'n':>5}{'滑点均值bp':>12}"
f"{'中位bp':>9}{'漂移bp':>9}{'价差bp':>9}{'冲击bp':>9}")
for lb in order:
for lb in ORDER:
g0 = sub[sub["delay_label"] == lb]
if g0.empty:
continue
for nt in sorted(sub["notional"].unique()):
g = g0[g0["notional"] == nt]
if g.empty:
@@ -117,19 +229,36 @@ def slippage_curve(sig: pd.DataFrame) -> None:
f"{g['impact_bp'].mean():>9.2f}")
print()
print("--- 分币种(仅 h1_agree=1,仓位 5000---")
m = sig[(sig["h1_agree"] == 1) & (sig["notional"] == 5000.0)]
print("--- 分币种判读(主口径,仓位 5000---")
m = main_scope[main_scope["notional"] == 5000.0] if not main_scope.empty \
else main_scope
if m.empty:
print(" 尚无样本")
return
print(f"{'':<5}{'延迟':<8}{'n':>5}{'滑点均值bp':>12}{'余量bp':>9}")
print(f"{'':<5}{'延迟':<8}{'n':>5}{'滑点中位bp':>12}{'预算bp':>9} 判读")
for s in SYMS:
for lb in order:
for lb in ORDER:
g = m[(m["sym"] == s) & (m["delay_label"] == lb)]
if g.empty:
continue
print(f"{s:<5}{lb:<8}{len(g):>5}{g['slip_bp'].mean():>12.2f}"
f"{BUDGET_BP[s]:>9.2f}")
med = float(g["slip_bp"].median())
b = budget_of(s)
note = verdict(med, s) if len(g) >= MIN_N \
else f"n={len(g)} < {MIN_N},不下结论"
print(f"{s:<5}{lb:<8}{len(g):>5}{med:>12.2f}{b:>9.2f} {note}")
n_main = len(m[m["delay_label"] == "actual"])
if n_main < MIN_N:
print(f"\n ⚠ 主口径仅 {n_main} 笔。门控后约 4~6 笔/天/全部币种,"
f"滑点判据要 {MIN_N} 笔以上——**一周起步**。首日只能判延迟和管道。")
print(f" 单币样本薄时可先看组合口径:2026 预算 {BUDGET_PORTFOLIO_2026}bp")
def _load(name: str) -> pd.DataFrame:
f = OUT / name
if not f.exists() or f.stat().st_size == 0:
return pd.DataFrame()
return pd.read_csv(f)
def main() -> None:
@@ -137,20 +266,17 @@ def main() -> None:
print("1m 收益标准差(bp/分钟,Bitget 实测):"
+ " ".join(f"{s} {v:.2f}" for s, v in vols.items()) + "\n")
f_lat = OUT / "shadow_latency.csv"
if f_lat.exists() and f_lat.stat().st_size > 0:
lat = pd.read_csv(f_lat)
if not lat.empty:
latency_gate(lat, vols)
else:
lat = _load("shadow_latency.csv")
if lat.empty:
print("尚无延迟数据")
f_sig = OUT / "shadow_signals.csv"
if f_sig.exists() and f_sig.stat().st_size > 0:
sig = pd.read_csv(f_sig)
slippage_curve(sig)
else:
print("\n尚无信号数据")
latency_gate(lat, vols)
lag_health(lat)
sig, drf = _load("shadow_signals.csv"), _load("shadow_drift.csv")
book_quality(sig, drf)
drift_split(sig, drf)
slippage_curve(sig)
if __name__ == "__main__":
+81 -30
View File
@@ -3,15 +3,30 @@
单次调用约 0.26s 的纯 CPU,且 chanlun 是纯 Python 受 GIL 限制,放进
Hummingbot 的 asyncio 循环里会把行情处理一起卡住,所以必须隔离到独立进程。
口径与 [aggregate_robustness.run_once] 逐行对齐:同样的 build_htf_zones →
find_fast_bsp3 → attach_htf_context(h1) 链路,同样的 h1_agree 过滤。两边
必须一致,否则影子测出来的滑点没法和 3.9bp 预算对照。
## 口径必须与预算同源,缺一项数就不可比
与回测的唯一差别是这里只关心**最后一根已收盘 K 线**上有没有信号——
实盘只能在当下下单,历史信号无意义。
预算(`lib/shadow_budget.BUDGET_BP`)算在 step42 的这套滤网上,
本文件逐行对齐 `step42_exit_tp_1m.run_one`
未过滤信号也一并返回:过滤后样本太稀,先用未过滤的当提前读数,
两者都记,靠 h1_agree 字段区分。
同向 h1_agree == 1
中枢阶梯 多头要求当前中枢整体高于前一个(zd > 前 zg),空头反之
ATR 门控 atr_pct ≥ ATR_GATE_BP(当前 8bp
早先这里只有 h1_agree。缺阶梯与门控测的就不是我们要交易的那批信号,
而这一项改常数解决不了——必须改信号路径本身。
门控阈值是**费率的函数**不是市场常数(低 ATR 信号的毛质量反而最好,
断崖只在扣费后出现),换 VIP 档或换交易所要重扫,不要抄 8bp。
## 与回测的两点差别
其一,这里只关心**最后一根已收盘 K 线**上有没有信号——实盘只能在当下下单。
其二,`atr_pct` 的分母取次根开盘价,与 `exit_model.walk_exits` 一致,
所以调用方必须把次根开盘价传进来。
未通过滤网的信号也一并返回并打上标志:过滤后样本很稀(门控后 8 个币
合计约 38 笔/周),未过滤的可作提前读数。但**统计主口径只能用 pass_all**
在我们根本不会下单的根上测滑点会把判据算宽。
"""
from __future__ import annotations
@@ -23,15 +38,20 @@ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
def compute(df_l, df_h) -> dict:
def compute(df_l, df_h, entry_px: float | None = None) -> dict:
"""在 df_l 的最后一根上找信号。df_l/df_h 都只含已收盘 K 线。
entry_px 是次根开盘价(回测 entry_delay=1 的成交价),用作 atr_pct 的
分母。取不到时退回用信号根收盘价,并在返回里标 atr_ref="close"
返回 dict
last_idx 最后一根在 chanlun 处理后 dataframe 里的下标
n_bars 实际参与计算的根数
hits 命中列表,每项 {direction, h1_agree}
atr_pct 信号根 ATR / 次根开盘价
hits 命中列表,每项含方向与三个滤网标志、pass_all
error 出错时的说明,正常为 None
"""
import numpy as np
import pandas as pd
try:
@@ -40,45 +60,76 @@ def compute(df_l, df_h) -> dict:
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
from lib.nested_level import build_htf_zones
from lib.shadow_budget import ATR_GATE_BP
chan_l = TF_DF(df_l, 1, "1m")
cdf = chan_l.dataframe
last = len(cdf) - 1
base = {"last_idx": last, "n_bars": int(len(df_l)), "hits": [],
"error": None}
"atr_pct": None, "atr_ref": None, "error": None}
zones = build_htf_zones(cdf, "1m", chan=chan_l)
# ATR 门控。分母与 exit_model.walk_exits 一致,取次根开盘价。
# 放在任何早退之前——无信号的根也要记,才能在线看到门控的真实刷除率
atr = float(cdf["atr"].to_numpy(dtype=float)[last]) \
if "atr" in cdf.columns else float("nan")
ref = entry_px if (entry_px and np.isfinite(entry_px)) else \
float(cdf["close"].to_numpy(dtype=float)[last])
atr_pct = atr / ref if (np.isfinite(atr) and ref) else float("nan")
gate_ok = bool(np.isfinite(atr_pct) and atr_pct * 1e4 >= ATR_GATE_BP)
base["atr_pct"] = None if not np.isfinite(atr_pct) else float(atr_pct)
base["atr_ref"] = "next_open" if (entry_px and np.isfinite(entry_px)) \
else "close"
zones = build_htf_zones(cdf, "1m", chan=chan_l).reset_index(drop=True)
if zones.empty:
return base
sig = find_fast_bsp3(cdf, zones.reset_index(drop=True))
# 中枢阶梯:当前中枢是否整体脱离前一个。与 step42 同一算法
z = zones.copy()
prev_zg, prev_zd = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > prev_zg, z["zg"] < prev_zd
z["zone_i"] = np.arange(len(z))
sig = find_fast_bsp3(cdf, zones)
if sig is None or sig.empty:
return base
sig = sig.merge(z[["zone_i", "z_above", "z_below"]],
on="zone_i", how="left")
# 只留落在最后一根上的信号,其余是历史,实盘下不了
cur = sig[sig["entry_idx"].astype(int) == last]
if cur.empty:
return base
# 5m 同向过滤:算得出就标 h1_agree,算不出就当未过滤照记
agree_map: dict[int, int] = {}
# 5m 同向。算不出时 h1_agree 记 0,该信号自然不会通过 pass_all
if df_h is not None and len(df_h) > 0:
chan_h = TF_DF(df_h, 1, "5m")
hdf = chan_h.dataframe
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
full = attach_htf_context(sig, cdf, tl, "h1")
f_cur = full[full["entry_idx"].astype(int) == last]
for _, r in f_cur.iterrows():
agree_map[int(r["direction"])] = int(r.get("h1_agree", 0))
sig = attach_htf_context(sig, cdf, tl, "h1")
else:
sig["h1_agree"] = 0
base["hits"] = [{"direction": int(r["direction"]),
"h1_agree": agree_map.get(int(r["direction"]), 0)}
for _, r in cur.iterrows()]
cur = sig[sig["entry_idx"].astype(int) == last]
if cur.empty:
return base
hits = []
for _, r in cur.iterrows():
d = int(r["direction"])
push = r["z_above"] if d == 1 else r["z_below"]
ladder_ok = bool(pd.notna(push) and bool(push))
# attach_htf_context 在入场时刻之前没有大级别分型时写 NaN。
# 不能写成 `int(x or 0)`——NaN 是真值,会走到 int(nan) 抛异常,
# 整根的信号就此丢掉,只留一行报错
raw = r.get("h1_agree", 0)
agree = int(raw) if pd.notna(raw) else 0
hits.append({"direction": d, "h1_agree": agree,
"ladder_ok": int(ladder_ok), "gate_ok": int(gate_ok),
"pass_all": int(agree == 1 and ladder_ok and gate_ok)})
base["hits"] = hits
return base
except Exception as e: # 子进程里异常必须带回主进程,否则只见超时不见原因
import traceback
return {"last_idx": -1, "n_bars": int(len(df_l)) if df_l is not None else 0,
"hits": [], "error": f"{type(e).__name__}: {e}",
"hits": [], "atr_pct": None, "atr_ref": None,
"error": f"{type(e).__name__}: {e}",
"traceback": traceback.format_exc()}
@@ -102,8 +153,8 @@ def _rebuild(rows) -> "object":
def compute_packed(payload: tuple) -> dict:
"""ProcessPoolExecutor 的入口:收 (l_rows, h_rows) 两组数值行"""
l_rows, h_rows = payload
"""ProcessPoolExecutor 的入口:收 (l_rows, h_rows, entry_px)"""
l_rows, h_rows, entry_px = payload
df_l = _rebuild(l_rows)
df_h = _rebuild(h_rows) if h_rows else None
return compute(df_l, df_h)
return compute(df_l, df_h, entry_px)
+205
View File
@@ -0,0 +1,205 @@
"""验证 live 信号路径与 step42 的批量过滤等价。
live 侧每根只看最后一根、且只喂 2000 根窗口;研究侧一次性跑全量。两者
用同一套滤网(同向 + 中枢阶梯 + ATR 门控),但**不保证逐笔一致**——
缠论结构依赖历史,2000 根窗口是 step39 定的命中率饱和点,不是无损截断。
每个信号根上比三种口径:
批量 全量历史 + 完整 5m。这是预算的来源,是基准
剔partial 窗口 2000 根 1m + 800 根**已收盘** 5m
含partial 窗口,5m 末尾保留那根**尚未收盘**的。这是 live 现行做法
## 结论:partial 根要保留,不能剔
live 的 `df_h[df_h["timestamp"] < kline_ts]` 里 kline_ts 是 1m 的收盘时刻,
而正在走的那根 5m 开盘更早,于是被保留下来——五根里有四根如此。乍看像是
「把未收盘的根当完整根用」的口径错误,实测**反过来**:
BTC 25/25、ETH 24/25、SOL 23/25 与批量一致(合计 96%
剔掉则只有 21/25、21/25、18/25(合计 80%
原因是批量口径里那根 5m 是存在的。缠论的包含处理与分型检测吃整条序列,
凭空少一根会把结构整体挪位;保留一根「开盘价正确、高低点尚不完整」的
近似根,比直接删掉更接近批量。
这也暴露了研究侧的一处残留:批量的 HTF 结构用到了那根 5m 的**最终**高低点,
而实盘在该时刻不可能知道。`htf_fx_timeline` 的 `confirm_ts += period` 只挡住了
分型**选取**上的未来函数,挡不住结构构建。live 用 partial 根逼近,落在 96%
差的那 4% 是这条残留的下界,不是可以修掉的 bug。
.venv/bin/python research/live/verify_signal_path.py --symbol BTC
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
sys.path.insert(0, str(HERE.parents[1]))
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
WINDOW_L, WINDOW_H = 2000, 800
HTF_MS = 300_000
def partial_htf_bar(df_l: pd.DataFrame, kline_ts: int) -> dict | None:
"""用 1m 合成「此刻正在走的那根 5m」,复现 live 曾经喂进去的 partial 根。"""
bucket = kline_ts // HTF_MS * HTF_MS
if bucket >= kline_ts: # 正好落在 5m 边界,没有未收盘的根
return None
part = df_l[(df_l["timestamp"] >= bucket) & (df_l["timestamp"] < kline_ts)]
if part.empty:
return None
date = pd.to_datetime(bucket, unit="ms", utc=True) \
.tz_convert("Asia/Shanghai")
return {"timestamp": bucket, "date": date,
"open": float(part["open"].iloc[0]),
"high": float(part["high"].max()), "low": float(part["low"].min()),
"close": float(part["close"].iloc[-1]),
"volume": float(part["volume"].sum())}
def load(sym: str, tf: str, days: int) -> pd.DataFrame:
f = HERE / "cache" / f"bitget_{sym}_{tf}_{days}d.feather"
if not f.exists():
raise SystemExit(f"缺数据 {f}")
return pd.read_feather(f)
def batch_flags(df_l: pd.DataFrame, df_h: pd.DataFrame) -> pd.DataFrame:
"""step42_exit_tp_1m.run_one 的滤网,逐行照搬。"""
from chanlun import TF_DF
from lib.fast_bsp3 import find_fast_bsp3
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
from lib.nested_level import build_htf_zones
from lib.shadow_budget import ATR_GATE_BP
chan_l = TF_DF(df_l, 1, "1m")
cdf = chan_l.dataframe
zones = build_htf_zones(cdf, "1m", chan=chan_l).reset_index(drop=True)
z = zones.copy()
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
chan_h = TF_DF(df_h, 1, "5m")
hdf = chan_h.dataframe
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
sig = find_fast_bsp3(cdf, zones)
sig = sig.merge(z[["zone_i", "z_above", "z_below"]], on="zone_i", how="left")
sig = attach_htf_context(sig, cdf, tl, "h1")
d = sig["direction"].astype(int)
push = np.where(d == 1, sig["z_above"], sig["z_below"])
idx = sig["entry_idx"].astype(int).to_numpy()
entry = cdf["open"].to_numpy(float)[np.minimum(idx + 1, len(cdf) - 1)]
atr_pct = cdf["atr"].to_numpy(float)[idx] / entry
out = pd.DataFrame({
"entry_idx": idx,
"ts": cdf["timestamp"].to_numpy()[idx],
"direction": d.to_numpy(),
"h1_agree": sig["h1_agree"].fillna(0).astype(int).to_numpy(),
"ladder_ok": pd.Series(push).fillna(False).astype(int).to_numpy(),
"atr_bp": atr_pct * 1e4,
})
out["gate_ok"] = (out["atr_bp"] >= ATR_GATE_BP).astype(int)
out["pass_all"] = ((out["h1_agree"] == 1) & (out["ladder_ok"] == 1)
& (out["gate_ok"] == 1)).astype(int)
return out, cdf
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbol", default="BTC")
ap.add_argument("--days", type=int, default=30)
ap.add_argument("--checks", type=int, default=12)
a = ap.parse_args()
df_l, df_h = load(a.symbol, "1m", a.days), load(a.symbol, "5m", a.days)
print(f"[{a.symbol}] 1m {len(df_l)} 根 / 5m {len(df_h)} 根,跑批量滤网…",
flush=True)
batch, cdf = batch_flags(df_l, df_h)
n = len(batch)
print(f" 原始 B4/S4 {n} 个 · 同向 {int((batch.h1_agree == 1).sum())}"
f" · 同向+阶梯 "
f"{int(((batch.h1_agree == 1) & (batch.ladder_ok == 1)).sum())}"
f" · 三项全过 {int(batch.pass_all.sum())}")
print(f" ATR 中位 {batch.atr_bp.median():.2f}bp · "
f"门控刷掉 {(1 - batch.gate_ok.mean()) * 100:.1f}%\n")
# 挑最近的若干个信号根做窗口复现
from shadow_signal import compute
cand = batch[batch["entry_idx"] >= WINDOW_L].tail(a.checks)
if cand.empty:
raise SystemExit("窗口内没有可核对的信号")
def run_window(r, with_partial: bool):
i = int(r["entry_idx"])
kline_ts = int(r["ts"]) + 60_000 # 信号根的收盘时刻
wl = df_l[df_l["timestamp"] < kline_ts].tail(WINDOW_L)
wh = df_h[df_h["timestamp"] + HTF_MS <= kline_ts].tail(WINDOW_H)
if with_partial:
p = partial_htf_bar(df_l, kline_ts)
if p is not None:
wh = pd.concat([wh, pd.DataFrame([p])], ignore_index=True)
entry_px = float(cdf["open"].to_numpy(float)[min(i + 1, len(cdf) - 1)])
res = compute(wl.reset_index(drop=True), wh.reset_index(drop=True),
entry_px)
if res.get("error"):
raise SystemExit(f"compute 报错,测试本身有问题:{res['error']}\n"
f"{res.get('traceback', '')}")
return next((h for h in res["hits"]
if h["direction"] == int(r["direction"])), None)
def fmt(h):
if h is None:
return f"{'未复现':>18}"
return (f"{h['h1_agree']:>6}{h['ladder_ok']:>5}{h['gate_ok']:>5}")
print(f"{'K线时刻':<15}{'方向':>4}{' 批量':>18}{' 窗口':>18}"
f"{' 含未收盘':>18}{' 截断':>7}{'partial':>9}")
print(f"{'':<15}{'':>4}{'同向 阶梯 门控':>20}{'同向 阶梯 门控':>20}"
f"{'同向 阶梯 门控':>20}")
n_trunc = n_part = n_ok = n_bad = 0
for _, r in cand.iterrows():
h_ok = run_window(r, False)
h_bad = run_window(r, True)
t = pd.to_datetime(r["ts"], unit="ms", utc=True) \
.tz_convert("Asia/Shanghai").strftime("%m-%d %H:%M")
ref = (int(r.h1_agree), int(r.ladder_ok), int(r.gate_ok))
got = None if h_ok is None else (h_ok["h1_agree"], h_ok["ladder_ok"],
h_ok["gate_ok"])
bad = None if h_bad is None else (h_bad["h1_agree"], h_bad["ladder_ok"],
h_bad["gate_ok"])
n_trunc += got != ref
n_part += bad != got
n_ok += got == ref
n_bad += bad == ref
print(f"{t:<15}{int(r.direction):>+4}"
f"{ref[0]:>6}{ref[1]:>5}{ref[2]:>5}"
f"{fmt(h_ok):>18}{fmt(h_bad):>18}"
f"{'' if got == ref else '':>7}"
f"{'' if bad == got else '':>9}")
n = len(cand)
print(f"\n 与批量(预算口径)一致:")
print(f" 剔掉未收盘 5m 根 {n_ok}/{n}")
print(f" 保留未收盘 5m 根 {n_bad}/{n} ← live 现行做法")
print(f" 两种窗口口径互不相同 {n_part}/{n}")
if __name__ == "__main__":
main()