Files
resonance-engine/_composite_band.py
T
2026-06-07 12:34:31 +07:00

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()