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

223 lines
8.3 KiB
Python

"""_decile_backtest.py
Decile-portfolio analysis on raw single-variable signals.
For each candidate variable and each horizon:
- bucket minutes into 10 deciles by variable value
- compute mean |fwd_return| per decile, also mean signed return, hit rate
- report top vs bottom decile ratio and t-stat
- report a simple "trade top decile" backtest: take a unit-vol position
every minute that is in the top decile, hold for h minutes, count
cost-adjusted return
NO LATTICE. Raw HL minute data only.
"""
from __future__ import annotations
import glob, sys
from pathlib import Path
import numpy as np
import pandas as pd
DATA_GLOB = sys.argv[1] if len(sys.argv) > 1 else "/mnt/d/PaperTrader/research/hl_data/minutes/202603*"
VARIABLES = [
"trade_count",
"wallet_entropy",
"taker_buy_usd",
"taker_sell_usd",
"vwap_drift", # direction signal — handled differently
]
HORIZONS = [5, 15, 30, 60, 120]
# round-trip cost in basis points (entry + exit + slippage estimate)
COST_BPS_PER_TRADE = 5.0 # 0.05% per side = 10bps round trip; halved here
COST_ROUND_TRIP = COST_BPS_PER_TRADE * 2 / 10000.0 # 0.001 = 0.1%
def load_btc() -> pd.DataFrame:
dfs = []
for d in sorted(glob.glob(DATA_GLOB)):
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)
df = df.sort_values("minute").drop_duplicates("minute").reset_index(drop=True)
return df
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 decile_table(df: pd.DataFrame, var: str, h: int) -> pd.DataFrame:
x = df[var].astype(float)
y_abs = df[f"abs_fwd_{h}"]
y_sgn = df[f"fwd_{h}"]
mask = x.notna() & y_abs.notna() & np.isfinite(x) & np.isfinite(y_abs)
sub = pd.DataFrame({"x": x[mask].values, "abs": y_abs[mask].values, "sgn": y_sgn[mask].values})
# rank-based decile (handles ties + sparse values)
sub["dec"] = pd.qcut(sub["x"].rank(method="first"), 10, labels=False)
g = sub.groupby("dec").agg(
n=("x", "size"),
x_mean=("x", "mean"),
abs_mean=("abs", "mean"),
abs_med=("abs", "median"),
sgn_mean=("sgn", "mean"),
)
return g
def t_stat_top_vs_bottom(df: pd.DataFrame, var: str, h: int) -> tuple[float, float, float, int, int]:
"""Returns (top_mean_abs, bot_mean_abs, t_stat, n_top, n_bot)."""
x = df[var].astype(float)
y_abs = df[f"abs_fwd_{h}"]
mask = x.notna() & y_abs.notna() & np.isfinite(x) & np.isfinite(y_abs)
xv = x[mask].values
yv = y_abs[mask].values
# top/bottom decile cutoffs by rank
n = len(xv)
order = np.argsort(xv, kind="stable")
cutoff = n // 10
bot_idx = order[:cutoff]
top_idx = order[-cutoff:]
bot_y = yv[bot_idx]
top_y = yv[top_idx]
top_mean = top_y.mean()
bot_mean = bot_y.mean()
pooled_var = (top_y.var(ddof=1) / len(top_y)) + (bot_y.var(ddof=1) / len(bot_y))
t = (top_mean - bot_mean) / np.sqrt(pooled_var) if pooled_var > 0 else 0.0
return top_mean, bot_mean, t, len(top_y), len(bot_y)
def vol_timing_backtest(df: pd.DataFrame, var: str, h: int) -> dict:
"""Strategy: when variable is in top decile, take a long-straddle-equivalent
position. PnL proxy = |fwd_return| - cost. (Cannot trade directionless without
a direction signal, but |return| - cost > 0 means a straddle would be net positive.)"""
x = df[var].astype(float)
y_abs = df[f"abs_fwd_{h}"]
mask = x.notna() & y_abs.notna() & np.isfinite(x) & np.isfinite(y_abs)
xv = x[mask].values
yv = y_abs[mask].values
n = len(xv)
threshold = np.quantile(xv, 0.9)
sig = xv >= threshold
trade_returns = yv[sig] - COST_ROUND_TRIP
return {
"n_signals": int(sig.sum()),
"fire_rate_pct": float(sig.mean() * 100),
"mean_abs_ret_top10pct": float(yv[sig].mean()),
"mean_abs_ret_all": float(yv.mean()),
"ratio_top_vs_all": float(yv[sig].mean() / yv.mean()) if yv.mean() > 0 else float("nan"),
"net_after_cost_per_trade_bps": float(trade_returns.mean() * 10000),
"sharpe_naive": float(trade_returns.mean() / trade_returns.std()) if trade_returns.std() > 0 else 0.0,
}
def directional_backtest(df: pd.DataFrame, var: str, h: int, low_q: float = 0.1, high_q: float = 0.9) -> dict:
"""Directional signal: long top decile, short bottom decile. Sign of fwd_return matters."""
x = df[var].astype(float)
y_sgn = df[f"fwd_{h}"]
mask = x.notna() & y_sgn.notna() & np.isfinite(x) & np.isfinite(y_sgn)
xv = x[mask].values
yv = y_sgn[mask].values
lo = np.quantile(xv, low_q)
hi = np.quantile(xv, high_q)
long_mask = xv >= hi
short_mask = xv <= lo
long_ret = yv[long_mask].mean() if long_mask.sum() else 0.0
short_ret = -yv[short_mask].mean() if short_mask.sum() else 0.0
n_trades = int(long_mask.sum() + short_mask.sum())
avg_gross = (long_ret + short_ret) / 2
avg_net = avg_gross - COST_ROUND_TRIP
return {
"n_trades": n_trades,
"long_mean_ret_bps": float(long_ret * 10000),
"short_mean_ret_bps": float(short_ret * 10000),
"avg_gross_bps": float(avg_gross * 10000),
"avg_net_bps_after_cost": float(avg_net * 10000),
"long_hit_rate_pct": float((yv[long_mask] > 0).mean() * 100) if long_mask.sum() else 0.0,
"short_hit_rate_pct": float((yv[short_mask] < 0).mean() * 100) if short_mask.sum() else 0.0,
}
def main():
print("loading BTC minutes from", DATA_GLOB)
df = load_btc()
print(f"loaded {len(df)} unique minutes")
df = add_forward_returns(df)
pd.set_option("display.width", 200)
pd.set_option("display.float_format", "{:+.4f}".format)
# === MAGNITUDE / VOL-TIMING ANALYSIS ===
print("\n" + "="*80)
print("VOL-TIMING DECILE ANALYSIS (target = |fwd_return|)")
print("="*80)
print(f"\nround-trip cost assumed = {COST_ROUND_TRIP*10000:.1f}bps")
for var in ["trade_count", "wallet_entropy", "taker_buy_usd", "taker_sell_usd"]:
print(f"\n--- {var} ---")
for h in HORIZONS:
tbl = decile_table(df, var, h)
top_mean, bot_mean, t, n_top, n_bot = t_stat_top_vs_bottom(df, var, h)
bt = vol_timing_backtest(df, var, h)
print(f" h={h:>4}min "
f"top_dec_|ret|={top_mean*10000:7.1f}bps "
f"bot_dec_|ret|={bot_mean*10000:6.1f}bps "
f"ratio={top_mean/bot_mean if bot_mean>0 else 0:5.2f}x "
f"t={t:6.2f} "
f"net_after_cost={bt['net_after_cost_per_trade_bps']:+6.1f}bps "
f"sharpe_naive={bt['sharpe_naive']:+.3f}")
if h == 60:
print(f" decile table (h=60):")
for d, row in tbl.iterrows():
print(f" dec {int(d):2d} n={int(row['n']):5d} "
f"x_mean={row['x_mean']:+12.3g} "
f"|ret|_mean={row['abs_mean']*10000:6.1f}bps "
f"|ret|_med={row['abs_med']*10000:6.1f}bps "
f"signed_ret={row['sgn_mean']*10000:+6.1f}bps")
# === DIRECTIONAL ANALYSIS ===
print("\n" + "="*80)
print("DIRECTIONAL DECILE ANALYSIS (target = signed fwd_return, long top / short bottom)")
print("="*80)
print(f"\nround-trip cost assumed = {COST_ROUND_TRIP*10000:.1f}bps")
for var in ["vwap_drift", "trade_count", "wallet_entropy"]:
print(f"\n--- {var} ---")
for h in HORIZONS:
d = directional_backtest(df, var, h)
print(f" h={h:>4}min "
f"long_ret={d['long_mean_ret_bps']:+6.1f}bps "
f"(hit {d['long_hit_rate_pct']:4.1f}%) "
f"short_ret={d['short_mean_ret_bps']:+6.1f}bps "
f"(hit {d['short_hit_rate_pct']:4.1f}%) "
f"gross={d['avg_gross_bps']:+6.1f}bps "
f"NET={d['avg_net_bps_after_cost']:+6.1f}bps "
f"n={d['n_trades']:>5}")
if __name__ == "__main__":
main()