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>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
========================================================================
|
||||
Economic Fee Sensitivity v0.1 (MATCHED=3890)
|
||||
========================================================================
|
||||
gross_markout_30s_usdt: -1.730780 USDT
|
||||
fee_total_usdt: +42.422146 USDT
|
||||
realized_component_usdt:-8.521730 USDT
|
||||
|
||||
Fee assumption → Net attributable @30s
|
||||
------------------------------------------
|
||||
fee_factor | fee_usdt_assumed | net_attr_30s_usdt
|
||||
1.00 | +42.422146 | -52.674655
|
||||
0.50 | +21.211073 | -31.463582
|
||||
0.25 | +10.605536 | -20.858046
|
||||
0.10 | +4.242215 | -14.494724
|
||||
0.00 | +0.000000 | -10.252510
|
||||
|
||||
Interpretation:
|
||||
- If net remains < 0 at fee_factor=0 → economics not salvageable by fee reduction alone.
|
||||
- If fee reduction flips net > 0 → current venue/fee tier can be the dominant issue.
|
||||
========================================================================
|
||||
@@ -0,0 +1,21 @@
|
||||
========================================================================
|
||||
Economic Metric Reconciliation v0.1 (MATCHED=3890)
|
||||
========================================================================
|
||||
Matched paths: 3886 (expected ~3886)
|
||||
|
||||
Definitions (same math as analyze_maker_edge):
|
||||
- markout_30s_return = _fav_ret(side, fill_price, after_30s_price)
|
||||
- gross_markout_usdt = sum(notional_usdt * markout_30s_return)
|
||||
|
||||
Return-space metrics (sign may differ due to weighting):
|
||||
MakerAlpha fill-weighted mean return: -0.000693%
|
||||
MakerAlpha notional-weighted mean return: -0.000816%
|
||||
MakerAlpha cluster-weighted mean return: -0.000830%
|
||||
|
||||
Dollar-space metrics:
|
||||
gross_markout_usdt (30s): -1.730780 USDT
|
||||
total_notional_usdt: 212110.750 USDT
|
||||
|
||||
If fill-weighted return is + but gross_markout_usdt is negative,
|
||||
it means notional weighting flips sign (alpha is conditionally realized).
|
||||
========================================================================
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
LOG_DIR="${MAKER_EDGE_LOG_DIR:-$ROOT/logs/maker_edge}"
|
||||
# Prefer project venv python if present
|
||||
PY="${ROOT}/.venv/bin/python"
|
||||
if [[ ! -x "$PY" ]]; then
|
||||
PY=python3
|
||||
fi
|
||||
export PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec "$PY" "$ROOT/scripts/analyze_maker_edge.py" --dir "$LOG_DIR" --report --min-fills "${1:-2000}"
|
||||
@@ -0,0 +1,978 @@
|
||||
#!/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 (fill−market) ≤ 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()
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 部署 MM_EDGE_EXP_001 → jack@jackyu66.com:/www/Project/nautilus_mm
|
||||
#
|
||||
# 默认:
|
||||
# SSH_HOST=jack@jackyu66.com
|
||||
# SSH_KEY=~/Project/deploy/zun_hk/id_ed25519_hk
|
||||
# REMOTE_DIR=/www/Project/nautilus_mm
|
||||
#
|
||||
# 覆盖:export SSH_HOST=... SSH_KEY=... REMOTE_DIR=...
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SSH_HOST="${SSH_HOST:-jack@jackyu66.com}"
|
||||
SSH_KEY="${SSH_KEY:-${HOME}/Project/deploy/zun_hk/id_ed25519_hk}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/www/Project/nautilus_mm}"
|
||||
|
||||
if [[ ! -f "$SSH_KEY" ]]; then
|
||||
echo "SSH key not found: $SSH_KEY"
|
||||
exit 1
|
||||
fi
|
||||
chmod 400 "$SSH_KEY" 2>/dev/null || true
|
||||
|
||||
SSH_OPTS=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
|
||||
SSH=(ssh "${SSH_OPTS[@]}" "$SSH_HOST")
|
||||
RSYNC_E="ssh ${SSH_OPTS[*]}"
|
||||
|
||||
echo "==> stop remote probe before sync (if running)"
|
||||
"${SSH[@]}" "systemctl --user stop mm-edge-probe 2>/dev/null || true"
|
||||
|
||||
echo "==> sync $ROOT → $SSH_HOST:$REMOTE_DIR"
|
||||
"${SSH[@]}" "mkdir -p '$REMOTE_DIR' '$REMOTE_DIR/logs/maker_edge'"
|
||||
rsync -avz --delete \
|
||||
-e "$RSYNC_E" \
|
||||
--exclude '.venv' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude 'logs/maker_edge/*.jsonl' \
|
||||
--exclude 'logs/maker_edge/*.txt' \
|
||||
--exclude 'logs/maker_edge_smoke' \
|
||||
--exclude '.env' \
|
||||
"$ROOT/" "$SSH_HOST:$REMOTE_DIR/"
|
||||
|
||||
echo "==> remote setup (uv venv + user systemd)"
|
||||
"${SSH[@]}" "REMOTE_DIR='$REMOTE_DIR' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
cd "$REMOTE_DIR"
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
{
|
||||
echo ""
|
||||
echo "# Server Data Collection — MM_EDGE_EXP_001"
|
||||
echo "EXPERIMENT_ID=MM_EDGE_EXP_001"
|
||||
echo "PROBE_VERSION=probe_v0.1"
|
||||
echo "EXCHANGE_NAME=binance_usdm"
|
||||
echo "BINANCE_ENVIRONMENT=TESTNET"
|
||||
echo "ENABLE_TRADING=false"
|
||||
echo "QUOTE_TTL_SECS=30"
|
||||
echo "MAX_ABS_INVENTORY=0.005"
|
||||
echo "HTTP_PROXY="
|
||||
echo "HTTPS_PROXY="
|
||||
echo "MAKER_EDGE_LOG_DIR=${REMOTE_DIR}/logs/maker_edge"
|
||||
} >> .env
|
||||
echo "CREATED .env — fill BINANCE_API_KEY / BINANCE_API_SECRET"
|
||||
else
|
||||
echo ".env exists — left untouched"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$HOME/.local/bin/uv" ]]; then
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
fi
|
||||
uv python install 3.12
|
||||
rm -rf .venv
|
||||
uv venv .venv --python 3.12
|
||||
uv pip install -r requirements.txt --python .venv/bin/python
|
||||
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
sed -e "s|/www/Project/nautilus_mm|${REMOTE_DIR}|g" \
|
||||
deploy/mm-edge-probe.user.service > "$HOME/.config/systemd/user/mm-edge-probe.service"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable mm-edge-probe.service
|
||||
loginctl enable-linger "$(whoami)" 2>/dev/null || true
|
||||
echo "User systemd installed (not started — fill keys first)."
|
||||
echo " nano $REMOTE_DIR/.env"
|
||||
echo " systemctl --user start mm-edge-probe"
|
||||
echo " journalctl --user -u mm-edge-probe -f"
|
||||
REMOTE
|
||||
|
||||
echo ""
|
||||
echo "==> done"
|
||||
echo "1) ssh -i $SSH_KEY $SSH_HOST"
|
||||
echo "2) nano $REMOTE_DIR/.env # TESTNET keys"
|
||||
echo "3) systemctl --user start mm-edge-probe"
|
||||
echo "4) ./scripts/probe_status.sh"
|
||||
echo "5) ./scripts/pull_report.sh"
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Economic Attribution v0.1
|
||||
|
||||
Hard Evidence Population only:
|
||||
MATCHED = Local Fill ↔ Venue Trade dual evidence
|
||||
|
||||
Purpose:
|
||||
Economic Attribution only.
|
||||
No strategy modification.
|
||||
No live execution.
|
||||
No economic simulation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
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]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from reconcile_fills import load_local_fills, match, normalize_local, normalize_venue # noqa: E402
|
||||
|
||||
|
||||
def _load_jsonl_df(log_dir: Path) -> pd.DataFrame:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
if f.name.startswith(("Account_", "Maker_", "RECON")):
|
||||
continue
|
||||
for line in f.open():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
rows.append(e)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _parse_fill_context(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df.empty or "fill_context" not in df.columns:
|
||||
return pd.DataFrame(columns=["fill_id"])
|
||||
rows = []
|
||||
for _, r in df.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 _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
|
||||
raw = (pd.to_numeric(px, errors="coerce") - pd.to_numeric(fill, errors="coerce")) / pd.to_numeric(
|
||||
fill, errors="coerce"
|
||||
)
|
||||
return pd.Series(np.where(side == "long", raw, -raw), index=side.index)
|
||||
|
||||
|
||||
def _cluster_weight(frame: pd.DataFrame) -> pd.Series:
|
||||
cnt = frame.groupby("event_cluster_id")["event_cluster_id"].transform("count")
|
||||
return 1.0 / cnt.clip(lower=1)
|
||||
|
||||
|
||||
def _pct(v: float | None) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v * 100:.4f}%"
|
||||
|
||||
|
||||
def _num(v: float | None, digits: int = 4) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v:.{digits}f}"
|
||||
|
||||
|
||||
def _mean(s: pd.Series) -> float | None:
|
||||
s = pd.to_numeric(s, errors="coerce").dropna()
|
||||
return None if s.empty else float(s.mean())
|
||||
|
||||
|
||||
def _sum(s: pd.Series) -> float:
|
||||
s = pd.to_numeric(s, errors="coerce").fillna(0.0)
|
||||
return float(s.sum())
|
||||
|
||||
|
||||
def _weighted_mean(v: pd.Series, w: pd.Series) -> float | None:
|
||||
vv = pd.to_numeric(v, errors="coerce")
|
||||
ww = pd.to_numeric(w, errors="coerce").fillna(0.0)
|
||||
mask = vv.notna() & ww.notna()
|
||||
vv = vv[mask]
|
||||
ww = ww[mask]
|
||||
if vv.empty or float(ww.sum()) == 0.0:
|
||||
return None
|
||||
return float((vv * ww).sum() / ww.sum())
|
||||
|
||||
|
||||
def _prepare_paths(paths: pd.DataFrame) -> pd.DataFrame:
|
||||
paths = paths.copy()
|
||||
if "max_price" in paths.columns and "min_price" in paths.columns and "fill_price" in paths.columns:
|
||||
rng = (pd.to_numeric(paths["max_price"], errors="coerce") - pd.to_numeric(paths["min_price"], errors="coerce")) / pd.to_numeric(
|
||||
paths["fill_price"], errors="coerce"
|
||||
)
|
||||
med = float(rng.dropna().median()) if rng.notna().any() else 0.0
|
||||
paths["vol_bucket"] = np.where(rng >= med, "high_vol", "low_vol")
|
||||
if "price_velocity_5s" in paths.columns and pd.to_numeric(paths["price_velocity_5s"], errors="coerce").notna().any():
|
||||
v = pd.to_numeric(paths["price_velocity_5s"], errors="coerce")
|
||||
thr = float(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 "fill_price" in paths.columns and pd.to_numeric(paths["spread"], errors="coerce").notna().any():
|
||||
sp = pd.to_numeric(paths["spread"], errors="coerce") / pd.to_numeric(paths["fill_price"], errors="coerce")
|
||||
med = float(sp.dropna().median()) if sp.notna().any() else 0.0
|
||||
paths["liq_bucket"] = np.where(sp <= med, "tight_spread", "wide_spread")
|
||||
paths["toxicity_bucket"] = np.where(paths["path_type"].astype(str).str.startswith("C"), "toxic", "non_toxic")
|
||||
return paths
|
||||
|
||||
|
||||
def _inventory_metrics(matched: pd.DataFrame) -> dict[str, float | None]:
|
||||
if matched.empty:
|
||||
return {}
|
||||
g = matched.sort_values("venue_time_ms").copy()
|
||||
g["signed_qty"] = np.where(g["side"] == "long", g["qty"], -g["qty"])
|
||||
g["net_btc"] = g["signed_qty"].cumsum()
|
||||
g["abs_net_btc"] = g["net_btc"].abs()
|
||||
times = pd.to_numeric(g["venue_time_ms"], errors="coerce").astype("float64") / 1000.0
|
||||
dt = times.shift(-1) - times
|
||||
dt = dt.fillna(0.0).clip(lower=0.0)
|
||||
total_t = float(dt.sum())
|
||||
tw_abs = float((g["abs_net_btc"] * dt).sum() / total_t) if total_t > 0 else None
|
||||
tw_signed = float((g["net_btc"] * dt).sum() / total_t) if total_t > 0 else None
|
||||
return {
|
||||
"max_net_btc": float(g["net_btc"].max()),
|
||||
"min_net_btc": float(g["net_btc"].min()),
|
||||
"max_abs_net_btc": float(g["abs_net_btc"].max()),
|
||||
"avg_abs_net_btc_per_fill": float(g["abs_net_btc"].mean()),
|
||||
"time_weighted_abs_net_btc": tw_abs,
|
||||
"time_weighted_signed_net_btc": tw_signed,
|
||||
"long_qty": float(g.loc[g["signed_qty"] > 0, "signed_qty"].sum()),
|
||||
"short_qty": float((-g.loc[g["signed_qty"] < 0, "signed_qty"]).sum()),
|
||||
"turnover_btc": float(g["qty"].sum()),
|
||||
}
|
||||
|
||||
|
||||
def _bucket_table(paths: pd.DataFrame, bucket: str, title: str) -> list[dict[str, Any]]:
|
||||
if bucket not in paths.columns or paths.empty:
|
||||
return []
|
||||
rows = []
|
||||
for key, grp in paths.groupby(bucket):
|
||||
notional = grp["notional_usdt"].sum()
|
||||
clusters = grp["event_cluster_id"].nunique()
|
||||
rows.append(
|
||||
{
|
||||
"dimension": title,
|
||||
"bucket": str(key),
|
||||
"fills": int(len(grp)),
|
||||
"clusters": int(clusters),
|
||||
"btc_qty": float(grp["qty"].sum()),
|
||||
"notional_usdt": float(notional),
|
||||
"fee_usdt": float(grp["commission_usdt"].sum()),
|
||||
"fee_per_fill": float(grp["commission_usdt"].mean()) if len(grp) else None,
|
||||
"fee_per_btc": float(grp["commission_usdt"].sum() / grp["qty"].sum()) if grp["qty"].sum() else None,
|
||||
"markout_1s": _weighted_mean(grp["markout_1s"], grp["notional_usdt"]),
|
||||
"markout_5s": _weighted_mean(grp["markout_5s"], grp["notional_usdt"]),
|
||||
"markout_10s": _weighted_mean(grp["markout_10s"], grp["notional_usdt"]),
|
||||
"markout_30s": _weighted_mean(grp["markout_30s"], grp["notional_usdt"]),
|
||||
"markout_300s": _weighted_mean(grp["markout_300s"], grp["notional_usdt"]),
|
||||
"gross_markout_30s_usdt": float(grp["gross_markout_30s_usdt"].sum()),
|
||||
"realized_pnl_usdt": float(grp["realized_pnl_usdt"].sum()),
|
||||
"net_attr_30s_usdt": float(grp["net_attr_30s_usdt"].sum()),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda x: (-x["fills"], x["bucket"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _counterfactual(base: pd.DataFrame, exclude_col: str, exclude_values: set[str], label: str) -> dict[str, Any]:
|
||||
kept = base[~base[exclude_col].astype(str).isin(exclude_values)].copy()
|
||||
return {
|
||||
"name": label,
|
||||
"fills": int(len(kept)),
|
||||
"clusters": int(kept["event_cluster_id"].nunique()) if not kept.empty else 0,
|
||||
"btc_qty": float(kept["qty"].sum()) if not kept.empty else 0.0,
|
||||
"fee_usdt": float(kept["commission_usdt"].sum()) if not kept.empty else 0.0,
|
||||
"gross_markout_30s_usdt": float(kept["gross_markout_30s_usdt"].sum()) if not kept.empty else 0.0,
|
||||
"realized_pnl_usdt": float(kept["realized_pnl_usdt"].sum()) if not kept.empty else 0.0,
|
||||
"net_attr_30s_usdt": float(kept["net_attr_30s_usdt"].sum()) if not kept.empty else 0.0,
|
||||
"markout_30s": _weighted_mean(kept["markout_30s"], kept["notional_usdt"]),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Economic Attribution v0.1 (MATCHED only)")
|
||||
ap.add_argument("--dir", default=str(ROOT / "logs" / "maker_edge"))
|
||||
ap.add_argument("--out", default=str(ROOT / "logs" / "maker_edge" / "Economic_Attribution_v0_1.txt"))
|
||||
ap.add_argument("--recon03", default=str(ROOT / "logs" / "maker_edge" / "RECONCILIATION_03.json"))
|
||||
ap.add_argument("--account", default=str(ROOT / "logs" / "maker_edge" / "Account_Reconciliation.json"))
|
||||
ap.add_argument("--venue-trades", default=str(ROOT / "logs" / "maker_edge" / "venue_trades.json"))
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
df = _load_jsonl_df(log_dir)
|
||||
fills = df[df["event"] == "fill"].copy()
|
||||
paths = df[df["event"] == "fill_path"].copy()
|
||||
inv = df[df["event"] == "inventory_tick"].copy()
|
||||
|
||||
venue_trades = json.loads(Path(args.venue_trades).read_text())
|
||||
local_fills_raw = load_local_fills(log_dir)
|
||||
locals_norm = [normalize_local(e, i) for i, e in enumerate(local_fills_raw)]
|
||||
venues_norm = [normalize_venue(t, i) for i, t in enumerate(venue_trades)]
|
||||
recon = match(locals_norm, venues_norm)
|
||||
matched_fill_ids = {m["local"]["fill_id"] for m in recon["matched"]}
|
||||
matched_trade_ids = {m["venue"]["venue_trade_id"] for m in recon["matched"]}
|
||||
|
||||
fills = fills[fills["fill_id"].isin(matched_fill_ids)].copy()
|
||||
paths = paths[paths["fill_id"].isin(matched_fill_ids)].copy()
|
||||
|
||||
fc = _parse_fill_context(fills)
|
||||
meta_cols = [
|
||||
c
|
||||
for c in [
|
||||
"fill_id",
|
||||
"side",
|
||||
"fill_price",
|
||||
"spread",
|
||||
"spread_capture_pct",
|
||||
"obi",
|
||||
"trade_imbalance",
|
||||
"bid_depth_5",
|
||||
"ask_depth_5",
|
||||
"book_age_ms",
|
||||
"inventory",
|
||||
"inventory_time",
|
||||
"inventory_skew",
|
||||
"pre_5s_deteriorated",
|
||||
"mid",
|
||||
"event_cluster_id",
|
||||
"pair",
|
||||
]
|
||||
if c in fills.columns
|
||||
]
|
||||
meta = fills.drop_duplicates("fill_id")[meta_cols]
|
||||
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
|
||||
for col in ["event_cluster_id", "side", "fill_price", "spread_capture_pct", "mid"]:
|
||||
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])
|
||||
if not fc.empty:
|
||||
paths = paths.merge(fc, on="fill_id", how="left")
|
||||
paths = _prepare_paths(paths)
|
||||
|
||||
venue = pd.DataFrame(venues_norm)
|
||||
venue = venue[venue["venue_trade_id"].isin(matched_trade_ids)].copy()
|
||||
venue = venue.rename(
|
||||
columns={
|
||||
"venue_trade_id": "trade_id_link",
|
||||
"venue_order_id": "venue_order_id",
|
||||
"qty": "qty",
|
||||
"px": "venue_price",
|
||||
"ts": "venue_ts",
|
||||
}
|
||||
)
|
||||
raw_v = pd.DataFrame(venue_trades)
|
||||
raw_v["trade_id_link"] = raw_v["id"].astype(str)
|
||||
raw_v["venue_order_id"] = raw_v["orderId"].astype(str)
|
||||
raw_v["commission_usdt"] = pd.to_numeric(raw_v["commission"], errors="coerce")
|
||||
raw_v["realized_pnl_usdt"] = pd.to_numeric(raw_v["realizedPnl"], errors="coerce").fillna(0.0)
|
||||
raw_v["venue_time_ms"] = pd.to_numeric(raw_v["time"], errors="coerce")
|
||||
raw_v["qty"] = pd.to_numeric(raw_v["qty"], errors="coerce")
|
||||
raw_v["venue_price"] = pd.to_numeric(raw_v["price"], errors="coerce")
|
||||
raw_v["side"] = np.where(raw_v["buyer"].astype(bool), "long", "short")
|
||||
raw_v = raw_v[raw_v["trade_id_link"].isin(matched_trade_ids)].copy()
|
||||
|
||||
matched_map = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"fill_id": m["local"]["fill_id"],
|
||||
"trade_id_link": m["venue"]["venue_trade_id"],
|
||||
"venue_order_id": m["venue"]["venue_order_id"],
|
||||
}
|
||||
for m in recon["matched"]
|
||||
]
|
||||
)
|
||||
|
||||
paths = paths.merge(
|
||||
matched_map.merge(
|
||||
raw_v[
|
||||
[
|
||||
"trade_id_link",
|
||||
"venue_order_id",
|
||||
"commission_usdt",
|
||||
"realized_pnl_usdt",
|
||||
"venue_time_ms",
|
||||
"qty",
|
||||
"venue_price",
|
||||
"side",
|
||||
]
|
||||
],
|
||||
on=["trade_id_link", "venue_order_id"],
|
||||
how="left",
|
||||
),
|
||||
on="fill_id",
|
||||
how="left",
|
||||
suffixes=("", "_venue"),
|
||||
)
|
||||
|
||||
paths["fill_price"] = pd.to_numeric(paths["fill_price"], errors="coerce")
|
||||
paths["qty"] = pd.to_numeric(paths["qty"], errors="coerce")
|
||||
paths["notional_usdt"] = paths["fill_price"] * paths["qty"]
|
||||
for sec, col in [(1, "after_1s_price"), (5, "after_5s_price"), (10, "after_10s_price"), (30, "after_30s_price"), (300, "after_5m_price")]:
|
||||
paths[f"markout_{sec}s"] = _fav_ret(paths["side"], paths["fill_price"], paths[col])
|
||||
paths["gross_markout_30s_usdt"] = paths["notional_usdt"] * paths["markout_30s"]
|
||||
paths["net_attr_30s_usdt"] = (
|
||||
paths["gross_markout_30s_usdt"]
|
||||
- pd.to_numeric(paths["commission_usdt"], errors="coerce").fillna(0.0)
|
||||
+ pd.to_numeric(paths["realized_pnl_usdt"], errors="coerce").fillna(0.0)
|
||||
)
|
||||
|
||||
inventory_metrics = _inventory_metrics(
|
||||
raw_v[
|
||||
["venue_time_ms", "side", "qty", "commission_usdt", "realized_pnl_usdt", "venue_order_id", "trade_id_link"]
|
||||
].copy()
|
||||
)
|
||||
|
||||
n_matched_paths = len(paths)
|
||||
n_matched_fills = len(fills)
|
||||
n_matched_clusters = int(fills["event_cluster_id"].nunique()) if not fills.empty else 0
|
||||
cluster_w = _cluster_weight(paths) if not paths.empty and "event_cluster_id" in paths.columns else pd.Series(dtype=float)
|
||||
|
||||
horizon_rows = []
|
||||
for sec in (1, 5, 10, 30, 300):
|
||||
col = f"markout_{sec}s"
|
||||
valid = paths[col].notna()
|
||||
sub = paths[valid]
|
||||
w = sub["notional_usdt"]
|
||||
horizon_rows.append(
|
||||
{
|
||||
"horizon": f"{sec}s",
|
||||
"n": int(len(sub)),
|
||||
"fill_w": _weighted_mean(sub[col], w),
|
||||
"cluster_w": _weighted_mean(sub[col], _cluster_weight(sub) if not sub.empty else pd.Series(dtype=float)),
|
||||
"gross_usdt": float((sub["notional_usdt"] * sub[col]).sum()) if not sub.empty else 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
fee_total = float(paths["commission_usdt"].sum())
|
||||
realized_total = float(paths["realized_pnl_usdt"].sum())
|
||||
gross_30_total = float(paths["gross_markout_30s_usdt"].sum())
|
||||
net_attr_30_total = float(paths["net_attr_30s_usdt"].sum())
|
||||
total_qty = float(paths["qty"].sum())
|
||||
total_notional = float(paths["notional_usdt"].sum())
|
||||
|
||||
bucket_rows: list[dict[str, Any]] = []
|
||||
for col, title in [
|
||||
("path_type", "PathType"),
|
||||
("toxicity_bucket", "Toxicity"),
|
||||
("vol_bucket", "Volatility"),
|
||||
("liq_bucket", "Spread"),
|
||||
("trend_bucket", "Trend"),
|
||||
("market_event_before_fill", "FillContext"),
|
||||
]:
|
||||
bucket_rows.extend(_bucket_table(paths, col, title))
|
||||
bucket_df = pd.DataFrame(bucket_rows)
|
||||
|
||||
negative_states: set[str] = set()
|
||||
if not bucket_df.empty:
|
||||
neg = bucket_df[(bucket_df["dimension"] != "PathType") & (bucket_df["markout_30s"] < 0)]
|
||||
negative_states = set(neg["bucket"].astype(str))
|
||||
|
||||
counterfactuals = [
|
||||
{
|
||||
"name": "BASELINE",
|
||||
"fills": int(len(paths)),
|
||||
"clusters": int(paths["event_cluster_id"].nunique()) if not paths.empty else 0,
|
||||
"btc_qty": total_qty,
|
||||
"fee_usdt": fee_total,
|
||||
"gross_markout_30s_usdt": gross_30_total,
|
||||
"realized_pnl_usdt": realized_total,
|
||||
"net_attr_30s_usdt": net_attr_30_total,
|
||||
"markout_30s": _weighted_mean(paths["markout_30s"], paths["notional_usdt"]),
|
||||
},
|
||||
_counterfactual(paths, "path_type", {"C_toxic"}, "EXCLUDE_PATH_C"),
|
||||
_counterfactual(paths, "toxicity_bucket", {"toxic"}, "EXCLUDE_TOXIC"),
|
||||
_counterfactual(paths, "market_event_before_fill", negative_states, "EXCLUDE_NEGATIVE_STATE"),
|
||||
]
|
||||
|
||||
account = json.loads(Path(args.account).read_text()) if Path(args.account).exists() else {}
|
||||
recon03 = json.loads(Path(args.recon03).read_text()) if Path(args.recon03).exists() else {}
|
||||
|
||||
out_txt = Path(args.out)
|
||||
out_json = out_txt.with_suffix(".json")
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Economic Attribution v0.1")
|
||||
p("=" * 72)
|
||||
p("Experiment: MM_EDGE_EXP_001")
|
||||
p("Population: MATCHED=3890")
|
||||
p("Strategy: v0.1 FROZEN")
|
||||
p("Execution: STOPPED")
|
||||
p("Stage3: LOCKED")
|
||||
p("Purpose: Economic Attribution only.")
|
||||
p("No strategy modification. No live execution. No economic simulation.")
|
||||
p()
|
||||
p("Layer 1 — Hard Economic Evidence")
|
||||
p("-" * 40)
|
||||
p(f"Matched fills: {n_matched_fills}")
|
||||
p(f"Matched paths: {n_matched_paths}")
|
||||
p(f"Matched clusters: {n_matched_clusters}")
|
||||
p(f"Fee total: {_num(fee_total, 6)} USDT")
|
||||
p(f"Fee / fill: {_num(fee_total / max(n_matched_paths, 1), 6)} USDT")
|
||||
p(f"Fee / BTC: {_num(fee_total / max(total_qty, 1e-12), 6)} USDT")
|
||||
p(f"Fee / cluster: {_num(fee_total / max(n_matched_clusters, 1), 6)} USDT")
|
||||
p(f"Realized component: {_num(realized_total, 6)} USDT")
|
||||
p(f"Gross markout @30s: {_num(gross_30_total, 6)} USDT")
|
||||
p(f"Net attributable @30s: {_num(net_attr_30_total, 6)} USDT")
|
||||
p()
|
||||
p("Markout by horizon (MATCHED only)")
|
||||
p("-" * 40)
|
||||
for row in horizon_rows:
|
||||
p(
|
||||
f"{row['horizon']:>5} n={row['n']:4d} fill-w={_pct(row['fill_w'])} "
|
||||
f"cluster-w={_pct(row['cluster_w'])} gross={_num(row['gross_usdt'], 6)} USDT"
|
||||
)
|
||||
p()
|
||||
p("Inventory carry / exposure")
|
||||
p("-" * 40)
|
||||
p(f"Max net BTC: {_num(inventory_metrics.get('max_net_btc'), 6)}")
|
||||
p(f"Min net BTC: {_num(inventory_metrics.get('min_net_btc'), 6)}")
|
||||
p(f"Max |net BTC|: {_num(inventory_metrics.get('max_abs_net_btc'), 6)}")
|
||||
p(f"Average |net BTC|: {_num(inventory_metrics.get('avg_abs_net_btc_per_fill'), 6)}")
|
||||
p(f"TW |net BTC|: {_num(inventory_metrics.get('time_weighted_abs_net_btc'), 6)}")
|
||||
p(f"TW signed net BTC: {_num(inventory_metrics.get('time_weighted_signed_net_btc'), 6)}")
|
||||
p(f"Long qty / Short qty: {_num(inventory_metrics.get('long_qty'), 6)} / {_num(inventory_metrics.get('short_qty'), 6)} BTC")
|
||||
p(f"Inventory turnover: {_num(inventory_metrics.get('turnover_btc'), 6)} BTC")
|
||||
p()
|
||||
p("Slices (weighted by notional, MATCHED only)")
|
||||
p("-" * 40)
|
||||
for dim in ["PathType", "Toxicity", "Volatility", "Spread", "Trend", "FillContext"]:
|
||||
sub = bucket_df[bucket_df["dimension"] == dim].copy()
|
||||
if sub.empty:
|
||||
continue
|
||||
p(dim)
|
||||
for _, r in sub.sort_values(["fills", "bucket"], ascending=[False, True]).iterrows():
|
||||
p(
|
||||
f" {r['bucket']}: n={int(r['fills'])} clusters={int(r['clusters'])} "
|
||||
f"fee={_num(r['fee_usdt'], 4)} gross30={_num(r['gross_markout_30s_usdt'], 4)} "
|
||||
f"realized={_num(r['realized_pnl_usdt'], 4)} net30={_num(r['net_attr_30s_usdt'], 4)} "
|
||||
f"m30={_pct(r['markout_30s'])}"
|
||||
)
|
||||
p()
|
||||
p("Layer 2 — Evidence Extension (excluded from core conclusion)")
|
||||
p("-" * 40)
|
||||
p(f"VENUE_CONFIRMED_NO_TRADE_HISTORY: {recon03.get('venue_confirmed_no_trade_history', 'n/a')}")
|
||||
p(f"VENUE_PARTIAL_ORDER_CANCELED: {recon03.get('venue_partial_order_canceled', 'n/a')}")
|
||||
p("These rows are order-confirmed, but not part of the Hard Evidence Population.")
|
||||
p()
|
||||
p("Layer 3 — Counterfactual Attribution (NOT backtest)")
|
||||
p("-" * 40)
|
||||
p("Observed vs Exclude-Bucket Attribution. These are contribution decompositions only.")
|
||||
for row in counterfactuals:
|
||||
p(
|
||||
f"{row['name']}: fills={row['fills']} clusters={row['clusters']} "
|
||||
f"fee={_num(row['fee_usdt'], 4)} gross30={_num(row['gross_markout_30s_usdt'], 4)} "
|
||||
f"realized={_num(row['realized_pnl_usdt'], 4)} net30={_num(row['net_attr_30s_usdt'], 4)} "
|
||||
f"m30={_pct(row['markout_30s'])}"
|
||||
)
|
||||
p()
|
||||
p("Interpretation")
|
||||
p("-" * 40)
|
||||
p("Core conclusion is based on 3890 fully matched fills.")
|
||||
p("Economic Attribution asks why MakerAlpha did not convert to money.")
|
||||
p("It does NOT change quote logic, does NOT restart v0.1, and does NOT unlock Stage 3.")
|
||||
p("=" * 72)
|
||||
|
||||
out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
sidecar = {
|
||||
"experiment_id": "MM_EDGE_EXP_001",
|
||||
"population": {
|
||||
"name": "MATCHED",
|
||||
"fills": n_matched_fills,
|
||||
"paths": n_matched_paths,
|
||||
"clusters": n_matched_clusters,
|
||||
},
|
||||
"strategy": "v0.1 FROZEN",
|
||||
"execution": "STOPPED",
|
||||
"stage3": "LOCKED",
|
||||
"fee_total_usdt": fee_total,
|
||||
"fee_per_fill_usdt": fee_total / max(n_matched_paths, 1),
|
||||
"fee_per_btc_usdt": fee_total / max(total_qty, 1e-12),
|
||||
"fee_per_cluster_usdt": fee_total / max(n_matched_clusters, 1),
|
||||
"realized_component_usdt": realized_total,
|
||||
"gross_markout_30s_usdt": gross_30_total,
|
||||
"net_attr_30s_usdt": net_attr_30_total,
|
||||
"markout_by_horizon": horizon_rows,
|
||||
"inventory_metrics": inventory_metrics,
|
||||
"bucket_rows": bucket_rows,
|
||||
"counterfactuals": counterfactuals,
|
||||
"recon03_extension": {
|
||||
"venue_confirmed_no_trade_history": recon03.get("venue_confirmed_no_trade_history"),
|
||||
"venue_partial_order_canceled": recon03.get("venue_partial_order_canceled"),
|
||||
},
|
||||
"account_recon_ref": account,
|
||||
}
|
||||
out_json.write_text(json.dumps(sidecar, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Economic Fee Sensitivity v0.1 (MATCHED=3890)
|
||||
|
||||
Computes:
|
||||
net_attr_30s(fee_factor) = gross_markout_30s_usdt - fee_factor * fee_total_usdt + realized_component_usdt
|
||||
|
||||
Assumption:
|
||||
realized_component_usdt and gross_markout_30s_usdt are fixed (price/path unchanged).
|
||||
Only fee scaling is applied as a counterfactual sensitivity.
|
||||
|
||||
This is NOT a strategy backtest and does NOT modify any execution logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Economic Fee Sensitivity v0.1")
|
||||
ap.add_argument(
|
||||
"--attribution",
|
||||
default=str(ROOT / "logs" / "maker_edge" / "Economic_Attribution_v0_1.json"),
|
||||
)
|
||||
ap.add_argument(
|
||||
"--out",
|
||||
default=str(ROOT / "logs" / "maker_edge" / "Economic_Fee_Sensitivity_v0_1.txt"),
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
data = json.loads(Path(args.attribution).read_text())
|
||||
fee_total = float(data["fee_total_usdt"])
|
||||
realized_total = float(data["realized_component_usdt"])
|
||||
gross_markout = float(data["gross_markout_30s_usdt"])
|
||||
|
||||
factors = [1.0, 0.5, 0.25, 0.1, 0.0]
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Economic Fee Sensitivity v0.1 (MATCHED=3890)")
|
||||
p("=" * 72)
|
||||
p(f"gross_markout_30s_usdt: {gross_markout:+.6f} USDT")
|
||||
p(f"fee_total_usdt: {fee_total:+.6f} USDT")
|
||||
p(f"realized_component_usdt:{realized_total:+.6f} USDT")
|
||||
p()
|
||||
p("Fee assumption → Net attributable @30s")
|
||||
p("-" * 42)
|
||||
|
||||
header = ["fee_factor", "fee_usdt_assumed", "net_attr_30s_usdt"]
|
||||
p(" | ".join(header))
|
||||
|
||||
for f in factors:
|
||||
fee_assumed = f * fee_total
|
||||
net = gross_markout - fee_assumed + realized_total
|
||||
row = [f"{f:.2f}", f"{fee_assumed:+.6f}", f"{net:+.6f}"]
|
||||
p(" | ".join(row))
|
||||
|
||||
p()
|
||||
p("Interpretation:")
|
||||
p("- If net remains < 0 at fee_factor=0 → economics not salvageable by fee reduction alone.")
|
||||
p("- If fee reduction flips net > 0 → current venue/fee tier can be the dominant issue.")
|
||||
p("=" * 72)
|
||||
|
||||
Path(args.out).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metric Reconciliation v0.1 (MATCHED only)
|
||||
|
||||
Confirms consistency between:
|
||||
- "MakerAlpha" reported in v0.1 research (return space)
|
||||
- "Gross markout @30s" in Economic Attribution (dollar space)
|
||||
- realized component used in Economic Attribution
|
||||
|
||||
Key point:
|
||||
Same definition may flip sign depending on weighting:
|
||||
fill-weighted mean return vs notional-weighted dollar markout
|
||||
|
||||
This script is read-only: it does NOT change any strategy/execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from reconcile_fills import load_local_fills, match, normalize_local, normalize_venue # noqa: E402
|
||||
|
||||
|
||||
def _load_jsonl_df(log_dir: Path) -> pd.DataFrame:
|
||||
rows: list[dict] = []
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
if f.name.startswith(("Account_", "Maker_", "RECON")):
|
||||
continue
|
||||
for line in f.open():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
rows.append(e)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
|
||||
# return space, signed by side
|
||||
fill = pd.to_numeric(fill, errors="coerce")
|
||||
px = pd.to_numeric(px, errors="coerce")
|
||||
raw = (px - fill) / fill
|
||||
return pd.Series(np.where(side == "long", raw, -raw), index=side.index)
|
||||
|
||||
|
||||
def _weighted_mean(x: pd.Series, w: pd.Series) -> float | None:
|
||||
xx = pd.to_numeric(x, errors="coerce")
|
||||
ww = pd.to_numeric(w, errors="coerce")
|
||||
mask = xx.notna() & ww.notna()
|
||||
xx = xx[mask]
|
||||
ww = ww[mask]
|
||||
if xx.empty:
|
||||
return None
|
||||
sw = float(ww.sum())
|
||||
if sw == 0:
|
||||
return None
|
||||
return float((xx * ww).sum() / sw)
|
||||
|
||||
|
||||
def _cluster_weight(paths: pd.DataFrame) -> pd.Series:
|
||||
if "event_cluster_id" not in paths.columns:
|
||||
return pd.Series(1.0, index=paths.index)
|
||||
cnt = paths.groupby("event_cluster_id")["event_cluster_id"].transform("count")
|
||||
return 1.0 / cnt.clip(lower=1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Economic Metric Reconciliation v0.1")
|
||||
ap.add_argument("--dir", default=str(ROOT / "logs" / "maker_edge"))
|
||||
ap.add_argument("--out", default=str(ROOT / "logs" / "maker_edge" / "Economic_Metric_Reconciliation_v0_1.txt"))
|
||||
ap.add_argument("--venue-trades", default=str(ROOT / "logs" / "maker_edge" / "venue_trades.json"))
|
||||
ap.add_argument("--matched-take", type=int, default=3890)
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
venue_trades_path = Path(args.venue_trades)
|
||||
|
||||
df = _load_jsonl_df(log_dir)
|
||||
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()
|
||||
|
||||
# Hard matched population via RECON-02/03 evidence: use existing matcher logic.
|
||||
venue_trades = json.loads(venue_trades_path.read_text())
|
||||
local_fills_raw = load_local_fills(log_dir)
|
||||
locals_norm = [normalize_local(e, i) for i, e in enumerate(local_fills_raw)]
|
||||
venues_norm = [normalize_venue(t, i) for i, t in enumerate(venue_trades)]
|
||||
recon = match(locals_norm, venues_norm)
|
||||
|
||||
matched_fill_ids = {m["local"]["fill_id"] for m in recon["matched"]}
|
||||
matched_trade_ids = {m["venue"]["venue_trade_id"] for m in recon["matched"]}
|
||||
|
||||
fills = fills[fills["fill_id"].isin(matched_fill_ids)].copy()
|
||||
paths = paths[paths["fill_id"].isin(matched_fill_ids)].copy()
|
||||
|
||||
# Build after_30s already present in fill_path fields.
|
||||
# MakerAlpha in analyze_maker_edge uses after_30s_price and _fav_ret definition.
|
||||
# We'll recompute:
|
||||
# return space:
|
||||
# maker_alpha_fill_weighted = mean(markout_30s)
|
||||
# maker_alpha_notional_weighted_return = (gross_markout_usdt / total_notional)
|
||||
# gross_markout_usdt = sum(notional * markout_30s)
|
||||
#
|
||||
if paths.empty:
|
||||
raise SystemExit("No matched paths loaded")
|
||||
|
||||
# Merge meta from fills (side, fill_price, event_cluster_id, notional proxy)
|
||||
meta_cols = [
|
||||
c
|
||||
for c in [
|
||||
"fill_id",
|
||||
"side",
|
||||
"fill_price",
|
||||
"amount",
|
||||
"event_cluster_id",
|
||||
"spread_capture_pct",
|
||||
"pair",
|
||||
]
|
||||
if c in fills.columns
|
||||
]
|
||||
meta = fills.drop_duplicates("fill_id")[meta_cols]
|
||||
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_m"))
|
||||
|
||||
# If fill_path already had these columns, merge created *_m alternates.
|
||||
for col in ["side", "fill_price", "amount", "event_cluster_id"]:
|
||||
alt = f"{col}_m"
|
||||
if alt in paths.columns:
|
||||
if col not in paths.columns:
|
||||
paths[col] = paths[alt]
|
||||
else:
|
||||
paths[col] = paths[col].fillna(paths[alt])
|
||||
|
||||
# Ensure required fields
|
||||
paths["side"] = paths["side"].astype(str)
|
||||
paths["fill_price"] = pd.to_numeric(paths["fill_price"], errors="coerce")
|
||||
paths["qty"] = pd.to_numeric(paths["amount"], errors="coerce")
|
||||
paths["notional_usdt"] = paths["fill_price"] * paths["qty"]
|
||||
paths["after_30s_price"] = pd.to_numeric(paths["after_30s_price"], errors="coerce")
|
||||
|
||||
paths["markout_30s_return"] = _fav_ret(paths["side"], paths["fill_price"], paths["after_30s_price"])
|
||||
|
||||
gross_markout_usdt = float((paths["notional_usdt"] * paths["markout_30s_return"]).sum())
|
||||
total_notional = float(paths["notional_usdt"].sum())
|
||||
maker_alpha_fill_weighted = float(paths["markout_30s_return"].mean())
|
||||
maker_alpha_notional_weighted_return = float(gross_markout_usdt / total_notional) if total_notional else None
|
||||
|
||||
cw = _cluster_weight(paths)
|
||||
maker_alpha_cluster_weighted_return = _weighted_mean(paths["markout_30s_return"], cw)
|
||||
|
||||
# realized component from userTrades is already in Economic Attribution.
|
||||
# Here we only validate return space; realized component sign conventions are asserted elsewhere.
|
||||
out = Path(args.out)
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Economic Metric Reconciliation v0.1 (MATCHED=3890)")
|
||||
p("=" * 72)
|
||||
p(f"Matched paths: {len(paths)} (expected ~3886)")
|
||||
p()
|
||||
p("Definitions (same math as analyze_maker_edge):")
|
||||
p("- markout_30s_return = _fav_ret(side, fill_price, after_30s_price)")
|
||||
p("- gross_markout_usdt = sum(notional_usdt * markout_30s_return)")
|
||||
p()
|
||||
p("Return-space metrics (sign may differ due to weighting):")
|
||||
p(f"MakerAlpha fill-weighted mean return: {_pct(maker_alpha_fill_weighted)}")
|
||||
p(f"MakerAlpha notional-weighted mean return: {_pct(maker_alpha_notional_weighted_return)}")
|
||||
p(f"MakerAlpha cluster-weighted mean return: {_pct(maker_alpha_cluster_weighted_return)}")
|
||||
p()
|
||||
p("Dollar-space metrics:")
|
||||
p(f"gross_markout_usdt (30s): {gross_markout_usdt:+.6f} USDT")
|
||||
p(f"total_notional_usdt: {total_notional:.3f} USDT")
|
||||
p()
|
||||
p("If fill-weighted return is + but gross_markout_usdt is negative,")
|
||||
p("it means notional weighting flips sign (alpha is conditionally realized).")
|
||||
p("=" * 72)
|
||||
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
def _pct(v: float | None) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v*100:.6f}%"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remote EXP_002 long-run status (read-only). Does not analyze Path C.
|
||||
set -euo pipefail
|
||||
|
||||
SSH_HOST="${SSH_HOST:-jack@jackyu66.com}"
|
||||
SSH_KEY="${SSH_KEY:-${HOME}/Project/deploy/zun_hk/id_ed25519_hk}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/www/Project/nautilus_mm}"
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
if [[ -n "$SSH_KEY" ]]; then
|
||||
chmod 400 "$SSH_KEY" 2>/dev/null || true
|
||||
SSH_OPTS+=(-i "$SSH_KEY")
|
||||
fi
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$SSH_HOST" "REMOTE_DIR='$REMOTE_DIR' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
echo "=== systemd --user event-state-probe ==="
|
||||
systemctl --user is-active event-state-probe || true
|
||||
systemctl --user show event-state-probe -p Environment --no-pager 2>/dev/null | tr ' ' '\n' | grep -E 'ENABLE_TRADING|EXPERIMENT_ID|LEDGER_RUN_ID' || true
|
||||
echo ""
|
||||
echo "=== mm-edge-probe (EXP_001) ==="
|
||||
systemctl --user is-active mm-edge-probe || true
|
||||
echo ""
|
||||
LOG="$REMOTE_DIR/logs/event_state/EXP-002-RUN-002"
|
||||
echo "=== ledger $LOG ==="
|
||||
if [[ ! -d "$LOG" ]]; then
|
||||
echo "no log dir yet"
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
log = Path("$LOG")
|
||||
starts = trades = books = fills = parse_fail = 0
|
||||
run_id = None
|
||||
for f in sorted(log.glob("*.jsonl")):
|
||||
for line in f.open():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(s)
|
||||
except Exception:
|
||||
parse_fail += 1
|
||||
continue
|
||||
run_id = ev.get("run_id") or run_id
|
||||
e = ev.get("event")
|
||||
if e == "experiment_start":
|
||||
starts += 1
|
||||
elif e == "fill_anchor":
|
||||
fills += 1
|
||||
elif e == "market_event":
|
||||
t = ev.get("event_type")
|
||||
if t == "aggressive_trade":
|
||||
trades += 1
|
||||
elif t == "book_update":
|
||||
books += 1
|
||||
print(f"run_id={run_id} starts={starts} trades={trades} books={books} fill_anchors={fills} parse_fail={parse_fail}")
|
||||
print("Gate 4 remains BLOCKED until fill_anchors exist. Do not Path-C snoop.")
|
||||
PY
|
||||
echo ""
|
||||
echo "=== journal (last 15) ==="
|
||||
journalctl --user -u event-state-probe -n 15 --no-pager || true
|
||||
EOF
|
||||
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Prefill Adverse-Selection Attribution v0.1
|
||||
|
||||
Experiment: MM_EDGE_EXP_001
|
||||
Population: frozen historical fills
|
||||
Strategy: v0.1 FROZEN
|
||||
Execution: STOPPED
|
||||
Purpose:
|
||||
Pre-fill adverse-selection predictability audit
|
||||
NOT:
|
||||
strategy
|
||||
backtest
|
||||
optimization
|
||||
model training
|
||||
|
||||
Hard contract:
|
||||
feature_timestamp <= t_fill - margin_sec
|
||||
|
||||
This script intentionally prefers strict no-leakage over feature richness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from reconcile_fills import load_local_fills, match, normalize_local, normalize_venue # noqa: E402
|
||||
|
||||
|
||||
def _load_jsonl_df(log_dir: Path) -> pd.DataFrame:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
if f.name.startswith(("Account_", "Maker_", "RECON")):
|
||||
continue
|
||||
for line in f.open():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
rows.append(e)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _pct(v: float | None) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v*100:.2f}%"
|
||||
|
||||
|
||||
def _num(v: float | None, digits: int = 4) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v:.{digits}f}"
|
||||
|
||||
|
||||
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
|
||||
fill = pd.to_numeric(fill, errors="coerce")
|
||||
px = pd.to_numeric(px, errors="coerce")
|
||||
raw = (px - fill) / fill
|
||||
return pd.Series(np.where(side == "long", raw, -raw), index=side.index)
|
||||
|
||||
|
||||
def _weighted_mean(x: pd.Series, w: pd.Series) -> float | None:
|
||||
xx = pd.to_numeric(x, errors="coerce")
|
||||
ww = pd.to_numeric(w, errors="coerce")
|
||||
mask = xx.notna() & ww.notna()
|
||||
xx = xx[mask]
|
||||
ww = ww[mask]
|
||||
if xx.empty:
|
||||
return None
|
||||
sw = float(ww.sum())
|
||||
if sw == 0.0:
|
||||
return None
|
||||
return float((xx * ww).sum() / sw)
|
||||
|
||||
|
||||
def _sample_grade(n: int) -> str:
|
||||
if n < 30:
|
||||
return "LOW_N"
|
||||
if n < 100:
|
||||
return "WEAK_EVIDENCE"
|
||||
return "USABLE"
|
||||
|
||||
|
||||
def _grade_probability(delta_pp: float, n_best: int) -> str:
|
||||
if n_best < 30:
|
||||
return "LOW_N"
|
||||
if delta_pp < 5.0:
|
||||
return "NO_PREFILL_SIGNAL"
|
||||
if n_best < 100 or delta_pp < 10.0:
|
||||
return "STATISTICAL_SIGNAL_ONLY"
|
||||
return "CANDIDATE_V0_2_SIGNAL"
|
||||
|
||||
|
||||
def _grade_economic(delta_usdt_per_fill: float, n_best: int) -> str:
|
||||
if n_best < 30:
|
||||
return "LOW_N"
|
||||
if abs(delta_usdt_per_fill) < 0.003:
|
||||
return "NO_PREFILL_SIGNAL"
|
||||
if n_best < 100 or abs(delta_usdt_per_fill) < 0.008:
|
||||
return "STATISTICAL_SIGNAL_ONLY"
|
||||
return "CANDIDATE_V0_2_SIGNAL"
|
||||
|
||||
|
||||
def _state_table_num(df: pd.DataFrame, feature: str, labels: list[str]) -> tuple[list[dict[str, Any]], dict[str, str]]:
|
||||
s = pd.to_numeric(df[feature], errors="coerce")
|
||||
valid = df[s.notna()].copy()
|
||||
valid[feature] = s[s.notna()]
|
||||
if valid.empty:
|
||||
return [], {k: "NO_DATA" for k in labels + ["Economic"]}
|
||||
q30 = float(valid[feature].quantile(0.30))
|
||||
q70 = float(valid[feature].quantile(0.70))
|
||||
# if no spread, collapse
|
||||
if math.isclose(q30, q70):
|
||||
valid["_state"] = "all"
|
||||
else:
|
||||
valid["_state"] = np.where(
|
||||
valid[feature] <= q30,
|
||||
"low",
|
||||
np.where(valid[feature] >= q70, "high", "mid"),
|
||||
)
|
||||
base = {
|
||||
lab: float(valid[lab].mean()) for lab in labels
|
||||
}
|
||||
base["economic_mean"] = float(valid["net_attr_30s_usdt"].mean())
|
||||
rows = []
|
||||
grades: dict[str, str] = {}
|
||||
for state, g in valid.groupby("_state"):
|
||||
row = {
|
||||
"feature": feature,
|
||||
"state": str(state),
|
||||
"n": int(len(g)),
|
||||
"sample_grade": _sample_grade(int(len(g))),
|
||||
"median": float(g[feature].median()),
|
||||
"p25": float(g[feature].quantile(0.25)),
|
||||
"p75": float(g[feature].quantile(0.75)),
|
||||
"net_attr_mean": float(g["net_attr_30s_usdt"].mean()),
|
||||
}
|
||||
for lab in labels:
|
||||
row[f"p_{lab}"] = float(g[lab].mean())
|
||||
row[f"delta_{lab}_pp"] = (row[f"p_{lab}"] - base[lab]) * 100.0
|
||||
row["delta_economic_per_fill"] = row["net_attr_mean"] - base["economic_mean"]
|
||||
rows.append(row)
|
||||
|
||||
# grade by strongest state-vs-baseline shift
|
||||
for lab in labels:
|
||||
best = max(rows, key=lambda r: abs(r[f"delta_{lab}_pp"]))
|
||||
grades[lab] = _grade_probability(abs(best[f"delta_{lab}_pp"]), int(best["n"]))
|
||||
best_e = max(rows, key=lambda r: abs(r["delta_economic_per_fill"]))
|
||||
grades["Economic"] = _grade_economic(abs(best_e["delta_economic_per_fill"]), int(best_e["n"]))
|
||||
return rows, grades
|
||||
|
||||
|
||||
def _state_table_cat(df: pd.DataFrame, feature: str, labels: list[str]) -> tuple[list[dict[str, Any]], dict[str, str]]:
|
||||
valid = df[df[feature].notna()].copy()
|
||||
if valid.empty:
|
||||
return [], {k: "NO_DATA" for k in labels + ["Economic"]}
|
||||
base = {
|
||||
lab: float(valid[lab].mean()) for lab in labels
|
||||
}
|
||||
base["economic_mean"] = float(valid["net_attr_30s_usdt"].mean())
|
||||
rows = []
|
||||
grades: dict[str, str] = {}
|
||||
for state, g in valid.groupby(feature):
|
||||
n = int(len(g))
|
||||
row = {
|
||||
"feature": feature,
|
||||
"state": str(state),
|
||||
"n": n,
|
||||
"sample_grade": _sample_grade(n),
|
||||
"median": None,
|
||||
"p25": None,
|
||||
"p75": None,
|
||||
"net_attr_mean": float(g["net_attr_30s_usdt"].mean()),
|
||||
}
|
||||
for lab in labels:
|
||||
row[f"p_{lab}"] = float(g[lab].mean())
|
||||
row[f"delta_{lab}_pp"] = (row[f"p_{lab}"] - base[lab]) * 100.0
|
||||
row["delta_economic_per_fill"] = row["net_attr_mean"] - base["economic_mean"]
|
||||
rows.append(row)
|
||||
for lab in labels:
|
||||
best = max(rows, key=lambda r: abs(r[f"delta_{lab}_pp"]))
|
||||
grades[lab] = _grade_probability(abs(best[f"delta_{lab}_pp"]), int(best["n"]))
|
||||
best_e = max(rows, key=lambda r: abs(r["delta_economic_per_fill"]))
|
||||
grades["Economic"] = _grade_economic(abs(best_e["delta_economic_per_fill"]), int(best_e["n"]))
|
||||
return rows, grades
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Prefill Adverse-Selection Attribution v0.1")
|
||||
ap.add_argument("--dir", default=str(ROOT / "logs" / "maker_edge"))
|
||||
ap.add_argument("--venue-trades", default=str(ROOT / "logs" / "maker_edge" / "venue_trades.json"))
|
||||
ap.add_argument("--out", default=str(ROOT / "logs" / "maker_edge" / "Prefill_Adverse_Selection_Attribution_v0_1.txt"))
|
||||
ap.add_argument("--margin-sec", type=float, default=0.25)
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
df = _load_jsonl_df(log_dir)
|
||||
fills = df[df["event"] == "fill"].copy()
|
||||
paths = df[df["event"] == "fill_path"].copy()
|
||||
state_ticks = df[df["event"].isin(["mid_tick", "inventory_tick"])].copy()
|
||||
|
||||
venue_trades = json.loads(Path(args.venue_trades).read_text())
|
||||
local_fills_raw = load_local_fills(log_dir)
|
||||
locals_norm = [normalize_local(e, i) for i, e in enumerate(local_fills_raw)]
|
||||
venues_norm = [normalize_venue(t, i) for i, t in enumerate(venue_trades)]
|
||||
recon = match(locals_norm, venues_norm)
|
||||
matched_fill_ids = {m["local"]["fill_id"] for m in recon["matched"]}
|
||||
matched_trade_ids = {m["venue"]["venue_trade_id"] for m in recon["matched"]}
|
||||
|
||||
fills = fills[fills["fill_id"].isin(matched_fill_ids)].copy()
|
||||
paths = paths[paths["fill_id"].isin(matched_fill_ids)].copy()
|
||||
|
||||
# merge labels/path info
|
||||
fill_meta_cols = [
|
||||
c
|
||||
for c in [
|
||||
"fill_id",
|
||||
"side",
|
||||
"fill_price",
|
||||
"amount",
|
||||
"quote_fill_time",
|
||||
"ts_epoch",
|
||||
"event_cluster_id",
|
||||
"pair",
|
||||
]
|
||||
if c in fills.columns
|
||||
]
|
||||
meta = fills.drop_duplicates("fill_id")[fill_meta_cols]
|
||||
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
|
||||
for col in ["side", "fill_price", "amount", "event_cluster_id", "quote_fill_time", "ts_epoch"]:
|
||||
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])
|
||||
paths["fill_ts"] = pd.to_datetime(paths["quote_fill_time"], utc=True, errors="coerce")
|
||||
# Prefer fill-event epoch seconds. astype(int64)/1e9 breaks when pandas stores UTC as us.
|
||||
fill_epoch = pd.to_numeric(paths["ts_epoch"], errors="coerce")
|
||||
iso_epoch = paths["fill_ts"].map(lambda ts: ts.timestamp() if pd.notna(ts) else np.nan)
|
||||
paths["fill_ts_epoch"] = fill_epoch.fillna(iso_epoch)
|
||||
paths["qty"] = pd.to_numeric(paths["amount"], errors="coerce")
|
||||
paths["fill_price"] = pd.to_numeric(paths["fill_price"], errors="coerce")
|
||||
paths["after_10s_price"] = pd.to_numeric(paths["after_10s_price"], errors="coerce")
|
||||
paths["after_30s_price"] = pd.to_numeric(paths["after_30s_price"], errors="coerce")
|
||||
paths["notional_usdt"] = paths["qty"] * paths["fill_price"]
|
||||
paths["markout_10s"] = _fav_ret(paths["side"], paths["fill_price"], paths["after_10s_price"])
|
||||
paths["markout_30s"] = _fav_ret(paths["side"], paths["fill_price"], paths["after_30s_price"])
|
||||
paths["path_c"] = paths["path_type"].astype(str).eq("C_toxic")
|
||||
paths["toxic"] = (paths["markout_10s"] < 0) & (paths["markout_30s"] < 0)
|
||||
paths["negative_30s"] = paths["markout_30s"] < 0
|
||||
|
||||
# attach trade economics
|
||||
raw_v = pd.DataFrame(venue_trades)
|
||||
raw_v["trade_id_link"] = raw_v["id"].astype(str)
|
||||
raw_v["commission_usdt"] = pd.to_numeric(raw_v["commission"], errors="coerce")
|
||||
raw_v["realized_pnl_usdt"] = pd.to_numeric(raw_v["realizedPnl"], errors="coerce").fillna(0.0)
|
||||
matched_map = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"fill_id": m["local"]["fill_id"],
|
||||
"trade_id_link": m["venue"]["venue_trade_id"],
|
||||
}
|
||||
for m in recon["matched"]
|
||||
]
|
||||
)
|
||||
paths = paths.merge(
|
||||
matched_map.merge(raw_v[["trade_id_link", "commission_usdt", "realized_pnl_usdt"]], on="trade_id_link", how="left"),
|
||||
on="fill_id",
|
||||
how="left",
|
||||
)
|
||||
paths["gross_markout_30s_usdt"] = paths["notional_usdt"] * paths["markout_30s"]
|
||||
paths["net_attr_30s_usdt"] = (
|
||||
paths["gross_markout_30s_usdt"]
|
||||
- pd.to_numeric(paths["commission_usdt"], errors="coerce").fillna(0.0)
|
||||
+ pd.to_numeric(paths["realized_pnl_usdt"], errors="coerce").fillna(0.0)
|
||||
)
|
||||
paths["economic_negative"] = paths["net_attr_30s_usdt"] < 0
|
||||
|
||||
# strict prefill state from sampled historical ticks only
|
||||
state_ticks = state_ticks.copy()
|
||||
state_ticks["ts_epoch"] = pd.to_numeric(state_ticks["ts_epoch"], errors="coerce")
|
||||
state_ticks = state_ticks.dropna(subset=["ts_epoch"]).sort_values("ts_epoch").drop_duplicates("ts_epoch")
|
||||
keep_cols = [
|
||||
c
|
||||
for c in [
|
||||
"ts_epoch",
|
||||
"mid",
|
||||
"spread",
|
||||
"bid_depth_1",
|
||||
"ask_depth_1",
|
||||
"bid_depth_5",
|
||||
"ask_depth_5",
|
||||
"obi",
|
||||
"delta",
|
||||
"trade_imbalance",
|
||||
"delta_efficiency",
|
||||
"inventory",
|
||||
"inventory_time",
|
||||
"inventory_skew",
|
||||
]
|
||||
if c in state_ticks.columns
|
||||
]
|
||||
states = state_ticks[keep_cols].copy()
|
||||
num_cols = [c for c in keep_cols if c != "ts_epoch"]
|
||||
for col in num_cols:
|
||||
states[col] = pd.to_numeric(states[col], errors="coerce")
|
||||
|
||||
# 5s lag features using sampled states
|
||||
lag_df = states[["ts_epoch"] + [c for c in ["mid", "spread", "obi", "bid_depth_5", "ask_depth_5", "trade_imbalance", "delta"] if c in states.columns]].copy()
|
||||
lag_df["lag_ts"] = lag_df["ts_epoch"] + 5.0
|
||||
lag_cols = {c: f"{c}_past5s" for c in lag_df.columns if c not in {"ts_epoch", "lag_ts"}}
|
||||
lag_df = lag_df.rename(columns=lag_cols)
|
||||
|
||||
paths = paths[paths["fill_ts_epoch"].notna()].copy()
|
||||
fill_states = paths[["fill_id", "fill_ts_epoch", "side"]].copy().sort_values("fill_ts_epoch")
|
||||
fill_states["feature_cutoff_ts"] = fill_states["fill_ts_epoch"] - float(args.margin_sec)
|
||||
|
||||
# latest sampled tick strictly before fill-margin
|
||||
snap = pd.merge_asof(
|
||||
fill_states.sort_values("feature_cutoff_ts"),
|
||||
states.sort_values("ts_epoch"),
|
||||
left_on="feature_cutoff_ts",
|
||||
right_on="ts_epoch",
|
||||
direction="backward",
|
||||
)
|
||||
snap = snap[snap["ts_epoch"].notna()].copy()
|
||||
snap = pd.merge_asof(
|
||||
snap.sort_values("ts_epoch"),
|
||||
lag_df.sort_values("lag_ts"),
|
||||
left_on="ts_epoch",
|
||||
right_on="lag_ts",
|
||||
direction="backward",
|
||||
)
|
||||
if "ts_epoch_x" in snap.columns:
|
||||
snap = snap.rename(columns={"ts_epoch_x": "ts_epoch"})
|
||||
|
||||
# derived strict-prefill features
|
||||
snap["spread_pct"] = snap["spread"] / snap["mid"]
|
||||
snap["depth_total_5"] = snap["bid_depth_5"] + snap["ask_depth_5"]
|
||||
snap["depth_imbalance_5"] = (snap["bid_depth_5"] - snap["ask_depth_5"]) / snap["depth_total_5"]
|
||||
snap["price_velocity_5s"] = (snap["mid"] - snap["mid_past5s"]) / snap["mid_past5s"]
|
||||
snap["spread_change_5s"] = snap["spread_pct"] - (snap["spread_past5s"] / snap["mid_past5s"])
|
||||
snap["obi_change_5s"] = snap["obi"] - snap["obi_past5s"]
|
||||
snap["depth_total_5_past"] = snap["bid_depth_5_past5s"] + snap["ask_depth_5_past5s"]
|
||||
snap["depth_change_5s"] = snap["depth_total_5"] - snap["depth_total_5_past"]
|
||||
snap["trade_imbalance_change_5s"] = snap["trade_imbalance"] - snap["trade_imbalance_past5s"]
|
||||
snap["delta_change_5s"] = snap["delta"] - snap["delta_past5s"]
|
||||
snap["pre_deteriorated_strict"] = np.where(
|
||||
snap["side"].eq("long"),
|
||||
(snap["price_velocity_5s"] < 0) | (snap["depth_change_5s"] < 0),
|
||||
(snap["price_velocity_5s"] > 0) | (snap["depth_change_5s"] < 0),
|
||||
)
|
||||
snap["feature_age_ms"] = (snap["fill_ts_epoch"] - snap["ts_epoch"]) * 1000.0
|
||||
snap = snap.rename(columns={"ts_epoch": "feature_ts_epoch"})
|
||||
|
||||
snap_feature_cols = [
|
||||
"fill_id",
|
||||
"feature_ts_epoch",
|
||||
"feature_cutoff_ts",
|
||||
"mid",
|
||||
"spread",
|
||||
"bid_depth_1",
|
||||
"ask_depth_1",
|
||||
"bid_depth_5",
|
||||
"ask_depth_5",
|
||||
"obi",
|
||||
"delta",
|
||||
"trade_imbalance",
|
||||
"delta_efficiency",
|
||||
"inventory",
|
||||
"inventory_time",
|
||||
"inventory_skew",
|
||||
"mid_past5s",
|
||||
"spread_past5s",
|
||||
"obi_past5s",
|
||||
"bid_depth_5_past5s",
|
||||
"ask_depth_5_past5s",
|
||||
"trade_imbalance_past5s",
|
||||
"delta_past5s",
|
||||
"spread_pct",
|
||||
"depth_total_5",
|
||||
"depth_imbalance_5",
|
||||
"price_velocity_5s",
|
||||
"spread_change_5s",
|
||||
"depth_total_5_past",
|
||||
"depth_change_5s",
|
||||
"obi_change_5s",
|
||||
"trade_imbalance_change_5s",
|
||||
"delta_change_5s",
|
||||
"pre_deteriorated_strict",
|
||||
"feature_age_ms",
|
||||
]
|
||||
snap_feature_cols = [c for c in snap_feature_cols if c in snap.columns]
|
||||
rename_map = {
|
||||
c: f"strict_{c}"
|
||||
for c in snap_feature_cols
|
||||
if c not in {"fill_id", "feature_ts_epoch", "feature_cutoff_ts", "feature_age_ms", "pre_deteriorated_strict"}
|
||||
}
|
||||
rename_map["feature_ts_epoch"] = "strict_feature_ts_epoch"
|
||||
rename_map["feature_cutoff_ts"] = "strict_feature_cutoff_ts"
|
||||
rename_map["feature_age_ms"] = "strict_feature_age_ms"
|
||||
rename_map["pre_deteriorated_strict"] = "strict_pre_deteriorated"
|
||||
snap_merge = snap[snap_feature_cols].rename(columns=rename_map)
|
||||
pref = paths.merge(snap_merge, on="fill_id", how="left")
|
||||
pref = pref[pref["strict_feature_ts_epoch"].notna()].copy()
|
||||
|
||||
labels = ["path_c", "toxic", "negative_30s"]
|
||||
numeric_features = [
|
||||
"strict_obi",
|
||||
"strict_delta",
|
||||
"strict_trade_imbalance",
|
||||
"strict_spread_pct",
|
||||
"strict_bid_depth_5",
|
||||
"strict_ask_depth_5",
|
||||
"strict_depth_total_5",
|
||||
"strict_depth_imbalance_5",
|
||||
"strict_price_velocity_5s",
|
||||
"strict_spread_change_5s",
|
||||
"strict_depth_change_5s",
|
||||
"strict_obi_change_5s",
|
||||
"strict_trade_imbalance_change_5s",
|
||||
"strict_delta_change_5s",
|
||||
"strict_inventory",
|
||||
"strict_inventory_skew",
|
||||
"strict_inventory_time",
|
||||
"strict_feature_age_ms",
|
||||
]
|
||||
cat_features = ["strict_pre_deteriorated"]
|
||||
|
||||
result_rows: list[dict[str, Any]] = []
|
||||
matrix_rows: list[dict[str, Any]] = []
|
||||
for feat in numeric_features:
|
||||
if feat not in pref.columns:
|
||||
continue
|
||||
rows, grades = _state_table_num(pref, feat, labels)
|
||||
result_rows.extend(rows)
|
||||
matrix_rows.append(
|
||||
{
|
||||
"feature": feat,
|
||||
"Path C": grades["path_c"],
|
||||
"Toxic": grades["toxic"],
|
||||
"Neg30s": grades["negative_30s"],
|
||||
"Economic": grades["Economic"],
|
||||
}
|
||||
)
|
||||
for feat in cat_features:
|
||||
if feat not in pref.columns:
|
||||
continue
|
||||
rows, grades = _state_table_cat(pref, feat, labels)
|
||||
result_rows.extend(rows)
|
||||
matrix_rows.append(
|
||||
{
|
||||
"feature": feat,
|
||||
"Path C": grades["path_c"],
|
||||
"Toxic": grades["toxic"],
|
||||
"Neg30s": grades["negative_30s"],
|
||||
"Economic": grades["Economic"],
|
||||
}
|
||||
)
|
||||
|
||||
baseline = {
|
||||
"path_c": float(pref["path_c"].mean()),
|
||||
"toxic": float(pref["toxic"].mean()),
|
||||
"negative_30s": float(pref["negative_30s"].mean()),
|
||||
"economic_negative": float(pref["economic_negative"].mean()),
|
||||
"net_attr_30s_usdt_mean": float(pref["net_attr_30s_usdt"].mean()),
|
||||
"markout_30s_mean": float(pref["markout_30s"].mean()),
|
||||
}
|
||||
coverage = {
|
||||
"matched_paths": int(len(paths)),
|
||||
"strict_prefill_rows": int(len(pref)),
|
||||
"strict_prefill_coverage_pct": float(len(pref) / max(len(paths), 1) * 100.0),
|
||||
"mean_feature_age_ms": float(pref["strict_feature_age_ms"].mean()),
|
||||
"median_feature_age_ms": float(pref["strict_feature_age_ms"].median()),
|
||||
}
|
||||
|
||||
out_txt = Path(args.out)
|
||||
out_json = out_txt.with_suffix(".json")
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Prefill Adverse-Selection Attribution v0.1")
|
||||
p("=" * 72)
|
||||
p("Experiment: MM_EDGE_EXP_001")
|
||||
p("Population: frozen historical fills (Hard core = MATCHED only)")
|
||||
p("Strategy: v0.1 FROZEN")
|
||||
p("Execution: STOPPED")
|
||||
p("Purpose: Pre-fill adverse-selection predictability audit")
|
||||
p("NOT: strategy / backtest / optimization / model training")
|
||||
p()
|
||||
p("Time Contract")
|
||||
p("-" * 40)
|
||||
p(f"feature_timestamp <= t_fill - {args.margin_sec:.2f}s")
|
||||
p("Only sampled historical mid_tick / inventory_tick states are used.")
|
||||
p("Fill-callback contemporaneous fields are intentionally excluded to avoid leakage.")
|
||||
p()
|
||||
p("Unavailable under strict contract in v0.1")
|
||||
p("-" * 40)
|
||||
p("- event intensity / large trades / time_since_last_market_event")
|
||||
p("- fill-callback market_event_before_fill")
|
||||
p("- any future path / realized / cancel-after-fill info as features")
|
||||
p()
|
||||
p("Baseline labels (MATCHED only)")
|
||||
p("-" * 40)
|
||||
p(f"P(Path C): {_pct(baseline['path_c'])}")
|
||||
p(f"P(Toxic): {_pct(baseline['toxic'])}")
|
||||
p(f"P(Neg30s): {_pct(baseline['negative_30s'])}")
|
||||
p(f"P(Economic<0): {_pct(baseline['economic_negative'])}")
|
||||
p(f"Mean net_attr_30s: {_num(baseline['net_attr_30s_usdt_mean'], 6)} USDT/fill")
|
||||
p(f"Mean markout_30s: {_pct(baseline['markout_30s_mean'])}")
|
||||
p(f"Matched path rows: {coverage['matched_paths']}")
|
||||
p(f"Strict prefill rows: {coverage['strict_prefill_rows']}")
|
||||
p(f"Strict coverage: {coverage['strict_prefill_coverage_pct']:.1f}%")
|
||||
p(f"Feature age ms: mean={coverage['mean_feature_age_ms']:.1f} median={coverage['median_feature_age_ms']:.1f}")
|
||||
p()
|
||||
p("Sample-size policy")
|
||||
p("-" * 40)
|
||||
p("n < 30 exploratory only (LOW_N)")
|
||||
p("n < 100 weak evidence (WEAK_EVIDENCE)")
|
||||
p("n >= 100 usable attribution (USABLE)")
|
||||
p()
|
||||
p("Conclusion Matrix")
|
||||
p("-" * 40)
|
||||
p("feature | Path C | Toxic | Neg30s | Economic")
|
||||
for row in matrix_rows:
|
||||
p(f"{row['feature']} | {row['Path C']} | {row['Toxic']} | {row['Neg30s']} | {row['Economic']}")
|
||||
p()
|
||||
p("State tables")
|
||||
p("-" * 40)
|
||||
for feat in [r["feature"] for r in matrix_rows]:
|
||||
sub = [r for r in result_rows if r["feature"] == feat]
|
||||
if not sub:
|
||||
continue
|
||||
p(feat)
|
||||
for r in sub:
|
||||
med = _num(r["median"], 6) if r["median"] is not None else "n/a"
|
||||
p(
|
||||
f" {r['state']}: n={r['n']} [{r['sample_grade']}] median={med} "
|
||||
f"P(C)={_pct(r['p_path_c'])} Δ={r['delta_path_c_pp']:+.1f}pp "
|
||||
f"P(Toxic)={_pct(r['p_toxic'])} Δ={r['delta_toxic_pp']:+.1f}pp "
|
||||
f"P(Neg30)={_pct(r['p_negative_30s'])} Δ={r['delta_negative_30s_pp']:+.1f}pp "
|
||||
f"E[net30]={_num(r['net_attr_mean'], 5)} Δ={_num(r['delta_economic_per_fill'], 5)}"
|
||||
)
|
||||
p()
|
||||
p("Interpretation")
|
||||
p("-" * 40)
|
||||
p("Only pre-fill observable states count as candidate signals.")
|
||||
p("A feature may separate Path C statistically but still fail Economic relevance.")
|
||||
p("Only rows graded CANDIDATE_V0_2_SIGNAL with usable n should enter v0.2 hypothesis design.")
|
||||
p("=" * 72)
|
||||
|
||||
out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
out_json.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"experiment_id": "MM_EDGE_EXP_001",
|
||||
"purpose": "prefill adverse-selection predictability audit",
|
||||
"population": {
|
||||
"matched_rows": int(len(pref)),
|
||||
"margin_sec": float(args.margin_sec),
|
||||
},
|
||||
"baseline": baseline,
|
||||
"coverage": coverage,
|
||||
"matrix": matrix_rows,
|
||||
"states": result_rows,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# 远程查看探针状态 + fills/clusters 粗计数
|
||||
# 用法:export SSH_HOST=user@ip [SSH_KEY=...] ./scripts/probe_status.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SSH_HOST="${SSH_HOST:-jack@jackyu66.com}"
|
||||
SSH_KEY="${SSH_KEY:-${HOME}/Project/deploy/zun_hk/id_ed25519_hk}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/www/Project/nautilus_mm}"
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
if [[ -n "$SSH_KEY" ]]; then
|
||||
chmod 400 "$SSH_KEY" 2>/dev/null || true
|
||||
SSH_OPTS+=(-i "$SSH_KEY")
|
||||
fi
|
||||
|
||||
ssh "${SSH_OPTS[@]}" "$SSH_HOST" "REMOTE_DIR='$REMOTE_DIR' bash -s" <<'EOF'
|
||||
set -euo pipefail
|
||||
echo "=== systemd --user ==="
|
||||
systemctl --user is-active mm-edge-probe || true
|
||||
systemctl --user status mm-edge-probe --no-pager -l | head -20 || true
|
||||
echo ""
|
||||
echo "=== experiment (.env) ==="
|
||||
grep -E '^(EXPERIMENT_ID|PROBE_VERSION|BINANCE_ENVIRONMENT|ENABLE_TRADING)=' "$REMOTE_DIR/.env" 2>/dev/null || true
|
||||
grep -E '^BINANCE_API_KEY=.+' "$REMOTE_DIR/.env" >/dev/null && echo "API key: SET" || echo "API key: EMPTY"
|
||||
echo ""
|
||||
echo "=== fills / clusters (jsonl) ==="
|
||||
cd "$REMOTE_DIR/logs/maker_edge" 2>/dev/null || { echo "no log dir"; exit 0; }
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
fills=0
|
||||
cids=set()
|
||||
health=0
|
||||
for f in sorted(Path('.').glob('*.jsonl')):
|
||||
for line in f.read_text().splitlines():
|
||||
try:
|
||||
ev=json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if ev.get('event')=='fill':
|
||||
fills+=1
|
||||
if ev.get('event_cluster_id'):
|
||||
cids.add(ev['event_cluster_id'])
|
||||
elif ev.get('event')=='health':
|
||||
health+=1
|
||||
print(f"fills={fills} clusters={len(cids)} health_ticks={health}")
|
||||
print(f"cluster/fill={len(cids)/fills*100:.1f}%" if fills else "cluster/fill=n/a")
|
||||
PY
|
||||
echo ""
|
||||
echo "=== recent journal (--user) ==="
|
||||
journalctl --user -u mm-edge-probe -n 30 --no-pager || true
|
||||
EOF
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# 从服务器拉取 jsonl + 本地生成 Maker Edge Report
|
||||
# 用法:export SSH_HOST=user@ip [SSH_KEY=...] ./scripts/pull_report.sh [min_fills]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SSH_HOST="${SSH_HOST:-jack@jackyu66.com}"
|
||||
SSH_KEY="${SSH_KEY:-${HOME}/Project/deploy/zun_hk/id_ed25519_hk}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/www/Project/nautilus_mm}"
|
||||
LOCAL_LOG="${LOCAL_LOG:-$ROOT/logs/maker_edge}"
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
if [[ -n "$SSH_KEY" ]]; then
|
||||
chmod 400 "$SSH_KEY" 2>/dev/null || true
|
||||
SSH_OPTS+=(-i "$SSH_KEY")
|
||||
fi
|
||||
|
||||
mkdir -p "$LOCAL_LOG"
|
||||
echo "==> pull logs from $SSH_HOST"
|
||||
rsync -avz -e "ssh ${SSH_OPTS[*]}" \
|
||||
"$SSH_HOST:$REMOTE_DIR/logs/maker_edge/" "$LOCAL_LOG/"
|
||||
|
||||
echo "==> analyze"
|
||||
export PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec "$ROOT/scripts/analyze.sh" "${1:-2000}"
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Account Ledger Reconciliation — MM_EDGE_EXP_001
|
||||
|
||||
Separates:
|
||||
MakerAlpha (research markout) ≠ Account Equity (wallet economics)
|
||||
|
||||
Pulls paginated Binance Futures:
|
||||
- /fapi/v1/userTrades (maker flag, commission per fill)
|
||||
- /fapi/v1/income (REALIZED_PNL, COMMISSION, FUNDING_FEE, …)
|
||||
- /fapi/v2/account (wallet + unrealized + position)
|
||||
|
||||
Hard gate:
|
||||
TAKER_FILLED_COUNT == 0 else Maker-only = INVALID
|
||||
|
||||
Equity identity (target error ≈ 0):
|
||||
StartWallet + Σincome_types + (EndUnrealized − StartUnrealized*)
|
||||
+ Transfers/Adjustments ≈ EndMarginBalance
|
||||
|
||||
* StartUnrealized often unknown → report EndUnrealized separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
load_dotenv(_ROOT / ".env")
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.getenv(name, default).strip()
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
env = _env("BINANCE_ENVIRONMENT", "TESTNET").upper()
|
||||
if env == "TESTNET":
|
||||
return "https://testnet.binancefuture.com"
|
||||
if env == "LIVE":
|
||||
return "https://fapi.binance.com"
|
||||
raise SystemExit(f"BINANCE_ENVIRONMENT must be TESTNET|LIVE, got {env!r}")
|
||||
|
||||
|
||||
def _signed_get(path: str, params: dict | None = None) -> object:
|
||||
key = _env("BINANCE_API_KEY")
|
||||
sec = _env("BINANCE_API_SECRET")
|
||||
if not key or not sec:
|
||||
raise SystemExit("BINANCE_API_KEY / BINANCE_API_SECRET required")
|
||||
params = dict(params or {})
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
params["recvWindow"] = 60_000
|
||||
qs = urllib.parse.urlencode(params)
|
||||
sig = hmac.new(sec.encode(), qs.encode(), hashlib.sha256).hexdigest()
|
||||
url = f"{_base_url()}{path}?{qs}&signature={sig}"
|
||||
req = urllib.request.Request(url, headers={"X-MBX-APIKEY": key})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
raise RuntimeError(f"HTTP {e.code} {path} params={params} body={body}") from e
|
||||
|
||||
|
||||
def _fetch_user_trades(symbol: str, start_ms: int, end_ms: int) -> list[dict]:
|
||||
"""Paginate userTrades by time windows (dedupe by trade id).
|
||||
|
||||
Note: Testnet userTrades can stop returning rows after ~7d of dense history
|
||||
even while orders/income continue — RECON-02 must flag that gap separately.
|
||||
"""
|
||||
out: dict[int, dict] = {}
|
||||
cursor = start_ms
|
||||
safety = 0
|
||||
while cursor < end_ms and safety < 2000:
|
||||
safety += 1
|
||||
chunk_end = min(cursor + 7 * 86400_000 - 1, end_ms)
|
||||
batch = _signed_get(
|
||||
"/fapi/v1/userTrades",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"startTime": cursor,
|
||||
"endTime": chunk_end,
|
||||
"limit": 1000,
|
||||
},
|
||||
)
|
||||
assert isinstance(batch, list)
|
||||
if not batch:
|
||||
cursor = chunk_end + 1
|
||||
continue
|
||||
for t in batch:
|
||||
out[int(t["id"])] = t
|
||||
last_t = int(batch[-1]["time"])
|
||||
if len(batch) < 1000:
|
||||
cursor = max(last_t + 1, chunk_end + 1)
|
||||
else:
|
||||
nxt = last_t + 1
|
||||
if nxt <= cursor:
|
||||
nxt = cursor + 1
|
||||
cursor = nxt
|
||||
time.sleep(0.08)
|
||||
return sorted(out.values(), key=lambda x: (int(x["time"]), int(x["id"])))
|
||||
|
||||
|
||||
def _fetch_income(start_ms: int, end_ms: int) -> list[dict]:
|
||||
"""Paginate income by time only."""
|
||||
out: list[dict] = []
|
||||
seen: set[tuple] = set()
|
||||
cursor = start_ms
|
||||
safety = 0
|
||||
while cursor < end_ms and safety < 2000:
|
||||
safety += 1
|
||||
chunk_end = min(cursor + 7 * 86400_000 - 1, end_ms)
|
||||
batch = _signed_get(
|
||||
"/fapi/v1/income",
|
||||
{"startTime": cursor, "endTime": chunk_end, "limit": 1000},
|
||||
)
|
||||
assert isinstance(batch, list)
|
||||
if not batch:
|
||||
cursor = chunk_end + 1
|
||||
continue
|
||||
for row in batch:
|
||||
key = (
|
||||
row.get("tranId"),
|
||||
row.get("time"),
|
||||
row.get("incomeType"),
|
||||
row.get("income"),
|
||||
row.get("asset"),
|
||||
row.get("symbol"),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
last_t = int(batch[-1]["time"])
|
||||
if len(batch) < 1000:
|
||||
cursor = max(last_t + 1, chunk_end + 1)
|
||||
else:
|
||||
cursor = last_t + 1
|
||||
time.sleep(0.08)
|
||||
return out
|
||||
|
||||
|
||||
def _ms_iso(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def load_jsonl_fill_count(log_dir: Path) -> int:
|
||||
n = 0
|
||||
if not log_dir.exists():
|
||||
return 0
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
for line in f.open():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict) and e.get("event") == "fill":
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Maker Edge account reconciliation")
|
||||
ap.add_argument(
|
||||
"--start-wallet",
|
||||
type=float,
|
||||
default=float(_env("RECON_START_WALLET", "5000")),
|
||||
help="Observed starting USDT wallet (default 5000 testnet grant)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--symbol",
|
||||
default=_env("RECON_SYMBOL", "BTCUSDT"),
|
||||
help="Futures symbol for userTrades (default BTCUSDT)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--since-days",
|
||||
type=float,
|
||||
default=float(_env("RECON_SINCE_DAYS", "14")),
|
||||
)
|
||||
ap.add_argument(
|
||||
"--out",
|
||||
default=str(_ROOT / "logs" / "maker_edge" / "Account_Reconciliation.txt"),
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
end_ms = int(time.time() * 1000)
|
||||
start_ms = end_ms - int(args.since_days * 86400 * 1000)
|
||||
|
||||
print(f"[recon] env={_env('BINANCE_ENVIRONMENT','TESTNET')} base={_base_url()}")
|
||||
print(f"[recon] window {_ms_iso(start_ms)} → {_ms_iso(end_ms)}")
|
||||
|
||||
print("[recon] pulling userTrades (paginated)…")
|
||||
trades = _fetch_user_trades(args.symbol, start_ms, end_ms)
|
||||
|
||||
print(f"[recon] userTrades={len(trades)}")
|
||||
print("[recon] pulling income (paginated)…")
|
||||
income = _fetch_income(start_ms, end_ms)
|
||||
print(f"[recon] income rows={len(income)}")
|
||||
|
||||
acct = _signed_get("/fapi/v2/account")
|
||||
assert isinstance(acct, dict)
|
||||
|
||||
# --- Maker-only hard check ---
|
||||
maker_n = sum(1 for t in trades if t.get("maker") is True)
|
||||
taker_n = sum(1 for t in trades if t.get("maker") is False)
|
||||
unknown_n = len(trades) - maker_n - taker_n
|
||||
maker_only_ok = taker_n == 0 and unknown_n == 0 and len(trades) > 0
|
||||
maker_only_status = "PASS" if maker_only_ok else ("INVALID" if taker_n > 0 else "NEED VERIFY")
|
||||
|
||||
fee_by_asset: dict[str, float] = defaultdict(float)
|
||||
notional = 0.0
|
||||
buy_qty = sell_qty = 0.0
|
||||
for t in trades:
|
||||
fee_by_asset[t.get("commissionAsset") or "?"] += float(t.get("commission") or 0)
|
||||
q = float(t.get("qty") or 0)
|
||||
px = float(t.get("price") or 0)
|
||||
notional += abs(q * px)
|
||||
if t.get("buyer"):
|
||||
buy_qty += q
|
||||
else:
|
||||
sell_qty += q
|
||||
net_qty = buy_qty - sell_qty
|
||||
|
||||
income_by: dict[str, float] = defaultdict(float)
|
||||
for row in income:
|
||||
income_by[str(row.get("incomeType"))] += float(row.get("income") or 0)
|
||||
|
||||
wallet = float(acct.get("totalWalletBalance") or 0)
|
||||
upnl = float(acct.get("totalUnrealizedProfit") or 0)
|
||||
margin = float(acct.get("totalMarginBalance") or 0)
|
||||
avail = float(acct.get("availableBalance") or 0)
|
||||
|
||||
positions = []
|
||||
for p in acct.get("positions") or []:
|
||||
amt = float(p.get("positionAmt") or 0)
|
||||
if abs(amt) > 1e-12:
|
||||
positions.append(
|
||||
{
|
||||
"symbol": p.get("symbol"),
|
||||
"amt": amt,
|
||||
"entry": float(p.get("entryPrice") or 0),
|
||||
"unrealized": float(p.get("unrealizedProfit") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
start_wallet = float(args.start_wallet)
|
||||
income_sum = sum(income_by.values())
|
||||
# Identity without known start upnl:
|
||||
# EndWallet ≈ StartWallet + Σ income (transfers included in income types if any)
|
||||
implied_end_wallet = start_wallet + income_sum
|
||||
wallet_gap = wallet - implied_end_wallet
|
||||
equity_now = margin # wallet + upnl
|
||||
equity_vs_start = equity_now - start_wallet
|
||||
|
||||
jsonl_fills = load_jsonl_fill_count(_ROOT / "logs" / "maker_edge")
|
||||
|
||||
lines: list[str] = []
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("Account Reconciliation — MM_EDGE_EXP_001 / probe_v0.1")
|
||||
p("Research markout (MakerAlpha) ≠ Account equity")
|
||||
p("=" * 72)
|
||||
p()
|
||||
p("Status Snapshot")
|
||||
p("-" * 40)
|
||||
p("Maker Phenomenon PARTIAL_PASS")
|
||||
p("Data Integrity PASS (from Maker Edge Report)")
|
||||
p(f"Maker-only constraint {maker_only_status}")
|
||||
p("Account Reconciliation NOT COMPLETE" if abs(wallet_gap) > 0.5 else "Account Reconciliation CLOSE")
|
||||
p("Economic Edge UNKNOWN")
|
||||
p("Stage 3 LOCKED")
|
||||
p("Probe STOPPED (no further volume until ledger closes)")
|
||||
p()
|
||||
|
||||
p("Section A — Maker-only hard check (exchange userTrades)")
|
||||
p("-" * 40)
|
||||
p(f"Symbol: {args.symbol}")
|
||||
p(f"Exchange trades: {len(trades)}")
|
||||
p(f"Jsonl fills (local): {jsonl_fills}")
|
||||
p(f"MAKER fills: {maker_n}")
|
||||
p(f"TAKER fills: {taker_n}")
|
||||
p(f"Unknown liquidity: {unknown_n}")
|
||||
p(f"TAKER_FILLED_COUNT: {taker_n}")
|
||||
if taker_n > 0:
|
||||
p("→ INVALID: sample contaminated by taker fills")
|
||||
elif maker_only_ok:
|
||||
p("→ PASS: all exchange trades marked maker=true")
|
||||
else:
|
||||
p("→ NEED VERIFY")
|
||||
p(f"Buy qty / Sell qty: {buy_qty:.6f} / {sell_qty:.6f}")
|
||||
p(f"Net inventory (qty): {net_qty:.6f}")
|
||||
p(f"Gross notional: {notional:.4f} USDT")
|
||||
for asset, fee in sorted(fee_by_asset.items()):
|
||||
p(f"Commission ({asset}): {fee}")
|
||||
p()
|
||||
|
||||
p("Section B — Income ledger (paginated, full window)")
|
||||
p("-" * 40)
|
||||
for k, v in sorted(income_by.items(), key=lambda kv: -abs(kv[1])):
|
||||
p(f" {k:24s} {v:+.8f}")
|
||||
p(f" {'Σ income':24s} {income_sum:+.8f}")
|
||||
p()
|
||||
|
||||
p("Section C — Account snapshot (now)")
|
||||
p("-" * 40)
|
||||
p(f"totalWalletBalance: {wallet:.8f}")
|
||||
p(f"totalUnrealizedProfit: {upnl:.8f}")
|
||||
p(f"totalMarginBalance: {margin:.8f} ← equity")
|
||||
p(f"availableBalance: {avail:.8f}")
|
||||
if positions:
|
||||
p("Open positions:")
|
||||
for pos in positions:
|
||||
p(
|
||||
f" {pos['symbol']} amt={pos['amt']} entry={pos['entry']} "
|
||||
f"upnl={pos['unrealized']}"
|
||||
)
|
||||
else:
|
||||
p("Open positions: (none)")
|
||||
p()
|
||||
|
||||
p("Section D — Equity bridge (attempt)")
|
||||
p("-" * 40)
|
||||
p(f"Start wallet (assumed): {start_wallet:.8f}")
|
||||
p(f"+ Σ income: {income_sum:+.8f}")
|
||||
p(f"= Implied end wallet: {implied_end_wallet:.8f}")
|
||||
p(f"Actual end wallet: {wallet:.8f}")
|
||||
p(f"Wallet residual gap: {wallet_gap:+.8f}")
|
||||
p(f"End unrealized: {upnl:+.8f}")
|
||||
p(f"End equity: {equity_now:.8f}")
|
||||
p(f"Equity − start wallet: {equity_vs_start:+.8f}")
|
||||
p()
|
||||
p("Interpretation:")
|
||||
p(" - Do NOT equate EquityΔ with MakerAlpha failure/success.")
|
||||
p(" - Residual gap means incomplete history, wrong start, or missing")
|
||||
p(" transfer/adjustment types — Account Reconciliation stays open.")
|
||||
p(" - Inventory drift (net qty / open position) can dominate economics")
|
||||
p(" even when per-fill markout is slightly positive.")
|
||||
p()
|
||||
|
||||
p("Section E — Next required chain")
|
||||
p("-" * 40)
|
||||
p("QuoteIntent → Submitted → Accepted → Filled")
|
||||
p(" → fill_px/qty → liquidity=MAKER → fee")
|
||||
p(" → position Δ → realized → funding → equity")
|
||||
p("Daily: StartEquity + TradingPnL + Fees + Funding + uPnL + Transfers = EndEquity")
|
||||
p("Target residual ≈ 0 before any Stage3 unlock / further volume.")
|
||||
p("=" * 72)
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
# machine-readable sidecar
|
||||
sidecar = out.with_suffix(".json")
|
||||
sidecar.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"experiment_id": "MM_EDGE_EXP_001",
|
||||
"maker_only_status": maker_only_status,
|
||||
"taker_filled_count": taker_n,
|
||||
"maker_filled_count": maker_n,
|
||||
"exchange_trades": len(trades),
|
||||
"jsonl_fills": jsonl_fills,
|
||||
"income_by_type": dict(income_by),
|
||||
"income_sum": income_sum,
|
||||
"start_wallet_assumed": start_wallet,
|
||||
"end_wallet": wallet,
|
||||
"end_unrealized": upnl,
|
||||
"end_equity": equity_now,
|
||||
"wallet_residual_gap": wallet_gap,
|
||||
"net_qty": net_qty,
|
||||
"fee_by_asset": dict(fee_by_asset),
|
||||
"positions": positions,
|
||||
"probe": "STOPPED",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"[recon] saved {out}")
|
||||
print(f"[recon] saved {sidecar}")
|
||||
return 0 if maker_only_ok or taker_n == 0 else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,594 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RECONCILIATION-02 — Local Fill ↔ Venue Trade 1:1 / quantity-level closure
|
||||
|
||||
Does NOT resume the probe. Does NOT change quote logic.
|
||||
|
||||
Gate: 100% of local fills and venue trades classified into:
|
||||
MATCHED | DUPLICATE | ORPHAN_LOCAL | ORPHAN_VENUE | MISMATCH | MALFORMED
|
||||
|
||||
Primary link: venue_trade_id when present.
|
||||
Fallback (historical jsonl has trade_id=None):
|
||||
venue_order_id + side + qty + price + timestamp window
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_SRC = _ROOT / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
# reuse pagination from recon-01
|
||||
sys.path.insert(0, str(_ROOT / "scripts"))
|
||||
from reconcile_account import _env, _fetch_user_trades, _signed_get # noqa: E402
|
||||
|
||||
|
||||
PX_TICK = 0.1 # BTCUSDT tick
|
||||
QTY_EPS = 1e-8
|
||||
TIME_MATCH_SEC = 30.0
|
||||
TIME_DUP_SEC = 2.0
|
||||
|
||||
|
||||
def _parse_iso(s: str | None) -> float | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ms_ts(ms: int | None) -> float | None:
|
||||
if ms is None:
|
||||
return None
|
||||
return int(ms) / 1000.0
|
||||
|
||||
|
||||
def load_local_fills(log_dir: Path) -> list[dict]:
|
||||
fills: list[dict] = []
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
if f.name.startswith("Account_") or f.name.startswith("Maker_") or f.name.startswith("RECON"):
|
||||
continue
|
||||
for line in f.open():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(e, dict) or e.get("event") != "fill":
|
||||
continue
|
||||
fills.append(e)
|
||||
return fills
|
||||
|
||||
|
||||
def normalize_local(e: dict, idx: int) -> dict:
|
||||
px = float(e.get("fill_price") or 0)
|
||||
qty = float(e.get("amount") or 0)
|
||||
side = e.get("side") # long / short
|
||||
venue_oid = e.get("venue_order_id")
|
||||
if venue_oid is not None:
|
||||
venue_oid = str(venue_oid)
|
||||
trade_id = e.get("venue_trade_id") or e.get("trade_id")
|
||||
if trade_id in (None, "None", ""):
|
||||
trade_id = None
|
||||
else:
|
||||
trade_id = str(trade_id)
|
||||
ts = _parse_iso(e.get("quote_fill_time"))
|
||||
malformed = []
|
||||
if px <= 0:
|
||||
malformed.append("bad_price")
|
||||
if qty <= 0:
|
||||
malformed.append("bad_qty")
|
||||
if side not in ("long", "short"):
|
||||
malformed.append("bad_side")
|
||||
if not venue_oid:
|
||||
malformed.append("missing_venue_order_id")
|
||||
return {
|
||||
"idx": idx,
|
||||
"fill_id": e.get("fill_id"),
|
||||
"client_order_id": e.get("client_order_id"),
|
||||
"venue_order_id": venue_oid,
|
||||
"venue_trade_id": trade_id,
|
||||
"side": side,
|
||||
"px": px,
|
||||
"qty": qty,
|
||||
"ts": ts,
|
||||
"ts_iso": e.get("quote_fill_time"),
|
||||
"commission": e.get("commission"),
|
||||
"malformed": malformed,
|
||||
"raw_keys": sorted(e.keys()),
|
||||
}
|
||||
|
||||
|
||||
def normalize_venue(t: dict, idx: int) -> dict:
|
||||
buyer = bool(t.get("buyer"))
|
||||
side = "long" if buyer else "short"
|
||||
return {
|
||||
"idx": idx,
|
||||
"venue_trade_id": str(t.get("id")),
|
||||
"venue_order_id": str(t.get("orderId")),
|
||||
"side": side,
|
||||
"px": float(t.get("price") or 0),
|
||||
"qty": float(t.get("qty") or 0),
|
||||
"ts": _ms_ts(t.get("time")),
|
||||
"ts_iso": datetime.fromtimestamp(int(t["time"]) / 1000, tz=timezone.utc).isoformat()
|
||||
if t.get("time")
|
||||
else None,
|
||||
"commission": float(t.get("commission") or 0),
|
||||
"commission_asset": t.get("commissionAsset"),
|
||||
"maker": t.get("maker"),
|
||||
"symbol": t.get("symbol"),
|
||||
}
|
||||
|
||||
|
||||
def _compatible(loc: dict, ven: dict) -> tuple[bool, str]:
|
||||
if loc["side"] != ven["side"]:
|
||||
return False, "side"
|
||||
if abs(loc["qty"] - ven["qty"]) > QTY_EPS:
|
||||
return False, "qty"
|
||||
if abs(loc["px"] - ven["px"]) > PX_TICK + 1e-9:
|
||||
return False, "price"
|
||||
if loc["ts"] is not None and ven["ts"] is not None:
|
||||
if abs(loc["ts"] - ven["ts"]) > TIME_MATCH_SEC:
|
||||
return False, "time"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def match(locals_: list[dict], venues: list[dict]) -> dict:
|
||||
"""Greedy unique matching. Each venue trade consumed at most once."""
|
||||
used_v: set[int] = set()
|
||||
used_l: set[int] = set()
|
||||
matched: list[dict] = []
|
||||
mismatch: list[dict] = []
|
||||
duplicate: list[dict] = []
|
||||
|
||||
loc_by_tid: dict[str, list[dict]] = defaultdict(list)
|
||||
ven_by_tid: dict[str, dict] = {}
|
||||
for v in venues:
|
||||
ven_by_tid[v["venue_trade_id"]] = v
|
||||
for loc in locals_:
|
||||
if loc["venue_trade_id"]:
|
||||
loc_by_tid[loc["venue_trade_id"]].append(loc)
|
||||
|
||||
# Pass 1: explicit venue_trade_id
|
||||
for tid, locs in loc_by_tid.items():
|
||||
v = ven_by_tid.get(tid)
|
||||
if v is None:
|
||||
continue
|
||||
primary, *rest = locs
|
||||
ok, why = _compatible(primary, v)
|
||||
rec = {"local": primary, "venue": v, "link": "venue_trade_id", "compat": why}
|
||||
if ok:
|
||||
matched.append(rec)
|
||||
else:
|
||||
rec["mismatch_reason"] = why
|
||||
mismatch.append(rec)
|
||||
used_v.add(v["idx"])
|
||||
used_l.add(primary["idx"])
|
||||
for d in rest:
|
||||
duplicate.append(
|
||||
{"local": d, "venue": v, "link": "venue_trade_id_dup", "reason": "same venue_trade_id"}
|
||||
)
|
||||
used_l.add(d["idx"])
|
||||
|
||||
# Pass 2: same venue_order_id, greedy best (qty, px, time)
|
||||
loc_by_oid: dict[str, list[dict]] = defaultdict(list)
|
||||
ven_by_oid: dict[str, list[dict]] = defaultdict(list)
|
||||
for loc in locals_:
|
||||
if loc["idx"] in used_l or loc["malformed"]:
|
||||
continue
|
||||
if loc["venue_order_id"]:
|
||||
loc_by_oid[loc["venue_order_id"]].append(loc)
|
||||
for v in venues:
|
||||
if v["idx"] in used_v:
|
||||
continue
|
||||
ven_by_oid[v["venue_order_id"]].append(v)
|
||||
|
||||
def score(loc: dict, v: dict) -> float:
|
||||
ok, _ = _compatible(loc, v)
|
||||
if not ok:
|
||||
return 1e18
|
||||
dt = 0.0
|
||||
if loc["ts"] is not None and v["ts"] is not None:
|
||||
dt = abs(loc["ts"] - v["ts"])
|
||||
return dt + abs(loc["px"] - v["px"]) * 1e-6
|
||||
|
||||
for oid, locs in loc_by_oid.items():
|
||||
cands = [v for v in ven_by_oid.get(oid, []) if v["idx"] not in used_v]
|
||||
remaining = [x for x in locs if x["idx"] not in used_l]
|
||||
for loc in sorted(remaining, key=lambda x: x["ts"] or 0):
|
||||
best = None
|
||||
best_s = 1e18
|
||||
for v in cands:
|
||||
if v["idx"] in used_v:
|
||||
continue
|
||||
s = score(loc, v)
|
||||
if s < best_s:
|
||||
best_s = s
|
||||
best = v
|
||||
if best is None or best_s >= 1e17:
|
||||
continue
|
||||
matched.append({"local": loc, "venue": best, "link": "order_id+px+qty+time", "compat": "ok"})
|
||||
used_l.add(loc["idx"])
|
||||
used_v.add(best["idx"])
|
||||
|
||||
# Pass 3: remaining locals that share (oid, px, qty) with an already-matched
|
||||
# local → DUPLICATE (restart / double-log of same execution)
|
||||
matched_sig: dict[tuple, dict] = {}
|
||||
for m in matched:
|
||||
loc = m["local"]
|
||||
v = m["venue"]
|
||||
matched_sig[(loc["venue_order_id"], round(loc["px"], 2), round(loc["qty"], 8), loc["side"])] = v
|
||||
|
||||
for loc in locals_:
|
||||
if loc["idx"] in used_l or loc["malformed"]:
|
||||
continue
|
||||
key = (loc["venue_order_id"], round(loc["px"], 2), round(loc["qty"], 8), loc["side"])
|
||||
v = matched_sig.get(key)
|
||||
if v is None:
|
||||
continue
|
||||
dt_ok = True
|
||||
if loc["ts"] is not None and v["ts"] is not None:
|
||||
dt_ok = abs(loc["ts"] - v["ts"]) <= TIME_MATCH_SEC
|
||||
if not dt_ok:
|
||||
continue
|
||||
duplicate.append(
|
||||
{
|
||||
"local": loc,
|
||||
"venue": v,
|
||||
"link": "dup_of_matched",
|
||||
"reason": "same order/px/qty/side as a matched fill",
|
||||
}
|
||||
)
|
||||
used_l.add(loc["idx"])
|
||||
|
||||
# Pass 4: global leftover by px+qty+side+time (order id mismatch)
|
||||
leftover_v = [v for v in venues if v["idx"] not in used_v]
|
||||
leftover_l = [x for x in locals_ if x["idx"] not in used_l and not x["malformed"]]
|
||||
for loc in leftover_l:
|
||||
best = None
|
||||
best_s = 1e18
|
||||
for v in leftover_v:
|
||||
if v["idx"] in used_v:
|
||||
continue
|
||||
s = score(loc, v)
|
||||
if s < best_s:
|
||||
best_s = s
|
||||
best = v
|
||||
if best is None or best_s >= 1e17:
|
||||
continue
|
||||
matched.append({"local": loc, "venue": best, "link": "global_px_qty_time", "compat": "ok"})
|
||||
used_l.add(loc["idx"])
|
||||
used_v.add(best["idx"])
|
||||
|
||||
malformed = [x for x in locals_ if x["malformed"]]
|
||||
for x in malformed:
|
||||
used_l.add(x["idx"])
|
||||
|
||||
orphan_local = [x for x in locals_ if x["idx"] not in used_l]
|
||||
orphan_venue = [v for v in venues if v["idx"] not in used_v]
|
||||
|
||||
return {
|
||||
"matched": matched,
|
||||
"duplicate": duplicate,
|
||||
"mismatch": mismatch,
|
||||
"malformed": malformed,
|
||||
"orphan_local": orphan_local,
|
||||
"orphan_venue": orphan_venue,
|
||||
}
|
||||
|
||||
|
||||
def _qty(xs, key="qty") -> float:
|
||||
return sum(float(x[key]) for x in xs)
|
||||
|
||||
|
||||
def audit_orphan_orders(orphans: list[dict], symbol: str, max_checks: int = 40) -> dict:
|
||||
"""Cross-check orphan locals against /fapi/v1/order and /userTrades?orderId=."""
|
||||
stats = {
|
||||
"checked": 0,
|
||||
"order_filled_no_trades": 0,
|
||||
"order_missing": 0,
|
||||
"order_other": 0,
|
||||
"trades_found": 0,
|
||||
}
|
||||
samples: list[dict] = []
|
||||
for loc in orphans[:max_checks]:
|
||||
oid = loc["venue_order_id"]
|
||||
if not oid:
|
||||
continue
|
||||
stats["checked"] += 1
|
||||
try:
|
||||
order = _signed_get("/fapi/v1/order", {"symbol": symbol, "orderId": oid})
|
||||
except Exception as exc:
|
||||
stats["order_missing"] += 1
|
||||
samples.append({"oid": oid, "fill_id": loc["fill_id"], "order": "ERR", "detail": str(exc)})
|
||||
continue
|
||||
st = order.get("status")
|
||||
try:
|
||||
tr = _signed_get("/fapi/v1/userTrades", {"symbol": symbol, "orderId": oid})
|
||||
except Exception:
|
||||
tr = []
|
||||
ntr = len(tr) if isinstance(tr, list) else 0
|
||||
if st == "FILLED" and ntr == 0:
|
||||
stats["order_filled_no_trades"] += 1
|
||||
elif ntr > 0:
|
||||
stats["trades_found"] += 1
|
||||
else:
|
||||
stats["order_other"] += 1
|
||||
if len(samples) < 8:
|
||||
samples.append(
|
||||
{
|
||||
"oid": oid,
|
||||
"fill_id": loc["fill_id"],
|
||||
"status": st,
|
||||
"execQty": order.get("executedQty"),
|
||||
"avgPrice": order.get("avgPrice"),
|
||||
"userTrades_n": ntr,
|
||||
}
|
||||
)
|
||||
stats["samples"] = samples
|
||||
return stats
|
||||
|
||||
|
||||
def write_report(
|
||||
out: Path,
|
||||
result: dict,
|
||||
n_local: int,
|
||||
n_venue: int,
|
||||
*,
|
||||
venue_t_max: str | None = None,
|
||||
orphan_audit: dict | None = None,
|
||||
) -> None:
|
||||
m = result["matched"]
|
||||
d = result["duplicate"]
|
||||
mm = result["mismatch"]
|
||||
mal = result["malformed"]
|
||||
ol = result["orphan_local"]
|
||||
ov = result["orphan_venue"]
|
||||
|
||||
loc_explained = len(m) + len(d) + len(mm) + len(mal) + len(ol)
|
||||
ven_explained = len(m) + len(mm) + len(ov) # dups share venue; orphans leftover
|
||||
# every local in exactly one bucket
|
||||
# every venue in matched, mismatch, or orphan_venue (dups don't extra-count venue)
|
||||
|
||||
m_qty_l = sum(x["local"]["qty"] for x in m)
|
||||
m_qty_v = sum(x["venue"]["qty"] for x in m)
|
||||
m_fee_v = sum(x["venue"]["commission"] for x in m)
|
||||
dt = [
|
||||
abs(x["local"]["ts"] - x["venue"]["ts"])
|
||||
for x in m
|
||||
if x["local"]["ts"] is not None and x["venue"]["ts"] is not None
|
||||
]
|
||||
dt.sort()
|
||||
|
||||
def pctile(a, q):
|
||||
if not a:
|
||||
return None
|
||||
i = min(len(a) - 1, max(0, int(round(q * (len(a) - 1)))))
|
||||
return a[i]
|
||||
|
||||
unexplained_local = n_local - (len(m) + len(d) + len(mm) + len(mal))
|
||||
# orphan_local IS unexplained in the sense of no venue link, but classified
|
||||
classified_local = len(m) + len(d) + len(mm) + len(mal) + len(ol)
|
||||
classified_venue = len({x["venue"]["idx"] for x in m + mm} | {x["idx"] for x in ov})
|
||||
|
||||
gate = (
|
||||
classified_local == n_local
|
||||
and classified_venue == n_venue
|
||||
and len(ol) == 0
|
||||
and len(ov) == 0
|
||||
and len(mm) == 0
|
||||
and len(mal) == 0
|
||||
)
|
||||
# 100% explainable ≠ zero orphans. User asked 100% explainable.
|
||||
# We treat orphans as classified. Gate PASS if all rows classified (always if logic sound)
|
||||
# Strict gate: no orphans/mismatch/malformed
|
||||
explainable = classified_local == n_local and classified_venue == n_venue
|
||||
|
||||
lines = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
p("=" * 72)
|
||||
p("RECONCILIATION-02 — Local Fill ↔ Venue Trade")
|
||||
p("MM_EDGE_EXP_001 / probe_v0.1 / TESTNET BTCUSDT")
|
||||
p("Probe remains STOPPED")
|
||||
p("=" * 72)
|
||||
p()
|
||||
p("Counts")
|
||||
p("-" * 40)
|
||||
p(f"Local JSONL fills: {n_local}")
|
||||
p(f"Venue userTrades: {n_venue}")
|
||||
p(f" MATCHED: {len(m)}")
|
||||
p(f" DUPLICATE (local): {len(d)}")
|
||||
p(f" MISMATCH: {len(mm)}")
|
||||
p(f" MALFORMED (local): {len(mal)}")
|
||||
p(f" ORPHAN_LOCAL: {len(ol)}")
|
||||
p(f" ORPHAN_VENUE: {len(ov)}")
|
||||
p(f"Local classified: {classified_local}/{n_local}")
|
||||
p(f"Venue classified: {classified_venue}/{n_venue}")
|
||||
venue_t_max_ts = None
|
||||
if venue_t_max:
|
||||
p(f"Venue history max (UTC): {venue_t_max}")
|
||||
try:
|
||||
venue_t_max_ts = datetime.fromisoformat(venue_t_max).timestamp()
|
||||
except Exception:
|
||||
venue_t_max_ts = None
|
||||
if ol and venue_t_max_ts:
|
||||
orphan_after = sum(1 for x in ol if x["ts"] is not None and x["ts"] > venue_t_max_ts)
|
||||
orphan_before = len(ol) - orphan_after
|
||||
p(f"Orphan after venue cutoff: {orphan_after} (userTrades history gap on testnet)")
|
||||
p(f"Orphan before cutoff: {orphan_before}")
|
||||
if orphan_audit:
|
||||
p()
|
||||
p("Orphan order audit (sample)")
|
||||
p("-" * 40)
|
||||
p(f" checked: {orphan_audit.get('checked')}")
|
||||
p(f" order FILLED, 0 trades: {orphan_audit.get('order_filled_no_trades')}")
|
||||
p(f" userTrades found: {orphan_audit.get('trades_found')}")
|
||||
for s in orphan_audit.get("samples") or []:
|
||||
p(f" oid={s.get('oid')} status={s.get('status')} exec={s.get('execQty')} trades={s.get('userTrades_n')}")
|
||||
p()
|
||||
p("Quantity (BTC)")
|
||||
p("-" * 40)
|
||||
p(f"Matched local qty: {m_qty_l:.6f}")
|
||||
p(f"Matched venue qty: {m_qty_v:.6f}")
|
||||
p(f"Qty residual: {m_qty_l - m_qty_v:+.8f}")
|
||||
p(f"Orphan local qty: {sum(x['qty'] for x in ol):.6f}")
|
||||
p(f"Orphan venue qty: {sum(x['qty'] for x in ov):.6f}")
|
||||
p(f"Duplicate local qty: {sum(x['local']['qty'] for x in d):.6f}")
|
||||
p()
|
||||
p("Fee / time (matched only)")
|
||||
p("-" * 40)
|
||||
p(f"Venue commission sum: {m_fee_v:.8f} USDT")
|
||||
if dt:
|
||||
p(f"|Δt| n={len(dt)} p50={pctile(dt,0.5):.3f}s p95={pctile(dt,0.95):.3f}s max={dt[-1]:.3f}s")
|
||||
p()
|
||||
p("Link methods (matched)")
|
||||
p("-" * 40)
|
||||
by = defaultdict(int)
|
||||
for x in m:
|
||||
by[x["link"]] += 1
|
||||
for k, v in sorted(by.items(), key=lambda kv: -kv[1]):
|
||||
p(f" {k:28s} {v}")
|
||||
p()
|
||||
p("Gate")
|
||||
p("-" * 40)
|
||||
p(f"100% classified: {'PASS' if explainable else 'FAIL'}")
|
||||
p(f"Strict (no orphan/mismatch/malformed): {'PASS' if gate else 'FAIL'}")
|
||||
p("Do not resume probe until strict gate PASS or leftovers 100% attributed.")
|
||||
p()
|
||||
|
||||
def dump_sample(title: str, rows: list, kind: str, n: int = 8) -> None:
|
||||
if not rows:
|
||||
return
|
||||
p(f"Samples — {title} (showing {min(n, len(rows))}/{len(rows)})")
|
||||
p("-" * 40)
|
||||
for row in rows[:n]:
|
||||
if kind == "match":
|
||||
loc, v = row["local"], row["venue"]
|
||||
p(
|
||||
f" fill={loc['fill_id']} oid={loc['venue_order_id']} "
|
||||
f"tid={v['venue_trade_id']} px={loc['px']}/{v['px']} "
|
||||
f"qty={loc['qty']}/{v['qty']} link={row['link']}"
|
||||
)
|
||||
elif kind == "dup":
|
||||
loc, v = row["local"], row["venue"]
|
||||
p(
|
||||
f" fill={loc['fill_id']} oid={loc['venue_order_id']} "
|
||||
f"tid={v['venue_trade_id']} reason={row.get('reason')}"
|
||||
)
|
||||
elif kind == "local":
|
||||
p(
|
||||
f" fill={row['fill_id']} oid={row['venue_order_id']} "
|
||||
f"px={row['px']} qty={row['qty']} side={row['side']} ts={row['ts_iso']}"
|
||||
)
|
||||
elif kind == "venue":
|
||||
p(
|
||||
f" tid={row['venue_trade_id']} oid={row['venue_order_id']} "
|
||||
f"px={row['px']} qty={row['qty']} side={row['side']} ts={row['ts_iso']}"
|
||||
)
|
||||
p()
|
||||
|
||||
dump_sample("ORPHAN_LOCAL", ol, "local")
|
||||
dump_sample("ORPHAN_VENUE", ov, "venue")
|
||||
dump_sample("DUPLICATE", d, "dup")
|
||||
dump_sample("MISMATCH", mm, "match")
|
||||
p("=" * 72)
|
||||
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print("\n".join(lines))
|
||||
|
||||
sidecar = {
|
||||
"experiment_id": "MM_EDGE_EXP_001",
|
||||
"recon": "RECONCILIATION-02",
|
||||
"n_local": n_local,
|
||||
"n_venue": n_venue,
|
||||
"matched": len(m),
|
||||
"duplicate": len(d),
|
||||
"mismatch": len(mm),
|
||||
"malformed": len(mal),
|
||||
"orphan_local": len(ol),
|
||||
"orphan_venue": len(ov),
|
||||
"classified_local": classified_local,
|
||||
"classified_venue": classified_venue,
|
||||
"qty_matched_local": m_qty_l,
|
||||
"qty_matched_venue": m_qty_v,
|
||||
"qty_orphan_local": sum(x["qty"] for x in ol),
|
||||
"qty_orphan_venue": sum(x["qty"] for x in ov),
|
||||
"qty_duplicate_local": sum(x["local"]["qty"] for x in d),
|
||||
"fee_matched_venue": m_fee_v,
|
||||
"strict_gate": gate,
|
||||
"classified_gate": explainable,
|
||||
"dt_p50_sec": pctile(dt, 0.5),
|
||||
"dt_p95_sec": pctile(dt, 0.95),
|
||||
"orphan_local_oids": [x["venue_order_id"] for x in ol[:50]],
|
||||
"orphan_venue_tids": [x["venue_trade_id"] for x in ov[:50]],
|
||||
"venue_history_max": venue_t_max,
|
||||
"orphan_audit": orphan_audit,
|
||||
"probe": "STOPPED",
|
||||
}
|
||||
out.with_suffix(".json").write_text(json.dumps(sidecar, indent=2) + "\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default=str(_ROOT / "logs" / "maker_edge"))
|
||||
ap.add_argument("--symbol", default=_env("RECON_SYMBOL", "BTCUSDT"))
|
||||
ap.add_argument("--since-days", type=float, default=20.0)
|
||||
ap.add_argument("--trades-cache", default="")
|
||||
ap.add_argument("--fetch", action="store_true", help="Fetch userTrades from exchange")
|
||||
ap.add_argument("--out", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
cache = Path(args.trades_cache) if args.trades_cache else log_dir / "venue_trades.json"
|
||||
|
||||
if args.fetch or not cache.exists():
|
||||
import time
|
||||
|
||||
end_ms = int(time.time() * 1000)
|
||||
start_ms = end_ms - int(args.since_days * 86400 * 1000)
|
||||
print(f"[recon-02] fetching userTrades {args.symbol} …")
|
||||
trades = _fetch_user_trades(args.symbol, start_ms, end_ms)
|
||||
cache.write_text(json.dumps(trades))
|
||||
print(f"[recon-02] cached {len(trades)} trades → {cache}")
|
||||
else:
|
||||
trades = json.loads(cache.read_text())
|
||||
print(f"[recon-02] loaded {len(trades)} trades from {cache}")
|
||||
|
||||
raw_fills = load_local_fills(log_dir)
|
||||
locals_ = [normalize_local(e, i) for i, e in enumerate(raw_fills)]
|
||||
venues = [normalize_venue(t, i) for i, t in enumerate(trades)]
|
||||
print(f"[recon-02] local fills={len(locals_)} venue={len(venues)}")
|
||||
|
||||
result = match(locals_, venues)
|
||||
venue_t_max = None
|
||||
if venues:
|
||||
venue_t_max = datetime.fromtimestamp(
|
||||
max(int(t["time"]) for t in trades) / 1000, tz=timezone.utc
|
||||
).isoformat()
|
||||
orphan_audit = audit_orphan_orders(result["orphan_local"], args.symbol)
|
||||
out = Path(args.out) if args.out else log_dir / "RECONCILIATION_02.txt"
|
||||
write_report(
|
||||
out,
|
||||
result,
|
||||
len(locals_),
|
||||
len(venues),
|
||||
venue_t_max=venue_t_max,
|
||||
orphan_audit=orphan_audit,
|
||||
)
|
||||
print(f"[recon-02] saved {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RECONCILIATION-03 — Order-level evidence for ORPHAN_LOCAL (post userTrades cutoff)
|
||||
|
||||
Does NOT resume probe. Does NOT reclassify as MATCHED.
|
||||
|
||||
For each ORPHAN_LOCAL from RECON-02, query /fapi/v1/order and validate:
|
||||
status == FILLED
|
||||
executedQty ~= sum(local qty per order)
|
||||
avgPrice ~= local weighted avg
|
||||
side consistent
|
||||
|
||||
Reclassify passing rows as:
|
||||
VENUE_CONFIRMED_NO_TRADE_HISTORY
|
||||
(Order evidence only — no userTrades row on Testnet after cutoff)
|
||||
|
||||
See TESTNET_LIMITATIONS.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
from reconcile_fills import ( # noqa: E402
|
||||
load_local_fills,
|
||||
match,
|
||||
normalize_local,
|
||||
normalize_venue,
|
||||
)
|
||||
from reconcile_account import _env, _fetch_user_trades, _signed_get # noqa: E402
|
||||
|
||||
PX_TICK = 0.1
|
||||
QTY_EPS = 1e-8
|
||||
|
||||
|
||||
def _order_side_to_local(side: str) -> str:
|
||||
return "long" if side.upper() == "BUY" else "short"
|
||||
|
||||
|
||||
def fetch_order(symbol: str, order_id: str, cache: dict) -> dict | None:
|
||||
if order_id in cache:
|
||||
return cache[order_id]
|
||||
try:
|
||||
o = _signed_get("/fapi/v1/order", {"symbol": symbol, "orderId": order_id})
|
||||
except Exception as exc:
|
||||
cache[order_id] = {"_error": str(exc)}
|
||||
return cache[order_id]
|
||||
cache[order_id] = o if isinstance(o, dict) else {"_error": "bad_response"}
|
||||
time.sleep(0.05)
|
||||
return cache[order_id]
|
||||
|
||||
|
||||
def validate_order_group(fills: list[dict], order: dict) -> tuple[str, list[str]]:
|
||||
"""Return (classification, reasons)."""
|
||||
reasons: list[str] = []
|
||||
if order.get("_error"):
|
||||
return "ORPHAN_LOCAL_UNCONFIRMED", [f"order_api_error:{order['_error']}"]
|
||||
st = order.get("status")
|
||||
exec_qty = float(order.get("executedQty") or 0)
|
||||
avg_px = float(order.get("avgPrice") or 0)
|
||||
local_qty = sum(f["qty"] for f in fills)
|
||||
if exec_qty <= 0:
|
||||
return "ORPHAN_LOCAL_UNCONFIRMED", [f"status={st} executedQty=0"]
|
||||
# Partial fill then TTL cancel: status=CANCELED but executedQty>0
|
||||
if st not in ("FILLED", "CANCELED"):
|
||||
return "ORPHAN_LOCAL_UNCONFIRMED", [f"status={st}"]
|
||||
if abs(local_qty - exec_qty) > QTY_EPS:
|
||||
reasons.append(f"qty local={local_qty} order={exec_qty}")
|
||||
wavg = sum(f["px"] * f["qty"] for f in fills) / local_qty if local_qty else 0
|
||||
if avg_px > 0 and abs(wavg - avg_px) > PX_TICK + 1e-6:
|
||||
reasons.append(f"px local_wavg={wavg:.2f} order_avg={avg_px:.2f}")
|
||||
order_side = _order_side_to_local(str(order.get("side", "")))
|
||||
for f in fills:
|
||||
if f["side"] != order_side:
|
||||
reasons.append(f"side local={f['side']} order={order_side}")
|
||||
break
|
||||
if reasons:
|
||||
return "ORDER_MISMATCH", reasons
|
||||
if st == "CANCELED":
|
||||
return "VENUE_PARTIAL_ORDER_CANCELED", []
|
||||
return "VENUE_CONFIRMED_NO_TRADE_HISTORY", []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default=str(_ROOT / "logs" / "maker_edge"))
|
||||
ap.add_argument("--symbol", default=_env("RECON_SYMBOL", "BTCUSDT"))
|
||||
ap.add_argument("--trades-cache", default="")
|
||||
ap.add_argument("--out", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
cache_path = Path(args.trades_cache) if args.trades_cache else log_dir / "venue_trades.json"
|
||||
trades = json.loads(cache_path.read_text()) if cache_path.exists() else []
|
||||
|
||||
raw = load_local_fills(log_dir)
|
||||
locals_ = [normalize_local(e, i) for i, e in enumerate(raw)]
|
||||
venues = [normalize_venue(t, i) for i, t in enumerate(trades)]
|
||||
r02 = match(locals_, venues)
|
||||
orphans = r02["orphan_local"]
|
||||
|
||||
by_oid: dict[str, list[dict]] = defaultdict(list)
|
||||
for f in orphans:
|
||||
if f.get("venue_order_id"):
|
||||
by_oid[f["venue_order_id"]].append(f)
|
||||
|
||||
order_cache: dict[str, dict] = {}
|
||||
fill_class: dict[str, tuple[str, list[str], dict | None]] = {}
|
||||
counts = defaultdict(int)
|
||||
order_rows: list[dict] = []
|
||||
|
||||
for oid, fills in sorted(by_oid.items()):
|
||||
order = fetch_order(args.symbol, oid, order_cache)
|
||||
cls, reasons = validate_order_group(fills, order or {})
|
||||
counts[cls] += len(fills)
|
||||
order_rows.append(
|
||||
{
|
||||
"venue_order_id": oid,
|
||||
"classification": cls,
|
||||
"n_local_fills": len(fills),
|
||||
"local_qty": sum(f["qty"] for f in fills),
|
||||
"order_executedQty": order.get("executedQty") if order else None,
|
||||
"order_avgPrice": order.get("avgPrice") if order else None,
|
||||
"order_status": order.get("status") if order else None,
|
||||
"order_updateTime": order.get("updateTime") if order else None,
|
||||
"reasons": reasons,
|
||||
}
|
||||
)
|
||||
for f in fills:
|
||||
fill_class[f["fill_id"]] = (cls, reasons, order)
|
||||
|
||||
# Summary from RECON-02 matched
|
||||
n_matched = len(r02["matched"])
|
||||
n_dup = len(r02["duplicate"])
|
||||
n_mismatch = len(r02["mismatch"])
|
||||
n_mal = len(r02["malformed"])
|
||||
n_confirmed = counts["VENUE_CONFIRMED_NO_TRADE_HISTORY"]
|
||||
n_partial_canceled = counts["VENUE_PARTIAL_ORDER_CANCELED"]
|
||||
n_order_mismatch = counts["ORDER_MISMATCH"]
|
||||
n_unconfirmed = counts["ORPHAN_LOCAL_UNCONFIRMED"]
|
||||
n_local = len(locals_)
|
||||
|
||||
venue_t_max = None
|
||||
if trades:
|
||||
venue_t_max = datetime.fromtimestamp(
|
||||
max(int(t["time"]) for t in trades) / 1000, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
order_evidence_ok = (
|
||||
n_unconfirmed == 0
|
||||
and n_order_mismatch == 0
|
||||
and (n_confirmed + n_partial_canceled) == len(orphans)
|
||||
)
|
||||
classified = (
|
||||
n_matched + n_dup + n_mismatch + n_mal
|
||||
+ n_confirmed + n_partial_canceled + n_order_mismatch + n_unconfirmed
|
||||
)
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def p(s: str = "") -> None:
|
||||
lines.append(s)
|
||||
print(s)
|
||||
|
||||
p("=" * 72)
|
||||
p("RECONCILIATION-03 — Order-level evidence (ORPHAN_LOCAL backfill)")
|
||||
p("MM_EDGE_EXP_001 / probe_v0.1 / TESTNET BTCUSDT")
|
||||
p("Probe remains STOPPED")
|
||||
p("=" * 72)
|
||||
p()
|
||||
p("Prior RECON-02 (trade-level)")
|
||||
p("-" * 40)
|
||||
p(f"MATCHED (Order+Trade): {n_matched}")
|
||||
p(f"DUPLICATE: {n_dup}")
|
||||
p(f"MISMATCH: {n_mismatch}")
|
||||
p(f"MALFORMED: {n_mal}")
|
||||
p(f"ORPHAN_LOCAL (pre-03): {len(orphans)}")
|
||||
if venue_t_max:
|
||||
p(f"userTrades history max (UTC): {venue_t_max}")
|
||||
p()
|
||||
p("RECON-03 order-level reclassification")
|
||||
p("-" * 40)
|
||||
p(f"VENUE_CONFIRMED_NO_TRADE_HISTORY: {n_confirmed}")
|
||||
p(f"VENUE_PARTIAL_ORDER_CANCELED: {n_partial_canceled}")
|
||||
p(f"ORDER_MISMATCH: {n_order_mismatch}")
|
||||
p(f"ORPHAN_LOCAL_UNCONFIRMED: {n_unconfirmed}")
|
||||
p(f"Unique orders checked: {len(by_oid)}")
|
||||
p()
|
||||
p("Evidence grades (permanent taxonomy)")
|
||||
p("-" * 40)
|
||||
p("MATCHED = Order + Trade row (dual evidence)")
|
||||
p("VENUE_CONFIRMED_NO_TRADE_HISTORY = Order FILLED, no userTrades row")
|
||||
p("VENUE_PARTIAL_ORDER_CANCELED = Partial fill, order later CANCELED (TTL)")
|
||||
p("ORDER_MISMATCH = Order exists but qty/px/side disagree")
|
||||
p("ORPHAN_LOCAL_UNCONFIRMED = No reliable order evidence")
|
||||
p()
|
||||
p("Gates")
|
||||
p("-" * 40)
|
||||
p(f"RECON-02 classification (all buckets): {'PASS' if classified == n_local else 'FAIL'}")
|
||||
p(f"Order-level closure (887 backfill): {'PASS' if order_evidence_ok else 'FAIL'}")
|
||||
p(f"Strict trade-level closure: FAIL (by design until live trade_id ledger)")
|
||||
p()
|
||||
p("Testnet limitation")
|
||||
p("-" * 40)
|
||||
p("userTrades history is NOT guaranteed complete after observed cutoff.")
|
||||
p("Order-level FILLED status remains queryable via /fapi/v1/order.")
|
||||
p("Do NOT treat VENUE_CONFIRMED fills as fake or duplicate.")
|
||||
p()
|
||||
|
||||
fails = [r for r in order_rows if r["classification"] in ("ORDER_MISMATCH", "ORPHAN_LOCAL_UNCONFIRMED")]
|
||||
if fails:
|
||||
p(f"Non-confirmed orders (showing {min(8, len(fails))}/{len(fails)})")
|
||||
p("-" * 40)
|
||||
for r in fails[:8]:
|
||||
p(
|
||||
f" oid={r['venue_order_id']} cls={r['classification']} "
|
||||
f"local_qty={r['local_qty']} exec={r['order_executedQty']} reasons={r['reasons']}"
|
||||
)
|
||||
p()
|
||||
|
||||
p("=" * 72)
|
||||
|
||||
out = Path(args.out) if args.out else log_dir / "RECONCILIATION_03.txt"
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
sidecar = {
|
||||
"experiment_id": "MM_EDGE_EXP_001",
|
||||
"recon": "RECONCILIATION-03",
|
||||
"n_local": n_local,
|
||||
"matched_trade_level": n_matched,
|
||||
"orphan_local_pre03": len(orphans),
|
||||
"venue_confirmed_no_trade_history": n_confirmed,
|
||||
"venue_partial_order_canceled": n_partial_canceled,
|
||||
"order_mismatch": n_order_mismatch,
|
||||
"orphan_local_unconfirmed": n_unconfirmed,
|
||||
"unique_orders_checked": len(by_oid),
|
||||
"userTrades_cutoff_utc": venue_t_max,
|
||||
"recon02_classification_pass": classified == n_local,
|
||||
"order_level_closure_pass": order_evidence_ok,
|
||||
"strict_trade_level_pass": False,
|
||||
"probe": "STOPPED",
|
||||
"order_rows": order_rows,
|
||||
}
|
||||
out.with_suffix(".json").write_text(json.dumps(sidecar, indent=2) + "\n")
|
||||
print(f"[recon-03] saved {out}")
|
||||
return 0 if order_evidence_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CCXT 轻量 L2 录音机(不依赖 Nautilus)
|
||||
|
||||
用途:在 Nautilus 节点未就绪时,先用代理拉 Binance USDT-M 盘口 + trades,
|
||||
写入与 Maker Edge 相同的 jsonl schema(book history + 模拟 quote 心跳)。
|
||||
|
||||
用法:
|
||||
cd nautilus_mm
|
||||
source .venv/bin/activate
|
||||
export PYTHONPATH=src
|
||||
export HTTPS_PROXY=http://127.0.0.1:7897
|
||||
python scripts/record_l2_ccxt.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
import ccxt # type: ignore
|
||||
|
||||
from nautilus_mm.recorder import MakerEdgeLogger
|
||||
|
||||
|
||||
def main() -> None:
|
||||
proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") or "http://127.0.0.1:7897"
|
||||
symbol = os.getenv("CCXT_SYMBOL", "BTC/USDT:USDT")
|
||||
poll = float(os.getenv("POLL_SECS", "2"))
|
||||
log_dir = os.getenv("MAKER_EDGE_LOG_DIR", str(_ROOT / "logs" / "maker_edge"))
|
||||
|
||||
ex = ccxt.binanceusdm(
|
||||
{
|
||||
"enableRateLimit": True,
|
||||
"proxies": {"http": proxy, "https": proxy},
|
||||
"options": {"defaultType": "future"},
|
||||
}
|
||||
)
|
||||
lg = MakerEdgeLogger(log_dir=log_dir, levels=10)
|
||||
last_mid = None
|
||||
print(f"[ccxt-recorder] {symbol} proxy={proxy} log={log_dir}")
|
||||
print("Ctrl+C to stop. This mode records book only (no live orders).")
|
||||
|
||||
while True:
|
||||
try:
|
||||
ob = ex.fetch_order_book(symbol, limit=10)
|
||||
trades = ex.fetch_trades(symbol, limit=100)
|
||||
snap = MakerEdgeLogger.snapshot_from_orderbook(
|
||||
ob, levels=10, recent_trades=trades, last_mid=last_mid
|
||||
)
|
||||
if snap.mid:
|
||||
last_mid = snap.mid
|
||||
now = time.time()
|
||||
lg.record_book(snap, now=now)
|
||||
# 心跳 quote(不挂单,仅记录可报价位置)
|
||||
if snap.best_bid:
|
||||
lg.write(
|
||||
{
|
||||
"event": "book_tick",
|
||||
"pair": symbol,
|
||||
"inventory": 0,
|
||||
**snap.to_book_fields(),
|
||||
}
|
||||
)
|
||||
lg.update_paths(symbol, snap.mid or 0, now=now)
|
||||
print(
|
||||
f"\r mid={snap.mid:.1f} spread={snap.spread:.2f} obi={snap.obi:+.3f} "
|
||||
f"timb={snap.trade_imbalance:+.3f} pending_fills={lg.pending_count}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"\nerror: {e}")
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Preserve systemd/caller identity before .env (which still belongs to EXP_001)
|
||||
PRESERVE_RUN_ID="${LEDGER_RUN_ID:-}"
|
||||
PRESERVE_LOG_DIR="${EVENT_STATE_LOG_DIR:-}"
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Layer 1 (script): force EXP_002 contract after .env
|
||||
export EXPERIMENT_ID=MM_EDGE_EXP_002
|
||||
export PROBE_VERSION=event_state_v0.1
|
||||
export ENABLE_TRADING=false
|
||||
export LEDGER_RUN_ID="${PRESERVE_RUN_ID:-${LEDGER_RUN_ID:-EXP-002-RUN-002}}"
|
||||
export EVENT_STATE_LOG_DIR="${PRESERVE_LOG_DIR:-$ROOT/logs/event_state/$LEDGER_RUN_ID}"
|
||||
export PYTHONPATH="${PYTHONPATH:-$ROOT/src}"
|
||||
mkdir -p "$EVENT_STATE_LOG_DIR"
|
||||
|
||||
echo "[run_event_state] EXP_002 observability | trading=NO | run=$LEDGER_RUN_ID | log=$EVENT_STATE_LOG_DIR"
|
||||
exec "$ROOT/.venv/bin/python" -m nautilus_mm.run_event_state
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -d .venv ]]; then
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -U pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 本地可开代理;服务器 systemd 直连,勿强制 7897
|
||||
if [[ "${USE_PROXY:-}" == "1" || "${USE_PROXY:-}" == "true" ]]; then
|
||||
export HTTP_PROXY="${HTTP_PROXY:-http://127.0.0.1:7897}"
|
||||
export HTTPS_PROXY="${HTTPS_PROXY:-http://127.0.0.1:7897}"
|
||||
echo "[run_probe] proxy=$HTTPS_PROXY"
|
||||
elif [[ -n "${HTTPS_PROXY:-}${HTTP_PROXY:-}" ]]; then
|
||||
echo "[run_probe] proxy=${HTTPS_PROXY:-$HTTP_PROXY}"
|
||||
else
|
||||
echo "[run_probe] direct (no proxy)"
|
||||
fi
|
||||
|
||||
exec python -m nautilus_mm.run_live
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# MM_EDGE_EXP_002 smoke test — 10–15 min, restart in the middle, NO trading.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
RUN_ID="${LEDGER_RUN_ID:-EXP-002-RUN-001}"
|
||||
SESSION_SECS="${SESSION_SECS:-360}" # 6 min × 2 = 12 min collect
|
||||
LOG_DIR="${EVENT_STATE_LOG_DIR:-$ROOT/logs/event_state/$RUN_ID}"
|
||||
PYTHON="${ROOT}/.venv/bin/python"
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
export EXPERIMENT_ID=MM_EDGE_EXP_002
|
||||
export PROBE_VERSION=event_state_v0.1
|
||||
export ENABLE_TRADING=false
|
||||
export LEDGER_RUN_ID="$RUN_ID"
|
||||
export EVENT_STATE_LOG_DIR="$LOG_DIR"
|
||||
export PYTHONPATH="$ROOT/src"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
run_session() {
|
||||
local label="$1"
|
||||
export LEDGER_SESSION_ID="$(python3 -c 'import uuid; print(uuid.uuid4().hex[:12])')"
|
||||
echo "[smoke] session ${label} start session_id=${LEDGER_SESSION_ID} secs=${SESSION_SECS}"
|
||||
"$PYTHON" -m nautilus_mm.run_event_state &
|
||||
local pid=$!
|
||||
echo "[smoke] pid=${pid}"
|
||||
sleep "$SESSION_SECS"
|
||||
echo "[smoke] session ${label} stopping pid=${pid}"
|
||||
kill -INT "$pid" 2>/dev/null || true
|
||||
# allow experiment_stop flush
|
||||
local i=0
|
||||
while kill -0 "$pid" 2>/dev/null && [[ $i -lt 30 ]]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[smoke] SIGINT timeout — SIGTERM"
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sleep 3
|
||||
fi
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[smoke] SIGTERM timeout — SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
echo "[smoke] session ${label} stopped"
|
||||
}
|
||||
|
||||
echo "[smoke] RUN_ID=${RUN_ID} log=${LOG_DIR} trading=NO"
|
||||
run_session A
|
||||
echo "[smoke] restart gap 5s"
|
||||
sleep 5
|
||||
run_session B
|
||||
|
||||
echo "[smoke] validating ledger"
|
||||
"$PYTHON" "$ROOT/scripts/validate_event_ledger.py" \
|
||||
--dir "$LOG_DIR" \
|
||||
--run-id "$RUN_ID" \
|
||||
--out "$LOG_DIR/Event_Ledger_Validation.json"
|
||||
echo "[smoke] done"
|
||||
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate MM_EDGE_EXP_002 Immutable Event Ledger.
|
||||
|
||||
Phase 1 smoke: Gates 1–3 plus ledger engineering contract.
|
||||
Gate 4 (predictability) is blocked until fill anchors exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
TRADE_REQUIRED = [
|
||||
"event_type",
|
||||
"exchange_ts_ns",
|
||||
"local_ts_epoch",
|
||||
"local_ts_ns",
|
||||
"trade_side",
|
||||
"trade_qty",
|
||||
"trade_price",
|
||||
"best_bid", # optional on trade; counted separately
|
||||
]
|
||||
TRADE_CORE = [
|
||||
"event_type",
|
||||
"exchange_ts_ns",
|
||||
"local_ts_epoch",
|
||||
"local_ts_ns",
|
||||
"trade_side",
|
||||
"trade_qty",
|
||||
"trade_price",
|
||||
"price",
|
||||
"quantity",
|
||||
"best_bid",
|
||||
"best_ask",
|
||||
"mid",
|
||||
"spread",
|
||||
]
|
||||
BOOK_CORE = [
|
||||
"event_type",
|
||||
"exchange_ts_ns",
|
||||
"local_ts_epoch",
|
||||
"local_ts_ns",
|
||||
"best_bid",
|
||||
"best_ask",
|
||||
"mid",
|
||||
"spread",
|
||||
"bid_depth_1",
|
||||
"ask_depth_1",
|
||||
"bid_depth_5",
|
||||
"ask_depth_5",
|
||||
]
|
||||
BOOK_DELTA_KEYS = [
|
||||
"bid_depth_delta_1",
|
||||
"ask_depth_delta_1",
|
||||
"bid_move",
|
||||
"ask_move",
|
||||
"spread_change",
|
||||
]
|
||||
|
||||
|
||||
def _pctile(xs: list[float], q: float) -> float | None:
|
||||
if not xs:
|
||||
return None
|
||||
ys = sorted(xs)
|
||||
if len(ys) == 1:
|
||||
return ys[0]
|
||||
i = (len(ys) - 1) * q
|
||||
lo = math.floor(i)
|
||||
hi = math.ceil(i)
|
||||
if lo == hi:
|
||||
return ys[lo]
|
||||
return ys[lo] * (hi - i) + ys[hi] * (i - lo)
|
||||
|
||||
|
||||
def _num(v: float | None, digits: int = 3) -> str:
|
||||
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
|
||||
return "n/a"
|
||||
return f"{v:.{digits}f}"
|
||||
|
||||
|
||||
def load_jsonl(log_dir: Path) -> tuple[list[dict[str, Any]], int, int]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
parse_fail = 0
|
||||
empty = 0
|
||||
for f in sorted(log_dir.glob("*.jsonl")):
|
||||
for line in f.open():
|
||||
s = line.strip()
|
||||
if not s:
|
||||
empty += 1
|
||||
continue
|
||||
try:
|
||||
e = json.loads(s)
|
||||
except Exception:
|
||||
parse_fail += 1
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
rows.append(e)
|
||||
else:
|
||||
parse_fail += 1
|
||||
return rows, parse_fail, empty
|
||||
|
||||
|
||||
def _present(ev: dict[str, Any], key: str) -> bool:
|
||||
v = ev.get(key)
|
||||
return v is not None and v != ""
|
||||
|
||||
|
||||
def _hollow_book(ev: dict[str, Any]) -> bool:
|
||||
depths = [
|
||||
ev.get("bid_depth_1"),
|
||||
ev.get("ask_depth_1"),
|
||||
ev.get("bid_depth_5"),
|
||||
ev.get("ask_depth_5"),
|
||||
ev.get("mid"),
|
||||
]
|
||||
nums = []
|
||||
for d in depths:
|
||||
try:
|
||||
nums.append(float(d))
|
||||
except (TypeError, ValueError):
|
||||
nums.append(0.0)
|
||||
return all(abs(x) < 1e-12 for x in nums)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Validate EXP_002 event ledger / smoke contract")
|
||||
ap.add_argument("--dir", default="logs/event_state")
|
||||
ap.add_argument("--out", default="")
|
||||
ap.add_argument("--run-id", default="")
|
||||
ap.add_argument("--sample", type=int, default=200)
|
||||
ap.add_argument("--latency-tolerance-ms", type=float, default=50.0)
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
args = ap.parse_args()
|
||||
|
||||
log_dir = Path(args.dir)
|
||||
rows, parse_fail, empty_lines = load_jsonl(log_dir)
|
||||
if args.run_id:
|
||||
rows = [r for r in rows if r.get("run_id") == args.run_id]
|
||||
|
||||
market = [r for r in rows if r.get("event") == "market_event"]
|
||||
trades = [r for r in market if r.get("event_type") == "aggressive_trade"]
|
||||
books = [r for r in market if r.get("event_type") == "book_update"]
|
||||
starts = [r for r in rows if r.get("event") == "experiment_start"]
|
||||
stops = [r for r in rows if r.get("event") == "experiment_stop"]
|
||||
anchors = [r for r in rows if r.get("event") == "fill_anchor"]
|
||||
|
||||
run_ids = sorted({r.get("run_id") for r in rows if r.get("run_id")})
|
||||
sessions = [r.get("session_id") for r in starts]
|
||||
|
||||
# Duration from first/last local_ts
|
||||
local_epochs = [float(r["local_ts_epoch"]) for r in market if r.get("local_ts_epoch") is not None]
|
||||
duration_s = (max(local_epochs) - min(local_epochs)) if len(local_epochs) >= 2 else 0.0
|
||||
if duration_s <= 0:
|
||||
duration_s = 1.0
|
||||
|
||||
rates = {
|
||||
"aggressive_trade_per_sec": len(trades) / duration_s,
|
||||
"book_update_per_sec": len(books) / duration_s,
|
||||
"total_market_events_per_sec": len(market) / duration_s,
|
||||
"duration_sec": duration_s,
|
||||
}
|
||||
|
||||
# Timestamp quality
|
||||
ex_ok = sum(1 for r in market if r.get("exchange_ts_ns") is not None)
|
||||
loc_ok = sum(1 for r in market if r.get("local_ts_epoch") is not None and r.get("local_ts_ns") is not None)
|
||||
latencies_ms: list[float] = []
|
||||
skew_violations = 0
|
||||
for r in market:
|
||||
ex = r.get("exchange_ts_ns")
|
||||
loc = r.get("local_ts_ns")
|
||||
if ex is None or loc is None:
|
||||
continue
|
||||
lag_ms = (float(loc) - float(ex)) / 1e6
|
||||
latencies_ms.append(lag_ms)
|
||||
if float(ex) > float(loc) + args.latency_tolerance_ms * 1e6:
|
||||
skew_violations += 1
|
||||
|
||||
ts_quality = {
|
||||
"exchange_ts_ns_pct": (ex_ok / len(market)) if market else 0.0,
|
||||
"local_ts_pct": (loc_ok / len(market)) if market else 0.0,
|
||||
"latency_n": len(latencies_ms),
|
||||
"latency_ms_p50": _pctile(latencies_ms, 0.50),
|
||||
"latency_ms_p95": _pctile(latencies_ms, 0.95),
|
||||
"latency_ms_p99": _pctile(latencies_ms, 0.99),
|
||||
"latency_ms_max": max(latencies_ms) if latencies_ms else None,
|
||||
"latency_ms_min": min(latencies_ms) if latencies_ms else None,
|
||||
"exchange_after_local_violations": skew_violations,
|
||||
"tolerance_ms": args.latency_tolerance_ms,
|
||||
}
|
||||
|
||||
# Event order: exchange_ts regression (do not silently sort)
|
||||
regressions = 0
|
||||
max_back_ns = 0
|
||||
prev_ex = None
|
||||
for r in market:
|
||||
ex = r.get("exchange_ts_ns")
|
||||
if ex is None:
|
||||
continue
|
||||
ex = int(ex)
|
||||
if prev_ex is not None and ex < prev_ex:
|
||||
regressions += 1
|
||||
max_back_ns = max(max_back_ns, prev_ex - ex)
|
||||
prev_ex = ex
|
||||
|
||||
# Schema completeness (sample)
|
||||
rng = random.Random(args.seed)
|
||||
n_trade_s = min(args.sample, len(trades))
|
||||
n_book_s = min(args.sample, len(books))
|
||||
trade_sample = rng.sample(trades, n_trade_s) if n_trade_s else []
|
||||
book_sample = rng.sample(books, n_book_s) if n_book_s else []
|
||||
|
||||
def missing_rate(sample: list[dict], keys: list[str]) -> dict[str, float]:
|
||||
if not sample:
|
||||
return {k: 1.0 for k in keys}
|
||||
out = {}
|
||||
for k in keys:
|
||||
miss = sum(1 for e in sample if not _present(e, k))
|
||||
out[k] = miss / len(sample)
|
||||
return out
|
||||
|
||||
trade_missing = missing_rate(trade_sample, TRADE_CORE)
|
||||
book_missing = missing_rate(book_sample, BOOK_CORE)
|
||||
book_delta_key_miss = 0.0
|
||||
if book_sample:
|
||||
book_delta_key_miss = sum(
|
||||
1 for e in book_sample if any(k not in e for k in BOOK_DELTA_KEYS)
|
||||
) / len(book_sample)
|
||||
hollow = sum(1 for e in book_sample if _hollow_book(e))
|
||||
|
||||
# Restart / integrity
|
||||
event_ids = [r.get("event_id") for r in market if r.get("event_id")]
|
||||
dup_ids = [k for k, v in Counter(event_ids).items() if v > 1]
|
||||
|
||||
seq_ok = True
|
||||
seq_notes = []
|
||||
by_session: dict[str, list[int]] = {}
|
||||
for r in rows:
|
||||
sid = r.get("session_id")
|
||||
seq = r.get("event_seq")
|
||||
if sid is None or seq is None:
|
||||
continue
|
||||
by_session.setdefault(str(sid), []).append(int(seq))
|
||||
for sid, seqs in by_session.items():
|
||||
if seqs != list(range(1, len(seqs) + 1)) and seqs != sorted(seqs):
|
||||
# allow gaps only if we filtered; within session expect 1..n
|
||||
expected = list(range(min(seqs), max(seqs) + 1))
|
||||
if seqs != expected:
|
||||
seq_ok = False
|
||||
seq_notes.append(f"{sid}: not contiguous {seqs[:5]}...{seqs[-3:]}")
|
||||
if seqs and seqs[0] != 1:
|
||||
seq_notes.append(f"{sid}: seq starts at {seqs[0]} (expected 1 after restart)")
|
||||
|
||||
seq_reset_expected = len(sessions) >= 2 and all(
|
||||
(by_session.get(str(s), [None])[0] == 1) for s in sessions if s
|
||||
)
|
||||
|
||||
# Gates
|
||||
gate1_pass: bool | None
|
||||
if anchors:
|
||||
reconstruct_fail = 0
|
||||
for anc in anchors:
|
||||
fill_ts = float(anc["fill_ts_epoch"])
|
||||
start = float(anc.get("window_start_epoch", fill_ts - 5.0))
|
||||
cutoff = float(anc.get("feature_cutoff_epoch", fill_ts - 0.25))
|
||||
window = []
|
||||
for r in market:
|
||||
ex = r.get("exchange_ts_ns")
|
||||
ts = float(ex) / 1e9 if ex is not None else r.get("local_ts_epoch")
|
||||
if ts is None:
|
||||
continue
|
||||
if start <= float(ts) < cutoff:
|
||||
window.append(r)
|
||||
if not window:
|
||||
reconstruct_fail += 1
|
||||
gate1_pass = reconstruct_fail == 0
|
||||
gate1_status = "PASS" if gate1_pass else "FAIL"
|
||||
else:
|
||||
# Phase 1: stream completeness stands in for fill reconstruction
|
||||
stream_ok = parse_fail == 0 and len(market) > 0 and loc_ok == len(market)
|
||||
gate1_pass = stream_ok
|
||||
gate1_status = (
|
||||
"PASS (Phase 1 stream completeness; no fill_anchor — expected)"
|
||||
if stream_ok
|
||||
else "FAIL (stream incomplete)"
|
||||
)
|
||||
|
||||
gate2_ok = (
|
||||
ts_quality["exchange_ts_ns_pct"] >= 0.99
|
||||
and ts_quality["local_ts_pct"] >= 0.99
|
||||
and skew_violations == 0
|
||||
)
|
||||
gate2_status = "PASS" if gate2_ok else "FAIL"
|
||||
|
||||
schema_ok = (
|
||||
all(v == 0.0 for v in trade_missing.values())
|
||||
and all(v == 0.0 for v in book_missing.values())
|
||||
and book_delta_key_miss == 0.0
|
||||
and hollow == 0
|
||||
and len(trades) > 0
|
||||
and len(books) > 0
|
||||
)
|
||||
gate3_ok = schema_ok and ts_quality["exchange_ts_ns_pct"] >= 0.99
|
||||
gate3_status = "PASS" if gate3_ok else "FAIL"
|
||||
|
||||
restart_ok = (
|
||||
parse_fail == 0
|
||||
and len(dup_ids) == 0
|
||||
and len(starts) >= 1
|
||||
and (len(starts) == 1 or (len(stops) >= len(starts) - 1 and seq_reset_expected))
|
||||
)
|
||||
|
||||
integrity = {
|
||||
"parse_fail_lines": parse_fail,
|
||||
"empty_lines": empty_lines,
|
||||
"duplicate_event_ids": len(dup_ids),
|
||||
"experiment_start_count": len(starts),
|
||||
"experiment_stop_count": len(stops),
|
||||
"sessions": sessions,
|
||||
"seq_contiguous_ok": seq_ok,
|
||||
"seq_reset_expected": seq_reset_expected,
|
||||
"seq_notes": seq_notes[:8],
|
||||
"restart_contract": "PASS" if restart_ok else "FAIL",
|
||||
}
|
||||
|
||||
run_id = args.run_id or (run_ids[0] if len(run_ids) == 1 else ",".join(run_ids) or "UNSET")
|
||||
start0 = starts[0] if starts else {}
|
||||
manifest = {
|
||||
"run_id": run_id,
|
||||
"start_ts": start0.get("local_ts"),
|
||||
"end_ts": stops[-1].get("local_ts") if stops else (rows[-1].get("local_ts") if rows else None),
|
||||
"host": start0.get("host"),
|
||||
"commit": start0.get("commit"),
|
||||
"config_hash": start0.get("config_hash"),
|
||||
"schema_version": start0.get("schema_version"),
|
||||
"event_count": len(rows),
|
||||
"trade_event_count": len(trades),
|
||||
"book_event_count": len(books),
|
||||
"session_count": len(sessions),
|
||||
}
|
||||
|
||||
report = {
|
||||
"experiment_id": start0.get("experiment_id", "MM_EDGE_EXP_002"),
|
||||
"run_id": run_id,
|
||||
"purpose": "ledger smoke / Gates 1-3",
|
||||
"gate4_predictability": "BLOCKED",
|
||||
"gates": {
|
||||
"gate1_event_completeness": gate1_status,
|
||||
"gate2_temporal_integrity": gate2_status,
|
||||
"gate3_event_coverage": gate3_status,
|
||||
},
|
||||
"manifest": manifest,
|
||||
"rates": rates,
|
||||
"timestamp_quality": ts_quality,
|
||||
"order": {
|
||||
"exchange_ts_regressions": regressions,
|
||||
"max_regression_ns": max_back_ns,
|
||||
"max_regression_ms": max_back_ns / 1e6 if regressions else 0.0,
|
||||
"note": "regressions recorded, not silently sorted",
|
||||
},
|
||||
"schema": {
|
||||
"trade_sample_n": n_trade_s,
|
||||
"book_sample_n": n_book_s,
|
||||
"trade_missing_rate": trade_missing,
|
||||
"book_missing_rate": book_missing,
|
||||
"hollow_book_in_sample": hollow,
|
||||
},
|
||||
"integrity": integrity,
|
||||
"counts": {
|
||||
"total_rows": len(rows),
|
||||
"market_events": len(market),
|
||||
"aggressive_trades": len(trades),
|
||||
"book_updates": len(books),
|
||||
"fill_anchors": len(anchors),
|
||||
},
|
||||
}
|
||||
|
||||
lines = [
|
||||
"=" * 68,
|
||||
"MM_EDGE_EXP_002 Ledger Smoke / Gates 1–3",
|
||||
"=" * 68,
|
||||
f"run_id: {run_id}",
|
||||
f"sessions: {len(sessions)} {sessions}",
|
||||
f"host/commit:{start0.get('host')} / {str(start0.get('commit') or '')[:12]}",
|
||||
f"config_hash:{start0.get('config_hash')}",
|
||||
f"schema: {start0.get('schema_version')}",
|
||||
"",
|
||||
"Gate 1 Event Completeness: " + gate1_status,
|
||||
"Gate 2 Temporal Integrity: " + gate2_status,
|
||||
"Gate 3 Event Coverage: " + gate3_status,
|
||||
"Gate 4 Predictability: BLOCKED",
|
||||
"",
|
||||
"1. Event write rates",
|
||||
"-" * 40,
|
||||
f"duration_sec: {_num(duration_s, 1)}",
|
||||
f"aggressive_trade / sec: {_num(rates['aggressive_trade_per_sec'], 3)}",
|
||||
f"book_update / sec: {_num(rates['book_update_per_sec'], 3)}",
|
||||
f"total market events / sec: {_num(rates['total_market_events_per_sec'], 3)}",
|
||||
f"counts: trades={len(trades)} books={len(books)} total={len(market)}",
|
||||
"",
|
||||
"2. Timestamp quality",
|
||||
"-" * 40,
|
||||
f"exchange_ts_ns != null: {ts_quality['exchange_ts_ns_pct']*100:.2f}%",
|
||||
f"local_ts_ns != null: {ts_quality['local_ts_pct']*100:.2f}%",
|
||||
f"exchange > local+tol: {skew_violations} (tol={args.latency_tolerance_ms}ms)",
|
||||
f"local-exchange lag ms: p50={_num(ts_quality['latency_ms_p50'])} "
|
||||
f"p95={_num(ts_quality['latency_ms_p95'])} p99={_num(ts_quality['latency_ms_p99'])} "
|
||||
f"max={_num(ts_quality['latency_ms_max'])}",
|
||||
"",
|
||||
"3. Event order (exchange_ts_ns regression, not sorted)",
|
||||
"-" * 40,
|
||||
f"regressions: {regressions} max_back_ms={_num(max_back_ns/1e6 if regressions else 0.0)}",
|
||||
"",
|
||||
"4. Raw event completeness (sample)",
|
||||
"-" * 40,
|
||||
f"trade sample={n_trade_s} missing={trade_missing}",
|
||||
f"book sample={n_book_s} missing={book_missing}",
|
||||
f"hollow book_update (all depth/mid empty): {hollow}",
|
||||
"",
|
||||
"5. Restart / immutable integrity",
|
||||
"-" * 40,
|
||||
f"parse_fail_lines={parse_fail} empty_lines={empty_lines}",
|
||||
f"duplicate_event_ids={len(dup_ids)}",
|
||||
f"start={len(starts)} stop={len(stops)} seq_ok={seq_ok} seq_reset_expected={seq_reset_expected}",
|
||||
f"restart_contract={integrity['restart_contract']}",
|
||||
"",
|
||||
"Gate 4 remains BLOCKED until fill_anchor exists. Do not resume trading.",
|
||||
"=" * 68,
|
||||
]
|
||||
text = "\n".join(lines) + "\n"
|
||||
print(text)
|
||||
|
||||
out_json = Path(args.out) if args.out else log_dir / "Event_Ledger_Validation.json"
|
||||
out_txt = out_json.with_suffix(".txt")
|
||||
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_json.write_text(json.dumps(report, indent=2, default=str) + "\n", encoding="utf-8")
|
||||
out_txt.write_text(text, encoding="utf-8")
|
||||
(log_dir / f"{run_id.replace('/', '_')}.manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, default=str) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
ok = gate1_pass is not False and gate2_ok and gate3_ok and restart_ok and parse_fail == 0
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user