""" Chart Utilities and Helper Functions This module provides utility functions for data processing, validation, and chart styling used by the ChartBuilder and layer components. """ import pandas as pd from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Union from decimal import Decimal from utils.logger import get_logger # Initialize logger logger = get_logger("chart_utils") # Default color scheme for charts DEFAULT_CHART_COLORS = { 'bullish': '#00C851', # Green for bullish candles 'bearish': '#FF4444', # Red for bearish candles 'sma': '#007bff', # Blue for SMA 'ema': '#ff6b35', # Orange for EMA 'bb_upper': '#6f42c1', # Purple for Bollinger upper 'bb_lower': '#6f42c1', # Purple for Bollinger lower 'bb_middle': '#6c757d', # Gray for Bollinger middle 'rsi': '#20c997', # Teal for RSI 'macd': '#fd7e14', # Orange for MACD 'macd_signal': '#e83e8c', # Pink for MACD signal 'volume': '#6c757d', # Gray for volume 'support': '#17a2b8', # Light blue for support 'resistance': '#dc3545' # Red for resistance } def validate_market_data(candles: List[Dict[str, Any]]) -> bool: """ Validate market data structure and content. Args: candles: List of candle dictionaries from database Returns: True if data is valid, False otherwise """ if not candles: logger.warning("Empty candles data") return False # Check required fields in first candle required_fields = ['timestamp', 'open', 'high', 'low', 'close'] first_candle = candles[0] for field in required_fields: if field not in first_candle: logger.error(f"Missing required field: {field}") return False # Validate data types and values for i, candle in enumerate(candles[:5]): # Check first 5 candles try: # Validate timestamp if not isinstance(candle['timestamp'], (datetime, str)): logger.error(f"Invalid timestamp type at index {i}") return False # Validate OHLC values for field in ['open', 'high', 'low', 'close']: value = candle[field] if value is None: logger.error(f"Null value for {field} at index {i}") return False # Convert to float for validation try: float_val = float(value) if float_val <= 0: logger.error(f"Non-positive value for {field} at index {i}: {float_val}") return False except (ValueError, TypeError): logger.error(f"Invalid numeric value for {field} at index {i}: {value}") return False # Validate OHLC relationships (high >= low, etc.) try: o, h, l, c = float(candle['open']), float(candle['high']), float(candle['low']), float(candle['close']) if not (h >= max(o, c) and l <= min(o, c)): logger.warning(f"Invalid OHLC relationship at index {i}: O={o}, H={h}, L={l}, C={c}") # Don't fail validation for this, just warn except (ValueError, TypeError): logger.error(f"Error validating OHLC relationships at index {i}") return False except Exception as e: logger.error(f"Error validating candle at index {i}: {e}") return False logger.debug(f"Market data validation passed for {len(candles)} candles") return True def prepare_chart_data(candles: List[Dict[str, Any]]) -> pd.DataFrame: """ Convert candle data to pandas DataFrame suitable for charting. Args: candles: List of candle dictionaries from database Returns: Prepared pandas DataFrame """ try: # Convert to DataFrame df = pd.DataFrame(candles) # Ensure timestamp is datetime if 'timestamp' in df.columns: df['timestamp'] = pd.to_datetime(df['timestamp']) # Convert OHLCV columns to numeric numeric_columns = ['open', 'high', 'low', 'close'] if 'volume' in df.columns: numeric_columns.append('volume') for col in numeric_columns: if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce') # Sort by timestamp df = df.sort_values('timestamp').reset_index(drop=True) # Handle missing volume data if 'volume' not in df.columns: df['volume'] = 0 # Fill any NaN values with forward fill, then backward fill df = df.ffill().bfill() logger.debug(f"Prepared chart data: {len(df)} rows, columns: {list(df.columns)}") return df except Exception as e: logger.error(f"Error preparing chart data: {e}") # Return empty DataFrame with expected structure return pd.DataFrame(columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) def get_indicator_colors() -> Dict[str, str]: """ Get the default color scheme for chart indicators. Returns: Dictionary of color mappings """ return DEFAULT_CHART_COLORS.copy() def format_price(price: Union[float, Decimal, str], decimals: int = 4) -> str: """ Format price value for display. Args: price: Price value to format decimals: Number of decimal places Returns: Formatted price string """ try: return f"{float(price):.{decimals}f}" except (ValueError, TypeError): return "N/A" def format_volume(volume: Union[float, int, str]) -> str: """ Format volume value for display with K/M/B suffixes. Args: volume: Volume value to format Returns: Formatted volume string """ try: vol = float(volume) if vol >= 1e9: return f"{vol/1e9:.2f}B" elif vol >= 1e6: return f"{vol/1e6:.2f}M" elif vol >= 1e3: return f"{vol/1e3:.2f}K" else: return f"{vol:.0f}" except (ValueError, TypeError): return "N/A" def calculate_price_change(current: Union[float, Decimal], previous: Union[float, Decimal]) -> Dict[str, Any]: """ Calculate price change and percentage change. Args: current: Current price previous: Previous price Returns: Dictionary with change, change_percent, and direction """ try: curr = float(current) prev = float(previous) if prev == 0: return {'change': 0, 'change_percent': 0, 'direction': 'neutral'} change = curr - prev change_percent = (change / prev) * 100 direction = 'up' if change > 0 else 'down' if change < 0 else 'neutral' return { 'change': change, 'change_percent': change_percent, 'direction': direction } except (ValueError, TypeError): return {'change': 0, 'change_percent': 0, 'direction': 'neutral'} def get_chart_height(include_volume: bool = False, num_subplots: int = 0) -> int: """ Calculate appropriate chart height based on components. Args: include_volume: Whether volume subplot is included num_subplots: Number of additional subplots (for indicators) Returns: Recommended chart height in pixels """ base_height = 500 volume_height = 150 if include_volume else 0 subplot_height = num_subplots * 120 return base_height + volume_height + subplot_height def validate_timeframe(timeframe: str) -> bool: """ Validate if timeframe string is supported. Args: timeframe: Timeframe string (e.g., '1m', '5m', '1h', '1d') Returns: True if valid, False otherwise """ valid_timeframes = [ '1s', '5s', '15s', '30s', # Seconds '1m', '5m', '15m', '30m', # Minutes '1h', '2h', '4h', '6h', '12h', # Hours '1d', '3d', '1w', '1M' # Days, weeks, months ] return timeframe in valid_timeframes def validate_symbol(symbol: str) -> bool: """ Validate trading symbol format. Args: symbol: Trading symbol (e.g., 'BTC-USDT') Returns: True if valid format, False otherwise """ if not symbol or not isinstance(symbol, str): return False # Basic validation: should contain a dash and have reasonable length parts = symbol.split('-') if len(parts) != 2: return False base, quote = parts if len(base) < 2 or len(quote) < 3 or len(base) > 10 or len(quote) > 10: return False return True