""" 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 tzlocal import get_localzone from utils.logger import get_logger from config.constants.chart_constants import DEFAULT_CHART_COLORS # Initialize logger logger = get_logger() 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("Chart utils: 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"Chart utils: 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"Chart utils: 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"Chart utils: 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"Chart utils: Non-positive value for {field} at index {i}: {float_val}") return False except (ValueError, TypeError): logger.error(f"Chart utils: 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"Chart utils: 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"Chart utils: Error validating OHLC relationships at index {i}") return False except Exception as e: logger.error(f"Chart utils: Error validating candle at index {i}: {e}") return False logger.debug(f"Chart utils: 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 and localized to system time if 'timestamp' in df.columns: df['timestamp'] = pd.to_datetime(df['timestamp']) local_tz = get_localzone() # Check if the timestamps are already timezone-aware if df['timestamp'].dt.tz is not None: # If they are, just convert to the local timezone df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz) logger.debug(f"Converted timezone-aware timestamps to local timezone: {local_tz}") else: # If they are naive, localize to UTC first, then convert df['timestamp'] = df['timestamp'].dt.tz_localize('UTC').dt.tz_convert(local_tz) logger.debug(f"Localized naive timestamps to UTC and converted to local timezone: {local_tz}") # 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 and set it as the index, keeping the column df = df.sort_values('timestamp') df.index = pd.to_datetime(df['timestamp']) # 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"Chart utils: Prepared chart data: {len(df)} rows, columns: {list(df.columns)}") return df except Exception as e: logger.error(f"Chart utils: 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