Files
nautilus_mm/scripts/analyze_maker_edge.py
jackyu66gitandCursor e2fbe1c2b3 Snapshot nautilus_mm after EXP_002 Phase 1 stop.
Keep frozen research conclusions and code; raw ledgers and secrets stay out of git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:53:22 +08:00

979 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 (fillmarket) ≤ 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()