离线验证信号时刻流动性不更差,免掉攒 30 笔信号的 17 天等待
三币过完三滤网只有 1.72 笔/天,攒 30 笔要 17 天。但滑点本来每根都在记,信号 时刻的测量只多回答一个问题:信号那一刻的流动性是否比普通根差。这个问题可以 用 210 天历史离线回答。 关键是必须做匹配对照。信号按 ATR ≥ 8bp 门控,信号根天然比平均根波动大,直接 和全体根比一定会「发现」一个我们自己施加的差异。所以对照组按「同时段 × 同 ATR 十分位」抽取,并排除距信号 48 根内的根(持仓期不独立)。自检 ATR 比值 0.976~1.004,匹配成立。 结果三币一致,方向与担心的相反:信号根成交额是对照的 2.1~2.6 倍、Amihud 非 流动性只有 0.47~0.60 倍、Roll 有效价差三个币都不显著。信号跟在突破后面, 突破自带成交量。所以全体根测出的冲击与价差偏保守而非偏乐观。 唯一真实差异是根内波幅宽 25~28%,但那是波动而非流动性,对应漂移而非冲击, 且可直接当缩放系数:1 秒延迟点漂移上调后仍只占预算 1.4%/3.0%/4.5%。 判读按流动性与波动分两组。初版把 range_bp 当成流动性红旗,会得出「信号时刻 更差、必须等样本」的相反结论——它是波动度量,且 ATR 已匹配。 检验用置换而非 t:成交额跨几个数量级,右尾太重。scipy 未安装,也不宜在采集 运行中动环境,所以用 numpy 自己实现。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
|||||||
|
"""信号时刻的流动性,是否系统性地差于普通根。
|
||||||
|
|
||||||
|
## 结论先行(2026-08-28)
|
||||||
|
|
||||||
|
**不需要等 17 天攒信号样本。** 三币一致,且方向与担心的相反:
|
||||||
|
|
||||||
|
成交额 信号根是匹配对照的 2.1~2.6 倍(p=0.0001)
|
||||||
|
Amihud 非流动性只有对照的 0.47~0.60 倍(p≤0.002)
|
||||||
|
Roll 价差 三个币都不显著(0.83~1.07)
|
||||||
|
根内波幅 一致地宽 25~28%(p≤0.0008)
|
||||||
|
|
||||||
|
信号跟在突破后面,突破自带成交量,所以**信号时刻流动性更好**。用全体根测出
|
||||||
|
的冲击与价差因此偏保守而非偏乐观,这一项不必等。
|
||||||
|
|
||||||
|
唯一的真实差异是波幅宽 26%——但那是波动而非流动性,对应漂移那一项,且可以
|
||||||
|
直接当缩放系数用:1 秒延迟点上漂移上调后仍只占预算 1.4% / 3.0% / 4.5%。
|
||||||
|
|
||||||
|
## 这个脚本要替掉的那 2.5 周
|
||||||
|
|
||||||
|
影子采集里滑点是**每根都记**的(drift 约 2 万行/天),而「信号时刻」的测量只
|
||||||
|
多回答一个问题:信号那一刻的流动性是否比普通根差。信号跟在突破/中枢事件后
|
||||||
|
面,盘口可能更薄、价差可能更宽,若真如此,用全体根的滑点分布会偏乐观。
|
||||||
|
|
||||||
|
但三币过完三滤网只有 1.72 笔/天,攒 30 笔要 17 天。所以先用 210 天历史离线
|
||||||
|
回答这个问题:若信号根与匹配对照根在流动性代理上无系统差异,就可以直接用
|
||||||
|
每根的滑点分布,不必等信号攒够。
|
||||||
|
|
||||||
|
## 为什么必须做匹配对照
|
||||||
|
|
||||||
|
信号是按 ATR ≥ 8bp 门控出来的,**信号根天然比平均根波动大**。直接和全体根
|
||||||
|
比,一定会「发现」ATR 更高、波幅更宽——那是我们自己施加的门控,不是新信息。
|
||||||
|
|
||||||
|
所以对照组按「同一时段(hour-of-day)× 同一 ATR 十分位」抽取,并排除距任何
|
||||||
|
信号 48 根以内的根(那些正处在持仓期内,不独立)。这样比较才只剩下「除门控
|
||||||
|
之外还有没有别的差异」。
|
||||||
|
|
||||||
|
## 代理量的局限
|
||||||
|
|
||||||
|
历史里没存盘口,所以比不了真实价差与深度,只能比 OHLCV 能给的四个代理:
|
||||||
|
|
||||||
|
vol_usd 名义成交额。越低冲击越大
|
||||||
|
range_bp 根内波幅
|
||||||
|
amihud |收益| / 成交额,标准非流动性代理
|
||||||
|
roll_bp Roll(1984) 有效价差估计 = 2√(−cov(r_t, r_{t−1}))
|
||||||
|
这是唯一能从价格反推「价差」的代理,但只在自协方差为负时有定义
|
||||||
|
|
||||||
|
结论强度到「旁证」为止,不是定论。真实价差要等影子数据。
|
||||||
|
|
||||||
|
检验用置换检验而非 t 检验:这些量右尾极重,均值和正态假设都不可靠。
|
||||||
|
|
||||||
|
python research/live/signal_liquidity.py --syms BTC,ETH,SOL
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||||
|
os.environ.setdefault(_v, "1")
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
|
||||||
|
ROLL_WIN = 60 # Roll 估计的滚动窗口,1 小时
|
||||||
|
GUARD = 48 # 对照根须距任何信号至少这么多根(= MAX_BARS 持仓期)
|
||||||
|
N_CTRL = 20 # 每个信号抽多少对照根
|
||||||
|
N_PERM = 10_000
|
||||||
|
RNG = np.random.default_rng(20260828)
|
||||||
|
|
||||||
|
|
||||||
|
def proxies(cdf: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""四个流动性代理,全部只用 OHLCV。"""
|
||||||
|
close = cdf["close"].to_numpy(float)
|
||||||
|
high = cdf["high"].to_numpy(float)
|
||||||
|
low = cdf["low"].to_numpy(float)
|
||||||
|
vol = cdf["volume"].to_numpy(float)
|
||||||
|
|
||||||
|
vol_usd = vol * close
|
||||||
|
range_bp = (high - low) / close * 1e4
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
ret = np.diff(np.log(close), prepend=np.nan)
|
||||||
|
# Amihud:单位百万美元成交额推动的 bp 变化
|
||||||
|
amihud = np.abs(ret) * 1e4 / np.maximum(vol_usd / 1e6, 1e-9)
|
||||||
|
|
||||||
|
# Roll:滚动窗口内相邻收益的自协方差。负协方差是买卖价反弹的signature,
|
||||||
|
# 幅度给出有效价差;正协方差(动量主导)时无定义,只能留 NaN
|
||||||
|
r = pd.Series(ret)
|
||||||
|
cov = (r * r.shift(1)).rolling(ROLL_WIN).mean() \
|
||||||
|
- r.rolling(ROLL_WIN).mean() * r.shift(1).rolling(ROLL_WIN).mean()
|
||||||
|
cov = cov.to_numpy()
|
||||||
|
roll_bp = np.where(cov < 0, 2.0 * np.sqrt(np.maximum(-cov, 0)) * 1e4,
|
||||||
|
np.nan)
|
||||||
|
|
||||||
|
return pd.DataFrame({"vol_usd": vol_usd, "range_bp": range_bp,
|
||||||
|
"amihud": amihud, "roll_bp": roll_bp})
|
||||||
|
|
||||||
|
|
||||||
|
def matched_controls(n_bars: int, sig_idx: np.ndarray, hour: np.ndarray,
|
||||||
|
atr_bp: np.ndarray) -> np.ndarray:
|
||||||
|
"""按「同时段 × 同 ATR 十分位」为每个信号抽对照根。
|
||||||
|
|
||||||
|
不匹配 ATR 的话,门控本身就会造出一个假差异;不匹配时段的话,亚洲/欧美
|
||||||
|
盘的流动性差异会混进来。排除信号前后 GUARD 根是因为那段正在持仓,与信号
|
||||||
|
根高度相关,不是独立样本。
|
||||||
|
"""
|
||||||
|
ok = np.isfinite(atr_bp)
|
||||||
|
# 十分位边界只用有定义的根来定,否则 NaN 会把分位挤歪
|
||||||
|
edges = np.nanquantile(atr_bp[ok], np.linspace(0, 1, 11))
|
||||||
|
bucket = np.clip(np.searchsorted(edges, atr_bp, side="right") - 1, 0, 9)
|
||||||
|
|
||||||
|
banned = np.zeros(n_bars, dtype=bool)
|
||||||
|
for i in sig_idx:
|
||||||
|
banned[max(0, i - GUARD):min(n_bars, i + GUARD + 1)] = True
|
||||||
|
|
||||||
|
cells: dict[tuple[int, int], np.ndarray] = {}
|
||||||
|
avail = ok & ~banned
|
||||||
|
key = hour * 10 + bucket
|
||||||
|
for k in np.unique(key[avail]):
|
||||||
|
cells[int(k)] = np.flatnonzero(avail & (key == k))
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for i in sig_idx:
|
||||||
|
pool = cells.get(int(key[i]))
|
||||||
|
if pool is None or len(pool) == 0:
|
||||||
|
continue
|
||||||
|
take = min(N_CTRL, len(pool))
|
||||||
|
out.append(RNG.choice(pool, size=take, replace=False))
|
||||||
|
return np.concatenate(out) if out else np.array([], dtype=int)
|
||||||
|
|
||||||
|
|
||||||
|
def perm_p(a: np.ndarray, b: np.ndarray) -> tuple[float, float]:
|
||||||
|
"""中位数之差的置换检验,返回 (差值, 双尾 p)。
|
||||||
|
|
||||||
|
这些量的右尾极重(成交额跨几个数量级),均值和 t 检验都不可靠,所以比
|
||||||
|
中位数、且用置换而非解析分布。
|
||||||
|
"""
|
||||||
|
a = a[np.isfinite(a)]
|
||||||
|
b = b[np.isfinite(b)]
|
||||||
|
if len(a) < 8 or len(b) < 8:
|
||||||
|
return float("nan"), float("nan")
|
||||||
|
obs = float(np.median(a) - np.median(b))
|
||||||
|
pool = np.concatenate([a, b])
|
||||||
|
n = len(a)
|
||||||
|
hits = 0
|
||||||
|
for _ in range(N_PERM):
|
||||||
|
RNG.shuffle(pool)
|
||||||
|
if abs(np.median(pool[:n]) - np.median(pool[n:])) >= abs(obs) - 1e-15:
|
||||||
|
hits += 1
|
||||||
|
return obs, (hits + 1) / (N_PERM + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(sym: str, cache: Path) -> pd.DataFrame:
|
||||||
|
from step43_fill_aware_budget import signals_for
|
||||||
|
|
||||||
|
cdf, sig = signals_for(sym, cache)
|
||||||
|
n = len(cdf)
|
||||||
|
# 成交发生在信号次根的开盘,所以要看的是那一根的流动性
|
||||||
|
sig_idx = np.minimum(sig["entry_idx"].astype(int).to_numpy() + 1, n - 1)
|
||||||
|
|
||||||
|
px = proxies(cdf)
|
||||||
|
atr = cdf["atr"].to_numpy(float)
|
||||||
|
ref = cdf["open"].to_numpy(float)
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
atr_bp = atr / ref * 1e4
|
||||||
|
hour = pd.to_datetime(cdf["date"]).dt.hour.to_numpy()
|
||||||
|
|
||||||
|
ctrl_idx = matched_controls(n, sig_idx, hour, atr_bp)
|
||||||
|
print(f" {len(cdf):,} 根 · 信号 {len(sig_idx)} 根 · "
|
||||||
|
f"匹配对照 {len(ctrl_idx):,} 根")
|
||||||
|
if len(ctrl_idx) < 50:
|
||||||
|
print(" 对照组太小,跳过")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 先自检匹配是否真的把 ATR 拉平了。若没拉平,后面所有比较都不可信
|
||||||
|
a_s, a_c = atr_bp[sig_idx], atr_bp[ctrl_idx]
|
||||||
|
print(f" 匹配自检 ATR 中位:信号 {np.nanmedian(a_s):.2f}bp · "
|
||||||
|
f"对照 {np.nanmedian(a_c):.2f}bp · "
|
||||||
|
f"比值 {np.nanmedian(a_s) / np.nanmedian(a_c):.3f}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
print(f"\n {'代理':<10}{'信号中位':>13}{'对照中位':>13}"
|
||||||
|
f"{'比值':>8}{'p':>9}")
|
||||||
|
for col in ("vol_usd", "range_bp", "amihud", "roll_bp"):
|
||||||
|
v = px[col].to_numpy(float)
|
||||||
|
s, c = v[sig_idx], v[ctrl_idx]
|
||||||
|
_, p = perm_p(s, c)
|
||||||
|
ms, mc = np.nanmedian(s), np.nanmedian(c)
|
||||||
|
ratio = ms / mc if mc not in (0.0,) and np.isfinite(mc) else np.nan
|
||||||
|
fmt = ",.0f" if col == "vol_usd" else ".3f"
|
||||||
|
print(f" {col:<10}{format(ms, fmt):>13}{format(mc, fmt):>13}"
|
||||||
|
f"{ratio:>8.3f}{p:>9.4f}")
|
||||||
|
rows.append({"sym": sym, "proxy": col, "sig_med": ms,
|
||||||
|
"ctrl_med": mc, "ratio": ratio, "p": p,
|
||||||
|
"n_sig": int(np.isfinite(s).sum()),
|
||||||
|
"n_ctrl": int(np.isfinite(c).sum())})
|
||||||
|
del cdf
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
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/signal_liquidity.csv")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
allr = []
|
||||||
|
for sym in a.syms.split(","):
|
||||||
|
print(f"\n{'=' * 74}\n{sym}")
|
||||||
|
try:
|
||||||
|
r = run_one(sym, Path(a.cache))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 跳过:{e!r}")
|
||||||
|
continue
|
||||||
|
if not r.empty:
|
||||||
|
allr.append(r)
|
||||||
|
|
||||||
|
if not allr:
|
||||||
|
return
|
||||||
|
out = pd.concat(allr, ignore_index=True)
|
||||||
|
Path(a.save).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.to_csv(a.save, index=False)
|
||||||
|
|
||||||
|
verdict(out)
|
||||||
|
print(f"\n已存 {a.save}")
|
||||||
|
|
||||||
|
|
||||||
|
def verdict(out: pd.DataFrame) -> None:
|
||||||
|
"""分两组判读:流动性决定冲击与价差,波动决定漂移。
|
||||||
|
|
||||||
|
这两组的含义完全不同,混在一起会得出错误结论。`range_bp` 是波动度量而非
|
||||||
|
流动性度量——它更宽不代表「更难成交」,而代表「延迟窗口内价格走得更远」,
|
||||||
|
对应的是漂移那一项,且可以直接当缩放系数用,不需要等信号样本。
|
||||||
|
"""
|
||||||
|
LIQ = {"vol_usd": -1, "amihud": +1, "roll_bp": +1} # +1 表示越大越差
|
||||||
|
|
||||||
|
print(f"\n\n{'=' * 74}\n判读\n")
|
||||||
|
print(" ── 流动性(决定冲击与价差)")
|
||||||
|
liq = out[out["proxy"].isin(LIQ)]
|
||||||
|
worse = np.array([(r["ratio"] - 1) * LIQ[r["proxy"]] > 0
|
||||||
|
for _, r in liq.iterrows()])
|
||||||
|
bad = liq[(liq["p"].to_numpy() < 0.05) & worse]
|
||||||
|
for _, r in liq.iterrows():
|
||||||
|
arrow = "更差" if (r["ratio"] - 1) * LIQ[r["proxy"]] > 0 else "更好"
|
||||||
|
sig = "" if r["p"] < 0.05 else "(不显著)"
|
||||||
|
print(f" {r['sym']:<4}{r['proxy']:<9}比值 {r['ratio']:.3f} "
|
||||||
|
f"→ 信号时刻{arrow}{sig}")
|
||||||
|
if bad.empty:
|
||||||
|
print("\n 没有一项显示信号时刻流动性更差。信号跟在突破后面,成交额")
|
||||||
|
print(" 反而是普通根的 2~2.6 倍、Amihud 非流动性只有一半,有效价差")
|
||||||
|
print(" (Roll)三个币都不显著。**所以用全体根测出的冲击与价差是")
|
||||||
|
print(" 偏保守的,不是偏乐观**,这一项不需要等信号样本。")
|
||||||
|
else:
|
||||||
|
print("\n ⚠ 以下项显示信号时刻流动性更差,全体根的冲击会偏乐观:")
|
||||||
|
for _, r in bad.iterrows():
|
||||||
|
print(f" {r['sym']} {r['proxy']} 比值 {r['ratio']:.3f} "
|
||||||
|
f"p={r['p']:.4f}")
|
||||||
|
|
||||||
|
print("\n ── 波动(决定漂移)")
|
||||||
|
rng = out[out["proxy"] == "range_bp"]
|
||||||
|
for _, r in rng.iterrows():
|
||||||
|
print(f" {r['sym']:<4}根内波幅比值 {r['ratio']:.3f} "
|
||||||
|
f"(p={r['p']:.4f}) → 漂移按此系数上调")
|
||||||
|
if not rng.empty:
|
||||||
|
k = float(rng["ratio"].mean())
|
||||||
|
print(f"\n 信号根波幅一致地比匹配对照宽约 {(k - 1) * 100:.0f}%。ATR 已")
|
||||||
|
print(" 匹配,所以这不是门控造成的——ATR 是 14 根均值,突破那一根的")
|
||||||
|
print(" 波幅本就超过它。这一项不需要等样本,把实测漂移乘以该系数即可。")
|
||||||
|
drift_check(k)
|
||||||
|
|
||||||
|
|
||||||
|
def drift_check(k: float) -> None:
|
||||||
|
"""把实测漂移按波幅系数上调,看是否仍远小于预算。
|
||||||
|
|
||||||
|
这是「要不要等 17 天」的最终判据:若上调后仍占预算个位数百分比,等待
|
||||||
|
换不到任何决策上的差别。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lib.shadow_budget import BUDGET_BP
|
||||||
|
d = pd.read_csv("research/out/shadow_drift.csv")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n (没读到实测漂移,跳过换算:{e!r})")
|
||||||
|
return
|
||||||
|
d = d[d["delay_label"].astype(str).str.startswith("1")]
|
||||||
|
if d.empty:
|
||||||
|
return
|
||||||
|
print(f"\n 1 秒延迟点上,漂移上调后占预算:")
|
||||||
|
for sym, g in d.groupby("sym"):
|
||||||
|
x = g["drift_bp_long"].abs().dropna()
|
||||||
|
b = BUDGET_BP.get(sym)
|
||||||
|
if len(x) < 5 or not b:
|
||||||
|
continue
|
||||||
|
adj = float(x.median()) * k
|
||||||
|
print(f" {sym:<4}{x.median():.2f}bp × {k:.2f} = {adj:.2f}bp"
|
||||||
|
f" · 预算 {b:.2f}bp · 占 {adj / b * 100:.1f}%")
|
||||||
|
print("\n 仍是个位数百分比,所以攒 30 笔信号换不到决策差别。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
sym,proxy,sig_med,ctrl_med,ratio,p,n_sig,n_ctrl
|
||||||
|
BTC,vol_usd,5550103.60788,2624836.584895,2.114456816023851,9.999000099990002e-05,91,1820
|
||||||
|
BTC,range_bp,14.211131842272723,11.379813132903628,1.248801863115187,0.0007999200079992001,91,1820
|
||||||
|
BTC,amihud,0.9320529751601416,1.995826823303005,0.4670009262715666,0.0007999200079992001,91,1820
|
||||||
|
BTC,roll_bp,4.779754159181145,5.745083186251997,0.8319730113950504,0.08029197080291971,44,996
|
||||||
|
ETH,vol_usd,3126641.7427,1207249.22975,2.589889200714149,9.999000099990002e-05,126,2520
|
||||||
|
ETH,range_bp,15.77081223459847,12.35743868645172,1.2762201484267979,0.00029997000299970003,126,2520
|
||||||
|
ETH,amihud,2.410291522791945,4.420234301516299,0.5452859188855959,0.0018998100189981002,126,2520
|
||||||
|
ETH,roll_bp,6.347567691125725,5.949780489978004,1.0668574583243478,0.42275772422757724,56,1362
|
||||||
|
SOL,vol_usd,345692.792,162027.9883,2.1335375179746032,9.999000099990002e-05,145,2900
|
||||||
|
SOL,range_bp,15.652030645029573,12.452668963002594,1.25692176444523,0.00019998000199980003,145,2900
|
||||||
|
SOL,amihud,20.14951190677575,33.887234963568275,0.594604780485579,0.001999800019998,145,2900
|
||||||
|
SOL,roll_bp,5.7402773658108215,6.019208571018624,0.9536598205699658,0.5864413558644136,73,1632
|
||||||
|
Reference in New Issue
Block a user