Files
2026-10-30 09:20:09 +01:00

192 lines
5.7 KiB
Python

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