Add new feature to handle user input
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,854 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Crypto Radar</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0e17;
|
||||||
|
--surface: #111827;
|
||||||
|
--surface2: #1a2235;
|
||||||
|
--border: #1e2d45;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #64748b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-dim: #1d4ed8;
|
||||||
|
--green: #10b981;
|
||||||
|
--green-dim: #064e3b;
|
||||||
|
--red: #ef4444;
|
||||||
|
--red-dim: #450a0a;
|
||||||
|
--yellow: #f59e0b;
|
||||||
|
--mono: 'IBM Plex Mono', monospace;
|
||||||
|
--sans: 'IBM Plex Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Layout ─── */
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 56px 1fr;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header ─── */
|
||||||
|
header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.logo span { color: var(--accent); }
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 6px var(--green);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Sidebar ─── */
|
||||||
|
aside {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.asset-card:hover { background: var(--surface2); }
|
||||||
|
.asset-card.active { border-left-color: var(--accent); background: var(--surface2); }
|
||||||
|
|
||||||
|
.asset-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-price {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.score-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-val {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main content ─── */
|
||||||
|
main {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Summary strip ─── */
|
||||||
|
.summary-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--mono);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Content grid ─── */
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Panel ─── */
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body { padding: 16px; }
|
||||||
|
|
||||||
|
/* ─── Chart ─── */
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Indicators grid ─── */
|
||||||
|
.indicators-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-cell {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ind-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ind-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ind-signal {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Signal pill ─── */
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pill-green { background: var(--green-dim); color: var(--green); }
|
||||||
|
.pill-red { background: var(--red-dim); color: var(--red); }
|
||||||
|
.pill-yellow { background: #292001; color: var(--yellow); }
|
||||||
|
.pill-blue { background: #0f1e3d; color: var(--accent); }
|
||||||
|
|
||||||
|
/* ─── Sentiment panel ─── */
|
||||||
|
.sentiment-score-large {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentiment-bar-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 12px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sentiment-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── News list ─── */
|
||||||
|
.news-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.news-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
}
|
||||||
|
.news-item.pos { border-left-color: var(--green); }
|
||||||
|
.news-item.neg { border-left-color: var(--red); }
|
||||||
|
.news-item.neu { border-left-color: var(--muted); }
|
||||||
|
|
||||||
|
.news-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.news-title a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.news-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reason box ─── */
|
||||||
|
.reason-box {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Change colors ─── */
|
||||||
|
.pos-text { color: var(--green); }
|
||||||
|
.neg-text { color: var(--red); }
|
||||||
|
|
||||||
|
/* ─── Loading / empty ─── */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tradeable badge ─── */
|
||||||
|
.tradeable-yes { color: var(--green); font-family: var(--mono); font-size: 11px; }
|
||||||
|
.tradeable-no { color: var(--red); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
|
/* scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<!-- Header -->
|
||||||
|
<header>
|
||||||
|
<div class="logo">crypto<span>.</span>radar</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<div class="pulse-dot"></div>
|
||||||
|
<span class="timestamp" id="ts">--:--:--</span>
|
||||||
|
<button class="refresh-btn" onclick="loadRanked()">Ververs</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside>
|
||||||
|
<div class="sidebar-label">TOP 6 ASSETS</div>
|
||||||
|
<div id="sidebar-list">
|
||||||
|
<div class="loading">laden...</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<main>
|
||||||
|
<!-- Summary strip -->
|
||||||
|
<div class="summary-strip">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-label">geselecteerd</div>
|
||||||
|
<div class="stat-value" id="sum-symbol">—</div>
|
||||||
|
<div class="stat-sub" id="sum-tradeable">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-label">prijs (EUR)</div>
|
||||||
|
<div class="stat-value" id="sum-price">—</div>
|
||||||
|
<div class="stat-sub" id="sum-change"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-label">opportunity score</div>
|
||||||
|
<div class="stat-value" id="sum-score">—</div>
|
||||||
|
<div class="stat-sub" id="sum-trend">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-label">sentiment</div>
|
||||||
|
<div class="stat-value" id="sum-sentiment">—</div>
|
||||||
|
<div class="stat-sub" id="sum-sent-label">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart + sentiment -->
|
||||||
|
<div class="content-grid">
|
||||||
|
<!-- Left: chart + indicators -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title" id="chart-title">GRAFIEK</span>
|
||||||
|
<span class="panel-subtitle">5m candles</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="priceChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">INDICATOREN</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="indicators-grid" id="indicators-grid">
|
||||||
|
<div class="loading">selecteer een asset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">SIGNAAL REDEN</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="reason-box" id="reason-box">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: sentiment + news -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">SENTIMENT</span>
|
||||||
|
<span class="panel-subtitle" id="news-count">0 artikelen</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="sentiment-score-large" id="sent-score-large">—</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);font-family:var(--mono)" id="sent-label-large">—</div>
|
||||||
|
<div class="sentiment-bar-track">
|
||||||
|
<div class="sentiment-bar-fill" id="sent-bar" style="width:50%;background:var(--muted)"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono)">
|
||||||
|
<span>negatief</span><span>neutraal</span><span>positief</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="flex:1;">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">NIEUWS</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="news-list" id="news-list">
|
||||||
|
<div class="loading">selecteer een asset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─── State ───────────────────────────────────────────
|
||||||
|
let rankedData = [];
|
||||||
|
let activeSymbol = null;
|
||||||
|
let priceChart = null;
|
||||||
|
|
||||||
|
// ─── API base — zelfde origin via nginx proxy ─────────
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
// ─── Timestamp tick ──────────────────────────────────
|
||||||
|
setInterval(() => {
|
||||||
|
document.getElementById('ts').textContent =
|
||||||
|
new Date().toLocaleTimeString('nl-NL', { hour12: false });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// ─── Initialiseer chart ───────────────────────────────
|
||||||
|
function initChart() {
|
||||||
|
const ctx = document.getElementById('priceChart').getContext('2d');
|
||||||
|
priceChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Sluitprijs',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.1,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
borderColor: '#1e2d45',
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: '#64748b',
|
||||||
|
bodyColor: '#e2e8f0',
|
||||||
|
titleFont: { family: 'IBM Plex Mono', size: 10 },
|
||||||
|
bodyFont: { family: 'IBM Plex Mono', size: 11 },
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => `€ ${ctx.parsed.y.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4})}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: '#1e2d45' },
|
||||||
|
ticks: { color: '#64748b', font: { family: 'IBM Plex Mono', size: 9 }, maxTicksLimit: 8 },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
position: 'right',
|
||||||
|
grid: { color: '#1e2d45' },
|
||||||
|
ticks: { color: '#64748b', font: { family: 'IBM Plex Mono', size: 9 },
|
||||||
|
callback: v => '€' + v.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Laad ranked assets ───────────────────────────────
|
||||||
|
async function loadRanked() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/assets/ranked`);
|
||||||
|
const json = await res.json();
|
||||||
|
rankedData = json.data || [];
|
||||||
|
renderSidebar(rankedData);
|
||||||
|
|
||||||
|
// Selecteer eerste asset automatisch
|
||||||
|
if (rankedData.length > 0 && !activeSymbol) {
|
||||||
|
selectAsset(rankedData[0].symbol);
|
||||||
|
} else if (activeSymbol) {
|
||||||
|
loadDetail(activeSymbol);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('sidebar-list').innerHTML =
|
||||||
|
`<div class="loading">fout: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render sidebar ───────────────────────────────────
|
||||||
|
function renderSidebar(assets) {
|
||||||
|
const el = document.getElementById('sidebar-list');
|
||||||
|
if (!assets.length) {
|
||||||
|
el.innerHTML = '<div class="loading">geen data</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = assets.map(a => {
|
||||||
|
const changeClass = a.change_24h >= 0 ? 'pos-text' : 'neg-text';
|
||||||
|
const changeSign = a.change_24h >= 0 ? '+' : '';
|
||||||
|
const coin = a.symbol.split('/')[0];
|
||||||
|
return `
|
||||||
|
<div class="asset-card ${a.symbol === activeSymbol ? 'active' : ''}"
|
||||||
|
onclick="selectAsset('${a.symbol}')">
|
||||||
|
<div class="asset-row">
|
||||||
|
<span class="asset-name">
|
||||||
|
<span class="rank-badge">#${a.rank}</span>${coin}
|
||||||
|
</span>
|
||||||
|
<span class="asset-price ${changeClass}">
|
||||||
|
${changeSign}${a.change_24h.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-meta">
|
||||||
|
<div class="score-bar-wrap">
|
||||||
|
<div class="score-bar" style="width:${a.combined_score}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="score-val">${a.combined_score}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Selecteer asset ──────────────────────────────────
|
||||||
|
function selectAsset(symbol) {
|
||||||
|
activeSymbol = symbol;
|
||||||
|
renderSidebar(rankedData);
|
||||||
|
loadDetail(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Laad detail ─────────────────────────────────────
|
||||||
|
async function loadDetail(symbol) {
|
||||||
|
const encodedSymbol = symbol.replace('/', '%2F');
|
||||||
|
|
||||||
|
// Summary strip alvast bijwerken met ranked data
|
||||||
|
const rankedAsset = rankedData.find(a => a.symbol === symbol);
|
||||||
|
if (rankedAsset) updateSummaryFromRanked(rankedAsset);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/assets/${encodedSymbol}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const d = json.data;
|
||||||
|
|
||||||
|
updateSummaryFull(symbol, d);
|
||||||
|
updateChart(symbol, d.candles);
|
||||||
|
updateIndicators(d.indicators, d.ta_score, rankedAsset);
|
||||||
|
updateSentiment(d.sentiment);
|
||||||
|
updateNews(d.sentiment.news);
|
||||||
|
|
||||||
|
document.getElementById('reason-box').textContent =
|
||||||
|
rankedAsset ? rankedAsset.reason : '—';
|
||||||
|
document.getElementById('chart-title').textContent =
|
||||||
|
`${symbol.replace('/', '/EUR — ')} PRIJS`;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Detail load fout:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary updates ─────────────────────────────────
|
||||||
|
function updateSummaryFromRanked(a) {
|
||||||
|
const coin = a.symbol.split('/')[0];
|
||||||
|
document.getElementById('sum-symbol').textContent = coin;
|
||||||
|
document.getElementById('sum-score').textContent = a.combined_score;
|
||||||
|
document.getElementById('sum-trend').textContent = `trend: ${a.trend_strength}`;
|
||||||
|
document.getElementById('sum-sentiment').textContent = a.sentiment_score.toFixed(2);
|
||||||
|
document.getElementById('sum-sent-label').textContent = a.sentiment_label;
|
||||||
|
|
||||||
|
const trEl = document.getElementById('sum-tradeable');
|
||||||
|
trEl.className = a.tradeable ? 'tradeable-yes' : 'tradeable-no';
|
||||||
|
trEl.textContent = a.tradeable ? '✓ tradeable' : '✗ niet tradeable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryFull(symbol, d) {
|
||||||
|
const price = d.price || 0;
|
||||||
|
const change = d.change_24h || 0;
|
||||||
|
const changeEl = document.getElementById('sum-change');
|
||||||
|
|
||||||
|
document.getElementById('sum-price').textContent =
|
||||||
|
'€ ' + price.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4});
|
||||||
|
|
||||||
|
changeEl.textContent = `${change >= 0 ? '+' : ''}${change.toFixed(2)}% (24u)`;
|
||||||
|
changeEl.className = change >= 0 ? 'pos-text' : 'neg-text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart update ─────────────────────────────────────
|
||||||
|
function updateChart(symbol, candles) {
|
||||||
|
if (!candles || !candles.length) return;
|
||||||
|
|
||||||
|
const labels = candles.map(c => {
|
||||||
|
const d = new Date(c.time);
|
||||||
|
return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
const prices = candles.map(c => c.close);
|
||||||
|
|
||||||
|
priceChart.data.labels = labels;
|
||||||
|
priceChart.data.datasets[0].data = prices;
|
||||||
|
priceChart.data.datasets[0].label = symbol;
|
||||||
|
priceChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Indicators update ────────────────────────────────
|
||||||
|
function updateIndicators(ind, taScore, ranked) {
|
||||||
|
const el = document.getElementById('indicators-grid');
|
||||||
|
if (!ind) { el.innerHTML = '<div class="loading">geen data</div>'; return; }
|
||||||
|
|
||||||
|
const rsiClass = ind.rsi < 30 ? 'pill-yellow' :
|
||||||
|
ind.rsi > 70 ? 'pill-red' : 'pill-green';
|
||||||
|
|
||||||
|
const emaClass = ranked && ranked.ema_signal === 'bullish' ? 'pill-green' :
|
||||||
|
ranked && ranked.ema_signal === 'bearish' ? 'pill-red' : 'pill-yellow';
|
||||||
|
|
||||||
|
const vwapClass = ranked && ranked.vwap_signal === 'onder_vwap' ? 'pill-green' : 'pill-blue';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">RSI (14)</div>
|
||||||
|
<div class="ind-value">${ind.rsi ? ind.rsi.toFixed(1) : '—'}</div>
|
||||||
|
<div class="ind-signal">
|
||||||
|
<span class="pill ${rsiClass}">${ranked ? ranked.rsi_signal : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">EMA 9 / 21</div>
|
||||||
|
<div class="ind-value" style="font-size:12px">
|
||||||
|
${ind.ema9 ? ind.ema9.toFixed(2) : '—'}<br>
|
||||||
|
<span style="color:var(--muted);font-size:11px">${ind.ema21 ? ind.ema21.toFixed(2) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ind-signal">
|
||||||
|
<span class="pill ${emaClass}">${ranked ? ranked.ema_signal : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">VWAP</div>
|
||||||
|
<div class="ind-value" style="font-size:13px">${ind.vwap ? ind.vwap.toFixed(2) : '—'}</div>
|
||||||
|
<div class="ind-signal">
|
||||||
|
<span class="pill ${vwapClass}">${ranked ? ranked.vwap_signal : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">Bollinger positie</div>
|
||||||
|
<div class="ind-value">${ind.bb_position ? (ind.bb_position * 100).toFixed(0) + '%' : '—'}</div>
|
||||||
|
<div class="ind-signal" style="font-size:10px;color:var(--muted)">0% = lower band</div>
|
||||||
|
</div>
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">ATR %</div>
|
||||||
|
<div class="ind-value ${ind.atr_pct > 5 ? 'neg-text' : 'pos-text'}">
|
||||||
|
${ind.atr_pct ? ind.atr_pct.toFixed(2) + '%' : '—'}
|
||||||
|
</div>
|
||||||
|
<div class="ind-signal" style="font-size:10px;color:var(--muted)">
|
||||||
|
${ind.atr_pct > 5 ? 'te volatiel' : 'acceptabel'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="indicator-cell">
|
||||||
|
<div class="ind-label">TA score</div>
|
||||||
|
<div class="ind-value ${taScore > 60 ? 'pos-text' : taScore > 40 ? '' : 'neg-text'}">${taScore || '—'}</div>
|
||||||
|
<div class="ind-signal" style="font-size:10px;color:var(--muted)">van 100</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sentiment update ─────────────────────────────────
|
||||||
|
function updateSentiment(s) {
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
const score = s.score;
|
||||||
|
const scoreEl = document.getElementById('sent-score-large');
|
||||||
|
scoreEl.textContent = score.toFixed(2);
|
||||||
|
scoreEl.style.color = score > 0.15 ? 'var(--green)' :
|
||||||
|
score < -0.15 ? 'var(--red)' : 'var(--yellow)';
|
||||||
|
|
||||||
|
document.getElementById('sent-label-large').textContent =
|
||||||
|
`${s.label} · confidence ${(s.confidence * 100).toFixed(0)}%`;
|
||||||
|
|
||||||
|
document.getElementById('news-count').textContent = `${s.news_count} artikelen`;
|
||||||
|
|
||||||
|
// Bar: score -1→+1 naar 0→100%
|
||||||
|
const pct = ((score + 1) / 2) * 100;
|
||||||
|
const bar = document.getElementById('sent-bar');
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
bar.style.background = score > 0.15 ? 'var(--green)' :
|
||||||
|
score < -0.15 ? 'var(--red)' : 'var(--yellow)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── News update ──────────────────────────────────────
|
||||||
|
function updateNews(news) {
|
||||||
|
const el = document.getElementById('news-list');
|
||||||
|
if (!news || !news.length) {
|
||||||
|
el.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--mono)">geen nieuws beschikbaar</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = news.map(n => {
|
||||||
|
const cls = n.sentiment === 'positive' ? 'pos' :
|
||||||
|
n.sentiment === 'negative' ? 'neg' : 'neu';
|
||||||
|
const pill = n.sentiment === 'positive' ? 'pill-green' :
|
||||||
|
n.sentiment === 'negative' ? 'pill-red' : 'pill-yellow';
|
||||||
|
const time = new Date(n.published).toLocaleString('nl-NL', {
|
||||||
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="news-item ${cls}">
|
||||||
|
<div class="news-title">
|
||||||
|
<a href="${n.url}" target="_blank" rel="noopener">${n.title}</a>
|
||||||
|
</div>
|
||||||
|
<div class="news-meta">
|
||||||
|
<span class="pill ${pill}">${n.sentiment}</span>
|
||||||
|
<span>${n.source}</span>
|
||||||
|
<span>${time}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auto-refresh elke 60s ────────────────────────────
|
||||||
|
setInterval(loadRanked, 60_000);
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────
|
||||||
|
initChart();
|
||||||
|
loadRanked();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user