Files
Chan/strategies/analyze_maker_edge.py
T
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00

322 lines
13 KiB
Python

#!/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()