Add new feature to handle user input

This commit is contained in:
Stefan
2026-10-30 09:20:09 +01:00
parent 033adb87da
commit 202f48d94f
10 changed files with 1535 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+209
View File
@@ -0,0 +1,209 @@
"""
analysis.py — Technische analyse engine
Berekent: RSI, EMA, VWAP, Bollinger Bands, ATR, volume score
Geeft per asset een opportunity score terug (0-100)
"""
import pandas as pd
import pandas_ta as ta
import numpy as np
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class AssetScore:
symbol: str
opportunity_score: float # 0-100, hoger = interessanter
rsi: float
rsi_signal: str # "oversold" | "neutral" | "overbought"
ema9: float
ema21: float
ema_signal: str # "bullish" | "bearish" | "neutral"
vwap: float
vwap_signal: str # "above" | "below"
bb_position: float # 0.0 = lower band, 1.0 = upper band
atr_pct: float # ATR als % van prijs
volume_score: float # 0-100, relatief volume
trend_strength: str # "strong" | "moderate" | "weak"
tradeable: bool # False als ATR te hoog of signalen ontbreken
def calculate_indicators(df: pd.DataFrame) -> dict:
"""
Bereken alle indicatoren op een OHLCV dataframe.
Geeft dict terug met indicator waarden.
"""
if df.empty or len(df) < 30:
return {}
try:
close = df["close"]
high = df["high"]
low = df["low"]
volume = df["volume"]
# --- RSI ---
rsi_series = ta.rsi(close, length=14)
rsi = float(rsi_series.iloc[-1]) if rsi_series is not None else 50.0
# --- EMA ---
ema9 = float(ta.ema(close, length=9).iloc[-1])
ema21 = float(ta.ema(close, length=21).iloc[-1])
ema50 = float(ta.ema(close, length=50).iloc[-1])
# EMA50 slope: positief = uptrend
ema50_series = ta.ema(close, length=50)
ema50_slope = float(ema50_series.iloc[-1] - ema50_series.iloc[-5]) if len(ema50_series) >= 5 else 0
# --- VWAP ---
typical_price = (high + low + close) / 3
cumulative_tp_vol = (typical_price * volume).cumsum()
cumulative_vol = volume.cumsum()
vwap = float((cumulative_tp_vol / cumulative_vol).iloc[-1])
# --- Bollinger Bands ---
bbands = ta.bbands(close, length=20, std=2)
if bbands is not None and not bbands.empty:
bb_upper = float(bbands.iloc[-1, 2]) # BBU
bb_lower = float(bbands.iloc[-1, 0]) # BBL
bb_mid = float(bbands.iloc[-1, 1]) # BBM
current = float(close.iloc[-1])
bb_range = bb_upper - bb_lower
bb_pos = (current - bb_lower) / bb_range if bb_range > 0 else 0.5
else:
bb_upper = bb_lower = bb_mid = 0
bb_pos = 0.5
# --- ATR ---
atr_series = ta.atr(high, low, close, length=14)
atr = float(atr_series.iloc[-1]) if atr_series is not None else 0
atr_pct = (atr / float(close.iloc[-1])) * 100
# --- Volume score ---
vol_mean = float(volume.rolling(20).mean().iloc[-1])
vol_current = float(volume.iloc[-1])
vol_ratio = vol_current / vol_mean if vol_mean > 0 else 1.0
vol_score = min(100, vol_ratio * 50) # Genormaliseerd 0-100
return {
"rsi": rsi,
"ema9": ema9,
"ema21": ema21,
"ema50": ema50,
"ema50_slope": ema50_slope,
"vwap": vwap,
"bb_upper": bb_upper,
"bb_lower": bb_lower,
"bb_mid": bb_mid,
"bb_position": bb_pos,
"atr": atr,
"atr_pct": atr_pct,
"volume_score": vol_score,
"close": float(close.iloc[-1]),
}
except Exception as e:
logger.error(f"Indicator berekening mislukt: {e}")
return {}
def score_asset(symbol: str, indicators: dict) -> AssetScore:
"""
Vertaal indicator waarden naar een opportunity score (0-100).
Hogere score = interessanter voor trading.
Scoring breakdown:
- RSI in koopzone (40-55): +25 punten
- EMA bullish crossover: +20 punten
- Prijs onder VWAP: +20 punten
- BB positie laag (< 0.4): +15 punten
- Volume boven gemiddeld: +10 punten
- Uptrend (EMA50 slope +): +10 punten
Totaal max: 100
"""
if not indicators:
return AssetScore(
symbol=symbol, opportunity_score=0, rsi=0, rsi_signal="unknown",
ema9=0, ema21=0, ema_signal="unknown", vwap=0, vwap_signal="unknown",
bb_position=0.5, atr_pct=0, volume_score=0,
trend_strength="unknown", tradeable=False
)
score = 0.0
rsi = indicators["rsi"]
close = indicators["close"]
# RSI beoordeling
if 40 <= rsi <= 55:
score += 25
rsi_signal = "koopzone"
elif rsi < 30:
score += 15 # Oversold maar gevaarlijk — minder punten
rsi_signal = "oversold"
elif rsi > 70:
rsi_signal = "overbought"
else:
score += 10
rsi_signal = "neutral"
# EMA crossover
ema9 = indicators["ema9"]
ema21 = indicators["ema21"]
if ema9 > ema21:
score += 20
ema_signal = "bullish"
elif ema9 < ema21 * 0.995:
ema_signal = "bearish"
else:
score += 8
ema_signal = "neutral"
# VWAP positie
vwap = indicators["vwap"]
if close < vwap:
score += 20
vwap_signal = "onder_vwap" # Potentiële reversion kans
else:
score += 5
vwap_signal = "boven_vwap"
# Bollinger Band positie
bb_pos = indicators["bb_position"]
if bb_pos < 0.25:
score += 15 # Dicht bij lower band
elif bb_pos < 0.4:
score += 10
# Volume
vol_score = indicators["volume_score"]
score += (vol_score / 100) * 10
# EMA50 trend
if indicators["ema50_slope"] > 0:
score += 10
trend_strength = "strong" if indicators["ema50_slope"] > indicators["close"] * 0.001 else "moderate"
else:
trend_strength = "weak"
# ATR filter — te volatiel = niet tradeable
atr_pct = indicators["atr_pct"]
tradeable = atr_pct < 5.0 # Max 5% ATR
return AssetScore(
symbol=symbol,
opportunity_score=round(min(100, score), 1),
rsi=round(rsi, 1),
rsi_signal=rsi_signal,
ema9=round(ema9, 4),
ema21=round(ema21, 4),
ema_signal=ema_signal,
vwap=round(vwap, 4),
vwap_signal=vwap_signal,
bb_position=round(bb_pos, 3),
atr_pct=round(atr_pct, 2),
volume_score=round(vol_score, 1),
trend_strength=trend_strength,
tradeable=tradeable,
)
+78
View File
@@ -0,0 +1,78 @@
"""
exchange.py — Kraken connector via CCXT
Verantwoordelijk voor: marktdata ophalen, OHLCV, ticker info
"""
import ccxt
import os
import pandas as pd
from typing import Optional
import logging
logger = logging.getLogger(__name__)
def get_exchange() -> ccxt.kraken:
"""Initialiseer Kraken exchange connectie."""
return ccxt.kraken({
"apiKey": os.getenv("KRAKEN_API_KEY", ""),
"secret": os.getenv("KRAKEN_SECRET", ""),
"enableRateLimit": True,
"options": {
"defaultType": "spot",
}
})
# Universe van tradeable pairs — uitbreidbaar
TRADING_UNIVERSE = [
"BTC/EUR", "ETH/EUR", "SOL/EUR", "ADA/EUR",
"XRP/EUR", "DOT/EUR", "MATIC/EUR", "LINK/EUR",
"AVAX/EUR", "ATOM/EUR", "UNI/EUR", "LTC/EUR",
"ALGO/EUR", "NEAR/EUR", "FTM/EUR", "MANA/EUR",
"SAND/EUR", "DOGE/EUR", "SHIB/EUR", "OP/EUR",
]
def fetch_ohlcv(symbol: str, timeframe: str = "5m", limit: int = 100) -> pd.DataFrame:
"""
Haal OHLCV candledata op voor een pair.
Timeframe: 1m, 5m, 15m, 1h, 4h, 1d
"""
exchange = get_exchange()
try:
raw = exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
df = pd.DataFrame(raw, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df.set_index("timestamp", inplace=True)
return df
except Exception as e:
logger.error(f"OHLCV fetch mislukt voor {symbol}: {e}")
return pd.DataFrame()
def fetch_tickers(symbols: Optional[list[str]] = None) -> dict:
"""
Haal huidige ticker data op voor meerdere pairs tegelijk.
Geeft dict terug met per symbol: prijs, volume, change%.
"""
exchange = get_exchange()
targets = symbols or TRADING_UNIVERSE
result = {}
try:
tickers = exchange.fetch_tickers(targets)
for symbol, ticker in tickers.items():
result[symbol] = {
"symbol": symbol,
"price": ticker.get("last") or 0,
"volume_24h": ticker.get("quoteVolume") or 0,
"change_24h": ticker.get("percentage") or 0,
"high_24h": ticker.get("high") or 0,
"low_24h": ticker.get("low") or 0,
"bid": ticker.get("bid") or 0,
"ask": ticker.get("ask") or 0,
}
except Exception as e:
logger.error(f"Ticker fetch mislukt: {e}")
return result
+230
View File
@@ -0,0 +1,230 @@
"""
main.py — FastAPI backend
Endpoints:
GET /api/assets/ranked — Top 6 ranked assets
GET /api/assets/{symbol} — Detail data voor één asset
GET /api/news/{coin} — Nieuws + sentiment voor één coin
GET /api/health — Health check
"""
import json
import logging
import asyncio
from datetime import datetime
from contextlib import asynccontextmanager
import redis.asyncio as redis
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from ranker import rank_assets, RankedAsset
from exchange import fetch_ohlcv, fetch_tickers, TRADING_UNIVERSE
from sentiment import fetch_sentiment
from analysis import calculate_indicators, score_asset
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Cache TTL in seconden
CACHE_TTL_RANKED = 60 # Ranking elke minuut ververst
CACHE_TTL_DETAIL = 30 # Detail data elke 30s
CACHE_TTL_NEWS = 120 # Nieuws elke 2 minuten
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start Redis connectie bij opstart."""
app.state.redis = redis.from_url("redis://redis:6379", decode_responses=True)
logger.info("Redis verbonden")
yield
await app.state.redis.close()
app = FastAPI(
title="Crypto Bot API",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
async def get_cached(redis_client, key: str):
"""Haal gecachte data op, of None als niet aanwezig."""
try:
data = await redis_client.get(key)
return json.loads(data) if data else None
except Exception:
return None
async def set_cached(redis_client, key: str, data, ttl: int):
"""Sla data op in cache."""
try:
await redis_client.setex(key, ttl, json.dumps(data, default=str))
except Exception as e:
logger.warning(f"Cache write mislukt: {e}")
@app.get("/api/health")
async def health():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
@app.get("/api/assets/ranked")
async def get_ranked_assets():
"""
Geeft top 6 ranked assets terug op basis van TA + sentiment.
Gecached voor 60 seconden.
"""
cache_key = "ranked_assets"
cached = await get_cached(app.state.redis, cache_key)
if cached:
return {"data": cached, "cached": True, "timestamp": datetime.utcnow().isoformat()}
try:
ranked = await rank_assets(top_n=6)
data = [_serialize_ranked(a) for a in ranked]
await set_cached(app.state.redis, cache_key, data, CACHE_TTL_RANKED)
return {"data": data, "cached": False, "timestamp": datetime.utcnow().isoformat()}
except Exception as e:
logger.error(f"Ranking mislukt: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/assets/{symbol:path}")
async def get_asset_detail(symbol: str):
"""
Detail data voor één asset inclusief OHLCV candles.
symbol: bijv. BTC%2FEUR of BTC/EUR
"""
symbol = symbol.replace("%2F", "/").upper()
cache_key = f"detail_{symbol}"
cached = await get_cached(app.state.redis, cache_key)
if cached:
return {"data": cached, "cached": True}
try:
df = fetch_ohlcv(symbol, timeframe="5m", limit=100)
indicators = calculate_indicators(df)
ta_score = score_asset(symbol, indicators)
coin_code = symbol.split("/")[0]
sentiment = await fetch_sentiment(coin_code)
tickers = fetch_tickers([symbol])
ticker = tickers.get(symbol, {})
# Candles voor grafiek
candles = []
if not df.empty:
for ts, row in df.iterrows():
candles.append({
"time": ts.isoformat(),
"open": row["open"],
"high": row["high"],
"low": row["low"],
"close": row["close"],
"volume": row["volume"],
})
data = {
"symbol": symbol,
"price": ticker.get("price", 0),
"change_24h": ticker.get("change_24h", 0),
"volume_24h": ticker.get("volume_24h", 0),
"indicators": indicators,
"ta_score": ta_score.opportunity_score,
"tradeable": ta_score.tradeable,
"sentiment": {
"score": sentiment.score,
"label": sentiment.label,
"confidence": sentiment.confidence,
"news_count": sentiment.news_count,
"news": [
{
"title": n.title,
"source": n.source,
"url": n.url,
"sentiment": n.sentiment,
"score": n.score,
"published": n.published.isoformat(),
}
for n in sentiment.recent_news
],
},
"candles": candles[-50:], # Laatste 50 candles
}
await set_cached(app.state.redis, cache_key, data, CACHE_TTL_DETAIL)
return {"data": data, "cached": False}
except Exception as e:
logger.error(f"Detail fetch mislukt voor {symbol}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/news/{coin}")
async def get_news(coin: str):
"""Nieuws + sentiment voor één coin. bijv. /api/news/BTC"""
coin = coin.upper()
cache_key = f"news_{coin}"
cached = await get_cached(app.state.redis, cache_key)
if cached:
return {"data": cached, "cached": True}
try:
sentiment = await fetch_sentiment(coin)
data = {
"coin": coin,
"score": sentiment.score,
"label": sentiment.label,
"confidence": sentiment.confidence,
"news_count": sentiment.news_count,
"news": [
{
"title": n.title,
"source": n.source,
"url": n.url,
"sentiment": n.sentiment,
"score": n.score,
"published": n.published.isoformat(),
}
for n in sentiment.recent_news
],
}
await set_cached(app.state.redis, cache_key, data, CACHE_TTL_NEWS)
return {"data": data, "cached": False}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def _serialize_ranked(asset: RankedAsset) -> dict:
return {
"symbol": asset.symbol,
"rank": asset.rank,
"combined_score": asset.combined_score,
"ta_score": asset.ta_score,
"sentiment_score": asset.sentiment_score,
"sentiment_label": asset.sentiment_label,
"sentiment_news": asset.sentiment_news,
"rsi": asset.rsi,
"rsi_signal": asset.rsi_signal,
"ema_signal": asset.ema_signal,
"vwap_signal": asset.vwap_signal,
"trend_strength": asset.trend_strength,
"atr_pct": asset.atr_pct,
"volume_score": asset.volume_score,
"price": asset.price,
"change_24h": asset.change_24h,
"volume_24h": asset.volume_24h,
"tradeable": asset.tradeable,
"reason": asset.reason,
}
+152
View File
@@ -0,0 +1,152 @@
"""
ranker.py — Asset ranker
Combineert: technische analyse score + sentiment score
Geeft top 6 tradeable assets terug uit het universe
"""
import asyncio
import logging
from dataclasses import dataclass
from exchange import fetch_ohlcv, fetch_tickers, TRADING_UNIVERSE
from analysis import calculate_indicators, score_asset, AssetScore
from sentiment import fetch_all_sentiments, SentimentResult
logger = logging.getLogger(__name__)
# Gewichten voor gecombineerde score
TA_WEIGHT = 0.70 # Technische analyse weegt zwaarder
SENTIMENT_WEIGHT = 0.30 # Sentiment is bevestigend, niet leidend
@dataclass
class RankedAsset:
symbol: str
rank: int
combined_score: float # 0-100
ta_score: float # 0-100 (AssetScore.opportunity_score)
sentiment_score: float # -1.0 tot +1.0
sentiment_label: str
sentiment_news: int
rsi: float
rsi_signal: str
ema_signal: str
vwap_signal: str
trend_strength: str
atr_pct: float
volume_score: float
price: float
change_24h: float
volume_24h: float
tradeable: bool
reason: str # Mensleesbare reden voor ranking
async def rank_assets(top_n: int = 6) -> list[RankedAsset]:
"""
Rangschik alle assets in het universe op combined score.
Geeft top_n tradeable assets terug.
"""
logger.info(f"Start ranking van {len(TRADING_UNIVERSE)} assets")
# 1. Ticker data ophalen (prijs, volume, 24h change)
tickers = fetch_tickers(TRADING_UNIVERSE)
# 2. OHLCV + TA berekeningen — sequentieel om rate limits te respecteren
ta_scores: dict[str, AssetScore] = {}
for symbol in TRADING_UNIVERSE:
try:
df = fetch_ohlcv(symbol, timeframe="5m", limit=100)
indicators = calculate_indicators(df)
ta_scores[symbol] = score_asset(symbol, indicators)
except Exception as e:
logger.warning(f"TA berekening mislukt voor {symbol}: {e}")
# 3. Sentiment — parallel ophalen
coin_codes = [s.split("/")[0] for s in TRADING_UNIVERSE]
sentiments = await fetch_all_sentiments(coin_codes)
# 4. Combineer scores
ranked = []
for symbol in TRADING_UNIVERSE:
ta = ta_scores.get(symbol)
coin_code = symbol.split("/")[0]
sent: SentimentResult = sentiments.get(coin_code)
ticker = tickers.get(symbol, {})
if not ta or not sent:
continue
# Sentiment score omzetten naar 0-100 schaal
sent_normalized = (sent.score + 1) * 50 # -1→0, 0→50, +1→100
combined = (
ta.opportunity_score * TA_WEIGHT +
sent_normalized * SENTIMENT_WEIGHT
)
# Reden genereren
reason = _build_reason(ta, sent)
ranked.append(RankedAsset(
symbol=symbol,
rank=0, # Wordt hieronder ingevuld
combined_score=round(combined, 1),
ta_score=ta.opportunity_score,
sentiment_score=sent.score,
sentiment_label=sent.label,
sentiment_news=sent.news_count,
rsi=ta.rsi,
rsi_signal=ta.rsi_signal,
ema_signal=ta.ema_signal,
vwap_signal=ta.vwap_signal,
trend_strength=ta.trend_strength,
atr_pct=ta.atr_pct,
volume_score=ta.volume_score,
price=ticker.get("price", 0),
change_24h=ticker.get("change_24h", 0),
volume_24h=ticker.get("volume_24h", 0),
tradeable=ta.tradeable and sent.score >= 0,
reason=reason,
))
# Sorteer op combined score
ranked.sort(key=lambda x: x.combined_score, reverse=True)
# Ken rang toe
for i, asset in enumerate(ranked):
asset.rank = i + 1
# Geef top_n terug
logger.info(f"Ranking klaar. Top asset: {ranked[0].symbol if ranked else 'geen'}")
return ranked[:top_n]
def _build_reason(ta: AssetScore, sent: SentimentResult) -> str:
"""Genereer een mensleesbare reden voor de ranking."""
parts = []
if ta.rsi_signal == "koopzone":
parts.append("RSI in koopzone")
elif ta.rsi_signal == "oversold":
parts.append("RSI oversold")
if ta.ema_signal == "bullish":
parts.append("EMA bullish")
if ta.vwap_signal == "onder_vwap":
parts.append("prijs onder VWAP")
if ta.bb_position < 0.3:
parts.append("dicht bij BB lower band")
if sent.score > 0.2:
parts.append(f"sterk positief nieuws ({sent.news_count} items)")
elif sent.score > 0:
parts.append("neutraal-positief nieuws")
elif sent.score < -0.1:
parts.append("negatief sentiment")
if not parts:
return "Geen duidelijk signaal"
return " · ".join(parts)
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.0
uvicorn==0.30.6
ccxt==4.3.95
pandas==2.2.3
pandas-ta==0.3.14b
redis==5.1.1
httpx==0.27.2
python-dotenv==1.0.1
pydantic==2.9.2
aiohttp==3.10.5
numpy==2.1.2
+191
View File
@@ -0,0 +1,191 @@
"""
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