This commit is contained in:
2026-06-12 16:25:41 +08:00
parent ef4c1cca32
commit 2d8a0c3bca
23 changed files with 2904 additions and 525 deletions
+233
View File
@@ -0,0 +1,233 @@
# SFGrid 网格交易策略流程图
## 1. 总览:策略生命周期
```mermaid
flowchart TD
A["SFGridStrategy.__init__()"] --> B["订阅事件总线<br/>onOrderCreateAsync / onOrderTrade / onOrderError"]
B --> C["获取涨跌停价<br/>todayUpStopPrice / todayDownStopPrice"]
C --> D["loadExistOrders()<br/>从券商侧恢复未成交订单到 orderGrid"]
D --> E["enabledTrading(enabled)"]
E --> F{"enabled ?"}
F -->|True| G["启用交易流程 → 见 §3"]
F -->|False| H["停用交易流程 → 见 §3"]
G --> I["saveProxy() 持久化"]
H --> I
I --> J["构造完成,进入事件循环<br/>等待 QMT 回调 / UI 操作"]
```
---
## 2. 核心:refreshGridOrder() 网格下单
```mermaid
flowchart TD
START["refreshGridOrder()"] --> CHECK1{"qmtv.isMarketActive<br/>AND<br/>tradeTarget.enabled ?"}
CHECK1 -->|No| SKIP["跳过不下单"]
CHECK1 -->|Yes| QUERY["查询未成交订单<br/>queryPendingOrder()"]
QUERY --> STATUS{"tradeTarget.status ?"}
STATUS -->|"= 0 未建仓"| CHECK_INIT{"已存在建仓单?<br/>remark = 'INIT,1,{code}'"}
CHECK_INIT -->|"No 没有"| PLACE_INIT["下建仓单 (STOCK_BUY)<br/>价格 = getPriceGrid()[0]<br/>remark = 'INIT,1,{code}'"]
CHECK_INIT -->|"Yes 已有"| DONE_INIT["建仓单已在途,跳过"]
STATUS -->|"= 1 已建仓"| GET_IDX["currentIdx = grid_index"]
GET_IDX --> SELL_CHECK{"currentIdx > 0 ?<br/>(grid_index 不是最低点)"}
SELL_CHECK -->|"Yes 可挂卖单"| SELL_EXIST{"已存在同 remark 卖单?<br/>remark='SELL,{idx-1},{code}'"}
SELL_EXIST -->|"No 没有"| SELL_PLACE["下卖出单 (STOCK_SELL)<br/>价格 = grid[sellIdx]<br/>sellIdx = currentIdx - 1"]
SELL_EXIST -->|"Yes 已有"| SELL_SKIP["跳过,避免重复"]
SELL_CHECK -->|"No 价格已最低"| SELL_SKIP2["无卖出空间"]
SELL_PLACE --> BUY_CHECK
SELL_SKIP --> BUY_CHECK
SELL_SKIP2 --> BUY_CHECK
BUY_CHECK{"currentIdx < len(grid)-1 ?<br/>(grid_index 不是最高点)"}
BUY_CHECK -->|"Yes 可挂买单"| BUY_EXIST{"已存在同价同类型买单?<br/>order_type=BUY AND price=buyPrice"}
BUY_EXIST -->|"No 没有"| BUY_PLACE["下买入单 (STOCK_BUY)<br/>价格 = grid[buyIdx]<br/>buyIdx = currentIdx + 1"]
BUY_EXIST -->|"Yes 已有"| BUY_SKIP["跳过,避免重复"]
BUY_CHECK -->|"No 价格已最高"| BUY_SKIP2["无买入空间"]
```
---
## 3. 交易启停:enabledTrading()
```mermaid
flowchart TD
START["enabledTrading(enabled)"] --> SET["self.tradeTarget.enabled = enabled"]
SET --> BRANCH{"enabled ?"}
BRANCH -->|"True 启用"| STATUS{"tradeTarget.status ?"}
STATUS -->|"= 0 未建仓"| INIT_IDX{"grid_index == 0 ?"}
INIT_IDX -->|"Yes"| SET1["grid_index = 1<br/>(默认建仓位置)"]
INIT_IDX -->|"No"| KEEP["保留现有 grid_index"]
SET1 --> REFRESH1["refreshGridOrder()"]
KEEP --> REFRESH1
STATUS -->|"= 1 已建仓"| CALC["计算最小需求仓位<br/>min = grid_volume × grid_index"]
CALC --> CHECK{"current_position >= min ?"}
CHECK -->|"Yes 充足"| REFRESH2["refreshGridOrder()"]
CHECK -->|"No 不足"| DENY["拒绝启用<br/>enabled = False<br/>(风控保护)"]
BRANCH -->|"False 停用"| CANCEL["取消所有未成交订单<br/>cancel_order_stock_async()"]
CANCEL --> LOG["记录取消数量"]
REFRESH1 --> SAVE["saveProxy() 持久化"]
REFRESH2 --> SAVE
DENY --> SAVE
LOG --> SAVE
```
---
## 4. 事件回调链
```mermaid
flowchart TD
subgraph QMT["QMT / xtquant 层"]
OA["orderAsync()<br/>返回 seq"]
PUSH_ERR["C扩展推送<br/>XtOrderError"]
PUSH_RESP["C扩展推送<br/>XtOrderResponse"]
PUSH_TRADE["C扩展推送<br/>XtTrade"]
end
subgraph BUS["事件总线 event_bus"]
EVT_ERR["MarketOrderError"]
EVT_RESP["MarketOrderCreated"]
EVT_TRADE["MarketOrderTraded"]
end
subgraph STG["SFGridStrategy 回调"]
OE["onOrderError()"]
OC["onOrderCreateAsync()"]
OT["onOrderTrade()"]
end
OA --> PUSH_RESP
OA --> PUSH_ERR
PUSH_ERR --> EVT_ERR --> OE
PUSH_RESP --> EVT_RESP --> OC
PUSH_TRADE --> EVT_TRADE --> OT
```
---
## 5. onOrderError() 委托失败处理
```mermaid
flowchart TD
START["onOrderError(order_error)"] --> CHK1{"order_remark 非空 ?"}
CHK1 -->|"No 空"| EXIT1["无法解析,忽略"]
CHK1 -->|"Yes"| PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
PARSE --> CHK2{"len(parts) >= 3 ?"}
CHK2 -->|"No"| EXIT1
CHK2 -->|"Yes"| CHK3{"strategy_name == 'SFGRID'<br/>AND<br/>stockCode 匹配本标的 ?"}
CHK3 -->|"No 不匹配"| EXIT1
CHK3 -->|"Yes"| LOCK["获取 dataUpdateLock"]
LOCK --> DEL{"gridIdx in orderGrid ?"}
DEL -->|"Yes"| REMOVE["del orderGrid[gridIdx]<br/>清理孤立条目"]
DEL -->|"No"| LOG_ERR["记录错误日志<br/>error_id / error_msg"]
REMOVE --> LOG_ERR
LOG_ERR --> UNLOCK["释放 dataUpdateLock"]
```
---
## 6. onOrderCreateAsync() 订单确认
```mermaid
flowchart TD
START["onOrderCreateAsync(response)"] --> PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
PARSE --> FILTER{"strategy_name == 'SFGRID'<br/>AND len(parts) >= 3<br/>AND stockCode 匹配 ?"}
FILTER -->|"No"| EXIT["忽略"]
FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
LOCK --> UPDATE["orderGrid[gridIdx] = response.order_id<br/>seq → order_id 替换"]
UPDATE --> UNLOCK["释放 dataUpdateLock"]
```
---
## 7. onOrderTrade() 成交处理
```mermaid
flowchart TD
START["onOrderTrade(trade)"] --> PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
PARSE --> FILTER{"strategy_name == 'SFGRID'<br/>AND len(parts) >= 3<br/>AND stockCode 匹配 ?"}
FILTER -->|"No"| EXIT["忽略"]
FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
LOCK --> TYPE{"orderType ?"}
TYPE -->|"INIT 建仓单"| INIT["status = 1<br/>init_price = traded_price<br/>grid_index = 1"]
TYPE -->|"BUY / SELL 网格单"| CMP{"gridIdx vs grid_index ?"}
CMP -->|"gridIdx > grid_index<br/>(买入成交)"| DOWN["grid_index += 1<br/>下移一格"]
CMP -->|"gridIdx < grid_index<br/>(卖出成交)"| UP["grid_index -= 1<br/>上移一格<br/>match_count += 1<br/>total_profit += grid_size × volume"]
CMP -->|"gridIdx == grid_index<br/>(异常)"| SAME["日志: 理论上不应该输出"]
INIT --> POST
DOWN --> POST
UP --> POST
SAME --> POST
POST["成交后处理"] --> SAVE["saveProxy() 持久化状态"]
SAVE --> DEL["del orderGrid[gridIdx]<br/>移除已成交订单"]
DEL --> REPORT["打印成交报告<br/>成交价/量/手续费"]
REPORT --> REFRESH["refreshGridOrder()<br/>在新位置挂新的网格单"]
REFRESH --> UNLOCK["释放 dataUpdateLock"]
```
---
## 8. 网格交易完整状态机
```mermaid
stateDiagram-v2
[*] --> 未建仓: 创建 SFGridStrategy
未建仓 --> 建仓中: enabledTrading(True)<br/>下建仓单 INIT
建仓中 --> 已建仓: onOrderTrade(INIT)<br/>建仓单成交
建仓中 --> 建仓失败: onOrderError(INIT)<br/>委托被拒
建仓失败 --> 建仓中: refreshGridOrder()<br/>重新下建仓单
已建仓 --> 网格运行: refreshGridOrder()<br/>上下各挂一单
网格运行 --> 网格运行: onOrderTrade(SELL)<br/>卖出成交 → 上移<br/>重新挂单
网格运行 --> 网格运行: onOrderTrade(BUY)<br/>买入成交 → 下移<br/>重新挂单
网格运行 --> 单边挂单: onOrderError<br/>某方向委托失败
单边挂单 --> 网格运行: refreshGridOrder()<br/>重新补挂失败方向的单
已建仓 --> 已停用: enabledTrading(False)<br/>取消所有挂单
网格运行 --> 已停用: enabledTrading(False)
单边挂单 --> 已停用: enabledTrading(False)
已停用 --> 已建仓: enabledTrading(True)<br/>仓位检查通过
已停用 --> 已停用: enabledTrading(True)<br/>仓位不足,回退
```
---
## 9. 网格价格示意
```
价格
│ grid[5] = 12.00 ← 最贵(顶部)
│ grid[4] = 11.50
│ grid[3] = 11.00 ← 当前位置 grid_index=3
│ grid[2] = 10.50 上方挂卖单 @10.50 (sellIdx=2, grid_index-1)
│ grid[1] = 10.00 下方挂买单 @10.00 (buyIdx=1, 已成交位置)
│ grid[0] = 9.50 ← 最便宜(底部/建仓价)
└──────────────────────→
grid_index=3 时:
卖单挂在 grid[2] @10.50 → 价格跌到 10.50 卖出(上移一格,赚差价)
买单挂在 grid[4] @11.50 → 价格涨到 11.50 买入(下移一格,补仓)
grid_size = grid[i] - grid[i-1] = 0.50(每格利润空间)
```
-5
View File
@@ -1,5 +0,0 @@
[config]
miniqmtpath = C:/Programs/GJQMT/userdata_mini
account_no = 8882874667
log_level = INFO
+18 -58
View File
@@ -1,65 +1,25 @@
import configparser """
from pathlib import Path 运行时配置 — 端口、路径、账号由自动探测设置,无需配置文件。
"""
import os
import sys import sys
from pathlib import Path
miniQMTPath = r'D:\\Programs\\DTQMT\\userdata_mini' # miniQMT软件的安装路径 # ---- 自动探测的配置项(默认值仅占位,启动时自动修正) ----
# miniQMTPath = '' miniQMTPath: str = ''
account_no:str = '99082560' account_no: str = ''
console_log = True log_level: str = 'INFO'
log_level = "INFO" console_log: bool = True
use_simulated_qmt: bool = False use_simulated_qmt: bool = False
def get_config_path() -> Path:
"""获取配置文件的正确路径(兼容开发环境和打包后的可执行文件)""" def app_dir() -> Path:
"""应用根目录(兼容开发环境与打包后的 exe)"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# 打包后的可执行文件环境 return Path(sys.executable).parent
# sys._MEIPASS是PyInstaller解压临时文件的目录 return Path(__file__).resolve().parent
# 配置文件应该放在可执行文件同目录下
base_path = Path(sys.executable).parent
else:
# 开发环境
base_path = Path(__file__).resolve().parent
return base_path / 'config.ini'
def save_config(miniQmtPath:str, account_no:str, use_simulated_qmt: bool = False): def log_file_path() -> Path:
"""创建默认配置文件""" """日志文件路径"""
config = configparser.ConfigParser() return app_dir() / 'sfgrid.log'
config['config'] = {
'miniQMTPath': miniQmtPath,
'account_no': account_no,
'use_simulated_qmt': str(use_simulated_qmt),
'log_level': 'INFO'
}
config_path = get_config_path()
with open(config_path, 'w') as configfile:
config.write(configfile)
print(f'已创建默认配置文件: {config_path}')
def exist_config() -> bool:
"""检查配置文件是否存在"""
config_path = get_config_path()
return config_path.exists()
def initConfig() -> bool:
global miniQMTPath, account_no, log_level, use_simulated_qmt
# 获取配置文件路径
config_path = get_config_path()
config = configparser.ConfigParser()
config.read(config_path, encoding='utf-8')
miniQMTPath = config.get('config','miniQMTPath')
account_no = config.get('config','account_no')
log_level = config.get('config','log_level')
try:
use_simulated_qmt = config.get('config','use_simulated_qmt').lower() in ('true', '1', 'yes')
except (configparser.NoOptionError, configparser.NoSectionError):
use_simulated_qmt = False
# 判断miniQMTPath是否为空,并且目录是否存在
if not miniQMTPath or not Path(miniQMTPath).exists():
print('请先配置miniQMTPath')
return False
else:
return True
-1
View File
@@ -2,5 +2,4 @@ import xtquant.xtconstant as xtconstant
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买 OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖 OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
OrderTypeInit = "0" # 建仓
OrderTypeNone = "None" OrderTypeNone = "None"
+24 -1
View File
@@ -1,4 +1,6 @@
from datetime import datetime
from enum import Enum from enum import Enum
import threading
from core.eventbus import EventPrintLog, event_bus from core.eventbus import EventPrintLog, event_bus
import config import config
@@ -14,13 +16,34 @@ class LogLevel(Enum):
def __le__(self, other): def __le__(self, other):
return self.value <= other.value return self.value <= other.value
class LogData: class LogData:
def __init__(self, level: LogLevel, message: str): def __init__(self, level: LogLevel, message: str):
self.level = level self.level = level
self.message = message self.message = message
_log_lock = threading.Lock()
def _log_file_path():
"""日志文件路径"""
return str(config.log_file_path())
def PrintLog(level: LogLevel, message: str): def PrintLog(level: LogLevel, message: str):
data = LogData(level, message) data = LogData(level, message)
event_bus.publish(EventPrintLog, data) event_bus.publish(EventPrintLog, data)
line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} [{level.name}] {message}'
if config.console_log: if config.console_log:
print(f'{level.name} {message}') print(line)
# 写入日志文件
try:
with _log_lock:
with open(_log_file_path(), 'a', encoding='utf-8') as f:
f.write(line + '\n')
except Exception:
pass # 写文件失败不阻塞主流程
+6 -2
View File
@@ -9,17 +9,21 @@ import config as _config
def _get_qmt(): def _get_qmt():
"""获取 QMT 模块(配置优先于平台检测)""" """获取 QMT 模块(配置优先于平台检测)"""
if _config.use_simulated_qmt: if _config.use_simulated_qmt:
print('[qmt] 配置指定模拟模式 → qmt_dummy')
from core.qmt_dummy import qmtv from core.qmt_dummy import qmtv
return qmtv return qmtv
if sys.platform == 'win32': if sys.platform == 'win32':
try: try:
print('[qmt] Windows 平台,尝试加载 qmt_real...')
from core.qmt_real import qmtv as real_qmtv from core.qmt_real import qmtv as real_qmtv
print('[qmt] qmt_real 加载成功')
return real_qmtv return real_qmtv
except ImportError: except ImportError as e:
pass print(f'[qmt] qmt_real 加载失败: {e},回退 qmt_dummy')
# 非 Windows 或导入失败,使用模拟器 # 非 Windows 或导入失败,使用模拟器
print('[qmt] 使用模拟模式 qmt_dummy')
from core.qmt_dummy import qmtv from core.qmt_dummy import qmtv
return qmtv return qmtv
+8
View File
@@ -121,6 +121,14 @@ class DummyQmtV:
return type('DummyPos', (), pos)() return type('DummyPos', (), pos)()
return None return None
def queryTodayOrders(self) -> list:
"""查询当日所有委托 (模拟)"""
return list(self._pending_orders)
def queryTodayTrades(self) -> list:
"""查询当日所有成交 (模拟)"""
return [] # 模拟模式无实际成交记录
def queryPendingOrder(self, stock_code: str, tag: str) -> list: def queryPendingOrder(self, stock_code: str, tag: str) -> list:
"""查询挂单""" """查询挂单"""
return [o for o in self._pending_orders return [o for o in self._pending_orders
+260 -28
View File
@@ -2,6 +2,8 @@
QMT 真实交易实现 - 封装 xtquant SDK QMT 真实交易实现 - 封装 xtquant SDK
""" """
import datetime import datetime
import os
import subprocess
import threading import threading
import time import time
import config import config
@@ -15,6 +17,185 @@ class RealQmtV:
封装 xtquant 的 XtQuantTrader,提供与模拟器一致的接口 封装 xtquant 的 XtQuantTrader,提供与模拟器一致的接口
""" """
# miniQMT 进程名关键字(GUI 壳: XtMiniQmt.exe,交易引擎: miniquote.exe
_QMT_PROCESS_KEYWORDS = ['Qmt', 'qmt', 'QMT', 'miniquote', 'MiniQuote']
@staticmethod
def _discover_qmt_port() -> int:
"""
自动探测 miniQMT 监听端口。
方法1: SDK 内部扫描 (读取配置)
方法2: netstat 找 LISTENING 端口 → 反查所属进程名 → 匹配 QMT 关键字
返回端口号,未找到返回 0。
"""
# ---- 方法1: SDK 内部扫描 ----
try:
from xtquant import xtconn
addrs = xtconn.scan_available_server_addr()
for addr in addrs:
try:
port = int(addr.split(':')[1])
if port:
PrintLog(LogLevel.INFO, f'[端口探测] SDK 扫描发现端口: {port}')
return port
except (ValueError, IndexError):
continue
except Exception as e:
PrintLog(LogLevel.DEBUG, f'[端口探测] SDK 扫描异常: {e}')
# ---- 方法2: netstat → 反向查进程名 ----
try:
# 2a. netstat 找出所有 LISTENING 端口的 PID
pid_ports = {} # pid -> [port, ...]
netstat = subprocess.run(
['netstat', '-ano'],
capture_output=True, text=True, timeout=10
)
for line in netstat.stdout.splitlines():
if 'LISTENING' not in line and 'LISTEN' not in line:
continue
parts = line.split()
if len(parts) < 5:
continue
try:
local_addr = parts[1]
port = int(local_addr.rsplit(':', 1)[-1])
pid = int(parts[-1])
if port > 0:
pid_ports.setdefault(pid, []).append(port)
except (ValueError, IndexError):
continue
if not pid_ports:
PrintLog(LogLevel.DEBUG, '[端口探测] netstat 未找到任何 LISTENING 端口')
return 0
# 2b. 对每个有监听端口的 PID,查进程名是否匹配 QMT
for pid, ports in pid_ports.items():
name = RealQmtV._get_process_name(pid)
if name and any(kw in name for kw in RealQmtV._QMT_PROCESS_KEYWORDS):
port = ports[0]
PrintLog(LogLevel.INFO, f'[端口探测] 发现 QMT 进程: {name} (PID={pid}), 端口: {port}')
# 同时探测 userdata_mini 路径
exe_path = RealQmtV._get_process_exe_path(pid)
if exe_path:
PrintLog(LogLevel.INFO, f'[路径探测] 进程路径: {exe_path}')
found_path = RealQmtV._find_userdata_mini(exe_path)
if found_path:
PrintLog(LogLevel.INFO, f'[路径探测] 发现 userdata_mini: {found_path}')
if found_path != config.miniQMTPath:
PrintLog(LogLevel.INFO, f'[路径探测] 自动修正 miniQMTPath: {config.miniQMTPath} -> {found_path}')
config.miniQMTPath = found_path
# 同时从窗口标题提取资金账号
account = RealQmtV._discover_account()
if account:
if account != config.account_no:
PrintLog(LogLevel.INFO, f'[账号探测] 自动修正 account_no: {config.account_no[-4:]}**** -> {account[-4:]}****')
config.account_no = account
else:
PrintLog(LogLevel.INFO, f'[账号探测] 确认账号: {account[-4:]}****')
return port
except Exception as e:
PrintLog(LogLevel.INFO, f'[端口探测] 进程扫描异常: {e}')
PrintLog(LogLevel.WARNING, '[端口探测] 未能自动发现 miniQMT 端口')
return 0
@staticmethod
def _get_process_name(pid: int) -> str:
"""通过 PID 获取进程名(单个查询,不用扫全量 tasklist)"""
try:
result = subprocess.run(
['tasklist', '/fi', f'PID eq {pid}', '/fo', 'csv', '/nh'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith('INFO:'):
continue
parts = [p.strip('"').strip() for p in line.split('","')]
if len(parts) >= 2:
return parts[0]
except Exception:
pass
return ''
@staticmethod
def _get_process_exe_path(pid: int) -> str:
"""通过 PID 获取进程的可执行文件完整路径"""
try:
result = subprocess.run(
['powershell', '-NoProfile', '-Command',
f'(Get-Process -Id {pid}).Path'],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
)
path = result.stdout.strip()
if path and os.path.isfile(path):
return path
except Exception:
pass
return ''
@staticmethod
def _find_userdata_mini(exe_path: str) -> str:
"""从 QMT 可执行文件路径向上查找 userdata_mini 目录"""
exe_dir = os.path.dirname(exe_path)
# 从 exe 所在目录开始,向上最多 3 层
for _ in range(4):
candidate = os.path.join(exe_dir, 'userdata_mini')
if os.path.isdir(candidate):
return candidate
parent = os.path.dirname(exe_dir)
if parent == exe_dir:
break
exe_dir = parent
return ''
@staticmethod
def _discover_account() -> str:
"""
从 XtMiniQmt.exe 的窗口标题中提取资金账号。
标题格式: "8882874667 - 国金证券QMT交易端 2.0.8.300"
返回账号字符串,失败返回空字符串。
"""
try:
# 找到 XtMiniQmt.exe 的 PID
tasklist = subprocess.run(
['tasklist', '/fo', 'csv', '/nh'],
capture_output=True, text=True, timeout=10
)
gui_pid = 0
for line in tasklist.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip('"').strip() for p in line.split('","')]
if len(parts) >= 2 and 'XtMiniQmt' in parts[0]:
gui_pid = int(parts[1])
break
if not gui_pid:
return ''
# 获取窗口标题(PowerShell 输出可能含中文,用 utf-8)
result = subprocess.run(
['powershell', '-NoProfile', '-Command',
f'(Get-Process -Id {gui_pid}).MainWindowTitle'],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
)
title = result.stdout.strip()
if title and ' - ' in title:
account = title.split(' - ')[0].strip()
if account.isdigit():
return account
except Exception:
pass
return ''
@staticmethod @staticmethod
def _to_plain_code(stock_code: str) -> str: def _to_plain_code(stock_code: str) -> str:
"""将 xtquant 格式 '600519.SH' 转换为数据库格式 '600519'""" """将 xtquant 格式 '600519.SH' 转换为数据库格式 '600519'"""
@@ -33,15 +214,6 @@ class RealQmtV:
# fallback: try both, prefer SH # fallback: try both, prefer SH
return f'{code}.SH' return f'{code}.SH'
@staticmethod
def _strip_code_suffixes(datas: dict) -> dict:
"""批量去除 xtquant 数据中的代码后缀"""
result = {}
for code, tick in datas.items():
result[code] = tick
if '.' in code:
result[code.split('.')[0]] = tick
return result
def __init__(self) -> None: def __init__(self) -> None:
self.inited = False self.inited = False
self.connected = False self.connected = False
@@ -66,9 +238,11 @@ class RealQmtV:
self.mini_qmt_path = config.miniQMTPath self.mini_qmt_path = config.miniQMTPath
self.account = StockAccount(config.account_no, 'STOCK') self.account = StockAccount(config.account_no, 'STOCK')
PrintLog(LogLevel.INFO, f'[QMT] 初始化: path={self.mini_qmt_path}, account={config.account_no[-4:]}****')
# 创建 XtQuantTrader 实例 # 创建 XtQuantTrader 实例
session_id = int(time.time()) % 10000 session_id = int(time.time()) % 10000
PrintLog(LogLevel.INFO, f'[QMT] 创建 XtQuantTrader, session={session_id}')
self.xt_trader = XtQuantTrader(self.mini_qmt_path, session_id) self.xt_trader = XtQuantTrader(self.mini_qmt_path, session_id)
# 注册回调 — xtquant 只接受一个回调对象,会在上面调用 on_xxx 方法 # 注册回调 — xtquant 只接受一个回调对象,会在上面调用 on_xxx 方法
@@ -81,30 +255,62 @@ class RealQmtV:
PrintLog(LogLevel.ERROR, f'- [失败] QMT 初始化: {e}') PrintLog(LogLevel.ERROR, f'- [失败] QMT 初始化: {e}')
def connect(self) -> bool: def connect(self) -> bool:
"""连接 MiniQMT""" """连接 MiniQMT,失败自动探测端口并重试"""
if not self.inited: if not self.inited:
PrintLog(LogLevel.ERROR, '- [失败] QMT 未初始化') PrintLog(LogLevel.ERROR, '[QMT] 连接失败: 未初始化')
return False return False
try: _connect_errors = {
# 启动 trader 线程 0: '成功',
-1: '一般错误(miniQMT 可能未启动)',
-2: 'miniQMT 未运行(请先启动极简QMT)',
-3: '连接超时',
}
def _do_connect() -> int:
self.xt_trader.start() self.xt_trader.start()
# 建立连接 PrintLog(LogLevel.INFO, '[QMT] xt_trader.start() 完成')
connect_result = self.xt_trader.connect() PrintLog(LogLevel.INFO, '[QMT] 正在连接 miniQMT...')
return self.xt_trader.connect()
try:
# 尝试默认连接
PrintLog(LogLevel.INFO, '[QMT] 尝试默认方式连接...')
connect_result = _do_connect()
# 失败则自动探测端口并重试
if connect_result != 0:
PrintLog(LogLevel.INFO, '[QMT] 默认连接失败,启动端口自动探测...')
discovered_port = self._discover_qmt_port()
if discovered_port > 0:
PrintLog(LogLevel.INFO, f'[QMT] 探测到端口 {discovered_port},尝试连接...')
try:
from xtquant import xtdata
xtdata.connect(ip='127.0.0.1', port=discovered_port)
PrintLog(LogLevel.INFO, f'[QMT] xtdata 连接成功 (端口: {discovered_port})')
except Exception as e:
PrintLog(LogLevel.ERROR, f'[QMT] xtdata 连接失败 (端口: {discovered_port}): {e}')
return False
connect_result = _do_connect()
else:
PrintLog(LogLevel.WARNING, '[QMT] 端口自动探测未找到 miniQMT 进程')
result_desc = _connect_errors.get(connect_result, f'未知({connect_result})')
PrintLog(LogLevel.INFO, f'[QMT] connect() 返回: {connect_result} ({result_desc})')
if connect_result == 0: if connect_result == 0:
# 订阅账户 (传入 StockAccount 对象而不是 account_id 字符串) PrintLog(LogLevel.INFO, f'[QMT] 订阅账户...')
self.xt_trader.subscribe(self.account) self.xt_trader.subscribe(self.account)
# 等待回调 PrintLog(LogLevel.INFO, '[QMT] 订阅完成')
time.sleep(1)
self.connected = True self.connected = True
self.startMarketDataSubscription() self.startMarketDataSubscription()
PrintLog(LogLevel.INFO, f'- [成功] 真实交易连接成功 (账号: {config.account_no})') PrintLog(LogLevel.INFO, f'[QMT] 连接成功 (账号: {config.account_no[-4:]}****)')
return True return True
else: else:
PrintLog(LogLevel.ERROR, f'- [失败] 连接失败, 返回码: {connect_result}') PrintLog(LogLevel.ERROR, f'[QMT] 连接失败: {result_desc}')
return False return False
except Exception as e: except Exception as e:
PrintLog(LogLevel.ERROR, f'- [失败] 连接异常: {e}') PrintLog(LogLevel.ERROR, f'[QMT] 连接异常: {e}')
return False return False
def getAllPositions(self) -> dict: def getAllPositions(self) -> dict:
@@ -144,18 +350,41 @@ class RealQmtV:
return None return None
def queryPendingOrder(self, stock_code: str, tag: str) -> list: def queryPendingOrder(self, stock_code: str, tag: str) -> list:
"""查询挂单""" """查询挂单(过滤已撤/废单)"""
if not self.connected: if not self.connected:
return [] return []
try: try:
orders = self.xt_trader.query_stock_orders(self.account) orders = self.xt_trader.query_stock_orders(self.account)
# 过滤已撤(54)和废单(57),避免策略误判"已有挂单"跳过下单
_CANCELED = {54, 57}
return [o for o in orders return [o for o in orders
if self._to_plain_code(getattr(o, 'stock_code', '')) == stock_code and if self._to_plain_code(getattr(o, 'stock_code', '')) == stock_code and
(tag is None or getattr(o, 'strategy_name', None) == tag)] (tag is None or getattr(o, 'strategy_name', None) == tag) and
getattr(o, 'order_status', 0) not in _CANCELED]
except Exception as e: except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询挂单失败] {e}') PrintLog(LogLevel.ERROR, f'- [查询挂单失败] {e}')
return [] return []
def queryTodayOrders(self) -> list:
"""查询当日所有委托"""
if not self.connected:
return []
try:
return list(self.xt_trader.query_stock_orders(self.account))
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询委托失败] {e}')
return []
def queryTodayTrades(self) -> list:
"""查询当日所有成交"""
if not self.connected:
return []
try:
return list(self.xt_trader.query_stock_trades(self.account))
except Exception as e:
PrintLog(LogLevel.ERROR, f'- [查询成交失败] {e}')
return []
def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name): def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name):
"""异步下单""" """异步下单"""
if not self.connected: if not self.connected:
@@ -328,7 +557,8 @@ class RealQmtV:
# xtquant 返回 "600519.SH" 格式 keyUI 使用纯代码 "600519" # xtquant 返回 "600519.SH" 格式 keyUI 使用纯代码 "600519"
# 构建同时包含两种 key 的数据确保匹配 # 构建同时包含两种 key 的数据确保匹配
eBus.event_bus.publish(eBus.MarketDataUpdate, self._strip_code_suffixes(datas)) # 直接发布 xtquant 原始数据(代码带 .SH/.SZ 后缀)
eBus.event_bus.publish(eBus.MarketDataUpdate, datas)
def _market_data_watchdog(self): def _market_data_watchdog(self):
"""行情活跃监控 — 超过 30 秒无数据则标记市场不活跃""" """行情活跃监控 — 超过 30 秒无数据则标记市场不活跃"""
@@ -349,10 +579,10 @@ class RealQmtV:
# ---- xtquant 回调处理 (xtquant 通过回调对象调用 on_xxx 方法) ---- # ---- xtquant 回调处理 (xtquant 通过回调对象调用 on_xxx 方法) ----
def on_connected(self): def on_connected(self):
print(datetime.datetime.now(), '真实 QMT 连接成功') PrintLog(LogLevel.INFO, f'[QMT] on_connected: 真实 QMT 连接成功 {datetime.datetime.now()}')
def on_disconnected(self): def on_disconnected(self):
print(datetime.datetime.now(), '真实 QMT 连接断开') PrintLog(LogLevel.WARNING, f'[QMT] on_disconnected: 真实 QMT 连接断开 {datetime.datetime.now()}')
def on_stock_order(self, order): def on_stock_order(self, order):
self._pending_orders.append(order) self._pending_orders.append(order)
@@ -364,11 +594,13 @@ class RealQmtV:
eBus.event_bus.publish(eBus.MarketOrderCreated, response) eBus.event_bus.publish(eBus.MarketOrderCreated, response)
def on_order_error(self, order_error): def on_order_error(self, order_error):
print(f"\n真实委托报错回调: order_id={order_error.order_id}, error_id={order_error.error_id}, error_msg={order_error.error_msg}, remark={order_error.order_remark}") PrintLog(LogLevel.ERROR,
f'[QMT] 委托报错: order_id={order_error.order_id}, error_id={order_error.error_id}, '
f'error_msg={order_error.error_msg}, remark={order_error.order_remark}')
eBus.event_bus.publish(eBus.MarketOrderError, order_error) eBus.event_bus.publish(eBus.MarketOrderError, order_error)
def on_account_status(self, status): def on_account_status(self, status):
print(datetime.datetime.now(), status) PrintLog(LogLevel.INFO, f'[QMT] on_account_status: {datetime.datetime.now()} {status}')
qmtv = RealQmtV() qmtv = RealQmtV()
+1 -1
View File
@@ -16,7 +16,7 @@ class SFGridTradeTarget(BaseModel):
init_price = FloatField(null=True) # 建仓成本 init_price = FloatField(null=True) # 建仓成本
grid_match_count = IntegerField(default=0) grid_match_count = IntegerField(default=0)
grid_total_profit = FloatField(default=0.0) grid_total_profit = FloatField(default=0.0)
status = IntegerField(default=0) # -1表示新标的,未完成交易配置,0表示新标的,已完成交易配置,1表示已建初始仓,正常交易中 status = IntegerField(default=0) # 已废弃,改用 strategy_type + grid_index
enabled = BooleanField(default=False) # 是否启动交易线程 enabled = BooleanField(default=False) # 是否启动交易线程
strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略 strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略
+49 -108
View File
@@ -25,7 +25,7 @@ from core.sfgrid import bus_events
from core.sfgrid.bus_events import EventTradeTargetUpdate from core.sfgrid.bus_events import EventTradeTargetUpdate
import core.sfgrid.model as model import core.sfgrid.model as model
from core.eventbus import event_bus from core.eventbus import event_bus
from core.constants import OrderTypeBuy, OrderTypeSell, OrderTypeInit from core.constants import OrderTypeBuy, OrderTypeSell
from xtquant import xtconstant from xtquant import xtconstant
from xtquant.xttype import XtOrderError, XtOrderResponse, XtTrade from xtquant.xttype import XtOrderError, XtOrderResponse, XtTrade
@@ -65,8 +65,7 @@ class SFGridStrategy:
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: ' f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
f'grid_index={tradeTarget.grid_index}, status={tradeTarget.status}, ' f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
f'enabled={tradeTarget.enabled}')
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射 # orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新) # seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
@@ -149,30 +148,10 @@ class SFGridStrategy:
# 获取当前该标的所有未成交订单 # 获取当前该标的所有未成交订单
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
# ── 分支1: status=0 未建仓 → 下建仓单 ── # ── 统一网格逻辑 ──
# 条件: 标的尚未建仓 且 不存在正在执行中的建仓单(防止重复建仓) # grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖
init_remark = self._make_remark(OrderTypeInit, 1) # grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1]
if self.tradeTarget.status == 0 and not any( if self.tradeTarget.grid_index >= 0:
o.order_remark == init_remark for o in orders
):
# 建仓价取价格网格中最高价(grid_index=0 即列表第一个元素)
price = self.tradeTarget.getPriceGrid()[0]
tmpOrderSeq = qmtv.orderAsync(
str(self.tradeTarget.stock_code),
self.tradeTarget.grid_volume,
xtconstant.STOCK_BUY, # 建仓 = 买入
price,
xtconstant.FIX_PRICE, # 限价单
init_remark,
self.getName(),
)
self.orderGrid[1] = tmpOrderSeq # 建仓单固定在网格索引 1
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 初始化: '
f'建仓单,建仓价: {price:.3f}')
# ── 分支2: status=1 已建仓 → 下网格买卖单 ──
elif self.tradeTarget.status == 1:
currentIdx = self.tradeTarget.grid_index # type: ignore currentIdx = self.tradeTarget.grid_index # type: ignore
# --- 上方挂卖出单(空单)--- # --- 上方挂卖出单(空单)---
@@ -272,19 +251,16 @@ class SFGridStrategy:
启用或停用该标的的网格交易 启用或停用该标的的网格交易
启用时 (enabled=True): 启用时 (enabled=True):
- status=0: 初始化网格索引后调用 refreshGridOrder 下建仓单 - grid_index=0 空仓: 直接调用 refreshGridOrder(只挂买单)
- status=1: 检查持仓是否满足当前网格位置要求,满足则刷新网格订单 - grid_index>0 有仓: 检查持仓是否满足 grid_volume × grid_index
不满足则回退 enabled=False(风控保护) 满足则刷新网格单,不满足则回退 enabled=False(风控保护)
停用时 (enabled=False): 停用时 (enabled=False):
- 取消该标的所有未成交订单,停止交易监控 - 取消该标的所有未成交订单,停止交易监控
返回:
更新后的 tradeTarget 对象
""" """
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f" |- [DEBUG] enabledTrading({enabled}) 调用前: " f" |- [DEBUG] enabledTrading({enabled}) 调用前: "
f"grid_index={self.tradeTarget.grid_index}, status={self.tradeTarget.status}") f"grid_index={self.tradeTarget.grid_index}")
self.tradeTarget.enabled = enabled # type: ignore self.tradeTarget.enabled = enabled # type: ignore
@@ -294,38 +270,23 @@ class SFGridStrategy:
f" |- 标的{self.tradeTarget.targetName()}交易启动, " f" |- 标的{self.tradeTarget.targetName()}交易启动, "
f"持仓量:{self.tradeTarget.current_position}") f"持仓量:{self.tradeTarget.current_position}")
if self.tradeTarget.status == 0:
# 未建仓状态: 初始化网格索引
if self.tradeTarget.grid_index == 0: if self.tradeTarget.grid_index == 0:
# grid_index=0 表示从未初始化过,设为 1(价格网格最高点建仓) # 空仓: refreshGridOrder 会在 grid[1] 挂第一笔买单
self.tradeTarget.grid_index = 1 # pyright: ignore[reportAttributeAccessIssue]
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f" |- 标的{self.tradeTarget.targetName()}初始状态, " f" |- 标的{self.tradeTarget.targetName()}空仓, "
f"设置网格序号 1,") f"等待首次买入建仓")
else: else:
# grid_index 非零,保留之前设置的值(可能是手动修改的) # 有仓: 检查现有持仓是否满足当前网格位置的仓位需求
PrintLog(LogLevel.INFO,
f" |- 标的{self.tradeTarget.targetName()}初始状态, "
f"保留网格序号 {self.tradeTarget.grid_index},")
else:
# 已建仓状态: 检查现有持仓是否满足当前网格位置的仓位需求
# 最小需求仓位 = 每格股数 × 当前网格索引 # 最小需求仓位 = 每格股数 × 当前网格索引
# 例: grid_volume=100, grid_index=3 → 需持股 300 股 # 例: grid_volume=100, grid_index=3 → 需持股 300 股
PrintLog(LogLevel.INFO,
f" |- 标的{self.tradeTarget.targetName()}已有仓位或非初始状态 "
f"无需建初始仓 当前仓位: {self.tradeTarget.current_position} "
f"状态: {self.tradeTarget.status}")
minRequirePosition: int = self.tradeTarget.grid_volume * int(self.tradeTarget.grid_index) # type: ignore minRequirePosition: int = self.tradeTarget.grid_volume * int(self.tradeTarget.grid_index) # type: ignore
if minRequirePosition <= int(self.tradeTarget.current_position): # type: ignore if minRequirePosition <= int(self.tradeTarget.current_position): # type: ignore
# 持仓充足,可以继续网格交易
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f' |- 仓位检查: 持仓需求充足, ' f' |- 仓位检查: 持仓需求充足, '
f'(gridVolume*gridIndex)={minRequirePosition}, ' f'(gridVolume*gridIndex)={minRequirePosition}, '
f'当前持仓:{self.tradeTarget.current_position}') f'当前持仓:{self.tradeTarget.current_position}')
else: else:
# 持仓不足(可能是之前部分成交或手动减仓),风控:拒绝启用
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f' |- 仓位检查: 持仓需求不足, ' f' |- 仓位检查: 持仓需求不足, '
f'(gridVolume*gridIndex)={minRequirePosition}, ' f'(gridVolume*gridIndex)={minRequirePosition}, '
@@ -333,7 +294,7 @@ class SFGridStrategy:
f'交易启动失败') f'交易启动失败')
self.tradeTarget.enabled = False # type: ignore self.tradeTarget.enabled = False # type: ignore
# 无论 status=0 还是 status=1,最终都调用 refreshGridOrder 下对应的单 # 刷新网格订单(空仓只挂买单,有仓买卖对冲)
self.refreshGridOrder() self.refreshGridOrder()
else: else:
@@ -429,20 +390,17 @@ class SFGridStrategy:
""" """
QMT 委托成交通知回调 QMT 委托成交通知回调
成交后: 收到成交后:
1. 更新网格索引(卖出上移 / 买入下移) 1. 判断成交方向(买入下移 / 卖出上移)→ 更新 grid_index
2. 如果是建仓单成交: status 0→1, 记录建仓价 2. 首次建仓(grid_index==0 时成交)→ 记录 init_price
3. 如果是网格单成交: 累计网格匹配次数和总利润 3. 卖出成交 → 累计 grid_match_count 和 grid_total_profit
4. orderGrid 删除已成交订 4. 清理 orderGrid → 持久化 → 刷新网格挂
5. 持久化状态到数据库
6. 调用 refreshGridOrder 挂新的网格单
trade.order_remark 格式: "{type},{gridIdx},{stockCode}"
""" """
# ── 过滤:只处理本策略本标的的成交 ──
parsed = self._filter_event(trade.order_remark, trade.strategy_name) parsed = self._filter_event(trade.order_remark, trade.strategy_name)
if parsed is None: if parsed is None:
return return
orderType, gridIdx, _ = parsed _, gridIdx, _ = parsed # gridIdx: 成交订单对应的网格索引(int)
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f'|- 委托成交通知' f'|- 委托成交通知'
@@ -451,61 +409,45 @@ class SFGridStrategy:
self.dataUpdateLock.acquire() self.dataUpdateLock.acquire()
try: try:
desc: str = "" # 用于日志展示的成交类型描述 # ── 首次建仓:记录建仓价 ──
# grid_index==0 表示成交前处于空仓状态,这笔成交就是首次建仓
# ── 分支1: 建仓单成交 ── if self.tradeTarget.grid_index == 0:
if orderType == OrderTypeInit:
PrintLog(LogLevel.INFO,
f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] '
f'- 建仓单成交')
# 状态切换: 未建仓(0) → 已建仓(1)
self.tradeTarget.status = 1 # type: ignore
# 记录建仓价格
self.tradeTarget.init_price = trade.traded_price # type: ignore self.tradeTarget.init_price = trade.traded_price # type: ignore
PrintLog(LogLevel.INFO,
f'|- [DEBUG] 建仓单成交: '
f'grid_index {self.tradeTarget.grid_index} → 1')
# 建仓后网格索引固定为 1(价格网格最高点)
self.tradeTarget.grid_index = 1 # type: ignore
desc = "建仓单"
# ── 分支2: 网格单成交 ── # ── 网格方向判断 ──
else: # 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
PrintLog(LogLevel.INFO, oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] '
f'- 网格单成交')
oriIdx = self.tradeTarget.grid_index # 记录原网格位置(用于日志)
# 判断成交方向: gridIdx > currentIdx → 买入成交(下移)
if gridIdx > self.tradeTarget.grid_index: if gridIdx > self.tradeTarget.grid_index:
desc = "下移一格" # 成交单在下方(更大索引 = 更低价格)→ 买入成交,持仓下移
self.tradeTarget.grid_index += 1 self.tradeTarget.grid_index += 1 # type: ignore
# 首次建仓时 oriIdx==0,加上"建仓单"前缀便于识别
desc = "建仓单(下移)" if oriIdx == 0 else "下移一格"
# 判断成交方向: gridIdx < currentIdx → 卖出成交(上移)
elif gridIdx < self.tradeTarget.grid_index: elif gridIdx < self.tradeTarget.grid_index:
# 成交单在上方(更小索引 = 更高价格)→ 卖出成交,持仓上移
self.tradeTarget.grid_index -= 1 # type: ignore
# 卖出获利:累计匹配次数和利润
self.tradeTarget.grid_match_count += 1 # type: ignore
# 单格利润 = grid_size × 成交量
self.tradeTarget.grid_total_profit += ( # type: ignore
self.tradeTarget.grid_size * trade.traded_volume)
desc = "上移一格" desc = "上移一格"
# 累计统计
self.tradeTarget.grid_match_count += 1 # 网格匹配次数+1
self.tradeTarget.grid_total_profit += (
self.tradeTarget.grid_size * trade.traded_volume
) # 累计利润 = 网格间距 × 成交量
self.tradeTarget.grid_index -= 1
# gridIdx == currentIdx: 理论上不应出现(同一个位置不会挂单给自己)
else: else:
desc = "保持格, 理论上不应该输出" # gridIdx == grid_index: 同格成交,正常情况下不会出现
desc = "同格(异常)"
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f'|- 委托成交通知' f'|- [{self.tradeTarget.targetName()}] '
f'[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name} - ' f'原网格 {oriIdx} → 现网格 {self.tradeTarget.grid_index}'
f'原网格位置 {oriIdx}, 现网格位置 {self.tradeTarget.grid_index}') f'{desc}')
# ── 成交后处理 ── # ── 成交后统一处理 ──
# 1. 持久化状态到数据库 # 1. 持久化状态到数据库(grid_index、持仓量等已变更)
self.saveProxy() self.saveProxy()
# 2. 从 orderGrid 中删除已成交订单(pop 防重复推送 KeyError # 2. 从 orderGrid 清理已成交订单(pop 防 xtquant 重复推送 KeyError
self.orderGrid.pop(gridIdx, None) self.orderGrid.pop(gridIdx, None)
# 3. 打印成交报告 # 3. 打印成交报告
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f"|- 成交报告[{self.tradeTarget.targetName()}] : " f"|- 成交报告[{self.tradeTarget.targetName()}] : "
@@ -517,8 +459,7 @@ class SFGridStrategy:
f' 成交价: {trade.traded_price} 成交量: {trade.traded_volume}') f' 成交价: {trade.traded_price} 成交量: {trade.traded_volume}')
PrintLog(LogLevel.INFO, PrintLog(LogLevel.INFO,
f' 手续费 : {trade.commission:.3f}') f' 手续费 : {trade.commission:.3f}')
# 4. 刷新网格订单:在新的 grid_index 位置重新挂买卖单
# 4. 刷新网格订单:在新的 grid_index 上下重新挂买卖单
self.refreshGridOrder() self.refreshGridOrder()
finally: finally:
@@ -571,7 +512,7 @@ class SFGridStrategy:
""" """
PrintLog(LogLevel.DEBUG, PrintLog(LogLevel.DEBUG,
f'|- [DEBUG] saveProxy: {self.tradeTarget.targetName()} ' f'|- [DEBUG] saveProxy: {self.tradeTarget.targetName()} '
f'grid_index={self.tradeTarget.grid_index}, status={self.tradeTarget.status}') f'网格={self.tradeTarget.grid_index}')
rc = self.tradeTarget.save() rc = self.tradeTarget.save()
event_bus.publish(EventTradeTargetUpdate, self.tradeTarget) event_bus.publish(EventTradeTargetUpdate, self.tradeTarget)
return rc return rc
View File
View File
+836
View File
@@ -0,0 +1,836 @@
"""
Flet UI — 完整对齐 Tkinter 版布局、数据流、刷新机制。
"""
import asyncio
import time
import threading
import flet as ft
from core.qmt_real import RealQmtV, qmtv
from core.logger import LogLevel, PrintLog
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID, STRATEGY_TYPE_UNCLASSIFIED
from core.sfgrid.sfgrid_strategy import SFGridStrategy
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
from core.sfgrid.bus_events import EventTradeTargetUpdate
# ── 委托状态 / 方向映射 ──
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
def _fmt_time(t) -> str:
"""格式化 QMT 时间为 HH:MM:SS(北京时间,Unix timestamp → 本地时间)"""
if not t:
return ''
import datetime
try:
ts = int(t)
if ts > 1e12: # 毫秒级
ts //= 1000
return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S')
except (ValueError, OSError):
return str(t)
def _direction(ot: int) -> str:
return '' if ot == 23 else '' if ot == 24 else str(ot)
def _plain(code: str) -> str:
return code.split('.')[0] if '.' in code else code
# ══════════════════════════════════════════════════════════════════════
# QmtApp
# ══════════════════════════════════════════════════════════════════════
class QmtApp:
"""Flet 版 QMT 交易界面,布局、数据流对齐 core/ui/tkinter/sfgrid_view.py"""
def __init__(self, page: ft.Page):
self.page = page
self.page.title = "神之一手"
self.page.window.width = 1400
self.page.window.height = 800
self.page.padding = 0
# ── 状态(对齐 Tkinter TradeTargetUI ──
self.tradeTargetData: dict[int, SFGridTradeTarget] = {}
self.stockCodeIdMap: dict[str, int] = {}
self.strategy_ctrl: dict[int, SFGridStrategy] = {}
self.targetMarketPrice: dict[int, float] = {}
self.targetPreClose: dict[int, float] = {} # 昨收
self.targetAvgPrice: dict[int, float] = {}
self.marketData: dict[str, dict] = {} # stock_code → {stock_name, last_price, time}
self.listening_stock: list = []
self.monitor_price: float = 10.0
self._market_active: bool = qmtv.isMarketActive
self._refresh_cycle: int = 0
self._drawer_open: bool = False
self._selected_target = None
self._prices_loaded: bool = False
self._orders: list = []
self._trades: list = []
self._run_startup()
# ══════════════════════════════════════════════════════════════
# 启动流程(对齐 tkinter/splash.py
# ══════════════════════════════════════════════════════════════
def _run_startup(self):
"""启动进度 — 先渲染 splash,再异步执行启动步骤"""
bar = ft.ProgressBar(width=340, value=0, color='#0078d4')
self._splash_status = ft.Text("正在初始化...", size=13)
self._splash_bar = bar
splash = ft.Container(
ft.Column([
ft.Text("神之一手", size=22, weight=ft.FontWeight.BOLD, color='#0078d4'),
ft.Text("交易系统", size=14, color='#666666'),
ft.Container(height=20),
self._splash_status,
ft.Container(height=8),
bar,
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
width=380, height=200,
bgcolor=ft.Colors.SURFACE,
border_radius=12,
shadow=ft.BoxShadow(blur_radius=20, color='#20000000'),
alignment=ft.Alignment.CENTER,
)
self.page.add(ft.Container(
content=splash,
alignment=ft.Alignment.CENTER, expand=True,
bgcolor='#F5F5F5',
))
self.page.update()
# 异步执行启动,确保 splash 先渲染
asyncio.ensure_future(self._do_startup())
async def _do_startup(self):
"""异步启动流程 — splash 已渲染,逐步执行并更新进度"""
# 给渲染一帧的时间
await asyncio.sleep(0.05)
steps = [
("正在检查 QMT 环境...", 0.10, lambda: RealQmtV._discover_qmt_port() or True),
("正在初始化交易器...", 0.35, lambda: qmtv.init_qmtv()),
("正在连接 QMT...", 0.55, lambda: qmtv.connect() or True),
("正在加载持仓数据...", 0.75, lambda: self._init_data()),
("正在构建界面...", 0.85, lambda: None),
("正在初始化策略...", 0.92, lambda: self._init_strategies()),
]
for text, pct, action in steps:
self._splash_status.value = text
self._splash_bar.value = pct
self.page.update()
try:
result = action()
if result is False:
self._show_error(f"启动失败: {text}")
return
except Exception as e:
self._show_error(f"启动异常: {text}\n{e}")
return
self._splash_status.value = "启动完成"
self._splash_bar.value = 1.0
self.page.update()
await asyncio.sleep(0.3)
self.page.clean()
self._build_main_ui()
self.page.update()
# 主动拉取市价(不等行情推送)
self._pull_prices()
# 加载委托/成交数据
self._refresh_orders()
self._refresh_trades()
self._rebuild_tables()
self.page.update()
# 订阅事件 + 后台刷新
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active_switch)
event_bus.subscribe(EventTradeTargetUpdate, self._on_strategy_update)
threading.Thread(target=self._refresh_loop, daemon=True).start()
def _show_error(self, msg: str):
self.page.clean()
self.page.add(ft.Container(
content=ft.Column([
ft.Icon(ft.Icons.ERROR_OUTLINE, size=48, color=ft.Colors.RED),
ft.Text(msg, size=16),
ft.ElevatedButton("重试", on_click=lambda e: self._retry()),
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
alignment=ft.Alignment.CENTER, expand=True,
))
self.page.update()
def _retry(self):
self.page.clean()
self._run_startup()
# ══════════════════════════════════════════════════════════════
# 数据初始化(对齐 Tkinter init_trade_target_pool
# ══════════════════════════════════════════════════════════════
def _init_data(self):
positions = qmtv.getAllPositions()
PrintLog(LogLevel.INFO, f'[Flet] 持仓: {len(positions)}')
for code, pos in positions.items():
existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code)
if existing is None:
name = getattr(pos, 'instrument_name', '') or qmtv.getInstrumentName(code)
SFGridTradeTarget.create(
stock_code=code, stock_name=name,
current_position=int(pos.volume),
init_price=float(getattr(pos, 'avg_price', 0) or 0),
grid_index=0, enabled=False,
grid_start_price=float(getattr(pos, 'avg_price', 0) or 0) or 10.0,
grid_size=1.0, grid_volume=200, grid_upper_count=1, grid_lower_count=10,
)
# 获取昨收价(需要带后缀的完整代码)
try:
from xtquant import xtdata
for stock_code, pos in positions.items():
full_code = stock_code
if '.' not in stock_code:
c = stock_code
full_code = f'{c}.SH' if c.startswith(('6', '5', '9')) else f'{c}.SZ'
detail = xtdata.get_instrument_detail(full_code)
if detail:
pre_close = detail.get('PreClose', 0) if isinstance(detail, dict) else getattr(detail, 'PreClose', 0)
if pre_close > 0:
self.targetPreClose[stock_code] = float(pre_close)
PrintLog(LogLevel.INFO, f'[Flet] 已获取 {len(self.targetPreClose)} 个标的昨收价')
except Exception as e:
PrintLog(LogLevel.DEBUG, f'[Flet] 昨收价获取异常: {e}')
results = list(SFGridTradeTarget.select())
for t in results:
pos = positions.get(t.stock_code)
t.current_position = 0 if pos is None else int(pos.volume)
tid = t.get_id()
self.tradeTargetData[tid] = t
self.stockCodeIdMap[t.stock_code] = tid
if pos is not None:
self.targetAvgPrice[tid] = float(getattr(pos, 'avg_price', 0) or 0)
def _init_strategies(self):
from core.sfgrid.model import STRATEGY_TYPE_GRID
for tid, t in self.tradeTargetData.items():
if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled:
self.strategy_ctrl[tid] = SFGridStrategy(t)
# ══════════════════════════════════════════════════════════════
# 主界面构建(对齐 Tkinter create_tables_area
# ══════════════════════════════════════════════════════════════
def _build_main_ui(self):
# ── 右侧面板内容 ──
self._tab_orders = ft.Tab(label="当前委托")
self._tab_trades = ft.Tab(label="当日成交")
right_bar = ft.TabBar(tabs=[
ft.Tab(label="实时价格监控"),
self._tab_orders,
self._tab_trades,
ft.Tab(label="未分类持仓"),
])
self._uncl_list = ft.ListView([self._build_unclassified_table()], expand=True)
self._right_view = ft.TabBarView(controls=[
self._build_market_view(),
self._build_order_view(),
self._build_trade_view(),
self._uncl_list,
], expand=True)
panel_content = ft.Container(
content=ft.Column([
ft.Container(ft.Text("监控面板", size=14, weight=ft.FontWeight.BOLD), padding=ft.Padding(10, 10, 10, 5)),
ft.Tabs(ft.Column([right_bar, self._right_view], expand=True), length=4, expand=True),
], expand=True),
width=700, bgcolor=ft.Colors.SURFACE,
)
# ── 遮罩层(点击关闭) ──
backdrop = ft.Container(
bgcolor='#44000000', expand=True,
on_click=lambda e: self._hide_overlay(),
)
# ── overlay 行:遮罩 + 面板 ──
self._overlay = ft.Container(
ft.Row([backdrop, panel_content], spacing=0),
visible=False, expand=True,
)
# ── 标题栏(始终可见,选中行后显示操作按钮) ──
self._sidebar_icon = _PanelIcon('sidebar', active=False, on_click=lambda e: self._toggle_overlay())
self._sel_actions = ft.Row([], spacing=4) # 动态操作按钮
self._sel_info = ft.Text("", size=12, color='#666666')
grid_title = ft.Container(
ft.Row([
ft.Row([
ft.Text("网格策略持仓", size=13, weight=ft.FontWeight.BOLD),
self._sel_info,
self._sel_actions,
]),
ft.Row([
ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=18,
on_click=lambda e: self._manual_refresh()),
self._sidebar_icon,
], spacing=0),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
padding=ft.Padding(10, 10, 10, 5),
)
# ── 表格(Stack 内,可被 overlay 覆盖) ──
self._grid_list = self._build_grid_table() # 回到 DataTable
grid_body = ft.Container(
content=self._grid_list, expand=True,
padding=ft.Padding(10, 0, 10, 10),
)
self.page.add(ft.Column([
grid_title,
ft.Stack([grid_body, self._overlay], expand=True),
], expand=True))
# ── 表格工具 ──
def _dt(self, cols: list[str], rows: list[list[str]], col_widths: list = None) -> ft.Control:
"""构建 DataTable"""
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
data_rows = []
for r in rows:
cells = []
for i, c in enumerate(r):
w = col_widths[i] if col_widths and i < len(col_widths) else None
cells.append(ft.DataCell(ft.Text(str(c), overflow=ft.TextOverflow.ELLIPSIS,
max_lines=1, width=w)))
data_rows.append(ft.DataRow(cells=cells))
if not data_rows:
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
return ft.ListView([ft.DataTable(
columns=data_cols, rows=data_rows,
width=float('inf'),
heading_row_height=36, data_row_min_height=32,
)], expand=True)
# ── 各表格 ──
def _pending_tags(self, stock_code: str) -> list:
"""返回该标的下挂单的方向标签列表:''(买单) / ''(卖单)"""
tags = []
_CANCELED = {54, 57}
for o in self._orders:
if _plain(getattr(o, 'stock_code', '')) != stock_code:
continue
if getattr(o, 'order_status', 0) in _CANCELED:
continue
ot = getattr(o, 'order_type', 0)
if ot == 23 and '' not in tags:
tags.append('')
elif ot == 24 and '' not in tags:
tags.append('')
return tags
def _tag_badge(self, text: str, color: str) -> ft.Container:
return ft.Container(
ft.Text(text, size=10, color='white', weight=ft.FontWeight.BOLD),
bgcolor=color, border_radius=4, padding=ft.Padding(3, 1, 3, 1),
)
def _on_grid_row_select(self, target):
"""DataRow 选中回调 — 在标题栏显示操作按钮"""
self._selected_target = target
name = f'{target.stock_code} {target.stock_name}'
self._sel_info.value = f" | 已选: {name}"
actions = []
if target.enabled:
actions.append(ft.ElevatedButton("⏸ 暂停", on_click=lambda e, t=target: self._on_stop_trade(t), height=28))
else:
actions.append(ft.ElevatedButton("▶ 启动", on_click=lambda e, t=target: self._on_start_trade(t), height=28))
actions.append(ft.ElevatedButton("⚙ 设置", on_click=lambda e, t=target: self._open_grid_config(t), height=28))
self._sel_actions.controls = actions
self.page.update()
def _build_grid_table(self) -> ft.Control:
"""网格表格 — DataTable + on_select_change"""
cols = ["ID", "股票", "市场价", "持仓", "成本", "网格基准", "状态"]
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
data_rows = []
is_sel = self._selected_target is not None
sel_id = self._selected_target.get_id() if self._selected_target else -1
for tid, t in self.tradeTargetData.items():
if t.strategy_type != 1:
continue
pg = t.getPriceGrid()
idx = t.grid_index
grid_base = pg[idx] if 0 <= idx < len(pg) else 0
mp = self.targetMarketPrice.get(tid, 0) or 0
pre_close = self.targetPreClose.get(t.stock_code, 0) or 0
up = mp > pre_close and pre_close > 0
down = mp < pre_close and mp > 0 and pre_close > 0
pcolor = '#CC0000' if up else '#009900' if down else None
gtext = ft.Text(f'{grid_base:.2f}', weight=ft.FontWeight.BOLD)
gparts = [gtext]
if mp > grid_base > 0:
gparts.append(ft.Text('', color='#CC0000', weight=ft.FontWeight.BOLD))
elif 0 < mp < grid_base:
gparts.append(ft.Text('', color='#009900', weight=ft.FontWeight.BOLD))
for tag in self._pending_tags(t.stock_code):
gparts.append(self._tag_badge(tag, '#E67E22' if tag == '' else '#3498DB'))
gcell = ft.Row(gparts, spacing=3) if len(gparts) > 1 else gtext
dr = ft.DataRow(cells=[
ft.DataCell(ft.Text(str(tid))),
ft.DataCell(ft.Text(f'{t.stock_code} {t.stock_name}')),
ft.DataCell(ft.Text(f'{mp:.3f}', color=pcolor, weight=ft.FontWeight.BOLD)),
ft.DataCell(ft.Text(str(t.current_position))),
ft.DataCell(ft.Text(f'{self.targetAvgPrice.get(tid, 0):.3f}')),
ft.DataCell(gcell),
ft.DataCell(ft.Text('▶运行中' if t.enabled else '⏸已暂停')),
], selected=(is_sel and tid == sel_id))
dr.on_select_change = lambda e, t=t: self._on_grid_row_select(t)
data_rows.append(dr)
if not data_rows:
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
return ft.ListView([ft.DataTable(columns=data_cols, rows=data_rows,
width=float('inf'),
heading_row_height=36, data_row_min_height=32)], expand=True)
def _build_unclassified_table(self) -> ft.Control:
cols = ["ID", "股票", "市场价", "当前持仓", "平均成本"]
rows = []
for tid, t in self.tradeTargetData.items():
if t.strategy_type == STRATEGY_TYPE_GRID:
continue
mp = self.targetMarketPrice.get(tid, 0) or 0
rows.append([
str(tid),
f'{t.stock_code} {t.stock_name}',
f'{mp:.3f}',
str(t.current_position),
f'{self.targetAvgPrice.get(tid, 0):.3f}',
])
return self._dt(cols, rows)
def _build_market_view(self) -> ft.Control:
"""实时价格监控 — 监控配置 + 表格"""
price_input = ft.TextField(value=str(self.monitor_price), width=80, height=32,
text_size=13, content_padding=ft.Padding(4, 0, 4, 0))
confirm_btn = ft.ElevatedButton("确认", on_click=lambda e: self._set_monitor_price(price_input.value), height=32)
self._market_table = self._dt(["时间", "股票名称", "最新价格"], [])
return ft.Column([
ft.Row([
ft.Text("监控配置", size=13), ft.Text("价格", size=13),
price_input, confirm_btn,
]),
ft.Container(content=self._market_table, expand=True),
], expand=True)
def _build_order_view(self) -> ft.Control:
self._order_table = self._dt(
["时间", "代码", "名称", "方向", "委托价", "委托量", "已成交", "均价", "状态"], [],
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])
return self._order_table
def _build_trade_view(self) -> ft.Control:
self._trade_table = self._dt(
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], [],
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])
return self._trade_table
# ══════════════════════════════════════════════════════════════
# 事件回调(对齐 Tkinter onMarketDataUpdated
# ══════════════════════════════════════════════════════════════
def _on_market_data(self, data: dict):
"""行情数据回调 — 来自 QMT 推送"""
need_rebuild = not self._prices_loaded
updated_count = 0
for stock_code, tick in data.items():
plain = _plain(stock_code)
tid = self.stockCodeIdMap.get(plain)
lp = tick.get('lastPrice', 0)
if tid is not None and tid in self.tradeTargetData:
self.targetMarketPrice[tid] = lp
self.tradeTargetData[tid].market_price = lp
updated_count += 1
else:
# 非目标标的:监控价格触发时记录
if lp == self.monitor_price or stock_code in self.listening_stock:
if stock_code not in self.listening_stock:
self.listening_stock.append(stock_code)
t_str = time.strftime("%H:%M:%S")
name = qmtv.getInstrumentName(stock_code)
self.marketData[stock_code] = {'stock_name': name, 'last_price': lp, 'time': t_str}
if need_rebuild and not self._prices_loaded and updated_count > 0:
self._prices_loaded = True
self._rebuild_tables()
self.page.update()
def _on_market_active_switch(self, is_active: bool):
self._market_active = is_active
def _on_strategy_update(self, target):
"""策略数据变更 — 成交后立即刷新表格"""
self._rebuild_tables()
self.page.update()
# ══════════════════════════════════════════════════════════════
# 刷新循环(对齐 Tkinter refresh_loop
# ══════════════════════════════════════════════════════════════
def _pull_prices(self):
"""主动拉取缺失的市价(对齐 Tkinter refresh_loop"""
for tid, t in self.tradeTargetData.items():
if tid not in self.targetMarketPrice or self.targetMarketPrice[tid] == 0:
price = qmtv.getLastPrice(t.stock_code)
if price > 0:
self.targetMarketPrice[tid] = price
t.market_price = price
def _manual_refresh(self):
self._pull_prices()
self._refresh_positions()
self._refresh_orders()
self._refresh_trades()
self._rebuild_tables()
self.page.update()
def _refresh_positions(self):
positions = qmtv.getAllPositions()
for t in self.tradeTargetData.values():
pos = positions.get(t.stock_code)
t.current_position = 0 if pos is None else int(pos.volume)
def _refresh_orders(self):
try:
self._orders = list(qmtv.queryTodayOrders())
except Exception:
pass
def _refresh_trades(self):
try:
self._trades = list(qmtv.queryTodayTrades())
except Exception:
pass
def _rebuild_tables(self):
"""重建所有表格数据"""
self._grid_list.controls = [self._build_grid_table()]
if not self._selected_target:
self._sel_info.value = ""
self._sel_actions.controls = []
self._uncl_list.controls = [self._build_unclassified_table()]
# 委托 — 过滤已撤/废单,按 order_id 去重(保留最后一条即最新状态)
_CANCELED = {54, 57}
o_map = {} # order_id → latest order
for o in self._orders:
oid = str(getattr(o, 'order_id', ''))
if not oid:
continue
o_map[oid] = o # 后面的覆盖前面的
o_rows = []
for o in o_map.values():
st = getattr(o, 'order_status', 0)
if st in _CANCELED:
continue
tv = getattr(o, 'traded_volume', 0) or 0
ov = getattr(o, 'order_volume', 0) or 0
o_rows.append([
_fmt_time(getattr(o, 'order_time', 0)),
_plain(getattr(o, 'stock_code', '')),
getattr(o, 'instrument_name', '') or '',
_direction(getattr(o, 'order_type', 0)),
f"{getattr(o, 'price', 0):.3f}",
f"{tv}/{ov}",
f"{getattr(o, 'traded_price', 0):.3f}" if getattr(o, 'traded_price', 0) > 0 else '-',
_ORDER_STATUS.get(st, '未知'),
])
self._tab_orders.label = f"当前委托 ({len(o_rows)})" if o_rows else "当前委托"
self._order_table.controls = [self._dt(
["时间", "代码", "名称", "方向", "委托价", "已成交/委托量", "均价", "状态"], o_rows,
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])]
# 成交 — 按 traded_id 去重(保留最后一条)
t_map = {}
for t in self._trades:
tid = str(getattr(t, 'traded_id', ''))
if not tid:
continue
t_map[tid] = t
t_rows = []
for t in t_map.values():
t_rows.append([
_fmt_time(getattr(t, 'traded_time', 0)),
_plain(getattr(t, 'stock_code', '')),
getattr(t, 'instrument_name', '') or '',
_direction(getattr(t, 'order_type', 0)),
f"{getattr(t, 'traded_price', 0):.3f}",
str(getattr(t, 'traded_volume', 0)),
f"{getattr(t, 'traded_amount', 0):.2f}",
f"{getattr(t, 'commission', 0):.2f}",
])
self._tab_trades.label = f"当日成交 ({len(t_rows)})" if t_rows else "当日成交"
self._trade_table.controls = [self._dt(
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], t_rows,
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])]
# 市场监控
m_rows = []
for sc, d in self.marketData.items():
m_rows.append([d['time'], f"{d['stock_name']}-{sc}", f"{d['last_price']:.3f}"])
self._market_table.controls = [self._dt(["时间", "股票名称", "最新价格"], m_rows)]
def _refresh_loop(self):
"""后台定时刷新 — 对齐 Tkinter: 5s 拉价 + 30s 委托/成交"""
while True:
time.sleep(5)
self._refresh_cycle += 1
try:
self._pull_prices()
self._refresh_positions()
if self._refresh_cycle % 6 == 0:
self._refresh_orders()
self._refresh_trades()
self._rebuild_tables()
self.page.update()
except Exception:
pass
# ══════════════════════════════════════════════════════════════
# 工具栏按钮
# ══════════════════════════════════════════════════════════════
def _on_start_trade(self, target):
PrintLog(LogLevel.INFO, f'[Flet-按钮] 启动按钮被点击: {target.stock_code}')
if target.enabled:
self._show_toast("该标的正运行中")
return
name = f'{target.stock_code} {target.stock_name}'
dlg = ft.AlertDialog(
title=ft.Text("确认启动"),
content=ft.Text(f"确定要启动交易吗?\n\n{name}"),
actions=[
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
ft.TextButton("确定", on_click=lambda e, t=target: self._do_start(t)),
],
)
self.page.show_dialog(dlg)
def _do_start(self, target):
self.page.pop_dialog()
target.enabled = True
target.save()
from core.sfgrid.sfgrid_strategy import SFGridStrategy
self.strategy_ctrl[target.get_id()] = SFGridStrategy(target)
self._rebuild_tables()
self.page.update()
PrintLog(LogLevel.INFO, f'[Flet] 启动交易: {target.targetName()}')
def _on_stop_trade(self, target):
PrintLog(LogLevel.INFO, f'[Flet-按钮] 暂停按钮被点击: {target.stock_code}')
if not target.enabled:
self._show_toast("该标的已暂停")
return
name = f'{target.stock_code} {target.stock_name}'
dlg = ft.AlertDialog(
title=ft.Text("确认暂停"),
content=ft.Text(f"确定要暂停交易吗?\n\n{name}"),
actions=[
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
ft.TextButton("确定", on_click=lambda e, t=target: self._do_stop(t)),
],
)
self.page.show_dialog(dlg)
def _do_stop(self, target):
self.page.pop_dialog()
target.enabled = False
target.save()
ctrl = self.strategy_ctrl.pop(target.get_id(), None)
if ctrl:
ctrl.enabledTrading(False)
self._rebuild_tables()
self.page.update()
PrintLog(LogLevel.INFO, f'[Flet] 暂停交易: {target.targetName()}')
def _open_grid_config(self, target):
"""网格配置对话框 — 对齐 Tkinter create_grid_config_window"""
PrintLog(LogLevel.INFO, f'[Flet-按钮] 设置按钮被点击: {target.stock_code}')
base = ft.TextField(label="基准价格", value=str(target.grid_start_price), width=120, text_size=13)
gsize = ft.TextField(label="网格大小", value=str(target.grid_size), width=120, text_size=13)
gvol = ft.TextField(label="网格交易量(手)", value=str(target.grid_volume), width=120, text_size=13)
gupper = ft.TextField(label="上方网格数", value=str(target.grid_upper_count), width=120, text_size=13)
glower = ft.TextField(label="下方网格数", value=str(target.grid_lower_count), width=120, text_size=13)
gidx = ft.TextField(label="当前网格层级", value=str(target.grid_index), width=120, text_size=13)
col1 = ft.Column([base, gsize, gvol], spacing=8)
col2 = ft.Column([gupper, glower, gidx], spacing=8)
grid_preview = ft.Text("", size=11, italic=True)
def _preview(e):
try:
bp = float(base.value)
gs = float(gsize.value)
up = int(gupper.value)
lo = int(glower.value)
prices = []
for i in range(up, 0, -1):
prices.append(f"{bp + gs * i:.2f}(卖{up - i + 1})")
prices.append(f"{bp:.2f}←(基准)")
for i in range(1, lo + 1):
p = bp - gs * i
if p > 0:
prices.append(f"{p:.2f}(买{i})")
grid_preview.value = " ".join(prices)
grid_preview.update()
except ValueError:
grid_preview.value = "请输入有效数字"
grid_preview.update()
def _save(e):
try:
target.grid_start_price = float(base.value)
target.grid_size = float(gsize.value)
target.grid_volume = int(gvol.value)
target.grid_upper_count = int(gupper.value)
target.grid_lower_count = int(glower.value)
target.grid_index = int(gidx.value)
target.save()
self._close_dialog()
self._rebuild_tables()
self.page.update()
PrintLog(LogLevel.INFO, f'[Flet] 网格配置已保存: {target.targetName()}')
except ValueError:
self._show_toast("请输入有效的数值")
dlg = ft.AlertDialog(
title=ft.Text(f"网格配置 - {target.stock_code} {target.stock_name}"),
content=ft.Column([
ft.Row([col1, col2], spacing=20),
ft.ElevatedButton("预览网格序列", on_click=_preview),
grid_preview,
], spacing=10, tight=True, height=320),
actions=[
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
ft.ElevatedButton("保存", on_click=_save),
],
)
self.page.show_dialog(dlg)
def _show_toast(self, msg: str):
dlg = ft.AlertDialog(title=ft.Text("提示"), content=ft.Text(msg),
actions=[ft.TextButton("确定", on_click=lambda e: self._close_dialog(dlg))])
self.page.show_dialog(dlg)
def _close_dialog(self, dlg=None):
self.page.pop_dialog()
self.page.update()
def _toggle_overlay(self):
self._drawer_open = not self._drawer_open
self._overlay.visible = self._drawer_open
self._sidebar_icon.set_active(self._drawer_open)
self.page.update()
def _hide_overlay(self):
self._drawer_open = False
self._overlay.visible = False
self._sidebar_icon.set_active(False)
self.page.update()
def _set_monitor_price(self, val: str):
try:
self.monitor_price = float(val)
self.marketData.clear()
self.listening_stock.clear()
self._rebuild_tables()
self.page.update()
except ValueError:
pass
# ══════════════════════════════════════════════════════════════════════
# PanelIcon — 对齐 Tkinter 版 Canvas 手绘图标
# ══════════════════════════════════════════════════════════════════════
class _PanelIcon(ft.Container):
"""VSCode 风格面板切换图标 — 两个色块拼成的分栏图标"""
_SIZE = 22
_M = 3
_COLORS = {
'light': {'bg': '#f0f0f0', 'hover': '#d4d4d4', 'off': '#b0b0b0', 'on': '#808080', 'active': '#0078d4'},
'dark': {'bg': '#3c3c3c', 'hover': '#505050', 'off': '#6a6a6a', 'on': '#a0a0a0', 'active': '#ffffff'},
}
def __init__(self, kind: str, active: bool = True, on_click=None):
self._kind = kind
self._active = active
c = self._COLORS['light'] # 默认亮色,后续可扩展暗色检测
self._bg = c['bg']
self._hover_bg = c['hover']
self._off = c['off']
self._on = c['on']
self._active_color = c['active']
rects = self._build_rects()
super().__init__(
content=rects,
width=self._SIZE, height=self._SIZE,
bgcolor=self._bg, border_radius=3,
ink=True, on_click=on_click,
padding=ft.Padding(self._M, self._M, self._M, self._M),
)
def _build_rects(self):
off, on, act = self._off, self._on, self._active_color
bar_w, bar_h = 6, self._SIZE - self._M * 2 - 2
if self._kind == 'sidebar':
c1 = on if self._active else off
c2 = act if self._active else off
return ft.Row([
ft.Container(width=bar_w, height=bar_h, bgcolor=c1, border_radius=1),
ft.Container(width=2), # gap
ft.Container(width=bar_w, height=bar_h, bgcolor=c2, border_radius=1),
], spacing=0)
else:
c1 = on if self._active else off
c2 = act if self._active else off
return ft.Column([
ft.Container(width=bar_h, height=bar_w, bgcolor=c1, border_radius=1),
ft.Container(height=2), # gap
ft.Container(width=bar_h, height=bar_w, bgcolor=c2, border_radius=1),
], spacing=0)
def set_active(self, active: bool):
self._active = active
self.content = self._build_rects()
# ══════════════════════════════════════════════════════════════════════
# 入口
# ══════════════════════════════════════════════════════════════════════
def main(page: ft.Page):
QmtApp(page)
def run():
ft.app(target=main)
def run_web():
ft.app(target=main, view=ft.AppView.WEB_BROWSER, port=8550)
+863
View File
@@ -0,0 +1,863 @@
"""
Flet UI v2 — 重构版。
分层架构:
DataStore — 纯数据,无 UI 依赖
GridPanel — 网格策略持仓表格 + 选中 + 操作栏
DrawerPanel — 右侧抽屉叠加层 + 四 Tab
TradeDialogs— 启动/暂停/网格配置弹窗
QmtApp — 生命周期协调
"""
import asyncio
import time
import threading
import flet as ft
from core.qmt_real import RealQmtV, qmtv
from core.logger import LogLevel, PrintLog
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
from core.sfgrid.sfgrid_strategy import SFGridStrategy
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
from core.sfgrid.bus_events import EventTradeTargetUpdate
# ── 工具函数 ──
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
def _fmt_time(t) -> str:
if not t: return ''
import datetime
try:
ts = int(t)
if ts > 1e12: ts //= 1000
return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S')
except (ValueError, OSError):
return str(t)
def _direction(ot: int) -> str:
return '' if ot == 23 else '' if ot == 24 else str(ot)
def _plain(code: str) -> str:
return code.split('.')[0] if '.' in code else code
def _text(s, color=None, bold=False, size=None) -> ft.Text:
"""快捷构建 Text"""
return ft.Text(str(s), color=color, weight=ft.FontWeight.BOLD if bold else None, size=size)
def _datatable(cols: list[str], rows: list[list[str]], col_widths: list = None) -> ft.DataTable:
"""构建 DataTable(不包装 ListView"""
data_rows = []
for r in rows:
cells = []
for i, c in enumerate(r):
w = (col_widths or [None] * len(r))[i] if i < len(r) else None
cells.append(ft.DataCell(ft.Text(str(c), overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, width=w)))
data_rows.append(ft.DataRow(cells=cells))
if not data_rows:
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
return ft.DataTable(columns=[ft.DataColumn(ft.Text(h)) for h in cols], rows=data_rows,
width=float('inf'), heading_row_height=36, data_row_min_height=32)
# ══════════════════════════════════════════════════════════════
# DataStore — 纯数据层
# ══════════════════════════════════════════════════════════════
class _DataStore:
"""持仓、行情、订单数据管理,不依赖任何 UI"""
def __init__(self):
self.tradeTargets: dict[int, SFGridTradeTarget] = {}
self.stockCodeIdMap: dict[str, int] = {}
self.marketPrices: dict[int, float] = {} # tid → lastPrice
self.preClose: dict[int, float] = {} # tid → 昨收
self.avgPrices: dict[int, float] = {} # tid → avgPrice
self.orders: list = []
self.trades: list = []
self.strategyCtrl: dict[int, SFGridStrategy] = {}
# 市场监控
self.monitorPrice: float = 10.0
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
self.listeningStocks: list = []
# ── 初始化 ──
def load_from_qmt(self):
"""从 QMT 加载持仓并导入 DB"""
positions = qmtv.getAllPositions()
PrintLog(LogLevel.INFO, f'[Data] 持仓: {len(positions)}')
for code, pos in positions.items():
existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code)
if existing is None:
name = getattr(pos, 'instrument_name', '') or qmtv.getInstrumentName(code)
SFGridTradeTarget.create(
stock_code=code, stock_name=name,
current_position=int(pos.volume),
init_price=float(getattr(pos, 'avg_price', 0) or 0),
grid_index=0, enabled=False,
grid_start_price=float(getattr(pos, 'avg_price', 0) or 0) or 10.0,
grid_size=1.0, grid_volume=200, grid_upper_count=1, grid_lower_count=10,
)
# 映射 (先建立 stockCodeIdMap)
results = list(SFGridTradeTarget.select())
for t in results:
pos = positions.get(t.stock_code)
t.current_position = 0 if pos is None else int(pos.volume)
tid = t.get_id()
self.tradeTargets[tid] = t
self.stockCodeIdMap[t.stock_code] = tid
if pos is not None:
self.avgPrices[tid] = float(getattr(pos, 'avg_price', 0) or 0)
# 昨收(依赖 stockCodeIdMap
self._load_preclose(positions)
def _load_preclose(self, positions: dict):
try:
from xtquant import xtdata
for stock_code in positions:
if '.' not in stock_code:
full_code = f'{stock_code}.SH' if stock_code.startswith(('6','5','9')) else f'{stock_code}.SZ'
else:
full_code = stock_code
detail = xtdata.get_instrument_detail(full_code)
if detail:
pc = detail.get('PreClose', 0) if isinstance(detail, dict) else getattr(detail, 'PreClose', 0)
if pc > 0 and stock_code in self.stockCodeIdMap:
self.preClose[self.stockCodeIdMap[stock_code]] = float(pc)
PrintLog(LogLevel.INFO, f'[Data] 昨收: {len(self.preClose)}')
except Exception as e:
PrintLog(LogLevel.DEBUG, f'[Data] 昨收异常: {e}')
def init_strategies(self):
for tid, t in self.tradeTargets.items():
if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled:
self.strategyCtrl[tid] = SFGridStrategy(t)
# ── 行情 ──
def apply_tick(self, data: dict) -> int:
"""应用行情数据,返回更新的标的数"""
count = 0
for stock_code, tick in data.items():
plain = _plain(stock_code)
tid = self.stockCodeIdMap.get(plain)
lp = tick.get('lastPrice', 0)
if tid is not None and tid in self.tradeTargets:
self.marketPrices[tid] = lp
self.tradeTargets[tid].market_price = lp
count += 1
elif lp == self.monitorPrice or stock_code in self.listeningStocks:
if stock_code not in self.listeningStocks:
self.listeningStocks.append(stock_code)
self.marketLog[stock_code] = {
'stock_name': qmtv.getInstrumentName(stock_code),
'last_price': lp,
'time': time.strftime("%H:%M:%S"),
}
return count
def pull_prices(self):
"""主动拉取缺失的市价"""
for tid, t in self.tradeTargets.items():
if tid not in self.marketPrices or self.marketPrices[tid] == 0:
price = qmtv.getLastPrice(t.stock_code)
if price > 0:
self.marketPrices[tid] = price
t.market_price = price
# ── 持仓 ──
def refresh_positions(self):
positions = qmtv.getAllPositions()
for t in self.tradeTargets.values():
pos = positions.get(t.stock_code)
t.current_position = 0 if pos is None else int(pos.volume)
# ── 委托 / 成交 ──
def refresh_orders(self):
try:
self.orders = list(qmtv.queryTodayOrders())
except Exception:
pass
def refresh_trades(self):
try:
self.trades = list(qmtv.queryTodayTrades())
except Exception:
pass
def active_orders(self) -> list:
"""返回过滤+去重后的活跃委托行"""
_CANCELED = {54, 57}
o_map = {}
for o in self.orders:
oid = str(getattr(o, 'order_id', ''))
if not oid: continue
o_map[oid] = o
rows = []
for o in o_map.values():
st = getattr(o, 'order_status', 0)
if st in _CANCELED: continue
tv = getattr(o, 'traded_volume', 0) or 0
ov = getattr(o, 'order_volume', 0) or 0
rows.append([
_fmt_time(getattr(o, 'order_time', 0)),
_plain(getattr(o, 'stock_code', '')),
getattr(o, 'instrument_name', '') or '',
_direction(getattr(o, 'order_type', 0)),
f"{getattr(o, 'price', 0):.3f}",
f"{tv}/{ov}",
f"{getattr(o, 'traded_price', 0):.3f}" if getattr(o, 'traded_price', 0) > 0 else '-',
_ORDER_STATUS.get(st, '未知'),
])
return rows
def active_trades(self) -> list:
"""返回去重后的成交行"""
t_map = {}
for t in self.trades:
tid = str(getattr(t, 'traded_id', ''))
if not tid: continue
t_map[tid] = t
rows = []
for t in t_map.values():
rows.append([
_fmt_time(getattr(t, 'traded_time', 0)),
_plain(getattr(t, 'stock_code', '')),
getattr(t, 'instrument_name', '') or '',
_direction(getattr(t, 'order_type', 0)),
f"{getattr(t, 'traded_price', 0):.3f}",
str(getattr(t, 'traded_volume', 0)),
f"{getattr(t, 'traded_amount', 0):.2f}",
f"{getattr(t, 'commission', 0):.2f}",
])
return rows
def pending_tags(self, stock_code: str) -> list:
"""某标的挂单方向标签"""
tags = []
for o in self.orders:
if _plain(getattr(o, 'stock_code', '')) != stock_code: continue
if getattr(o, 'order_status', 0) in {54, 57}: continue
ot = getattr(o, 'order_type', 0)
if ot == 23 and '' not in tags: tags.append('')
elif ot == 24 and '' not in tags: tags.append('')
return tags
# ══════════════════════════════════════════════════════════════
# GridPanel — 网格策略持仓表格
# ══════════════════════════════════════════════════════════════
class _GridPanel:
"""网格策略持仓面板 — 自定义 Row 表格,行内按钮直接可用"""
def __init__(self, data: _DataStore, dialogs=None):
self._data = data
self._dialogs = dialogs # _TradeDialogs
self._col = None
@property
def controls(self):
return self._col.controls if self._col else []
def build(self) -> ft.Control:
self._col = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
self._rebuild()
return self._col
def _rebuild(self):
# 列: (宽度/expand, ...) expand为比重,无则固定宽
_C = [(40, 0), (0, 2), (80, 2), (0, 2), (55, 1), (65, 1), (75, 1), (100, 0)]
H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "状态", "操作"]
header = []
for h, (w, e) in zip(H, _C):
if e > 0:
header.append(ft.Container(_text(h, bold=True), padding=4, expand=e))
else:
header.append(ft.Container(_text(h, bold=True), width=w, padding=4))
rows = [ft.Row(header, spacing=0), ft.Divider(height=1, color='#e0e0e0')]
for tid, t in self._data.tradeTargets.items():
if t.strategy_type != STRATEGY_TYPE_GRID: continue
pg = t.getPriceGrid()
idx = t.grid_index
base = pg[idx] if 0 <= idx < len(pg) else 0
mp = self._data.marketPrices.get(tid, 0) or 0
pc = self._data.preClose.get(tid, 0) or 0
pcolor = '#CC0000' if mp > pc > 0 else '#009900' if mp < pc and mp > 0 else None
# 网格基准列
gcells = [_text(f'{base:.2f}', bold=True, )]
if mp > base > 0:
gcells.append(_text('', color='#CC0000', bold=True, ))
elif 0 < mp < base:
gcells.append(_text('', color='#009900', bold=True, ))
for tag in self._data.pending_tags(t.stock_code):
gcells.append(ft.Container(
_text(tag, color='white', bold=True, size=10),
bgcolor='#E67E22' if tag == '' else '#3498DB',
border_radius=4, padding=ft.Padding(3, 1, 3, 1),
))
gcell = ft.Row(gcells, spacing=3) if len(gcells) > 1 else gcells[0]
# 操作按钮 — IconButton 图标居中,无点击偏移
if t.enabled:
btns = [
ft.IconButton(ft.Icons.PAUSE, icon_size=18, tooltip="暂停",
on_click=lambda e, tt=t: self._dialogs.confirm_stop(tt)),
]
else:
btns = [
ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=18, tooltip="启动",
on_click=lambda e, tt=t: self._dialogs.confirm_start(tt)),
]
btns.append(ft.IconButton(ft.Icons.SETTINGS, icon_size=18, tooltip="设置",
icon_color='#BDBDBD' if t.enabled else None,
on_click=lambda e, tt=t: self._dialogs.open_config(tt)))
cells_text = [str(tid), f'{t.stock_code} {t.stock_name}',
f'{mp:.3f}', gcell,
str(t.current_position),
f'{self._data.avgPrices.get(tid, 0):.3f}',
'▶运行中' if t.enabled else '⏸已暂停',
btns]
row_cells = []
for i, (w, e) in enumerate(_C):
content = cells_text[i]
if i == 2: # 市场价用颜色
content = _text(cells_text[i], color=pcolor, bold=True)
elif isinstance(content, str):
content = _text(content)
elif isinstance(content, list):
content = ft.Row(content, spacing=2)
if e > 0:
row_cells.append(ft.Container(content, padding=4, expand=e))
else:
row_cells.append(ft.Container(content, width=w, padding=4 if i != 7 else 2))
row = ft.Row(row_cells, spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
rows.append(ft.Divider(height=1, color='#f0f0f0'))
self._col.controls = rows
# ══════════════════════════════════════════════════════════════
# DrawerPanel — 右侧抽屉面板
# ══════════════════════════════════════════════════════════════
class _DrawerPanel:
"""右侧叠加抽屉:市场监控 + 委托 + 成交 + 未分类"""
def __init__(self, data: _DataStore, dialogs=None):
self._data = data
self._dialogs = dialogs
self._tab_orders = ft.Tab(label="当前委托")
self._tab_trades = ft.Tab(label="当日成交")
self._order_table = None
self._trade_table = None
self._uncl_col = None
self._market_table = None
def build(self) -> ft.Control:
"""返回 overlay Container"""
self._uncl_col = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
self._order_table = ft.ListView(expand=True)
self._trade_table = ft.ListView(expand=True)
self._market_table = ft.ListView(expand=True)
bar = ft.TabBar(tabs=[
ft.Tab(label="实时价格监控"),
self._tab_orders,
self._tab_trades,
ft.Tab(label="未分类持仓"),
])
view = ft.TabBarView(controls=[
self._build_market_tab(),
self._order_table,
self._trade_table,
self._uncl_col,
], expand=True)
panel = ft.Container(
ft.Column([
ft.Container(_text("监控面板", bold=True, size=14), padding=ft.Padding(10, 10, 10, 5)),
ft.Tabs(ft.Column([bar, view], expand=True), length=4, expand=True),
], expand=True),
width=700, bgcolor=ft.Colors.SURFACE,
)
backdrop = ft.Container(bgcolor='#44000000', expand=True)
return ft.Container(ft.Row([backdrop, panel], spacing=0), visible=False, expand=True)
def _build_market_tab(self) -> ft.Control:
price_input = ft.TextField(value=str(self._data.monitorPrice), width=80, height=32, text_size=13)
return ft.Column([
ft.Row([_text("监控配置", size=13), _text("价格", size=13), price_input,
ft.ElevatedButton("确认", on_click=lambda e: self._set_monitor(price_input.value), height=32)]),
ft.Container(content=self._market_table, expand=True),
], expand=True)
def _set_monitor(self, val: str):
try:
self._data.monitorPrice = float(val)
self._data.marketLog.clear()
self._data.listeningStocks.clear()
except ValueError:
pass
# ── 刷新 ──
def refresh_all(self):
self._refresh_grid()
self._refresh_orders()
self._refresh_trades()
self._refresh_market()
def _refresh_grid(self):
W = [35, 180, 80, 60, 70, 65] # ID, 股票, 市场价, 持仓, 成本, 操作
H = ["ID", "股票", "市场价", "持仓", "成本", "操作"]
rows = [
ft.Row([ft.Container(_text(h, bold=True), width=w, padding=4)
for h, w in zip(H, W)], spacing=0),
ft.Divider(height=1, color='#e0e0e0'),
]
for tid, t in self._data.tradeTargets.items():
if t.strategy_type == STRATEGY_TYPE_GRID: continue
mp = self._data.marketPrices.get(tid, 0) or 0
row = ft.Row([
ft.Container(_text(str(tid)), width=W[0], padding=4),
ft.Container(_text(f'{t.stock_code} {t.stock_name}'), width=W[1], padding=4),
ft.Container(_text(f'{mp:.3f}'), width=W[2], padding=4),
ft.Container(_text(str(t.current_position)), width=W[3], padding=4),
ft.Container(_text(f'{self._data.avgPrices.get(tid, 0):.3f}'), width=W[4], padding=4),
ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=18, tooltip="网格配置",
on_click=lambda e, tt=t: self._dialogs.open_config(tt)),
width=W[5], padding=0),
], spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
rows.append(ft.Divider(height=1, color='#f0f0f0'))
self._uncl_col.controls = rows
def _refresh_orders(self):
rows = self._data.active_orders()
self._tab_orders.label = f"当前委托 ({len(rows)})" if rows else "当前委托"
cols = ["时间", "代码", "名称", "方向", "委托价", "已成交/委托量", "均价", "状态"]
self._order_table.controls = [ft.ListView(
[_datatable(cols, rows, [65,55,70,35,60,80,55,50])], expand=True)]
def _refresh_trades(self):
rows = self._data.active_trades()
self._tab_trades.label = f"当日成交 ({len(rows)})" if rows else "当日成交"
cols = ["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"]
self._trade_table.controls = [ft.ListView(
[_datatable(cols, rows, [65,55,70,35,65,60,70,55])], expand=True)]
def _refresh_market(self):
rows = [[d['time'], f"{d['stock_name']}-{sc}", f"{d['last_price']:.3f}"]
for sc, d in self._data.marketLog.items()]
self._market_table.controls = [ft.ListView(
[_datatable(["时间", "股票名称", "最新价格"], rows)], expand=True)]
# ══════════════════════════════════════════════════════════════
# ActionBar — 选中行后的操作按钮栏
# ══════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
# SplashScreen
# ══════════════════════════════════════════════════════════════
class _SplashScreen:
def __init__(self, page: ft.Page):
self._bar = ft.ProgressBar(width=340, value=0, color='#0078d4')
self._status = ft.Text("正在初始化...", size=13)
self._page = page
page.add(ft.Container(
ft.Container(
ft.Column([
_text("神之一手", color='#0078d4', bold=True, size=22),
_text("交易系统", color='#666666', size=14),
ft.Container(height=20),
self._status,
ft.Container(height=8),
self._bar,
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
width=380, height=200,
bgcolor=ft.Colors.SURFACE, border_radius=12,
shadow=ft.BoxShadow(blur_radius=20, color='#20000000'),
alignment=ft.Alignment.CENTER,
),
alignment=ft.Alignment.CENTER, expand=True, bgcolor='#F5F5F5',
))
page.update()
def progress(self, text: str, pct: float):
self._status.value = text
self._bar.value = pct
self._page.update()
# ══════════════════════════════════════════════════════════════
# TradeDialogs
# ══════════════════════════════════════════════════════════════
class _TradeDialogs:
"""启动/暂停/网格配置弹窗"""
def __init__(self, page: ft.Page, data: _DataStore, on_done):
self._page = page
self._data = data
self._on_done = on_done
def confirm_start(self, target):
dlg = ft.AlertDialog(
title=_text("确认启动"),
content=_text(f"确定要启动交易吗?\n\n{target.stock_code} {target.stock_name}"),
actions=[
ft.TextButton("取消", on_click=lambda e: self._close()),
ft.TextButton("确定", on_click=lambda e, t=target: self._do_start(t)),
],
)
self._page.show_dialog(dlg)
def confirm_stop(self, target):
dlg = ft.AlertDialog(
title=_text("确认暂停"),
content=_text(f"确定要暂停交易吗?\n\n{target.stock_code} {target.stock_name}"),
actions=[
ft.TextButton("取消", on_click=lambda e: self._close()),
ft.TextButton("确定", on_click=lambda e, t=target: self._do_stop(t)),
],
)
self._page.show_dialog(dlg)
def open_config(self, target):
if target.enabled:
dlg = ft.AlertDialog(title=_text("提示"), content=_text("运行中的策略无法修改参数,请先暂停"))
self._page.show_dialog(dlg)
# 1.5 秒后自动关闭
import asyncio
async def _auto_close():
await asyncio.sleep(1.5)
self._page.pop_dialog()
self._page.update()
asyncio.ensure_future(_auto_close())
return
PrintLog(LogLevel.INFO, f'[Flet-v2] 打开网格配置: {target.stock_code}')
def _row(label, value, unit=''):
inp = ft.TextField(value=str(value), width=120, height=30, text_size=13,
content_padding=ft.Padding(6, 0, 6, 0))
return ft.Row([_text(label, size=12),
inp,
_text(unit, color='#888888', size=12) if unit else ft.Container(width=0)],
spacing=6, alignment=ft.MainAxisAlignment.START)
r1 = _row("基准价格:", target.grid_start_price, "")
r2 = _row("网格大小:", target.grid_size, "")
r3 = _row("网格交易量:", target.grid_volume, "")
r4 = _row("上方网格数量:", target.grid_upper_count, "")
r5 = _row("下方网格数量:", target.grid_lower_count, "")
r6 = _row("当前网格层级:", target.grid_index, "")
all_rows = [r1, r2, r3, r4, r5, r6]
inputs = [r.controls[1] for r in all_rows] # index 1 is the TextField
preview = ft.Text("", size=11, italic=True)
def _refresh_preview(e=None):
try:
bp, gs = float(inputs[0].value), float(inputs[1].value)
up, lo = int(inputs[3].value), int(inputs[4].value)
parts = []
for i in range(up, 0, -1): parts.append(f"{bp + gs * i:.2f}(卖{up - i + 1})")
parts.append(f"{bp:.2f}←(基准)")
for i in range(1, lo + 1):
p = bp - gs * i
if p > 0: parts.append(f"{p:.2f}(买{i})")
preview.value = " ".join(parts) or ""
except ValueError:
preview.value = ""
preview.update()
# 实时计算:四个关键字段绑定 on_change
for idx in (0, 1, 3, 4):
inputs[idx].on_change = _refresh_preview
def _save(e):
try:
target.grid_start_price = float(inputs[0].value)
target.grid_size = float(inputs[1].value)
target.grid_volume = int(inputs[2].value)
target.grid_upper_count = int(inputs[3].value)
target.grid_lower_count = int(inputs[4].value)
target.grid_index = int(inputs[5].value)
target.save()
self._close()
self._on_done()
PrintLog(LogLevel.INFO, f'[Flet] 网格配置已保存: {target.targetName()}')
except ValueError:
pass
info = ft.Column([
ft.Row([_text("股票代码:", bold=True, size=12), _text(target.stock_code, size=12)]),
ft.Row([_text("股票名称:", bold=True, size=12), _text(target.stock_name, size=12)]),
], spacing=2)
self._page.show_dialog(ft.AlertDialog(
title=_text(f"网格配置 - {target.stock_code} ({target.stock_name})"),
content=ft.Column([
ft.Container(info, bgcolor='#F5F5F5', padding=10, border_radius=6),
ft.Divider(height=1, color='#e0e0e0'),
*all_rows,
preview,
], spacing=8, tight=True, height=420, scroll=ft.ScrollMode.AUTO),
actions=[ft.TextButton("取消", on_click=lambda e: self._close()),
ft.ElevatedButton("保存", on_click=_save)],
))
def toast(self, msg: str):
self._page.show_dialog(ft.AlertDialog(
title=_text("提示"), content=_text(msg),
actions=[ft.TextButton("确定", on_click=lambda e: self._close())],
))
def _do_start(self, target):
self._close()
target.enabled = True
target.save()
self._data.strategyCtrl[target.get_id()] = SFGridStrategy(target)
self._on_done()
PrintLog(LogLevel.INFO, f'[Flet] 启动交易: {target.targetName()}')
def _do_stop(self, target):
self._close()
target.enabled = False
target.save()
ctrl = self._data.strategyCtrl.pop(target.get_id(), None)
if ctrl: ctrl.enabledTrading(False)
self._on_done()
PrintLog(LogLevel.INFO, f'[Flet] 暂停交易: {target.targetName()}')
def _close(self):
self._page.pop_dialog()
self._page.update()
# ══════════════════════════════════════════════════════════════
# QmtApp v2
# ══════════════════════════════════════════════════════════════
class QmtApp:
"""Flet 版 QMT 交易界面 v2"""
def __init__(self, page: ft.Page):
self.page = page
self.page.title = "神之一手"
self.page.window.width = 1400
self.page.window.height = 800
self.page.padding = 0
self._data = _DataStore()
self._dialogs = _TradeDialogs(page, self._data, self._refresh_ui)
self._grid = _GridPanel(self._data, dialogs=self._dialogs)
self._drawer = _DrawerPanel(self._data, dialogs=self._dialogs)
self._prices_loaded = False
self._run_startup()
# ══════════════════════════════════════════════════════════
# 启动
# ══════════════════════════════════════════════════════════
def _run_startup(self):
splash = _SplashScreen(self.page)
asyncio.ensure_future(self._do_startup(splash))
async def _do_startup(self, splash: _SplashScreen):
await asyncio.sleep(0.05)
steps = [
("正在检查 QMT 环境...", 0.10, lambda: RealQmtV._discover_qmt_port() or True),
("正在初始化交易器...", 0.35, lambda: qmtv.init_qmtv()),
("正在连接 QMT...", 0.55, lambda: qmtv.connect() or True),
("正在加载持仓数据...", 0.75, lambda: self._data.load_from_qmt()),
("正在构建界面...", 0.85, lambda: None),
("正在初始化策略...", 0.92, lambda: self._data.init_strategies()),
]
for text, pct, action in steps:
splash.progress(text, pct)
try:
if action() is False:
self._show_error(f"启动失败: {text}"); return
except Exception as e:
self._show_error(f"启动异常: {text}\n{e}"); return
splash.progress("启动完成", 1.0)
await asyncio.sleep(0.3)
self.page.clean()
self._build_main_ui()
self.page.update()
self._data.pull_prices()
self._data.refresh_orders()
self._data.refresh_trades()
self._refresh_ui()
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
event_bus.subscribe(EventTradeTargetUpdate, lambda t: self._refresh_ui())
threading.Thread(target=self._refresh_loop, daemon=True).start()
def _show_error(self, msg: str):
self.page.clean()
self.page.add(ft.Container(
ft.Column([
ft.Icon(ft.Icons.ERROR_OUTLINE, size=48, color=ft.Colors.RED),
_text(msg, size=16),
ft.ElevatedButton("重试", on_click=lambda e: self._retry()),
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
alignment=ft.Alignment.CENTER, expand=True,
))
self.page.update()
def _retry(self):
self.page.clean()
self._run_startup()
# ══════════════════════════════════════════════════════════
# UI 构建
# ══════════════════════════════════════════════════════════
def _build_main_ui(self):
self._sidebar_icon = _PanelIcon('sidebar', active=False,
on_click=lambda e: self._toggle_drawer())
self._overlay = self._drawer.build()
title = ft.Container(
ft.Row([
_text("网格策略持仓", bold=True, size=13),
ft.Row([
ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=18,
on_click=lambda e: self._manual_refresh()),
self._sidebar_icon,
], spacing=0),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
padding=ft.Padding(10, 10, 10, 5),
)
grid_body = ft.Container(content=self._grid.build(), expand=True,
padding=ft.Padding(10, 0, 10, 10))
self.page.add(ft.Column([
title, ft.Stack([grid_body, self._overlay], expand=True),
], expand=True))
# ══════════════════════════════════════════════════════════
# UI 刷新
# ══════════════════════════════════════════════════════════
def _refresh_ui(self):
self._grid._rebuild()
self._drawer.refresh_all()
self.page.update()
def _manual_refresh(self):
self._data.pull_prices()
self._data.refresh_positions()
self._data.refresh_orders()
self._data.refresh_trades()
self._refresh_ui()
def _toggle_drawer(self):
self._drawer_open = not getattr(self, '_drawer_open', False)
self._overlay.visible = self._drawer_open
self._sidebar_icon.set_active(self._drawer_open)
self.page.update()
def _hide_overlay(self):
self._drawer_open = False
self._overlay.visible = False
self._sidebar_icon.set_active(False)
self.page.update()
# ══════════════════════════════════════════════════════════
# 事件
# ══════════════════════════════════════════════════════════
def _on_market_data(self, data: dict):
count = self._data.apply_tick(data)
if not self._prices_loaded and count > 0:
self._prices_loaded = True
self._refresh_ui()
def _on_market_active(self, is_active: bool):
pass
# ══════════════════════════════════════════════════════════
# 后台刷新
# ══════════════════════════════════════════════════════════
def _refresh_loop(self):
cycle = 0
while True:
time.sleep(5)
cycle += 1
try:
self._data.pull_prices()
self._data.refresh_positions()
if cycle % 6 == 0:
self._data.refresh_orders()
self._data.refresh_trades()
self._refresh_ui()
except Exception:
pass
# ══════════════════════════════════════════════════════════════
# PanelIcon
# ══════════════════════════════════════════════════════════════
class _PanelIcon(ft.Container):
_SIZE = 22; _M = 3
_C = {'bg': '#f0f0f0', 'off': '#b0b0b0', 'on': '#808080', 'active': '#0078d4'}
def __init__(self, kind: str, active: bool = True, on_click=None):
self._kind = kind
self._active = active
super().__init__(content=self._rects(), width=self._SIZE, height=self._SIZE,
bgcolor=self._C['bg'], border_radius=3, ink=True,
on_click=on_click, padding=ft.Padding(self._M, self._M, self._M, self._M))
def _rects(self):
o, n, a = self._C['off'], self._C['on'], self._C['active']
bw, bh = 6, self._SIZE - self._M * 2 - 2
if self._kind == 'sidebar':
return ft.Row([
ft.Container(width=bw, height=bh, bgcolor=n if self._active else o, border_radius=1),
ft.Container(width=2),
ft.Container(width=bw, height=bh, bgcolor=a if self._active else o, border_radius=1),
], spacing=0)
return ft.Column([
ft.Container(width=bh, height=bw, bgcolor=n if self._active else o, border_radius=1),
ft.Container(height=2),
ft.Container(width=bh, height=bw, bgcolor=a if self._active else o, border_radius=1),
], spacing=0)
def set_active(self, active: bool):
self._active = active
self.content = self._rects()
# ══════════════════════════════════════════════════════════════
# 入口
# ══════════════════════════════════════════════════════════════
def main(page: ft.Page):
QmtApp(page)
def run():
ft.app(target=main)
View File
@@ -1,7 +1,7 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from core.logger import LogLevel, LogData, PrintLog from core.logger import LogLevel, LogData, PrintLog
from core.sfgrid.sfgrid_ui import TradeTargetUI from core.ui.tkinter.sfgrid_view import TradeTargetUI
# 检测运行环境,决定使用真实或模拟 QMT # 检测运行环境,决定使用真实或模拟 QMT
def get_qmt_module(): def get_qmt_module():
@@ -20,7 +20,7 @@ from core.eventbus import event_bus as eBus
class MainWindow: class MainWindow:
def __init__(self, configLogLevel:str): def __init__(self, configLogLevel:str, progress=None):
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("神之一手 - 交易系统") self.root.title("神之一手 - 交易系统")
self.root.geometry("1400x700") self.root.geometry("1400x700")
@@ -31,12 +31,12 @@ class MainWindow:
self.strategy_frames = {} self.strategy_frames = {}
# 日志面板可见性标志 # 日志面板可见性标志
self.log_visible = False self.log_visible = False
self.create_ui() self.create_ui(progress)
eBus.subscribe(EventPrintLog, self.on_log_event) eBus.subscribe(EventPrintLog, self.on_log_event)
def create_ui(self): def create_ui(self, progress=None):
"""创建UI界面""" """创建UI界面"""
# 主容器 # 主容器
main_container = ttk.Frame(self.root) main_container = ttk.Frame(self.root)
@@ -52,7 +52,7 @@ class MainWindow:
# 创建策略Frame # 创建策略Frame
strategy_names = ["网格"] strategy_names = ["网格"]
self.create_strategy_frames(strategy_names) self.create_strategy_frames(strategy_names, progress)
# 创建全局日志面板(默认隐藏) # 创建全局日志面板(默认隐藏)
self.create_global_log_panel(main_container) self.create_global_log_panel(main_container)
@@ -111,9 +111,9 @@ class MainWindow:
for item in self.log_table.get_children(): for item in self.log_table.get_children():
self.log_table.delete(item) self.log_table.delete(item)
def create_strategy_frames(self, strategy_names): def create_strategy_frames(self, strategy_names, progress=None):
"""创建各个策略的Frame""" """创建各个策略的Frame"""
frame = TradeTargetUI(self.content_container) frame = TradeTargetUI(self.content_container, progress=progress)
self.strategy_frames[0] = frame self.strategy_frames[0] = frame
def show_strategy_frame(self, index): def show_strategy_frame(self, index):
@@ -14,7 +14,9 @@ from core.sfgrid.sfgrid_strategy import SFGridStrategy
class TradeTargetUI(ttk.Frame): class TradeTargetUI(ttk.Frame):
def __init__(self, parent): def __init__(self, parent, progress=None):
import time as _time
_t0 = _time.time()
super().__init__(parent) super().__init__(parent)
self.tradeTargetData:dict[int, SFGridTradeTarget] = {} # id->trade_target self.tradeTargetData:dict[int, SFGridTradeTarget] = {} # id->trade_target
self.stockCodeIdMap:dict[str, int] = {} self.stockCodeIdMap:dict[str, int] = {}
@@ -27,41 +29,48 @@ class TradeTargetUI(ttk.Frame):
# 追踪最后点击的表格 (0=网格, 1=未分类) # 追踪最后点击的表格 (0=网格, 1=未分类)
self._active_table = 0 self._active_table = 0
if progress:
progress("正在加载持仓数据...", 0.3)
self.init_trade_target_pool() self.init_trade_target_pool()
# 市场监控数据 # 市场监控数据
self.marketData: dict[str, Any] = {} # 存储市场数据 {stock_code: {stock_name, last_price, time}} self.marketData: dict[str, Any] = {} # 存储市场数据 {stock_code: {stock_name, last_price, time}}
# 市场监控窗口显示状态 # 面板显示状态
self.market_monitor_visible = True self.market_monitor_visible = True
self.bottom_panel_visible = True
# 市场活跃状态 + 刷新控制 # 市场活跃状态 + 刷新控制
self._market_active = qmtv.isMarketActive # type: ignore self._market_active = qmtv.isMarketActive # type: ignore
self._refresh_event = threading.Event() self._refresh_event = threading.Event()
self._refresh_cycle = 0 # 后台刷新周期计数
self._prices_pulled_after_close = False # 收盘后是否已拉取过 self._prices_pulled_after_close = False # 收盘后是否已拉取过
if progress:
progress("正在构建界面...", 0.6)
# 创建界面 # 创建界面
self.create_ui() self.create_ui()
if progress:
progress("正在初始化策略...", 0.85)
eBus.event_bus.subscribe(eBus.MarketDataUpdate, self.onMarketDataUpdated) eBus.event_bus.subscribe(eBus.MarketDataUpdate, self.onMarketDataUpdated)
eBus.event_bus.subscribe(eBus.EventMarketActiveSwitch, self._on_market_active_switch) eBus.event_bus.subscribe(eBus.EventMarketActiveSwitch, self._on_market_active_switch)
eBus.event_bus.subscribe(bus_events.EventTradeTargetUpdate, self.onStrategyUpdate) eBus.event_bus.subscribe(bus_events.EventTradeTargetUpdate, self.onStrategyUpdate)
eBus.event_bus.subscribe(bus_events.EventTradeTargetDeleted, self.onTradeTargetDeleted) eBus.event_bus.subscribe(bus_events.EventTradeTargetDeleted, self.onTradeTargetDeleted)
print(f'[计时] TradeTargetUI.__init__ 总计: {_time.time() - _t0:.2f}s')
def init_trade_target_pool(self): def init_trade_target_pool(self):
# 一次性迁移: 已配置过的标的 (status >= 0) → 网格策略 import time as _time
from core.sfgrid.model import STRATEGY_TYPE_GRID _t = _time.time()
migrated = SFGridTradeTarget.update(strategy_type=STRATEGY_TYPE_GRID).where(
SFGridTradeTarget.status >= 0
).execute()
if migrated:
PrintLog(LogLevel.INFO, f'- [迁移] {migrated} 个已配置标的标记为网格策略')
# 一次性从 QMT 获取全部持仓 # 一次性从 QMT 获取全部持仓
all_positions = qmtv.getAllPositions() all_positions = qmtv.getAllPositions()
PrintLog(LogLevel.INFO, f'- [持仓] 从 QMT 获取到 {len(all_positions)} 个持仓') PrintLog(LogLevel.INFO, f'- [持仓] 从 QMT 获取到 {len(all_positions)} 个持仓')
print(f'[计时] └─ getAllPositions: {_time.time() - _t:.2f}s')
# 自动将 QMT 持仓导入到数据库(持仓但未在交易池中的标的) # 自动将 QMT 持仓导入到数据库(持仓但未在交易池中的标的)
_t2 = _time.time()
imported_count = 0 imported_count = 0
for code, pos in all_positions.items(): for code, pos in all_positions.items():
existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code) existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code)
@@ -77,7 +86,6 @@ class TradeTargetUI(ttk.Frame):
init_price=avg_price, init_price=avg_price,
grid_match_count=0, grid_match_count=0,
grid_total_profit=0.0, grid_total_profit=0.0,
status=-1,
enabled=False, enabled=False,
grid_start_price=avg_price if avg_price > 0 else 10.0, grid_start_price=avg_price if avg_price > 0 else 10.0,
grid_size=1.0, grid_size=1.0,
@@ -89,7 +97,9 @@ class TradeTargetUI(ttk.Frame):
PrintLog(LogLevel.INFO, f'- [导入] QMT持仓 → 交易池: {code} {name} 持仓:{volume} 成本:{avg_price:.4f}') PrintLog(LogLevel.INFO, f'- [导入] QMT持仓 → 交易池: {code} {name} 持仓:{volume} 成本:{avg_price:.4f}')
if imported_count: if imported_count:
PrintLog(LogLevel.INFO, f'- [导入] 共新增 {imported_count} 个标的到交易池') PrintLog(LogLevel.INFO, f'- [导入] 共新增 {imported_count} 个标的到交易池')
print(f'[计时] └─ 持仓导入DB: {_time.time() - _t2:.2f}s ({imported_count} 个新增)')
_t3 = _time.time()
results = SFGridTradeTarget.select() results = SFGridTradeTarget.select()
for temp in results: for temp in results:
tradeTarget:SFGridTradeTarget = temp tradeTarget:SFGridTradeTarget = temp
@@ -104,6 +114,8 @@ class TradeTargetUI(ttk.Frame):
PrintLog(LogLevel.DEBUG, f'- [DEBUG] updateTradeTarget 前: {tradeTarget.stock_code} grid_index={tradeTarget.grid_index}, status={tradeTarget.status}, enabled={tradeTarget.enabled}') PrintLog(LogLevel.DEBUG, f'- [DEBUG] updateTradeTarget 前: {tradeTarget.stock_code} grid_index={tradeTarget.grid_index}, status={tradeTarget.status}, enabled={tradeTarget.enabled}')
self.updateTradeTarget(tradeTarget, True) # 初始化的时候 self.updateTradeTarget(tradeTarget, True) # 初始化的时候
PrintLog(LogLevel.DEBUG, f'- [DEBUG] updateTradeTarget 后: {tradeTarget.stock_code} grid_index={tradeTarget.grid_index}') PrintLog(LogLevel.DEBUG, f'- [DEBUG] updateTradeTarget 后: {tradeTarget.stock_code} grid_index={tradeTarget.grid_index}')
print(f'[计时] └─ 策略初始化: {_time.time() - _t3:.2f}s ({len(results)} 个标的)')
print(f'[计时] └─ init_trade_target_pool 总计: {_time.time() - _t:.2f}s')
PrintLog(LogLevel.INFO, f'- [成功]交易标的信息初始化, 共 {len(self.tradeTargetData)} 个标的') PrintLog(LogLevel.INFO, f'- [成功]交易标的信息初始化, 共 {len(self.tradeTargetData)} 个标的')
@@ -111,14 +123,14 @@ class TradeTargetUI(ttk.Frame):
# 收集所有市场数据用于市场监控 # 收集所有市场数据用于市场监控
def onMarketDataUpdated(self, data): def onMarketDataUpdated(self, data):
for stock_code, tickData in data.items(): for stock_code, tickData in data.items():
if stock_code in self.stockCodeIdMap: # 统一去掉后缀用于内部查找
id:int = self.stockCodeIdMap[stock_code] plain_code = stock_code.split('.')[0] if '.' in stock_code else stock_code
if plain_code in self.stockCodeIdMap:
id:int = self.stockCodeIdMap[plain_code]
self.targetMarketPrice[id] = tickData['lastPrice'] self.targetMarketPrice[id] = tickData['lastPrice']
tradeTarget = self.tradeTargetData[id] tradeTarget = self.tradeTargetData[id]
# timeStr = datetime.fromtimestamp(tickData['time']/1000)
lastPrice = float("{:.3f}".format(tickData['lastPrice'])) lastPrice = float("{:.3f}".format(tickData['lastPrice']))
tradeTarget.market_price = lastPrice # type: ignore tradeTarget.market_price = lastPrice # type: ignore
# PrintLog(LogLevel.INFO, f'|- 市价更新[{tradeTarget.targetName()}] - {timeStr.strftime("%H:%M:%S")} 市价更新: {lastPrice}======================{id}')
self.updateTradeTarget(tradeTarget, False) # 市价更新 self.updateTradeTarget(tradeTarget, False) # 市价更新
else: else:
# 非目标交易,发布市场数据更新事件用于市场监控 # 非目标交易,发布市场数据更新事件用于市场监控
@@ -141,6 +153,8 @@ class TradeTargetUI(ttk.Frame):
def onStrategyUpdate(self, target: SFGridTradeTarget): def onStrategyUpdate(self, target: SFGridTradeTarget):
id = target.get_id() id = target.get_id()
self.tradeTargetData[id] = target self.tradeTargetData[id] = target
# 唤醒 refresh_loop 立即刷新表格,统一由 refresh_table 处理
self._refresh_event.set()
# priceChange 用于控制是否对更新价格数据,进行交易判断 # priceChange 用于控制是否对更新价格数据,进行交易判断
@@ -170,16 +184,21 @@ class TradeTargetUI(ttk.Frame):
# UI CREATE # UI CREATE
def create_ui(self): def create_ui(self):
"""创建UI界面""" """创建UI界面"""
import time as _time
_t = _time.time()
# 主框架(使用self作为父容器) # 主框架(使用self作为父容器)
main_frame = ttk.Frame(self) main_frame = ttk.Frame(self)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 表格区域(左右布局:左侧=工具栏+持仓表格,右侧=Notebook标签页) # 表格区域(左右布局:左侧=工具栏+持仓表格,右侧=Notebook标签页)
self.create_tables_area(main_frame) self.create_tables_area(main_frame)
print(f'[计时] └─ create_tables_area: {_time.time() - _t:.2f}s')
# 启动刷新线程 # 启动刷新线程
self.refresh_thread = threading.Thread(target=self.refresh_loop, daemon=True) self.refresh_thread = threading.Thread(target=self.refresh_loop, daemon=True)
self.refresh_thread.start() self.refresh_thread.start()
print(f'[计时] └─ create_ui 总计: {_time.time() - _t:.2f}s')
def _on_market_active_switch(self, is_active: bool): def _on_market_active_switch(self, is_active: bool):
@@ -206,6 +225,13 @@ class TradeTargetUI(ttk.Frame):
self.after(0, self.refresh_table) self.after(0, self.refresh_table)
self.after(0, self.populate_market_table) self.after(0, self.populate_market_table)
# 每 6 个周期 (30s) 自动刷新委托/成交
self._refresh_cycle += 1
if self._refresh_cycle % 6 == 0:
self.after(0, self._refresh_orders)
self.after(0, self._refresh_trades)
self._refresh_event.wait(timeout=5) self._refresh_event.wait(timeout=5)
self._refresh_event.clear() self._refresh_event.clear()
@@ -263,8 +289,16 @@ class TradeTargetUI(ttk.Frame):
command=self.btnHandlerStopSelectedTrade, width=12).pack(side=tk.LEFT, padx=2) command=self.btnHandlerStopSelectedTrade, width=12).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar_frame, text="🛠 交易设置", ttk.Button(toolbar_frame, text="🛠 交易设置",
command=self.btnHandlerTradeSettings, width=12).pack(side=tk.LEFT, padx=2) command=self.btnHandlerTradeSettings, width=12).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar_frame, text="▣ 边栏",
command=self.btnHandlerToggleMarketMonitor, width=8).pack(side=tk.RIGHT, padx=2) # 右上角 VSCode 风格图标按钮
sidebar_btn = PanelIcon(toolbar_frame, 'sidebar', self.btnHandlerToggleMarketMonitor,
active=self.market_monitor_visible)
sidebar_btn.pack(side=tk.RIGHT, padx=1, pady=2)
bottom_btn = PanelIcon(toolbar_frame, 'bottom', self._toggle_bottom_panel,
active=self.bottom_panel_visible)
bottom_btn.pack(side=tk.RIGHT, padx=1, pady=2)
self._sidebar_icon = sidebar_btn
self._bottom_icon = bottom_btn
# 上半部分: 网格策略持仓 # 上半部分: 网格策略持仓
grid_frame = ttk.LabelFrame(left_frame, text="网格策略持仓", padding=5) grid_frame = ttk.LabelFrame(left_frame, text="网格策略持仓", padding=5)
@@ -272,9 +306,9 @@ class TradeTargetUI(ttk.Frame):
self.create_grid_table(grid_frame) self.create_grid_table(grid_frame)
# 下半部分: 未分类持仓 # 下半部分: 未分类持仓
unclassified_frame = ttk.LabelFrame(left_frame, text="未分类持仓", padding=5) self.unclassified_frame = ttk.LabelFrame(left_frame, text="未分类持仓", padding=5)
unclassified_frame.pack(fill=tk.BOTH, expand=True, pady=(3, 0)) self.unclassified_frame.pack(fill=tk.BOTH, expand=True, pady=(3, 0))
self.create_unclassified_table(unclassified_frame) self.create_unclassified_table(self.unclassified_frame)
# 右侧: Notebook 标签页容器 # 右侧: Notebook 标签页容器
self.right_notebook = ttk.Notebook(tables_frame) self.right_notebook = ttk.Notebook(tables_frame)
@@ -297,17 +331,18 @@ class TradeTargetUI(ttk.Frame):
self.create_market_monitor_table(self.market_frame) self.create_market_monitor_table(self.market_frame)
# Tab 2: 订单记录 (占位) # Tab 2: 当日委托
model_tab = ttk.Frame(self.right_notebook) self.order_tab = ttk.Frame(self.right_notebook)
self.right_notebook.add(model_tab, text="订单记录") self.right_notebook.add(self.order_tab, text="当日委托")
ttk.Label(model_tab, text="订单记录 - 待实现", font=('Arial', 12), self._create_order_table(self.order_tab)
foreground='gray').pack(expand=True)
# Tab 3: 成交记录 (占位) # Tab 3: 当日成交
dataset_tab = ttk.Frame(self.right_notebook) self.trade_tab = ttk.Frame(self.right_notebook)
self.right_notebook.add(dataset_tab, text="成交记录") self.right_notebook.add(self.trade_tab, text="当日成交")
ttk.Label(dataset_tab, text="成交记录 - 待实现", font=('Arial', 12), self._create_trade_table(self.trade_tab)
foreground='gray').pack(expand=True)
# Tab 切换时自动刷新
self.right_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
def create_grid_table(self, parent): def create_grid_table(self, parent):
@@ -411,6 +446,125 @@ class TradeTargetUI(ttk.Frame):
# 填充初始数据 # 填充初始数据
self.populate_market_table() self.populate_market_table()
# ---- 委托/成交 Tab 页 ----
_ORDER_STATUS_MAP = {
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤',
52: '部成待撤', 53: '部撤', 54: '已撤', 55: '部成',
56: '已成', 57: '废单',
}
@staticmethod
def _order_direction(ot: int) -> str:
return '' if ot in (23,) else '' if ot in (24,) else str(ot)
def _create_order_table(self, parent):
"""创建当日委托表格"""
toolbar = ttk.Frame(parent)
toolbar.pack(fill=tk.X, pady=(0, 3))
ttk.Button(toolbar, text="刷新", command=self._refresh_orders, width=6).pack(side=tk.RIGHT)
ttk.Label(toolbar, text="当日委托", font=('', 10, 'bold')).pack(side=tk.LEFT)
cols = ("时间", "代码", "名称", "方向", "委托价", "委托量", "已成交", "均价", "状态")
self.order_tree = ttk.Treeview(parent, columns=cols, show='headings', height=14)
widths = {"时间": 70, "代码": 60, "名称": 70, "方向": 30, "委托价": 55, "委托量": 55, "已成交": 55, "均价": 55, "状态": 55}
for c in cols:
self.order_tree.heading(c, text=c)
self.order_tree.column(c, width=widths.get(c, 60), anchor=tk.CENTER)
sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.order_tree.yview)
self.order_tree.configure(yscrollcommand=sb.set)
self.order_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sb.pack(side=tk.RIGHT, fill=tk.Y)
def _create_trade_table(self, parent):
"""创建当日成交表格"""
toolbar = ttk.Frame(parent)
toolbar.pack(fill=tk.X, pady=(0, 3))
ttk.Button(toolbar, text="刷新", command=self._refresh_trades, width=6).pack(side=tk.RIGHT)
ttk.Label(toolbar, text="当日成交", font=('', 10, 'bold')).pack(side=tk.LEFT)
cols = ("时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费")
self.trade_tree = ttk.Treeview(parent, columns=cols, show='headings', height=14)
widths = {"时间": 70, "代码": 60, "名称": 70, "方向": 30, "成交价": 55, "成交量": 55, "成交金额": 65, "手续费": 50}
for c in cols:
self.trade_tree.heading(c, text=c)
self.trade_tree.column(c, width=widths.get(c, 60), anchor=tk.CENTER)
sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.trade_tree.yview)
self.trade_tree.configure(yscrollcommand=sb.set)
self.trade_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sb.pack(side=tk.RIGHT, fill=tk.Y)
def _on_tab_changed(self, event):
"""Tab 切换时自动刷新"""
nb = self.right_notebook
try:
tab_id = nb.select()
tab_text = nb.tab(tab_id, "text")
if tab_text == "当日委托":
self._refresh_orders()
elif tab_text == "当日成交":
self._refresh_trades()
except Exception:
pass
def _refresh_orders(self):
"""从 QMT 读取当日委托并刷新表格"""
from core.qmt import qmtv
try:
orders = qmtv.queryTodayOrders()
except Exception:
orders = []
for item in self.order_tree.get_children():
self.order_tree.delete(item)
_CANCELED = {54, 57}
for o in orders:
if getattr(o, 'order_status', 0) in _CANCELED:
continue
code = getattr(o, 'stock_code', '')
if '.' in code:
code = code.split('.')[0]
self.order_tree.insert('', tk.END, values=(
str(getattr(o, 'order_time', '')),
code,
getattr(o, 'instrument_name', '') or code,
self._order_direction(getattr(o, 'order_type', 0)),
f"{getattr(o, 'price', 0):.3f}",
getattr(o, 'order_volume', 0),
getattr(o, 'traded_volume', 0),
f"{getattr(o, 'traded_price', 0):.3f}" if getattr(o, 'traded_price', 0) > 0 else '-',
self._ORDER_STATUS_MAP.get(getattr(o, 'order_status', 255), '未知'),
))
def _refresh_trades(self):
"""从 QMT 读取当日成交并刷新表格"""
from core.qmt import qmtv
try:
trades = qmtv.queryTodayTrades()
except Exception:
trades = []
for item in self.trade_tree.get_children():
self.trade_tree.delete(item)
for t in trades:
code = getattr(t, 'stock_code', '')
if '.' in code:
code = code.split('.')[0]
self.trade_tree.insert('', tk.END, values=(
str(getattr(t, 'traded_time', '')),
code,
getattr(t, 'instrument_name', '') or code,
self._order_direction(getattr(t, 'order_type', 0)),
f"{getattr(t, 'traded_price', 0):.3f}",
getattr(t, 'traded_volume', 0),
f"{getattr(t, 'traded_amount', 0):.2f}",
f"{getattr(t, 'commission', 0):.2f}",
))
def populate_market_table(self): def populate_market_table(self):
"""填充市场监控表格数据""" """填充市场监控表格数据"""
# 保存当前选中的项 # 保存当前选中的项
@@ -490,9 +644,9 @@ class TradeTargetUI(ttk.Frame):
def get_trade_enabled_indicator(self, target: SFGridTradeTarget) -> str: def get_trade_enabled_indicator(self, target: SFGridTradeTarget) -> str:
"""获取交易状态指示器""" """获取交易状态指示器"""
if target.status == -1: if target.strategy_type == 0: # 未分类 = 未配置
return "请做交易设置" return "请做交易设置"
elif target.status >= 0: else:
if target.enabled: if target.enabled:
return "▶ 运行中" return "▶ 运行中"
else: else:
@@ -550,15 +704,16 @@ class TradeTargetUI(ttk.Frame):
def populate_grid_table(self): def populate_grid_table(self):
"""填充网格策略表格数据""" """填充网格策略表格数据"""
# 先清空所有行再重建,防止重复
self.grid_table.delete(*self.grid_table.get_children())
for id, target in self.tradeTargetData.items(): for id, target in self.tradeTargetData.items():
from core.sfgrid.model import STRATEGY_TYPE_GRID from core.sfgrid.model import STRATEGY_TYPE_GRID
if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore
continue continue
grid_info = '-'
if target.status >= 0:
grid_idx = target.grid_index grid_idx = target.grid_index
price_grid = target.getPriceGrid() price_grid = target.getPriceGrid()
grid_info = '-'
if 0 <= grid_idx < len(price_grid): if 0 <= grid_idx < len(price_grid):
grid_info = f'{grid_idx}({price_grid[grid_idx]:.2f}元)' grid_info = f'{grid_idx}({price_grid[grid_idx]:.2f}元)'
else: else:
@@ -578,6 +733,8 @@ class TradeTargetUI(ttk.Frame):
def populate_unclassified_table(self): def populate_unclassified_table(self):
"""填充未分类持仓表格数据""" """填充未分类持仓表格数据"""
# 先清空所有行再重建,防止重复
self.unclassified_table.delete(*self.unclassified_table.get_children())
for id, target in self.tradeTargetData.items(): for id, target in self.tradeTargetData.items():
from core.sfgrid.model import STRATEGY_TYPE_UNCLASSIFIED from core.sfgrid.model import STRATEGY_TYPE_UNCLASSIFIED
if target.strategy_type != STRATEGY_TYPE_UNCLASSIFIED: # type: ignore if target.strategy_type != STRATEGY_TYPE_UNCLASSIFIED: # type: ignore
@@ -629,6 +786,18 @@ class TradeTargetUI(ttk.Frame):
messagebox.showwarning("未选中", "请先选择一个交易标的") messagebox.showwarning("未选中", "请先选择一个交易标的")
return None return None
def get_selected_targets(self):
"""获取所有选中的交易标的(支持多选)"""
targets = []
for table in (self.grid_table, self.unclassified_table):
for item in table.selection():
values = table.item(item)['values']
if values:
target = self.tradeTargetData.get(int(values[0]))
if target:
targets.append(target)
return targets
def refresh_table(self): def refresh_table(self):
"""刷新表格数据(纯UI操作,在主线程执行)""" """刷新表格数据(纯UI操作,在主线程执行)"""
# 刷新网格策略表格 # 刷新网格策略表格
@@ -703,25 +872,25 @@ class TradeTargetUI(ttk.Frame):
ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2) ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2)
# 建仓状态(可变更) # 建仓状态(可变更)
status_text = "已建仓" if target.status >= 1 else "未建仓" status_text = "已建仓" if target.grid_index > 0 else "未建仓"
status_color = "green" if target.status >= 1 else "orange" status_color = "green" if target.grid_index > 0 else "orange"
status_label = ttk.Label(info_frame, text=f"建仓状态: {status_text}", foreground=status_color) status_label = ttk.Label(info_frame, text=f"建仓状态: {status_text}", foreground=status_color)
status_label.grid(row=1, column=0, sticky=tk.W, pady=2) status_label.grid(row=1, column=0, sticky=tk.W, pady=2)
def toggle_position_status(): def toggle_position_status():
new_status = 0 if target.status >= 1 else 1 new_idx = 0 if target.grid_index > 0 else 1
setattr(target, 'status', new_status) setattr(target, 'grid_index', new_idx)
target.save() target.save()
new_text = "已建仓" if new_status >= 1 else "未建仓" new_text = "已建仓" if new_idx > 0 else "未建仓"
new_color = "green" if new_status >= 1 else "orange" new_color = "green" if new_idx > 0 else "orange"
status_label.config(text=f"建仓状态: {new_text}", foreground=new_color) status_label.config(text=f"建仓状态: {new_text}", foreground=new_color)
toggle_btn.config(text="标记为未建仓" if new_status >= 1 else "标记为已建仓") toggle_btn.config(text="标记为未建仓" if new_idx > 0 else "标记为已建仓")
self.updateTradeTarget(target, False) self.updateTradeTarget(target, False)
PrintLog(LogLevel.INFO, f"建仓状态变更: {target.stock_code}{new_text}") PrintLog(LogLevel.INFO, f"建仓状态变更: {target.stock_code}{new_text}")
toggle_btn = ttk.Button( toggle_btn = ttk.Button(
info_frame, info_frame,
text="标记为未建仓" if target.status >= 1 else "标记为已建仓", text="标记为未建仓" if target.grid_index > 0 else "标记为已建仓",
command=toggle_position_status command=toggle_position_status
) )
toggle_btn.grid(row=1, column=1, sticky=tk.W, padx=(20, 0), pady=2) toggle_btn.grid(row=1, column=1, sticky=tk.W, padx=(20, 0), pady=2)
@@ -826,15 +995,6 @@ class TradeTargetUI(ttk.Frame):
ttk.Label(info_frame, text=f"股票代码: {target.stock_code}").grid(row=0, column=0, sticky=tk.W, pady=2) ttk.Label(info_frame, text=f"股票代码: {target.stock_code}").grid(row=0, column=0, sticky=tk.W, pady=2)
ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2) ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2)
# 建仓状态选择
ttk.Label(info_frame, text="建仓状态:", width=12).grid(row=1, column=0, sticky=tk.W, pady=2)
position_status_var = tk.StringVar(value="未建仓" if target.status < 1 else "已建仓")
position_status_combo = ttk.Combobox(
info_frame, textvariable=position_status_var,
values=["未建仓", "已建仓"], state="readonly", width=10
)
position_status_combo.grid(row=1, column=1, sticky=tk.W, padx=(20, 0), pady=2)
ttk.Label(info_frame, text=f"状态: 新标的(可配置模式)").grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=2) ttk.Label(info_frame, text=f"状态: 新标的(可配置模式)").grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=2)
# 创建网格配置框架 # 创建网格配置框架
@@ -977,8 +1137,6 @@ class TradeTargetUI(ttk.Frame):
setattr(target, 'grid_volume', grid_volume) setattr(target, 'grid_volume', grid_volume)
setattr(target, 'grid_upper_count', grid_upper_count) setattr(target, 'grid_upper_count', grid_upper_count)
setattr(target, 'grid_lower_count', grid_lower_count) setattr(target, 'grid_lower_count', grid_lower_count)
# 建仓状态: "已建仓" → 1, "未建仓" → 0
setattr(target, 'status', 1 if position_status_var.get() == "已建仓" else 0)
# grid_index 设为基准价在网格中的位置 (grid_upper_count) # grid_index 设为基准价在网格中的位置 (grid_upper_count)
setattr(target, 'grid_index', grid_upper_count) setattr(target, 'grid_index', grid_upper_count)
# 自动标记为网格策略 # 自动标记为网格策略
@@ -1068,7 +1226,6 @@ class TradeTargetUI(ttk.Frame):
current_position=0 if pos is None else int(pos.volume), current_position=0 if pos is None else int(pos.volume),
grid_index=gridIndex, grid_index=gridIndex,
init_price=0.0, init_price=0.0,
status=-1,
strategy_type=0 # 默认为未分类 strategy_type=0 # 默认为未分类
) )
# 更新标的池 # 更新标的池
@@ -1094,6 +1251,19 @@ class TradeTargetUI(ttk.Frame):
else: else:
self.right_notebook.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0)) self.right_notebook.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
self.market_monitor_visible = True self.market_monitor_visible = True
if hasattr(self, '_sidebar_icon'):
self._sidebar_icon.set_active(self.market_monitor_visible)
def _toggle_bottom_panel(self):
"""切换底部未分类持仓面板显示/隐藏"""
if self.bottom_panel_visible:
self.unclassified_frame.pack_forget()
self.bottom_panel_visible = False
else:
self.unclassified_frame.pack(fill=tk.BOTH, expand=True, pady=(3, 0))
self.bottom_panel_visible = True
if hasattr(self, '_bottom_icon'):
self._bottom_icon.set_active(self.bottom_panel_visible)
def btnHandlerTradeSettings(self): def btnHandlerTradeSettings(self):
"""网格配置功能""" """网格配置功能"""
@@ -1109,71 +1279,61 @@ class TradeTargetUI(ttk.Frame):
self.create_grid_view_window(target) self.create_grid_view_window(target)
def btnHandlerStartSelectedTrade(self): def btnHandlerStartSelectedTrade(self):
"""启动选中的交易""" """启动选中的交易(支持多选)"""
target = self.get_selected_target()
if not target:
return
from core.sfgrid.model import STRATEGY_TYPE_GRID from core.sfgrid.model import STRATEGY_TYPE_GRID
if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore targets = self.get_selected_targets()
messagebox.showinfo("提示", "该标的不属于网格策略,请先转为网格策略后再启动交易。") if not targets:
messagebox.showwarning("未选中", "请先选择交易标的")
return return
if target.status < 0: # 过滤:只处理已暂停的网格策略标的
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 未配置交易参数, 请做交易设置。") to_start = [t for t in targets
if t.strategy_type == STRATEGY_TYPE_GRID and not t.enabled] # type: ignore
if not to_start:
messagebox.showinfo("提示", "选中的标的中没有可启动的(已在运行中或非网格策略)")
return return
if target.enabled: # type: ignore # 确认对话框
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 已经在运行中") names = "\n".join(f"{t.stock_code} {t.stock_name}" for t in to_start)
result = messagebox.askyesno("确认启动", f"确定要启动以下 {len(to_start)} 个交易标的吗?\n\n{names}")
if not result:
return return
result = messagebox.askyesno( for target in to_start:
"确认启动",
f"确定要启动以下交易标的吗?\n\n"
f"股票代码: {target.stock_code}\n"
f"股票名称: {target.stock_name}"
)
if result:
PrintLog(LogLevel.INFO, f'启动标的交易 {target.targetName()}') PrintLog(LogLevel.INFO, f'启动标的交易 {target.targetName()}')
target.enabled = True # type: ignore target.enabled = True # type: ignore
ctrl = self.strategy_ctrl.get(target.get_id())
id = target.get_id() if ctrl:
if id in self.strategy_ctrl: ctrl.enabledTrading(True)
tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()]
tradeTarget = tradeController.enabledTrading(True)
self.tradeTargetData[id] = tradeTarget
else: else:
PrintLog(LogLevel.INFO, f"\t创建标的交易控制器 {target.targetName()}") PrintLog(LogLevel.INFO, f"\t创建标的交易控制器 {target.targetName()}")
def btnHandlerStopSelectedTrade(self): def btnHandlerStopSelectedTrade(self):
"""暂停选中的交易""" """暂停选中的交易(支持多选)"""
target = self.get_selected_target()
if not target:
return
from core.sfgrid.model import STRATEGY_TYPE_GRID from core.sfgrid.model import STRATEGY_TYPE_GRID
if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore targets = self.get_selected_targets()
messagebox.showinfo("提示", "该标的不属于网格策略。") if not targets:
messagebox.showwarning("未选中", "请先选择交易标的")
return return
if not target.enabled: # type: ignore # 过滤:只处理已启用的网格策略标的
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 已经是暂停状态") to_stop = [t for t in targets
if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled] # type: ignore
if not to_stop:
messagebox.showinfo("提示", "选中的标的中没有可暂停的(已是暂停状态或非网格策略)")
return return
result = messagebox.askyesno( # 确认对话框
"确认暂停", names = "\n".join(f"{t.stock_code} {t.stock_name}" for t in to_stop)
f"确定要暂停以下交易标的吗?\n\n" result = messagebox.askyesno("确认暂停", f"确定要暂停以下 {len(to_stop)}交易标的吗?\n\n{names}")
f"股票代码: {target.stock_code}\n" if not result:
f"股票名称: {target.stock_name}" return
)
if result: for target in to_stop:
PrintLog(LogLevel.INFO, f'暂停标的交易 {target.targetName()}') PrintLog(LogLevel.INFO, f'暂停标的交易 {target.targetName()}')
id = target.get_id() ctrl = self.strategy_ctrl.get(target.get_id())
if id in self.strategy_ctrl: if ctrl:
tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()] ctrl.enabledTrading(False)
tradeController.enabledTrading(False)
else: else:
print(f"标的交易控制器不存在 {target.stock_code} {target.stock_name}\n") print(f"标的交易控制器不存在 {target.stock_code} {target.stock_name}\n")
@@ -1288,3 +1448,86 @@ class TradeTargetUI(ttk.Frame):
PrintLog(LogLevel.INFO, f"监控价格已更新为: {new_price}") PrintLog(LogLevel.INFO, f"监控价格已更新为: {new_price}")
except ValueError: except ValueError:
messagebox.showerror("错误", "请输入有效的数字") messagebox.showerror("错误", "请输入有效的数字")
class PanelIcon(tk.Canvas):
"""VSCode 风格面板切换图标,颜色自适应系统主题"""
_SIZE = 22
def __init__(self, parent, kind: str, command, active: bool = True):
super().__init__(parent, width=self._SIZE, height=self._SIZE,
bd=0, highlightthickness=0, cursor='hand2')
self._command = command
self._kind = kind
self._active = active
self.bind('<Button-1>', self._on_click)
self.bind('<Enter>', self._on_enter)
self.bind('<Leave>', self._on_leave)
self.bind('<Map>', lambda e: self._update_colors())
self._update_colors()
def _update_colors(self):
"""从父容器读取实际背景色,推导图标配色"""
try:
bg = self.master.cget('background') or 'SystemButtonFace'
# 转 RGB 判断明暗
rgb = self.winfo_rgb(bg) if bg.startswith('#') else None
if rgb:
r, g, b = rgb[0] >> 8, rgb[1] >> 8, rgb[2] >> 8
bright = (r * 299 + g * 587 + b * 114) / 1000
else:
bright = 240 # 系统颜色默认当作亮色
except Exception:
bright = 240
if bright > 128:
# 亮色主题
self._BG = '#e8e8e8' if bright < 220 else '#f0f0f0'
self._HOVER = '#d4d4d4'
self._OFF = '#b0b0b0'
self._ON = '#808080'
self._ACTIVE = '#0078d4' # Windows 蓝色
else:
# 暗色主题
self._BG = '#3c3c3c'
self._HOVER = '#505050'
self._OFF = '#6a6a6a'
self._ON = '#a0a0a0'
self._ACTIVE = '#ffffff'
self.configure(bg=self._BG)
self._draw()
def _draw(self):
self.delete('all')
m = 3
s = self._SIZE - m * 2
off = self._OFF
on = self._ON
if self._kind == 'sidebar':
# 左右分栏图标,右边面板高亮
l = self._ON if self._active else off
r = self._ACTIVE if self._active else off
self.create_rectangle(m + 1, m + 1, m + 6, m + s - 2, fill=l, outline='', width=0)
self.create_rectangle(m + 8, m + 1, m + 13, m + s - 2, fill=r, outline='', width=0)
else:
# 上下分栏图标,下面板高亮
t = self._ON if self._active else off
b = self._ACTIVE if self._active else off
self.create_rectangle(m + 1, m + 1, m + s - 2, m + 6, fill=t, outline='', width=0)
self.create_rectangle(m + 1, m + 8, m + s - 2, m + 13, fill=b, outline='', width=0)
def set_active(self, active: bool):
self._active = active
self._draw()
def _on_click(self, event):
self._command()
def _on_enter(self, event):
self.configure(bg=self._HOVER)
def _on_leave(self, event):
self.configure(bg=self._BG)
+112
View File
@@ -0,0 +1,112 @@
"""
启动进度窗口 — 无边框小窗口,负责整个初始化流程。
"""
import time
import tkinter as tk
from tkinter import ttk, messagebox
class SplashWindow:
"""初始化进度窗口,所有者启动逻辑"""
def __init__(self):
self.root = tk.Tk()
self.root.title("神之一手")
self.root.geometry("380x120")
self.root.resizable(False, False)
self.root.overrideredirect(True)
self.root.update_idletasks()
sw = self.root.winfo_screenwidth()
sh = self.root.winfo_screenheight()
w, h = 380, 120
self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
frame = ttk.Frame(self.root, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="神之一手", font=('Microsoft YaHei', 14, 'bold')).pack(pady=(0, 5))
self._status = ttk.Label(frame, text="正在初始化...", font=('Microsoft YaHei', 9))
self._status.pack(pady=(0, 10))
self._bar = ttk.Progressbar(frame, mode='determinate', length=340)
self._bar.pack()
self.root.update()
def progress(self, text: str, pct: float):
self._status.configure(text=text)
self._bar.configure(value=pct)
self.root.update()
def _destroy(self):
self.root.destroy()
def run(self):
"""执行完整启动流程,成功返回主窗口,失败返回 None"""
from core.qmt_real import RealQmtV, qmtv as selected_qmtv
while True:
_t_total = time.time()
# 步骤1: 探测 QMT 环境
self.progress("正在检查 QMT 环境...", 10)
_t = time.time()
try:
discovered = RealQmtV._discover_qmt_port()
except Exception:
discovered = 0
print(f'[计时] 步骤1-探测QMT环境: {time.time() - _t:.2f}s')
if not discovered:
self._destroy()
messagebox.showerror(
"启动失败",
"未能自动探测到 QMT 环境。\n\n"
"请确认:\n"
"1. 极简QMT(GJQMT)已启动并登录\n"
"2. XtMiniQmt.exe 和 miniquote.exe 进程在运行"
)
return None
# 步骤2: 初始化交易器
self.progress("正在初始化交易器...", 35)
_t = time.time()
selected_qmtv.init_qmtv()
print(f'[计时] 步骤2-初始化交易器: {time.time() - _t:.2f}s')
# 步骤3: 连接 QMT
self.progress("正在连接 QMT...", 55)
_t = time.time()
connected = selected_qmtv.connect()
print(f'[计时] 步骤3-连接QMT: {time.time() - _t:.2f}s')
if not connected:
self._destroy()
option = messagebox.askokcancel(
"连接失败",
"QMT 连接失败。\n\n"
"请确认极简QMT 已启动并登录交易账号。\n"
"点击「确定」重试,或「取消」退出。"
)
if not option:
return None
# 重试:重新创建进度窗口
self.__init__()
continue
# 步骤4: 加载主界面
self.progress("正在加载持仓与策略...", 75)
_t = time.time()
from core.ui.tkinter.main_window import MainWindow
window = MainWindow('INFO', progress=lambda t, p: self.progress(t, 75 + p * 0.2))
print(f'[计时] 步骤4-主界面加载: {time.time() - _t:.2f}s')
window.root.update()
# 步骤5: 完成
self.progress("启动完成", 100)
self.root.update()
self.root.after(300, self._destroy)
print(f'[计时] 总启动耗时: {time.time() - _t_total:.2f}s')
return window
+98
View File
@@ -0,0 +1,98 @@
# 统一网格逻辑
## 核心规则
对任意 `grid_index`,两个方向各挂一单:
| 方向 | 条件 | 价格 | 含义 |
|------|------|------|------|
| 卖出(上移) | `grid_index > 0` | `grid[grid_index - 1]` | 涨回到上一格时卖出获利 |
| 买入(下移) | `grid_index < len(grid)-1` | `grid[grid_index + 1]` | 跌到下一格时补仓 |
不需要"建仓"概念,`grid_index=0` 自然表示空仓。
## 流程图
```mermaid
flowchart TD
START["refreshGridOrder()"] --> GUARD{"isMarketActive AND enabled ?"}
GUARD -->|No| EXIT0["跳过不下单"]
GUARD -->|Yes| QUERY["查询未成交订单<br/>queryPendingOrder()"]
QUERY --> IDX["currentIdx = grid_index"]
IDX --> SELL{"currentIdx > 0 ?"}
SELL -->|"No<br/>(空仓,无持仓可卖)"| BUY
SELL -->|"Yes"| SELL_IDX["sellIdx = currentIdx - 1<br/>卖价 = grid[sellIdx]"]
SELL_IDX --> SELL_EXIST{"已有同 remark 卖单?"}
SELL_EXIST -->|No| SELL_CHECK{"卖价 > 涨停价 ?"}
SELL_CHECK -->|Yes| SELL_SKIP["跳过(超出涨停)"]
SELL_CHECK -->|No| SELL_PLACE["挂卖出单<br/>orderGrid[sellIdx] = seq"]
SELL_EXIST -->|Yes| SELL_DUP["跳过(已挂单)"]
SELL_SKIP --> BUY
SELL_PLACE --> BUY
SELL_DUP --> BUY
BUY{"currentIdx < len(grid)-1 ?"}
BUY -->|"No<br/>(已到最低价)"| EXIT["结束"]
BUY -->|"Yes"| BUY_IDX["buyIdx = currentIdx + 1<br/>买价 = grid[buyIdx]"]
BUY_IDX --> BUY_EXIST{"已有同 remark 买单?"}
BUY_EXIST -->|No| BUY_CHECK{"买价 < 跌停价 ?"}
BUY_CHECK -->|Yes| BUY_SKIP["跳过(低于跌停)"]
BUY_CHECK -->|No| BUY_PLACE["挂买入单<br/>orderGrid[buyIdx] = seq"]
BUY_EXIST -->|Yes| BUY_DUP["跳过(已挂单)"]
BUY_SKIP --> EXIT
BUY_PLACE --> EXIT
BUY_DUP --> EXIT
```
## 三种典型状态
```
grid = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
↑ ↑ ↑ ↑
0 1 2 3 ...
grid_index=0(空仓):
┌────┬────┬────┬────┐
│ 11 │ 10 │ 9 │ 8 │ ...
└────┴────┴────┴────┘
sell=无 buy=10 ← 第一笔买单
grid_index=1(持1份@10元):
┌────┬────┬────┬────┐
│ 11 │ 10 │ 9 │ 8 │ ...
└────┴────┴────┴────┘
sell=11 → buy=9 →
grid_index=3(持3份@8,9,10元):
┌────┬────┬────┬────┐
│ 11 │ 10 │ 9 │ 8 │ ...
└────┴────┴────┴────┘
↑ sell=9 buy=7 →
当前位置=3
成交后处理(onOrderTrade:
卖单成交 gridIdx < currentIdx → grid_index -= 1(上移,赚差价)
买单成交 gridIdx > currentIdx → grid_index += 1(下移,补仓)
然后 refreshGridOrder → 在新位置重新挂单
```
## 和之前的区别
| | 之前 | 之后 |
|---|---|---|
| 分支数 | 2 个(status=0 / status=1 | 1 个(统一网格逻辑) |
| 空仓第一笔 | INIT 单 @ grid[0]=11 | 普通买单 @ grid[1]=10 |
| grid[0]=11 的用途 | 建仓买入 | 永远只卖不买 |
| 状态字段 | status + grid_index | 仅 grid_index |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

+19 -187
View File
@@ -1,191 +1,23 @@
# coding:utf-8 # coding:utf-8
import os """
启动入口 — 自动探测 QMT 环境。
默认使用 Tkinter UI,使用 --flet 参数切换到 Flet (Flutter) UI。
"""
import sys import sys
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import configparser
import config as sdConstants
class ConfigWindow:
def __init__(self, root):
self.root = root
self.root.title("系统配置")
self.root.geometry("500x300")
self.root.resizable(False, False)
# 居中显示
self.root.withdraw() # 先隐藏窗口
self.root.update_idletasks()
x = (self.root.winfo_screenwidth() // 2) - (500 // 2)
y = (self.root.winfo_screenheight() // 2) - (300 // 2)
self.root.geometry(f"500x300+{x}+{y}")
self.root.deiconify() # 再显示窗口
self.miniQMTPath = tk.StringVar()
self.account_no = tk.StringVar()
self.create_widgets()
def create_widgets(self):
# 创建主框架
main_frame = ttk.Frame(self.root, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)
# miniQMT路径配置
path_frame = ttk.Frame(main_frame)
path_frame.pack(fill=tk.X, pady=5)
path_label = ttk.Label(path_frame, text="miniQMT路径:")
path_label.pack(side=tk.LEFT)
path_entry = ttk.Entry(path_frame, textvariable=self.miniQMTPath, width=40)
path_entry.pack(side=tk.LEFT, padx=(10, 5), fill=tk.X, expand=True)
browse_btn = ttk.Button(path_frame, text="浏览", command=self.browse_folder)
browse_btn.pack(side=tk.LEFT)
# 资金账号配置
account_frame = ttk.Frame(main_frame)
account_frame.pack(fill=tk.X, pady=5)
account_label = ttk.Label(account_frame, text="资金账号:")
account_label.pack(side=tk.LEFT)
account_entry = ttk.Entry(account_frame, textvariable=self.account_no, width=40)
account_entry.pack(side=tk.LEFT, padx=(10, 0))
# 模拟模式复选框
self.use_simulated = tk.BooleanVar(value=False)
simulated_check = ttk.Checkbutton(
main_frame,
text="使用模拟交易模式(无需真实 QMT 连接)",
variable=self.use_simulated
)
simulated_check.pack(fill=tk.X, pady=5)
# 说明文本
info_label = ttk.Label(
main_frame,
text="请配置miniQMT的userdata_mini路径和资金账号\n路径示例: D:/Programs/DTQMT/userdata_mini",
foreground="gray"
)
info_label.pack(pady=10)
# 按钮框架
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill=tk.X, pady=10)
save_btn = ttk.Button(button_frame, text="保存配置", command=self.save_config)
save_btn.pack(side=tk.RIGHT)
cancel_btn = ttk.Button(button_frame, text="取消", command=self.root.destroy)
cancel_btn.pack(side=tk.RIGHT, padx=(0, 10))
def browse_folder(self):
folder_selected = filedialog.askdirectory()
if folder_selected:
self.miniQMTPath.set(folder_selected)
def save_config(self):
mini_qmt_path = self.miniQMTPath.get().strip()
account_number = self.account_no.get().strip()
# 检查miniQMT路径
if not mini_qmt_path:
messagebox.showerror("错误", "请选择miniQMT路径")
return
if not os.path.exists(mini_qmt_path):
messagebox.showerror("错误", "miniQMT路径不存在")
return
# 检查账号
if not account_number:
messagebox.showerror("错误", "请输入资金账号")
return
# 保存配置
config = configparser.ConfigParser()
config['config'] = {
'miniQMTPath': mini_qmt_path.replace('\\', '/'),
'account_no': account_number,
'use_simulated_qmt': str(self.use_simulated.get()),
'log_level': 'INFO'
}
config_path = sdConstants.get_config_path()
try:
with open(config_path, 'w') as configfile:
config.write(configfile)
messagebox.showinfo("成功", "配置已保存")
self.root.destroy()
except Exception as e:
messagebox.showerror("错误", f"保存配置失败: {str(e)}")
def check_and_create_config():
"""检查配置文件,如果不存在则打开配置窗口"""
root = tk.Tk()
config_window = ConfigWindow(root)
root.mainloop()
def resolve_simulated_mode() -> bool:
"""确定是否使用模拟模式(CLI > 配置文件 > 默认 real"""
if '--simulated' in sys.argv:
print('[配置] 命令行指定: 模拟交易模式')
return True
if sdConstants.exist_config():
sdConstants.initConfig()
if sdConstants.use_simulated_qmt:
print('[配置] 配置文件指定: 模拟交易模式')
return True
print('[配置] 默认: 真实交易模式')
return False
def initialize_system():
"""初始化系统"""
simulated = resolve_simulated_mode()
sdConstants.use_simulated_qmt = simulated
try:
if simulated:
from core.qmt_dummy import qmtv as selected_qmtv
print("[模拟模式] 使用模拟交易器")
sdConstants.miniQMTPath = '/dummy/path'
sdConstants.account_no = 'DUMMY_ACCOUNT'
sdConstants.log_level = 'INFO'
selected_qmtv.init_qmtv()
selected_qmtv.connect()
from core.main_ui import MainWindow
window = MainWindow(sdConstants.log_level)
window.run()
else:
from core.qmt_real import qmtv as selected_qmtv
while True:
if sdConstants.exist_config() and sdConstants.initConfig():
selected_qmtv.init_qmtv()
connected = selected_qmtv.connect()
if connected:
from core.main_ui import MainWindow
window = MainWindow(sdConstants.log_level)
window.run()
break
else:
option = messagebox.askokcancel("连接失败", "QMT连接失败,请检查")
if option:
check_and_create_config()
else:
break
else:
option = messagebox.askokcancel("错误", "请检查配置")
if option:
check_and_create_config()
else:
break
except Exception as e:
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
if __name__ == '__main__': if __name__ == '__main__':
initialize_system() if '--flet2' in sys.argv:
from core.ui.flet.app_v2 import run
run()
elif '--flet' in sys.argv:
from core.ui.flet.app import run
run()
else:
from tkinter import messagebox
from core.ui.tkinter.splash import SplashWindow
try:
window = SplashWindow().run()
if window:
window.run()
except Exception as e:
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
+4 -4
View File
@@ -4,7 +4,7 @@ a = Analysis(
['starter.py'], ['starter.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[('config.ini', '.'), ('xtquant/xtdata.ini', 'xtquant')], # 明确包含配置文件和xtdata.ini datas=[('xtquant/xtdata.ini', 'xtquant')], # xtdata 依赖的配置文件
hiddenimports=['brotli', 'brotli.encoding'], hiddenimports=['brotli', 'brotli.encoding'],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
@@ -24,8 +24,8 @@ exe = EXE(
name='神之一手', name='神之一手',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=True, # 去除调试符号 strip=False,
upx=True, upx=False,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=False,
@@ -34,5 +34,5 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon='logo.png' # 添加图标文件 icon='logo.ico',
) )