171 lines
6.6 KiB
Python
171 lines
6.6 KiB
Python
"""_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()
|