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