"""_composite_band.py Composite rank-average vol-timing signal, with band-geometry analysis. Construction (per-window, no leakage): - For each variable in VOL_VARS, compute rank within the current sample (0..1 normalised rank). - Composite = mean of per-variable ranks. Re-rank into 10 deciles. Per-window means: when called on March data, ranks are computed within March. When called on April data, ranks are computed within April. There is no cross-window leakage. Report: 1. Full 10-decile ladder: n, composite_rank_mean, |ret|_mean, |ret|_median, signed_ret_mean — for both March (IS) and April (OOS). 2. Inflection: per-decile delta vs the global mean — where does the ladder pull away from the middle? 3. Stability: per-decile |ret| comparison March vs April. """ from __future__ import annotations import glob, sys from pathlib import Path import numpy as np import pandas as pd DATA_ROOT = "/mnt/d/PaperTrader/research/hl_data/minutes" VOL_VARS = ["trade_count", "wallet_entropy", "taker_buy_usd", "taker_sell_usd"] HORIZONS = [15, 60, 120] N_DEC = 10 def load_btc(glob_pattern: str) -> pd.DataFrame: dfs = [] for d in sorted(glob.glob(glob_pattern)): for f in sorted(glob.glob(f"{d}/*.parquet")): try: df = pd.read_parquet(f) except Exception: continue if "coin" in df.columns: df = df[df["coin"] == "BTC"] if len(df): dfs.append(df) df = pd.concat(dfs, ignore_index=True) return df.sort_values("minute").drop_duplicates("minute").reset_index(drop=True) def add_forward_returns(df: pd.DataFrame) -> pd.DataFrame: p = df["mid_price"].astype(float).values for h in HORIZONS: future = pd.Series(p).shift(-h).values fwd = (future - p) / p df[f"fwd_{h}"] = fwd df[f"abs_fwd_{h}"] = np.abs(fwd) return df def build_composite(df: pd.DataFrame) -> pd.Series: """Composite = mean of normalised ranks of VOL_VARS, computed within this sample only. Returns a Series aligned to df.index.""" rank_cols = [] for v in VOL_VARS: s = df[v].astype(float) # rank with pct=True gives 0..1; method='average' handles ties cleanly r = s.rank(method="average", pct=True, na_option="keep") rank_cols.append(r) composite = pd.concat(rank_cols, axis=1).mean(axis=1) return composite def decile_ladder(df: pd.DataFrame, composite: pd.Series, h: int) -> pd.DataFrame: y_abs = df[f"abs_fwd_{h}"] y_sgn = df[f"fwd_{h}"] mask = composite.notna() & y_abs.notna() & np.isfinite(composite) & np.isfinite(y_abs) sub = pd.DataFrame({ "comp": composite[mask].values, "abs": y_abs[mask].values, "sgn": y_sgn[mask].values, }) sub["dec"] = pd.qcut(sub["comp"].rank(method="first"), N_DEC, labels=False) g = sub.groupby("dec").agg( n=("comp", "size"), comp_mean=("comp", "mean"), abs_mean=("abs", "mean"), abs_med=("abs", "median"), sgn_mean=("sgn", "mean"), ) g["abs_mean_bps"] = g["abs_mean"] * 10000 g["abs_med_bps"] = g["abs_med"] * 10000 g["sgn_mean_bps"] = g["sgn_mean"] * 10000 return g def inflection_analysis(g: pd.DataFrame) -> pd.DataFrame: """Per-decile |ret| vs the global mean. Identifies where the ladder pulls away (positive or negative) from the middle.""" global_mean = g["abs_mean_bps"].mean() g = g.copy() g["delta_vs_global_bps"] = g["abs_mean_bps"] - global_mean g["pct_vs_global"] = (g["abs_mean_bps"] / global_mean - 1) * 100 # detect knee: largest decile-to-decile jump in |ret| diffs = g["abs_mean_bps"].diff() g["jump_from_prev_bps"] = diffs return g def main(): pd.set_option("display.width", 220) pd.set_option("display.max_rows", None) pd.set_option("display.float_format", "{:+.3f}".format) march = add_forward_returns(load_btc(f"{DATA_ROOT}/202603*")) april = add_forward_returns(load_btc(f"{DATA_ROOT}/202604*")) print(f"loaded March={len(march)} April={len(april)} BTC minutes") comp_m = build_composite(march) comp_a = build_composite(april) for h in HORIZONS: print("\n" + "="*92) print(f"COMPOSITE BAND LADDER h={h}min (vars={VOL_VARS}, rank-avg, in-window only)") print("="*92) gm = inflection_analysis(decile_ladder(march, comp_m, h)) ga = inflection_analysis(decile_ladder(april, comp_a, h)) print(f"\n--- MARCH (IS) ---") print(gm[["n", "comp_mean", "abs_mean_bps", "abs_med_bps", "sgn_mean_bps", "delta_vs_global_bps", "pct_vs_global", "jump_from_prev_bps"]].to_string()) print(f"\n--- APRIL (OOS) ---") print(ga[["n", "comp_mean", "abs_mean_bps", "abs_med_bps", "sgn_mean_bps", "delta_vs_global_bps", "pct_vs_global", "jump_from_prev_bps"]].to_string()) # Side-by-side stability check comp = pd.DataFrame({ "march_|ret|_bps": gm["abs_mean_bps"], "april_|ret|_bps": ga["abs_mean_bps"], "march_pct_vs_global": gm["pct_vs_global"], "april_pct_vs_global": ga["pct_vs_global"], }) comp["bps_diff_AvsM"] = comp["april_|ret|_bps"] - comp["march_|ret|_bps"] comp["pct_shape_diff"] = comp["april_pct_vs_global"] - comp["march_pct_vs_global"] print(f"\n--- STABILITY (per-decile shape: March vs April) ---") print(comp.to_string()) # rank correlation between months on the per-decile |ret| ordering rho = gm["abs_mean_bps"].rank().corr(ga["abs_mean_bps"].rank(), method="spearman") print(f"\nSpearman rank correlation of decile |ret| (March vs April): {rho:+.4f}") # ratio metrics top_m, bot_m = gm["abs_mean_bps"].iloc[-1], gm["abs_mean_bps"].iloc[0] top_a, bot_a = ga["abs_mean_bps"].iloc[-1], ga["abs_mean_bps"].iloc[0] print(f"top/bot ratio March={top_m/bot_m:.2f}x April={top_a/bot_a:.2f}x") # vs best single-variable benchmark (trade_count) # for reference: trade_count March top/bot @ h=60 was 2.27x, April 2.42x if h == 60: print(f"\nBenchmark to beat (trade_count alone @ h=60): March 2.27x April 2.42x") comp_m_ratio = top_m / bot_m comp_a_ratio = top_a / bot_a verdict_m = "BEATS" if comp_m_ratio > 2.27 else "does not beat" verdict_a = "BEATS" if comp_a_ratio > 2.42 else "does not beat" print(f"Composite vs trade_count: March {comp_m_ratio:.2f}x ({verdict_m}) " f"April {comp_a_ratio:.2f}x ({verdict_a})") if __name__ == "__main__": main()