|
|
| """
|
| Technical Indicators API Router (PRODUCTION SAFE)
|
| Provides API endpoints for calculating technical indicators on cryptocurrency data.
|
| Includes: Bollinger Bands, Stochastic RSI, ATR, SMA, EMA, MACD, RSI
|
|
|
| CRITICAL RULES:
|
| - HTTP 400 for insufficient data (NOT HTTP 500)
|
| - Strict minimum candle requirements enforced
|
| - NaN/Infinity values sanitized before response
|
| - Comprehensive logging for all operations
|
| - Never crash - always return valid JSON
|
| """
|
|
|
| from fastapi import APIRouter, HTTPException, Query
|
| from fastapi.responses import JSONResponse
|
| from pydantic import BaseModel, Field
|
| from typing import List, Dict, Any, Optional
|
| from datetime import datetime
|
| import logging
|
| import math
|
| import httpx
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
| router = APIRouter(prefix="/api/indicators", tags=["Technical Indicators"])
|
|
|
|
|
|
|
|
|
| MIN_CANDLES = {
|
| "SMA": 20,
|
| "EMA": 20,
|
| "RSI": 15,
|
| "ATR": 15,
|
| "MACD": 35,
|
| "STOCH_RSI": 50,
|
| "BOLLINGER_BANDS": 20
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| class OHLCVData(BaseModel):
|
| """OHLCV data model"""
|
| timestamp: int
|
| open: float
|
| high: float
|
| low: float
|
| close: float
|
| volume: float
|
|
|
|
|
| class IndicatorRequest(BaseModel):
|
| """Request model for indicator calculation"""
|
| symbol: str = Field(default="BTC", description="Cryptocurrency symbol")
|
| timeframe: str = Field(default="1h", description="Timeframe (1m, 5m, 15m, 1h, 4h, 1d)")
|
| ohlcv: Optional[List[OHLCVData]] = Field(default=None, description="OHLCV data array")
|
| period: int = Field(default=14, description="Indicator period")
|
|
|
|
|
| class AnalyzeBatchRequest(BaseModel):
|
| """Main-app batch indicator request"""
|
| symbol: str = Field(default="BTC")
|
| timeframe: str = Field(default="1h")
|
| indicators: Optional[List[str]] = Field(default=None, description="e.g. ['rsi','macd'] or ['comprehensive']")
|
|
|
|
|
| class BollingerBandsResponse(BaseModel):
|
| """Bollinger Bands response model"""
|
| upper: float
|
| middle: float
|
| lower: float
|
| bandwidth: float
|
| percent_b: float
|
| signal: str
|
| description: str
|
|
|
|
|
| class StochRSIResponse(BaseModel):
|
| """Stochastic RSI response model"""
|
| value: float
|
| k_line: float
|
| d_line: float
|
| signal: str
|
| description: str
|
|
|
|
|
| class ATRResponse(BaseModel):
|
| """Average True Range response model"""
|
| value: float
|
| percent: float
|
| volatility_level: str
|
| signal: str
|
| description: str
|
|
|
|
|
| class SMAResponse(BaseModel):
|
| """Simple Moving Average response model"""
|
| sma20: float
|
| sma50: float
|
| sma200: Optional[float]
|
| price_vs_sma20: str
|
| price_vs_sma50: str
|
| trend: str
|
| signal: str
|
| description: str
|
|
|
|
|
| class EMAResponse(BaseModel):
|
| """Exponential Moving Average response model"""
|
| ema12: float
|
| ema26: float
|
| ema50: Optional[float]
|
| trend: str
|
| signal: str
|
| description: str
|
|
|
|
|
| class MACDResponse(BaseModel):
|
| """MACD response model"""
|
| macd_line: float
|
| signal_line: float
|
| histogram: float
|
| trend: str
|
| signal: str
|
| description: str
|
|
|
|
|
| class RSIResponse(BaseModel):
|
| """RSI response model"""
|
| value: float
|
| signal: str
|
| description: str
|
|
|
|
|
| class ComprehensiveIndicatorsResponse(BaseModel):
|
| """All indicators combined response"""
|
| symbol: str
|
| timeframe: str
|
| timestamp: str
|
| current_price: float
|
| bollinger_bands: BollingerBandsResponse
|
| stoch_rsi: StochRSIResponse
|
| atr: ATRResponse
|
| sma: SMAResponse
|
| ema: EMAResponse
|
| macd: MACDResponse
|
| rsi: RSIResponse
|
| overall_signal: str
|
| recommendation: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| def sanitize_value(value: Any) -> Optional[float]:
|
| """
|
| Sanitize a numeric value - remove NaN, Infinity, None
|
| Returns None if value is invalid, otherwise returns the float value
|
| """
|
| if value is None:
|
| return None
|
| try:
|
| val = float(value)
|
| if math.isnan(val) or math.isinf(val):
|
| return None
|
| return val
|
| except (ValueError, TypeError):
|
| return None
|
|
|
|
|
| def sanitize_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
| """
|
| Sanitize all numeric values in a dictionary
|
| Replace NaN/Infinity with None or 0 depending on context
|
| """
|
| sanitized = {}
|
| for key, value in data.items():
|
| if isinstance(value, dict):
|
| sanitized[key] = sanitize_dict(value)
|
| elif isinstance(value, (int, float)):
|
| clean_val = sanitize_value(value)
|
| sanitized[key] = clean_val if clean_val is not None else 0
|
| else:
|
| sanitized[key] = value
|
| return sanitized
|
|
|
|
|
| def validate_ohlcv_data(ohlcv: Optional[Dict[str, Any]], min_candles: int, symbol: str, indicator: str) -> tuple[bool, Optional[List[float]], Optional[str]]:
|
| """
|
| Validate OHLCV data and extract prices
|
|
|
| Returns:
|
| (is_valid, prices, error_message)
|
| """
|
| if not ohlcv:
|
| logger.warning(f"❌ {indicator} - {symbol}: No OHLCV data received")
|
| return False, None, "No market data available"
|
|
|
| if "prices" not in ohlcv:
|
| logger.warning(f"❌ {indicator} - {symbol}: OHLCV missing 'prices' key")
|
| return False, None, "Invalid market data format"
|
|
|
| prices = [p[1] for p in ohlcv["prices"] if len(p) >= 2]
|
|
|
| if not prices:
|
| logger.warning(f"❌ {indicator} - {symbol}: Empty price array")
|
| return False, None, "No price data available"
|
|
|
| if len(prices) < min_candles:
|
| logger.warning(f"❌ {indicator} - {symbol}: Insufficient candles ({len(prices)} < {min_candles} required)")
|
| return False, None, f"Insufficient market data: need at least {min_candles} candles, got {len(prices)}"
|
|
|
| logger.info(f"✅ {indicator} - {symbol}: Validated {len(prices)} candles (required: {min_candles})")
|
| return True, prices, None
|
|
|
|
|
|
|
|
|
|
|
|
|
| def calculate_sma(prices: List[float], period: int) -> float:
|
| """Calculate Simple Moving Average"""
|
| if len(prices) < period:
|
| return prices[-1] if prices else 0
|
| return sum(prices[-period:]) / period
|
|
|
|
|
| def calculate_ema(prices: List[float], period: int) -> float:
|
| """Calculate Exponential Moving Average"""
|
| if len(prices) < period:
|
| return prices[-1] if prices else 0
|
|
|
| multiplier = 2 / (period + 1)
|
| ema = sum(prices[:period]) / period
|
|
|
| for price in prices[period:]:
|
| ema = (price * multiplier) + (ema * (1 - multiplier))
|
|
|
| return ema
|
|
|
|
|
| def calculate_rsi(prices: List[float], period: int = 14) -> float:
|
| """Calculate Relative Strength Index"""
|
| if len(prices) < period + 1:
|
| return 50.0
|
|
|
| deltas = [prices[i] - prices[i-1] for i in range(1, len(prices))]
|
| gains = [d if d > 0 else 0 for d in deltas[-period:]]
|
| losses = [-d if d < 0 else 0 for d in deltas[-period:]]
|
|
|
| avg_gain = sum(gains) / period
|
| avg_loss = sum(losses) / period
|
|
|
| if avg_loss == 0:
|
| return 100.0 if avg_gain > 0 else 50.0
|
|
|
| rs = avg_gain / avg_loss
|
| return 100 - (100 / (1 + rs))
|
|
|
|
|
| def calculate_bollinger_bands(prices: List[float], period: int = 20, std_dev: float = 2) -> Dict[str, float]:
|
| """Calculate Bollinger Bands"""
|
| if len(prices) < period:
|
| current = prices[-1] if prices else 0
|
| return {
|
| "upper": current,
|
| "middle": current,
|
| "lower": current,
|
| "bandwidth": 0,
|
| "percent_b": 50
|
| }
|
|
|
| recent_prices = prices[-period:]
|
| middle = sum(recent_prices) / period
|
|
|
|
|
| variance = sum((p - middle) ** 2 for p in recent_prices) / period
|
| std = variance ** 0.5
|
|
|
| upper = middle + (std_dev * std)
|
| lower = middle - (std_dev * std)
|
|
|
|
|
| bandwidth = ((upper - lower) / middle) * 100 if middle > 0 else 0
|
|
|
|
|
| current_price = prices[-1]
|
| if upper != lower:
|
| percent_b = ((current_price - lower) / (upper - lower)) * 100
|
| else:
|
| percent_b = 50
|
|
|
| return {
|
| "upper": round(upper, 8),
|
| "middle": round(middle, 8),
|
| "lower": round(lower, 8),
|
| "bandwidth": round(bandwidth, 2),
|
| "percent_b": round(percent_b, 2)
|
| }
|
|
|
|
|
| def calculate_stoch_rsi(prices: List[float], rsi_period: int = 14, stoch_period: int = 14) -> Dict[str, float]:
|
| """Calculate Stochastic RSI"""
|
| if len(prices) < rsi_period + stoch_period:
|
| return {"value": 50, "k_line": 50, "d_line": 50}
|
|
|
|
|
| rsi_values = []
|
| for i in range(stoch_period + 3):
|
| end_idx = len(prices) - stoch_period + i + 1
|
| if end_idx > rsi_period:
|
| slice_prices = prices[:end_idx]
|
| rsi_values.append(calculate_rsi(slice_prices, rsi_period))
|
|
|
| if len(rsi_values) < stoch_period:
|
| return {"value": 50, "k_line": 50, "d_line": 50}
|
|
|
| recent_rsi = rsi_values[-stoch_period:]
|
| rsi_high = max(recent_rsi)
|
| rsi_low = min(recent_rsi)
|
|
|
| current_rsi = rsi_values[-1]
|
|
|
| if rsi_high == rsi_low:
|
| stoch_rsi = 50
|
| else:
|
| stoch_rsi = ((current_rsi - rsi_low) / (rsi_high - rsi_low)) * 100
|
|
|
|
|
| k_line = stoch_rsi
|
|
|
|
|
| if len(rsi_values) >= 3:
|
| k_values = []
|
| for i in range(3):
|
| idx = -3 + i
|
| window = rsi_values[idx - stoch_period + 1:idx + 1] if idx + 1 <= 0 else recent_rsi
|
| if not window:
|
| k_values.append(50)
|
| continue
|
| r_high = max(window)
|
| r_low = min(window)
|
| curr = rsi_values[idx]
|
| if r_high != r_low:
|
| k_values.append(((curr - r_low) / (r_high - r_low)) * 100)
|
| else:
|
| k_values.append(50)
|
| d_line = sum(k_values) / 3
|
| else:
|
| d_line = k_line
|
|
|
| return {
|
| "value": round(stoch_rsi, 2),
|
| "k_line": round(k_line, 2),
|
| "d_line": round(d_line, 2)
|
| }
|
|
|
|
|
| def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> float:
|
| """Calculate Average True Range"""
|
| if len(closes) < period + 1:
|
| if len(highs) > 0 and len(lows) > 0:
|
| return highs[-1] - lows[-1]
|
| return 0
|
|
|
| true_ranges = []
|
| for i in range(1, len(closes)):
|
| high = highs[i]
|
| low = lows[i]
|
| prev_close = closes[i-1]
|
|
|
| tr = max(
|
| high - low,
|
| abs(high - prev_close),
|
| abs(low - prev_close)
|
| )
|
| true_ranges.append(tr)
|
|
|
|
|
| if len(true_ranges) < period:
|
| return sum(true_ranges) / len(true_ranges) if true_ranges else 0
|
|
|
| return sum(true_ranges[-period:]) / period
|
|
|
|
|
| def calculate_macd(prices: List[float], fast: int = 12, slow: int = 26, signal: int = 9) -> Dict[str, float]:
|
| """Calculate MACD"""
|
| if len(prices) < slow + signal:
|
| return {"macd_line": 0, "signal_line": 0, "histogram": 0}
|
|
|
| ema_fast = calculate_ema(prices, fast)
|
| ema_slow = calculate_ema(prices, slow)
|
| macd_line = ema_fast - ema_slow
|
|
|
|
|
|
|
| macd_values = []
|
| for i in range(signal + 5):
|
| idx = len(prices) - signal - 5 + i
|
| if idx > slow:
|
| slice_prices = prices[:idx+1]
|
| ef = calculate_ema(slice_prices, fast)
|
| es = calculate_ema(slice_prices, slow)
|
| macd_values.append(ef - es)
|
|
|
| if len(macd_values) >= signal:
|
| signal_line = calculate_ema(macd_values, signal)
|
| else:
|
| signal_line = macd_line
|
|
|
| histogram = macd_line - signal_line
|
|
|
| return {
|
| "macd_line": round(macd_line, 8),
|
| "signal_line": round(signal_line, 8),
|
| "histogram": round(histogram, 8)
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| @router.get("/services")
|
| async def list_indicator_services():
|
| """List all available technical indicator services"""
|
| from backend.services.indicator_analysis_service import services_catalog
|
| return services_catalog()
|
|
|
|
|
| @router.post("/analyze")
|
| async def analyze_indicators_batch(request: AnalyzeBatchRequest):
|
| """Batch indicator analysis for main-app integration."""
|
| from backend.services.indicator_analysis_service import batch_analyze
|
| result = await batch_analyze(request.symbol, request.timeframe, request.indicators)
|
| if not result.get("success"):
|
| return JSONResponse(status_code=400, content=result)
|
| return result
|
|
|
|
|
| def _indicator_error(result):
|
| return JSONResponse(status_code=400, content=result)
|
|
|
|
|
| @router.get('/bollinger-bands')
|
| async def get_bollinger_bands(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| period: int = Query(default=20),
|
| std_dev: float = Query(default=2.0),
|
| ):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('bollinger_bands', symbol, timeframe, period=period, std_dev=std_dev)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/stoch-rsi')
|
| async def get_stoch_rsi(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| rsi_period: int = Query(default=14),
|
| stoch_period: int = Query(default=14),
|
| ):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('stoch_rsi', symbol, timeframe, rsi_period=rsi_period, stoch_period=stoch_period)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/atr')
|
| async def get_atr(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| period: int = Query(default=14),
|
| ):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('atr', symbol, timeframe, period=period)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/sma')
|
| async def get_sma(symbol: str = Query(default='BTC'), timeframe: str = Query(default='1h')):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('sma', symbol, timeframe)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/ema')
|
| async def get_ema(symbol: str = Query(default='BTC'), timeframe: str = Query(default='1h')):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('ema', symbol, timeframe)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/macd')
|
| async def get_macd(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| fast: int = Query(default=12),
|
| slow: int = Query(default=26),
|
| signal: int = Query(default=9),
|
| ):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('macd', symbol, timeframe, fast=fast, slow=slow, signal_period=signal)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/rsi')
|
| async def get_rsi(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| period: int = Query(default=14),
|
| ):
|
| from backend.services.indicator_analysis_service import single_indicator
|
| result = await single_indicator('rsi', symbol, timeframe, period=period)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|
| @router.get('/comprehensive')
|
| async def get_comprehensive_analysis(
|
| symbol: str = Query(default='BTC'),
|
| timeframe: str = Query(default='1h'),
|
| ):
|
| from backend.services.indicator_analysis_service import comprehensive_analysis
|
| result = await comprehensive_analysis(symbol, timeframe)
|
| return _indicator_error(result) if not result.get('success') else result
|
|
|
|
|