From ee8fc60eaee1118b4fdfa9a931d9e86e669df7ab Mon Sep 17 00:00:00 2001 From: "WIN-ALB39P9B6BU\\Docker" Date: Wed, 24 Jun 2026 11:02:42 +0800 Subject: [PATCH] release --- .gitignore | 2 ++ core/eventbus.py | 16 ++++++++++- core/qmt_real.py | 23 ++++++++++------ core/sfgrid/sfgrid_strategy.py | 26 +++++++++++++----- core/ui/flet/app_v2.py | 3 +++ starter.py | 49 ++++++++++++++++++++++++++++++++++ starter.spec | 2 +- 7 files changed, 105 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index f98dc7e..1571093 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ .vscode/ example.db.bak venv/ +flet_desktop/ +.flet/ diff --git a/core/eventbus.py b/core/eventbus.py index c19804c..629d4e1 100644 --- a/core/eventbus.py +++ b/core/eventbus.py @@ -11,13 +11,27 @@ EventPrintLog = "print_log" # 打印日志 class EventBus: def __init__(self): self.listeners = {} # 管理各种event的订阅情况 + self.last_events = {} # 存储每个事件的最后一次值,用于"重播"给新订阅者 - def subscribe(self, event_type, listener): + def subscribe(self, event_type, listener, replay=True): + """订阅事件 + + Args: + event_type: 事件类型 + listener: 回调函数 + replay: 是否自动重播最近一次事件状态(默认True) + """ if event_type not in self.listeners: self.listeners[event_type] = [] self.listeners[event_type].append(listener) + # 新订阅者自动收到最近一次事件状态(如果存在) + if replay and event_type in self.last_events: + listener(self.last_events[event_type]) + def publish(self, event_type, data): + # 存储最后一次事件值 + self.last_events[event_type] = data if event_type in self.listeners: for listener in self.listeners[event_type]: listener(data) diff --git a/core/qmt_real.py b/core/qmt_real.py index ac14014..70163bb 100644 --- a/core/qmt_real.py +++ b/core/qmt_real.py @@ -545,11 +545,19 @@ class RealQmtV: PrintLog(LogLevel.ERROR, f'- [市场数据订阅失败-{e}]') def _on_market_data(self, datas: dict): - """xtquant 行情回调 — 收到行情即标记市场活跃""" + """xtquant 行情回调 — 收到行情即标记市场活跃(但需满足 09:15 后才激活)""" self.lastMarketDataUpdateTimestamp = time.time() if not self.isMarketActive: - self.isMarketActive = True - eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) + # 检查当前时间是否已过 09:15(集合竞价结束后才激活市场状态) + import zoneinfo + beijing_tz = zoneinfo.ZoneInfo("Asia/Shanghai") + now = datetime.datetime.now(beijing_tz) + t = now.time() + activation_time = datetime.time(9, 15) + if t >= activation_time: + self.isMarketActive = True + eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) + PrintLog(LogLevel.INFO, f'- [行情] 市场激活 (时间 {t.strftime("%H:%M:%S")} >= 09:15)') eBus.event_bus.publish(eBus.MarketDataUpdate, datas) def _is_trading_time(self) -> bool: @@ -567,17 +575,16 @@ class RealQmtV: return (morning_start <= t <= morning_end) or (afternoon_start <= t <= afternoon_end) def _market_data_watchdog(self): - """行情活跃监控 — 超过 120 秒无行情 且 非交易时间 则标记市场不活跃""" + """行情活跃监控 — 超过 120 秒无行情 则标记市场不活跃(无论是否交易时间)""" while True: time.sleep(15) if self.isMarketActive: elapsed = time.time() - self.lastMarketDataUpdateTimestamp - # 非交易时间直接标记休市;交易时间内才用超时判断 - if not self._is_trading_time() or elapsed > 120: + # 只有超过 120 秒无行情才标记不活跃,不再区分交易时间 + if elapsed > 120: self.isMarketActive = False eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False) - if not self._is_trading_time(): - PrintLog(LogLevel.INFO, '- [行情] 非交易时间,市场标记为不活跃') + PrintLog(LogLevel.INFO, f'- [行情] 超过 {elapsed:.0f} 秒无数据,市场标记为不活跃') def stopMarketDataSubscription(self): """停止市场数据订阅""" diff --git a/core/sfgrid/sfgrid_strategy.py b/core/sfgrid/sfgrid_strategy.py index cd182b4..8684057 100644 --- a/core/sfgrid/sfgrid_strategy.py +++ b/core/sfgrid/sfgrid_strategy.py @@ -54,6 +54,11 @@ class SFGridStrategy: """ self.tradeTarget: model.SFGridTradeTarget = tradeTarget + # 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问 + # QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建 + # 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发 + self.dataUpdateLock = threading.Lock() + # 订阅事件总线:监听订单创建、成交、失败三种事件 event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync) event_bus.subscribe(eBus.MarketOrderTraded, self.onOrderTrade) @@ -75,10 +80,6 @@ class SFGridStrategy: # 加载券商侧已存在的未成交订单,恢复到 orderGrid 中 self.loadExistOrders() - # 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问 - # QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建 - self.dataUpdateLock = threading.Lock() - # 根据数据库中的 enabled 字段决定是否启动交易 self.enabledTrading(tradeTarget.enabled) # type: ignore @@ -122,6 +123,9 @@ class SFGridStrategy: 当市场数据从不可用变为可用时,如果策略已启用则刷新网格订单。 """ + PrintLog(LogLevel.INFO, + f'|- [市场状态切换] 标的{self.tradeTarget.targetName()} ' + f'isActive={isActive}, enabled={self.tradeTarget.enabled}') if isActive and self.tradeTarget.enabled: self.refreshGridOrder() @@ -300,7 +304,12 @@ class SFGridStrategy: self.tradeTarget.enabled = False # type: ignore # 刷新网格订单(空仓只挂买单,有仓买卖对冲) - self.refreshGridOrder() + # 只有市场活跃时才下单,收盘后不再尝试下单 + if qmtv.isMarketActive: + self.refreshGridOrder() + else: + PrintLog(LogLevel.INFO, + f' |- 市场已休市,跳过刷新网格订单') else: # ── 停用交易: 取消所有未成交订单 ── @@ -465,7 +474,12 @@ class SFGridStrategy: PrintLog(LogLevel.INFO, f' 手续费 : {trade.commission:.3f}') # 4. 刷新网格订单:在新的 grid_index 位置重新挂买卖单 - self.refreshGridOrder() + # 只有市场活跃时才下单,收盘后不再尝试下单 + if qmtv.isMarketActive: + self.refreshGridOrder() + else: + PrintLog(LogLevel.INFO, + f'|- 成交后市场已休市,跳过刷新网格订单') finally: self.dataUpdateLock.release() diff --git a/core/ui/flet/app_v2.py b/core/ui/flet/app_v2.py index b33eb84..cd545ef 100644 --- a/core/ui/flet/app_v2.py +++ b/core/ui/flet/app_v2.py @@ -1279,8 +1279,11 @@ class QmtApp: self._refresh_ui() def _on_market_active(self, is_active: bool): + from core.logger import PrintLog, LogLevel + PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}') self._update_market_status(is_active) self.page.update() + PrintLog(LogLevel.INFO, f'[UI] 市场状态已更新,_market_label={self._market_label.value}') def _update_market_status(self, active: bool): """更新顶栏市场状态指示灯""" diff --git a/starter.py b/starter.py index 75736fb..86cfc38 100644 --- a/starter.py +++ b/starter.py @@ -4,6 +4,55 @@ 使用 --tk 参数切换到 Tkinter UI。 """ import sys +import os +import subprocess +import ssl +import traceback + +# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题 +# 原始 Popen 不带 CREATE_NO_WINDOW 标志,会为每个子进程创建控制台窗口 +_original_popen = subprocess.Popen + +class Popen(_original_popen): + def __init__(self, *args, **kwargs): + if sys.platform == "win32" and "creationflags" not in kwargs: + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + super().__init__(*args, **kwargs) + +subprocess.Popen = Popen + +# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端 +# 避免从 GitHub 下载 +if getattr(sys, 'frozen', False): + # 运行在打包后的 exe 中 + base_path = sys._MEIPASS # PyInstaller 解压到的临时目录 + flet_client_path = os.path.join(base_path, '.flet', 'client', 'flet-desktop-full-0.85.3') + flet_exe = os.path.join(flet_client_path, 'flet', 'flet.exe') + + # 写入日志便于调试 + log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt') + with open(log_file, 'w') as f: + f.write(f'base_path: {base_path}\n') + f.write(f'flet_client_path: {flet_client_path}\n') + f.write(f'flet_exe: {flet_exe}\n') + f.write(f'exists: {os.path.exists(flet_exe)}\n') + if os.path.exists(flet_exe): + f.write('Setting FLET_VIEW_PATH\n') + os.environ['FLET_VIEW_PATH'] = flet_client_path + f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n') + + # 禁用 SSL 验证,避免证书问题 + if hasattr(ssl, '_create_unverified_context'): + ssl._create_default_https_context = ssl._create_unverified_context + +def excepthook(type, value, tb): + """捕获未处理的异常,写入日志""" + log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt') + with open(log_file, 'w') as f: + f.write(''.join(traceback.format_exception(type, value, tb))) + sys.__excepthook__(type, value, tb) + +sys.excepthook = excepthook if __name__ == '__main__': if '--tk' in sys.argv: diff --git a/starter.spec b/starter.spec index 429033b..bd75b51 100644 --- a/starter.spec +++ b/starter.spec @@ -4,7 +4,7 @@ a = Analysis( ['starter.py'], pathex=[], binaries=[], - datas=[('xtquant/xtdata.ini', 'xtquant'), ('.flet/client', '.flet/client')], # xtdata 依赖的配置文件 + datas=[('xtquant/xtdata.ini', 'xtquant'), ('flet_desktop/app', 'flet_desktop/app')], # xtdata 依赖的配置文件 hiddenimports=['brotli', 'brotli.encoding'], hookspath=[], hooksconfig={},