"""STA06 — Kalman Local Level+Slope Trend Hypothesis: Run a causal Kalman filter on log price with local level + slope states. The slope state gives a smooth, causal estimate of local trend direction. Long when filtered slope > 0, flat otherwise (long-only, crypto-style). Vol-targeted position like TP01. Grid: 2 observation-noise / process-noise ratio settings × 2 TFs = 4 total cells. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np def kalman_slope(log_price: np.ndarray, q_level: float = 1e-4, q_slope: float = 1e-6, r_obs: float = 1e-2) -> np.ndarray: """ Causal Kalman local-level + slope filter on log_price. State: x = [level, slope] Transition: level_{t+1} = level_t + slope_t slope_{t+1} = slope_t Observation: y_t = level_t + noise Parameters: q_level: process noise variance for the level q_slope: process noise variance for the slope r_obs: observation noise variance Returns slope array (same length as log_price), causal at each i. """ n = len(log_price) slope_out = np.zeros(n) # State transition matrix F F = np.array([[1.0, 1.0], [0.0, 1.0]]) # Process noise covariance Q Q = np.array([[q_level, 0.0], [0.0, q_slope]]) # Observation matrix H (we observe only the level) H = np.array([[1.0, 0.0]]) # Observation noise variance R R = np.array([[r_obs]]) # Initialize state and covariance x = np.array([[log_price[0]], [0.0]]) # [level, slope] P = np.eye(2) * 1.0 for i in range(n): # --- Predict --- x_pred = F @ x P_pred = F @ P @ F.T + Q # --- Update with observation y[i] --- y = np.array([[log_price[i]]]) S = H @ P_pred @ H.T + R K = P_pred @ H.T @ np.linalg.inv(S) x = x_pred + K @ (y - H @ x_pred) P = (np.eye(2) - K @ H) @ P_pred # Record slope (state[1]) at this bar — causal (uses data up to i) slope_out[i] = x[1, 0] return slope_out def make_target(q_slope: float): """Factory: return a target_fn for a given Kalman noise configuration.""" def target_fn(df): c = df["close"].values.astype(float) lp = np.log(c) # Kalman filter slope — fully causal recursive # q_level scales with q_slope for coherence q_level = q_slope * 100.0 # level noise 100x slope noise r_obs = 1e-2 # observation noise fixed slope = kalman_slope(lp, q_level=q_level, q_slope=q_slope, r_obs=r_obs) # Direction: long when slope > 0, flat otherwise direction = np.where(slope > 0, 1.0, 0.0) # Vol-target the position (TP01 style) pos = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return pos return target_fn if __name__ == "__main__": # Small grid: 2 q_slope values (controls filter responsiveness) # Low q_slope = smoother/slower filter; high q_slope = more responsive configs = [ ("q_slope=1e-6", 1e-6), # slow, smooth ("q_slope=1e-5", 1e-5), # medium ] results = [] for label, q_slope in configs: print(f"\n--- Running STA06 config: {label} ---") rep = al.study_weights( f"STA06-Kalman-{label}", make_target(q_slope), tfs=("1d", "12h"), ) print(al.fmt(rep)) print("JSON:", al.as_json(rep)) results.append((label, q_slope, rep)) # Pick best config by min_asset_holdout_sharpe across all cells best_label, best_q, best_rep = max( results, key=lambda x: x[2]["verdict"].get("best_holdout_sharpe", -99) ) print(f"\n=== BEST CONFIG: {best_label} ===") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))