""" sentiment.py — Nieuws sentiment engine Bron: CryptoPanic API (gratis tier beschikbaar) Scoring: gewogen op votes + recency, -1.0 tot +1.0 Geen zware ML modellen — rule-based op CryptoPanic's eigen bullish/bearish labels + vote ratio. Snel en betrouwbaar. """ import os import httpx import asyncio import logging from datetime import datetime, timedelta from dataclasses import dataclass, field logger = logging.getLogger(__name__) CRYPTOPANIC_BASE = "https://cryptopanic.com/api/v1" @dataclass class NewsItem: title: str published: datetime source: str url: str currencies: list[str] sentiment: str # "positive" | "negative" | "neutral" votes_pos: int votes_neg: int score: float # berekend gewogen score @dataclass class SentimentResult: symbol: str # bijv. "BTC" score: float # -1.0 tot +1.0 label: str # "positief" | "negatief" | "neutraal" confidence: float # 0.0 tot 1.0 news_count: int recent_news: list[NewsItem] = field(default_factory=list) last_updated: datetime = field(default_factory=datetime.utcnow) def _calculate_item_score(item: dict) -> float: """ Bereken score per nieuwsitem op basis van: - CryptoPanic kind label (bullish/bearish) - Vote verhouding - Recency (items ouder dan 12u wegen minder) """ votes = item.get("votes", {}) pos = votes.get("positive", 0) neg = votes.get("negative", 0) total = pos + neg # Vote-gebaseerde score if total > 0: vote_score = (pos - neg) / total # -1 tot +1 else: vote_score = 0.0 # CryptoPanic kind label kind = item.get("kind", "news") if kind == "bullish": label_score = 0.6 elif kind == "bearish": label_score = -0.6 else: label_score = 0.0 # Recency weight try: published = datetime.fromisoformat( item["published_at"].replace("Z", "+00:00") ).replace(tzinfo=None) age_hours = (datetime.utcnow() - published).total_seconds() / 3600 recency = max(0.2, 1.0 - (age_hours / 24)) # 24u = 0.2 gewicht except Exception: recency = 0.5 # Gewogen combinatie score = (vote_score * 0.4 + label_score * 0.6) * recency return round(score, 3) async def fetch_sentiment(symbol: str) -> SentimentResult: """ Haal sentiment op voor een specifiek crypto symbol. symbol = coin code zonder /EUR, bijv. "BTC", "ETH" """ api_key = os.getenv("CRYPTOPANIC_API_KEY", "") if not api_key: logger.warning("CRYPTOPANIC_API_KEY niet ingesteld — dummy sentiment") return _dummy_sentiment(symbol) url = f"{CRYPTOPANIC_BASE}/posts/" params = { "auth_token": api_key, "currencies": symbol, "filter": "hot", "public": "true", } try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(url, params=params) resp.raise_for_status() data = resp.json() results = data.get("results", []) news_items = [] scores = [] for item in results[:20]: # Maximaal 20 recente items item_score = _calculate_item_score(item) scores.append(item_score) currencies = [ c.get("code", "") for c in item.get("currencies", []) ] votes = item.get("votes", {}) news_items.append(NewsItem( title=item.get("title", ""), published=datetime.fromisoformat( item["published_at"].replace("Z", "+00:00") ).replace(tzinfo=None), source=item.get("source", {}).get("title", ""), url=item.get("url", ""), currencies=currencies, sentiment="positive" if item_score > 0.1 else ( "negative" if item_score < -0.1 else "neutral" ), votes_pos=votes.get("positive", 0), votes_neg=votes.get("negative", 0), score=item_score, )) if not scores: return _dummy_sentiment(symbol) avg_score = sum(scores) / len(scores) confidence = min(1.0, len(scores) / 10) # 10+ items = max confidence if avg_score > 0.15: label = "positief" elif avg_score < -0.15: label = "negatief" else: label = "neutraal" return SentimentResult( symbol=symbol, score=round(avg_score, 3), label=label, confidence=round(confidence, 2), news_count=len(scores), recent_news=news_items[:5], # Top 5 voor dashboard ) except Exception as e: logger.error(f"Sentiment fetch mislukt voor {symbol}: {e}") return _dummy_sentiment(symbol) def _dummy_sentiment(symbol: str) -> SentimentResult: """Fallback als API niet beschikbaar is.""" return SentimentResult( symbol=symbol, score=0.0, label="neutraal", confidence=0.0, news_count=0, recent_news=[], ) async def fetch_all_sentiments(symbols: list[str]) -> dict[str, SentimentResult]: """Haal sentiment op voor meerdere coins parallel.""" tasks = [fetch_sentiment(s) for s in symbols] results = await asyncio.gather(*tasks, return_exceptions=True) output = {} for symbol, result in zip(symbols, results): if isinstance(result, SentimentResult): output[symbol] = result else: output[symbol] = _dummy_sentiment(symbol) return output