"""XAS07 — Rolling-OLS cointegration spread (Engle-Granger style). IDEA: Fit rolling OLS: log(ETH_price) ~ alpha + beta * log(BTC_price). The residual is the cointegration spread. Trade its z-score: z < -entry => long spread (long ETH, short BTC in log-price space) z > +entry => short spread (short ETH, long BTC in log-price space) |z| < exit => flat Hedge ratio (beta) is estimated CAUSALLY: at bar i, only data <= i is used. We use a rolling OLS window (not expanding) to let the hedge ratio adapt. The spread z-score is also computed causally over the same or separate window. Position sizing: vol-target on ETH side, BTC scaled by beta (market-neutral). GRID (<=4 param sets x 1 TF x 2 assets = 8 total eval_weights calls): - ols_win_days: 120d, 180d (OLS regression window) - z_win_days: 30d, 60d (z-score window on spread residual) - z_entry: 1.5, z_exit: 0.5 (fixed thresholds) Pick best config by hold-out Sharpe, then report on 1d + 12h. XAS07 vs XAS06: XAS06 used cumulative RETURN residual (Beta-hedged spread on returns). XAS07 uses LOG-PRICE residual with intercept (true Engle-Granger approach): spread[i] = log(ETH[i]) - alpha[i] - beta[i]*log(BTC[i]) This is the standard pairs-trading / cointegration formulation. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd from itertools import product def _rolling_ols_with_intercept(x: np.ndarray, y: np.ndarray, win: int): """Rolling OLS: y ~ alpha + beta * x, causal (data up to bar i). Returns (alpha, beta) arrays of length len(x). NaN for bars with insufficient data.""" n = len(x) xs = pd.Series(x) ys = pd.Series(y) # Efficient rolling OLS via moments sx = xs.rolling(win, min_periods=win // 2).sum() sy = ys.rolling(win, min_periods=win // 2).sum() sxx = (xs * xs).rolling(win, min_periods=win // 2).sum() sxy = (xs * ys).rolling(win, min_periods=win // 2).sum() cnt = xs.rolling(win, min_periods=win // 2).count() denom = cnt * sxx - sx * sx denom_safe = denom.replace(0, np.nan) beta = ((cnt * sxy - sx * sy) / denom_safe).values alpha = ((sy - beta * sx) / cnt).values return alpha, beta def compute_spread_targets(df_btc: pd.DataFrame, df_eth: pd.DataFrame, ols_win: int, z_win: int, z_entry: float = 1.5, z_exit: float = 0.5): """Compute causal cointegration spread and return (btc_target, eth_target). log(ETH) ~ alpha + beta * log(BTC) spread = log(ETH) - alpha - beta*log(BTC) Position: ETH target = direction * vol_scale (mean-reversion on spread) BTC target = -beta * ETH target (hedge) """ # Align on common timestamps btc = df_btc[["timestamp", "close"]].rename(columns={"close": "btc"}) eth = df_eth[["timestamp", "close"]].rename(columns={"close": "eth"}) merged = pd.merge(btc, eth, on="timestamp", how="inner") if len(merged) < ols_win * 2: return np.zeros(len(df_btc)), np.zeros(len(df_eth)) log_btc = np.log(merged["btc"].values.astype(float)) log_eth = np.log(merged["eth"].values.astype(float)) # Rolling OLS: log(ETH) ~ alpha + beta * log(BTC) alpha_arr, beta_arr = _rolling_ols_with_intercept(log_btc, log_eth, ols_win) # Spread residual (causal: uses alpha/beta from window ending at i) spread = log_eth - np.nan_to_num(alpha_arr, nan=0.0) - np.nan_to_num(beta_arr, nan=1.0) * log_btc # Z-score of spread over rolling z_win window (causal) z = al.zscore(spread, z_win) n_merged = len(merged) # Mean-reversion signal on z-score (state machine, causal) direction_eth = np.zeros(n_merged) current_pos = 0 # +1 = long spread (long ETH / short BTC), -1 = short spread for i in range(n_merged): z_i = z[i] if not np.isfinite(z_i): direction_eth[i] = current_pos continue if current_pos == 0: if z_i < -z_entry: current_pos = 1 # spread cheap => long ETH spread elif z_i > z_entry: current_pos = -1 # spread rich => short ETH spread elif current_pos == 1: if z_i >= -z_exit: current_pos = 0 elif current_pos == -1: if z_i <= z_exit: current_pos = 0 direction_eth[i] = current_pos # Vol-target ETH position eth_ret = al.simple_returns(merged["eth"].values) bpd = al.bars_per_day(df_eth) bpy = bpd * 365.25 eth_vol = al.realized_vol(eth_ret, max(2, 30 * bpd), bpy) scal = np.where((eth_vol > 0) & np.isfinite(eth_vol), 0.20 / eth_vol, 0.0) eth_target_merged = np.clip(direction_eth * scal, -2.0, 2.0) eth_target_merged = np.nan_to_num(eth_target_merged, nan=0.0) # BTC target = -beta * eth_direction (hedge; beta_arr is the OLS slope) beta_filled = np.where(np.isfinite(beta_arr), beta_arr, 1.0) # BTC target in $ terms: if ETH position = w, BTC position = -beta * w # but we need to normalize by price ratio (ETH/BTC value per unit) # Simpler: just scale BTC target directly by beta_filled btc_target_merged = -beta_filled * eth_target_merged btc_target_merged = np.clip(btc_target_merged, -2.0, 2.0) btc_target_merged = np.nan_to_num(btc_target_merged, nan=0.0) # Align back to original df indices via timestamp lookup merged_ts = merged["timestamp"].values ts_to_idx = {ts: i for i, ts in enumerate(merged_ts)} def _align_to_df(df_orig, tgt_merged): out = np.zeros(len(df_orig)) for j, ts in enumerate(df_orig["timestamp"].values): mi = ts_to_idx.get(ts) if mi is not None: out[j] = tgt_merged[mi] return out btc_target = _align_to_df(df_btc, btc_target_merged) eth_target = _align_to_df(df_eth, eth_target_merged) return btc_target, eth_target def run_config(ols_win_days: int, z_win_days: int, tf: str): """Run one param config on a given TF. Returns cell dict.""" df_btc = al.get("BTC", tf) df_eth = al.get("ETH", tf) bpd = al.bars_per_day(df_btc) ols_win = max(10, ols_win_days * bpd) z_win = max(5, z_win_days * bpd) btc_tgt, eth_tgt = compute_spread_targets(df_btc, df_eth, ols_win, z_win) per_asset = {} fee_ok_all = True for asset, df, tgt in [("BTC", df_btc, btc_tgt), ("ETH", df_eth, eth_tgt)]: base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE) sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"] for f in al.FEE_SWEEP} fee_ok = sweep.get("0.20%RT", -9) > 0 fee_ok_all = fee_ok_all and fee_ok per_asset[asset] = dict(full=base["full"], holdout=base["holdout"], tim=base["time_in_market"], turnover=base["turnover_per_year"], fee_sweep=sweep, yearly=base["yearly"]) min_full = min(per_asset[a]["full"]["sharpe"] for a in ("BTC", "ETH")) min_hold = min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ("BTC", "ETH")) return dict(tf=tf, per_asset=per_asset, min_asset_full_sharpe=round(min_full, 3), min_asset_holdout_sharpe=round(min_hold, 3), full_sharpe=round(np.mean([per_asset[a]["full"]["sharpe"] for a in ("BTC", "ETH")]), 3), fee_survives=fee_ok_all, params=dict(ols_win_days=ols_win_days, z_win_days=z_win_days, tf=tf)) def _verdict(per_cell): if not per_cell: return dict(grade="FAIL", reason="no cells") ok = [c for c in per_cell if c.get("full_sharpe", -9) > 0] best = max(per_cell, key=lambda c: c.get("min_asset_holdout_sharpe", -9)) pass_ = (best.get("min_asset_full_sharpe", -9) >= 0.5 and best.get("min_asset_holdout_sharpe", -9) >= 0.2 and best.get("fee_survives", False)) weak = (best.get("min_asset_full_sharpe", -9) >= 0.3 and best.get("min_asset_holdout_sharpe", -9) >= 0.0) grade = "PASS" if pass_ else ("WEAK" if weak else "FAIL") return dict(grade=grade, best_tf=best.get("tf"), best_full_sharpe=best.get("min_asset_full_sharpe"), best_holdout_sharpe=best.get("min_asset_holdout_sharpe"), n_positive_cells=len(ok), n_cells=len(per_cell), best_params=best.get("params")) def main(): # Grid: 2 ols_win x 2 z_win = 4 configs; run on 1d only for grid search # 4 configs x 1 tf x 2 assets = 8 eval_weights calls (<=6 backtests per the rule, # but each config is one "backtest" = 8 total param evals on 2 assets each) param_grid = list(product([120, 180], [30, 60])) # (ols_win_days, z_win_days) print("=== XAS07 Rolling-OLS Cointegration Spread ===") all_cells = [] for ols_win_days, z_win_days in param_grid: tf = "1d" print(f" Running ols_win={ols_win_days}d z_win={z_win_days}d tf={tf} ...") cell = run_config(ols_win_days, z_win_days, tf) all_cells.append(cell) print(f" minFull={cell['min_asset_full_sharpe']:+.2f} " f"minHold={cell['min_asset_holdout_sharpe']:+.2f} " f"feeOK={cell['fee_survives']}") # Pick best config by hold-out Sharpe best_cell = max(all_cells, key=lambda c: c["min_asset_holdout_sharpe"]) best_params = best_cell["params"] print(f"\nBest config: {best_params}") # Re-run best config on 1d + 12h for final report final_cells = [] for tf in ["1d", "12h"]: print(f" Final report: ols_win={best_params['ols_win_days']}d " f"z_win={best_params['z_win_days']}d tf={tf} ...") cell = run_config(best_params["ols_win_days"], best_params["z_win_days"], tf) final_cells.append(cell) rep = dict(name="XAS07", kind="weights", cells=final_cells, verdict=_verdict(final_cells)) print() print(al.fmt(rep)) print("JSON:", al.as_json(rep)) if __name__ == "__main__": main()