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>
This commit is contained in:
80
scripts/surfer/fng_gate.py
Normal file
80
scripts/surfer/fng_gate.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user