2025-06-12 13:27:30 +08:00

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