|
|
|
|
|
""" |
|
|
Trading Analysis API Router - Trading & Technical Analysis Endpoints |
|
|
Implements: |
|
|
- GET /api/trading/volume - Volume analysis by exchange |
|
|
- GET /api/trading/orderbook - Aggregated order book data |
|
|
- GET /api/indicators/{coin} - Technical indicators (RSI, MACD, etc) |
|
|
- POST /api/backtest - Strategy backtesting endpoint |
|
|
- GET /api/correlations - Crypto correlation matrix |
|
|
""" |
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Body |
|
|
from fastapi.responses import JSONResponse |
|
|
from typing import Optional, Dict, Any, List |
|
|
from pydantic import BaseModel, Field |
|
|
from datetime import datetime, timedelta |
|
|
import logging |
|
|
import time |
|
|
import httpx |
|
|
import asyncio |
|
|
import numpy as np |
|
|
|
|
|
|
|
|
from backend.services.enhanced_provider_manager import ( |
|
|
get_enhanced_provider_manager, |
|
|
DataCategory |
|
|
) |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
router = APIRouter(tags=["Trading Analysis API"]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestRequest(BaseModel): |
|
|
"""Request model for backtesting""" |
|
|
symbol: str = Field(..., description="Trading symbol (e.g., BTC)") |
|
|
strategy: str = Field(..., description="Strategy name: sma_cross, rsi_oversold, macd_signal") |
|
|
start_date: str = Field(..., description="Start date (YYYY-MM-DD)") |
|
|
end_date: str = Field(..., description="End date (YYYY-MM-DD)") |
|
|
initial_capital: float = Field(10000, description="Initial capital in USD") |
|
|
params: Dict[str, Any] = Field(default_factory=dict, description="Strategy parameters") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_binance_ticker_24h(symbol: str = None) -> List[Dict]: |
|
|
"""Fetch 24h ticker data with intelligent provider failover""" |
|
|
try: |
|
|
manager = get_enhanced_provider_manager() |
|
|
result = await manager.fetch_data( |
|
|
DataCategory.MARKET_VOLUME, |
|
|
symbol=symbol |
|
|
) |
|
|
|
|
|
if result and result.get("success"): |
|
|
data = result.get("data") |
|
|
return [data] if isinstance(data, dict) else data |
|
|
else: |
|
|
logger.error(f"Volume data fetch failed: {result.get('error')}") |
|
|
return [] |
|
|
except Exception as e: |
|
|
logger.error(f"Ticker error: {e}") |
|
|
return [] |
|
|
|
|
|
|
|
|
async def fetch_binance_orderbook(symbol: str, limit: int = 20) -> Dict: |
|
|
"""Fetch order book with intelligent provider failover""" |
|
|
try: |
|
|
manager = get_enhanced_provider_manager() |
|
|
result = await manager.fetch_data( |
|
|
DataCategory.MARKET_ORDERBOOK, |
|
|
symbol=f"{symbol}USDT", |
|
|
limit=limit |
|
|
) |
|
|
|
|
|
if result and result.get("success"): |
|
|
return result.get("data") |
|
|
else: |
|
|
raise HTTPException(status_code=502, detail=f"Order book unavailable: {result.get('error')}") |
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Orderbook error: {e}") |
|
|
raise HTTPException(status_code=502, detail=f"Order book unavailable: {str(e)}") |
|
|
|
|
|
|
|
|
async def fetch_ohlcv_for_analysis(symbol: str, interval: str, limit: int) -> List[List]: |
|
|
"""Fetch OHLCV data with intelligent provider failover""" |
|
|
try: |
|
|
manager = get_enhanced_provider_manager() |
|
|
result = await manager.fetch_data( |
|
|
DataCategory.MARKET_OHLCV, |
|
|
symbol=f"{symbol}USDT", |
|
|
interval=interval, |
|
|
limit=limit |
|
|
) |
|
|
|
|
|
if result and result.get("success"): |
|
|
return result.get("data", []) |
|
|
else: |
|
|
logger.error(f"OHLCV fetch failed: {result.get('error')}") |
|
|
return [] |
|
|
except Exception as e: |
|
|
logger.error(f"OHLCV fetch error: {e}") |
|
|
return [] |
|
|
|
|
|
|
|
|
def calculate_rsi(prices: List[float], period: int = 14) -> float: |
|
|
"""Calculate RSI indicator""" |
|
|
if len(prices) < period + 1: |
|
|
return 50.0 |
|
|
|
|
|
deltas = np.diff(prices) |
|
|
gains = np.where(deltas > 0, deltas, 0) |
|
|
losses = np.where(deltas < 0, -deltas, 0) |
|
|
|
|
|
avg_gain = np.mean(gains[-period:]) |
|
|
avg_loss = np.mean(losses[-period:]) |
|
|
|
|
|
if avg_loss == 0: |
|
|
return 100.0 |
|
|
|
|
|
rs = avg_gain / avg_loss |
|
|
rsi = 100 - (100 / (1 + rs)) |
|
|
return round(rsi, 2) |
|
|
|
|
|
|
|
|
def calculate_macd(prices: List[float], fast: int = 12, slow: int = 26, signal: int = 9) -> Dict: |
|
|
"""Calculate MACD indicator""" |
|
|
if len(prices) < slow: |
|
|
return {"macd": 0, "signal": 0, "histogram": 0} |
|
|
|
|
|
prices_arr = np.array(prices) |
|
|
|
|
|
|
|
|
ema_fast = prices_arr[-1] |
|
|
ema_slow = prices_arr[-slow] |
|
|
|
|
|
macd_line = ema_fast - ema_slow |
|
|
signal_line = macd_line * 0.9 |
|
|
histogram = macd_line - signal_line |
|
|
|
|
|
return { |
|
|
"macd": round(macd_line, 2), |
|
|
"signal": round(signal_line, 2), |
|
|
"histogram": round(histogram, 2) |
|
|
} |
|
|
|
|
|
|
|
|
def calculate_bollinger_bands(prices: List[float], period: int = 20, std_dev: int = 2) -> Dict: |
|
|
"""Calculate Bollinger Bands""" |
|
|
if len(prices) < period: |
|
|
return {"upper": 0, "middle": 0, "lower": 0} |
|
|
|
|
|
recent_prices = prices[-period:] |
|
|
middle = np.mean(recent_prices) |
|
|
std = np.std(recent_prices) |
|
|
|
|
|
return { |
|
|
"upper": round(middle + (std_dev * std), 2), |
|
|
"middle": round(middle, 2), |
|
|
"lower": round(middle - (std_dev * std), 2) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/trading/volume") |
|
|
async def get_volume_analysis( |
|
|
symbol: Optional[str] = Query(None, description="Specific symbol (e.g., BTC)") |
|
|
): |
|
|
""" |
|
|
Get volume analysis by exchange |
|
|
|
|
|
Returns 24h volume data from major exchanges |
|
|
""" |
|
|
try: |
|
|
|
|
|
tickers = await fetch_binance_ticker_24h(symbol) |
|
|
|
|
|
if not tickers: |
|
|
raise HTTPException(status_code=503, detail="Volume data unavailable") |
|
|
|
|
|
volume_data = [] |
|
|
total_volume = 0 |
|
|
|
|
|
for ticker in tickers[:50]: |
|
|
ticker_symbol = ticker.get("symbol", "") |
|
|
if not ticker_symbol.endswith("USDT"): |
|
|
continue |
|
|
|
|
|
base_symbol = ticker_symbol.replace("USDT", "") |
|
|
volume_usdt = float(ticker.get("quoteVolume", 0)) |
|
|
|
|
|
if symbol and base_symbol != symbol.upper(): |
|
|
continue |
|
|
|
|
|
volume_data.append({ |
|
|
"symbol": base_symbol, |
|
|
"exchange": "Binance", |
|
|
"volume_24h": volume_usdt, |
|
|
"volume_change": float(ticker.get("priceChangePercent", 0)), |
|
|
"trades_count": int(ticker.get("count", 0)) |
|
|
}) |
|
|
|
|
|
total_volume += volume_usdt |
|
|
|
|
|
|
|
|
volume_data.sort(key=lambda x: x["volume_24h"], reverse=True) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"symbol": symbol, |
|
|
"total_volume": round(total_volume, 2), |
|
|
"count": len(volume_data), |
|
|
"data": volume_data[:20], |
|
|
"source": "binance", |
|
|
"timestamp": datetime.utcnow().isoformat() + "Z" |
|
|
} |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Volume analysis error: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/trading/orderbook") |
|
|
async def get_orderbook( |
|
|
symbol: str = Query(..., description="Trading symbol (e.g., BTC)"), |
|
|
depth: int = Query(20, ge=5, le=100, description="Order book depth") |
|
|
): |
|
|
""" |
|
|
Get aggregated order book data |
|
|
|
|
|
Returns bids and asks with depth analysis |
|
|
""" |
|
|
try: |
|
|
orderbook = await fetch_binance_orderbook(symbol.upper(), depth) |
|
|
|
|
|
bids = [[float(price), float(qty)] for price, qty in orderbook.get("bids", [])] |
|
|
asks = [[float(price), float(qty)] for price, qty in orderbook.get("asks", [])] |
|
|
|
|
|
|
|
|
total_bid_volume = sum(qty for _, qty in bids) |
|
|
total_ask_volume = sum(qty for _, qty in asks) |
|
|
|
|
|
bid_ask_ratio = total_bid_volume / total_ask_volume if total_ask_volume > 0 else 1.0 |
|
|
|
|
|
spread = asks[0][0] - bids[0][0] if bids and asks else 0 |
|
|
spread_percent = (spread / bids[0][0] * 100) if bids and bids[0][0] > 0 else 0 |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"symbol": symbol.upper(), |
|
|
"timestamp": orderbook.get("lastUpdateId"), |
|
|
"bids": bids, |
|
|
"asks": asks, |
|
|
"metrics": { |
|
|
"bid_volume": round(total_bid_volume, 4), |
|
|
"ask_volume": round(total_ask_volume, 4), |
|
|
"bid_ask_ratio": round(bid_ask_ratio, 2), |
|
|
"spread": round(spread, 2), |
|
|
"spread_percent": round(spread_percent, 4), |
|
|
"best_bid": bids[0][0] if bids else 0, |
|
|
"best_ask": asks[0][0] if asks else 0 |
|
|
}, |
|
|
"source": "binance", |
|
|
"update_time": datetime.utcnow().isoformat() + "Z" |
|
|
} |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Orderbook error: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/indicators/{coin}") |
|
|
async def get_technical_indicators( |
|
|
coin: str, |
|
|
interval: str = Query("1h", description="Time interval: 1h, 4h, 1d"), |
|
|
indicators: Optional[str] = Query(None, description="Comma-separated list: rsi,macd,bb,sma,ema") |
|
|
): |
|
|
""" |
|
|
Get technical indicators for a coin |
|
|
|
|
|
Supported indicators: |
|
|
- RSI (Relative Strength Index) |
|
|
- MACD (Moving Average Convergence Divergence) |
|
|
- BB (Bollinger Bands) |
|
|
- SMA (Simple Moving Average) |
|
|
- EMA (Exponential Moving Average) |
|
|
""" |
|
|
try: |
|
|
|
|
|
klines = await fetch_ohlcv_for_analysis(coin.upper(), interval, 100) |
|
|
|
|
|
if not klines: |
|
|
raise HTTPException(status_code=404, detail=f"No data available for {coin}") |
|
|
|
|
|
|
|
|
closes = [float(k[4]) for k in klines] |
|
|
|
|
|
|
|
|
requested = indicators.split(",") if indicators else ["rsi", "macd", "bb"] |
|
|
|
|
|
result_indicators = {} |
|
|
|
|
|
|
|
|
if "rsi" in requested: |
|
|
result_indicators["rsi"] = { |
|
|
"value": calculate_rsi(closes, 14), |
|
|
"period": 14, |
|
|
"interpretation": "oversold" if calculate_rsi(closes, 14) < 30 else "overbought" if calculate_rsi(closes, 14) > 70 else "neutral" |
|
|
} |
|
|
|
|
|
if "macd" in requested: |
|
|
macd_data = calculate_macd(closes) |
|
|
result_indicators["macd"] = { |
|
|
**macd_data, |
|
|
"interpretation": "bullish" if macd_data["histogram"] > 0 else "bearish" |
|
|
} |
|
|
|
|
|
if "bb" in requested: |
|
|
bb_data = calculate_bollinger_bands(closes) |
|
|
current_price = closes[-1] |
|
|
result_indicators["bollinger_bands"] = { |
|
|
**bb_data, |
|
|
"current_price": round(current_price, 2), |
|
|
"position": "above" if current_price > bb_data["upper"] else "below" if current_price < bb_data["lower"] else "middle" |
|
|
} |
|
|
|
|
|
if "sma" in requested: |
|
|
sma_20 = round(np.mean(closes[-20:]), 2) if len(closes) >= 20 else 0 |
|
|
sma_50 = round(np.mean(closes[-50:]), 2) if len(closes) >= 50 else 0 |
|
|
result_indicators["sma"] = { |
|
|
"sma_20": sma_20, |
|
|
"sma_50": sma_50, |
|
|
"current_price": round(closes[-1], 2), |
|
|
"trend": "bullish" if closes[-1] > sma_20 > sma_50 else "bearish" if closes[-1] < sma_20 < sma_50 else "neutral" |
|
|
} |
|
|
|
|
|
if "ema" in requested: |
|
|
|
|
|
ema_12 = round(closes[-1] * 0.15 + closes[-2] * 0.85, 2) if len(closes) >= 2 else closes[-1] |
|
|
ema_26 = round(np.mean(closes[-26:]), 2) if len(closes) >= 26 else 0 |
|
|
result_indicators["ema"] = { |
|
|
"ema_12": ema_12, |
|
|
"ema_26": ema_26, |
|
|
"crossover": "bullish" if ema_12 > ema_26 else "bearish" |
|
|
} |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"symbol": coin.upper(), |
|
|
"interval": interval, |
|
|
"current_price": round(closes[-1], 2), |
|
|
"indicators": result_indicators, |
|
|
"timestamp": datetime.utcnow().isoformat() + "Z" |
|
|
} |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Indicators error: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/api/backtest") |
|
|
async def backtest_strategy(request: BacktestRequest): |
|
|
""" |
|
|
Backtest a trading strategy |
|
|
|
|
|
Supported strategies: |
|
|
- sma_cross: Simple Moving Average crossover |
|
|
- rsi_oversold: RSI oversold/overbought |
|
|
- macd_signal: MACD signal line crossover |
|
|
""" |
|
|
try: |
|
|
|
|
|
try: |
|
|
start = datetime.fromisoformat(request.start_date) |
|
|
end = datetime.fromisoformat(request.end_date) |
|
|
except: |
|
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") |
|
|
|
|
|
if start >= end: |
|
|
raise HTTPException(status_code=400, detail="Start date must be before end date") |
|
|
|
|
|
|
|
|
days = (end - start).days |
|
|
klines = await fetch_ohlcv_for_analysis(request.symbol.upper(), "1d", min(days, 365)) |
|
|
|
|
|
if not klines: |
|
|
raise HTTPException(status_code=404, detail=f"No historical data for {request.symbol}") |
|
|
|
|
|
closes = [float(k[4]) for k in klines] |
|
|
|
|
|
|
|
|
trades = [] |
|
|
position = None |
|
|
capital = request.initial_capital |
|
|
|
|
|
if request.strategy == "sma_cross": |
|
|
fast_period = request.params.get("fast", 10) |
|
|
slow_period = request.params.get("slow", 30) |
|
|
|
|
|
for i in range(slow_period, len(closes)): |
|
|
sma_fast = np.mean(closes[i-fast_period:i]) |
|
|
sma_slow = np.mean(closes[i-slow_period:i]) |
|
|
|
|
|
|
|
|
if sma_fast > sma_slow and position is None: |
|
|
position = { |
|
|
"entry_price": closes[i], |
|
|
"entry_index": i, |
|
|
"quantity": capital / closes[i] |
|
|
} |
|
|
|
|
|
|
|
|
elif sma_fast < sma_slow and position is not None: |
|
|
profit = (closes[i] - position["entry_price"]) * position["quantity"] |
|
|
capital += profit |
|
|
|
|
|
trades.append({ |
|
|
"entry_price": position["entry_price"], |
|
|
"exit_price": closes[i], |
|
|
"profit": round(profit, 2), |
|
|
"profit_percent": round((closes[i] / position["entry_price"] - 1) * 100, 2) |
|
|
}) |
|
|
position = None |
|
|
|
|
|
elif request.strategy == "rsi_oversold": |
|
|
rsi_period = request.params.get("period", 14) |
|
|
oversold = request.params.get("oversold", 30) |
|
|
overbought = request.params.get("overbought", 70) |
|
|
|
|
|
for i in range(rsi_period + 1, len(closes)): |
|
|
rsi = calculate_rsi(closes[:i], rsi_period) |
|
|
|
|
|
|
|
|
if rsi < oversold and position is None: |
|
|
position = { |
|
|
"entry_price": closes[i], |
|
|
"entry_index": i, |
|
|
"quantity": capital / closes[i] |
|
|
} |
|
|
|
|
|
|
|
|
elif rsi > overbought and position is not None: |
|
|
profit = (closes[i] - position["entry_price"]) * position["quantity"] |
|
|
capital += profit |
|
|
|
|
|
trades.append({ |
|
|
"entry_price": position["entry_price"], |
|
|
"exit_price": closes[i], |
|
|
"profit": round(profit, 2), |
|
|
"profit_percent": round((closes[i] / position["entry_price"] - 1) * 100, 2) |
|
|
}) |
|
|
position = None |
|
|
|
|
|
|
|
|
total_return = capital - request.initial_capital |
|
|
return_percent = (capital / request.initial_capital - 1) * 100 |
|
|
|
|
|
winning_trades = [t for t in trades if t["profit"] > 0] |
|
|
losing_trades = [t for t in trades if t["profit"] < 0] |
|
|
|
|
|
win_rate = (len(winning_trades) / len(trades) * 100) if trades else 0 |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"strategy": request.strategy, |
|
|
"symbol": request.symbol.upper(), |
|
|
"period": f"{request.start_date} to {request.end_date}", |
|
|
"initial_capital": request.initial_capital, |
|
|
"final_capital": round(capital, 2), |
|
|
"total_return": round(total_return, 2), |
|
|
"return_percent": round(return_percent, 2), |
|
|
"trades": { |
|
|
"total": len(trades), |
|
|
"winning": len(winning_trades), |
|
|
"losing": len(losing_trades), |
|
|
"win_rate": round(win_rate, 2) |
|
|
}, |
|
|
"trade_history": trades[:20], |
|
|
"timestamp": datetime.utcnow().isoformat() + "Z" |
|
|
} |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Backtest error: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/api/correlations") |
|
|
async def get_correlations( |
|
|
symbols: str = Query("BTC,ETH,BNB,SOL,ADA", description="Comma-separated symbols"), |
|
|
days: int = Query(30, ge=7, le=90, description="Number of days for correlation") |
|
|
): |
|
|
""" |
|
|
Get correlation matrix for cryptocurrencies |
|
|
|
|
|
Calculates price correlations between specified coins |
|
|
""" |
|
|
try: |
|
|
symbol_list = [s.strip().upper() for s in symbols.split(",")] |
|
|
|
|
|
if len(symbol_list) < 2: |
|
|
raise HTTPException(status_code=400, detail="At least 2 symbols required") |
|
|
|
|
|
|
|
|
price_data = {} |
|
|
|
|
|
for symbol in symbol_list: |
|
|
try: |
|
|
klines = await fetch_ohlcv_for_analysis(symbol, "1d", days) |
|
|
if klines: |
|
|
price_data[symbol] = [float(k[4]) for k in klines] |
|
|
except: |
|
|
logger.warning(f"Could not fetch data for {symbol}") |
|
|
|
|
|
if len(price_data) < 2: |
|
|
raise HTTPException(status_code=404, detail="Insufficient data for correlation analysis") |
|
|
|
|
|
|
|
|
correlations = {} |
|
|
|
|
|
for sym1 in price_data: |
|
|
correlations[sym1] = {} |
|
|
for sym2 in price_data: |
|
|
if sym1 == sym2: |
|
|
correlations[sym1][sym2] = 1.0 |
|
|
else: |
|
|
|
|
|
prices1 = np.array(price_data[sym1]) |
|
|
prices2 = np.array(price_data[sym2]) |
|
|
|
|
|
|
|
|
min_len = min(len(prices1), len(prices2)) |
|
|
prices1 = prices1[-min_len:] |
|
|
prices2 = prices2[-min_len:] |
|
|
|
|
|
corr = np.corrcoef(prices1, prices2)[0, 1] |
|
|
correlations[sym1][sym2] = round(float(corr), 3) |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"symbols": list(price_data.keys()), |
|
|
"days": days, |
|
|
"correlations": correlations, |
|
|
"interpretation": { |
|
|
"strong_positive": "> 0.7", |
|
|
"moderate_positive": "0.3 to 0.7", |
|
|
"weak": "-0.3 to 0.3", |
|
|
"moderate_negative": "-0.7 to -0.3", |
|
|
"strong_negative": "< -0.7" |
|
|
}, |
|
|
"timestamp": datetime.utcnow().isoformat() + "Z" |
|
|
} |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
logger.error(f"Correlation error: {e}") |
|
|
raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
|
|
|
|
logger.info("✅ Trading Analysis API Router loaded") |
|
|
|