自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
322 lines
13 KiB
Python
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()
|