TCPDashboard/tests/test_chart_layers.py
Vasily.onl a969defe1f 3.4 -2.0 Indicator Layer System Implementation
Implement modular chart layers and error handling for Crypto Trading Bot Dashboard

- Introduced a comprehensive chart layer system in `components/charts/layers/` to support various technical indicators and subplots.
- Added base layer components including `BaseLayer`, `CandlestickLayer`, and `VolumeLayer` for flexible chart rendering.
- Implemented overlay indicators such as `SMALayer`, `EMALayer`, and `BollingerBandsLayer` with robust error handling.
- Created subplot layers for indicators like `RSILayer` and `MACDLayer`, enhancing visualization capabilities.
- Developed a `MarketDataIntegrator` for seamless data fetching and validation, improving data quality assurance.
- Enhanced error handling utilities in `components/charts/error_handling.py` to manage insufficient data scenarios effectively.
- Updated documentation to reflect the new chart layer architecture and usage guidelines.
- Added unit tests for all chart layer components to ensure functionality and reliability.
2025-06-03 13:56:15 +08:00

711 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Comprehensive Unit Tests for Chart Layer Components
Tests for all chart layer functionality including:
- Error handling system
- Base layer components (CandlestickLayer, VolumeLayer, LayerManager)
- Indicator layers (SMA, EMA, Bollinger Bands)
- Subplot layers (RSI, MACD)
- Integration and error recovery
"""
import pytest
import pandas as pd
import plotly.graph_objects as go
from datetime import datetime, timezone, timedelta
from unittest.mock import Mock, patch, MagicMock
from typing import List, Dict, Any
from decimal import Decimal
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# Import components to test
from components.charts.error_handling import (
ChartErrorHandler, ChartError, ErrorSeverity, DataRequirements,
ErrorRecoveryStrategies, check_data_sufficiency, get_error_message
)
from components.charts.layers.base import (
LayerConfig, BaseLayer, CandlestickLayer, VolumeLayer, LayerManager
)
from components.charts.layers.indicators import (
IndicatorLayerConfig, BaseIndicatorLayer, SMALayer, EMALayer, BollingerBandsLayer
)
from components.charts.layers.subplots import (
SubplotLayerConfig, BaseSubplotLayer, RSILayer, MACDLayer
)
class TestErrorHandlingSystem:
"""Test suite for chart error handling system"""
@pytest.fixture
def sample_data(self):
"""Sample market data for testing"""
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
return [
{
'timestamp': base_time + timedelta(minutes=i),
'open': 50000 + i * 10,
'high': 50100 + i * 10,
'low': 49900 + i * 10,
'close': 50050 + i * 10,
'volume': 1000 + i * 5
}
for i in range(50) # 50 data points
]
@pytest.fixture
def insufficient_data(self):
"""Insufficient market data for testing"""
base_time = datetime.now(timezone.utc)
return [
{
'timestamp': base_time + timedelta(minutes=i),
'open': 50000,
'high': 50100,
'low': 49900,
'close': 50050,
'volume': 1000
}
for i in range(5) # Only 5 data points
]
def test_chart_error_creation(self):
"""Test ChartError dataclass creation"""
error = ChartError(
code='TEST_ERROR',
message='Test error message',
severity=ErrorSeverity.ERROR,
context={'test': 'value'},
recovery_suggestion='Fix the test'
)
assert error.code == 'TEST_ERROR'
assert error.message == 'Test error message'
assert error.severity == ErrorSeverity.ERROR
assert error.context == {'test': 'value'}
assert error.recovery_suggestion == 'Fix the test'
# Test dict conversion
error_dict = error.to_dict()
assert error_dict['code'] == 'TEST_ERROR'
assert error_dict['severity'] == 'error'
def test_data_requirements_candlestick(self):
"""Test data requirements checking for candlestick charts"""
# Test sufficient data
error = DataRequirements.check_candlestick_requirements(50)
assert error.severity == ErrorSeverity.INFO
assert error.code == 'SUFFICIENT_DATA'
# Test insufficient data
error = DataRequirements.check_candlestick_requirements(5)
assert error.severity == ErrorSeverity.WARNING
assert error.code == 'INSUFFICIENT_CANDLESTICK_DATA'
# Test no data
error = DataRequirements.check_candlestick_requirements(0)
assert error.severity == ErrorSeverity.CRITICAL
assert error.code == 'NO_DATA'
def test_data_requirements_indicators(self):
"""Test data requirements checking for indicators"""
# Test SMA with sufficient data
error = DataRequirements.check_indicator_requirements('sma', 50, {'period': 20})
assert error.severity == ErrorSeverity.INFO
# Test SMA with insufficient data
error = DataRequirements.check_indicator_requirements('sma', 15, {'period': 20})
assert error.severity == ErrorSeverity.WARNING
assert error.code == 'INSUFFICIENT_INDICATOR_DATA'
# Test unknown indicator
error = DataRequirements.check_indicator_requirements('unknown', 50, {})
assert error.severity == ErrorSeverity.ERROR
assert error.code == 'UNKNOWN_INDICATOR'
def test_chart_error_handler(self, sample_data, insufficient_data):
"""Test ChartErrorHandler functionality"""
handler = ChartErrorHandler()
# Test with sufficient data
is_valid = handler.validate_data_sufficiency(sample_data)
assert is_valid == True
assert len(handler.errors) == 0
# Test with insufficient data and indicators
indicators = [{'type': 'sma', 'parameters': {'period': 30}}]
is_valid = handler.validate_data_sufficiency(insufficient_data, indicators=indicators)
assert is_valid == False
assert len(handler.errors) > 0 or len(handler.warnings) > 0
# Test error summary
summary = handler.get_error_summary()
assert 'has_errors' in summary
assert 'can_proceed' in summary
def test_convenience_functions(self, sample_data, insufficient_data):
"""Test convenience functions for error handling"""
# Test check_data_sufficiency
is_sufficient, summary = check_data_sufficiency(sample_data)
assert is_sufficient == True
assert summary['can_proceed'] == True
# Test with insufficient data
indicators = [{'type': 'sma', 'parameters': {'period': 100}}]
is_sufficient, summary = check_data_sufficiency(insufficient_data, indicators)
assert is_sufficient == False
# Test get_error_message
error_msg = get_error_message(insufficient_data, indicators)
assert isinstance(error_msg, str)
assert len(error_msg) > 0
class TestBaseLayerSystem:
"""Test suite for base layer components"""
@pytest.fixture
def sample_df(self):
"""Sample DataFrame for testing"""
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
data = []
for i in range(100):
data.append({
'timestamp': base_time + timedelta(minutes=i),
'open': 50000 + i * 10,
'high': 50100 + i * 10,
'low': 49900 + i * 10,
'close': 50050 + i * 10,
'volume': 1000 + i * 5
})
return pd.DataFrame(data)
@pytest.fixture
def invalid_df(self):
"""Invalid DataFrame for testing error handling"""
return pd.DataFrame([
{'timestamp': datetime.now(), 'open': -100, 'high': 50, 'low': 60, 'close': 40, 'volume': -50},
{'timestamp': datetime.now(), 'open': None, 'high': None, 'low': None, 'close': None, 'volume': None}
])
def test_layer_config(self):
"""Test LayerConfig creation"""
config = LayerConfig(name="test", enabled=True, color="#FF0000")
assert config.name == "test"
assert config.enabled == True
assert config.color == "#FF0000"
assert config.style == {}
assert config.subplot_row is None
def test_base_layer(self):
"""Test BaseLayer functionality"""
config = LayerConfig(name="test_layer")
layer = BaseLayer(config)
assert layer.config.name == "test_layer"
assert hasattr(layer, 'error_handler')
assert hasattr(layer, 'logger')
def test_candlestick_layer_validation(self, sample_df, invalid_df):
"""Test CandlestickLayer data validation"""
layer = CandlestickLayer()
# Test valid data
is_valid = layer.validate_data(sample_df)
assert is_valid == True
# Test invalid data
is_valid = layer.validate_data(invalid_df)
assert is_valid == False
assert len(layer.error_handler.errors) > 0
def test_candlestick_layer_render(self, sample_df):
"""Test CandlestickLayer rendering"""
layer = CandlestickLayer()
fig = go.Figure()
result_fig = layer.render(fig, sample_df)
assert result_fig is not None
assert len(result_fig.data) >= 1 # Should have candlestick trace
def test_volume_layer_validation(self, sample_df, invalid_df):
"""Test VolumeLayer data validation"""
layer = VolumeLayer()
# Test valid data
is_valid = layer.validate_data(sample_df)
assert is_valid == True
# Test invalid data (some volume issues)
is_valid = layer.validate_data(invalid_df)
# Volume layer should handle invalid data gracefully
assert len(layer.error_handler.warnings) >= 0 # May have warnings
def test_volume_layer_render(self, sample_df):
"""Test VolumeLayer rendering"""
layer = VolumeLayer()
fig = go.Figure()
result_fig = layer.render(fig, sample_df)
assert result_fig is not None
def test_layer_manager(self, sample_df):
"""Test LayerManager functionality"""
manager = LayerManager()
# Add layers
candlestick_layer = CandlestickLayer()
volume_layer = VolumeLayer()
manager.add_layer(candlestick_layer)
manager.add_layer(volume_layer)
assert len(manager.layers) == 2
# Test enabled layers
enabled = manager.get_enabled_layers()
assert len(enabled) == 2
# Test overlay vs subplot layers
overlays = manager.get_overlay_layers()
subplots = manager.get_subplot_layers()
assert len(overlays) == 1 # Candlestick is overlay
assert len(subplots) >= 1 # Volume is subplot
# Test layout calculation
layout_config = manager.calculate_subplot_layout()
assert 'rows' in layout_config
assert 'cols' in layout_config
assert layout_config['rows'] >= 2 # Main chart + volume subplot
# Test rendering all layers
fig = manager.render_all_layers(sample_df)
assert fig is not None
assert len(fig.data) >= 2 # Candlestick + volume
class TestIndicatorLayers:
"""Test suite for indicator layer components"""
@pytest.fixture
def sample_df(self):
"""Sample DataFrame with trend for indicator testing"""
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
data = []
for i in range(100):
# Create trending data for better indicator calculation
trend = i * 0.1
base_price = 50000 + trend
data.append({
'timestamp': base_time + timedelta(minutes=i),
'open': base_price + (i % 3) * 10,
'high': base_price + 50 + (i % 3) * 10,
'low': base_price - 50 + (i % 3) * 10,
'close': base_price + (i % 2) * 10,
'volume': 1000 + i * 5
})
return pd.DataFrame(data)
@pytest.fixture
def insufficient_df(self):
"""Insufficient data for indicator testing"""
base_time = datetime.now(timezone.utc)
data = []
for i in range(10): # Only 10 data points
data.append({
'timestamp': base_time + timedelta(minutes=i),
'open': 50000,
'high': 50100,
'low': 49900,
'close': 50050,
'volume': 1000
})
return pd.DataFrame(data)
def test_indicator_layer_config(self):
"""Test IndicatorLayerConfig creation"""
config = IndicatorLayerConfig(
name="test_indicator",
indicator_type="sma",
parameters={'period': 20}
)
assert config.name == "test_indicator"
assert config.indicator_type == "sma"
assert config.parameters == {'period': 20}
assert config.line_width == 2
assert config.opacity == 1.0
def test_sma_layer(self, sample_df, insufficient_df):
"""Test SMALayer functionality"""
config = IndicatorLayerConfig(
name="SMA(20)",
indicator_type='sma',
parameters={'period': 20}
)
layer = SMALayer(config)
# Test with sufficient data
is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp'])
assert is_valid == True
# Test calculation
sma_data = layer._calculate_sma(sample_df, 20)
assert sma_data is not None
assert 'sma' in sma_data.columns
assert len(sma_data) > 0
# Test with insufficient data
is_valid = layer.validate_indicator_data(insufficient_df, required_columns=['close', 'timestamp'])
# Should have warnings but may still be valid for short periods
assert len(layer.error_handler.warnings) >= 0
def test_ema_layer(self, sample_df):
"""Test EMALayer functionality"""
config = IndicatorLayerConfig(
name="EMA(12)",
indicator_type='ema',
parameters={'period': 12}
)
layer = EMALayer(config)
# Test validation
is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp'])
assert is_valid == True
# Test calculation
ema_data = layer._calculate_ema(sample_df, 12)
assert ema_data is not None
assert 'ema' in ema_data.columns
assert len(ema_data) > 0
def test_bollinger_bands_layer(self, sample_df):
"""Test BollingerBandsLayer functionality"""
config = IndicatorLayerConfig(
name="BB(20,2)",
indicator_type='bollinger_bands',
parameters={'period': 20, 'std_dev': 2}
)
layer = BollingerBandsLayer(config)
# Test validation
is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp'])
assert is_valid == True
# Test calculation
bb_data = layer._calculate_bollinger_bands(sample_df, 20, 2)
assert bb_data is not None
assert 'upper_band' in bb_data.columns
assert 'middle_band' in bb_data.columns
assert 'lower_band' in bb_data.columns
assert len(bb_data) > 0
def test_safe_calculate_indicator(self, sample_df, insufficient_df):
"""Test safe indicator calculation with error handling"""
config = IndicatorLayerConfig(
name="SMA(20)",
indicator_type='sma',
parameters={'period': 20}
)
layer = SMALayer(config)
# Test successful calculation
result = layer.safe_calculate_indicator(
sample_df,
layer._calculate_sma,
period=20
)
assert result is not None
# Test with insufficient data - should attempt recovery
result = layer.safe_calculate_indicator(
insufficient_df,
layer._calculate_sma,
period=50 # Too large for data
)
# Should either return adjusted result or None
assert result is None or len(result) > 0
class TestSubplotLayers:
"""Test suite for subplot layer components"""
@pytest.fixture
def sample_df(self):
"""Sample DataFrame for RSI/MACD testing"""
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
data = []
# Create more realistic price data for RSI/MACD
prices = [50000]
for i in range(100):
# Random walk with trend
change = (i % 7 - 3) * 50 # Some volatility
new_price = prices[-1] + change
prices.append(new_price)
data.append({
'timestamp': base_time + timedelta(minutes=i),
'open': prices[i],
'high': prices[i] + abs(change) + 20,
'low': prices[i] - abs(change) - 20,
'close': prices[i+1],
'volume': 1000 + i * 5
})
return pd.DataFrame(data)
def test_subplot_layer_config(self):
"""Test SubplotLayerConfig creation"""
config = SubplotLayerConfig(
name="RSI(14)",
indicator_type="rsi",
parameters={'period': 14},
subplot_height_ratio=0.25,
y_axis_range=(0, 100),
reference_lines=[30, 70]
)
assert config.name == "RSI(14)"
assert config.indicator_type == "rsi"
assert config.subplot_height_ratio == 0.25
assert config.y_axis_range == (0, 100)
assert config.reference_lines == [30, 70]
def test_rsi_layer(self, sample_df):
"""Test RSILayer functionality"""
layer = RSILayer(period=14)
# Test validation
is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp'])
assert is_valid == True
# Test RSI calculation
rsi_data = layer._calculate_rsi(sample_df, 14)
assert rsi_data is not None
assert 'rsi' in rsi_data.columns
assert len(rsi_data) > 0
# Validate RSI values are in correct range
assert (rsi_data['rsi'] >= 0).all()
assert (rsi_data['rsi'] <= 100).all()
# Test subplot properties
assert layer.has_fixed_range() == True
assert layer.get_y_axis_range() == (0, 100)
assert 30 in layer.get_reference_lines()
assert 70 in layer.get_reference_lines()
def test_macd_layer(self, sample_df):
"""Test MACDLayer functionality"""
layer = MACDLayer(fast_period=12, slow_period=26, signal_period=9)
# Test validation
is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp'])
assert is_valid == True
# Test MACD calculation
macd_data = layer._calculate_macd(sample_df, 12, 26, 9)
assert macd_data is not None
assert 'macd' in macd_data.columns
assert 'signal' in macd_data.columns
assert 'histogram' in macd_data.columns
assert len(macd_data) > 0
# Test subplot properties
assert layer.should_show_zero_line() == True
assert layer.get_subplot_height_ratio() == 0.3
def test_rsi_calculation_edge_cases(self, sample_df):
"""Test RSI calculation with edge cases"""
layer = RSILayer(period=14)
# Test with very short period
short_data = sample_df.head(20)
rsi_data = layer._calculate_rsi(short_data, 5) # Short period
assert rsi_data is not None
assert len(rsi_data) > 0
# Test with period too large for data
try:
layer._calculate_rsi(sample_df.head(10), 20) # Period larger than data
assert False, "Should have raised an error"
except Exception:
pass # Expected to fail
def test_macd_calculation_edge_cases(self, sample_df):
"""Test MACD calculation with edge cases"""
layer = MACDLayer(fast_period=12, slow_period=26, signal_period=9)
# Test with invalid periods (fast >= slow)
try:
layer._calculate_macd(sample_df, 26, 12, 9) # fast >= slow
assert False, "Should have raised an error"
except Exception:
pass # Expected to fail
class TestLayerIntegration:
"""Test suite for layer integration and complex scenarios"""
@pytest.fixture
def sample_df(self):
"""Sample DataFrame for integration testing"""
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
data = []
for i in range(150): # Enough data for all indicators
trend = i * 0.1
base_price = 50000 + trend
volatility = (i % 10) * 20
data.append({
'timestamp': base_time + timedelta(minutes=i),
'open': base_price + volatility,
'high': base_price + volatility + 50,
'low': base_price + volatility - 50,
'close': base_price + volatility + (i % 3 - 1) * 10,
'volume': 1000 + i * 5
})
return pd.DataFrame(data)
def test_full_chart_creation(self, sample_df):
"""Test creating a full chart with multiple layers"""
manager = LayerManager()
# Add base layers
manager.add_layer(CandlestickLayer())
manager.add_layer(VolumeLayer())
# Add indicator layers
manager.add_layer(SMALayer(IndicatorLayerConfig(
name="SMA(20)",
indicator_type='sma',
parameters={'period': 20}
)))
manager.add_layer(EMALayer(IndicatorLayerConfig(
name="EMA(12)",
indicator_type='ema',
parameters={'period': 12}
)))
# Add subplot layers
manager.add_layer(RSILayer(period=14))
manager.add_layer(MACDLayer(fast_period=12, slow_period=26, signal_period=9))
# Calculate layout
layout_config = manager.calculate_subplot_layout()
assert layout_config['rows'] >= 4 # Main + volume + RSI + MACD
# Render all layers
fig = manager.render_all_layers(sample_df)
assert fig is not None
assert len(fig.data) >= 6 # Candlestick + volume + SMA + EMA + RSI + MACD components
def test_error_recovery_integration(self):
"""Test error recovery with insufficient data"""
manager = LayerManager()
# Create insufficient data
base_time = datetime.now(timezone.utc)
insufficient_data = pd.DataFrame([
{
'timestamp': base_time + timedelta(minutes=i),
'open': 50000,
'high': 50100,
'low': 49900,
'close': 50050,
'volume': 1000
}
for i in range(15) # Only 15 data points
])
# Add layers that require more data
manager.add_layer(CandlestickLayer())
manager.add_layer(SMALayer(IndicatorLayerConfig(
name="SMA(50)", # Requires too much data
indicator_type='sma',
parameters={'period': 50}
)))
# Should still create a chart (graceful degradation)
fig = manager.render_all_layers(insufficient_data)
assert fig is not None
# Should have at least candlestick layer
assert len(fig.data) >= 1
def test_mixed_valid_invalid_data(self):
"""Test handling mixed valid and invalid data"""
# Create data with some invalid entries
base_time = datetime.now(timezone.utc)
mixed_data = []
for i in range(50):
if i % 10 == 0: # Every 10th entry is invalid
data_point = {
'timestamp': base_time + timedelta(minutes=i),
'open': -100, # Invalid negative price
'high': None, # Missing data
'low': None,
'close': None,
'volume': -50 # Invalid negative volume
}
else:
data_point = {
'timestamp': base_time + timedelta(minutes=i),
'open': 50000 + i * 10,
'high': 50100 + i * 10,
'low': 49900 + i * 10,
'close': 50050 + i * 10,
'volume': 1000 + i * 5
}
mixed_data.append(data_point)
df = pd.DataFrame(mixed_data)
# Test candlestick layer with mixed data
candlestick_layer = CandlestickLayer()
is_valid = candlestick_layer.validate_data(df)
# Should handle mixed data gracefully
if not is_valid:
# Should have warnings but possibly still proceed
assert len(candlestick_layer.error_handler.warnings) > 0
def test_layer_manager_dynamic_layout(self):
"""Test LayerManager dynamic layout calculation"""
manager = LayerManager()
# Test with no subplots
manager.add_layer(CandlestickLayer())
layout = manager.calculate_subplot_layout()
assert layout['rows'] == 1
# Add one subplot
manager.add_layer(VolumeLayer())
layout = manager.calculate_subplot_layout()
assert layout['rows'] == 2
# Add more subplots
manager.add_layer(RSILayer(period=14))
manager.add_layer(MACDLayer(fast_period=12, slow_period=26, signal_period=9))
layout = manager.calculate_subplot_layout()
assert layout['rows'] == 4 # Main + volume + RSI + MACD
assert layout['cols'] == 1
assert len(layout['subplot_titles']) == 4
assert len(layout['row_heights']) == 4
# Test row height calculation
total_height = sum(layout['row_heights'])
assert abs(total_height - 1.0) < 0.01 # Should sum to approximately 1.0
if __name__ == "__main__":
pytest.main([__file__, "-v"])