#!/usr/bin/env python3 """Phase A (non-price) — Fear & Greed sentiment gate (market-timing signal). Does crypto sentiment (alternative.me Fear & Greed, daily since 2018) predict forward crypto-market returns? Contrarian hypothesis: extreme fear -> buy, extreme greed -> sell. Market = equal-weight return over the crypto_pit universe. Tests contrarian-level, extreme-only, and sentiment-change signals -> forward 1/7/30d returns, IC + timing-strategy Sharpe (net 5bp), per-year, OOS. A market-timing sleeve is directional -> diversifies the market-neutral momentum book if it works. """ import json import math import os import sys import urllib.request import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import pit_sweep # noqa: E402 from signal_sweep import sharpe_t # noqa: E402 import torch # noqa: E402 DEV = "cuda" if torch.cuda.is_available() else "cpu" DAY = 86_400 def fng(): cache = "data/surfer/fng.json" if os.path.exists(cache): return json.load(open(cache)) d = json.load(urllib.request.urlopen(urllib.request.Request( "https://api.alternative.me/fng/?limit=0&format=json", headers={"User-Agent": "curl/8"}), timeout=30))["data"] out = {int(r["timestamp"]) // DAY: float(r["value"]) for r in d} os.makedirs("data/surfer", exist_ok=True); json.dump(out, open(cache, "w")) return {int(k): v for k, v in out.items()} def main(): fg = fng() syms, days, close, qv, fund = pit_sweep.load() R = np.zeros_like(close); R[1:] = np.log(close)[1:] - np.log(close)[:-1]; R = np.where(np.isfinite(R), R, 0.0) mkt = {int(days[t]): float(np.nanmean(R[t][np.isfinite(close[t])])) for t in range(len(days))} # EW market ret day t common = sorted(set(fg) & set(mkt)) f = np.array([fg[d] for d in common]); r = np.array([mkt[d] for d in common]) cum = np.concatenate([[0], np.cumsum(r)]) year = (1970 + np.array(common) / 365.25).astype(int) n = len(common); split = int(0.7 * n) def fwd(h): out = np.full(n, np.nan) out[:n - h] = cum[h + 1:n + 1] - cum[1:n - h + 1] # sum of mkt returns t+1..t+h return out sigs = { "contrarian_level": -(f - 50), "extreme_only": np.where(f < 25, 1.0, np.where(f > 75, -1.0, 0.0)), "sentiment_chg": np.concatenate([[np.nan] * 7, f[7:] - f[:-7]]), # rising sentiment (momentum) } print(f"\n===== FEAR&GREED GATE — market-timing, {n} days ({common[0]}..{common[-1]}), cost 5bp =====") print(f"{'signal':>16} " + " ".join(f"h={h}:IC" for h in [1, 7, 30]) + " timing(h=7): full/IS/OOS/peryr") for nm, s in sigs.items(): ics = [] for h in [1, 7, 30]: fw = fwd(h); m = np.isfinite(s) & np.isfinite(fw) ics.append(float(np.corrcoef(s[m], fw[m])[0, 1]) if m.sum() > 100 else float("nan")) # timing strategy at h=7 (weekly hold): position = sign(s) lagged, daily mkt return, ~weekly turnover pos = np.sign(s); pnl = np.full(n, np.nan) pnl[1:] = pos[:-1] * r[1:] turn = np.abs(np.diff(np.nan_to_num(pos))) pnl[1:] -= turn * 5e-4 T = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64) full = sharpe_t(T(pnl)); isr = sharpe_t(T(pnl[:split])); oos = sharpe_t(T(pnl[split:])) py = " ".join(f"{y}:{sharpe_t(T(pnl[year==y])):+.1f}" for y in range(2019, 2027) if (year == y).sum() > 60) print(f"{nm:>16} " + " ".join(f"{ic:+.3f}" for ic in ics) + f" {full:+.2f}/{isr:+.2f}/{oos:+.2f} {py}") print("\nVERDICT: contrarian IC>0 (fear->up) + timing OOS Sharpe>0 net = real sentiment signal (directional sleeve).") if __name__ == "__main__": main()