"""STA08 — AR(1) residual reversion. IDEA: Fit an expanding-window AR(1) on log returns. The AR(1) residual is r[t] - (a0 + a1 * r[t-1]), where a0 and a1 are estimated causally from all data up to t-1. Trade the mean-reversion of the residual: if residual is positive (return exceeded AR(1) prediction) we expect reversion → short; if negative → long. Signal: z-score the residual over a rolling window, take the negative of it as the continuous position (mean-reversion), then vol-target it. Grid: 2 lookback windows for z-scoring (60, 120 bars), tested on 1d and 12h. Total cells: 2 TFs × 2 params × 2 assets = 8 backtests — within limit. We pick the best config by min-asset hold-out Sharpe. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np def ar1_residual_target(df, zscore_win: int = 60) -> np.ndarray: """ Causal AR(1) residual reversion target. At each bar i: - Use all returns r[0..i-1] to fit AR(1): regress r[t] on r[t-1] (expanding OLS — efficient via running sums) - Compute residual[i] = r[i] - (a0 + a1 * r[i-1]) (uses closed bar i) - Z-score the residual over last zscore_win bars - Position = -z (mean-reversion) → vol-targeted Minimum warmup: 30 bars for stable OLS + zscore_win bars for z-score. """ c = df["close"].values.astype(float) n = len(c) r = al.log_returns(c) # r[0]=0, r[i] = log(c[i]/c[i-1]) # Expanding AR(1): for each bar i, estimate (a0, a1) from data up to i-1. # We need: sum(r), sum(r^2), sum(r_t * r_{t-1}), sum(r_{t-1}), sum(r_{t-1}^2) # for t in [1..i-1]. # Then OLS: regress r_t ~ a0 + a1*r_{t-1}. # Normal equations: # [n-1, sum_r1 ] [a0] [sum_r ] # [sum_r1, sum_r1sq] [a1] = [sum_r_r1] # where sum_r1 = sum(r[t-1]), sum_r = sum(r[t]), etc. residuals = np.zeros(n) min_warmup = 30 # minimum bars to fit AR(1) # Running sums for expanding OLS (using pairs (r[t-1], r[t]) for t>=1) S_n = 0.0 # count of pairs S_x = 0.0 # sum of r[t-1] S_y = 0.0 # sum of r[t] S_xx = 0.0 # sum of r[t-1]^2 S_xy = 0.0 # sum of r[t-1]*r[t] for i in range(1, n): # Update running sums with pair (r[i-1], r[i]) but we use data up to i-1 # So at step i, we first compute residual using sums from [1..i-1], # then update sums to include pair for t=i. if S_n >= min_warmup: # Fit AR(1) from expanding window up to t=i-1 denom = S_n * S_xx - S_x * S_x if abs(denom) > 1e-14: a1 = (S_n * S_xy - S_x * S_y) / denom a0 = (S_y - a1 * S_x) / S_n else: a0, a1 = 0.0, 0.0 # Residual at bar i: actual r[i] minus AR(1) prediction pred = a0 + a1 * r[i - 1] residuals[i] = r[i] - pred # else: residuals[i] remains 0 # Update running sums with the new observation pair (r[i-1], r[i]) # This is data point for t=i: x=r[i-1], y=r[i] S_n += 1.0 S_x += r[i - 1] S_y += r[i] S_xx += r[i - 1] ** 2 S_xy += r[i - 1] * r[i] # Z-score the residual with rolling window z = al.zscore(residuals, zscore_win) # Mean-reversion: negative of z-score direction = -z direction = np.nan_to_num(direction, nan=0.0) # Vol-target to 20% annualized, cap at 2x leverage target = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return target def make_target(zscore_win: int): return lambda df: ar1_residual_target(df, zscore_win=zscore_win) if __name__ == "__main__": # Small internal grid: 2 z-score windows × 2 TFs = 4 cells per config # Pick best by min-asset holdout Sharpe configs = [ {"zscore_win": 60, "label": "z60"}, {"zscore_win": 120, "label": "z120"}, ] tfs = ("1d", "12h") best_rep = None best_score = -9.0 for cfg in configs: zw = cfg["zscore_win"] rep = al.study_weights( f"STA08-AR1resid-z{zw}", make_target(zw), tfs=tfs, ) score = rep["verdict"].get("best_holdout_sharpe", -9.0) if score > best_score: best_score = score best_rep = rep # Print intermediate for debug print(f"\n--- Config z{zw} ---") print(al.fmt(rep)) print("\n\n=== BEST CONFIG ===") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))