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('', lambda e: self._update_colors()) + self._update_colors() + + def _update_colors(self): + """从父容器读取实际背景色,推导图标配色""" + try: + bg = self.master.cget('background') or 'SystemButtonFace' + # 转 RGB 判断明暗 + rgb = self.winfo_rgb(bg) if bg.startswith('#') else None + if rgb: + r, g, b = rgb[0] >> 8, rgb[1] >> 8, rgb[2] >> 8 + bright = (r * 299 + g * 587 + b * 114) / 1000 + else: + bright = 240 # 系统颜色默认当作亮色 + except Exception: + bright = 240 + + if bright > 128: + # 亮色主题 + self._BG = '#e8e8e8' if bright < 220 else '#f0f0f0' + self._HOVER = '#d4d4d4' + self._OFF = '#b0b0b0' + self._ON = '#808080' + self._ACTIVE = '#0078d4' # Windows 蓝色 + else: + # 暗色主题 + self._BG = '#3c3c3c' + self._HOVER = '#505050' + self._OFF = '#6a6a6a' + self._ON = '#a0a0a0' + self._ACTIVE = '#ffffff' + + self.configure(bg=self._BG) + self._draw() + + def _draw(self): + self.delete('all') + m = 3 + s = self._SIZE - m * 2 + off = self._OFF + on = self._ON + + if self._kind == 'sidebar': + # 左右分栏图标,右边面板高亮 + l = self._ON if self._active else off + r = self._ACTIVE if self._active else off + self.create_rectangle(m + 1, m + 1, m + 6, m + s - 2, fill=l, outline='', width=0) + self.create_rectangle(m + 8, m + 1, m + 13, m + s - 2, fill=r, outline='', width=0) + else: + # 上下分栏图标,下面板高亮 + t = self._ON if self._active else off + b = self._ACTIVE if self._active else off + self.create_rectangle(m + 1, m + 1, m + s - 2, m + 6, fill=t, outline='', width=0) + self.create_rectangle(m + 1, m + 8, m + s - 2, m + 13, fill=b, outline='', width=0) + + def set_active(self, active: bool): + self._active = active + self._draw() + + def _on_click(self, event): + self._command() + + def _on_enter(self, event): + self.configure(bg=self._HOVER) + + def _on_leave(self, event): + self.configure(bg=self._BG) diff --git a/core/ui/tkinter/splash.py b/core/ui/tkinter/splash.py new file mode 100644 index 0000000..86f4c12 --- /dev/null +++ b/core/ui/tkinter/splash.py @@ -0,0 +1,112 @@ +""" +启动进度窗口 — 无边框小窗口,负责整个初始化流程。 +""" +import time +import tkinter as tk +from tkinter import ttk, messagebox + + +class SplashWindow: + """初始化进度窗口,所有者启动逻辑""" + + def __init__(self): + self.root = tk.Tk() + self.root.title("神之一手") + self.root.geometry("380x120") + self.root.resizable(False, False) + self.root.overrideredirect(True) + + self.root.update_idletasks() + sw = self.root.winfo_screenwidth() + sh = self.root.winfo_screenheight() + w, h = 380, 120 + self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") + + frame = ttk.Frame(self.root, padding=20) + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="神之一手", font=('Microsoft YaHei', 14, 'bold')).pack(pady=(0, 5)) + self._status = ttk.Label(frame, text="正在初始化...", font=('Microsoft YaHei', 9)) + self._status.pack(pady=(0, 10)) + + self._bar = ttk.Progressbar(frame, mode='determinate', length=340) + self._bar.pack() + self.root.update() + + def progress(self, text: str, pct: float): + self._status.configure(text=text) + self._bar.configure(value=pct) + self.root.update() + + def _destroy(self): + self.root.destroy() + + def run(self): + """执行完整启动流程,成功返回主窗口,失败返回 None""" + from core.qmt_real import RealQmtV, qmtv as selected_qmtv + + while True: + _t_total = time.time() + + # 步骤1: 探测 QMT 环境 + self.progress("正在检查 QMT 环境...", 10) + _t = time.time() + try: + discovered = RealQmtV._discover_qmt_port() + except Exception: + discovered = 0 + print(f'[计时] 步骤1-探测QMT环境: {time.time() - _t:.2f}s') + + if not discovered: + self._destroy() + messagebox.showerror( + "启动失败", + "未能自动探测到 QMT 环境。\n\n" + "请确认:\n" + "1. 极简QMT(GJQMT)已启动并登录\n" + "2. XtMiniQmt.exe 和 miniquote.exe 进程在运行" + ) + return None + + # 步骤2: 初始化交易器 + self.progress("正在初始化交易器...", 35) + _t = time.time() + selected_qmtv.init_qmtv() + print(f'[计时] 步骤2-初始化交易器: {time.time() - _t:.2f}s') + + # 步骤3: 连接 QMT + self.progress("正在连接 QMT...", 55) + _t = time.time() + connected = selected_qmtv.connect() + print(f'[计时] 步骤3-连接QMT: {time.time() - _t:.2f}s') + + if not connected: + self._destroy() + option = messagebox.askokcancel( + "连接失败", + "QMT 连接失败。\n\n" + "请确认极简QMT 已启动并登录交易账号。\n" + "点击「确定」重试,或「取消」退出。" + ) + if not option: + return None + # 重试:重新创建进度窗口 + self.__init__() + continue + + # 步骤4: 加载主界面 + self.progress("正在加载持仓与策略...", 75) + _t = time.time() + from core.ui.tkinter.main_window import MainWindow + window = MainWindow('INFO', progress=lambda t, p: self.progress(t, 75 + p * 0.2)) + print(f'[计时] 步骤4-主界面加载: {time.time() - _t:.2f}s') + + window.root.update() + + # 步骤5: 完成 + self.progress("启动完成", 100) + self.root.update() + self.root.after(300, self._destroy) + print(f'[计时] 总启动耗时: {time.time() - _t_total:.2f}s') + + return window diff --git a/grid_flow.md b/grid_flow.md new file mode 100644 index 0000000..a58a362 --- /dev/null +++ b/grid_flow.md @@ -0,0 +1,98 @@ +# 统一网格逻辑 + +## 核心规则 + +对任意 `grid_index`,两个方向各挂一单: + +| 方向 | 条件 | 价格 | 含义 | +|------|------|------|------| +| 卖出(上移) | `grid_index > 0` | `grid[grid_index - 1]` | 涨回到上一格时卖出获利 | +| 买入(下移) | `grid_index < len(grid)-1` | `grid[grid_index + 1]` | 跌到下一格时补仓 | + +不需要"建仓"概念,`grid_index=0` 自然表示空仓。 + +## 流程图 + +```mermaid +flowchart TD + START["refreshGridOrder()"] --> GUARD{"isMarketActive AND enabled ?"} + GUARD -->|No| EXIT0["跳过不下单"] + GUARD -->|Yes| QUERY["查询未成交订单
queryPendingOrder()"] + + QUERY --> IDX["currentIdx = grid_index"] + + IDX --> SELL{"currentIdx > 0 ?"} + + SELL -->|"No
(空仓,无持仓可卖)"| BUY + + SELL -->|"Yes"| SELL_IDX["sellIdx = currentIdx - 1
卖价 = grid[sellIdx]"] + SELL_IDX --> SELL_EXIST{"已有同 remark 卖单?"} + SELL_EXIST -->|No| SELL_CHECK{"卖价 > 涨停价 ?"} + SELL_CHECK -->|Yes| SELL_SKIP["跳过(超出涨停)"] + SELL_CHECK -->|No| SELL_PLACE["挂卖出单
orderGrid[sellIdx] = seq"] + SELL_EXIST -->|Yes| SELL_DUP["跳过(已挂单)"] + + SELL_SKIP --> BUY + SELL_PLACE --> BUY + SELL_DUP --> BUY + + BUY{"currentIdx < len(grid)-1 ?"} + + BUY -->|"No
(已到最低价)"| EXIT["结束"] + + BUY -->|"Yes"| BUY_IDX["buyIdx = currentIdx + 1
买价 = grid[buyIdx]"] + BUY_IDX --> BUY_EXIST{"已有同 remark 买单?"} + BUY_EXIST -->|No| BUY_CHECK{"买价 < 跌停价 ?"} + BUY_CHECK -->|Yes| BUY_SKIP["跳过(低于跌停)"] + BUY_CHECK -->|No| BUY_PLACE["挂买入单
orderGrid[buyIdx] = seq"] + BUY_EXIST -->|Yes| BUY_DUP["跳过(已挂单)"] + + BUY_SKIP --> EXIT + BUY_PLACE --> EXIT + BUY_DUP --> EXIT +``` + +## 三种典型状态 + +``` +grid = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + ↑ ↑ ↑ ↑ + 0 1 2 3 ... + + +grid_index=0(空仓): + ┌────┬────┬────┬────┐ + │ 11 │ 10 │ 9 │ 8 │ ... + └────┴────┴────┴────┘ + sell=无 buy=10 ← 第一笔买单 + + +grid_index=1(持1份@10元): + ┌────┬────┬────┬────┐ + │ 11 │ 10 │ 9 │ 8 │ ... + └────┴────┴────┴────┘ + sell=11 → buy=9 → + + +grid_index=3(持3份@8,9,10元): + ┌────┬────┬────┬────┐ + │ 11 │ 10 │ 9 │ 8 │ ... + └────┴────┴────┴────┘ + ↑ sell=9 buy=7 → + 当前位置=3 + + +成交后处理(onOrderTrade): + 卖单成交 gridIdx < currentIdx → grid_index -= 1(上移,赚差价) + 买单成交 gridIdx > currentIdx → grid_index += 1(下移,补仓) + 然后 refreshGridOrder → 在新位置重新挂单 +``` + +## 和之前的区别 + +| | 之前 | 之后 | +|---|---|---| +| 分支数 | 2 个(status=0 / status=1) | 1 个(统一网格逻辑) | +| 空仓第一笔 | INIT 单 @ grid[0]=11 | 普通买单 @ grid[1]=10 | +| grid[0]=11 的用途 | 建仓买入 | 永远只卖不买 | +| 状态字段 | status + grid_index | 仅 grid_index | diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..eb9e15b Binary files /dev/null and b/logo.ico differ diff --git a/logo.png b/logo.png deleted file mode 100644 index 287922f..0000000 Binary files a/logo.png and /dev/null differ diff --git a/starter.py b/starter.py index dd125c0..e66c5bc 100644 --- a/starter.py +++ b/starter.py @@ -1,191 +1,23 @@ # coding:utf-8 -import os +""" +启动入口 — 自动探测 QMT 环境。 +默认使用 Tkinter UI,使用 --flet 参数切换到 Flet (Flutter) UI。 +""" import sys -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import configparser -import config as sdConstants - -class ConfigWindow: - def __init__(self, root): - self.root = root - self.root.title("系统配置") - self.root.geometry("500x300") - self.root.resizable(False, False) - - # 居中显示 - self.root.withdraw() # 先隐藏窗口 - self.root.update_idletasks() - x = (self.root.winfo_screenwidth() // 2) - (500 // 2) - y = (self.root.winfo_screenheight() // 2) - (300 // 2) - self.root.geometry(f"500x300+{x}+{y}") - self.root.deiconify() # 再显示窗口 - - self.miniQMTPath = tk.StringVar() - self.account_no = tk.StringVar() - - self.create_widgets() - - def create_widgets(self): - # 创建主框架 - main_frame = ttk.Frame(self.root, padding="20") - main_frame.pack(fill=tk.BOTH, expand=True) - - # miniQMT路径配置 - path_frame = ttk.Frame(main_frame) - path_frame.pack(fill=tk.X, pady=5) - - path_label = ttk.Label(path_frame, text="miniQMT路径:") - path_label.pack(side=tk.LEFT) - - path_entry = ttk.Entry(path_frame, textvariable=self.miniQMTPath, width=40) - path_entry.pack(side=tk.LEFT, padx=(10, 5), fill=tk.X, expand=True) - - browse_btn = ttk.Button(path_frame, text="浏览", command=self.browse_folder) - browse_btn.pack(side=tk.LEFT) - - # 资金账号配置 - account_frame = ttk.Frame(main_frame) - account_frame.pack(fill=tk.X, pady=5) - - account_label = ttk.Label(account_frame, text="资金账号:") - account_label.pack(side=tk.LEFT) - - account_entry = ttk.Entry(account_frame, textvariable=self.account_no, width=40) - account_entry.pack(side=tk.LEFT, padx=(10, 0)) - - # 模拟模式复选框 - self.use_simulated = tk.BooleanVar(value=False) - simulated_check = ttk.Checkbutton( - main_frame, - text="使用模拟交易模式(无需真实 QMT 连接)", - variable=self.use_simulated - ) - simulated_check.pack(fill=tk.X, pady=5) - - # 说明文本 - info_label = ttk.Label( - main_frame, - text="请配置miniQMT的userdata_mini路径和资金账号\n路径示例: D:/Programs/DTQMT/userdata_mini", - foreground="gray" - ) - info_label.pack(pady=10) - - # 按钮框架 - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X, pady=10) - - save_btn = ttk.Button(button_frame, text="保存配置", command=self.save_config) - save_btn.pack(side=tk.RIGHT) - - cancel_btn = ttk.Button(button_frame, text="取消", command=self.root.destroy) - cancel_btn.pack(side=tk.RIGHT, padx=(0, 10)) - - def browse_folder(self): - folder_selected = filedialog.askdirectory() - if folder_selected: - self.miniQMTPath.set(folder_selected) - - def save_config(self): - mini_qmt_path = self.miniQMTPath.get().strip() - account_number = self.account_no.get().strip() - - # 检查miniQMT路径 - if not mini_qmt_path: - messagebox.showerror("错误", "请选择miniQMT路径") - return - - if not os.path.exists(mini_qmt_path): - messagebox.showerror("错误", "miniQMT路径不存在") - return - - # 检查账号 - if not account_number: - messagebox.showerror("错误", "请输入资金账号") - return - - # 保存配置 - config = configparser.ConfigParser() - config['config'] = { - 'miniQMTPath': mini_qmt_path.replace('\\', '/'), - 'account_no': account_number, - 'use_simulated_qmt': str(self.use_simulated.get()), - 'log_level': 'INFO' - } - - config_path = sdConstants.get_config_path() - try: - with open(config_path, 'w') as configfile: - config.write(configfile) - messagebox.showinfo("成功", "配置已保存") - self.root.destroy() - except Exception as e: - messagebox.showerror("错误", f"保存配置失败: {str(e)}") - -def check_and_create_config(): - """检查配置文件,如果不存在则打开配置窗口""" - root = tk.Tk() - config_window = ConfigWindow(root) - root.mainloop() - -def resolve_simulated_mode() -> bool: - """确定是否使用模拟模式(CLI > 配置文件 > 默认 real)""" - if '--simulated' in sys.argv: - print('[配置] 命令行指定: 模拟交易模式') - return True - - if sdConstants.exist_config(): - sdConstants.initConfig() - if sdConstants.use_simulated_qmt: - print('[配置] 配置文件指定: 模拟交易模式') - return True - - print('[配置] 默认: 真实交易模式') - return False - - -def initialize_system(): - """初始化系统""" - simulated = resolve_simulated_mode() - sdConstants.use_simulated_qmt = simulated - - try: - if simulated: - from core.qmt_dummy import qmtv as selected_qmtv - print("[模拟模式] 使用模拟交易器") - sdConstants.miniQMTPath = '/dummy/path' - sdConstants.account_no = 'DUMMY_ACCOUNT' - sdConstants.log_level = 'INFO' - selected_qmtv.init_qmtv() - selected_qmtv.connect() - from core.main_ui import MainWindow - window = MainWindow(sdConstants.log_level) - window.run() - else: - from core.qmt_real import qmtv as selected_qmtv - while True: - if sdConstants.exist_config() and sdConstants.initConfig(): - selected_qmtv.init_qmtv() - connected = selected_qmtv.connect() - if connected: - from core.main_ui import MainWindow - window = MainWindow(sdConstants.log_level) - window.run() - break - else: - option = messagebox.askokcancel("连接失败", "QMT连接失败,请检查") - if option: - check_and_create_config() - else: - break - else: - option = messagebox.askokcancel("错误", "请检查配置") - if option: - check_and_create_config() - else: - break - except Exception as e: - messagebox.showerror("错误", f"系统初始化失败: {str(e)}") if __name__ == '__main__': - initialize_system() \ No newline at end of file + if '--flet2' in sys.argv: + from core.ui.flet.app_v2 import run + run() + elif '--flet' in sys.argv: + from core.ui.flet.app import run + run() + else: + from tkinter import messagebox + from core.ui.tkinter.splash import SplashWindow + try: + window = SplashWindow().run() + if window: + window.run() + except Exception as e: + messagebox.showerror("错误", f"系统初始化失败: {str(e)}") diff --git a/starter.spec b/starter.spec index f717274..a179a00 100644 --- a/starter.spec +++ b/starter.spec @@ -4,7 +4,7 @@ a = Analysis( ['starter.py'], pathex=[], binaries=[], - datas=[('config.ini', '.'), ('xtquant/xtdata.ini', 'xtquant')], # 明确包含配置文件和xtdata.ini + datas=[('xtquant/xtdata.ini', 'xtquant')], # xtdata 依赖的配置文件 hiddenimports=['brotli', 'brotli.encoding'], hookspath=[], hooksconfig={}, @@ -24,8 +24,8 @@ exe = EXE( name='神之一手', debug=False, bootloader_ignore_signals=False, - strip=True, # 去除调试符号 - upx=True, + strip=False, + upx=False, upx_exclude=[], runtime_tmpdir=None, console=False, @@ -34,5 +34,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon='logo.png' # 添加图标文件 + icon='logo.ico', ) \ No newline at end of file