fix: refresh quotes without blocking UI
This commit is contained in:
@@ -94,10 +94,19 @@ class TradingService:
|
|||||||
|
|
||||||
def refresh_quotes(self) -> dict[str, QuoteSnapshot]:
|
def refresh_quotes(self) -> dict[str, QuoteSnapshot]:
|
||||||
instruments = self.repository.list_instruments()
|
instruments = self.repository.list_instruments()
|
||||||
quotes = self.quote_provider.fetch_quotes(instruments)
|
quotes = self.fetch_quotes_for_instruments(instruments)
|
||||||
self._quote_cache = quotes
|
self.apply_quote_snapshots(quotes)
|
||||||
return 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:
|
def get_default_strategy_template(self) -> StrategyTemplate:
|
||||||
self.ensure_defaults()
|
self.ensure_defaults()
|
||||||
template = self.repository.get_default_strategy_template()
|
template = self.repository.get_default_strategy_template()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import QObject, Qt, QThread, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
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
|
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):
|
class MainWindow(QMainWindow):
|
||||||
HOLDING_COLUMNS = [
|
HOLDING_COLUMNS = [
|
||||||
"代码",
|
"代码",
|
||||||
@@ -56,6 +74,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._positions: list[PositionSummary] = []
|
self._positions: list[PositionSummary] = []
|
||||||
self._selected_instrument_id: int | None = None
|
self._selected_instrument_id: int | None = None
|
||||||
self._trade_ids_by_row: dict[int, int] = {}
|
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.setWindowTitle("Grid Trading Manager")
|
||||||
self.resize(1280, 780)
|
self.resize(1280, 780)
|
||||||
@@ -168,6 +189,8 @@ class MainWindow(QMainWindow):
|
|||||||
for text, handler in buttons:
|
for text, handler in buttons:
|
||||||
button = QPushButton(text)
|
button = QPushButton(text)
|
||||||
button.clicked.connect(handler)
|
button.clicked.connect(handler)
|
||||||
|
if text == "刷新行情":
|
||||||
|
self.refresh_quotes_button = button
|
||||||
layout.addWidget(button)
|
layout.addWidget(button)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return layout
|
return layout
|
||||||
@@ -188,13 +211,51 @@ class MainWindow(QMainWindow):
|
|||||||
self._refresh_details()
|
self._refresh_details()
|
||||||
|
|
||||||
def _refresh_quotes(self) -> None:
|
def _refresh_quotes(self) -> None:
|
||||||
|
if self._quote_thread is not None and self._quote_thread.isRunning():
|
||||||
|
return
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
QMessageBox.warning(self, "行情刷新失败", str(exc))
|
QMessageBox.warning(self, "行情刷新失败", str(exc))
|
||||||
return
|
return
|
||||||
self.refresh_all()
|
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:
|
def _fill_holdings_table(self) -> None:
|
||||||
self.holdings_table.setRowCount(len(self._positions))
|
self.holdings_table.setRowCount(len(self._positions))
|
||||||
for row, position in enumerate(self._positions):
|
for row, position in enumerate(self._positions):
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import time
|
||||||
|
|
||||||
|
from grid_trading.domain.models import Instrument
|
||||||
|
|
||||||
|
|
||||||
def test_formatters_render_money_percent_and_empty_values():
|
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()
|
window.close()
|
||||||
service.close()
|
service.close()
|
||||||
app.processEvents()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user