316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""
|
|
Market sentiment tracker — CNN Fear & Greed + NAAIM + AAII.
|
|
All free/public sources. No API key required.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CNN_FG_URL = "https://production.dataviz.cnn.io/index/fearandgreed/graphdata/"
|
|
|
|
NAAIM_URLS = [
|
|
"https://www.naaim.org/wp-content/uploads/NAAIM-Exposure-Index-Weekly-Data.csv",
|
|
"https://www.naaim.org/programs/naaim-exposure-index/",
|
|
]
|
|
|
|
AAII_URL = "https://www.aaii.com/sentimentsurvey/sent_results.js"
|
|
|
|
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; OpenFin/1.0)"}
|
|
_TIMEOUT = 15
|
|
|
|
|
|
def _fetch_cnn_fear_greed() -> Optional[Dict[str, Any]]:
|
|
try:
|
|
resp = requests.get(CNN_FG_URL, timeout=_TIMEOUT, headers=_HEADERS)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except Exception as e:
|
|
logger.warning(f"[SENTIMENT] CNN F&G fetch failed: {e}")
|
|
return None
|
|
|
|
fg = data.get("fear_and_greed", {})
|
|
try:
|
|
score = float(fg.get("score", 0))
|
|
rating = str(fg.get("rating", "unknown")).lower()
|
|
prev_close = float(fg.get("previous_close", score))
|
|
prev_1_week = float(fg.get("previous_1_week", score))
|
|
prev_1_month = float(fg.get("previous_1_month", score))
|
|
prev_1_year = float(fg.get("previous_1_year", score))
|
|
except (TypeError, ValueError) as e:
|
|
logger.warning(f"[SENTIMENT] CNN F&G parse error: {e}")
|
|
return None
|
|
|
|
logger.info(f"[SENTIMENT] CNN F&G: {score:.1f} / 100 ({rating})")
|
|
return {
|
|
"score": score,
|
|
"rating": rating,
|
|
"previous_close": prev_close,
|
|
"previous_1_week": prev_1_week,
|
|
"previous_1_month": prev_1_month,
|
|
"previous_1_year": prev_1_year,
|
|
}
|
|
|
|
|
|
_NAAIM_NUMBER_RE = re.compile(r"(\d+\.?\d*)\s*(?:Average|Mean|Exposure)", re.IGNORECASE)
|
|
|
|
|
|
def _parse_naaim_csv(text: str) -> Optional[float]:
|
|
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
|
if len(lines) < 2:
|
|
return None
|
|
data_line = lines[1]
|
|
parts = re.split(r"[,\t]", data_line)
|
|
for part in reversed(parts):
|
|
try:
|
|
val = float(part.strip())
|
|
if 0.0 <= val <= 200.0:
|
|
return val
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _fetch_naaim() -> Optional[float]:
|
|
try:
|
|
resp = requests.get(NAAIM_URLS[0], timeout=_TIMEOUT, headers=_HEADERS)
|
|
if resp.status_code == 200 and resp.text.strip():
|
|
val = _parse_naaim_csv(resp.text)
|
|
if val is not None:
|
|
logger.info(f"[SENTIMENT] NAAIM exposure (CSV): {val:.1f}")
|
|
return val
|
|
except Exception as e:
|
|
logger.debug(f"[SENTIMENT] NAAIM CSV failed: {e}")
|
|
|
|
try:
|
|
resp = requests.get(NAAIM_URLS[1], timeout=_TIMEOUT, headers=_HEADERS)
|
|
if resp.status_code == 200:
|
|
m = _NAAIM_NUMBER_RE.search(resp.text)
|
|
if m:
|
|
val = float(m.group(1))
|
|
logger.info(f"[SENTIMENT] NAAIM exposure (HTML): {val:.1f}")
|
|
return val
|
|
except Exception as e:
|
|
logger.debug(f"[SENTIMENT] NAAIM HTML failed: {e}")
|
|
|
|
logger.info("[SENTIMENT] NAAIM not available — skipping")
|
|
return None
|
|
|
|
|
|
def _fetch_aaii() -> Optional[Tuple[float, float, float]]:
|
|
try:
|
|
resp = requests.get(AAII_URL, timeout=_TIMEOUT, headers=_HEADERS)
|
|
if resp.status_code != 200:
|
|
return None
|
|
text = resp.text
|
|
except Exception as e:
|
|
logger.debug(f"[SENTIMENT] AAII fetch failed: {e}")
|
|
return None
|
|
|
|
try:
|
|
bull_m = re.search(r'"bullish"\s*:\s*([\d.]+)', text, re.IGNORECASE)
|
|
bear_m = re.search(r'"bearish"\s*:\s*([\d.]+)', text, re.IGNORECASE)
|
|
neut_m = re.search(r'"neutral"\s*:\s*([\d.]+)', text, re.IGNORECASE)
|
|
|
|
if bull_m and bear_m:
|
|
bull = float(bull_m.group(1))
|
|
bear = float(bear_m.group(1))
|
|
neut = float(neut_m.group(1)) if neut_m else max(0.0, 100.0 - bull - bear)
|
|
|
|
if 0 <= bull <= 100 and 0 <= bear <= 100:
|
|
logger.info(f"[SENTIMENT] AAII Bulls={bull:.1f}% Bears={bear:.1f}%")
|
|
return bull, bear, neut
|
|
except (TypeError, ValueError) as e:
|
|
logger.debug(f"[SENTIMENT] AAII parse error: {e}")
|
|
|
|
logger.info("[SENTIMENT] AAII not available — skipping")
|
|
return None
|
|
|
|
|
|
def _signal_indices_from_fg(score: float) -> str:
|
|
if score < 25:
|
|
return "bullish"
|
|
if score > 75:
|
|
return "bearish"
|
|
if score < 40:
|
|
return "bullish"
|
|
if score > 60:
|
|
return "bearish"
|
|
return "neutral"
|
|
|
|
|
|
def _build_key_points(
|
|
fg: Dict[str, Any],
|
|
naaim: Optional[float],
|
|
aaii: Optional[Tuple[float, float, float]],
|
|
) -> List[str]:
|
|
points: List[str] = []
|
|
|
|
score = fg["score"]
|
|
rating = fg["rating"]
|
|
prev_week = fg["previous_1_week"]
|
|
wow_delta = score - prev_week
|
|
|
|
points.append(
|
|
f"CNN Fear & Greed: {score:.0f}/100 ({rating.upper()}) — WoW {wow_delta:+.0f}pt"
|
|
)
|
|
|
|
prev_close = fg["previous_close"]
|
|
dod_delta = score - prev_close
|
|
points.append(f"CNN F&G DoD change: {dod_delta:+.0f}pt (prev close {prev_close:.0f})")
|
|
|
|
if naaim is not None:
|
|
naaim_level = "HIGH" if naaim > 80 else ("LOW" if naaim < 30 else "NORMAL")
|
|
points.append(f"NAAIM Exposure Index: {naaim:.0f}/200 ({naaim_level})")
|
|
if naaim > 80:
|
|
points.append("NAAIM: Active managers at high equity exposure — limited incremental buying power")
|
|
elif naaim < 30:
|
|
points.append("NAAIM: Active managers heavily derisked — potential fuel for snapback rally")
|
|
|
|
if aaii is not None:
|
|
bull, bear, neut = aaii
|
|
points.append(f"AAII Bulls: {bull:.0f}% | Bears: {bear:.0f}% | Neutral: {neut:.0f}%")
|
|
if bull > 40:
|
|
points.append(f"AAII: Bullish sentiment elevated ({bull:.0f}%) — mild contrarian bearish signal")
|
|
elif bull < 20:
|
|
points.append(f"AAII: Extreme pessimism ({bull:.0f}% bulls) — contrarian bullish signal")
|
|
if bear > 40:
|
|
points.append(f"AAII: High bear reading ({bear:.0f}%) — contrarian bullish signal")
|
|
|
|
if score < 20:
|
|
points.append("EXTREME FEAR: historically a strong mean-reversion buy signal (>80% 1M forward returns positive)")
|
|
elif score > 80:
|
|
points.append("EXTREME GREED: market complacency elevated — tail-risk hedging warranted")
|
|
|
|
return points
|
|
|
|
|
|
def _build_implications(
|
|
fg: Dict[str, Any],
|
|
naaim: Optional[float],
|
|
aaii: Optional[Tuple[float, float, float]],
|
|
) -> List[str]:
|
|
score = fg["score"]
|
|
rating = fg["rating"]
|
|
implications: List[str] = []
|
|
|
|
if rating == "extreme_fear" or score < 20:
|
|
implications.append(
|
|
"Extreme Fear reading — historically strong contrarian buy signal for long delta"
|
|
)
|
|
elif rating == "extreme_greed" or score > 80:
|
|
implications.append(
|
|
"Extreme Greed — market may be overbought, consider protective puts or vol selling"
|
|
)
|
|
elif score < 40:
|
|
implications.append(
|
|
"Fear sentiment — elevated risk premium may support long vol or mean-reversion longs"
|
|
)
|
|
elif score > 60:
|
|
implications.append(
|
|
"Greed sentiment — consider trimming high-beta risk, watch for vol compression unwind"
|
|
)
|
|
else:
|
|
implications.append(
|
|
"Neutral sentiment — no contrarian edge; follow underlying trend and macro catalysts"
|
|
)
|
|
|
|
if naaim is not None:
|
|
if naaim > 80:
|
|
implications.append(
|
|
"Active managers fully exposed — limited buying power remaining, correction risk elevated"
|
|
)
|
|
elif naaim < 30:
|
|
implications.append(
|
|
"Active managers heavily derisked — potential fuel for snapback rally on positive catalyst"
|
|
)
|
|
|
|
if aaii is not None:
|
|
bull, bear, _ = aaii
|
|
if bull > 40 and score > 60:
|
|
implications.append(
|
|
"AAII + CNN both elevated — dual sentiment warning, consider vol hedges"
|
|
)
|
|
elif bear > 40 and score < 40:
|
|
implications.append(
|
|
"AAII bears elevated + CNN fear — strong contrarian setup, watch for reversal catalyst"
|
|
)
|
|
|
|
return implications
|
|
|
|
|
|
def fetch_sentiment_report() -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Fetch CNN Fear & Greed (primary) + NAAIM + AAII (optional).
|
|
Returns a single structured report dict compatible with institutional_reports table.
|
|
Returns None only if CNN F&G fetch fails entirely.
|
|
"""
|
|
fg = _fetch_cnn_fear_greed()
|
|
if fg is None:
|
|
logger.error("[SENTIMENT] CNN Fear & Greed unavailable — aborting sentiment report")
|
|
return None
|
|
|
|
naaim = _fetch_naaim()
|
|
aaii = _fetch_aaii()
|
|
|
|
score = fg["score"]
|
|
rating = fg["rating"]
|
|
|
|
key_points = _build_key_points(fg, naaim, aaii)
|
|
implications = _build_implications(fg, naaim, aaii)
|
|
signal_indices = _signal_indices_from_fg(score)
|
|
|
|
importance = 3 if (score < 20 or score > 80) else 2
|
|
|
|
report_date = datetime.utcnow().strftime("%Y-%m-%d")
|
|
|
|
sources = ["CNN Fear & Greed"]
|
|
if naaim is not None:
|
|
sources.append("NAAIM")
|
|
if aaii is not None:
|
|
sources.append("AAII")
|
|
source_label = " + ".join(sources)
|
|
|
|
raw: Dict[str, Any] = {"cnn_fear_greed": fg}
|
|
if naaim is not None:
|
|
raw["naaim"] = {
|
|
"exposure_index": naaim,
|
|
"level": "HIGH" if naaim > 80 else ("LOW" if naaim < 30 else "NORMAL"),
|
|
}
|
|
if aaii is not None:
|
|
bull, bear, neut = aaii
|
|
raw["aaii"] = {"bullish_pct": bull, "bearish_pct": bear, "neutral_pct": neut}
|
|
|
|
ai_summary = (
|
|
f"Market Sentiment ({report_date}). "
|
|
f"CNN F&G: {score:.0f}/100 ({rating.upper()}), "
|
|
f"WoW {score - fg['previous_1_week']:+.0f}pt. "
|
|
+ (f"NAAIM: {naaim:.0f}/200. " if naaim is not None else "")
|
|
+ (f"AAII Bulls: {aaii[0]:.0f}%. " if aaii is not None else "")
|
|
+ f"Signal: {signal_indices.upper()}. "
|
|
+ implications[0]
|
|
)
|
|
|
|
return {
|
|
"report_type": "sentiment",
|
|
"report_date": report_date,
|
|
"title": f"Market Sentiment — CNN F&G {score:.0f}/100 ({rating.upper()}) — {report_date}",
|
|
"source": source_label,
|
|
"importance": importance,
|
|
"category": "sentiment",
|
|
"raw_data": raw,
|
|
"key_points": key_points,
|
|
"trading_implications": " | ".join(implications),
|
|
"signal_energy": "neutral",
|
|
"signal_metals": "neutral",
|
|
"signal_indices": signal_indices,
|
|
"signal_forex": "neutral",
|
|
"ai_summary": ai_summary,
|
|
}
|