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
View File
View File
+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
+3
View File
@@ -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
+854
View File
@@ -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>
+16
View File
@@ -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;
}
}