223 lines
8.3 KiB
Python
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()
|