diff --git a/Dockerfile b/backend/Dockerfile similarity index 100% rename from Dockerfile rename to backend/Dockerfile diff --git a/analysis.py b/backend/analysis.py similarity index 100% rename from analysis.py rename to backend/analysis.py diff --git a/backend/exchange.py b/backend/exchange.py new file mode 100644 index 0000000..d7d227c --- /dev/null +++ b/backend/exchange.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b08ee9c --- /dev/null +++ b/backend/main.py @@ -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, + } diff --git a/backend/ranker.py b/backend/ranker.py new file mode 100644 index 0000000..927ceb9 --- /dev/null +++ b/backend/ranker.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..36ce10f --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/sentiment.py b/backend/sentiment.py new file mode 100644 index 0000000..7722ccb --- /dev/null +++ b/backend/sentiment.py @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f90742e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY index.html /usr/share/nginx/html/index.html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ac10136 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,854 @@ + + + + + +Crypto Radar + + + + + +
+ +
+ +
+
+ --:--:-- + +
+
+ + + + + +
+ +
+
+
geselecteerd
+
—
+
—
+
+
+
prijs (EUR)
+
—
+
+
+
+
opportunity score
+
—
+
—
+
+
+
sentiment
+
—
+
—
+
+
+ + +
+ +
+
+
+ GRAFIEK + 5m candles +
+
+
+ +
+
+
+ +
+
+ INDICATOREN +
+
+
+
selecteer een asset
+
+
+
+ +
+
+ SIGNAAL REDEN +
+
+
—
+
+
+
+ + +
+
+
+ SENTIMENT + 0 artikelen +
+
+
—
+
—
+
+
+
+
+ negatiefneutraalpositief +
+
+
+ +
+
+ NIEUWS +
+
+
+
selecteer een asset
+
+
+
+
+
+
+
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8c37f56 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls naar backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +}