Files
foxhunt/scripts/surfer/fng_gate.py
jgrusewski b664673df3 feat(surfer): Fear&Greed gate — sentiment regime-dependent, not robust
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) <noreply@anthropic.com>
2026-06-06 19:35:37 +02:00

81 lines
3.6 KiB
Python

#!/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()