fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user