"""SEA09 — Asia-session mean-reversion on 1h bars. HYPOTHESIS: During the Asian session (00-08 UTC), fade extreme moves back toward the session open. If price has moved far up from the session open, go short (expecting reversion); if far down, go long. Session mean-reversion idea. BAR LABELING (1h bars): - A bar labeled/timestamped at "01:00 UTC" closes at 01:00 UTC (covers 00:00-01:00). - Close[00:00 UTC] = the midnight bar close = prior day's last bar. - Close[08:00 UTC] = end of the Asia window. CAUSAL DECISION: target[i] = position to hold DURING bar i+1 (decided with data <= close[i]). Asian session window: we want to hold a position during the bars from 01:00 UTC to 08:00 UTC (bars closing at those hours cover 00:00-01:00 ... 07:00-08:00). To hold during the bar closing at h+1 UTC, we set target at bar closing at h UTC. So to be active during hours 01..08 UTC, we set target at hours 00..07 UTC. At bar[i] closing at h (00..07): - We know the session open = close of the bar at h=00 of the current day (midnight). If h > 0, this is already in the past and known. If h == 0, we use the current bar's close as the session open (we'll be entering the next bar at h=1 anyway, and we don't know the overnight move yet — so for h=0 we set target=0 to avoid a contamination: we'd be computing signal from the same bar we're deciding on). Actually at h=0 (midnight), we just know close[00:00] but don't yet know if there will be an extreme move — so the target for bar(h=1) set at bar(h=0) should compare close[00:00] vs itself = 0 move. We'll mark target=0 for this bar. - For h in {1..7}: session_open = close of the 00:00 bar of the same day. session_move = (close[i] - session_open) / session_open z-score of session_move vs historical distribution (rolling 30d) -> signal strength. target[i] = -sign(session_move) * |z| if |z| > threshold -> fade the move. GRID (4 variants, 1 TF each = 4 * 2 assets = 8 backtests — within budget): A: simple sign-fade, no z-threshold (fade any move, binary direction) B: z-score fade, threshold=1.0 (only fade "significant" moves) C: z-score proportional (continuous weight proportional to -z) D: z-score proportional + vol-target We only test 1h (this is an intraday hourly hypothesis). Total: 4 variants × 1 TF × 2 assets = 8 backtests. Within budget. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd def _build_asia_features(df: pd.DataFrame, z_win_days: int = 30): """ For each 1h bar at index i: - Compute session_move[i] = (close[i] - session_open) / session_open where session_open = close of the 00:00 UTC bar of the SAME day. - Causal: session_open for day D is known from bar(h=0, day D) onward. - z-score of session_move vs rolling historical moves (causal). Returns (hour_arr, session_move_arr, z_arr). """ dt = pd.to_datetime(df["datetime"], utc=True) close = df["close"].values.astype(float) n = len(df) hour_arr = dt.dt.hour.values date_arr = dt.dt.date.values # Build date -> index of the 00:00 bar (the "session open" for that date) # The 00:00 UTC bar closes at midnight, so date is the same calendar date. session_open_by_date = {} # date -> close at 00:00 UTC for i in range(n): if hour_arr[i] == 0: session_open_by_date[date_arr[i]] = close[i] # Compute session_move for each bar in Asian session (h in 0..7) session_move = np.full(n, np.nan) for i in range(n): h = hour_arr[i] d = date_arr[i] if h in range(1, 8): # h=1..7 (h=0 excluded: move relative to itself = 0, no signal) so = session_open_by_date.get(d, np.nan) if np.isfinite(so) and so > 0: session_move[i] = (close[i] - so) / so # Compute rolling z-score of session_move (causal, only using past observations) # We compute it only for the non-NaN values (within-session bars), treating them # as a time series. For z-scoring we use a rolling window of z_win_days * ~7 (bars per day # in session = 7 bars at h=1..7). session_move_series = pd.Series(session_move) roll_mean = session_move_series.rolling(z_win_days * 7, min_periods=14).mean() roll_std = session_move_series.rolling(z_win_days * 7, min_periods=14).std() z_arr = ((session_move_series - roll_mean) / roll_std.replace(0, np.nan)).values z_arr = np.nan_to_num(z_arr, nan=0.0) return hour_arr, session_move, z_arr def target_simple_fade(df: pd.DataFrame) -> np.ndarray: """ Variant A: Fade any Asia-session move (binary sign-based). target[i] = -sign(session_move[i]) if h in [1..7], else 0. Holds the position during bar i+1 (so exposure hours = 02..09 UTC closes). We restrict to h in [0..6] so we hold during [1..7] UTC. """ hour_arr, session_move, _ = _build_asia_features(df) n = len(df) target = np.zeros(n) for i in range(n): h = hour_arr[i] # Set target at h=0..6 -> holds during h+1=1..7 UTC bar if h in range(0, 7) and np.isfinite(session_move[i]): target[i] = -np.sign(session_move[i]) if session_move[i] != 0 else 0.0 # h=0: session_move is NaN (no move yet), so target stays 0 — flat at bar(h=1) # Actually let's re-check: session_move[h=0] is NaN (excluded range(1,8) above). # So for h=0, target=0 (flat) -> we don't take a position at the very first bar. return target def target_zscore_threshold(df: pd.DataFrame) -> np.ndarray: """ Variant B: Fade only when z-score of move exceeds 1.0 (i.e., "significant" extremes). target[i] = -sign(z) if |z| > 1.0 and h in [0..6], else 0. """ hour_arr, _, z_arr = _build_asia_features(df) n = len(df) target = np.zeros(n) THRESHOLD = 1.0 for i in range(n): h = hour_arr[i] if h in range(0, 7): z = z_arr[i] if abs(z) > THRESHOLD: target[i] = -np.sign(z) return target def target_zscore_proportional(df: pd.DataFrame) -> np.ndarray: """ Variant C: Continuous fade proportional to -z (clipped to [-1, 1]). target[i] = clip(-z / 2.0, -1, 1) for h in [0..6], else 0. Dividing by 2.0 so that a z=2 sigma move gives full unit position. """ hour_arr, _, z_arr = _build_asia_features(df) n = len(df) target = np.zeros(n) for i in range(n): h = hour_arr[i] if h in range(0, 7): target[i] = float(np.clip(-z_arr[i] / 2.0, -1.0, 1.0)) return target def target_zscore_vol_targeted(df: pd.DataFrame) -> np.ndarray: """ Variant D: Proportional z-score fade + vol-targeting (20% annual vol, 2x cap). """ direction = target_zscore_proportional(df) return al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) if __name__ == "__main__": print("SEA09 — Asia-session mean-reversion on 1h bars") print("Grid: 4 variants × 1 TF (1h) × 2 assets = 8 backtests") print() # Variant A: simple sign fade rep_a = al.study_weights("SEA09-A-simple-fade", target_simple_fade, tfs=("1h",)) print("=== Variant A: simple sign fade ===") print(al.fmt(rep_a)) print() # Variant B: z-score threshold rep_b = al.study_weights("SEA09-B-zscore-threshold", target_zscore_threshold, tfs=("1h",)) print("=== Variant B: z-score threshold (|z|>1.0) ===") print(al.fmt(rep_b)) print() # Variant C: z-score proportional rep_c = al.study_weights("SEA09-C-zscore-proportional", target_zscore_proportional, tfs=("1h",)) print("=== Variant C: z-score proportional ===") print(al.fmt(rep_c)) print() # Variant D: z-score vol-targeted rep_d = al.study_weights("SEA09-D-zscore-vol-target", target_zscore_vol_targeted, tfs=("1h",)) print("=== Variant D: z-score proportional + vol-target ===") print(al.fmt(rep_d)) print() # Pick best by holdout Sharpe reps = [rep_a, rep_b, rep_c, rep_d] labels = ["A-simple-fade", "B-zscore-threshold", "C-zscore-proportional", "D-zscore-vol-target"] best = max(reps, key=lambda r: r["verdict"].get("best_holdout_sharpe", -9)) best_label = labels[reps.index(best)] print(f"=== BEST CONFIG: {best_label} ===") print(al.fmt(best)) print() print("JSON:", al.as_json(best))