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:
jackyu66git
2026-09-10 16:53:22 +08:00
co-authored by Cursor
commit e2fbe1c2b3
49 changed files with 9633 additions and 0 deletions
+20
View File
@@ -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).
========================================================================
+11
View File
@@ -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}"
+978
View File
@@ -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 (fillmarket) ≤ 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()
+95
View File
@@ -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"
+533
View File
@@ -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())
+81
View File
@@ -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())
+64
View File
@@ -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())
+53
View File
@@ -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
+25
View File
@@ -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}"
+402
View File
@@ -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())
+594
View File
@@ -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())
+260
View File
@@ -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())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
CCXT 轻量 L2 录音机(不依赖 Nautilus
用途:在 Nautilus 节点未就绪时,先用代理拉 Binance USDT-M 盘口 + trades
写入与 Maker Edge 相同的 jsonl schemabook 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()
+27
View File
@@ -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
+34
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# MM_EDGE_EXP_002 smoke test — 1015 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"
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""
Validate MM_EDGE_EXP_002 Immutable Event Ledger.
Phase 1 smoke: Gates 13 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 13",
"=" * 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())