Keep frozen research conclusions and code; raw ledgers and secrets stay out of git. Co-authored-by: Cursor <cursoragent@cursor.com>
979 lines
39 KiB
Python
979 lines
39 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Maker Edge Report v0.1 — Research Freeze / Data Collection Phase
|
||
|
||
固定格式(每次运行必须相同、可比较):
|
||
Executive Summary
|
||
Section 1 — Data Integrity
|
||
Section 2 — Fill Alpha
|
||
Section 3 — Toxicity Profile
|
||
Section 4 — Observed Edge Attribution
|
||
Section 5 — Decision
|
||
|
||
研究对象:可验证的市场现象(不是策略)。
|
||
见 FREEZE.md — 只许数据字段/质量检查/报告解释;禁止新交易规则。
|
||
|
||
用法:
|
||
./scripts/analyze.sh 2000
|
||
python scripts/analyze_maker_edge.py --report --min-fills 2000
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
_ROOT = Path(__file__).resolve().parents[1]
|
||
_SRC = _ROOT / "src"
|
||
if str(_SRC) not in sys.path:
|
||
sys.path.insert(0, str(_SRC))
|
||
|
||
|
||
FEE = 0.00016
|
||
EXPECTED_SLIPPAGE = 0.00005
|
||
POSITIVE_EDGE_NET = 0.0002
|
||
CLUSTER_GAP_SEC = 30.0
|
||
TOXIC_FAIL_RATIO = 0.60
|
||
PASS_MIN_FILLS_DEFAULT = 2000
|
||
|
||
|
||
def _load_experiment_from_df(df: pd.DataFrame) -> dict[str, Any]:
|
||
"""优先用 jsonl 中的 experiment_start / 事件戳;否则回退环境默认。"""
|
||
try:
|
||
from nautilus_mm.experiment import load_experiment_meta
|
||
|
||
base = load_experiment_meta()
|
||
except Exception:
|
||
base = {
|
||
"experiment_id": "MM_EDGE_EXP_001",
|
||
"probe_version": "probe_v0.1",
|
||
"quote_assumption": "frozen",
|
||
"fee_model": "frozen",
|
||
"exchange_assumption": "frozen",
|
||
"exchange": "binance_usdm",
|
||
"environment": "TESTNET",
|
||
"symbol": "BTCUSDT-PERP",
|
||
}
|
||
if df.empty or "event" not in df.columns:
|
||
return base
|
||
starts = df[df["event"] == "experiment_start"]
|
||
if not starts.empty:
|
||
row = starts.iloc[-1]
|
||
for k in ("experiment_id", "probe_version", "exchange", "environment", "symbol"):
|
||
if k in row and pd.notna(row[k]):
|
||
base[k] = row[k]
|
||
return base
|
||
# 任意带 experiment_id 的事件
|
||
if "experiment_id" in df.columns and df["experiment_id"].notna().any():
|
||
base["experiment_id"] = df["experiment_id"].dropna().iloc[-1]
|
||
if "probe_version" in df.columns and df["probe_version"].notna().any():
|
||
base["probe_version"] = df["probe_version"].dropna().iloc[-1]
|
||
return base
|
||
|
||
|
||
def load_events(log_dir: Path) -> pd.DataFrame:
|
||
rows = []
|
||
files = sorted(log_dir.glob("*.jsonl"))
|
||
if not files:
|
||
raise FileNotFoundError(f"No jsonl in {log_dir}")
|
||
for f in files:
|
||
for line in f.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
rows.append(json.loads(line))
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
|
||
raw = (px.astype(float) - fill.astype(float)) / fill.astype(float)
|
||
return pd.Series(np.where(side == "long", raw, -raw), index=side.index)
|
||
|
||
|
||
def _side_label(side: str) -> str:
|
||
return "Bid" if side == "long" else "Ask"
|
||
|
||
|
||
def _extract_fill_context(fills: pd.DataFrame) -> pd.DataFrame:
|
||
if fills.empty or "fill_context" not in fills.columns:
|
||
return pd.DataFrame()
|
||
rows = []
|
||
for _, r in fills.iterrows():
|
||
ctx = r.get("fill_context")
|
||
if not isinstance(ctx, dict):
|
||
continue
|
||
rows.append(
|
||
{
|
||
"fill_id": r.get("fill_id"),
|
||
"market_event_before_fill": ctx.get("market_event_before_fill"),
|
||
"trade_imbalance_5s": ctx.get("trade_imbalance_5s"),
|
||
"price_velocity_5s": ctx.get("price_velocity_5s"),
|
||
"fill_type": ctx.get("fill_type"),
|
||
}
|
||
)
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def _median_safe(s: pd.Series) -> float | None:
|
||
s = pd.to_numeric(s, errors="coerce").dropna()
|
||
return float(s.median()) if len(s) else None
|
||
|
||
|
||
def _fmt_pct(x: float | None, digits: int = 4) -> str:
|
||
if x is None or (isinstance(x, float) and np.isnan(x)):
|
||
return "n/a"
|
||
return f"{x*100:+.{digits}f}%"
|
||
|
||
|
||
def _fmt_pp(x: float | None) -> str:
|
||
if x is None or (isinstance(x, float) and np.isnan(x)):
|
||
return "n/a"
|
||
return f"{x*100:+.1f}pp"
|
||
|
||
|
||
def _dist_stats(s: pd.Series) -> dict[str, float | None]:
|
||
s = pd.to_numeric(s, errors="coerce").dropna()
|
||
if s.empty:
|
||
return {"mean": None, "median": None, "p25": None, "p75": None, "n": 0}
|
||
return {
|
||
"mean": float(s.mean()),
|
||
"median": float(s.median()),
|
||
"p25": float(s.quantile(0.25)),
|
||
"p75": float(s.quantile(0.75)),
|
||
"n": int(len(s)),
|
||
}
|
||
|
||
|
||
def _print_dist(p, title: str, d: dict[str, float | None]) -> None:
|
||
if not d.get("n"):
|
||
p(f"{title}: n/a")
|
||
return
|
||
p(f"{title} (n={d['n']}):")
|
||
p(f" mean: {_fmt_pct(d['mean'])}")
|
||
p(f" median: {_fmt_pct(d['median'])}")
|
||
p(f" p25: {_fmt_pct(d['p25'])}")
|
||
p(f" p75: {_fmt_pct(d['p75'])}")
|
||
|
||
|
||
def _observation_window(n_fills: int, n_clusters: int) -> str:
|
||
if n_fills < 500:
|
||
return "anomaly-check only (<500 fills)"
|
||
if n_fills < 2000:
|
||
return "early look (500+) — do not over-interpret"
|
||
if n_fills < 10000:
|
||
return "preliminary judgment (2000+) — clusters still matter more than fills"
|
||
return "stability discussion eligible (10000+ fills)"
|
||
|
||
|
||
def assign_clusters_offline(fills: pd.DataFrame, gap_sec: float = CLUSTER_GAP_SEC) -> pd.DataFrame:
|
||
out = fills.copy()
|
||
if out.empty:
|
||
return out
|
||
if "event_cluster_id" in out.columns and out["event_cluster_id"].notna().any():
|
||
return out
|
||
if "ts_epoch" not in out.columns:
|
||
out["event_cluster_id"] = [f"na_{i}" for i in range(len(out))]
|
||
out["cluster_fill_index"] = 1
|
||
return out
|
||
out = out.sort_values("ts_epoch").reset_index(drop=True)
|
||
cids: list[str] = []
|
||
idxs: list[int] = []
|
||
cid = None
|
||
last_ts = -1e18
|
||
last_side = None
|
||
n = 0
|
||
for _, r in out.iterrows():
|
||
ts = float(r["ts_epoch"])
|
||
side = r.get("side")
|
||
if cid is None or side != last_side or (ts - last_ts) > gap_sec:
|
||
cid = uuid.uuid4().hex[:12]
|
||
n = 0
|
||
n += 1
|
||
cids.append(cid)
|
||
idxs.append(n)
|
||
last_ts = ts
|
||
last_side = side
|
||
out["event_cluster_id"] = cids
|
||
out["cluster_fill_index"] = idxs
|
||
return out
|
||
|
||
|
||
def classify_space(raw_capture: float, net_edge: float) -> str:
|
||
if raw_capture <= 0 or net_edge <= 0:
|
||
return "NO_EDGE"
|
||
if net_edge < POSITIVE_EDGE_NET:
|
||
return "EDGE_AFTER_COST"
|
||
return "POSITIVE_EDGE"
|
||
|
||
|
||
def build_mid_series(df: pd.DataFrame) -> pd.DataFrame:
|
||
parts = []
|
||
for ev in ("mid_tick", "inventory_tick"):
|
||
if "event" not in df.columns:
|
||
break
|
||
sub = df[df["event"] == ev]
|
||
if sub.empty or "mid" not in sub.columns or "ts_epoch" not in sub.columns:
|
||
continue
|
||
parts.append(sub[["ts_epoch", "mid"]].dropna())
|
||
if not parts:
|
||
return pd.DataFrame(columns=["ts_epoch", "mid"])
|
||
m = pd.concat(parts, ignore_index=True)
|
||
m["ts_epoch"] = pd.to_numeric(m["ts_epoch"], errors="coerce")
|
||
m["mid"] = pd.to_numeric(m["mid"], errors="coerce")
|
||
return m.dropna().sort_values("ts_epoch").drop_duplicates("ts_epoch").reset_index(drop=True)
|
||
|
||
|
||
def _cluster_weight(frame: pd.DataFrame) -> pd.Series:
|
||
if "event_cluster_id" not in frame.columns:
|
||
return pd.Series(1.0, index=frame.index)
|
||
cnt = frame.groupby("event_cluster_id")["event_cluster_id"].transform("count")
|
||
return 1.0 / cnt.clip(lower=1)
|
||
|
||
|
||
def _period_str(df: pd.DataFrame) -> str:
|
||
if df.empty or "ts_epoch" not in df.columns or df["ts_epoch"].isna().all():
|
||
return "n/a"
|
||
t0 = float(pd.to_numeric(df["ts_epoch"], errors="coerce").min())
|
||
t1 = float(pd.to_numeric(df["ts_epoch"], errors="coerce").max())
|
||
a = datetime.fromtimestamp(t0, tz=timezone.utc).strftime("%Y-%m-%d")
|
||
b = datetime.fromtimestamp(t1, tz=timezone.utc).strftime("%Y-%m-%d")
|
||
return f"{a} ~ {b}"
|
||
|
||
|
||
def _instrument(fills: pd.DataFrame, df: pd.DataFrame) -> str:
|
||
for src in (fills, df):
|
||
if not src.empty and "pair" in src.columns and src["pair"].notna().any():
|
||
return str(src["pair"].dropna().iloc[0])
|
||
return "BTCUSDT Perpetual (assumed)"
|
||
|
||
|
||
def _maker_alpha_frame(g: pd.DataFrame) -> tuple[pd.Series, pd.Series]:
|
||
"""Return (fill_ret, mkt_signed) for MakerAlpha = fill − market."""
|
||
mid0 = g["mid"].astype(float)
|
||
mid1 = g["after_30s_price"].astype(float)
|
||
mkt_ret = (mid1 - mid0) / mid0
|
||
mkt_signed = pd.Series(
|
||
np.where(g["side"] == "long", mkt_ret, -mkt_ret), index=g.index
|
||
)
|
||
fill_ret = _fav_ret(g["side"], g["fill_price"], g["after_30s_price"])
|
||
return fill_ret, mkt_signed
|
||
|
||
|
||
def report(df: pd.DataFrame, min_fills: int = PASS_MIN_FILLS_DEFAULT, out_path: Path | None = None) -> dict[str, Any]:
|
||
fills = df[df["event"] == "fill"].copy() if "event" in df.columns else pd.DataFrame()
|
||
paths = df[df["event"] == "fill_path"].copy() if "event" in df.columns else pd.DataFrame()
|
||
health = df[df["event"] == "health"].copy() if "event" in df.columns else pd.DataFrame()
|
||
exp = _load_experiment_from_df(df)
|
||
|
||
lines: list[str] = []
|
||
|
||
def p(s: str = "") -> None:
|
||
lines.append(s)
|
||
print(s)
|
||
|
||
if not fills.empty:
|
||
fills = assign_clusters_offline(fills)
|
||
|
||
if not paths.empty and not fills.empty and "fill_id" in fills.columns:
|
||
meta_cols = [
|
||
c
|
||
for c in [
|
||
"side",
|
||
"fill_price",
|
||
"fill_reason",
|
||
"spread",
|
||
"spread_capture_pct",
|
||
"obi",
|
||
"trade_imbalance",
|
||
"bid_depth_5",
|
||
"ask_depth_5",
|
||
"book_age_ms",
|
||
"inventory",
|
||
"pre_5s_deteriorated",
|
||
"mid",
|
||
"event_cluster_id",
|
||
"cluster_fill_index",
|
||
"ts_epoch",
|
||
"pair",
|
||
]
|
||
if c in fills.columns
|
||
]
|
||
meta = fills.drop_duplicates("fill_id")[["fill_id"] + meta_cols]
|
||
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
|
||
for col in ("side", "fill_price", "mid", "event_cluster_id", "spread", "spread_capture_pct"):
|
||
alt = f"{col}_f"
|
||
if alt in paths.columns:
|
||
if col not in paths.columns:
|
||
paths[col] = paths[alt]
|
||
else:
|
||
paths[col] = paths[col].fillna(paths[alt])
|
||
fc = _extract_fill_context(fills)
|
||
if not fc.empty:
|
||
paths = paths.merge(fc, on="fill_id", how="left")
|
||
|
||
n_fills = len(fills)
|
||
n_paths = len(paths)
|
||
n_clusters = int(fills["event_cluster_id"].nunique()) if n_fills and "event_cluster_id" in fills.columns else 0
|
||
cluster_fill_ratio = n_clusters / max(n_fills, 1)
|
||
|
||
# ---------- compute: integrity ----------
|
||
integrity_ok = True
|
||
integrity_notes: list[str] = []
|
||
healthy_ratio = gap_total = gap_win_max = None
|
||
lat_p50 = lat_p95 = lat_p99 = ba_med = None
|
||
if health.empty:
|
||
integrity_ok = False
|
||
integrity_notes.append("no health telemetry")
|
||
else:
|
||
healthy_ratio = float(health["healthy"].astype(bool).mean()) if "healthy" in health.columns else 0.0
|
||
gap_total = int(health["sequence_gap"].iloc[-1]) if "sequence_gap" in health.columns else 0
|
||
gap_win_max = (
|
||
int(pd.to_numeric(health.get("sequence_gap_window"), errors="coerce").fillna(0).max())
|
||
if "sequence_gap_window" in health.columns
|
||
else 0
|
||
)
|
||
lat_p50 = health["latency_ms_p50"].iloc[-1] if "latency_ms_p50" in health.columns else None
|
||
lat_p95 = health["latency_ms_p95"].iloc[-1] if "latency_ms_p95" in health.columns else None
|
||
lat_p99 = health["latency_ms_p99"].iloc[-1] if "latency_ms_p99" in health.columns else None
|
||
ba_series = (
|
||
fills["book_age_ms"]
|
||
if "book_age_ms" in fills.columns and fills["book_age_ms"].notna().any()
|
||
else health.get("book_age_ms")
|
||
)
|
||
ba_med = _median_safe(ba_series) if ba_series is not None else None
|
||
if healthy_ratio < 0.99:
|
||
integrity_ok = False
|
||
integrity_notes.append(f"healthy_ratio={healthy_ratio*100:.2f}% < 99%")
|
||
# Binance depth update ids are not contiguous — log only, do not INVALID.
|
||
if gap_win_max and gap_win_max > 0:
|
||
integrity_notes.append(
|
||
f"sequence_gap_window_max={gap_win_max} (observe-only; Binance ids skip)"
|
||
)
|
||
if ba_med is not None and ba_med > 500:
|
||
integrity_ok = False
|
||
integrity_notes.append(f"book_age_median={ba_med:.0f}ms > 500ms")
|
||
|
||
decision: dict[str, Any] = {
|
||
"integrity": integrity_ok,
|
||
"verdict": "INSUFFICIENT_DATA",
|
||
"reasons": [],
|
||
"space_class": None,
|
||
"benchmark_alpha": None,
|
||
"maker_alpha_mean": None,
|
||
"quality": {
|
||
"fills": n_fills,
|
||
"clusters": n_clusters,
|
||
"cluster_fill_ratio": cluster_fill_ratio,
|
||
"healthy_ratio": healthy_ratio,
|
||
},
|
||
}
|
||
|
||
if fills.empty:
|
||
p("=" * 72)
|
||
p("Maker Edge Report v0.1")
|
||
p("Phase: Research Freeze / Data Collection")
|
||
p("=" * 72)
|
||
p("\nExecutive Summary")
|
||
p(f" Experiment: {exp.get('experiment_id')}")
|
||
p(f" Version: {exp.get('probe_version')}")
|
||
p(" Quote: frozen")
|
||
p(" Fee: frozen")
|
||
p(" Exchange: frozen")
|
||
p(f" Period: {_period_str(df)}")
|
||
p(f" Instrument: {_instrument(fills, df)}")
|
||
p(" Samples:")
|
||
p(" fills: 0")
|
||
p(" clusters: 0")
|
||
p(" Decision: INSUFFICIENT_DATA")
|
||
p(" Reason: no fills yet — run probe")
|
||
decision["experiment"] = exp
|
||
_finish(lines, out_path, decision)
|
||
return decision
|
||
|
||
# ---------- compute: fill alpha table + distributions ----------
|
||
alpha_table: dict[str, dict[str, float | None]] = {
|
||
"Bid": {"fill_w": None, "cluster_w": None},
|
||
"Ask": {"fill_w": None, "cluster_w": None},
|
||
"Overall": {"fill_w": None, "cluster_w": None},
|
||
}
|
||
fill_alpha_dist: dict[str, float | None] = {}
|
||
cluster_alpha_dist: dict[str, float | None] = {}
|
||
fq_pass = None
|
||
bench_alpha = None
|
||
maker_alpha_mean = None
|
||
agree = None
|
||
pct_fills_positive_alpha = None
|
||
|
||
if not paths.empty and "after_30s_price" in paths.columns and "mid" in paths.columns and paths["mid"].notna().any():
|
||
for side_name, g in paths.groupby("side"):
|
||
label = _side_label(str(side_name))
|
||
fill_ret, mkt_signed = _maker_alpha_frame(g)
|
||
alpha = fill_ret - mkt_signed
|
||
w = _cluster_weight(g)
|
||
alpha_table[label]["fill_w"] = float(alpha.mean())
|
||
alpha_table[label]["cluster_w"] = float((alpha * w).sum() / w.sum()) if w.sum() else float(alpha.mean())
|
||
|
||
fill_ret, mkt_signed = _maker_alpha_frame(paths)
|
||
alpha = fill_ret - mkt_signed
|
||
w = _cluster_weight(paths)
|
||
alpha_table["Overall"]["fill_w"] = float(alpha.mean())
|
||
alpha_table["Overall"]["cluster_w"] = (
|
||
float((alpha * w).sum() / w.sum()) if w.sum() else float(alpha.mean())
|
||
)
|
||
maker_alpha_mean = alpha_table["Overall"]["cluster_w"]
|
||
fw, cw = alpha_table["Overall"]["fill_w"], alpha_table["Overall"]["cluster_w"]
|
||
agree = (fw > 0 and cw > 0) or (fw <= 0 and cw <= 0)
|
||
fill_alpha_dist = _dist_stats(alpha)
|
||
pct_fills_positive_alpha = float((alpha > 0).mean())
|
||
|
||
# per-cluster mean MakerAlpha(事件级分布)
|
||
if "event_cluster_id" in paths.columns:
|
||
tmp = paths.assign(_alpha=alpha)
|
||
cluster_means = tmp.groupby("event_cluster_id")["_alpha"].mean()
|
||
cluster_alpha_dist = _dist_stats(cluster_means)
|
||
|
||
mkt_fav = mkt_signed > 0
|
||
fill_fav = fill_ret > 0
|
||
bench_alpha = float(np.mean(fill_fav) - np.mean(mkt_fav))
|
||
|
||
fav30 = fill_ret
|
||
p30_clu = float((fav30 > 0).astype(float).mul(w).sum() / w.sum()) if w.sum() else float((fav30 > 0).mean())
|
||
fq_pass = p30_clu > 0.50
|
||
|
||
decision["fill_quality"] = fq_pass
|
||
decision["benchmark_alpha"] = bench_alpha
|
||
decision["maker_alpha_mean"] = maker_alpha_mean
|
||
|
||
# ---------- compute: toxicity + loss concentration ----------
|
||
toxicity: dict[str, dict[str, float | None]] = {}
|
||
toxic_bid_ratio = None
|
||
c_share = None
|
||
tox_dist: dict[str, Any] = {}
|
||
if not paths.empty:
|
||
for side_name, g in paths.groupby("side"):
|
||
label = _side_label(str(side_name))
|
||
row: dict[str, float | None] = {}
|
||
for hz, col in [
|
||
("1s", "after_1s_price"),
|
||
("5s", "after_5s_price"),
|
||
("10s", "after_10s_price"),
|
||
("30s", "after_30s_price"),
|
||
("300s", "after_5m_price"),
|
||
]:
|
||
if col in g.columns:
|
||
row[hz] = float(_fav_ret(g["side"], g["fill_price"], g[col]).mean())
|
||
else:
|
||
row[hz] = None
|
||
toxicity[label] = row
|
||
if "path_type" in paths.columns:
|
||
c_share = float((paths["path_type"].astype(str).str.startswith("C")).mean())
|
||
bid = paths[paths["side"] == "long"]
|
||
if len(bid):
|
||
toxic_bid_ratio = float((bid["path_type"].astype(str).str.startswith("C")).mean())
|
||
|
||
# 毒性分布:多少成交在 10s 不利;最差 20% 占总不利损失比例
|
||
if "after_10s_price" in paths.columns:
|
||
fav10 = _fav_ret(paths["side"], paths["fill_price"], paths["after_10s_price"])
|
||
adverse = fav10[fav10 < 0]
|
||
tox_dist["pct_adverse_10s"] = float((fav10 < 0).mean())
|
||
tox_dist["fav10"] = _dist_stats(fav10)
|
||
if len(adverse) >= 5:
|
||
worst_n = max(1, int(np.ceil(0.20 * len(fav10))))
|
||
worst = fav10.nsmallest(worst_n)
|
||
total_adv = float((-adverse).sum())
|
||
worst_adv = float((-worst.clip(upper=0)).sum())
|
||
tox_dist["worst20_share_of_adverse"] = (
|
||
worst_adv / total_adv if total_adv > 1e-12 else None
|
||
)
|
||
else:
|
||
tox_dist["worst20_share_of_adverse"] = None
|
||
|
||
# ---------- compute: cost / adverse ----------
|
||
space_class = None
|
||
adv_pass = None
|
||
raw_capture = net_edge = adv_mag = sc_mean = total_cost = None
|
||
if not paths.empty and "after_30s_price" in paths.columns:
|
||
fav30 = _fav_ret(paths["side"], paths["fill_price"], paths["after_30s_price"])
|
||
w = _cluster_weight(paths)
|
||
raw_capture = float((fav30 * w).sum() / w.sum()) if w.sum() else float(fav30.mean())
|
||
adv_mag = (
|
||
float((-fav30.clip(upper=0) * w).sum() / w.sum())
|
||
if w.sum()
|
||
else float((-fav30.clip(upper=0)).mean())
|
||
)
|
||
sc_mean = (
|
||
float(fills["spread_capture_pct"].mean())
|
||
if "spread_capture_pct" in fills.columns and fills["spread_capture_pct"].notna().any()
|
||
else 0.0
|
||
)
|
||
if "book_age_ms" in fills.columns and fills["book_age_ms"].notna().any():
|
||
latency_cost = float(fills["book_age_ms"].mean()) / 100.0 * 0.00002
|
||
else:
|
||
latency_cost = 0.00002
|
||
total_cost = 2 * FEE + EXPECTED_SLIPPAGE + latency_cost
|
||
net_edge = raw_capture - total_cost
|
||
space_class = classify_space(raw_capture, net_edge)
|
||
adv_ok = (adv_mag < abs(sc_mean)) if sc_mean != 0 else False
|
||
adv_pass = bool(adv_ok and space_class in ("POSITIVE_EDGE", "EDGE_AFTER_COST"))
|
||
|
||
decision["adverse"] = adv_pass
|
||
decision["space_class"] = space_class
|
||
|
||
# ---------- compute: attribution (facts only) ----------
|
||
attr_rows: list[tuple[str, str, int, float]] = []
|
||
stab_pass = None
|
||
state_coverage_ok = None
|
||
concentrated = False
|
||
positive_envs = 0
|
||
total_envs = 0
|
||
if not paths.empty and "after_30s_price" in paths.columns:
|
||
paths = paths.copy()
|
||
paths["_fav30"] = _fav_ret(paths["side"], paths["fill_price"], paths["after_30s_price"])
|
||
if "vol_proxy_5m" in paths.columns and paths["vol_proxy_5m"].notna().any():
|
||
med = paths["vol_proxy_5m"].median()
|
||
paths["vol_bucket"] = np.where(paths["vol_proxy_5m"] >= med, "high_vol", "low_vol")
|
||
elif "max_price" in paths.columns:
|
||
rng = (paths["max_price"] - paths["min_price"]) / paths["fill_price"]
|
||
paths["vol_bucket"] = np.where(rng >= rng.median(), "high_vol", "low_vol")
|
||
if "price_velocity_5s" in paths.columns and paths["price_velocity_5s"].notna().any():
|
||
v = paths["price_velocity_5s"].astype(float)
|
||
thr = v.abs().median() * 0.5
|
||
paths["trend_bucket"] = np.where(
|
||
v > thr, "trend_up", np.where(v < -thr, "trend_down", "range")
|
||
)
|
||
if "spread" in paths.columns and paths["spread"].notna().any():
|
||
sp_pct = paths["spread"] / paths["fill_price"]
|
||
paths["liq_bucket"] = np.where(sp_pct <= sp_pct.median(), "tight_spread", "wide_spread")
|
||
if "bid_depth_5" in paths.columns and "ask_depth_5" in paths.columns:
|
||
depth = paths["bid_depth_5"].fillna(0) + paths["ask_depth_5"].fillna(0)
|
||
if depth.gt(0).any():
|
||
paths["depth_bucket"] = np.where(depth >= depth[depth > 0].median(), "deep_book", "thin_book")
|
||
|
||
pos_counts: list[int] = []
|
||
for col, title in [
|
||
("vol_bucket", "Volatility"),
|
||
("trend_bucket", "Trend"),
|
||
("liq_bucket", "Liquidity(spread)"),
|
||
("depth_bucket", "Liquidity(depth)"),
|
||
("market_event_before_fill", "FillContext"),
|
||
("path_type", "PathType"),
|
||
]:
|
||
if col not in paths.columns or paths[col].isna().all():
|
||
continue
|
||
for idx, row in paths.groupby(col)["_fav30"].agg(["count", "mean"]).iterrows():
|
||
total_envs += 1
|
||
mean = float(row["mean"])
|
||
n = int(row["count"])
|
||
attr_rows.append((title, str(idx), n, mean))
|
||
if mean > 0:
|
||
positive_envs += 1
|
||
pos_counts.append(n)
|
||
|
||
state_coverage_ok = total_envs >= 4
|
||
if total_envs >= 2:
|
||
if pos_counts:
|
||
concentrated = (max(pos_counts) / max(sum(pos_counts), 1)) >= 0.70 and len(pos_counts) == 1
|
||
stab_pass = positive_envs >= 2 and not concentrated
|
||
else:
|
||
state_coverage_ok = False
|
||
|
||
decision["stability"] = stab_pass
|
||
decision["quality"]["state_buckets"] = len(attr_rows)
|
||
|
||
# ---------- decision ----------
|
||
independence_ok = (
|
||
n_clusters >= max(50, min_fills // 20) if n_fills >= min_fills else None
|
||
)
|
||
decision["independence"] = independence_ok
|
||
reasons: list[str] = []
|
||
|
||
min_paths = max(1, min_fills // 10)
|
||
sample_ok = n_fills >= min_fills and n_paths >= min_paths
|
||
|
||
gates = {
|
||
"integrity": integrity_ok,
|
||
"fill_quality": fq_pass,
|
||
"adverse": adv_pass,
|
||
"stability": stab_pass,
|
||
}
|
||
|
||
hard_fail = False
|
||
if not integrity_ok:
|
||
hard_fail = True
|
||
reasons.append("data integrity failed — stop interpretation")
|
||
if toxic_bid_ratio is not None and toxic_bid_ratio > TOXIC_FAIL_RATIO:
|
||
hard_fail = True
|
||
reasons.append(f"Bid toxic fill ratio {toxic_bid_ratio*100:.0f}% > {TOXIC_FAIL_RATIO*100:.0f}%")
|
||
if space_class == "NO_EDGE" and sample_ok:
|
||
hard_fail = True
|
||
reasons.append("edge disappears after cost / NO_EDGE")
|
||
if bench_alpha is not None and bench_alpha <= 0 and sample_ok:
|
||
reasons.append("benchmark-adjusted alpha negative")
|
||
if maker_alpha_mean is not None and maker_alpha_mean <= 0 and sample_ok:
|
||
reasons.append("MakerAlpha (fill−market) ≤ 0")
|
||
if adv_pass is False and sample_ok:
|
||
reasons.append("adverse selection ≥ spread capture")
|
||
if concentrated:
|
||
reasons.append("edge concentrated in single regime")
|
||
|
||
pass_extras = True
|
||
if bench_alpha is not None and bench_alpha <= 0:
|
||
pass_extras = False
|
||
if independence_ok is False:
|
||
pass_extras = False
|
||
reasons.append(f"insufficient independent clusters ({n_clusters})")
|
||
if space_class == "NO_EDGE":
|
||
pass_extras = False
|
||
|
||
all_gates = all(v is True for v in gates.values())
|
||
|
||
# Stage3 unlock checklist(严格)
|
||
stage3_unlock = {
|
||
"data_integrity": integrity_ok is True,
|
||
"cluster_weighted_alpha_gt_0": bool(maker_alpha_mean is not None and maker_alpha_mean > 0),
|
||
"benchmark_alpha_gt_0": bool(bench_alpha is not None and bench_alpha > 0),
|
||
"not_concentrated": not concentrated,
|
||
}
|
||
stage3_ready = all(stage3_unlock.values()) and sample_ok and all_gates and pass_extras
|
||
|
||
# 局部正 edge:归因桶分化或集中在单一正 regime
|
||
local_positive = positive_envs >= 1 and total_envs >= 2 and (
|
||
(positive_envs < total_envs) or concentrated
|
||
)
|
||
|
||
if not integrity_ok:
|
||
verdict = "INVALID"
|
||
reasons = ["Data Integrity FAIL — do not interpret Alpha; discard / keep collecting clean data"]
|
||
reasons.extend(integrity_notes)
|
||
elif not sample_ok or state_coverage_ok is False:
|
||
verdict = "COLLECTING"
|
||
reasons = []
|
||
if n_fills < min_fills:
|
||
reasons.append(f"fills {n_fills} < {min_fills}")
|
||
if n_paths < min_paths:
|
||
reasons.append(f"fill_paths {n_paths} < {min_paths}")
|
||
if n_clusters < max(50, min_fills // 20) and n_fills >= 500:
|
||
reasons.append(f"clusters {n_clusters} insufficient (independent liquidity events)")
|
||
if state_coverage_ok is False:
|
||
reasons.append("state coverage incomplete")
|
||
if not reasons:
|
||
reasons.append("Insufficient independent liquidity events")
|
||
elif hard_fail and not local_positive:
|
||
verdict = "FAIL"
|
||
if not reasons:
|
||
reasons.append("market hypothesis does not hold under current quote assumption")
|
||
elif stage3_ready:
|
||
verdict = "PASS"
|
||
reasons = [
|
||
"Maker alpha survives: cost",
|
||
"Maker alpha survives: benchmark",
|
||
"Maker alpha survives: cluster weighting",
|
||
"Maker alpha survives: multiple states",
|
||
]
|
||
elif local_positive and integrity_ok and sample_ok:
|
||
verdict = "PARTIAL_PASS"
|
||
reasons = [
|
||
"edge not universal — observed only in subset of states/events",
|
||
f"positive attribution buckets: {positive_envs}/{total_envs}",
|
||
]
|
||
if concentrated:
|
||
reasons.append("edge concentrated in one regime/event class")
|
||
if maker_alpha_mean is not None and maker_alpha_mean <= 0:
|
||
reasons.append("overall cluster-weighted MakerAlpha ≤ 0")
|
||
else:
|
||
verdict = "FAIL"
|
||
if not reasons:
|
||
reasons.append("gates failed under current quote assumption")
|
||
if adv_pass is False:
|
||
reasons.insert(0, "adverse selection")
|
||
if sc_mean is not None and abs(sc_mean) < 1e-8:
|
||
reasons.append("insufficient spread")
|
||
|
||
decision["verdict"] = verdict
|
||
decision["reasons"] = reasons
|
||
decision["stage3_unlock"] = stage3_unlock
|
||
decision["stage3_ready"] = stage3_ready
|
||
decision["experiment"] = exp
|
||
|
||
# ==================================================================
|
||
# PRINT — fixed format
|
||
# ==================================================================
|
||
p("=" * 72)
|
||
p("Maker Edge Report v0.1")
|
||
p("Phase: Research Freeze / Data Collection")
|
||
p("Object: verifiable market phenomenon (not a strategy)")
|
||
p("=" * 72)
|
||
|
||
# ----- Executive Summary -----
|
||
p("\nExecutive Summary")
|
||
p("-" * 40)
|
||
p(f"Experiment: {exp.get('experiment_id')}")
|
||
p(f"Version: {exp.get('probe_version')}")
|
||
p("Quote: frozen")
|
||
p("Fee: frozen")
|
||
p("Exchange: frozen")
|
||
p(f"Venue: {exp.get('exchange')} / {exp.get('environment')}")
|
||
p(f"Period: {_period_str(df if not df.empty else fills)}")
|
||
p(f"Instrument: {_instrument(fills, df)}")
|
||
p("Samples:")
|
||
p(f" fills: {n_fills}")
|
||
p(f" clusters: {n_clusters}")
|
||
p(f" paths: {n_paths}")
|
||
p(f" cluster/fill: {cluster_fill_ratio*100:.1f}%")
|
||
p(f"Observation window: {_observation_window(n_fills, n_clusters)}")
|
||
p(" (500=anomaly · 2000=preliminary · 10000=stability; clusters > fills)")
|
||
p(f"Decision: {verdict}")
|
||
p("Reason:")
|
||
for r in reasons:
|
||
p(f" - {r}")
|
||
p("Hypothesis under test: passive fills produce +MakerAlpha")
|
||
p(" under current BTC perp / venue / quote / execution — not strategy PnL.")
|
||
p("Read order: Integrity → distributions (not mean) → Cluster → Toxicity → Decision")
|
||
|
||
# ----- Section 1 -----
|
||
p("\n" + "=" * 72)
|
||
p("Section 1 — Data Integrity")
|
||
p("Question: Is the data trustworthy?")
|
||
p("=" * 72)
|
||
if health.empty:
|
||
p("Healthy: n/a (no health events)")
|
||
p("Sequence gap: n/a")
|
||
p("Latency: n/a")
|
||
p("Book freshness:n/a")
|
||
else:
|
||
p(f"Healthy: {healthy_ratio*100:.2f}%")
|
||
p(f"Sequence gap: total={gap_total} window_max={gap_win_max}")
|
||
p("Latency:")
|
||
p(f" p50: {lat_p50} ms")
|
||
p(f" p95: {lat_p95} ms")
|
||
p(f" p99: {lat_p99} ms")
|
||
p(f"Book freshness: median={ba_med:.1f} ms" if ba_med is not None else "Book freshness: n/a")
|
||
p(f"Integrity: [{'PASS' if integrity_ok else 'FAIL'}]")
|
||
for n in integrity_notes:
|
||
p(f" · {n}")
|
||
if not integrity_ok:
|
||
p("\n★ STOP — Data Integrity FAIL → Decision=INVALID.")
|
||
p(" Do not interpret Alpha. Bad book/latency/gap fills have no research value.")
|
||
|
||
# ----- Section 2 -----
|
||
p("\n" + "=" * 72)
|
||
p("Section 2 — Fill Alpha")
|
||
p("Question: Fill − Matched Market Move (not PnL)")
|
||
p("Priority: distribution (median/p25/p75) over mean")
|
||
p("=" * 72)
|
||
if not integrity_ok:
|
||
p("(skipped for decision — integrity INVALID; numbers below are not evidence)")
|
||
if alpha_table["Overall"]["fill_w"] is None:
|
||
p("(waiting for fill_path with mid + after_30s)")
|
||
else:
|
||
p(f"{'':12s} {'Fill weighted':>16s} {'Cluster weighted':>18s}")
|
||
for lab in ("Bid", "Ask", "Overall"):
|
||
fw = alpha_table[lab]["fill_w"]
|
||
cw = alpha_table[lab]["cluster_w"]
|
||
p(f"{lab+' Alpha':12s} {_fmt_pct(fw):>16s} {_fmt_pct(cw):>18s}")
|
||
p(f"Direction agree (fill-w vs cluster-w): {'YES' if agree else 'NO ★'}")
|
||
p(f"Benchmark P(+) Δ (fill − matched mid): {_fmt_pp(bench_alpha)}")
|
||
p(f"SPACE class: {space_class or 'PENDING'}")
|
||
if raw_capture is not None and net_edge is not None and total_cost is not None:
|
||
p(f"Raw capture@30s (cluster-w): {_fmt_pct(raw_capture)}")
|
||
p(f"Total cost (fee+slip+lat): {_fmt_pct(total_cost)}")
|
||
p(f"Net edge: {_fmt_pct(net_edge)}")
|
||
p("")
|
||
p("Fill Alpha distribution (do not trust mean alone):")
|
||
_print_dist(p, " per-fill MakerAlpha", fill_alpha_dist)
|
||
if pct_fills_positive_alpha is not None:
|
||
p(f" share of fills with +alpha: {pct_fills_positive_alpha*100:.1f}%")
|
||
if pct_fills_positive_alpha < 0.35 and (fill_alpha_dist.get("mean") or 0) > 0:
|
||
p(" ★ mean>0 but minority of fills — edge likely event-driven / fat tail")
|
||
p("")
|
||
p("Cluster Alpha distribution (independent liquidity events):")
|
||
_print_dist(p, " per-cluster mean MakerAlpha", cluster_alpha_dist)
|
||
if (
|
||
alpha_table["Overall"]["fill_w"] is not None
|
||
and alpha_table["Overall"]["cluster_w"] is not None
|
||
):
|
||
fw, cw = alpha_table["Overall"]["fill_w"], alpha_table["Overall"]["cluster_w"]
|
||
if fw > 0 >= cw:
|
||
p(" ★ Fill+ but Cluster≤0 — edge from few burst fills; unstable")
|
||
elif fw > 0 and cw > 0:
|
||
p(" Fill+ and Cluster+ — credibility higher")
|
||
|
||
# ----- Section 3 -----
|
||
p("\n" + "=" * 72)
|
||
p("Section 3 — Toxicity Profile")
|
||
p("Question: Are fills naturally on the wrong side? (record only — no quote changes)")
|
||
p("=" * 72)
|
||
if not toxicity:
|
||
p("(waiting for fill_path)")
|
||
else:
|
||
for label, row in toxicity.items():
|
||
p(f"\n{label}:")
|
||
p(" Immediate toxicity:")
|
||
for hz in ("1s", "5s", "10s"):
|
||
p(f" {hz}: {_fmt_pct(row.get(hz))}")
|
||
p(" Recovery:")
|
||
for hz in ("30s", "300s"):
|
||
p(f" {hz}: {_fmt_pct(row.get(hz))}")
|
||
# factual pattern note only
|
||
t10, t300 = row.get("10s"), row.get("300s")
|
||
if t10 is not None and t300 is not None:
|
||
if t10 < 0 < t300:
|
||
p(" Observed pattern: early toxicity + later recovery (fact; not a rule)")
|
||
elif t10 < 0 and t300 <= 0:
|
||
p(" Observed pattern: sustained adverse (fact; not a rule)")
|
||
elif t10 is not None and t10 > 0:
|
||
p(" Observed pattern: immediate favorable (fact; not a rule)")
|
||
if c_share is not None:
|
||
p(f"\nPath C (toxic) share: {c_share*100:.1f}%")
|
||
if toxic_bid_ratio is not None:
|
||
p(f"Bid toxic fill ratio: {toxic_bid_ratio*100:.1f}%")
|
||
if adv_mag is not None and sc_mean is not None:
|
||
p(f"mean_adverse vs |spread_capture|: {_fmt_pct(adv_mag)} vs {_fmt_pct(abs(sc_mean))}")
|
||
if tox_dist:
|
||
p("\nToxicity distribution:")
|
||
if tox_dist.get("pct_adverse_10s") is not None:
|
||
p(f" fills adverse@10s: {tox_dist['pct_adverse_10s']*100:.1f}%")
|
||
if tox_dist.get("fav10"):
|
||
_print_dist(p, " fav@10s", tox_dist["fav10"])
|
||
w20 = tox_dist.get("worst20_share_of_adverse")
|
||
if w20 is not None:
|
||
p(f" worst 20% of fills share of adverse loss: {w20*100:.1f}%")
|
||
if w20 >= 0.70:
|
||
p(" ★ losses concentrated — future value may be 'which quotes NOT to place'")
|
||
p(" (record only; no cancel/filter rules in freeze)")
|
||
|
||
# ----- Section 4 -----
|
||
p("\n" + "=" * 72)
|
||
p("Section 4 — Observed Edge Attribution")
|
||
p("Facts only. Not strategy recommendations. Not filter rules.")
|
||
p("=" * 72)
|
||
if not attr_rows:
|
||
p("(insufficient state slices)")
|
||
else:
|
||
cur_title = None
|
||
for title, idx, n, mean in attr_rows:
|
||
if title != cur_title:
|
||
p(f"\n{title}:")
|
||
cur_title = title
|
||
sign = "positive" if mean > 0 else ("negative" if mean < 0 else "flat")
|
||
p(f" {idx}: n={n} E[fav30]={_fmt_pct(mean)} ({sign})")
|
||
if concentrated:
|
||
p("\nObservation: positive mass concentrated in a single bucket (fact).")
|
||
|
||
# ----- Section 5 -----
|
||
p("\n" + "=" * 72)
|
||
p("Section 5 — Decision")
|
||
p("=" * 72)
|
||
p(f"Decision: {verdict}")
|
||
p("")
|
||
if verdict == "INVALID":
|
||
p("Reason:")
|
||
for r in reasons:
|
||
p(f" - {r}")
|
||
p("\nKeep collecting only after Data Integrity is clean.")
|
||
elif verdict == "COLLECTING":
|
||
p("Reason:")
|
||
for r in reasons:
|
||
p(f" - {r}")
|
||
p("\nDo not over-interpret before 2000 fills / adequate clusters.")
|
||
p("500 = anomaly check · 2000 = preliminary · 10000 = stability.")
|
||
elif verdict == "PASS":
|
||
p("Maker alpha survives:")
|
||
for r in reasons:
|
||
p(f" - {r.replace('Maker alpha survives: ', '')}")
|
||
p("\n→ Unlock Stage3 Economic Simulation → Symmetric MM")
|
||
elif verdict == "PARTIAL_PASS":
|
||
p("Partial: market hypothesis holds only in some states/events.")
|
||
for r in reasons:
|
||
p(f" - {r}")
|
||
p("\n→ Path: Event-driven LP (not all-day Symmetric MM)")
|
||
p(" Still locked: no new filters yet — attribution is observation only.")
|
||
else:
|
||
p("No maker edge under current quote assumption.")
|
||
p("Dominant reasons:")
|
||
for r in reasons:
|
||
p(f" - {r}")
|
||
p("\nConclusion = hypothesis false (not 'strategy failed'). Avoid futile tuning.")
|
||
|
||
p("\nStage3 Unlock Checklist (Economic Simulation):")
|
||
for k, v in stage3_unlock.items():
|
||
p(f" [{'OK' if v else '·'}] {k}")
|
||
p(f" Stage3 ready: {'YES' if stage3_ready else 'NO'}")
|
||
|
||
p("")
|
||
p("State machine:")
|
||
p(" FAIL → change hypothesis")
|
||
p(" PARTIAL_PASS → Event-driven LP")
|
||
p(" PASS → Economic Simulation → Symmetric MM")
|
||
p(" COLLECTING → keep collecting")
|
||
p("")
|
||
p("Action: run probe. Look at distributions first, Decision second.")
|
||
p("=" * 72)
|
||
|
||
_finish(lines, out_path, decision)
|
||
return decision
|
||
|
||
|
||
def _finish(lines: list[str], out_path: Path | None, decision: dict[str, Any]) -> None:
|
||
if out_path:
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
footer = {
|
||
"event": "maker_edge_decision",
|
||
"report": "Maker Edge Report v0.1",
|
||
"phase": "Research Freeze / Data Collection",
|
||
"verdict": decision.get("verdict"),
|
||
"experiment": decision.get("experiment"),
|
||
"space_class": decision.get("space_class"),
|
||
"benchmark_alpha": decision.get("benchmark_alpha"),
|
||
"maker_alpha_mean": decision.get("maker_alpha_mean"),
|
||
"stage3_ready": decision.get("stage3_ready"),
|
||
"stage3_unlock": decision.get("stage3_unlock"),
|
||
"quality": decision.get("quality"),
|
||
"gates": {
|
||
"integrity": decision.get("integrity"),
|
||
"independence": decision.get("independence"),
|
||
"fill_quality": decision.get("fill_quality"),
|
||
"adverse": decision.get("adverse"),
|
||
"stability": decision.get("stability"),
|
||
},
|
||
"reasons": decision.get("reasons"),
|
||
}
|
||
text = "\n".join(lines) + "\n\n---\n" + json.dumps(footer, ensure_ascii=False, indent=2) + "\n"
|
||
out_path.write_text(text, encoding="utf-8")
|
||
print(f"\nReport saved: {out_path}")
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description="Maker Edge Report v0.1 — Research Freeze")
|
||
ap.add_argument(
|
||
"--dir",
|
||
type=str,
|
||
default=str(Path(__file__).resolve().parents[1] / "logs" / "maker_edge"),
|
||
)
|
||
ap.add_argument("--min-fills", type=int, default=PASS_MIN_FILLS_DEFAULT)
|
||
ap.add_argument("--report", action="store_true")
|
||
args = ap.parse_args()
|
||
log_dir = Path(args.dir)
|
||
if not log_dir.exists():
|
||
print(f"日志目录不存在: {log_dir}")
|
||
return
|
||
try:
|
||
df = load_events(log_dir)
|
||
except FileNotFoundError as e:
|
||
print(e)
|
||
return
|
||
out = log_dir / "Maker_Edge_Report_v0.1.txt" if args.report else None
|
||
report(df, min_fills=args.min_fills, out_path=out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|