feat: add pyside6 holdings gui
This commit is contained in:
1
src/grid_trading/ui/__init__.py
Normal file
1
src/grid_trading/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""PySide6 user interface."""
|
||||||
300
src/grid_trading/ui/dialogs.py
Normal file
300
src/grid_trading/ui/dialogs.py
Normal 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
|
||||||
28
src/grid_trading/ui/formatters.py
Normal file
28
src/grid_trading/ui/formatters.py
Normal 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:,}"
|
||||||
334
src/grid_trading/ui/main_window.py
Normal file
334
src/grid_trading/ui/main_window.py
Normal 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
31
tests/test_ui.py
Normal 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()
|
||||||
Reference in New Issue
Block a user