diff --git a/SFGrid_Flowchart.md b/SFGrid_Flowchart.md
new file mode 100644
index 0000000..ecd50eb
--- /dev/null
+++ b/SFGrid_Flowchart.md
@@ -0,0 +1,233 @@
+# SFGrid 网格交易策略流程图
+
+## 1. 总览:策略生命周期
+
+```mermaid
+flowchart TD
+ A["SFGridStrategy.__init__()"] --> B["订阅事件总线
onOrderCreateAsync / onOrderTrade / onOrderError"]
+ B --> C["获取涨跌停价
todayUpStopPrice / todayDownStopPrice"]
+ C --> D["loadExistOrders()
从券商侧恢复未成交订单到 orderGrid"]
+ D --> E["enabledTrading(enabled)"]
+ E --> F{"enabled ?"}
+ F -->|True| G["启用交易流程 → 见 §3"]
+ F -->|False| H["停用交易流程 → 见 §3"]
+ G --> I["saveProxy() 持久化"]
+ H --> I
+ I --> J["构造完成,进入事件循环
等待 QMT 回调 / UI 操作"]
+```
+
+---
+
+## 2. 核心:refreshGridOrder() 网格下单
+
+```mermaid
+flowchart TD
+ START["refreshGridOrder()"] --> CHECK1{"qmtv.isMarketActive
AND
tradeTarget.enabled ?"}
+ CHECK1 -->|No| SKIP["跳过不下单"]
+ CHECK1 -->|Yes| QUERY["查询未成交订单
queryPendingOrder()"]
+
+ QUERY --> STATUS{"tradeTarget.status ?"}
+
+ STATUS -->|"= 0 未建仓"| CHECK_INIT{"已存在建仓单?
remark = 'INIT,1,{code}'"}
+ CHECK_INIT -->|"No 没有"| PLACE_INIT["下建仓单 (STOCK_BUY)
价格 = getPriceGrid()[0]
remark = 'INIT,1,{code}'"]
+ CHECK_INIT -->|"Yes 已有"| DONE_INIT["建仓单已在途,跳过"]
+
+ STATUS -->|"= 1 已建仓"| GET_IDX["currentIdx = grid_index"]
+ GET_IDX --> SELL_CHECK{"currentIdx > 0 ?
(grid_index 不是最低点)"}
+ SELL_CHECK -->|"Yes 可挂卖单"| SELL_EXIST{"已存在同 remark 卖单?
remark='SELL,{idx-1},{code}'"}
+ SELL_EXIST -->|"No 没有"| SELL_PLACE["下卖出单 (STOCK_SELL)
价格 = grid[sellIdx]
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 ?
(grid_index 不是最高点)"}
+ BUY_CHECK -->|"Yes 可挂买单"| BUY_EXIST{"已存在同价同类型买单?
order_type=BUY AND price=buyPrice"}
+ BUY_EXIST -->|"No 没有"| BUY_PLACE["下买入单 (STOCK_BUY)
价格 = grid[buyIdx]
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
(默认建仓位置)"]
+ INIT_IDX -->|"No"| KEEP["保留现有 grid_index"]
+ SET1 --> REFRESH1["refreshGridOrder()"]
+ KEEP --> REFRESH1
+
+ STATUS -->|"= 1 已建仓"| CALC["计算最小需求仓位
min = grid_volume × grid_index"]
+ CALC --> CHECK{"current_position >= min ?"}
+ CHECK -->|"Yes 充足"| REFRESH2["refreshGridOrder()"]
+ CHECK -->|"No 不足"| DENY["拒绝启用
enabled = False
(风控保护)"]
+
+ BRANCH -->|"False 停用"| CANCEL["取消所有未成交订单
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()
返回 seq"]
+ PUSH_ERR["C扩展推送
XtOrderError"]
+ PUSH_RESP["C扩展推送
XtOrderResponse"]
+ PUSH_TRADE["C扩展推送
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
'{type},{gridIdx},{stockCode}'"]
+ PARSE --> CHK2{"len(parts) >= 3 ?"}
+ CHK2 -->|"No"| EXIT1
+ CHK2 -->|"Yes"| CHK3{"strategy_name == 'SFGRID'
AND
stockCode 匹配本标的 ?"}
+ CHK3 -->|"No 不匹配"| EXIT1
+ CHK3 -->|"Yes"| LOCK["获取 dataUpdateLock"]
+ LOCK --> DEL{"gridIdx in orderGrid ?"}
+ DEL -->|"Yes"| REMOVE["del orderGrid[gridIdx]
清理孤立条目"]
+ DEL -->|"No"| LOG_ERR["记录错误日志
error_id / error_msg"]
+ REMOVE --> LOG_ERR
+ LOG_ERR --> UNLOCK["释放 dataUpdateLock"]
+```
+
+---
+
+## 6. onOrderCreateAsync() 订单确认
+
+```mermaid
+flowchart TD
+ START["onOrderCreateAsync(response)"] --> PARSE["解析 remark
'{type},{gridIdx},{stockCode}'"]
+ PARSE --> FILTER{"strategy_name == 'SFGRID'
AND len(parts) >= 3
AND stockCode 匹配 ?"}
+ FILTER -->|"No"| EXIT["忽略"]
+ FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
+ LOCK --> UPDATE["orderGrid[gridIdx] = response.order_id
seq → order_id 替换"]
+ UPDATE --> UNLOCK["释放 dataUpdateLock"]
+```
+
+---
+
+## 7. onOrderTrade() 成交处理
+
+```mermaid
+flowchart TD
+ START["onOrderTrade(trade)"] --> PARSE["解析 remark
'{type},{gridIdx},{stockCode}'"]
+ PARSE --> FILTER{"strategy_name == 'SFGRID'
AND len(parts) >= 3
AND stockCode 匹配 ?"}
+ FILTER -->|"No"| EXIT["忽略"]
+ FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
+
+ LOCK --> TYPE{"orderType ?"}
+
+ TYPE -->|"INIT 建仓单"| INIT["status = 1
init_price = traded_price
grid_index = 1"]
+ TYPE -->|"BUY / SELL 网格单"| CMP{"gridIdx vs grid_index ?"}
+
+ CMP -->|"gridIdx > grid_index
(买入成交)"| DOWN["grid_index += 1
下移一格"]
+ CMP -->|"gridIdx < grid_index
(卖出成交)"| UP["grid_index -= 1
上移一格
match_count += 1
total_profit += grid_size × volume"]
+ CMP -->|"gridIdx == grid_index
(异常)"| SAME["日志: 理论上不应该输出"]
+
+ INIT --> POST
+ DOWN --> POST
+ UP --> POST
+ SAME --> POST
+
+ POST["成交后处理"] --> SAVE["saveProxy() 持久化状态"]
+ SAVE --> DEL["del orderGrid[gridIdx]
移除已成交订单"]
+ DEL --> REPORT["打印成交报告
成交价/量/手续费"]
+ REPORT --> REFRESH["refreshGridOrder()
在新位置挂新的网格单"]
+ REFRESH --> UNLOCK["释放 dataUpdateLock"]
+```
+
+---
+
+## 8. 网格交易完整状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> 未建仓: 创建 SFGridStrategy
+ 未建仓 --> 建仓中: enabledTrading(True)
下建仓单 INIT
+
+ 建仓中 --> 已建仓: onOrderTrade(INIT)
建仓单成交
+ 建仓中 --> 建仓失败: onOrderError(INIT)
委托被拒
+
+ 建仓失败 --> 建仓中: refreshGridOrder()
重新下建仓单
+
+ 已建仓 --> 网格运行: refreshGridOrder()
上下各挂一单
+
+ 网格运行 --> 网格运行: onOrderTrade(SELL)
卖出成交 → 上移
重新挂单
+ 网格运行 --> 网格运行: onOrderTrade(BUY)
买入成交 → 下移
重新挂单
+ 网格运行 --> 单边挂单: onOrderError
某方向委托失败
+
+ 单边挂单 --> 网格运行: refreshGridOrder()
重新补挂失败方向的单
+
+ 已建仓 --> 已停用: enabledTrading(False)
取消所有挂单
+ 网格运行 --> 已停用: enabledTrading(False)
+ 单边挂单 --> 已停用: enabledTrading(False)
+
+ 已停用 --> 已建仓: enabledTrading(True)
仓位检查通过
+ 已停用 --> 已停用: enabledTrading(True)
仓位不足,回退
+```
+
+---
+
+## 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(每格利润空间)
+```
diff --git a/config.ini b/config.ini
deleted file mode 100644
index b5694bc..0000000
--- a/config.ini
+++ /dev/null
@@ -1,5 +0,0 @@
-[config]
-miniqmtpath = C:/Programs/GJQMT/userdata_mini
-account_no = 8882874667
-log_level = INFO
-
diff --git a/config.py b/config.py
index d1a4f47..411e2ed 100644
--- a/config.py
+++ b/config.py
@@ -1,65 +1,25 @@
-import configparser
-from pathlib import Path
+"""
+运行时配置 — 端口、路径、账号由自动探测设置,无需配置文件。
+"""
+import os
import sys
+from pathlib import Path
-miniQMTPath = r'D:\\Programs\\DTQMT\\userdata_mini' # miniQMT软件的安装路径
-# miniQMTPath = ''
-account_no:str = '99082560'
-console_log = True
-log_level = "INFO"
+# ---- 自动探测的配置项(默认值仅占位,启动时自动修正) ----
+miniQMTPath: str = ''
+account_no: str = ''
+log_level: str = 'INFO'
+console_log: bool = True
use_simulated_qmt: bool = False
-def get_config_path() -> Path:
- """获取配置文件的正确路径(兼容开发环境和打包后的可执行文件)"""
+
+def app_dir() -> Path:
+ """应用根目录(兼容开发环境与打包后的 exe)"""
if getattr(sys, 'frozen', False):
- # 打包后的可执行文件环境
- # sys._MEIPASS是PyInstaller解压临时文件的目录
- # 配置文件应该放在可执行文件同目录下
- base_path = Path(sys.executable).parent
- else:
- # 开发环境
- base_path = Path(__file__).resolve().parent
-
- return base_path / 'config.ini'
+ return Path(sys.executable).parent
+ return Path(__file__).resolve().parent
-def save_config(miniQmtPath:str, account_no:str, use_simulated_qmt: bool = False):
- """创建默认配置文件"""
- config = configparser.ConfigParser()
- 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
+def log_file_path() -> Path:
+ """日志文件路径"""
+ return app_dir() / 'sfgrid.log'
diff --git a/core/constants.py b/core/constants.py
index dd64a00..ea76ed7 100644
--- a/core/constants.py
+++ b/core/constants.py
@@ -2,5 +2,4 @@ import xtquant.xtconstant as xtconstant
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
-OrderTypeInit = "0" # 建仓
OrderTypeNone = "None"
\ No newline at end of file
diff --git a/core/logger.py b/core/logger.py
index 019bc09..09294ad 100644
--- a/core/logger.py
+++ b/core/logger.py
@@ -1,4 +1,6 @@
+from datetime import datetime
from enum import Enum
+import threading
from core.eventbus import EventPrintLog, event_bus
import config
@@ -10,17 +12,38 @@ class LogLevel(Enum):
WARNING = 2
ERROR = 3
CRITICAL = 4
-
+
def __le__(self, other):
return self.value <= other.value
+
class LogData:
- def __init__(self, level:LogLevel, message:str):
+ def __init__(self, level: LogLevel, message: str):
self.level = level
self.message = message
-def PrintLog(level:LogLevel, message:str):
+
+_log_lock = threading.Lock()
+
+
+def _log_file_path():
+ """日志文件路径"""
+ return str(config.log_file_path())
+
+
+def PrintLog(level: LogLevel, message: str):
data = LogData(level, message)
event_bus.publish(EventPrintLog, data)
+
+ line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} [{level.name}] {message}'
+
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 # 写文件失败不阻塞主流程
diff --git a/core/qmt.py b/core/qmt.py
index 8aa8880..bffe0fd 100644
--- a/core/qmt.py
+++ b/core/qmt.py
@@ -9,17 +9,21 @@ import config as _config
def _get_qmt():
"""获取 QMT 模块(配置优先于平台检测)"""
if _config.use_simulated_qmt:
+ print('[qmt] 配置指定模拟模式 → qmt_dummy')
from core.qmt_dummy import qmtv
return qmtv
if sys.platform == 'win32':
try:
+ print('[qmt] Windows 平台,尝试加载 qmt_real...')
from core.qmt_real import qmtv as real_qmtv
+ print('[qmt] qmt_real 加载成功')
return real_qmtv
- except ImportError:
- pass
+ except ImportError as e:
+ print(f'[qmt] qmt_real 加载失败: {e},回退 qmt_dummy')
# 非 Windows 或导入失败,使用模拟器
+ print('[qmt] 使用模拟模式 qmt_dummy')
from core.qmt_dummy import qmtv
return qmtv
diff --git a/core/qmt_dummy.py b/core/qmt_dummy.py
index 3634738..19207dd 100644
--- a/core/qmt_dummy.py
+++ b/core/qmt_dummy.py
@@ -121,6 +121,14 @@ class DummyQmtV:
return type('DummyPos', (), pos)()
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:
"""查询挂单"""
return [o for o in self._pending_orders
diff --git a/core/qmt_real.py b/core/qmt_real.py
index 893ceda..9e6ee09 100644
--- a/core/qmt_real.py
+++ b/core/qmt_real.py
@@ -2,6 +2,8 @@
QMT 真实交易实现 - 封装 xtquant SDK
"""
import datetime
+import os
+import subprocess
import threading
import time
import config
@@ -15,6 +17,185 @@ class RealQmtV:
封装 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
def _to_plain_code(stock_code: str) -> str:
"""将 xtquant 格式 '600519.SH' 转换为数据库格式 '600519'"""
@@ -33,15 +214,6 @@ class RealQmtV:
# fallback: try both, prefer 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:
self.inited = False
self.connected = False
@@ -66,9 +238,11 @@ class RealQmtV:
self.mini_qmt_path = config.miniQMTPath
self.account = StockAccount(config.account_no, 'STOCK')
+ PrintLog(LogLevel.INFO, f'[QMT] 初始化: path={self.mini_qmt_path}, account={config.account_no[-4:]}****')
# 创建 XtQuantTrader 实例
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)
# 注册回调 — xtquant 只接受一个回调对象,会在上面调用 on_xxx 方法
@@ -81,30 +255,62 @@ class RealQmtV:
PrintLog(LogLevel.ERROR, f'- [失败] QMT 初始化: {e}')
def connect(self) -> bool:
- """连接 MiniQMT"""
+ """连接 MiniQMT,失败自动探测端口并重试"""
if not self.inited:
- PrintLog(LogLevel.ERROR, '- [失败] QMT 未初始化')
+ PrintLog(LogLevel.ERROR, '[QMT] 连接失败: 未初始化')
return False
- try:
- # 启动 trader 线程
+ _connect_errors = {
+ 0: '成功',
+ -1: '一般错误(miniQMT 可能未启动)',
+ -2: 'miniQMT 未运行(请先启动极简QMT)',
+ -3: '连接超时',
+ }
+
+ def _do_connect() -> int:
self.xt_trader.start()
- # 建立连接
- connect_result = self.xt_trader.connect()
+ PrintLog(LogLevel.INFO, '[QMT] xt_trader.start() 完成')
+ 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:
- # 订阅账户 (传入 StockAccount 对象而不是 account_id 字符串)
+ PrintLog(LogLevel.INFO, f'[QMT] 订阅账户...')
self.xt_trader.subscribe(self.account)
- # 等待回调
- time.sleep(1)
+ PrintLog(LogLevel.INFO, '[QMT] 订阅完成')
self.connected = True
self.startMarketDataSubscription()
- PrintLog(LogLevel.INFO, f'- [成功] 真实交易连接成功 (账号: {config.account_no})')
+ PrintLog(LogLevel.INFO, f'[QMT] 连接成功 (账号: {config.account_no[-4:]}****)')
return True
else:
- PrintLog(LogLevel.ERROR, f'- [失败] 连接失败, 返回码: {connect_result}')
+ PrintLog(LogLevel.ERROR, f'[QMT] 连接失败: {result_desc}')
return False
except Exception as e:
- PrintLog(LogLevel.ERROR, f'- [失败] 连接异常: {e}')
+ PrintLog(LogLevel.ERROR, f'[QMT] 连接异常: {e}')
return False
def getAllPositions(self) -> dict:
@@ -144,18 +350,41 @@ class RealQmtV:
return None
def queryPendingOrder(self, stock_code: str, tag: str) -> list:
- """查询挂单"""
+ """查询挂单(过滤已撤/废单)"""
if not self.connected:
return []
try:
orders = self.xt_trader.query_stock_orders(self.account)
+ # 过滤已撤(54)和废单(57),避免策略误判"已有挂单"跳过下单
+ _CANCELED = {54, 57}
return [o for o in orders
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:
PrintLog(LogLevel.ERROR, f'- [查询挂单失败] {e}')
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):
"""异步下单"""
if not self.connected:
@@ -328,7 +557,8 @@ class RealQmtV:
# xtquant 返回 "600519.SH" 格式 key,UI 使用纯代码 "600519"
# 构建同时包含两种 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):
"""行情活跃监控 — 超过 30 秒无数据则标记市场不活跃"""
@@ -349,10 +579,10 @@ class RealQmtV:
# ---- xtquant 回调处理 (xtquant 通过回调对象调用 on_xxx 方法) ----
def on_connected(self):
- print(datetime.datetime.now(), '真实 QMT 连接成功')
+ PrintLog(LogLevel.INFO, f'[QMT] on_connected: 真实 QMT 连接成功 {datetime.datetime.now()}')
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):
self._pending_orders.append(order)
@@ -364,11 +594,13 @@ class RealQmtV:
eBus.event_bus.publish(eBus.MarketOrderCreated, response)
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)
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()
diff --git a/core/sfgrid/model.py b/core/sfgrid/model.py
index b702581..f7f221d 100644
--- a/core/sfgrid/model.py
+++ b/core/sfgrid/model.py
@@ -16,7 +16,7 @@ class SFGridTradeTarget(BaseModel):
init_price = FloatField(null=True) # 建仓成本
grid_match_count = IntegerField(default=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) # 是否启动交易线程
strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略
diff --git a/core/sfgrid/sfgrid_strategy.py b/core/sfgrid/sfgrid_strategy.py
index ea2ce2f..6a5bd1a 100644
--- a/core/sfgrid/sfgrid_strategy.py
+++ b/core/sfgrid/sfgrid_strategy.py
@@ -25,7 +25,7 @@ from core.sfgrid import bus_events
from core.sfgrid.bus_events import EventTradeTargetUpdate
import core.sfgrid.model as model
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.xttype import XtOrderError, XtOrderResponse, XtTrade
@@ -65,8 +65,7 @@ class SFGridStrategy:
PrintLog(LogLevel.INFO,
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
- f'grid_index={tradeTarget.grid_index}, status={tradeTarget.status}, '
- f'enabled={tradeTarget.enabled}')
+ f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
@@ -149,30 +148,10 @@ class SFGridStrategy:
# 获取当前该标的所有未成交订单
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
- # ── 分支1: status=0 未建仓 → 下建仓单 ──
- # 条件: 标的尚未建仓 且 不存在正在执行中的建仓单(防止重复建仓)
- init_remark = self._make_remark(OrderTypeInit, 1)
- if self.tradeTarget.status == 0 and not any(
- 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:
+ # ── 统一网格逻辑 ──
+ # grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖
+ # grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1]
+ if self.tradeTarget.grid_index >= 0:
currentIdx = self.tradeTarget.grid_index # type: ignore
# --- 上方挂卖出单(空单)---
@@ -272,19 +251,16 @@ class SFGridStrategy:
启用或停用该标的的网格交易
启用时 (enabled=True):
- - status=0: 初始化网格索引后调用 refreshGridOrder 下建仓单
- - status=1: 检查持仓是否满足当前网格位置要求,满足则刷新网格订单
- 不满足则回退 enabled=False(风控保护)
+ - grid_index=0 空仓: 直接调用 refreshGridOrder(只挂买单)
+ - grid_index>0 有仓: 检查持仓是否满足 grid_volume × grid_index
+ 满足则刷新网格单,不满足则回退 enabled=False(风控保护)
停用时 (enabled=False):
- 取消该标的所有未成交订单,停止交易监控
-
- 返回:
- 更新后的 tradeTarget 对象
"""
PrintLog(LogLevel.INFO,
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
@@ -294,38 +270,23 @@ class SFGridStrategy:
f" |- 标的{self.tradeTarget.targetName()}交易启动, "
f"持仓量:{self.tradeTarget.current_position}")
- if self.tradeTarget.status == 0:
- # 未建仓状态: 初始化网格索引
- if self.tradeTarget.grid_index == 0:
- # grid_index=0 表示从未初始化过,设为 1(价格网格最高点建仓)
- self.tradeTarget.grid_index = 1 # pyright: ignore[reportAttributeAccessIssue]
- PrintLog(LogLevel.INFO,
- f" |- 标的{self.tradeTarget.targetName()}初始状态, "
- f"设置网格序号 1,")
- else:
- # grid_index 非零,保留之前设置的值(可能是手动修改的)
- PrintLog(LogLevel.INFO,
- f" |- 标的{self.tradeTarget.targetName()}初始状态, "
- f"保留网格序号 {self.tradeTarget.grid_index},")
+ if self.tradeTarget.grid_index == 0:
+ # 空仓: refreshGridOrder 会在 grid[1] 挂第一笔买单
+ PrintLog(LogLevel.INFO,
+ f" |- 标的{self.tradeTarget.targetName()}空仓, "
+ f"等待首次买入建仓")
else:
- # 已建仓状态: 检查现有持仓是否满足当前网格位置的仓位需求
+ # 有仓: 检查现有持仓是否满足当前网格位置的仓位需求
# 最小需求仓位 = 每格股数 × 当前网格索引
# 例: 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
if minRequirePosition <= int(self.tradeTarget.current_position): # type: ignore
- # 持仓充足,可以继续网格交易
PrintLog(LogLevel.INFO,
f' |- 仓位检查: 持仓需求充足, '
f'(gridVolume*gridIndex)={minRequirePosition}, '
f'当前持仓:{self.tradeTarget.current_position}')
else:
- # 持仓不足(可能是之前部分成交或手动减仓),风控:拒绝启用
PrintLog(LogLevel.INFO,
f' |- 仓位检查: 持仓需求不足, '
f'(gridVolume*gridIndex)={minRequirePosition}, '
@@ -333,7 +294,7 @@ class SFGridStrategy:
f'交易启动失败')
self.tradeTarget.enabled = False # type: ignore
- # 无论 status=0 还是 status=1,最终都调用 refreshGridOrder 下对应的单
+ # 刷新网格订单(空仓只挂买单,有仓买卖对冲)
self.refreshGridOrder()
else:
@@ -429,20 +390,17 @@ class SFGridStrategy:
"""
QMT 委托成交通知回调
- 成交后:
- 1. 更新网格索引(卖出上移 / 买入下移)
- 2. 如果是建仓单成交: status 0→1, 记录建仓价
- 3. 如果是网格单成交: 累计网格匹配次数和总利润
- 4. 从 orderGrid 删除已成交订单
- 5. 持久化状态到数据库
- 6. 调用 refreshGridOrder 挂新的网格单
-
- trade.order_remark 格式: "{type},{gridIdx},{stockCode}"
+ 收到成交后:
+ 1. 判断成交方向(买入下移 / 卖出上移)→ 更新 grid_index
+ 2. 首次建仓(grid_index==0 时成交)→ 记录 init_price
+ 3. 卖出成交 → 累计 grid_match_count 和 grid_total_profit
+ 4. 清理 orderGrid → 持久化 → 刷新网格挂单
"""
+ # ── 过滤:只处理本策略本标的的成交 ──
parsed = self._filter_event(trade.order_remark, trade.strategy_name)
if parsed is None:
return
- orderType, gridIdx, _ = parsed
+ _, gridIdx, _ = parsed # gridIdx: 成交订单对应的网格索引(int)
PrintLog(LogLevel.INFO,
f'|- 委托成交通知'
@@ -451,61 +409,45 @@ class SFGridStrategy:
self.dataUpdateLock.acquire()
try:
- desc: str = "" # 用于日志展示的成交类型描述
-
- # ── 分支1: 建仓单成交 ──
- if orderType == OrderTypeInit:
- PrintLog(LogLevel.INFO,
- f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] '
- f'- 建仓单成交')
- # 状态切换: 未建仓(0) → 已建仓(1)
- self.tradeTarget.status = 1 # type: ignore
- # 记录建仓价格
+ # ── 首次建仓:记录建仓价 ──
+ # grid_index==0 表示成交前处于空仓状态,这笔成交就是首次建仓
+ if self.tradeTarget.grid_index == 0:
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: 网格单成交 ──
+ # ── 网格方向判断 ──
+ # 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
+ oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
+
+ if gridIdx > self.tradeTarget.grid_index:
+ # 成交单在下方(更大索引 = 更低价格)→ 买入成交,持仓下移
+ self.tradeTarget.grid_index += 1 # type: ignore
+ # 首次建仓时 oriIdx==0,加上"建仓单"前缀便于识别
+ desc = "建仓单(下移)" if oriIdx == 0 else "下移一格"
+
+ 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 = "上移一格"
+
else:
- PrintLog(LogLevel.INFO,
- f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] '
- f'- 网格单成交')
- oriIdx = self.tradeTarget.grid_index # 记录原网格位置(用于日志)
+ # gridIdx == grid_index: 同格成交,正常情况下不会出现
+ desc = "同格(异常)"
- # 判断成交方向: gridIdx > currentIdx → 买入成交(下移)
- if gridIdx > self.tradeTarget.grid_index:
- desc = "下移一格"
- self.tradeTarget.grid_index += 1
+ PrintLog(LogLevel.INFO,
+ f'|- [{self.tradeTarget.targetName()}] '
+ f'原网格 {oriIdx} → 现网格 {self.tradeTarget.grid_index}'
+ f'({desc})')
- # 判断成交方向: gridIdx < currentIdx → 卖出成交(上移)
- elif gridIdx < self.tradeTarget.grid_index:
- 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:
- desc = "保持格, 理论上不应该输出"
-
- PrintLog(LogLevel.INFO,
- f'|- 委托成交通知'
- f'[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name} - '
- f'原网格位置 {oriIdx}, 现网格位置 {self.tradeTarget.grid_index}')
-
- # ── 成交后处理 ──
- # 1. 持久化状态到数据库
+ # ── 成交后统一处理 ──
+ # 1. 持久化状态到数据库(grid_index、持仓量等已变更)
self.saveProxy()
- # 2. 从 orderGrid 中删除已成交的订单(pop 防重复推送 KeyError)
+ # 2. 从 orderGrid 清理已成交订单(pop 防 xtquant 重复推送 KeyError)
self.orderGrid.pop(gridIdx, None)
-
# 3. 打印成交报告
PrintLog(LogLevel.INFO,
f"|- 成交报告[{self.tradeTarget.targetName()}] : "
@@ -517,8 +459,7 @@ class SFGridStrategy:
f' 成交价: {trade.traded_price} 成交量: {trade.traded_volume}')
PrintLog(LogLevel.INFO,
f' 手续费 : {trade.commission:.3f}')
-
- # 4. 刷新网格订单:在新的 grid_index 上下重新挂买卖单
+ # 4. 刷新网格订单:在新的 grid_index 位置重新挂买卖单
self.refreshGridOrder()
finally:
@@ -571,7 +512,7 @@ class SFGridStrategy:
"""
PrintLog(LogLevel.DEBUG,
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()
event_bus.publish(EventTradeTargetUpdate, self.tradeTarget)
return rc
diff --git a/core/ui/__init__.py b/core/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/ui/flet/__init__.py b/core/ui/flet/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/ui/flet/app.py b/core/ui/flet/app.py
new file mode 100644
index 0000000..8d329c4
--- /dev/null
+++ b/core/ui/flet/app.py
@@ -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)
diff --git a/core/ui/flet/app_v2.py b/core/ui/flet/app_v2.py
new file mode 100644
index 0000000..8b50e58
--- /dev/null
+++ b/core/ui/flet/app_v2.py
@@ -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)
diff --git a/core/ui/tkinter/__init__.py b/core/ui/tkinter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/main_ui.py b/core/ui/tkinter/main_window.py
similarity index 92%
rename from core/main_ui.py
rename to core/ui/tkinter/main_window.py
index d5e76b9..2a09504 100644
--- a/core/main_ui.py
+++ b/core/ui/tkinter/main_window.py
@@ -1,7 +1,7 @@
import tkinter as tk
from tkinter import ttk
from core.logger import LogLevel, LogData, PrintLog
-from core.sfgrid.sfgrid_ui import TradeTargetUI
+from core.ui.tkinter.sfgrid_view import TradeTargetUI
# 检测运行环境,决定使用真实或模拟 QMT
def get_qmt_module():
@@ -20,7 +20,7 @@ from core.eventbus import event_bus as eBus
class MainWindow:
- def __init__(self, configLogLevel:str):
+ def __init__(self, configLogLevel:str, progress=None):
self.root = tk.Tk()
self.root.title("神之一手 - 交易系统")
self.root.geometry("1400x700")
@@ -31,12 +31,12 @@ class MainWindow:
self.strategy_frames = {}
# 日志面板可见性标志
self.log_visible = False
- self.create_ui()
+ self.create_ui(progress)
eBus.subscribe(EventPrintLog, self.on_log_event)
- def create_ui(self):
+ def create_ui(self, progress=None):
"""创建UI界面"""
# 主容器
main_container = ttk.Frame(self.root)
@@ -52,7 +52,7 @@ class MainWindow:
# 创建策略Frame
strategy_names = ["网格"]
- self.create_strategy_frames(strategy_names)
+ self.create_strategy_frames(strategy_names, progress)
# 创建全局日志面板(默认隐藏)
self.create_global_log_panel(main_container)
@@ -111,9 +111,9 @@ class MainWindow:
for item in self.log_table.get_children():
self.log_table.delete(item)
- def create_strategy_frames(self, strategy_names):
+ def create_strategy_frames(self, strategy_names, progress=None):
"""创建各个策略的Frame"""
- frame = TradeTargetUI(self.content_container)
+ frame = TradeTargetUI(self.content_container, progress=progress)
self.strategy_frames[0] = frame
def show_strategy_frame(self, index):
diff --git a/core/sfgrid/sfgrid_ui.py b/core/ui/tkinter/sfgrid_view.py
similarity index 77%
rename from core/sfgrid/sfgrid_ui.py
rename to core/ui/tkinter/sfgrid_view.py
index 9840f31..8bfd36b 100644
--- a/core/sfgrid/sfgrid_ui.py
+++ b/core/ui/tkinter/sfgrid_view.py
@@ -14,7 +14,9 @@ from core.sfgrid.sfgrid_strategy import SFGridStrategy
class TradeTargetUI(ttk.Frame):
- def __init__(self, parent):
+ def __init__(self, parent, progress=None):
+ import time as _time
+ _t0 = _time.time()
super().__init__(parent)
self.tradeTargetData:dict[int, SFGridTradeTarget] = {} # id->trade_target
self.stockCodeIdMap:dict[str, int] = {}
@@ -27,41 +29,48 @@ class TradeTargetUI(ttk.Frame):
# 追踪最后点击的表格 (0=网格, 1=未分类)
self._active_table = 0
+ if progress:
+ progress("正在加载持仓数据...", 0.3)
self.init_trade_target_pool()
-
+
# 市场监控数据
self.marketData: dict[str, Any] = {} # 存储市场数据 {stock_code: {stock_name, last_price, time}}
-
- # 市场监控窗口显示状态
+
+ # 面板显示状态
self.market_monitor_visible = True
+ self.bottom_panel_visible = True
# 市场活跃状态 + 刷新控制
self._market_active = qmtv.isMarketActive # type: ignore
self._refresh_event = threading.Event()
+ self._refresh_cycle = 0 # 后台刷新周期计数
self._prices_pulled_after_close = False # 收盘后是否已拉取过
+ if progress:
+ progress("正在构建界面...", 0.6)
# 创建界面
self.create_ui()
+
+ if progress:
+ progress("正在初始化策略...", 0.85)
eBus.event_bus.subscribe(eBus.MarketDataUpdate, self.onMarketDataUpdated)
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.EventTradeTargetDeleted, self.onTradeTargetDeleted)
+ print(f'[计时] TradeTargetUI.__init__ 总计: {_time.time() - _t0:.2f}s')
def init_trade_target_pool(self):
- # 一次性迁移: 已配置过的标的 (status >= 0) → 网格策略
- from core.sfgrid.model import STRATEGY_TYPE_GRID
- migrated = SFGridTradeTarget.update(strategy_type=STRATEGY_TYPE_GRID).where(
- SFGridTradeTarget.status >= 0
- ).execute()
- if migrated:
- PrintLog(LogLevel.INFO, f'- [迁移] {migrated} 个已配置标的标记为网格策略')
+ import time as _time
+ _t = _time.time()
# 一次性从 QMT 获取全部持仓
all_positions = qmtv.getAllPositions()
PrintLog(LogLevel.INFO, f'- [持仓] 从 QMT 获取到 {len(all_positions)} 个持仓')
+ print(f'[计时] └─ getAllPositions: {_time.time() - _t:.2f}s')
# 自动将 QMT 持仓导入到数据库(持仓但未在交易池中的标的)
+ _t2 = _time.time()
imported_count = 0
for code, pos in all_positions.items():
existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code)
@@ -77,7 +86,6 @@ class TradeTargetUI(ttk.Frame):
init_price=avg_price,
grid_match_count=0,
grid_total_profit=0.0,
- status=-1,
enabled=False,
grid_start_price=avg_price if avg_price > 0 else 10.0,
grid_size=1.0,
@@ -89,7 +97,9 @@ class TradeTargetUI(ttk.Frame):
PrintLog(LogLevel.INFO, f'- [导入] QMT持仓 → 交易池: {code} {name} 持仓:{volume} 成本:{avg_price:.4f}')
if imported_count:
PrintLog(LogLevel.INFO, f'- [导入] 共新增 {imported_count} 个标的到交易池')
+ print(f'[计时] └─ 持仓导入DB: {_time.time() - _t2:.2f}s ({imported_count} 个新增)')
+ _t3 = _time.time()
results = SFGridTradeTarget.select()
for temp in results:
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}')
self.updateTradeTarget(tradeTarget, True) # 初始化的时候
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)} 个标的')
@@ -111,14 +123,14 @@ class TradeTargetUI(ttk.Frame):
# 收集所有市场数据用于市场监控
def onMarketDataUpdated(self, data):
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']
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
- # PrintLog(LogLevel.INFO, f'|- 市价更新[{tradeTarget.targetName()}] - {timeStr.strftime("%H:%M:%S")} 市价更新: {lastPrice}======================{id}')
self.updateTradeTarget(tradeTarget, False) # 市价更新
else:
# 非目标交易,发布市场数据更新事件用于市场监控
@@ -141,6 +153,8 @@ class TradeTargetUI(ttk.Frame):
def onStrategyUpdate(self, target: SFGridTradeTarget):
id = target.get_id()
self.tradeTargetData[id] = target
+ # 唤醒 refresh_loop 立即刷新表格,统一由 refresh_table 处理
+ self._refresh_event.set()
# priceChange 用于控制是否对更新价格数据,进行交易判断
@@ -170,16 +184,21 @@ class TradeTargetUI(ttk.Frame):
# UI CREATE
def create_ui(self):
"""创建UI界面"""
+ import time as _time
+ _t = _time.time()
+
# 主框架(使用self作为父容器)
main_frame = ttk.Frame(self)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 表格区域(左右布局:左侧=工具栏+持仓表格,右侧=Notebook标签页)
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.start()
+ print(f'[计时] └─ create_ui 总计: {_time.time() - _t:.2f}s')
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.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.clear()
@@ -263,8 +289,16 @@ class TradeTargetUI(ttk.Frame):
command=self.btnHandlerStopSelectedTrade, width=12).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar_frame, text="🛠 交易设置",
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)
@@ -272,9 +306,9 @@ class TradeTargetUI(ttk.Frame):
self.create_grid_table(grid_frame)
# 下半部分: 未分类持仓
- unclassified_frame = ttk.LabelFrame(left_frame, text="未分类持仓", padding=5)
- unclassified_frame.pack(fill=tk.BOTH, expand=True, pady=(3, 0))
- self.create_unclassified_table(unclassified_frame)
+ self.unclassified_frame = ttk.LabelFrame(left_frame, text="未分类持仓", padding=5)
+ self.unclassified_frame.pack(fill=tk.BOTH, expand=True, pady=(3, 0))
+ self.create_unclassified_table(self.unclassified_frame)
# 右侧: Notebook 标签页容器
self.right_notebook = ttk.Notebook(tables_frame)
@@ -297,17 +331,18 @@ class TradeTargetUI(ttk.Frame):
self.create_market_monitor_table(self.market_frame)
- # Tab 2: 订单记录 (占位)
- model_tab = ttk.Frame(self.right_notebook)
- self.right_notebook.add(model_tab, text="订单记录")
- ttk.Label(model_tab, text="订单记录 - 待实现", font=('Arial', 12),
- foreground='gray').pack(expand=True)
+ # Tab 2: 当日委托
+ self.order_tab = ttk.Frame(self.right_notebook)
+ self.right_notebook.add(self.order_tab, text="当日委托")
+ self._create_order_table(self.order_tab)
- # Tab 3: 成交记录 (占位)
- dataset_tab = ttk.Frame(self.right_notebook)
- self.right_notebook.add(dataset_tab, text="成交记录")
- ttk.Label(dataset_tab, text="成交记录 - 待实现", font=('Arial', 12),
- foreground='gray').pack(expand=True)
+ # Tab 3: 当日成交
+ self.trade_tab = ttk.Frame(self.right_notebook)
+ self.right_notebook.add(self.trade_tab, text="当日成交")
+ self._create_trade_table(self.trade_tab)
+
+ # Tab 切换时自动刷新
+ self.right_notebook.bind("<>", self._on_tab_changed)
def create_grid_table(self, parent):
@@ -410,7 +445,126 @@ class TradeTargetUI(ttk.Frame):
# 填充初始数据
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):
"""填充市场监控表格数据"""
# 保存当前选中的项
@@ -490,9 +644,9 @@ class TradeTargetUI(ttk.Frame):
def get_trade_enabled_indicator(self, target: SFGridTradeTarget) -> str:
"""获取交易状态指示器"""
- if target.status == -1:
+ if target.strategy_type == 0: # 未分类 = 未配置
return "请做交易设置"
- elif target.status >= 0:
+ else:
if target.enabled:
return "▶ 运行中"
else:
@@ -550,19 +704,20 @@ class TradeTargetUI(ttk.Frame):
def populate_grid_table(self):
"""填充网格策略表格数据"""
+ # 先清空所有行再重建,防止重复
+ self.grid_table.delete(*self.grid_table.get_children())
for id, target in self.tradeTargetData.items():
from core.sfgrid.model import STRATEGY_TYPE_GRID
if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore
continue
+ grid_idx = target.grid_index
+ price_grid = target.getPriceGrid()
grid_info = '-'
- if target.status >= 0:
- grid_idx = target.grid_index
- price_grid = target.getPriceGrid()
- if 0 <= grid_idx < len(price_grid):
- grid_info = f'{grid_idx}({price_grid[grid_idx]:.2f}元)'
- else:
- grid_info = f'{grid_idx}(?)'
+ if 0 <= grid_idx < len(price_grid):
+ grid_info = f'{grid_idx}({price_grid[grid_idx]:.2f}元)'
+ else:
+ grid_info = f'{grid_idx}(?)'
values = [
id,
@@ -578,6 +733,8 @@ class TradeTargetUI(ttk.Frame):
def populate_unclassified_table(self):
"""填充未分类持仓表格数据"""
+ # 先清空所有行再重建,防止重复
+ self.unclassified_table.delete(*self.unclassified_table.get_children())
for id, target in self.tradeTargetData.items():
from core.sfgrid.model import STRATEGY_TYPE_UNCLASSIFIED
if target.strategy_type != STRATEGY_TYPE_UNCLASSIFIED: # type: ignore
@@ -629,6 +786,18 @@ class TradeTargetUI(ttk.Frame):
messagebox.showwarning("未选中", "请先选择一个交易标的")
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):
"""刷新表格数据(纯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)
# 建仓状态(可变更)
- status_text = "已建仓" if target.status >= 1 else "未建仓"
- status_color = "green" if target.status >= 1 else "orange"
+ status_text = "已建仓" if target.grid_index > 0 else "未建仓"
+ 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.grid(row=1, column=0, sticky=tk.W, pady=2)
def toggle_position_status():
- new_status = 0 if target.status >= 1 else 1
- setattr(target, 'status', new_status)
+ new_idx = 0 if target.grid_index > 0 else 1
+ setattr(target, 'grid_index', new_idx)
target.save()
- new_text = "已建仓" if new_status >= 1 else "未建仓"
- new_color = "green" if new_status >= 1 else "orange"
+ new_text = "已建仓" if new_idx > 0 else "未建仓"
+ new_color = "green" if new_idx > 0 else "orange"
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)
PrintLog(LogLevel.INFO, f"建仓状态变更: {target.stock_code} → {new_text}")
toggle_btn = ttk.Button(
info_frame,
- text="标记为未建仓" if target.status >= 1 else "标记为已建仓",
+ text="标记为未建仓" if target.grid_index > 0 else "标记为已建仓",
command=toggle_position_status
)
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_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)
# 创建网格配置框架
@@ -977,8 +1137,6 @@ class TradeTargetUI(ttk.Frame):
setattr(target, 'grid_volume', grid_volume)
setattr(target, 'grid_upper_count', grid_upper_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)
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),
grid_index=gridIndex,
init_price=0.0,
- status=-1,
strategy_type=0 # 默认为未分类
)
# 更新标的池
@@ -1094,6 +1251,19 @@ class TradeTargetUI(ttk.Frame):
else:
self.right_notebook.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
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):
"""网格配置功能"""
@@ -1109,71 +1279,61 @@ class TradeTargetUI(ttk.Frame):
self.create_grid_view_window(target)
def btnHandlerStartSelectedTrade(self):
- """启动选中的交易"""
- target = self.get_selected_target()
- if not target:
- return
-
+ """启动选中的交易(支持多选)"""
from core.sfgrid.model import STRATEGY_TYPE_GRID
- if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore
- messagebox.showinfo("提示", "该标的不属于网格策略,请先转为网格策略后再启动交易。")
+ targets = self.get_selected_targets()
+ if not targets:
+ messagebox.showwarning("未选中", "请先选择交易标的")
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
-
- 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
-
- result = messagebox.askyesno(
- "确认启动",
- f"确定要启动以下交易标的吗?\n\n"
- f"股票代码: {target.stock_code}\n"
- f"股票名称: {target.stock_name}"
- )
-
- if result:
+
+ for target in to_start:
PrintLog(LogLevel.INFO, f'启动标的交易 {target.targetName()}')
target.enabled = True # type: ignore
-
- id = target.get_id()
- if id in self.strategy_ctrl:
- tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()]
- tradeTarget = tradeController.enabledTrading(True)
- self.tradeTargetData[id] = tradeTarget
+ ctrl = self.strategy_ctrl.get(target.get_id())
+ if ctrl:
+ ctrl.enabledTrading(True)
else:
PrintLog(LogLevel.INFO, f"\t创建标的交易控制器 {target.targetName()}")
def btnHandlerStopSelectedTrade(self):
- """暂停选中的交易"""
- target = self.get_selected_target()
- if not target:
- return
-
+ """暂停选中的交易(支持多选)"""
from core.sfgrid.model import STRATEGY_TYPE_GRID
- if target.strategy_type != STRATEGY_TYPE_GRID: # type: ignore
- messagebox.showinfo("提示", "该标的不属于网格策略。")
+ targets = self.get_selected_targets()
+ if not targets:
+ messagebox.showwarning("未选中", "请先选择交易标的")
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
-
- result = messagebox.askyesno(
- "确认暂停",
- f"确定要暂停以下交易标的吗?\n\n"
- f"股票代码: {target.stock_code}\n"
- f"股票名称: {target.stock_name}"
- )
-
- if result:
+
+ # 确认对话框
+ names = "\n".join(f"{t.stock_code} {t.stock_name}" for t in to_stop)
+ result = messagebox.askyesno("确认暂停", f"确定要暂停以下 {len(to_stop)} 个交易标的吗?\n\n{names}")
+ if not result:
+ return
+
+ for target in to_stop:
PrintLog(LogLevel.INFO, f'暂停标的交易 {target.targetName()}')
- id = target.get_id()
- if id in self.strategy_ctrl:
- tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()]
- tradeController.enabledTrading(False)
+ ctrl = self.strategy_ctrl.get(target.get_id())
+ if ctrl:
+ ctrl.enabledTrading(False)
else:
print(f"标的交易控制器不存在 {target.stock_code} {target.stock_name}\n")
@@ -1288,3 +1448,86 @@ class TradeTargetUI(ttk.Frame):
PrintLog(LogLevel.INFO, f"监控价格已更新为: {new_price}")
except ValueError:
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('', self._on_click)
+ self.bind('', self._on_enter)
+ self.bind('', self._on_leave)
+ self.bind('