import logging
import time
from enum import Enum
from typing import List, Dict, Optional, Any
from threading import Lock
from dataclasses import dataclass
from PySide6.QtWidgets import QWidget, QVBoxLayout
from PySide6.QtCore import Slot, Signal, QTimer
from lightweight_charts.widgets import QtChart
# 配置日志系统
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class ChartState(Enum):
"""图表状态枚举"""
INITIALIZING = "initializing"
WEBVIEW_LOADING = "webview_loading"
CHART_LOADING = "chart_loading"
READY = "ready"
ERROR = "error"
@dataclass
class ChartConfig:
"""图表配置类"""
# 重试配置
max_retries: int = 5
initial_retry_interval: int = 500 # 毫秒
max_retry_interval: int = 5000 # 毫秒
# 缓存配置
max_pending_updates: int = 100
cache_cleanup_interval: int = 30 # 秒
max_cache_age: int = 300 # 秒
# 性能配置
batch_update_threshold: int = 10
# JavaScript检测配置
js_ready_check_interval: int = 100 # 毫秒
js_ready_max_attempts: int = 50
class DataValidator:
"""数据验证器"""
@staticmethod
def validate_historical_data(klines: List[Any]) -> bool:
"""验证历史K线数据格式"""
if not klines or not isinstance(klines, list):
return False
try:
for kline in klines:
if not isinstance(kline, (list, tuple)) or len(kline) < 6:
return False
# 检查数值类型
for i in range(1, 6): # open, high, low, close, volume
float(kline[i])
# 检查时间戳
int(kline[0])
return True
except (ValueError, TypeError, IndexError):
return False
@staticmethod
def validate_kline_update(kline: Dict[str, Any]) -> bool:
"""验证实时K线更新数据格式"""
required_keys = ['t', 'o', 'h', 'l', 'c', 'v']
if not isinstance(kline, dict):
return False
try:
for key in required_keys:
if key not in kline:
return False
# 验证数值
float(kline['o']) # open
float(kline['h']) # high
float(kline['l']) # low
float(kline['c']) # close
float(kline['v']) # volume
int(kline['t']) # timestamp
# 验证价格逻辑(high >= max(open,close), low <= min(open,close))
o, h, l, c = float(kline['o']), float(kline['h']), float(kline['l']), float(kline['c'])
if h < max(o, c) or l > min(o, c):
logger.warning("K线数据价格逻辑不一致")
return False
return True
except (ValueError, TypeError, KeyError):
return False
class LightweightChartWidget(QWidget):
"""
一个使用 lightweight-charts 库来显示高性能金融图表的封装组件。
"""
# 信号定义
chartReady = Signal()
chartError = Signal(str) # 新增错误信号
stateChanged = Signal(str) # 新增状态变化信号
def __init__(self, symbol: str, interval: str, config: Optional[ChartConfig] = None, parent=None):
super().__init__(parent)
self.symbol = symbol
self.interval = interval
self.config = config or ChartConfig()
# 状态管理
self._state = ChartState.INITIALIZING
self._state_lock = Lock()
self._is_destroyed = False # 销毁标志
# 数据缓存(增加时间戳)
self._pending_historical_data: Optional[List[Any]] = None
self._pending_updates: List[Dict[str, Any]] = []
self._cache_timestamps: List[float] = []
# 重试机制
self._retry_count = 0
self._current_retry_interval = self.config.initial_retry_interval
# JavaScript就绪检测
self._js_ready_attempts = 0
self._js_ready_timer = QTimer()
self._js_ready_timer.timeout.connect(self._check_javascript_ready)
# 缓存清理定时器
self._cache_cleanup_timer = QTimer()
self._cache_cleanup_timer.timeout.connect(self._cleanup_old_cache)
self._cache_cleanup_timer.start(self.config.cache_cleanup_interval * 1000)
# 性能优化:批量更新
self._batch_updates: List[Dict[str, Any]] = []
self._batch_timer = QTimer()
self._batch_timer.setSingleShot(True)
self._batch_timer.timeout.connect(self._process_batch_updates)
self._setup_ui()
self._set_state(ChartState.WEBVIEW_LOADING)
def _setup_ui(self):
"""设置用户界面"""
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.chart = QtChart(widget=self, inner_width=1, inner_height=1)
self.webview = self.chart.get_webview()
self.layout.addWidget(self.webview)
# 应用样式配置
self._apply_chart_styles()
# 连接WebView信号
self.webview.loadFinished.connect(self._on_webview_loaded)
def _apply_chart_styles(self):
"""应用图表样式配置"""
try:
self.chart.layout(
background_color='#131722', text_color='#D9D9D9', font_size=12
)
self.chart.grid(
vert_enabled=True, horz_enabled=True, color='#3C4048', style='solid'
)
self.chart.candle_style(
up_color='#26a69a', down_color='#ef5350',
border_up_color='#26a69a', border_down_color='#ef5350',
wick_up_color='#26a69a', wick_down_color='#ef5350'
)
self.chart.volume_config(
up_color='rgba(38, 166, 154, 0.5)',
down_color='rgba(239, 83, 80, 0.5)'
)
except Exception as e:
logger.error(f"应用图表样式时发生错误: {e}")
def _set_state(self, new_state: ChartState):
"""线程安全的状态设置"""
if getattr(self, '_is_destroyed', False):
return
with self._state_lock:
if self._state != new_state:
old_state = self._state
self._state = new_state
logger.info(f"[{self.symbol}] 状态变化: {old_state.value} -> {new_state.value}")
try:
self.stateChanged.emit(new_state.value)
except (RuntimeError, AttributeError):
# 信号可能已经断开
pass
def get_state(self) -> ChartState:
"""获取当前状态"""
with self._state_lock:
return self._state
@Slot(bool)
def _on_webview_loaded(self, success: bool):
"""WebView加载完成处理"""
if success:
logger.info(f"[{self.symbol}] WebView加载成功,开始检测JavaScript就绪状态")
self._set_state(ChartState.CHART_LOADING)
self._reset_retry_state()
self._start_javascript_ready_check()
else:
self._handle_load_failure()
def _start_javascript_ready_check(self):
"""开始JavaScript就绪状态检测"""
self._js_ready_attempts = 0
self._js_ready_timer.start(self.config.js_ready_check_interval)
def _check_javascript_ready(self):
"""检测JavaScript图表库是否就绪"""
self._js_ready_attempts += 1
# 检测代码:验证图表对象是否存在并可用
js_code = """
(function() {
try {
return window.chart &&
typeof window.chart === 'object' &&
typeof window.chart.update === 'function';
} catch(e) {
return false;
}
})();
"""
def on_result(result):
if result:
self._on_javascript_ready()
elif self._js_ready_attempts >= self.config.js_ready_max_attempts:
self._on_javascript_timeout()
try:
# 使用WebView的页面执行JavaScript
self.webview.page().runJavaScript(js_code, on_result)
except Exception as e:
logger.warning(f"JavaScript就绪检测失败: {e}")
if self._js_ready_attempts >= self.config.js_ready_max_attempts:
self._on_javascript_timeout()
def _on_javascript_ready(self):
"""JavaScript就绪处理"""
self._js_ready_timer.stop()
self._set_state(ChartState.READY)
logger.info(f"[{self.symbol}] 图表完全就绪")
# 发射就绪信号
self.chartReady.emit()
# 处理缓存数据
self._process_cached_data()
def _on_javascript_timeout(self):
"""JavaScript检测超时处理"""
self._js_ready_timer.stop()
error_msg = f"图表JavaScript初始化超时 (尝试了 {self._js_ready_attempts} 次)"
logger.error(f"[{self.symbol}] {error_msg}")
self._set_state(ChartState.ERROR)
self.chartError.emit(error_msg)
def _handle_load_failure(self):
"""处理加载失败"""
self._retry_count += 1
error_msg = f"WebView加载失败 (尝试 {self._retry_count}/{self.config.max_retries})"
logger.error(f"[{self.symbol}] {error_msg}")
if self._retry_count < self.config.max_retries:
# 指数退避重试
self._current_retry_interval = min(
self._current_retry_interval * 2,
self.config.max_retry_interval
)
QTimer.singleShot(self._current_retry_interval, lambda: self.webview.reload())
else:
error_msg = f"达到最大重试次数,加载失败"
logger.critical(f"[{self.symbol}] {error_msg}")
self._set_state(ChartState.ERROR)
self.chartError.emit(error_msg)
def _reset_retry_state(self):
"""重置重试状态"""
self._retry_count = 0
self._current_retry_interval = self.config.initial_retry_interval
def _process_cached_data(self):
"""处理缓存的数据"""
try:
# 处理历史数据
if self._pending_historical_data is not None:
self._apply_historical_data(self._pending_historical_data)
self._pending_historical_data = None
# 处理缓存的更新
if self._pending_updates:
for kline in self._pending_updates:
self._apply_kline_update_internal(kline)
self._clear_cache()
except Exception as e:
logger.error(f"[{self.symbol}] 处理缓存数据时发生错误: {e}")
@Slot(list)
def set_historical_data(self, klines: List[Any]):
"""设置历史数据"""
if getattr(self, '_is_destroyed', False):
return
if not DataValidator.validate_historical_data(klines):
error_msg = "历史K线数据格式验证失败"
logger.error(f"[{self.symbol}] {error_msg}")
try:
self.chartError.emit(error_msg)
except (RuntimeError, AttributeError):
pass
return
if self.get_state() != ChartState.READY:
logger.info(f"[{self.symbol}] 图表未就绪,缓存历史数据 ({len(klines)} 条)")
self._pending_historical_data = klines
return
self._apply_historical_data(klines)
def _apply_historical_data(self, klines: List[Any]):
"""应用历史数据到图表"""
try:
logger.info(f"[{self.symbol}] 处理 {len(klines)} 条历史K线数据")
# 优化的数据处理:避免不必要的列操作
chart_data = [{
'time': int(kline[0]),
'open': float(kline[1]),
'high': float(kline[2]),
'low': float(kline[3]),
'close': float(kline[4]),
'volume': float(kline[5])
} for kline in klines]
self.chart.set(chart_data)
logger.info(f"[{self.symbol}] 历史数据加载成功")
except Exception as e:
error_msg = f"处理历史数据时发生错误: {e}"
logger.error(f"[{self.symbol}] {error_msg}")
self.chartError.emit(error_msg)
@Slot(dict)
def update_kline(self, kline: Dict[str, Any]):
"""更新K线数据"""
if getattr(self, '_is_destroyed', False):
return
if not DataValidator.validate_kline_update(kline):
logger.warning(f"[{self.symbol}] 实时K线数据格式验证失败")
return
if self.get_state() != ChartState.READY:
self._cache_update(kline)
return
# 批量处理优化
self._batch_updates.append(kline)
if len(self._batch_updates) >= self.config.batch_update_threshold:
self._process_batch_updates()
else:
# 延迟处理,允许批量积累
try:
if hasattr(self, '_batch_timer') and not self._batch_timer.isActive():
self._batch_timer.start(50) # 50ms延迟
except (RuntimeError, AttributeError):
# 定时器可能已被删除,直接处理
self._process_batch_updates()
def _cache_update(self, kline: Dict[str, Any]):
"""缓存更新数据"""
current_time = time.time()
# 清理过期缓存
self._cleanup_old_cache()
# 限制缓存大小
if len(self._pending_updates) >= self.config.max_pending_updates:
# 移除最旧的数据
self._pending_updates.pop(0)
self._cache_timestamps.pop(0)
self._pending_updates.append(kline)
self._cache_timestamps.append(current_time)
def _process_batch_updates(self):
"""处理批量更新"""
if not self._batch_updates:
return
try:
# 处理最新的数据(通常只需要最后一条)
latest_kline = self._batch_updates[-1]
self._apply_kline_update_internal(latest_kline)
self._batch_updates.clear()
except Exception as e:
logger.error(f"[{self.symbol}] 批量更新处理失败: {e}")
def _apply_kline_update_internal(self, kline: Dict[str, Any]):
"""内部K线更新实现"""
try:
update_data = {
'time': pd.to_datetime(kline['t'], unit='ms'),
'open': float(kline['o']),
'high': float(kline['h']),
'low': float(kline['l']),
'close': float(kline['c']),
'volume': float(kline['v'])
}
kline_series = pd.Series(update_data)
self.chart.update(kline_series)
except Exception as e:
logger.error(f"[{self.symbol}] 更新图表失败: {e}")
def _cleanup_old_cache(self):
"""清理过期缓存"""
if not self._cache_timestamps:
return
current_time = time.time()
cutoff_time = current_time - self.config.max_cache_age
# 找到第一个未过期的索引
valid_index = 0
for i, timestamp in enumerate(self._cache_timestamps):
if timestamp >= cutoff_time:
valid_index = i
break
else:
valid_index = len(self._cache_timestamps)
if valid_index > 0:
self._pending_updates = self._pending_updates[valid_index:]
self._cache_timestamps = self._cache_timestamps[valid_index:]
logger.info(f"[{self.symbol}] 清理了 {valid_index} 条过期缓存")
def _clear_cache(self):
"""清空所有缓存"""
self._pending_updates.clear()
self._cache_timestamps.clear()
def force_refresh(self):
"""强制刷新图表"""
logger.info(f"[{self.symbol}] 执行强制刷新")
self._set_state(ChartState.WEBVIEW_LOADING)
self._reset_retry_state()
self.webview.reload()
def get_cache_info(self) -> Dict[str, Any]:
"""获取缓存信息(用于调试)"""
return {
'pending_updates_count': len(self._pending_updates),
'has_pending_historical': self._pending_historical_data is not None,
'batch_updates_count': len(self._batch_updates),
'state': self._state.value,
'retry_count': self._retry_count
}
def cleanup(self):
"""清理资源"""
try:
logger.info(f"[{self.symbol}] 开始清理资源")
# 安全停止定时器 - 检查对象是否仍然有效
timers = [
('js_ready_timer', self._js_ready_timer),
('cache_cleanup_timer', self._cache_cleanup_timer),
('batch_timer', self._batch_timer)
]
for timer_name, timer in timers:
try:
if timer is not None and hasattr(timer, 'isActive') and timer.isActive():
timer.stop()
logger.debug(f"[{self.symbol}] 已停止 {timer_name}")
except (RuntimeError, AttributeError) as e:
logger.debug(f"[{self.symbol}] {timer_name} 已被Qt清理: {e}")
# 清理缓存
self._clear_cache()
self._pending_historical_data = None
logger.info(f"[{self.symbol}] 资源清理完成")
except Exception as e:
# 在析构过程中,日志系统可能也不可用,所以使用try-except
try:
logger.error(f"[{self.symbol}] 清理资源时发生错误: {e}")
except:
pass
def __del__(self):
"""析构函数 - 安全版本"""
try:
if hasattr(self, '_is_destroyed') and not self._is_destroyed:
if hasattr(self, '_pending_updates'):
self._pending_updates.clear()
if hasattr(self, '_cache_timestamps'):
self._cache_timestamps.clear()
if hasattr(self, '_batch_updates'):
self._batch_updates.clear()
self._pending_historical_data = None
except:
pass # 静默处理日志错误
def destroy_widget(self):
"""手动销毁组件(推荐使用此方法而非依赖析构函数)"""
try:
self.cleanup()
try:
self.chartReady.disconnect()
self.chartError.disconnect()
self.stateChanged.disconnect()
if hasattr(self.webview, 'loadFinished'):
self.webview.loadFinished.disconnect()
except (RuntimeError, TypeError):
pass # 信号可能已经断开或对象已删除
self._is_destroyed = True
except Exception as e:
try:
logger.error(f"[{self.symbol}] 销毁组件时发生错误: {e}")
except:
pass
这样修复对吗?
最新发布