231 lines
7.4 KiB
Python
231 lines
7.4 KiB
Python
"""
|
|
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,
|
|
}
|