feat: add pyside6 holdings gui

This commit is contained in:
王鹏
2026-07-08 17:33:52 +08:00
parent f282d4a38b
commit 999ddf5e91
5 changed files with 694 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""PySide6 user interface."""

View File

@@ -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

View File

@@ -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:,}"

View File

@@ -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

31
tests/test_ui.py Normal file
View File

@@ -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()