"""S3-02: Lead-lag multi-asset squeeze. Quando BTC fa squeeze breakout, ETH/SOL spesso seguono. Usa il breakout di BTC per anticipare entrata su ETH (e viceversa). Testa anche correlazione inter-asset per conferma segnale. """ from __future__ import annotations import sys sys.path.insert(0, ".") import numpy as np import pandas as pd from src.data.downloader import load_data FEE_RT = 0.002 INITIAL = 1000 LEVERAGE = 3 def keltner_ratio(close, high, low, window=14): n = len(close) r = np.full(n, np.nan) for i in range(window, n): wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i] ma = np.mean(wc) bb_std = np.std(wc) tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1)))) atr = np.mean(tr[1:]) kc = (ma+1.5*atr)-(ma-1.5*atr) bb = (ma+2*bb_std)-(ma-2*bb_std) if kc > 0: r[i] = bb/kc return r def load_aligned(assets, tf): """Carica e allinea dati multi-asset per timestamp.""" dfs = {} for asset in assets: try: if asset == "SOL": df = pd.read_parquet(f"data/raw/sol_{tf}.parquet") df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) else: df = load_data(asset, tf) dfs[asset] = df except Exception: pass if len(dfs) < 2: return None # Allinea per timestamp common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values) for df in dfs.values(): common_ts &= set(df["timestamp"].values) common_ts = sorted(common_ts) aligned = {} for asset, df in dfs.items(): mask = df["timestamp"].isin(common_ts) aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True) return aligned def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5): """Detect squeeze breakout events.""" events = [] in_sq = False sq_start = 0 for i in range(1, len(close)): if np.isnan(kcr[i]): continue is_sq = kcr[i] < sq_thr if is_sq and not in_sq: in_sq = True sq_start = i elif not is_sq and in_sq: in_sq = False if i - sq_start < min_dur: continue first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0 if abs(first_ret) < 0.001: continue events.append({ "idx": i, "duration": i - sq_start, "direction": 1 if first_ret > 0 else -1, "first_ret": first_ret, }) return events print("=" * 75) print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE") print("=" * 75) for tf in ["1h", "15m"]: aligned = load_aligned(["BTC", "ETH", "SOL"], tf) if aligned is None: continue n = len(aligned["BTC"]) ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True) print(f"\n Timeframe: {tf}, Candles allineate: {n}") # Calcola squeeze per ogni asset asset_data = {} for asset in aligned: df = aligned[asset] c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values kcr = keltner_ratio(c, h, l, 14) events = detect_breakouts(c, h, l, v, kcr) asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events} print(f" {asset}: {len(events)} squeeze breakouts") # ================================================================ # STRATEGIA A: Leader-follower # Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione # ================================================================ print(f"\n --- LEADER-FOLLOWER ({tf}) ---") for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]: if leader not in asset_data or follower not in asset_data: continue leader_events = asset_data[leader]["events"] fc = asset_data[follower]["close"] for hold in [3, 6]: for delay in [0, 1, 2]: yearly = {} for ev in leader_events: i = ev["idx"] + delay if i + hold >= n: continue # Anti-fakeout su follower entry = fc[i] exit_price = fc[min(i + hold, n - 1)] direction = ev["direction"] actual = (exit_price - entry) / entry * direction net = actual * LEVERAGE - FEE_RT * LEVERAGE year = ts.iloc[min(i, n-1)].year if year not in yearly: yearly[year] = {"w": 0, "t": 0, "pnls": []} yearly[year]["t"] += 1 if actual > 0: yearly[year]["w"] += 1 yearly[year]["pnls"].append(net * INITIAL) all_t = sum(d["t"] for d in yearly.values()) all_w = sum(d["w"] for d in yearly.values()) if all_t < 30: continue acc = all_w / all_t * 100 pnl = sum(p for d in yearly.values() for p in d["pnls"]) worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0) worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0 tag = "✅" if acc >= 76 else "" print(f" {leader}→{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}") # ================================================================ # STRATEGIA B: Consensus multi-asset # Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento # ================================================================ print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---") # Build event map: timestamp → list of (asset, direction) event_map = {} for asset, data in asset_data.items(): for ev in data["events"]: idx = ev["idx"] if idx not in event_map: event_map[idx] = [] event_map[idx].append((asset, ev["direction"])) for target in ["BTC", "ETH", "SOL"]: if target not in asset_data: continue tc = asset_data[target]["close"] for min_consensus in [2, 3]: for window_bars in [1, 3, 5]: yearly = {} daily_done = set() for idx in sorted(event_map.keys()): if idx + 6 >= n: continue day = ts.iloc[idx].strftime("%Y-%m-%d") if day in daily_done: continue # Count consensus within window nearby_events = [] for j in range(max(0, idx - window_bars), idx + window_bars + 1): if j in event_map: nearby_events.extend(event_map[j]) # Unique assets unique_assets = set(a for a, d in nearby_events) if len(unique_assets) < min_consensus: continue # Majority direction dirs = [d for a, d in nearby_events] majority = 1 if sum(dirs) > 0 else -1 entry = tc[idx] exit_price = tc[min(idx + 3, n - 1)] actual = (exit_price - entry) / entry * majority net = actual * LEVERAGE - FEE_RT * LEVERAGE year = ts.iloc[idx].year if year not in yearly: yearly[year] = {"w": 0, "t": 0, "pnls": []} yearly[year]["t"] += 1 if actual > 0: yearly[year]["w"] += 1 yearly[year]["pnls"].append(net * INITIAL) daily_done.add(day) all_t = sum(d["t"] for d in yearly.values()) all_w = sum(d["w"] for d in yearly.values()) if all_t < 20: continue acc = all_w / all_t * 100 pnl = sum(p for d in yearly.values() for p in d["pnls"]) tag = "✅" if acc >= 76 else "" print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}") # ================================================================ # STRATEGIA C: Correlation-weighted squeeze # Peso il segnale squeeze in base alla correlazione rolling con BTC # ================================================================ print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---") for target in ["ETH", "SOL"]: if target not in asset_data: continue tc = asset_data[target]["close"] btc_c = asset_data["BTC"]["close"] # Rolling correlation corr_window = 48 # 48 bars rolling_corr = np.full(n, np.nan) ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc))) ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c))) for i in range(corr_window, len(ret_t)): c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1] rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0 events = asset_data[target]["events"] for corr_thr in [0.5, 0.6, 0.7, 0.8]: yearly = {} for ev in events: i = ev["idx"] if i + 3 >= n or np.isnan(rolling_corr[i]): continue # Solo quando correlazione con BTC è alta if abs(rolling_corr[i]) < corr_thr: continue entry = tc[i - 1] exit_price = tc[min(i + 2, n - 1)] actual = (exit_price - entry) / entry * ev["direction"] net = actual * LEVERAGE - FEE_RT * LEVERAGE year = ts.iloc[i].year if year not in yearly: yearly[year] = {"w": 0, "t": 0, "pnls": []} yearly[year]["t"] += 1 if actual > 0: yearly[year]["w"] += 1 yearly[year]["pnls"].append(net * INITIAL) all_t = sum(d["t"] for d in yearly.values()) all_w = sum(d["w"] for d in yearly.values()) if all_t < 20: continue acc = all_w / all_t * 100 pnl = sum(p for d in yearly.values() for p in d["pnls"]) tag = "✅" if acc >= 76 else "" print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")