crypto-forecasting-ai / scripts /run_sentiment_alignment.py
chonthichar's picture
Claude Opus 5
Measure whether price and sentiment trends align, and which leads
e517b62
Raw History Blame Contribute Delete
11.2 kB
"""Does the trend of price always align with the trend of sentiment?
Three different questions hide inside that one, and they have different answers:
1. do they MOVE TOGETHER? contemporaneous correlation and sign agreement
2. does one LEAD the other? cross-correlation across lags, Granger tests
3. is the alignment STABLE? rolling correlation, and what follows a
divergence
Sentiment series: the Crypto Fear & Greed Index, daily since 2018-02-01. It is
the only sentiment history available over this span, and one caveat has to be
stated before any number is read: the index is partly *constructed from price*.
Its published components include volatility and market momentum, so some
contemporaneous agreement with price is definitional rather than empirical. That
makes question 2 the one that carries information -- if sentiment merely encodes
recent price, it will track price with a lag and lead nothing.
Usage:
python scripts/run_sentiment_alignment.py
python scripts/run_sentiment_alignment.py --max-lag 21 --window 90
"""
from __future__ import annotations
import argparse
import json
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
from scipy import stats
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
warnings.filterwarnings("ignore")
from scripts.run_direction_sota_experiment import load_candles # noqa: E402
from scripts.run_sentiment_replication import fear_greed # noqa: E402
CACHE = ROOT / "outputs" / "volatility" / "btc_1h.csv"
def build() -> pd.DataFrame:
candles = load_candles("2018-01-01", CACHE)
daily = (candles.set_index("timestamp")["close"].resample("1D").last()
.dropna().rename("close").reset_index())
daily["date"] = daily["timestamp"].dt.normalize()
df = daily.merge(fear_greed(), on="date", how="inner").sort_values("date").reset_index(drop=True)
df["price_chg"] = np.log(df["close"]).diff()
df["fg_chg"] = df["fear_greed"].diff()
# "trend" rather than one day's noise: the 7-day move in each series
df["price_trend"] = np.log(df["close"]).diff(7)
df["fg_trend"] = df["fear_greed"].diff(7)
return df
def agreement(a: np.ndarray, b: np.ndarray) -> dict:
"""How often do the two series move the same way, with a binomial CI?"""
ok = np.isfinite(a) & np.isfinite(b) & (a != 0) & (b != 0)
same = (np.sign(a[ok]) == np.sign(b[ok]))
n, k = len(same), int(same.sum())
lo, hi = stats.binomtest(k, n).proportion_ci(0.95)
return dict(n=n, rate=k / n, lo=lo, hi=hi,
p_vs_coinflip=stats.binomtest(k, n, 0.5).pvalue)
def granger(y: np.ndarray, x: np.ndarray, lags: int) -> dict:
"""Does past x improve a model of y beyond y's own past? (F test)
Hand-rolled rather than taken from statsmodels, whose compiled extension is
blocked by this machine's application-control policy. Standard SSR F test:
regress y on its own lags, then on its own lags plus x's lags, and ask
whether the residual sum of squares falls by more than chance.
"""
def design(series, n_obs, offset):
return np.column_stack([series[offset - k: offset - k + n_obs] for k in range(1, lags + 1)])
data = np.column_stack([y, x])
data = data[np.isfinite(data).all(axis=1)]
yy, xx = data[:, 0], data[:, 1]
n_obs = len(yy) - lags
target = yy[lags:]
y_lags = design(yy, n_obs, lags)
x_lags = design(xx, n_obs, lags)
const = np.ones((n_obs, 1))
def ssr(mat):
beta, *_ = np.linalg.lstsq(mat, target, rcond=None)
resid = target - mat @ beta
return float(resid @ resid)
ssr_r = ssr(np.hstack([const, y_lags]))
ssr_u = ssr(np.hstack([const, y_lags, x_lags]))
df_den = n_obs - (2 * lags + 1)
f = ((ssr_r - ssr_u) / lags) / (ssr_u / df_den)
return dict(lags=lags, F=float(f), p=float(stats.f.sf(f, lags, df_den)),
n=int(n_obs), r2_gain=float((ssr_r - ssr_u) / ssr_r))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--max-lag", type=int, default=21)
ap.add_argument("--window", type=int, default=90)
ap.add_argument("--granger-lags", type=int, default=7)
args = ap.parse_args()
df = build()
print(f"daily rows {len(df):,} {str(df['date'].min())[:10]} to {str(df['date'].max())[:10]}")
print("sentiment: Crypto Fear & Greed Index (partly built FROM price -- see module docstring)\n")
out: dict = {"rows": len(df)}
# ---------------------------------------------------------------- 1. together
print("=" * 92)
print("1. DO THEY MOVE TOGETHER?")
print("=" * 92)
pairs = [("level vs level", np.log(df["close"]), df["fear_greed"]),
("daily change", df["price_chg"], df["fg_chg"]),
("7-day trend", df["price_trend"], df["fg_trend"])]
out["correlations"] = {}
for name, a, b in pairs:
m = np.isfinite(a) & np.isfinite(b)
r, rp = stats.pearsonr(a[m], b[m])
s, sp = stats.spearmanr(a[m], b[m])
out["correlations"][name] = dict(pearson=r, pearson_p=rp, spearman=s, spearman_p=sp, n=int(m.sum()))
print(f" {name:<16} Pearson r = {r:+.3f} (p={rp:.2e}) Spearman = {s:+.3f} (p={sp:.2e}) n={int(m.sum()):,}")
print()
out["agreement"] = {}
for name, a, b in [("daily change", df["price_chg"], df["fg_chg"]),
("7-day trend", df["price_trend"], df["fg_trend"])]:
g = agreement(a.to_numpy(), b.to_numpy())
out["agreement"][name] = g
print(f" signs agree, {name:<14} {g['rate']:.1%} 95% CI [{g['lo']:.1%}, {g['hi']:.1%}]"
f" n={g['n']:,} p vs coin flip = {g['p_vs_coinflip']:.2e}")
print("\n -> 'always' is the wrong word if this is well below 100%.")
# ---------------------------------------------------------------- 2. lead/lag
print("\n" + "=" * 92)
print("2. DOES ONE LEAD THE OTHER?")
print(" correlation of price change at day t with sentiment change at day t+k")
print("=" * 92)
pc, fc = df["price_chg"].to_numpy(), df["fg_chg"].to_numpy()
rows = []
for k in range(-args.max_lag, args.max_lag + 1):
shifted = pd.Series(fc).shift(-k).to_numpy()
m = np.isfinite(pc) & np.isfinite(shifted)
if m.sum() < 100:
continue
r, p = stats.pearsonr(pc[m], shifted[m])
rows.append(dict(lag=k, r=r, p=p, n=int(m.sum())))
out["cross_correlation"] = rows
best = max(rows, key=lambda r: abs(r["r"]))
for r in rows:
if abs(r["lag"]) <= 7:
bar = "#" * int(abs(r["r"]) * 60)
mark = " <= PEAK" if r["lag"] == best["lag"] else ""
meaning = ("sentiment leads price" if r["lag"] < 0
else "price leads sentiment" if r["lag"] > 0 else "same day")
print(f" k={r['lag']:+3d} r={r['r']:+.3f} {bar:<34}{meaning}{mark}")
print(f"\n peak |r| at k = {best['lag']:+d} ({best['r']:+.3f}, p={best['p']:.2e})")
print(" -> k=0 or k>0 means sentiment reacts to price; only k<0 would mean it predicts price.")
print("\n Granger causality (daily changes, "
f"{args.granger_lags} lags):")
g1 = granger(fc, pc, args.granger_lags) # does price explain sentiment?
g2 = granger(pc, fc, args.granger_lags) # does sentiment explain price?
out["granger"] = {"price_to_sentiment": g1, "sentiment_to_price": g2}
print(f" price -> sentiment : F = {g1['F']:8.3f} p = {g1['p']:.3e}"
f" SSR reduction {g1['r2_gain'] * 100:5.2f}%"
f" {'SIGNIFICANT' if g1['p'] < 0.05 else 'not significant'}")
print(f" sentiment -> price : F = {g2['F']:8.3f} p = {g2['p']:.3e}"
f" SSR reduction {g2['r2_gain'] * 100:5.2f}%"
f" {'SIGNIFICANT' if g2['p'] < 0.05 else 'not significant'}")
# ---------------------------------------------------------------- 3. stability
print("\n" + "=" * 92)
print("3. IS THE ALIGNMENT STABLE, OR DOES IT BREAK DOWN?")
print("=" * 92)
roll = (df.set_index("date")["price_trend"]
.rolling(args.window).corr(df.set_index("date")["fg_trend"]))
roll = roll.dropna()
out["rolling"] = dict(window=args.window, mean=float(roll.mean()), sd=float(roll.std()),
min=float(roll.min()), max=float(roll.max()),
frac_negative=float((roll < 0).mean()),
min_date=str(roll.idxmin())[:10], max_date=str(roll.idxmax())[:10])
print(f" {args.window}-day rolling correlation of the 7-day trends:")
print(f" mean {roll.mean():+.3f} sd {roll.std():.3f} "
f"range [{roll.min():+.3f} on {str(roll.idxmin())[:10]}, "
f"{roll.max():+.3f} on {str(roll.idxmax())[:10]}]")
print(f" windows where the correlation is NEGATIVE: {(roll < 0).mean():.1%}")
for year, g in roll.groupby(roll.index.year):
print(f" {year}: mean {g.mean():+.3f} min {g.min():+.3f} max {g.max():+.3f}")
# ------------------------------------------------- 4. what follows a divergence
print("\n" + "=" * 92)
print("4. WHEN THEY DISAGREE, WHAT HAPPENS NEXT?")
print(" (the only part with any trading relevance)")
print("=" * 92)
df["fwd_7d"] = np.log(df["close"]).shift(-7) - np.log(df["close"])
aligned = np.sign(df["price_trend"]) == np.sign(df["fg_trend"])
valid = df["price_trend"].notna() & df["fg_trend"].notna() & df["fwd_7d"].notna()
out["divergence"] = {}
print(f" {'state':<34}{'days':>7}{'mean fwd 7d':>14}{'median':>10}{'% up':>8}")
for label, mask in [
("aligned (both up or both down)", valid & aligned),
(" of which both UP", valid & aligned & (df["price_trend"] > 0)),
(" of which both DOWN", valid & aligned & (df["price_trend"] < 0)),
("diverging", valid & ~aligned),
(" price up, sentiment down", valid & ~aligned & (df["price_trend"] > 0)),
(" price down, sentiment up", valid & ~aligned & (df["price_trend"] < 0)),
]:
f = df.loc[mask, "fwd_7d"]
if len(f) < 20:
continue
out["divergence"][label.strip()] = dict(n=int(len(f)), mean=float(f.mean()),
median=float(f.median()),
pct_up=float((f > 0).mean()))
print(f" {label:<34}{len(f):>7,}{f.mean() * 100:>+13.2f}%{f.median() * 100:>+9.2f}%"
f"{(f > 0).mean() * 100:>7.1f}%")
a = df.loc[valid & aligned, "fwd_7d"]
d = df.loc[valid & ~aligned, "fwd_7d"]
t, p = stats.ttest_ind(a, d, equal_var=False)
out["divergence_test"] = dict(t=float(t), p=float(p), n_aligned=int(len(a)), n_diverging=int(len(d)))
print(f"\n aligned vs diverging, difference in next-7-day return: "
f"t = {t:+.2f}, p = {p:.3f} {'SIGNIFICANT' if p < 0.05 else 'not significant'}")
path = ROOT / "outputs" / "sentiment_alignment.json"
path.write_text(json.dumps(out, indent=2, default=str), encoding="utf-8")
print(f"\nwritten: {path}")
if __name__ == "__main__":
main()