Add new feature to handle user input
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
analysis.py — Technische analyse engine
|
||||
Berekent: RSI, EMA, VWAP, Bollinger Bands, ATR, volume score
|
||||
Geeft per asset een opportunity score terug (0-100)
|
||||
"""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import numpy as np
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetScore:
|
||||
symbol: str
|
||||
opportunity_score: float # 0-100, hoger = interessanter
|
||||
rsi: float
|
||||
rsi_signal: str # "oversold" | "neutral" | "overbought"
|
||||
ema9: float
|
||||
ema21: float
|
||||
ema_signal: str # "bullish" | "bearish" | "neutral"
|
||||
vwap: float
|
||||
vwap_signal: str # "above" | "below"
|
||||
bb_position: float # 0.0 = lower band, 1.0 = upper band
|
||||
atr_pct: float # ATR als % van prijs
|
||||
volume_score: float # 0-100, relatief volume
|
||||
trend_strength: str # "strong" | "moderate" | "weak"
|
||||
tradeable: bool # False als ATR te hoog of signalen ontbreken
|
||||
|
||||
|
||||
def calculate_indicators(df: pd.DataFrame) -> dict:
|
||||
"""
|
||||
Bereken alle indicatoren op een OHLCV dataframe.
|
||||
Geeft dict terug met indicator waarden.
|
||||
"""
|
||||
if df.empty or len(df) < 30:
|
||||
return {}
|
||||
|
||||
try:
|
||||
close = df["close"]
|
||||
high = df["high"]
|
||||
low = df["low"]
|
||||
volume = df["volume"]
|
||||
|
||||
# --- RSI ---
|
||||
rsi_series = ta.rsi(close, length=14)
|
||||
rsi = float(rsi_series.iloc[-1]) if rsi_series is not None else 50.0
|
||||
|
||||
# --- EMA ---
|
||||
ema9 = float(ta.ema(close, length=9).iloc[-1])
|
||||
ema21 = float(ta.ema(close, length=21).iloc[-1])
|
||||
ema50 = float(ta.ema(close, length=50).iloc[-1])
|
||||
|
||||
# EMA50 slope: positief = uptrend
|
||||
ema50_series = ta.ema(close, length=50)
|
||||
ema50_slope = float(ema50_series.iloc[-1] - ema50_series.iloc[-5]) if len(ema50_series) >= 5 else 0
|
||||
|
||||
# --- VWAP ---
|
||||
typical_price = (high + low + close) / 3
|
||||
cumulative_tp_vol = (typical_price * volume).cumsum()
|
||||
cumulative_vol = volume.cumsum()
|
||||
vwap = float((cumulative_tp_vol / cumulative_vol).iloc[-1])
|
||||
|
||||
# --- Bollinger Bands ---
|
||||
bbands = ta.bbands(close, length=20, std=2)
|
||||
if bbands is not None and not bbands.empty:
|
||||
bb_upper = float(bbands.iloc[-1, 2]) # BBU
|
||||
bb_lower = float(bbands.iloc[-1, 0]) # BBL
|
||||
bb_mid = float(bbands.iloc[-1, 1]) # BBM
|
||||
current = float(close.iloc[-1])
|
||||
bb_range = bb_upper - bb_lower
|
||||
bb_pos = (current - bb_lower) / bb_range if bb_range > 0 else 0.5
|
||||
else:
|
||||
bb_upper = bb_lower = bb_mid = 0
|
||||
bb_pos = 0.5
|
||||
|
||||
# --- ATR ---
|
||||
atr_series = ta.atr(high, low, close, length=14)
|
||||
atr = float(atr_series.iloc[-1]) if atr_series is not None else 0
|
||||
atr_pct = (atr / float(close.iloc[-1])) * 100
|
||||
|
||||
# --- Volume score ---
|
||||
vol_mean = float(volume.rolling(20).mean().iloc[-1])
|
||||
vol_current = float(volume.iloc[-1])
|
||||
vol_ratio = vol_current / vol_mean if vol_mean > 0 else 1.0
|
||||
vol_score = min(100, vol_ratio * 50) # Genormaliseerd 0-100
|
||||
|
||||
return {
|
||||
"rsi": rsi,
|
||||
"ema9": ema9,
|
||||
"ema21": ema21,
|
||||
"ema50": ema50,
|
||||
"ema50_slope": ema50_slope,
|
||||
"vwap": vwap,
|
||||
"bb_upper": bb_upper,
|
||||
"bb_lower": bb_lower,
|
||||
"bb_mid": bb_mid,
|
||||
"bb_position": bb_pos,
|
||||
"atr": atr,
|
||||
"atr_pct": atr_pct,
|
||||
"volume_score": vol_score,
|
||||
"close": float(close.iloc[-1]),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Indicator berekening mislukt: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def score_asset(symbol: str, indicators: dict) -> AssetScore:
|
||||
"""
|
||||
Vertaal indicator waarden naar een opportunity score (0-100).
|
||||
Hogere score = interessanter voor trading.
|
||||
|
||||
Scoring breakdown:
|
||||
- RSI in koopzone (40-55): +25 punten
|
||||
- EMA bullish crossover: +20 punten
|
||||
- Prijs onder VWAP: +20 punten
|
||||
- BB positie laag (< 0.4): +15 punten
|
||||
- Volume boven gemiddeld: +10 punten
|
||||
- Uptrend (EMA50 slope +): +10 punten
|
||||
Totaal max: 100
|
||||
"""
|
||||
if not indicators:
|
||||
return AssetScore(
|
||||
symbol=symbol, opportunity_score=0, rsi=0, rsi_signal="unknown",
|
||||
ema9=0, ema21=0, ema_signal="unknown", vwap=0, vwap_signal="unknown",
|
||||
bb_position=0.5, atr_pct=0, volume_score=0,
|
||||
trend_strength="unknown", tradeable=False
|
||||
)
|
||||
|
||||
score = 0.0
|
||||
rsi = indicators["rsi"]
|
||||
close = indicators["close"]
|
||||
|
||||
# RSI beoordeling
|
||||
if 40 <= rsi <= 55:
|
||||
score += 25
|
||||
rsi_signal = "koopzone"
|
||||
elif rsi < 30:
|
||||
score += 15 # Oversold maar gevaarlijk — minder punten
|
||||
rsi_signal = "oversold"
|
||||
elif rsi > 70:
|
||||
rsi_signal = "overbought"
|
||||
else:
|
||||
score += 10
|
||||
rsi_signal = "neutral"
|
||||
|
||||
# EMA crossover
|
||||
ema9 = indicators["ema9"]
|
||||
ema21 = indicators["ema21"]
|
||||
if ema9 > ema21:
|
||||
score += 20
|
||||
ema_signal = "bullish"
|
||||
elif ema9 < ema21 * 0.995:
|
||||
ema_signal = "bearish"
|
||||
else:
|
||||
score += 8
|
||||
ema_signal = "neutral"
|
||||
|
||||
# VWAP positie
|
||||
vwap = indicators["vwap"]
|
||||
if close < vwap:
|
||||
score += 20
|
||||
vwap_signal = "onder_vwap" # Potentiële reversion kans
|
||||
else:
|
||||
score += 5
|
||||
vwap_signal = "boven_vwap"
|
||||
|
||||
# Bollinger Band positie
|
||||
bb_pos = indicators["bb_position"]
|
||||
if bb_pos < 0.25:
|
||||
score += 15 # Dicht bij lower band
|
||||
elif bb_pos < 0.4:
|
||||
score += 10
|
||||
|
||||
# Volume
|
||||
vol_score = indicators["volume_score"]
|
||||
score += (vol_score / 100) * 10
|
||||
|
||||
# EMA50 trend
|
||||
if indicators["ema50_slope"] > 0:
|
||||
score += 10
|
||||
trend_strength = "strong" if indicators["ema50_slope"] > indicators["close"] * 0.001 else "moderate"
|
||||
else:
|
||||
trend_strength = "weak"
|
||||
|
||||
# ATR filter — te volatiel = niet tradeable
|
||||
atr_pct = indicators["atr_pct"]
|
||||
tradeable = atr_pct < 5.0 # Max 5% ATR
|
||||
|
||||
return AssetScore(
|
||||
symbol=symbol,
|
||||
opportunity_score=round(min(100, score), 1),
|
||||
rsi=round(rsi, 1),
|
||||
rsi_signal=rsi_signal,
|
||||
ema9=round(ema9, 4),
|
||||
ema21=round(ema21, 4),
|
||||
ema_signal=ema_signal,
|
||||
vwap=round(vwap, 4),
|
||||
vwap_signal=vwap_signal,
|
||||
bb_position=round(bb_pos, 3),
|
||||
atr_pct=round(atr_pct, 2),
|
||||
volume_score=round(vol_score, 1),
|
||||
trend_strength=trend_strength,
|
||||
tradeable=tradeable,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user