From 999ddf5e91db486a15e0601060ce2cc112d971b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Wed, 8 Jul 2026 17:33:52 +0800 Subject: [PATCH] feat: add pyside6 holdings gui --- src/grid_trading/ui/__init__.py | 1 + src/grid_trading/ui/dialogs.py | 300 ++++++++++++++++++++++++++ src/grid_trading/ui/formatters.py | 28 +++ src/grid_trading/ui/main_window.py | 334 +++++++++++++++++++++++++++++ tests/test_ui.py | 31 +++ 5 files changed, 694 insertions(+) create mode 100644 src/grid_trading/ui/__init__.py create mode 100644 src/grid_trading/ui/dialogs.py create mode 100644 src/grid_trading/ui/formatters.py create mode 100644 src/grid_trading/ui/main_window.py create mode 100644 tests/test_ui.py diff --git a/src/grid_trading/ui/__init__.py b/src/grid_trading/ui/__init__.py new file mode 100644 index 0000000..d7d66a8 --- /dev/null +++ b/src/grid_trading/ui/__init__.py @@ -0,0 +1 @@ +"""PySide6 user interface.""" diff --git a/src/grid_trading/ui/dialogs.py b/src/grid_trading/ui/dialogs.py new file mode 100644 index 0000000..b8f1768 --- /dev/null +++ b/src/grid_trading/ui/dialogs.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal, InvalidOperation + +from PySide6.QtCore import QDate +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDateEdit, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QTextEdit, + QVBoxLayout, +) + +from grid_trading.domain.models import ( + Account, + Instrument, + StrategyTemplate, + Trade, + TradeGroup, + TradeSide, +) +from grid_trading.services.trading_service import TradingService + + +class AccountDialog(QDialog): + def __init__(self, account: Account, parent=None): + super().__init__(parent) + self.setWindowTitle("账户设置") + self._account = account + + self.name_edit = QLineEdit(account.name) + self.cash_edit = _money_spinbox(account.initial_cash) + self.notes_edit = QTextEdit(account.notes) + self.notes_edit.setFixedHeight(70) + + form = QFormLayout() + form.addRow("账户名称", self.name_edit) + form.addRow("初始资金", self.cash_edit) + form.addRow("备注", self.notes_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + + def to_account(self) -> Account: + return Account( + id=self._account.id, + name=self.name_edit.text().strip(), + initial_cash=Decimal(str(self.cash_edit.value())), + notes=self.notes_edit.toPlainText().strip(), + ) + + +class InstrumentDialog(QDialog): + def __init__(self, instrument: Instrument | None = None, parent=None): + super().__init__(parent) + self.setWindowTitle("标的设置") + self._instrument = instrument + + self.code_edit = QLineEdit(instrument.code if instrument else "") + self.name_edit = QLineEdit(instrument.name if instrument else "") + self.market_edit = QComboBox() + self.market_edit.addItems(["A", "ETF"]) + if instrument: + self.market_edit.setCurrentText(instrument.market) + self.lot_size_edit = QSpinBox() + self.lot_size_edit.setRange(1, 1_000_000) + self.lot_size_edit.setValue(instrument.lot_size if instrument else 100) + self.manual_price_edit = QLineEdit(str(instrument.manual_price or "") if instrument else "") + self.allow_odd_lot_edit = QCheckBox("允许非整手") + self.allow_odd_lot_edit.setChecked(instrument.allow_odd_lot if instrument else False) + + form = QFormLayout() + form.addRow("代码", self.code_edit) + form.addRow("名称", self.name_edit) + form.addRow("市场", self.market_edit) + form.addRow("交易单位", self.lot_size_edit) + form.addRow("手动价格", self.manual_price_edit) + form.addRow("", self.allow_odd_lot_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + + def to_instrument(self) -> Instrument: + manual_price = _optional_decimal(self.manual_price_edit.text().strip(), "手动价格") + return Instrument( + id=self._instrument.id if self._instrument else None, + code=self.code_edit.text().strip(), + name=self.name_edit.text().strip(), + market=self.market_edit.currentText(), + lot_size=self.lot_size_edit.value(), + manual_price=manual_price, + allow_odd_lot=self.allow_odd_lot_edit.isChecked(), + active=True, + ) + + +class StrategyTemplateDialog(QDialog): + def __init__(self, template: StrategyTemplate, parent=None): + super().__init__(parent) + self.setWindowTitle("默认网格策略") + self._template = template + + self.name_edit = QLineEdit(template.name) + self.grid_spacing_edit = _percent_spinbox(template.grid_spacing_pct) + self.amount_per_grid_edit = _money_spinbox(template.amount_per_grid) + self.base_target_edit = _money_spinbox(template.base_target_amount) + self.max_position_edit = _money_spinbox(template.max_position_amount) + self.min_lot_edit = QSpinBox() + self.min_lot_edit.setRange(1, 1_000_000) + self.min_lot_edit.setValue(template.min_lot) + + form = QFormLayout() + form.addRow("模板名称", self.name_edit) + form.addRow("网格间距", self.grid_spacing_edit) + form.addRow("每格金额", self.amount_per_grid_edit) + form.addRow("底仓目标金额", self.base_target_edit) + form.addRow("最大投入金额", self.max_position_edit) + form.addRow("最小交易单位", self.min_lot_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + + def to_template(self) -> StrategyTemplate: + return StrategyTemplate( + id=self._template.id, + name=self.name_edit.text().strip(), + grid_spacing_pct=Decimal(str(self.grid_spacing_edit.value())) / Decimal("100"), + amount_per_grid=Decimal(str(self.amount_per_grid_edit.value())), + base_target_amount=Decimal(str(self.base_target_edit.value())), + max_position_amount=Decimal(str(self.max_position_edit.value())), + min_lot=self.min_lot_edit.value(), + is_default=True, + ) + + +class TradeDialog(QDialog): + def __init__( + self, + service: TradingService, + account_id: int, + instruments: list[Instrument], + trade: Trade | None = None, + parent=None, + ): + super().__init__(parent) + self.setWindowTitle("成交记录") + self._service = service + self._account_id = account_id + self._instruments = instruments + self._trade = trade + + self.instrument_edit = QComboBox() + for instrument in instruments: + self.instrument_edit.addItem(f"{instrument.code} {instrument.name}", instrument.id) + if trade: + index = self.instrument_edit.findData(trade.instrument_id) + if index >= 0: + self.instrument_edit.setCurrentIndex(index) + + self.date_edit = QDateEdit() + self.date_edit.setCalendarPopup(True) + trade_date = trade.trade_date if trade else date.today() + self.date_edit.setDate(QDate(trade_date.year, trade_date.month, trade_date.day)) + + self.side_edit = QComboBox() + self.side_edit.addItem("买入", TradeSide.BUY.value) + self.side_edit.addItem("卖出", TradeSide.SELL.value) + if trade: + self.side_edit.setCurrentIndex(self.side_edit.findData(trade.side.value)) + + self.group_edit = QComboBox() + self.group_edit.addItem("底仓", TradeGroup.BASE.value) + self.group_edit.addItem("网格", TradeGroup.GRID.value) + self.group_edit.addItem("其他", TradeGroup.OTHER.value) + if trade: + self.group_edit.setCurrentIndex(self.group_edit.findData(trade.trade_group.value)) + + self.price_edit = _money_spinbox(trade.price if trade else Decimal("0")) + self.quantity_edit = QSpinBox() + self.quantity_edit.setRange(1, 100_000_000) + self.quantity_edit.setSingleStep(100) + self.quantity_edit.setValue(trade.quantity if trade else 100) + self.commission_edit = _money_spinbox(trade.commission if trade else Decimal("0")) + self.stamp_tax_edit = _money_spinbox(trade.stamp_tax if trade else Decimal("0")) + self.transfer_fee_edit = _money_spinbox(trade.transfer_fee if trade else Decimal("0")) + self.notes_edit = QLineEdit(trade.notes if trade else "") + + estimate_button = QPushButton("估算费用") + estimate_button.clicked.connect(self._estimate_fees) + fee_row = QHBoxLayout() + fee_row.addWidget(QLabel("手续费")) + fee_row.addWidget(self.commission_edit) + fee_row.addWidget(QLabel("印花税")) + fee_row.addWidget(self.stamp_tax_edit) + fee_row.addWidget(QLabel("过户费")) + fee_row.addWidget(self.transfer_fee_edit) + fee_row.addWidget(estimate_button) + + form = QFormLayout() + form.addRow("标的", self.instrument_edit) + form.addRow("日期", self.date_edit) + form.addRow("方向", self.side_edit) + form.addRow("分组", self.group_edit) + form.addRow("价格", self.price_edit) + form.addRow("数量", self.quantity_edit) + form.addRow(fee_row) + form.addRow("备注", self.notes_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + + def to_trade(self) -> Trade: + selected_date = self.date_edit.date().toPython() + return Trade( + id=self._trade.id if self._trade else None, + account_id=self._account_id, + instrument_id=self.instrument_edit.currentData(), + trade_date=selected_date, + side=TradeSide(self.side_edit.currentData()), + price=Decimal(str(self.price_edit.value())), + quantity=self.quantity_edit.value(), + commission=Decimal(str(self.commission_edit.value())), + stamp_tax=Decimal(str(self.stamp_tax_edit.value())), + transfer_fee=Decimal(str(self.transfer_fee_edit.value())), + trade_group=TradeGroup(self.group_edit.currentData()), + notes=self.notes_edit.text().strip(), + ) + + def accept(self) -> None: + if self.instrument_edit.currentData() is None: + QMessageBox.warning(self, "无法保存", "请先添加标的") + return + super().accept() + + def _estimate_fees(self) -> None: + fees = self._service.estimate_trade_fees( + TradeSide(self.side_edit.currentData()), + Decimal(str(self.price_edit.value())), + self.quantity_edit.value(), + ) + self.commission_edit.setValue(float(fees.commission)) + self.stamp_tax_edit.setValue(float(fees.stamp_tax)) + self.transfer_fee_edit.setValue(float(fees.transfer_fee)) + + +def _money_spinbox(value: Decimal) -> QDoubleSpinBox: + spinbox = QDoubleSpinBox() + spinbox.setRange(0, 1_000_000_000) + spinbox.setDecimals(4) + spinbox.setValue(float(value)) + return spinbox + + +def _percent_spinbox(value: Decimal) -> QDoubleSpinBox: + spinbox = QDoubleSpinBox() + spinbox.setRange(0.01, 100) + spinbox.setDecimals(2) + spinbox.setSuffix("%") + spinbox.setValue(float(value * Decimal("100"))) + return spinbox + + +def _optional_decimal(raw: str, label: str) -> Decimal | None: + if not raw: + return None + try: + return Decimal(raw) + except InvalidOperation as exc: + raise ValueError(f"{label} 不是有效数字") from exc diff --git a/src/grid_trading/ui/formatters.py b/src/grid_trading/ui/formatters.py new file mode 100644 index 0000000..e775d9c --- /dev/null +++ b/src/grid_trading/ui/formatters.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + + +def format_money(value: Decimal | None) -> str: + if value is None: + return "-" + return f"{value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP):,.2f}" + + +def format_price(value: Decimal | None) -> str: + if value is None: + return "-" + return f"{value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP):,.2f}" + + +def format_percent(value: Decimal | None) -> str: + if value is None: + return "-" + percent = (value * Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{percent:.2f}%" + + +def format_quantity(value: int | None) -> str: + if value is None: + return "-" + return f"{value:,}" diff --git a/src/grid_trading/ui/main_window.py b/src/grid_trading/ui/main_window.py new file mode 100644 index 0000000..a3c326c --- /dev/null +++ b/src/grid_trading/ui/main_window.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QListWidget, + QMainWindow, + QMessageBox, + QPushButton, + QSplitter, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from grid_trading.config import DEFAULT_DB_PATH +from grid_trading.domain.models import PositionSummary, Trade, TradeSide +from grid_trading.services.trading_service import TradingService +from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog +from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity + + +class MainWindow(QMainWindow): + HOLDING_COLUMNS = [ + "代码", + "名称", + "现价", + "总持仓", + "可用", + "底仓", + "网格仓", + "持仓成本", + "持仓回本价", + "账户回本价", + "已实现盈亏", + "网格利润", + "浮动盈亏", + ] + TRADE_COLUMNS = ["日期", "方向", "分组", "价格", "数量", "费用", "备注"] + + def __init__(self, service: TradingService): + super().__init__() + self.service = service + self.service.ensure_defaults() + self._positions: list[PositionSummary] = [] + self._selected_instrument_id: int | None = None + self._trade_ids_by_row: dict[int, int] = {} + + self.setWindowTitle("Grid Trading Manager") + self.resize(1280, 780) + self._build_ui() + self.refresh_all() + + def _build_ui(self) -> None: + root = QWidget() + root_layout = QHBoxLayout(root) + + nav = QListWidget() + nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"]) + nav.setFixedWidth(150) + root_layout.addWidget(nav) + + content = QWidget() + content_layout = QVBoxLayout(content) + self.summary_labels = self._create_summary_cards() + content_layout.addLayout(self.summary_cards_layout) + content_layout.addLayout(self._create_toolbar()) + + splitter = QSplitter(Qt.Orientation.Vertical) + self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS)) + self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS) + self.holdings_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.holdings_table.horizontalHeader().setStretchLastSection(True) + self.holdings_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.holdings_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected) + splitter.addWidget(self.holdings_table) + + details = QSplitter(Qt.Orientation.Horizontal) + self.detail_box = QGroupBox("选中标的详情") + self.detail_layout = QGridLayout(self.detail_box) + self.detail_labels = { + "price_source": QLabel("-"), + "cost": QLabel("-"), + "breakeven": QLabel("-"), + "profit": QLabel("-"), + } + self.detail_layout.addWidget(QLabel("价格来源"), 0, 0) + self.detail_layout.addWidget(self.detail_labels["price_source"], 0, 1) + self.detail_layout.addWidget(QLabel("剩余成本"), 1, 0) + self.detail_layout.addWidget(self.detail_labels["cost"], 1, 1) + self.detail_layout.addWidget(QLabel("回本价"), 2, 0) + self.detail_layout.addWidget(self.detail_labels["breakeven"], 2, 1) + self.detail_layout.addWidget(QLabel("利润"), 3, 0) + self.detail_layout.addWidget(self.detail_labels["profit"], 3, 1) + + trade_group = QGroupBox("最近成交") + trade_layout = QVBoxLayout(trade_group) + trade_buttons = QHBoxLayout() + edit_trade_button = QPushButton("编辑成交") + edit_trade_button.clicked.connect(self._edit_selected_trade) + delete_trade_button = QPushButton("删除成交") + delete_trade_button.clicked.connect(self._delete_selected_trade) + trade_buttons.addWidget(edit_trade_button) + trade_buttons.addWidget(delete_trade_button) + trade_buttons.addStretch() + self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS)) + self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS) + self.trades_table.horizontalHeader().setStretchLastSection(True) + self.trades_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.trades_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + trade_layout.addLayout(trade_buttons) + trade_layout.addWidget(self.trades_table) + + details.addWidget(self.detail_box) + details.addWidget(trade_group) + splitter.addWidget(details) + splitter.setSizes([470, 230]) + content_layout.addWidget(splitter) + + root_layout.addWidget(content) + self.setCentralWidget(root) + + def _create_summary_cards(self) -> dict[str, QLabel]: + self.summary_cards_layout = QGridLayout() + labels: dict[str, QLabel] = {} + for column, (key, title) in enumerate( + [ + ("total_assets", "总资产"), + ("cash", "现金"), + ("market_value", "持仓市值"), + ("floating_pnl", "浮动盈亏"), + ("usage", "资金使用率"), + ] + ): + frame = QFrame() + frame.setFrameShape(QFrame.Shape.StyledPanel) + layout = QVBoxLayout(frame) + title_label = QLabel(title) + value_label = QLabel("-") + value_label.setStyleSheet("font-size: 20px; font-weight: 700;") + layout.addWidget(title_label) + layout.addWidget(value_label) + labels[key] = value_label + self.summary_cards_layout.addWidget(frame, 0, column) + return labels + + def _create_toolbar(self) -> QHBoxLayout: + layout = QHBoxLayout() + buttons = [ + ("刷新", self.refresh_all), + ("账户设置", self._edit_account), + ("添加标的", self._add_instrument), + ("录入成交", self._add_trade), + ("策略设置", self._edit_strategy), + ] + for text, handler in buttons: + button = QPushButton(text) + button.clicked.connect(handler) + layout.addWidget(button) + layout.addStretch() + return layout + + def refresh_all(self) -> None: + try: + self._positions = self.service.get_position_summaries() + account_summary = self.service.get_account_summary() + except Exception as exc: + QMessageBox.warning(self, "刷新失败", str(exc)) + return + self.summary_labels["total_assets"].setText(format_money(account_summary.total_assets)) + self.summary_labels["cash"].setText(format_money(account_summary.cash)) + self.summary_labels["market_value"].setText(format_money(account_summary.market_value)) + self.summary_labels["floating_pnl"].setText(format_money(account_summary.floating_pnl)) + self.summary_labels["usage"].setText(format_percent(account_summary.capital_usage_rate)) + self._fill_holdings_table() + self._refresh_details() + + def _fill_holdings_table(self) -> None: + self.holdings_table.setRowCount(len(self._positions)) + for row, position in enumerate(self._positions): + values = [ + position.code, + position.name, + format_price(position.current_price), + format_quantity(position.total_quantity), + format_quantity(position.available_quantity), + format_quantity(position.base_quantity), + format_quantity(position.grid_quantity), + format_money(position.remaining_cost), + format_price(position.position_breakeven_price), + format_price(position.account_breakeven_price), + format_money(position.realized_pnl), + format_money(position.grid_profit), + format_money(position.floating_pnl), + ] + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setData(Qt.ItemDataRole.UserRole, position.instrument_id) + self.holdings_table.setItem(row, column, item) + if self._positions and self.holdings_table.currentRow() < 0: + self.holdings_table.selectRow(0) + + def _on_holding_selected(self) -> None: + row = self.holdings_table.currentRow() + item = self.holdings_table.item(row, 0) if row >= 0 else None + self._selected_instrument_id = item.data(Qt.ItemDataRole.UserRole) if item else None + self._refresh_details() + + def _refresh_details(self) -> None: + position = self._selected_position() + if position is None: + for label in self.detail_labels.values(): + label.setText("-") + self._fill_trades_table([]) + return + self.detail_labels["price_source"].setText(position.price_source) + self.detail_labels["cost"].setText(format_money(position.remaining_cost)) + self.detail_labels["breakeven"].setText( + f"持仓 {format_price(position.position_breakeven_price)} / 账户 {format_price(position.account_breakeven_price)}" + ) + self.detail_labels["profit"].setText( + f"已实现 {format_money(position.realized_pnl)} / 网格 {format_money(position.grid_profit)}" + ) + self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id)) + + def _fill_trades_table(self, trades: list[Trade]) -> None: + self._trade_ids_by_row = {} + self.trades_table.setRowCount(len(trades)) + for row, trade in enumerate(trades): + self._trade_ids_by_row[row] = trade.id + values = [ + trade.trade_date.isoformat(), + "买入" if trade.side is TradeSide.BUY else "卖出", + trade.trade_group.value, + format_price(trade.price), + format_quantity(trade.quantity), + format_money(trade.total_fee), + trade.notes, + ] + for column, value in enumerate(values): + self.trades_table.setItem(row, column, QTableWidgetItem(value)) + + def _selected_position(self) -> PositionSummary | None: + if self._selected_instrument_id is None: + return None + return next( + ( + position + for position in self._positions + if position.instrument_id == self._selected_instrument_id + ), + None, + ) + + def _edit_account(self) -> None: + dialog = AccountDialog(self.service.get_active_account(), self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._run_action(lambda: self.service.save_account(dialog.to_account())) + + def _add_instrument(self) -> None: + dialog = InstrumentDialog(parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._run_action(lambda: self.service.add_instrument(dialog.to_instrument())) + + def _edit_strategy(self) -> None: + dialog = StrategyTemplateDialog(self.service.get_default_strategy_template(), self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._run_action(lambda: self.service.save_strategy_template(dialog.to_template())) + + def _add_trade(self) -> None: + instruments = self.service.list_instruments() + if not instruments: + QMessageBox.information(self, "需要标的", "请先添加股票或 ETF 标的。") + return + account = self.service.get_active_account() + dialog = TradeDialog(self.service, account.id, instruments, parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._run_action(lambda: self.service.save_trade(dialog.to_trade())) + + def _edit_selected_trade(self) -> None: + trade = self._selected_trade() + if trade is None: + QMessageBox.information(self, "请选择成交", "请先在最近成交表中选择一条记录。") + return + account = self.service.get_active_account() + dialog = TradeDialog(self.service, account.id, self.service.list_instruments(), trade, self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._run_action(lambda: self.service.update_trade(dialog.to_trade())) + + def _delete_selected_trade(self) -> None: + trade = self._selected_trade() + if trade is None: + QMessageBox.information(self, "请选择成交", "请先在最近成交表中选择一条记录。") + return + answer = QMessageBox.question(self, "删除成交", "确定删除选中的成交记录吗?") + if answer == QMessageBox.StandardButton.Yes: + self._run_action(lambda: self.service.delete_trade(trade.id)) + + def _selected_trade(self) -> Trade | None: + row = self.trades_table.currentRow() + trade_id = self._trade_ids_by_row.get(row) + if trade_id is None: + return None + return next((trade for trade in self.service.list_trades() if trade.id == trade_id), None) + + def _run_action(self, action) -> None: + try: + action() + except Exception as exc: + QMessageBox.warning(self, "操作失败", str(exc)) + return + self.refresh_all() + + +def run_gui(db_path: str | Path | None = None) -> int: + app = QApplication.instance() or QApplication(sys.argv) + service = TradingService(db_path or DEFAULT_DB_PATH) + window = MainWindow(service) + window.show() + result = app.exec() + service.close() + return result diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..9a2b3d4 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,31 @@ +from decimal import Decimal + + +def test_formatters_render_money_percent_and_empty_values(): + from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity + + assert format_money(Decimal("1234.5")) == "1,234.50" + assert format_price(Decimal("3.956")) == "3.96" + assert format_percent(Decimal("0.1234")) == "12.34%" + assert format_quantity(1200) == "1,200" + assert format_money(None) == "-" + + +def test_main_window_can_be_constructed_offscreen(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 + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + + assert window.windowTitle() == "Grid Trading Manager" + assert window.holdings_table.columnCount() > 0 + + window.close() + service.close() + app.processEvents()