290 lines
9.2 KiB
Python
290 lines
9.2 KiB
Python
"""
|
|
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 |