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>
81 lines
3.6 KiB
Python
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()
|