192 lines
5.7 KiB
Python
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
|