From b664673df345b65818570ea69e24b1c9cc366f11 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 6 Jun 2026 19:35:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(surfer):=20Fear&Greed=20gate=20=E2=80=94?= =?UTF-8?q?=20sentiment=20regime-dependent,=20not=20robust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F&G market-timing: full-sample IC negative (fear->continuation), recent OOS positive, IS/OOS sign-flip (-0.65->+0.75), 2026 flipped -0.8 = regime-dependent, not robust. Free non-price is mostly market-wide timing (low-breadth, regime-prone); cross-sectional on-chain (the valuable kind) needs paid data. Non-price free frontier mostly dead. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/surfer/fng_gate.py | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 scripts/surfer/fng_gate.py diff --git a/scripts/surfer/fng_gate.py b/scripts/surfer/fng_gate.py new file mode 100644 index 000000000..c350974cc --- /dev/null +++ b/scripts/surfer/fng_gate.py @@ -0,0 +1,80 @@ +#!/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()