diff --git a/core/qmt_real.py b/core/qmt_real.py index 9e6ee09..206568e 100644 --- a/core/qmt_real.py +++ b/core/qmt_real.py @@ -536,11 +536,7 @@ class RealQmtV: seq = xtdata.subscribe_whole_quote(['SH', 'SZ'], self._on_market_data) PrintLog(LogLevel.INFO, f'- [市场数据订阅成功-真实] seq={seq}') - # 订阅成功即标记市场活跃,避免策略初始化时因等待首条数据被误判为休市 - self.isMarketActive = True - eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) - - # 启动行情活跃监控线程 + # 启动行情活跃监控线程(默认不活跃,收到行情后激活) self._market_data_thread = threading.Thread( target=self._market_data_watchdog, daemon=True ) @@ -549,24 +545,20 @@ class RealQmtV: PrintLog(LogLevel.ERROR, f'- [市场数据订阅失败-{e}]') def _on_market_data(self, datas: dict): - """xtquant 行情回调 — 将数据转换为事件总线格式""" + """xtquant 行情回调 — 收到行情即标记市场活跃""" self.lastMarketDataUpdateTimestamp = time.time() if not self.isMarketActive: self.isMarketActive = True eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True) - - # xtquant 返回 "600519.SH" 格式 key,UI 使用纯代码 "600519" - # 构建同时包含两种 key 的数据确保匹配 - # 直接发布 xtquant 原始数据(代码带 .SH/.SZ 后缀) eBus.event_bus.publish(eBus.MarketDataUpdate, datas) def _market_data_watchdog(self): - """行情活跃监控 — 超过 30 秒无数据则标记市场不活跃""" + """行情活跃监控 — 超过 120 秒无行情则标记市场不活跃""" while True: - time.sleep(10) + time.sleep(15) if self.isMarketActive: elapsed = time.time() - self.lastMarketDataUpdateTimestamp - if elapsed > 30: + if elapsed > 120: self.isMarketActive = False eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False) PrintLog(LogLevel.WARNING, f'- [行情] 超过 {elapsed:.0f} 秒无更新,市场标记为不活跃') diff --git a/core/ui/flet/app.py b/core/ui/flet/app.py index 8d329c4..d81d81a 100644 --- a/core/ui/flet/app.py +++ b/core/ui/flet/app.py @@ -327,11 +327,11 @@ class QmtApp: def _pending_tags(self, stock_code: str) -> list: """返回该标的下挂单的方向标签列表:'多'(买单) / '空'(卖单)""" tags = [] - _CANCELED = {54, 57} + _TERMINAL = {54, 56, 57} for o in self._orders: if _plain(getattr(o, 'stock_code', '')) != stock_code: continue - if getattr(o, 'order_status', 0) in _CANCELED: + if getattr(o, 'order_status', 0) in _TERMINAL: continue ot = getattr(o, 'order_type', 0) if ot == 23 and '多' not in tags: @@ -533,8 +533,8 @@ class QmtApp: self._sel_actions.controls = [] self._uncl_list.controls = [self._build_unclassified_table()] - # 委托 — 过滤已撤/废单,按 order_id 去重(保留最后一条即最新状态) - _CANCELED = {54, 57} + # 委托 — 过滤已终结订单(已撤/已成/废单),按 order_id 去重 + _TERMINAL = {54, 56, 57} o_map = {} # order_id → latest order for o in self._orders: oid = str(getattr(o, 'order_id', '')) @@ -544,7 +544,7 @@ class QmtApp: o_rows = [] for o in o_map.values(): st = getattr(o, 'order_status', 0) - if st in _CANCELED: + if st in _TERMINAL: continue tv = getattr(o, 'traded_volume', 0) or 0 ov = getattr(o, 'order_volume', 0) or 0 diff --git a/core/ui/flet/app_v2.py b/core/ui/flet/app_v2.py index 8b50e58..fb76c56 100644 --- a/core/ui/flet/app_v2.py +++ b/core/ui/flet/app_v2.py @@ -133,6 +133,31 @@ class _DataStore: if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled: self.strategyCtrl[tid] = SFGridStrategy(t) + def remove_target(self, tid: int) -> str: + """移除/降级网格策略标的。返回 'unclassified'(有持仓降级)或 'deleted'(无持仓删除)""" + target = self.tradeTargets.get(tid) + if target is None: + return 'none' + # 先停止策略 + ctrl = self.strategyCtrl.pop(tid, None) + if ctrl: + ctrl.enabledTrading(False) + if target.current_position > 0: + # 有持仓 → 降级为未分类 + target.strategy_type = 0 # STRATEGY_TYPE_UNCLASSIFIED + target.enabled = False + target.save() + return 'unclassified' + else: + # 无持仓 → 直接删除 + target.delete_instance() + self.tradeTargets.pop(tid, None) + self.stockCodeIdMap.pop(target.stock_code, None) + self.marketPrices.pop(tid, None) + self.avgPrices.pop(tid, None) + self.preClose.pop(tid, None) + return 'deleted' + # ── 行情 ── def apply_tick(self, data: dict) -> int: @@ -189,7 +214,8 @@ class _DataStore: def active_orders(self) -> list: """返回过滤+去重后的活跃委托行""" - _CANCELED = {54, 57} + # 过滤已终结的订单: 已撤(54) 已成(56) 废单(57) + _TERMINAL = {54, 56, 57} o_map = {} for o in self.orders: oid = str(getattr(o, 'order_id', '')) @@ -198,7 +224,7 @@ class _DataStore: rows = [] for o in o_map.values(): st = getattr(o, 'order_status', 0) - if st in _CANCELED: continue + if st in _TERMINAL: continue tv = getattr(o, 'traded_volume', 0) or 0 ov = getattr(o, 'order_volume', 0) or 0 rows.append([ @@ -234,6 +260,36 @@ class _DataStore: ]) return rows + def add_from_market(self, stock_code: str, stock_name: str) -> int: + """从市场监控添加标的(默认: grid_index=0, upper=0, lower=10),返回 tid""" + plain = _plain(stock_code) + existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == plain) + if existing: + tid = existing.get_id() + if tid not in self.tradeTargets: + self.tradeTargets[tid] = existing + self.stockCodeIdMap[plain] = tid + return tid + mp = self.marketLog.get(stock_code, {}) + price = mp.get('last_price', 10.0) + target = SFGridTradeTarget.create( + stock_code=plain, stock_name=stock_name, + current_position=0, init_price=0.0, + grid_index=0, enabled=False, + strategy_type=STRATEGY_TYPE_GRID, + grid_start_price=float(price) if price > 0 else 10.0, + grid_size=1.0, grid_volume=200, + grid_upper_count=0, grid_lower_count=10, + ) + tid = target.get_id() + self.tradeTargets[tid] = target + self.stockCodeIdMap[plain] = tid + # 从市场监控列表中移除(加入交易池后不再监控) + self.marketLog.pop(stock_code, None) + if stock_code in self.listeningStocks: + self.listeningStocks.remove(stock_code) + return tid + def pending_tags(self, stock_code: str) -> list: """某标的挂单方向标签""" tags = [] @@ -268,8 +324,8 @@ class _GridPanel: 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)] + # (width, expand): 0=固定宽, >0=弹性比重 + _C = [(35, 0), (0, 2), (70, 1), (0, 2), (50, 1), (55, 1), (60, 1), (0, 1)] H = ["ID", "股票", "市场价", "网格基准", "持仓", "成本", "状态", "操作"] header = [] for h, (w, e) in zip(H, _C): @@ -302,20 +358,28 @@ class _GridPanel: )) gcell = ft.Row(gcells, spacing=3) if len(gcells) > 1 else gcells[0] - # 操作按钮 — IconButton 图标居中,无点击偏移 + # 操作按钮 + _ICON = 22 # 图标大小,比默认 18 好按 if t.enabled: btns = [ - ft.IconButton(ft.Icons.PAUSE, icon_size=18, tooltip="暂停", + ft.IconButton(ft.Icons.PAUSE, icon_size=_ICON, tooltip="暂停", on_click=lambda e, tt=t: self._dialogs.confirm_stop(tt)), + ft.IconButton(ft.Icons.SETTINGS, icon_size=_ICON, tooltip="设置", + icon_color='#BDBDBD', + on_click=lambda e, tt=t: self._dialogs.open_config(tt)), + ft.IconButton(ft.Icons.REMOVE, icon_size=_ICON, tooltip="运行中不可移除", + icon_color='#BDBDBD', + on_click=None), ] else: btns = [ - ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=18, tooltip="启动", + ft.IconButton(ft.Icons.PLAY_ARROW, icon_size=_ICON, tooltip="启动", on_click=lambda e, tt=t: self._dialogs.confirm_start(tt)), + ft.IconButton(ft.Icons.SETTINGS, icon_size=_ICON, tooltip="设置", + on_click=lambda e, tt=t: self._dialogs.open_config(tt)), + ft.IconButton(ft.Icons.REMOVE, icon_size=_ICON, tooltip="移除", + on_click=lambda e, tt=t: self._dialogs.confirm_remove(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, @@ -331,11 +395,11 @@ class _GridPanel: elif isinstance(content, str): content = _text(content) elif isinstance(content, list): - content = ft.Row(content, spacing=2) + content = ft.Row(content, spacing=6) 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_cells.append(ft.Container(content, width=w, padding=4)) 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')) @@ -382,7 +446,6 @@ class _DrawerPanel: 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, @@ -416,26 +479,32 @@ class _DrawerPanel: self._refresh_market() def _refresh_grid(self): - W = [35, 180, 80, 60, 70, 65] # ID, 股票, 市场价, 持仓, 成本, 操作 + # (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间 + _C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (65, 0)] 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'), - ] + + def _cell(text, w, e, color=None): + if e > 0: + return ft.Container(_text(text, color=color), padding=4, expand=e) + return ft.Container(_text(text, color=color), width=w, padding=4) + + header = ft.Row([_cell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0) + rows = [header, 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="网格配置", + cells = [ + _cell(str(tid), *_C[0]), + _cell(f'{t.stock_code} {t.stock_name}', *_C[1]), + _cell(f'{mp:.3f}', *_C[2]), + _cell(str(t.current_position), *_C[3]), + _cell(f'{self._data.avgPrices.get(tid, 0):.3f}', *_C[4]), + ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=20, tooltip="网格配置", on_click=lambda e, tt=t: self._dialogs.open_config(tt)), - width=W[5], padding=0), - ], spacing=0) + width=_C[5][0], padding=0), + ] + row = ft.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._uncl_col.controls = rows @@ -455,10 +524,57 @@ class _DrawerPanel: [_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)] + # (width, expand): 0=固定宽, >0=弹性比重; 股票名称列 expand 自动填充剩余空间 + _C = [(80, 0), (0, 1), (80, 0), (45, 0)] + H = ["时间", "股票名称", "最新价格", "操作"] + + def _header_cell(text, w, e): + if e > 0: + return ft.Container(_text(text, bold=True), padding=4, expand=e) + return ft.Container(_text(text, bold=True), width=w, padding=4) + + header = ft.Row([_header_cell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0) + rows = [header, ft.Divider(height=1, color='#e0e0e0')] + + for sc, d in self._data.marketLog.items(): + plain = _plain(sc) + already_in_pool = plain in self._data.stockCodeIdMap + + def _data_cell(text, w, e, color=None, size=None): + if e > 0: + return ft.Container(_text(text, color=color, size=size), padding=4, expand=e) + return ft.Container(_text(text, color=color, size=size), width=w, padding=4) + + cells = [ + _data_cell(d.get('time', ''), *_C[0]), + _data_cell(f"{d['stock_name']}-{sc}", *_C[1]), + _data_cell(f"{d['last_price']:.3f}", *_C[2]), + ] + if already_in_pool: + cells.append(_data_cell("已在池", *_C[3], color='#AAAAAA', size=11)) + else: + cells.append(ft.Container( + ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓", + on_click=lambda e, code=sc: self._on_add_from_market(code)), + width=_C[3][0], padding=0)) + row = ft.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._market_table.controls = [ft.Column(rows, spacing=0)] + + def _on_add_from_market(self, stock_code: str): + """+ 按钮:从市场监控添加标的并打开网格配置""" + info = self._data.marketLog.get(stock_code) + if not info: + return + stock_name = info.get('stock_name', '') + tid = self._data.add_from_market(stock_code, stock_name) + target = self._data.tradeTargets.get(tid) + if target is None: + return + # 打开网格配置弹窗 + self._dialogs.open_config(target) # ══════════════════════════════════════════════════════════════ @@ -641,6 +757,31 @@ class _TradeDialogs: self._on_done() PrintLog(LogLevel.INFO, f'[Flet] 暂停交易: {target.targetName()}') + def confirm_remove(self, target): + """移除网格策略 — 根据持仓量显示不同提示""" + if target.current_position > 0: + msg = f"该标的当前持仓 {target.current_position} 股,将移回「未分类持仓」。\n\n确定要移除网格策略吗?\n\n{target.stock_code} {target.stock_name}" + else: + msg = f"该标的当前无持仓,将直接删除。\n\n确定要移除吗?\n\n{target.stock_code} {target.stock_name}" + dlg = ft.AlertDialog( + title=_text("确认移除"), + content=_text(msg), + actions=[ + ft.TextButton("取消", on_click=lambda e: self._close()), + ft.TextButton("确定", on_click=lambda e, t=target: self._do_remove(t)), + ], + ) + self._page.show_dialog(dlg) + + def _do_remove(self, target): + self._close() + result = self._data.remove_target(target.get_id()) + self._on_done() + if result == 'unclassified': + PrintLog(LogLevel.INFO, f'[Flet] 降级为未分类: {target.targetName()}') + elif result == 'deleted': + PrintLog(LogLevel.INFO, f'[Flet] 已删除: {target.targetName()}') + def _close(self): self._page.pop_dialog() self._page.update() @@ -737,11 +878,21 @@ class QmtApp: on_click=lambda e: self._toggle_drawer()) self._overlay = self._drawer.build() + # 市场状态指示灯 + self._market_dot = ft.Container(width=8, height=8, border_radius=4, + bgcolor='#AAAAAA') + self._market_label = _text("休市", color='#888888', size=12) + market_status = ft.Row([self._market_dot, self._market_label], spacing=6, + alignment=ft.MainAxisAlignment.CENTER) + # 应用启动时同步当前状态 + self._update_market_status(qmtv.isMarketActive) + + # 顶部标题栏 — 左侧市场状态,右侧刷新 + 侧栏按钮 title = ft.Container( ft.Row([ - _text("网格策略持仓", bold=True, size=13), + market_status, ft.Row([ - ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=18, + ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=20, on_click=lambda e: self._manual_refresh()), self._sidebar_icon, ], spacing=0), @@ -749,8 +900,17 @@ class QmtApp: padding=ft.Padding(10, 10, 10, 5), ) - grid_body = ft.Container(content=self._grid.build(), expand=True, - padding=ft.Padding(10, 0, 10, 10)) + # 网格持仓面板 — 标题 + 数据表,与右侧 drawer 风格一致 + grid_body = ft.Container( + ft.Column([ + ft.Container(_text("网格策略持仓", bold=True, size=14), + padding=ft.Padding(10, 10, 10, 5)), + ft.Container(content=self._grid.build(), expand=True, + padding=ft.Padding(10, 0, 10, 10)), + ], expand=True), + bgcolor=ft.Colors.SURFACE, + expand=True, + ) self.page.add(ft.Column([ title, ft.Stack([grid_body, self._overlay], expand=True), @@ -761,6 +921,8 @@ class QmtApp: # ══════════════════════════════════════════════════════════ def _refresh_ui(self): + self._data.refresh_orders() + self._data.refresh_trades() self._grid._rebuild() self._drawer.refresh_all() self.page.update() @@ -795,7 +957,19 @@ class QmtApp: self._refresh_ui() def _on_market_active(self, is_active: bool): - pass + self._update_market_status(is_active) + self.page.update() + + def _update_market_status(self, active: bool): + """更新顶栏市场状态指示灯""" + if active: + self._market_dot.bgcolor = '#4CAF50' + self._market_label.value = '盘中' + self._market_label.color = '#2E7D32' + else: + self._market_dot.bgcolor = '#AAAAAA' + self._market_label.value = '休市' + self._market_label.color = '#888888' # ══════════════════════════════════════════════════════════ # 后台刷新 diff --git a/core/ui/tkinter/sfgrid_view.py b/core/ui/tkinter/sfgrid_view.py index 8bfd36b..67bd456 100644 --- a/core/ui/tkinter/sfgrid_view.py +++ b/core/ui/tkinter/sfgrid_view.py @@ -520,9 +520,9 @@ class TradeTargetUI(ttk.Frame): for item in self.order_tree.get_children(): self.order_tree.delete(item) - _CANCELED = {54, 57} + _TERMINAL = {54, 56, 57} for o in orders: - if getattr(o, 'order_status', 0) in _CANCELED: + if getattr(o, 'order_status', 0) in _TERMINAL: continue code = getattr(o, 'stock_code', '') if '.' in code: