- Enhanced the `UserIndicator` class to include an optional `timeframe` attribute for custom indicator timeframes. - Updated the `get_indicator_data` method in `MarketDataIntegrator` to fetch and calculate indicators based on the specified timeframe, ensuring proper data alignment and handling. - Modified the `ChartBuilder` to pass the correct DataFrame for plotting indicators with different timeframes. - Added UI elements in the indicator modal for selecting timeframes, improving user experience. - Updated relevant JSON templates to include the new `timeframe` field for all indicators. - Refactored the `prepare_chart_data` function to ensure it returns a DataFrame with a `DatetimeIndex` for consistent calculations. This commit enhances the flexibility and usability of the indicator system, allowing users to analyze data across various timeframes.
306 lines
9.9 KiB
Python
306 lines
9.9 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
|
|
|
|
# Initialize logger
|
|
logger = get_logger("default_logger")
|
|
|
|
# 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("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 |