From 659846fe18f4389360ef6342f92f9a0962ccecac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Thu, 9 Jul 2026 10:01:15 +0800 Subject: [PATCH] fix: refresh quotes without blocking UI --- src/grid_trading/services/trading_service.py | 13 +++- src/grid_trading/ui/main_window.py | 65 +++++++++++++++++++- tests/test_ui.py | 40 ++++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/grid_trading/services/trading_service.py b/src/grid_trading/services/trading_service.py index 7d07786..d6837b0 100644 --- a/src/grid_trading/services/trading_service.py +++ b/src/grid_trading/services/trading_service.py @@ -94,10 +94,19 @@ class TradingService: def refresh_quotes(self) -> dict[str, QuoteSnapshot]: instruments = self.repository.list_instruments() - quotes = self.quote_provider.fetch_quotes(instruments) - self._quote_cache = quotes + quotes = self.fetch_quotes_for_instruments(instruments) + self.apply_quote_snapshots(quotes) return quotes + def fetch_quotes_for_instruments( + self, + instruments: list[Instrument], + ) -> dict[str, QuoteSnapshot]: + return self.quote_provider.fetch_quotes(instruments) + + def apply_quote_snapshots(self, quotes: dict[str, QuoteSnapshot]) -> None: + self._quote_cache = quotes + def get_default_strategy_template(self) -> StrategyTemplate: self.ensure_defaults() template = self.repository.get_default_strategy_template() diff --git a/src/grid_trading/ui/main_window.py b/src/grid_trading/ui/main_window.py index d6dedeb..2cb110c 100644 --- a/src/grid_trading/ui/main_window.py +++ b/src/grid_trading/ui/main_window.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys from pathlib import Path -from PySide6.QtCore import Qt +from PySide6.QtCore import QObject, Qt, QThread, Signal from PySide6.QtWidgets import ( QApplication, QDialog, @@ -31,6 +31,24 @@ from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTem from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity +class QuoteRefreshWorker(QObject): + finished = Signal(object) + failed = Signal(str) + + def __init__(self, service: TradingService, instruments): + super().__init__() + self._service = service + self._instruments = instruments + + def run(self) -> None: + try: + quotes = self._service.fetch_quotes_for_instruments(self._instruments) + except Exception as exc: + self.failed.emit(str(exc)) + return + self.finished.emit(quotes) + + class MainWindow(QMainWindow): HOLDING_COLUMNS = [ "代码", @@ -56,6 +74,9 @@ class MainWindow(QMainWindow): self._positions: list[PositionSummary] = [] self._selected_instrument_id: int | None = None self._trade_ids_by_row: dict[int, int] = {} + self._quote_thread: QThread | None = None + self._quote_worker: QuoteRefreshWorker | None = None + self.refresh_quotes_button: QPushButton | None = None self.setWindowTitle("Grid Trading Manager") self.resize(1280, 780) @@ -168,6 +189,8 @@ class MainWindow(QMainWindow): for text, handler in buttons: button = QPushButton(text) button.clicked.connect(handler) + if text == "刷新行情": + self.refresh_quotes_button = button layout.addWidget(button) layout.addStretch() return layout @@ -188,13 +211,51 @@ class MainWindow(QMainWindow): self._refresh_details() def _refresh_quotes(self) -> None: + if self._quote_thread is not None and self._quote_thread.isRunning(): + return try: - self.service.refresh_quotes() + instruments = self.service.list_instruments() + except Exception as exc: + QMessageBox.warning(self, "行情刷新失败", str(exc)) + return + if not instruments: + QMessageBox.information(self, "行情刷新", "请先添加股票或 ETF 标的。") + return + self._set_quote_refreshing(True) + self._quote_thread = QThread(self) + self._quote_worker = QuoteRefreshWorker(self.service, instruments) + self._quote_worker.moveToThread(self._quote_thread) + self._quote_thread.started.connect(self._quote_worker.run) + self._quote_worker.finished.connect(self._on_quote_refresh_finished) + self._quote_worker.failed.connect(self._on_quote_refresh_failed) + self._quote_worker.finished.connect(self._quote_thread.quit) + self._quote_worker.failed.connect(self._quote_thread.quit) + self._quote_thread.finished.connect(self._quote_worker.deleteLater) + self._quote_thread.finished.connect(self._on_quote_thread_finished) + self._quote_thread.start() + + def _on_quote_refresh_finished(self, quotes: dict) -> None: + try: + self.service.apply_quote_snapshots(quotes) except Exception as exc: QMessageBox.warning(self, "行情刷新失败", str(exc)) return self.refresh_all() + def _on_quote_refresh_failed(self, message: str) -> None: + QMessageBox.warning(self, "行情刷新失败", message) + + def _on_quote_thread_finished(self) -> None: + self._set_quote_refreshing(False) + self._quote_thread = None + self._quote_worker = None + + def _set_quote_refreshing(self, refreshing: bool) -> None: + if self.refresh_quotes_button is None: + return + self.refresh_quotes_button.setEnabled(not refreshing) + self.refresh_quotes_button.setText("刷新中..." if refreshing else "刷新行情") + def _fill_holdings_table(self) -> None: self.holdings_table.setRowCount(len(self._positions)) for row, position in enumerate(self._positions): diff --git a/tests/test_ui.py b/tests/test_ui.py index 6b26e62..80a7864 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,4 +1,7 @@ from decimal import Decimal +import time + +from grid_trading.domain.models import Instrument def test_formatters_render_money_percent_and_empty_values(): @@ -30,3 +33,40 @@ def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): window.close() service.close() app.processEvents() + + +def test_quote_refresh_does_not_block_main_window(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication + + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + class SlowQuoteProvider: + def fetch_quotes(self, instruments): + time.sleep(0.3) + return {} + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db", quote_provider=SlowQuoteProvider()) + service.ensure_defaults() + service.add_instrument(Instrument(id=None, code="000001", name="Ping An Bank")) + window = MainWindow(service) + + started_at = time.perf_counter() + window._refresh_quotes() + elapsed = time.perf_counter() - started_at + + assert elapsed < 0.15 + + deadline = time.perf_counter() + 2 + while getattr(window, "_quote_thread", None) is not None and time.perf_counter() < deadline: + app.processEvents() + time.sleep(0.01) + + assert getattr(window, "_quote_thread", None) is None + + window.close() + service.close() + app.processEvents()