#!/usr/bin/env python3 """ Maker Edge Report v0.1 章节: 1. Fill Quality 2. Adverse Selection 3. MAE/MFE (Price + Time) 4. State Attribution 5. Spread Capture / Quote Lifecycle 假设: H1: P(ret_30s 有利) > 50% H2: restore exit 优于 all fills H3: 亏损集中在某类状态 → 应撤单而非止损 用法: python user_data/Chan/strategies/analyze_maker_edge.py python user_data/Chan/strategies/analyze_maker_edge.py --report python user_data/Chan/strategies/analyze_maker_edge.py --min-fills 500 """ from __future__ import annotations import argparse import json from pathlib import Path import numpy as np import pandas as pd 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 - fill) / fill return np.where(side == "long", raw, -raw) def report(df: pd.DataFrame, min_fills: int = 500, out_path: Path | None = None) -> None: 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() created = df[df["event"] == "quote_created"].copy() if "event" in df.columns else pd.DataFrame() canceled = df[df["event"] == "quote_canceled"].copy() if "event" in df.columns else pd.DataFrame() qfilled = df[df["event"] == "quote_filled"].copy() if "event" in df.columns else pd.DataFrame() exits = df[df["event"] == "fill_exit"].copy() if "event" in df.columns else pd.DataFrame() lines: list[str] = [] def p(s: str = ""): lines.append(s) print(s) p("=" * 72) p("Maker Edge Report v0.1") p("=" * 72) p(f"quote_created : {len(created)}") p(f"quote_canceled: {len(canceled)}") p(f"quote_filled : {len(qfilled)}") p(f"fills : {len(fills)}") p(f"fill_paths : {len(paths)} (需成交后≥5m)") p(f"target fills : ≥{min_fills} [{'OK' if len(fills) >= min_fills else 'COLLECTING'}]") if fills.empty: p("\n尚无 fill。先跑 Dry-run 探针。") return # merge exit_reason onto paths if not exits.empty and not paths.empty and "fill_id" in exits.columns: er = exits.drop_duplicates("fill_id").set_index("fill_id")["exit_reason"] if "exit_reason" not in paths.columns or paths["exit_reason"].isna().all(): paths = paths.merge(er.rename("exit_reason_x"), left_on="fill_id", right_index=True, how="left") if "exit_reason" not in paths.columns: paths["exit_reason"] = paths.get("exit_reason_x") else: paths["exit_reason"] = paths["exit_reason"].fillna(paths.get("exit_reason_x")) # merge fill meta into paths if not paths.empty: cols = [ c for c in [ "side", "fill_price", "fill_reason", "time_to_fill", "trend_state", "volatility_regime", "pre_5s_deteriorated", "obi", "trade_imbalance", "spread", "entry_tag", ] if c in fills.columns ] if cols and "fill_id" in fills.columns: meta = fills.drop_duplicates("fill_id")[["fill_id"] + cols] paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f")) # -------------------- 1. Fill Quality -------------------- p("\n" + "-" * 72) p("1. Fill Quality") p("-" * 72) if "time_to_fill" in fills.columns: ttf = fills["time_to_fill"].dropna() if len(ttf): p( f"time_to_fill mean={ttf.mean():.1f}s median={ttf.median():.1f}s " f"p90={ttf.quantile(0.9):.1f}s" ) fast = fills[fills["time_to_fill"].fillna(1e9) <= 10] slow = fills[fills["time_to_fill"].fillna(0) > 30] p(f"fast fills (≤10s): {len(fast)} slow fills (>30s): {len(slow)}") if "fill_reason" in fills.columns: p("fill_reason: " + str(fills["fill_reason"].value_counts().to_dict())) if "pre_5s_deteriorated" in fills.columns: det = fills["pre_5s_deteriorated"].fillna(False).astype(bool) p(f"pre_5s book deteriorated: {det.mean()*100:.1f}% of fills") n_created = max(len(created), 1) p(f"fill rate (filled/created): {len(qfilled)/n_created*100:.1f}%") if len(canceled): p(f"cancel rate: {len(canceled)/n_created*100:.1f}%") # -------------------- 2. Adverse Selection -------------------- p("\n" + "-" * 72) p("2. Adverse Selection (fill 后收益分布)") p("-" * 72) if paths.empty: p("等待 fill_path 完成(成交后 ≥5 分钟)…") else: side = paths["side"] if "side" in paths.columns else paths.get("side_f") fp = paths["fill_price"] for label, col in [ ("10s", "after_10s_price"), ("30s", "after_30s_price"), ("1m", "after_1m_price"), ("5m", "after_5m_price"), ]: if col not in paths.columns: continue fav = pd.Series(_fav_ret(side, fp, paths[col]), index=paths.index) p( f" +{label:3s} mean={fav.mean()*100:+.4f}% " f"median={fav.median()*100:+.4f}% " f"P(fav)={ (fav>0).mean()*100:.1f}% n={fav.notna().sum()}" ) # toxic: 10s 立刻不利 if "after_10s_price" in paths.columns: fav10 = pd.Series(_fav_ret(side, fp, paths["after_10s_price"]), index=paths.index) p(f" toxic@10s (fav<0): { (fav10<0).mean()*100:.1f}% → 接毒比例") # -------------------- 3. MAE / MFE -------------------- p("\n" + "-" * 72) p("3. MAE / MFE (Price + Time)") p("-" * 72) if not paths.empty: if "price_mae" in paths.columns: p( f"Price MAE mean={paths['price_mae'].mean():+.2f} " f"Price MFE mean={paths['price_mfe'].mean():+.2f}" ) for h in ["10s", "30s", "1m", "5m"]: mae_c, mfe_c = f"mae_{h}", f"mfe_{h}" if mae_c in paths.columns and mfe_c in paths.columns: p( f" Time@{h:3s} MAE={paths[mae_c].mean()*100:+.4f}% " f"MFE={paths[mfe_c].mean()*100:+.4f}%" ) if "mae_5m" in paths.columns and "mfe_5m" in paths.columns: ratio = paths["mfe_5m"].mean() / abs(paths["mae_5m"].mean()) if paths["mae_5m"].mean() != 0 else np.nan p(f" MFE/|MAE| @5m = {ratio:.2f}") # -------------------- 4. State Attribution -------------------- p("\n" + "-" * 72) p("4. State Attribution (亏损集中在哪?)") p("-" * 72) if not paths.empty and "after_5m_price" in paths.columns: side = paths["side"] if "side" in paths.columns else paths.get("side_f") fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index) paths = paths.copy() paths["_fav5"] = fav5 paths["_loss"] = fav5 < 0 loss_rate = float(paths["_loss"].mean()) p(f"overall loss@5m: {loss_rate*100:.1f}%") for col in ["trend_state", "volatility_regime", "fill_reason", "pre_5s_deteriorated"]: c = col if col in paths.columns else (col + "_f" if col + "_f" in paths.columns else None) if not c: continue p(f"\n by {c}:") g = paths.groupby(c).agg( n=("_fav5", "count"), loss_rate=("_loss", "mean"), mean_ret=("_fav5", "mean"), ) for idx, row in g.iterrows(): p( f" {idx}: n={int(row['n'])} loss={row['loss_rate']*100:.1f}% " f"E[ret]={row['mean_ret']*100:+.4f}%" ) # -------------------- 5. Spread Capture / Lifecycle -------------------- p("\n" + "-" * 72) p("5. Spread Capture / Quote Lifecycle") p("-" * 72) if "spread" in fills.columns and fills["spread"].notna().any(): mid = (fills.get("bid_price", 0) + fills.get("ask_price", 0)) / 2 # 简化:相对价差 p(f"spread at fill mean={fills['spread'].mean():.4f} ({(fills['spread']/fills['fill_price']).mean()*100:.5f}%)") if not paths.empty and "after_30s_price" in paths.columns: side = paths["side"] if "side" in paths.columns else paths.get("side_f") fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index) p(f"mean edge@30s (proxy spread capture): {fav30.mean()*100:+.4f}%") # -------------------- Hypotheses -------------------- p("\n" + "-" * 72) p("Hypotheses") p("-" * 72) # H1 h1 = None if not paths.empty and "after_30s_price" in paths.columns: side = paths["side"] if "side" in paths.columns else paths.get("side_f") fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index) h1 = float((fav30 > 0).mean()) p(f"H1 P(fav@30s)>50%: {h1*100:.1f}% [{'PASS' if h1>0.5 else 'FAIL'}]") else: p("H1: insufficient fill_path with after_30s") # H2 restore vs all if not paths.empty and "after_5m_price" in paths.columns: side = paths["side"] if "side" in paths.columns else paths.get("side_f") fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index) er_col = "exit_reason" if "exit_reason" in paths.columns else None if er_col and paths[er_col].notna().any(): restore_mask = paths[er_col].astype(str).str.contains("restore", case=False, na=False) if restore_mask.any(): r_all = float(fav5.mean()) r_res = float(fav5[restore_mask].mean()) verdict = ( "PASS" if r_res > r_all + 1e-12 else ("INCONCLUSIVE" if abs(r_res - r_all) < 1e-12 else "FAIL") ) p( f"H2 restore vs all @5m: restore={r_res*100:+.4f}% all={r_all*100:+.4f}% " f"[{verdict}] n_restore={int(restore_mask.sum())}" ) else: p("H2: no restore exits tagged yet") else: p("H2: exit_reason not linked yet (need closed trades)") else: p("H2: waiting for paths") # H3 concentrated losses if not paths.empty and "_loss" in paths.columns and paths["_loss"].any(): losses = paths[paths["_loss"]] for col in ["trend_state", "volatility_regime", "fill_reason"]: c = col if col in losses.columns else None if c and losses[c].notna().any(): top = losses[c].value_counts(normalize=True).head(1) if len(top): k, v = top.index[0], float(top.iloc[0]) p(f"H3 loss concentration: {v*100:.1f}% of losses in {c}={k} " f"[{'ACTION: cancel in this state' if v>=0.5 else 'diffuse'}]") else: p("H3: need completed paths with losses") p("\n" + "=" * 72) p("Next: accumulate ≥500 fills (ideal 1000) before designing quote model / v1.2.") p("=" * 72) if out_path: out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"\nReport saved: {out_path}") def main(): ap = argparse.ArgumentParser() ap.add_argument( "--dir", type=str, default=str(Path(__file__).resolve().parents[2] / "logs" / "maker_edge"), ) ap.add_argument("--min-fills", type=int, default=500) ap.add_argument("--report", action="store_true", help="also write markdown/txt report") 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 = None if args.report: out = Path(__file__).resolve().parents[2] / "logs" / "maker_edge" / "Maker_Edge_Report_v0.1.txt" report(df, min_fills=args.min_fills, out_path=out) if __name__ == "__main__": main()