Files
2026-07-13 12:07:23 +08:00

1711 lines
59 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Basic widgets for UI.
"""
import csv
import platform
import time
from enum import Enum
from io import BytesIO
from typing import cast, Any
from copy import copy
from tzlocal import get_localzone_name
from datetime import datetime
from importlib import metadata
import qrcode
from .qt import QtCore, QtGui, QtWidgets, Qt
from ..constant import Direction, Exchange, Offset, OrderType
from ..engine import MainEngine, Event, EventEngine, WechatEngine
from ..event import (
EVENT_QUOTE,
EVENT_TICK,
EVENT_TRADE,
EVENT_ORDER,
EVENT_POSITION,
EVENT_ACCOUNT,
EVENT_LOG
)
from ..object import (
OrderRequest,
SubscribeRequest,
CancelRequest,
ContractData,
PositionData,
OrderData,
QuoteData,
TickData
)
from ..utility import load_json, save_json, get_digits, ZoneInfo
from ..setting import SETTING_FILENAME, SETTINGS
from ..locale import _
from ..wechat import (
Credentials,
WeixinError,
WeixinTimeout,
poll,
request_qrcode,
wait_for_login,
)
COLOR_LONG = QtGui.QColor("red")
COLOR_SHORT = QtGui.QColor("green")
COLOR_BID = QtGui.QColor(255, 174, 201)
COLOR_ASK = QtGui.QColor(160, 255, 160)
COLOR_BLACK = QtGui.QColor("black")
class BaseCell(QtWidgets.QTableWidgetItem):
"""
General cell used in tablewidgets.
"""
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__()
self._text: str = ""
self._data: Any = None
self.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.set_content(content, data)
def set_content(self, content: Any, data: Any) -> None:
"""
Set text content.
"""
self._text = str(content)
self._data = data
self.setText(self._text)
def get_data(self) -> Any:
"""
Get data object.
"""
return self._data
def __lt__(self, other: "BaseCell") -> bool: # type: ignore
"""
Sort by text content.
"""
result: bool = self._text < other._text
return result
class EnumCell(BaseCell):
"""
Cell used for showing enum data.
"""
def __init__(self, content: Enum, data: Any) -> None:
""""""
super().__init__(content, data)
def set_content(self, content: Any, data: Any) -> None:
"""
Set text using enum.constant.value.
"""
if content:
super().set_content(content.value, data)
class DirectionCell(EnumCell):
"""
Cell used for showing direction data.
"""
def __init__(self, content: Enum, data: Any) -> None:
""""""
super().__init__(content, data)
def set_content(self, content: Any, data: Any) -> None:
"""
Cell color is set according to direction.
"""
super().set_content(content, data)
if content is Direction.SHORT:
self.setForeground(COLOR_SHORT)
else:
self.setForeground(COLOR_LONG)
class BidCell(BaseCell):
"""
Cell used for showing bid price and volume.
"""
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__(content, data)
self.setForeground(COLOR_BID)
class AskCell(BaseCell):
"""
Cell used for showing ask price and volume.
"""
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__(content, data)
self.setForeground(COLOR_ASK)
class PnlCell(BaseCell):
"""
Cell used for showing pnl data.
"""
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__(content, data)
def set_content(self, content: Any, data: Any) -> None:
"""
Cell color is set based on whether pnl is
positive or negative.
"""
super().set_content(content, data)
if str(content).startswith("-"):
self.setForeground(COLOR_SHORT)
else:
self.setForeground(COLOR_LONG)
class TimeCell(BaseCell):
"""
Cell used for showing time string from datetime object.
"""
local_tz = ZoneInfo(get_localzone_name())
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__(content, data)
def set_content(self, content: datetime | None, data: Any) -> None:
""""""
if content is None:
return
content = content.astimezone(self.local_tz)
timestamp: str = content.strftime("%H:%M:%S")
millisecond: int = int(content.microsecond / 1000)
if millisecond:
timestamp = f"{timestamp}.{millisecond}"
else:
timestamp = f"{timestamp}.000"
self.setText(timestamp)
self._data = data
class DateCell(BaseCell):
"""
Cell used for showing date string from datetime object.
"""
def __init__(self, content: Any, data: Any) -> None:
""""""
super().__init__(content, data)
def set_content(self, content: Any, data: Any) -> None:
""""""
if content is None:
return
self.setText(content.strftime("%Y-%m-%d"))
self._data = data
class MsgCell(BaseCell):
"""
Cell used for showing msg data.
"""
def __init__(self, content: str, data: Any) -> None:
""""""
super().__init__(content, data)
self.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter)
class BaseMonitor(QtWidgets.QTableWidget):
"""
Monitor data update.
"""
event_type: str = ""
data_key: str = ""
sorting: bool = False
headers: dict = {}
signal: QtCore.Signal = QtCore.Signal(Event)
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
""""""
super().__init__()
self.main_engine: MainEngine = main_engine
self.event_engine: EventEngine = event_engine
self.cells: dict[str, dict] = {}
self.init_ui()
self.load_setting()
self.register_event()
def init_ui(self) -> None:
""""""
self.init_table()
self.init_menu()
def init_table(self) -> None:
"""
Initialize table.
"""
self.setColumnCount(len(self.headers))
labels: list = [d["display"] for d in self.headers.values()]
self.setHorizontalHeaderLabels(labels)
self.verticalHeader().setVisible(False)
self.setEditTriggers(self.EditTrigger.NoEditTriggers)
self.setAlternatingRowColors(True)
self.setSortingEnabled(self.sorting)
def init_menu(self) -> None:
"""
Create right click menu.
"""
self.menu: QtWidgets.QMenu = QtWidgets.QMenu(self)
resize_action: QtGui.QAction = QtGui.QAction(_("调整列宽"), self)
resize_action.triggered.connect(self.resize_columns)
self.menu.addAction(resize_action)
save_action: QtGui.QAction = QtGui.QAction(_("保存数据"), self)
save_action.triggered.connect(self.save_csv)
self.menu.addAction(save_action)
def register_event(self) -> None:
"""
Register event handler into event engine.
"""
if self.event_type:
self.signal.connect(self.process_event)
self.event_engine.register(self.event_type, self.signal.emit)
def process_event(self, event: Event) -> None:
"""
Process new data from event and update into table.
"""
# Disable sorting to prevent unwanted error.
if self.sorting:
self.setSortingEnabled(False)
# Update data into table.
data = event.data
if not self.data_key:
self.insert_new_row(data)
else:
key: str = data.__getattribute__(self.data_key)
if key in self.cells:
self.update_old_row(data)
else:
self.insert_new_row(data)
# Enable sorting
if self.sorting:
self.setSortingEnabled(True)
def insert_new_row(self, data: Any) -> None:
"""
Insert a new row at the top of table.
"""
self.insertRow(0)
row_cells: dict = {}
for column, header in enumerate(self.headers.keys()):
setting: dict = self.headers[header]
content = data.__getattribute__(header)
cell: QtWidgets.QTableWidgetItem = setting["cell"](content, data)
self.setItem(0, column, cell)
if setting["update"]:
row_cells[header] = cell
if self.data_key:
key: str = data.__getattribute__(self.data_key)
self.cells[key] = row_cells
def update_old_row(self, data: Any) -> None:
"""
Update an old row in table.
"""
key: str = data.__getattribute__(self.data_key)
row_cells = self.cells[key]
for header, cell in row_cells.items():
content = data.__getattribute__(header)
cell.set_content(content, data)
def resize_columns(self) -> None:
"""
Resize all columns according to contents.
"""
self.horizontalHeader().resizeSections(QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
def save_csv(self) -> None:
"""
Save table data into a csv file
"""
path, __ = QtWidgets.QFileDialog.getSaveFileName(
self, _("保存数据"), "", "CSV(*.csv)")
if not path:
return
with open(path, "w") as f:
writer = csv.writer(f, lineterminator="\n")
headers: list = [d["display"] for d in self.headers.values()]
writer.writerow(headers)
for row in range(self.rowCount()):
if self.isRowHidden(row):
continue
row_data: list = []
for column in range(self.columnCount()):
item: QtWidgets.QTableWidgetItem | None = self.item(row, column)
if item:
row_data.append(str(item.text()))
else:
row_data.append("")
writer.writerow(row_data)
def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None:
"""
Show menu with right click.
"""
self.menu.popup(QtGui.QCursor.pos())
def save_setting(self) -> None:
""""""
settings: QtCore.QSettings = QtCore.QSettings(self.__class__.__name__, "custom")
settings.setValue("column_state", self.horizontalHeader().saveState())
def load_setting(self) -> None:
""""""
settings: QtCore.QSettings = QtCore.QSettings(self.__class__.__name__, "custom")
column_state = settings.value("column_state")
if isinstance(column_state, QtCore.QByteArray):
self.horizontalHeader().restoreState(column_state)
self.horizontalHeader().setSortIndicator(-1, QtCore.Qt.SortOrder.AscendingOrder)
class TickMonitor(BaseMonitor):
"""
Monitor for tick data.
"""
event_type: str = EVENT_TICK
data_key: str = "vt_symbol"
sorting: bool = True
headers: dict = {
"symbol": {"display": _("代码"), "cell": BaseCell, "update": False},
"exchange": {"display": _("交易所"), "cell": EnumCell, "update": False},
"name": {"display": _("名称"), "cell": BaseCell, "update": True},
"last_price": {"display": _("最新价"), "cell": BaseCell, "update": True},
"volume": {"display": _("成交量"), "cell": BaseCell, "update": True},
"open_price": {"display": _("开盘价"), "cell": BaseCell, "update": True},
"high_price": {"display": _("最高价"), "cell": BaseCell, "update": True},
"low_price": {"display": _("最低价"), "cell": BaseCell, "update": True},
"bid_price_1": {"display": _("买1价"), "cell": BidCell, "update": True},
"bid_volume_1": {"display": _("买1量"), "cell": BidCell, "update": True},
"ask_price_1": {"display": _("卖1价"), "cell": AskCell, "update": True},
"ask_volume_1": {"display": _("卖1量"), "cell": AskCell, "update": True},
"datetime": {"display": _("时间"), "cell": TimeCell, "update": True},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
class LogMonitor(BaseMonitor):
"""
Monitor for log data.
"""
event_type: str = EVENT_LOG
data_key: str = ""
sorting: bool = False
headers: dict = {
"time": {"display": _("时间"), "cell": TimeCell, "update": False},
"msg": {"display": _("信息"), "cell": MsgCell, "update": False},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
class TradeMonitor(BaseMonitor):
"""
Monitor for trade data.
"""
event_type: str = EVENT_TRADE
data_key: str = ""
sorting: bool = True
headers: dict = {
"tradeid": {"display": _("成交号"), "cell": BaseCell, "update": False},
"orderid": {"display": _("委托号"), "cell": BaseCell, "update": False},
"symbol": {"display": _("代码"), "cell": BaseCell, "update": False},
"exchange": {"display": _("交易所"), "cell": EnumCell, "update": False},
"direction": {"display": _("方向"), "cell": DirectionCell, "update": False},
"offset": {"display": _("开平"), "cell": EnumCell, "update": False},
"price": {"display": _("价格"), "cell": BaseCell, "update": False},
"volume": {"display": _("数量"), "cell": BaseCell, "update": False},
"datetime": {"display": _("时间"), "cell": TimeCell, "update": False},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
class OrderMonitor(BaseMonitor):
"""
Monitor for order data.
"""
event_type: str = EVENT_ORDER
data_key: str = "vt_orderid"
sorting: bool = True
headers: dict = {
"orderid": {"display": _("委托号"), "cell": BaseCell, "update": False},
"reference": {"display": _("来源"), "cell": BaseCell, "update": False},
"symbol": {"display": _("代码"), "cell": BaseCell, "update": False},
"exchange": {"display": _("交易所"), "cell": EnumCell, "update": False},
"type": {"display": _("类型"), "cell": EnumCell, "update": False},
"direction": {"display": _("方向"), "cell": DirectionCell, "update": False},
"offset": {"display": _("开平"), "cell": EnumCell, "update": False},
"price": {"display": _("价格"), "cell": BaseCell, "update": False},
"volume": {"display": _("总数量"), "cell": BaseCell, "update": True},
"traded": {"display": _("已成交"), "cell": BaseCell, "update": True},
"status": {"display": _("状态"), "cell": EnumCell, "update": True},
"datetime": {"display": _("时间"), "cell": TimeCell, "update": True},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
def init_ui(self) -> None:
"""
Connect signal.
"""
super().init_ui()
self.setToolTip(_("双击单元格撤单"))
self.itemDoubleClicked.connect(self.cancel_order)
def cancel_order(self, cell: BaseCell) -> None:
"""
Cancel order if cell double clicked.
"""
order: OrderData = cell.get_data()
req: CancelRequest = order.create_cancel_request()
self.main_engine.cancel_order(req, order.gateway_name)
class PositionMonitor(BaseMonitor):
"""
Monitor for position data.
"""
event_type: str = EVENT_POSITION
data_key: str = "vt_positionid"
sorting: bool = True
headers: dict = {
"symbol": {"display": _("代码"), "cell": BaseCell, "update": False},
"exchange": {"display": _("交易所"), "cell": EnumCell, "update": False},
"direction": {"display": _("方向"), "cell": DirectionCell, "update": False},
"volume": {"display": _("数量"), "cell": BaseCell, "update": True},
"yd_volume": {"display": _("昨仓"), "cell": BaseCell, "update": True},
"frozen": {"display": _("冻结"), "cell": BaseCell, "update": True},
"price": {"display": _("均价"), "cell": BaseCell, "update": True},
"pnl": {"display": _("盈亏"), "cell": PnlCell, "update": True},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
class AccountMonitor(BaseMonitor):
"""
Monitor for account data.
"""
event_type: str = EVENT_ACCOUNT
data_key: str = "vt_accountid"
sorting: bool = True
headers: dict = {
"accountid": {"display": _("账号"), "cell": BaseCell, "update": False},
"balance": {"display": _("余额"), "cell": BaseCell, "update": True},
"frozen": {"display": _("冻结"), "cell": BaseCell, "update": True},
"available": {"display": _("可用"), "cell": BaseCell, "update": True},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
class QuoteMonitor(BaseMonitor):
"""
Monitor for quote data.
"""
event_type: str = EVENT_QUOTE
data_key: str = "vt_quoteid"
sorting: bool = True
headers: dict = {
"quoteid": {"display": _("报价号"), "cell": BaseCell, "update": False},
"reference": {"display": _("来源"), "cell": BaseCell, "update": False},
"symbol": {"display": _("代码"), "cell": BaseCell, "update": False},
"exchange": {"display": _("交易所"), "cell": EnumCell, "update": False},
"bid_offset": {"display": _("买开平"), "cell": EnumCell, "update": False},
"bid_volume": {"display": _("买量"), "cell": BidCell, "update": False},
"bid_price": {"display": _("买价"), "cell": BidCell, "update": False},
"ask_price": {"display": _("卖价"), "cell": AskCell, "update": False},
"ask_volume": {"display": _("卖量"), "cell": AskCell, "update": False},
"ask_offset": {"display": _("卖开平"), "cell": EnumCell, "update": False},
"status": {"display": _("状态"), "cell": EnumCell, "update": True},
"datetime": {"display": _("时间"), "cell": TimeCell, "update": True},
"gateway_name": {"display": _("接口"), "cell": BaseCell, "update": False},
}
def init_ui(self) -> None:
"""
Connect signal.
"""
super().init_ui()
self.setToolTip(_("双击单元格撤销报价"))
self.itemDoubleClicked.connect(self.cancel_quote)
def cancel_quote(self, cell: BaseCell) -> None:
"""
Cancel quote if cell double clicked.
"""
quote: QuoteData = cell.get_data()
req: CancelRequest = quote.create_cancel_request()
self.main_engine.cancel_quote(req, quote.gateway_name)
class ConnectDialog(QtWidgets.QDialog):
"""
Start connection of a certain gateway.
"""
def __init__(self, main_engine: MainEngine, gateway_name: str) -> None:
""""""
super().__init__()
self.main_engine: MainEngine = main_engine
self.gateway_name: str = gateway_name
self.filename: str = f"connect_{gateway_name.lower()}.json"
self.widgets: dict[str, tuple[QtWidgets.QWidget, type]] = {}
self.init_ui()
def init_ui(self) -> None:
""""""
self.setWindowTitle(_("连接{}").format(self.gateway_name))
# Default setting provides field name, field data type and field default value.
default_setting: dict | None = self.main_engine.get_default_setting(self.gateway_name)
# Saved setting provides field data used last time.
loaded_setting: dict = load_json(self.filename)
# Initialize line edits and form layout based on setting.
form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
if default_setting:
for field_name, field_value in default_setting.items():
field_type: type = type(field_value)
if field_type is list:
combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
combo.addItems(field_value)
if field_name in loaded_setting:
saved_value = loaded_setting[field_name]
ix: int = combo.findText(saved_value)
combo.setCurrentIndex(ix)
form.addRow(f"{field_name} <{field_type.__name__}>", combo)
self.widgets[field_name] = (combo, field_type)
else:
line: QtWidgets.QLineEdit = QtWidgets.QLineEdit(str(field_value))
if field_name in loaded_setting:
saved_value = loaded_setting[field_name]
line.setText(str(saved_value))
if _("密码") in field_name:
line.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
if field_type is int:
validator: QtGui.QIntValidator = QtGui.QIntValidator()
line.setValidator(validator)
form.addRow(f"{field_name} <{field_type.__name__}>", line)
self.widgets[field_name] = (line, field_type)
button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("连接"))
button.clicked.connect(self.connect_gateway)
form.addRow(button)
self.setLayout(form)
def connect_gateway(self) -> None:
"""
Get setting value from line edits and connect the gateway.
"""
setting: dict = {}
for field_name, tp in self.widgets.items():
widget, field_type = tp
if field_type is list:
combo: QtWidgets.QComboBox = cast(QtWidgets.QComboBox, widget)
field_value = str(combo.currentText())
else:
line: QtWidgets.QLineEdit = cast(QtWidgets.QLineEdit, widget)
try:
field_value = field_type(line.text())
except ValueError:
field_value = field_type()
setting[field_name] = field_value
save_json(self.filename, setting)
self.main_engine.connect(setting, self.gateway_name)
self.accept()
class TradingWidget(QtWidgets.QWidget):
"""
General manual trading widget.
"""
signal_tick: QtCore.Signal = QtCore.Signal(Event)
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
""""""
super().__init__()
self.main_engine: MainEngine = main_engine
self.event_engine: EventEngine = event_engine
self.vt_symbol: str = ""
self.price_digits: int = 0
self.init_ui()
self.register_event()
def init_ui(self) -> None:
""""""
self.setFixedWidth(300)
# Trading function area
exchanges: list[Exchange] = self.main_engine.get_all_exchanges()
self.exchange_combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
self.exchange_combo.addItems([exchange.value for exchange in exchanges])
self.symbol_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit()
self.symbol_line.returnPressed.connect(self.set_vt_symbol)
self.name_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit()
self.name_line.setReadOnly(True)
self.direction_combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
self.direction_combo.addItems(
[Direction.LONG.value, Direction.SHORT.value])
self.offset_combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
self.offset_combo.addItems([offset.value for offset in Offset])
self.order_type_combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
self.order_type_combo.addItems(
[order_type.value for order_type in OrderType])
double_validator: QtGui.QDoubleValidator = QtGui.QDoubleValidator()
double_validator.setBottom(0)
self.price_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit()
self.price_line.setValidator(double_validator)
self.volume_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit()
self.volume_line.setValidator(double_validator)
self.gateway_combo: QtWidgets.QComboBox = QtWidgets.QComboBox()
self.gateway_combo.addItems(self.main_engine.get_all_gateway_names())
self.price_check: QtWidgets.QCheckBox = QtWidgets.QCheckBox()
self.price_check.setToolTip(_("设置价格随行情更新"))
send_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("委托"))
send_button.clicked.connect(self.send_order)
cancel_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("全撤"))
cancel_button.clicked.connect(self.cancel_all)
grid: QtWidgets.QGridLayout = QtWidgets.QGridLayout()
grid.addWidget(QtWidgets.QLabel(_("交易所")), 0, 0)
grid.addWidget(QtWidgets.QLabel(_("代码")), 1, 0)
grid.addWidget(QtWidgets.QLabel(_("名称")), 2, 0)
grid.addWidget(QtWidgets.QLabel(_("方向")), 3, 0)
grid.addWidget(QtWidgets.QLabel(_("开平")), 4, 0)
grid.addWidget(QtWidgets.QLabel(_("类型")), 5, 0)
grid.addWidget(QtWidgets.QLabel(_("价格")), 6, 0)
grid.addWidget(QtWidgets.QLabel(_("数量")), 7, 0)
grid.addWidget(QtWidgets.QLabel(_("接口")), 8, 0)
grid.addWidget(self.exchange_combo, 0, 1, 1, 2)
grid.addWidget(self.symbol_line, 1, 1, 1, 2)
grid.addWidget(self.name_line, 2, 1, 1, 2)
grid.addWidget(self.direction_combo, 3, 1, 1, 2)
grid.addWidget(self.offset_combo, 4, 1, 1, 2)
grid.addWidget(self.order_type_combo, 5, 1, 1, 2)
grid.addWidget(self.price_line, 6, 1, 1, 1)
grid.addWidget(self.price_check, 6, 2, 1, 1)
grid.addWidget(self.volume_line, 7, 1, 1, 2)
grid.addWidget(self.gateway_combo, 8, 1, 1, 2)
grid.addWidget(send_button, 9, 0, 1, 3)
grid.addWidget(cancel_button, 10, 0, 1, 3)
# Market depth display area
bid_color: str = "rgb(255,174,201)"
ask_color: str = "rgb(160,255,160)"
self.bp1_label: QtWidgets.QLabel = self.create_label(bid_color)
self.bp2_label: QtWidgets.QLabel = self.create_label(bid_color)
self.bp3_label: QtWidgets.QLabel = self.create_label(bid_color)
self.bp4_label: QtWidgets.QLabel = self.create_label(bid_color)
self.bp5_label: QtWidgets.QLabel = self.create_label(bid_color)
self.bv1_label: QtWidgets.QLabel = self.create_label(
bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.bv2_label: QtWidgets.QLabel = self.create_label(
bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.bv3_label: QtWidgets.QLabel = self.create_label(
bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.bv4_label: QtWidgets.QLabel = self.create_label(
bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.bv5_label: QtWidgets.QLabel = self.create_label(
bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.ap1_label: QtWidgets.QLabel = self.create_label(ask_color)
self.ap2_label: QtWidgets.QLabel = self.create_label(ask_color)
self.ap3_label: QtWidgets.QLabel = self.create_label(ask_color)
self.ap4_label: QtWidgets.QLabel = self.create_label(ask_color)
self.ap5_label: QtWidgets.QLabel = self.create_label(ask_color)
self.av1_label: QtWidgets.QLabel = self.create_label(
ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.av2_label: QtWidgets.QLabel = self.create_label(
ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.av3_label: QtWidgets.QLabel = self.create_label(
ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.av4_label: QtWidgets.QLabel = self.create_label(
ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.av5_label: QtWidgets.QLabel = self.create_label(
ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
self.lp_label: QtWidgets.QLabel = self.create_label()
self.return_label: QtWidgets.QLabel = self.create_label(alignment=QtCore.Qt.AlignmentFlag.AlignRight)
form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
form.addRow(self.ap5_label, self.av5_label)
form.addRow(self.ap4_label, self.av4_label)
form.addRow(self.ap3_label, self.av3_label)
form.addRow(self.ap2_label, self.av2_label)
form.addRow(self.ap1_label, self.av1_label)
form.addRow(self.lp_label, self.return_label)
form.addRow(self.bp1_label, self.bv1_label)
form.addRow(self.bp2_label, self.bv2_label)
form.addRow(self.bp3_label, self.bv3_label)
form.addRow(self.bp4_label, self.bv4_label)
form.addRow(self.bp5_label, self.bv5_label)
# Overall layout
vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
vbox.addLayout(grid)
vbox.addLayout(form)
self.setLayout(vbox)
def create_label(
self,
color: str = "",
alignment: int = QtCore.Qt.AlignmentFlag.AlignLeft
) -> QtWidgets.QLabel:
"""
Create label with certain font color.
"""
label: QtWidgets.QLabel = QtWidgets.QLabel()
if color:
label.setStyleSheet(f"color:{color}")
label.setAlignment(Qt.AlignmentFlag(alignment))
return label
def register_event(self) -> None:
""""""
self.signal_tick.connect(self.process_tick_event)
self.event_engine.register(EVENT_TICK, self.signal_tick.emit)
def process_tick_event(self, event: Event) -> None:
""""""
tick: TickData = event.data
if tick.vt_symbol != self.vt_symbol:
return
price_digits: int = self.price_digits
self.lp_label.setText(f"{tick.last_price:.{price_digits}f}")
self.bp1_label.setText(f"{tick.bid_price_1:.{price_digits}f}")
self.bv1_label.setText(str(tick.bid_volume_1))
self.ap1_label.setText(f"{tick.ask_price_1:.{price_digits}f}")
self.av1_label.setText(str(tick.ask_volume_1))
if tick.pre_close:
r: float = (tick.last_price / tick.pre_close - 1) * 100
self.return_label.setText(f"{r:.2f}%")
if tick.bid_price_2:
self.bp2_label.setText(f"{tick.bid_price_2:.{price_digits}f}")
self.bv2_label.setText(str(tick.bid_volume_2))
self.ap2_label.setText(f"{tick.ask_price_2:.{price_digits}f}")
self.av2_label.setText(str(tick.ask_volume_2))
self.bp3_label.setText(f"{tick.bid_price_3:.{price_digits}f}")
self.bv3_label.setText(str(tick.bid_volume_3))
self.ap3_label.setText(f"{tick.ask_price_3:.{price_digits}f}")
self.av3_label.setText(str(tick.ask_volume_3))
self.bp4_label.setText(f"{tick.bid_price_4:.{price_digits}f}")
self.bv4_label.setText(str(tick.bid_volume_4))
self.ap4_label.setText(f"{tick.ask_price_4:.{price_digits}f}")
self.av4_label.setText(str(tick.ask_volume_4))
self.bp5_label.setText(f"{tick.bid_price_5:.{price_digits}f}")
self.bv5_label.setText(str(tick.bid_volume_5))
self.ap5_label.setText(f"{tick.ask_price_5:.{price_digits}f}")
self.av5_label.setText(str(tick.ask_volume_5))
if self.price_check.isChecked():
self.price_line.setText(f"{tick.last_price:.{price_digits}f}")
def set_vt_symbol(self) -> None:
"""
Set the tick depth data to monitor by vt_symbol.
"""
symbol: str = str(self.symbol_line.text())
if not symbol:
return
# Generate vt_symbol from symbol and exchange
exchange_value: str = str(self.exchange_combo.currentText())
vt_symbol: str = f"{symbol}.{exchange_value}"
if vt_symbol == self.vt_symbol:
return
self.vt_symbol = vt_symbol
# Update name line widget and clear all labels
contract: ContractData | None = self.main_engine.get_contract(vt_symbol)
if not contract:
self.name_line.setText("")
gateway_name: str = self.gateway_combo.currentText()
else:
self.name_line.setText(contract.name)
gateway_name = contract.gateway_name
# Update gateway combo box.
ix: int = self.gateway_combo.findText(gateway_name)
self.gateway_combo.setCurrentIndex(ix)
# Update price digits
self.price_digits = get_digits(contract.pricetick)
self.clear_label_text()
self.volume_line.setText("")
self.price_line.setText("")
# Subscribe tick data
req: SubscribeRequest = SubscribeRequest(
symbol=symbol, exchange=Exchange(exchange_value)
)
self.main_engine.subscribe(req, gateway_name)
def clear_label_text(self) -> None:
"""
Clear text on all labels.
"""
self.lp_label.setText("")
self.return_label.setText("")
self.bv1_label.setText("")
self.bv2_label.setText("")
self.bv3_label.setText("")
self.bv4_label.setText("")
self.bv5_label.setText("")
self.av1_label.setText("")
self.av2_label.setText("")
self.av3_label.setText("")
self.av4_label.setText("")
self.av5_label.setText("")
self.bp1_label.setText("")
self.bp2_label.setText("")
self.bp3_label.setText("")
self.bp4_label.setText("")
self.bp5_label.setText("")
self.ap1_label.setText("")
self.ap2_label.setText("")
self.ap3_label.setText("")
self.ap4_label.setText("")
self.ap5_label.setText("")
def send_order(self) -> None:
"""
Send new order manually.
"""
symbol: str = str(self.symbol_line.text())
if not symbol:
QtWidgets.QMessageBox.critical(self, _("委托失败"), _("请输入合约代码"))
return
volume_text: str = str(self.volume_line.text())
if not volume_text:
QtWidgets.QMessageBox.critical(self, _("委托失败"), _("请输入委托数量"))
return
volume: float = float(volume_text)
price_text: str = str(self.price_line.text())
if not price_text:
price: float = 0
else:
price = float(price_text)
req: OrderRequest = OrderRequest(
symbol=symbol,
exchange=Exchange(str(self.exchange_combo.currentText())),
direction=Direction(str(self.direction_combo.currentText())),
type=OrderType(str(self.order_type_combo.currentText())),
volume=volume,
price=price,
offset=Offset(str(self.offset_combo.currentText())),
reference="ManualTrading"
)
gateway_name: str = str(self.gateway_combo.currentText())
self.main_engine.send_order(req, gateway_name)
def cancel_all(self) -> None:
"""
Cancel all active orders.
"""
order_list: list[OrderData] = self.main_engine.get_all_active_orders()
for order in order_list:
req: CancelRequest = order.create_cancel_request()
self.main_engine.cancel_order(req, order.gateway_name)
def update_with_cell(self, cell: BaseCell) -> None:
""""""
data = cell.get_data()
self.symbol_line.setText(data.symbol)
self.exchange_combo.setCurrentIndex(
self.exchange_combo.findText(data.exchange.value)
)
self.set_vt_symbol()
if isinstance(data, PositionData):
if data.direction == Direction.SHORT:
direction: Direction = Direction.LONG
elif data.direction == Direction.LONG:
direction = Direction.SHORT
else: # Net position mode
if data.volume > 0:
direction = Direction.SHORT
else:
direction = Direction.LONG
self.direction_combo.setCurrentIndex(
self.direction_combo.findText(direction.value)
)
self.offset_combo.setCurrentIndex(
self.offset_combo.findText(Offset.CLOSE.value)
)
self.volume_line.setText(str(abs(data.volume)))
class ActiveOrderMonitor(OrderMonitor):
"""
Monitor which shows active order only.
"""
def process_event(self, event: Event) -> None:
"""
Hides the row if order is not active.
"""
super().process_event(event)
order: OrderData = event.data
row_cells: dict = self.cells[order.vt_orderid]
row: int = self.row(row_cells["volume"])
if order.is_active():
self.showRow(row)
else:
self.hideRow(row)
class ContractManager(QtWidgets.QWidget):
"""
Query contract data available to trade in system.
"""
headers: dict[str, str] = {
"vt_symbol": _("本地代码"),
"symbol": _("代码"),
"exchange": _("交易所"),
"name": _("名称"),
"product": _("合约分类"),
"size": _("合约乘数"),
"pricetick": _("价格跳动"),
"min_volume": _("最小委托量"),
"option_portfolio": _("期权产品"),
"option_expiry": _("期权到期日"),
"option_strike": _("期权行权价"),
"option_type": _("期权类型"),
"gateway_name": _("交易接口"),
}
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
super().__init__()
self.main_engine: MainEngine = main_engine
self.event_engine: EventEngine = event_engine
self.init_ui()
def init_ui(self) -> None:
""""""
self.setWindowTitle(_("合约查询"))
self.resize(1000, 600)
self.filter_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit()
self.filter_line.setPlaceholderText(_("输入合约代码或者交易所,留空则查询所有合约"))
self.button_show: QtWidgets.QPushButton = QtWidgets.QPushButton(_("查询"))
self.button_show.clicked.connect(self.show_contracts)
labels: list = []
for name, display in self.headers.items():
label: str = f"{display}\n{name}"
labels.append(label)
self.contract_table: QtWidgets.QTableWidget = QtWidgets.QTableWidget()
self.contract_table.setColumnCount(len(self.headers))
self.contract_table.setHorizontalHeaderLabels(labels)
self.contract_table.verticalHeader().setVisible(False)
self.contract_table.setEditTriggers(self.contract_table.EditTrigger.NoEditTriggers)
self.contract_table.setAlternatingRowColors(True)
hbox: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout()
hbox.addWidget(self.filter_line)
hbox.addWidget(self.button_show)
vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
vbox.addLayout(hbox)
vbox.addWidget(self.contract_table)
self.setLayout(vbox)
def show_contracts(self) -> None:
"""
Show contracts by symbol
"""
flt: str = str(self.filter_line.text())
all_contracts: list[ContractData] = self.main_engine.get_all_contracts()
if flt:
contracts: list[ContractData] = [
contract for contract in all_contracts if flt in contract.vt_symbol
]
else:
contracts = all_contracts
self.contract_table.clearContents()
self.contract_table.setRowCount(len(contracts))
for row, contract in enumerate(contracts):
for column, name in enumerate(self.headers.keys()):
value: Any = getattr(contract, name)
if value in {None, 0}:
value = ""
cell: BaseCell
if isinstance(value, Enum):
cell = EnumCell(value, contract)
elif isinstance(value, datetime):
cell = DateCell(value, contract)
else:
cell = BaseCell(value, contract)
self.contract_table.setItem(row, column, cell)
self.contract_table.resizeColumnsToContents()
class AboutDialog(QtWidgets.QDialog):
"""
Information about the trading platform.
"""
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
""""""
super().__init__()
self.main_engine: MainEngine = main_engine
self.event_engine: EventEngine = event_engine
self.init_ui()
def init_ui(self) -> None:
""""""
self.setWindowTitle(_("关于VeighNa Trader"))
from ... import __version__ as vnpy_version
text: str = f"""
By Traders, For Traders.
Created by VeighNa Technology
LicenseMIT
Websitewww.vnpy.com
Githubwww.github.com/vnpy/vnpy
VeighNa - {vnpy_version}
Python - {platform.python_version()}
PySide6 - {metadata.version("pyside6")}
NumPy - {metadata.version("numpy")}
pandas - {metadata.version("pandas")}
"""
label: QtWidgets.QLabel = QtWidgets.QLabel()
label.setText(text)
label.setMinimumWidth(500)
vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
vbox.addWidget(label)
self.setLayout(vbox)
class GlobalDialog(QtWidgets.QDialog):
"""
Start connection of a certain gateway.
"""
def __init__(self) -> None:
""""""
super().__init__()
self.widgets: dict[str, tuple[QtWidgets.QLineEdit, type]] = {}
self.init_ui()
def init_ui(self) -> None:
""""""
self.setWindowTitle(_("全局配置"))
self.setMinimumWidth(800)
settings: dict = copy(SETTINGS)
settings.update(load_json(SETTING_FILENAME))
# Initialize line edits and form layout based on setting.
form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
for field_name, field_value in settings.items():
field_type: type = type(field_value)
widget: QtWidgets.QLineEdit = QtWidgets.QLineEdit(str(field_value))
form.addRow(f"{field_name} <{field_type.__name__}>", widget)
self.widgets[field_name] = (widget, field_type)
button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("确定"))
button.clicked.connect(self.update_setting)
form.addRow(button)
scroll_widget: QtWidgets.QWidget = QtWidgets.QWidget()
scroll_widget.setLayout(form)
scroll_area: QtWidgets.QScrollArea = QtWidgets.QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setWidget(scroll_widget)
vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
vbox.addWidget(scroll_area)
self.setLayout(vbox)
def update_setting(self) -> None:
"""
Get setting value from line edits and update global setting file.
"""
settings: dict = {}
for field_name, tp in self.widgets.items():
widget, field_type = tp
value_text: str = widget.text()
if field_type is bool:
if value_text == "True":
field_value: bool = True
else:
field_value = False
else:
field_value = field_type(value_text)
settings[field_name] = field_value
QtWidgets.QMessageBox.information(
self,
_("注意"),
_("全局配置的修改需要重启后才会生效!"),
QtWidgets.QMessageBox.StandardButton.Ok
)
save_json(SETTING_FILENAME, settings)
self.accept()
class WechatWorker(QtCore.QThread):
"""
Background worker for two-phase WeChat binding: QR-code login first,
then wait for the user's first inbound message to capture user info.
"""
signal_qr_ready: QtCore.Signal = QtCore.Signal(str)
signal_waiting_message: QtCore.Signal = QtCore.Signal()
signal_bound: QtCore.Signal = QtCore.Signal(object, object)
signal_failed: QtCore.Signal = QtCore.Signal(str)
def __init__(self) -> None:
""""""
super().__init__()
self._stop: bool = False
def stop(self) -> None:
"""Request the worker loop to terminate at the next checkpoint."""
self._stop = True
def run(self) -> None:
""""""
try:
deadline: float = time.monotonic() + 600.0 # wait for 10 minutes
# Phase 1: request QR-code and wait for user confirmation
creds: Credentials | None = None
while not self._stop and time.monotonic() < deadline:
try:
qrcode_value, scan_url = request_qrcode()
self.signal_qr_ready.emit(scan_url)
creds = wait_for_login(
qrcode_value,
deadline,
)
if creds is not None:
break
except WeixinTimeout:
continue
if self._stop or creds is None:
self.signal_failed.emit(_("扫码超时或被取消"))
return
# Phase 2: long-poll inbound messages until the first one arrives
self.signal_waiting_message.emit()
sync_buf: str = ""
while not self._stop and time.monotonic() < deadline:
try:
user_ids, sync_buf = poll(
creds,
sync_buf,
)
if user_ids:
self.signal_bound.emit(creds, user_ids[0])
return
except WeixinTimeout:
continue
self.signal_failed.emit(_("等待首条消息超时"))
except WeixinError as exc:
self.signal_failed.emit(str(exc))
except Exception as exc:
self.signal_failed.emit(str(exc))
class WechatDialog(QtWidgets.QDialog):
"""
WeChat binding dialog: shows status when bound, drives the scan and
first-message flow when not bound.
"""
def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None:
""""""
super().__init__()
self.main_engine: MainEngine = main_engine
self.event_engine: EventEngine = event_engine
self.wechat_engine: WechatEngine = cast(WechatEngine, main_engine.get_engine("wechat"))
self.worker: WechatWorker | None = None
self.init_ui()
self.refresh_status()
def init_ui(self) -> None:
""""""
self.setWindowTitle(_("微信通知"))
self.setMinimumWidth(380)
# Create Stacked Widget
self.stack: QtWidgets.QStackedWidget = QtWidgets.QStackedWidget()
# Create pages
self.page_status: QtWidgets.QWidget = self._create_status_page()
self.page_loading: QtWidgets.QWidget = self._create_loading_page()
self.page_qr: QtWidgets.QWidget = self._create_qr_page()
self.page_waiting: QtWidgets.QWidget = self._create_waiting_page()
self.page_result: QtWidgets.QWidget = self._create_result_page()
self.stack.addWidget(self.page_status)
self.stack.addWidget(self.page_loading)
self.stack.addWidget(self.page_qr)
self.stack.addWidget(self.page_waiting)
self.stack.addWidget(self.page_result)
vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
vbox.addWidget(self.stack)
self.setLayout(vbox)
def _create_status_page(self) -> QtWidgets.QWidget:
"""Create the status page."""
page: QtWidgets.QWidget = QtWidgets.QWidget()
# Status area
group_status: QtWidgets.QGroupBox = QtWidgets.QGroupBox(_("账号状态"))
self.status_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
# Info form layout
self.info_widget: QtWidgets.QWidget = QtWidgets.QWidget()
self.info_form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
self.info_form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter)
self.info_widget.setLayout(self.info_form)
self.bot_id_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.bot_id_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
self.bot_id_label.setWordWrap(True)
self.user_id_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.user_id_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
self.user_id_label.setWordWrap(True)
self.gateway_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.gateway_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
self.gateway_label.setWordWrap(True)
self.interval_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox()
self.interval_spin.setRange(1, 8640)
interval_tip: str = _(
"控制两次微信推送之间的间隔时间。\n"
"间隔内的新消息会暂存,并在下次推送时合并发送。\n"
"用户每发送 1 条消息,机器人可在 24 小时内推送 10 条;超限后需用户再次发送消息才能恢复。"
)
self.interval_spin.setSuffix(_(" 秒"))
self.interval_spin.setToolTip(interval_tip)
self.interval_spin.valueChanged.connect(self.change_interval)
self.interval_spin.setValue(self.wechat_engine.send_interval)
self.info_form.addRow("Bot ID", self.bot_id_label)
self.info_form.addRow(_("用户 ID"), self.user_id_label)
self.info_form.addRow(_("网关:"), self.gateway_label)
group_setting: QtWidgets.QGroupBox = QtWidgets.QGroupBox(_("推送设置"))
setting_form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
setting_form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter)
setting_form.addRow(_("推送间隔:"), self.interval_spin)
group_setting.setLayout(setting_form)
self.bind_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("开始绑定"))
self.bind_button.clicked.connect(self.start_bind)
self.test_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("测试消息"))
self.test_button.clicked.connect(self.send_test_message)
self.unbind_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("解除绑定"))
self.unbind_button.clicked.connect(self.unbind)
status_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
status_layout.addStretch()
status_layout.addWidget(self.status_label)
status_layout.addWidget(self.info_widget)
status_layout.addStretch()
status_layout.addWidget(self.bind_button)
status_layout.addWidget(self.test_button)
status_layout.addWidget(self.unbind_button)
group_status.setLayout(status_layout)
layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
layout.addWidget(group_status)
layout.addWidget(group_setting)
page.setLayout(layout)
return page
def _create_loading_page(self) -> QtWidgets.QWidget:
"""Create the loading page."""
page: QtWidgets.QWidget = QtWidgets.QWidget()
layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
label: QtWidgets.QLabel = QtWidgets.QLabel(_("正在向服务器请求二维码,请稍候..."))
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
cancel_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("取消绑定"))
cancel_button.clicked.connect(self.cancel_bind)
layout.addStretch()
layout.addWidget(label)
layout.addStretch()
layout.addWidget(cancel_button)
page.setLayout(layout)
return page
def _create_qr_page(self) -> QtWidgets.QWidget:
"""Create the QR page."""
page: QtWidgets.QWidget = QtWidgets.QWidget()
layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
label: QtWidgets.QLabel = QtWidgets.QLabel(_("第一步:请使用微信扫描下方二维码"))
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.qr_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.qr_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.qr_label.setMinimumSize(260, 260)
cancel_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("取消绑定"))
cancel_button.clicked.connect(self.cancel_bind)
layout.addStretch()
layout.addWidget(label)
layout.addWidget(self.qr_label)
layout.addStretch()
layout.addWidget(cancel_button)
page.setLayout(layout)
return page
def _create_waiting_page(self) -> QtWidgets.QWidget:
"""Create the waiting page."""
page: QtWidgets.QWidget = QtWidgets.QWidget()
layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
label: QtWidgets.QLabel = QtWidgets.QLabel(_("第二步:扫码成功!\n\n请在微信中向机器人发送任意文字消息以完成最终激活。"))
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
cancel_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("取消绑定"))
cancel_button.clicked.connect(self.cancel_bind)
layout.addStretch()
layout.addWidget(label)
layout.addStretch()
layout.addWidget(cancel_button)
page.setLayout(layout)
return page
def _create_result_page(self) -> QtWidgets.QWidget:
"""Create the result page."""
page: QtWidgets.QWidget = QtWidgets.QWidget()
layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout()
self.result_label: QtWidgets.QLabel = QtWidgets.QLabel()
self.result_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.result_label.setWordWrap(True)
self.result_ok_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("完成"))
self.result_ok_button.clicked.connect(self.refresh_status)
self.result_retry_button: QtWidgets.QPushButton = QtWidgets.QPushButton(_("重试"))
self.result_retry_button.clicked.connect(self.start_bind)
layout.addStretch()
layout.addWidget(self.result_label)
layout.addStretch()
layout.addWidget(self.result_retry_button)
layout.addWidget(self.result_ok_button)
page.setLayout(layout)
return page
def refresh_status(self) -> None:
"""Refresh dialog widgets according to current binding state."""
creds: Credentials | None = self.wechat_engine.creds
bound: bool = bool(creds and self.wechat_engine.user_id)
if bound and creds:
self.status_label.setText(_("状态:已绑定"))
self.bot_id_label.setText(creds.bot_id)
self.user_id_label.setText(self.wechat_engine.user_id)
self.gateway_label.setText(creds.base_url)
self.info_widget.show()
self.bind_button.setText(_("重新绑定"))
self.test_button.show()
self.unbind_button.setEnabled(True)
else:
self.status_label.setText(_("状态:未绑定"))
self.bot_id_label.clear()
self.user_id_label.clear()
self.gateway_label.clear()
self.info_widget.hide()
self.bind_button.setText(_("开始绑定"))
self.test_button.hide()
self.unbind_button.setEnabled(False)
with QtCore.QSignalBlocker(self.interval_spin):
self.interval_spin.setValue(self.wechat_engine.send_interval)
self.stack.setCurrentWidget(self.page_status)
def send_test_message(self) -> None:
"""Send a test message via WeChat engine."""
self.wechat_engine.send_wechat(_("VeighNa Trader 消息推送测试"))
QtWidgets.QMessageBox.information(
self,
_("测试消息"),
_("测试消息已加入推送队列!"),
QtWidgets.QMessageBox.StandardButton.Ok
)
def start_bind(self) -> None:
"""Start the background binding worker."""
if self.worker and self.worker.isRunning():
return
self.stack.setCurrentWidget(self.page_loading)
self.worker = WechatWorker()
self.worker.signal_qr_ready.connect(self.show_qrcode)
self.worker.signal_waiting_message.connect(self.show_waiting_message)
self.worker.signal_bound.connect(self.finish_bind)
self.worker.signal_failed.connect(self.fail_bind)
self.worker.start()
def show_qrcode(self, url: str) -> None:
"""Render the scan URL as a QR-code pixmap in the dialog."""
qr: Any = qrcode.QRCode(border=1)
qr.add_data(url)
qr.make(fit=True)
img: Any = qr.make_image(fill_color="black", back_color="white")
buf: BytesIO = BytesIO()
img.save(buf, format="PNG")
pixmap: QtGui.QPixmap = QtGui.QPixmap()
if not pixmap.loadFromData(buf.getvalue()):
self.fail_bind(_("二维码图片加载失败"))
return
scaled: QtGui.QPixmap = pixmap.scaled(
260,
260,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.SmoothTransformation
)
self.qr_label.setPixmap(scaled)
self.stack.setCurrentWidget(self.page_qr)
def show_waiting_message(self) -> None:
"""Switch UI to the second binding phase."""
self.stack.setCurrentWidget(self.page_waiting)
def finish_bind(self, creds: Credentials, user_id: str) -> None:
"""Persist binding result via WechatEngine and refresh the UI."""
self.wechat_engine.bind(creds, user_id)
self.result_label.setText(_("微信通知绑定成功!"))
self.result_retry_button.hide()
self.result_ok_button.show()
self.stack.setCurrentWidget(self.page_result)
def fail_bind(self, error: str) -> None:
"""Show the failure reason and reset the UI for retry."""
self.result_label.setText(_("绑定失败:\n{}").format(error))
self.result_retry_button.show()
self.result_ok_button.show()
self.stack.setCurrentWidget(self.page_result)
def cancel_bind(self) -> None:
"""Cancel binding process and return to status page."""
if self.worker and self.worker.isRunning():
self.worker.stop()
self.worker.wait(2000)
self.refresh_status()
def unbind(self) -> None:
"""Confirm and clear current binding."""
reply: QtWidgets.QMessageBox.StandardButton = QtWidgets.QMessageBox.question(
self,
_("解除绑定"),
_("确认解除当前微信绑定?"),
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No,
)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
self.wechat_engine.unbind()
self.refresh_status()
def change_interval(self, value: int) -> None:
"""Persist send interval updates from the status page."""
self.wechat_engine.send_interval = value
self.wechat_engine.save_setting()
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
"""Stop the binding worker before closing the dialog."""
if self.worker and self.worker.isRunning():
self.worker.stop()
self.worker.wait(2000)
super().closeEvent(event)