""" 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, }