""" 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 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: """应用行情数据,返回更新的标的数""" 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: """返回过滤+去重后的活跃委托行""" # 过滤已终结的订单: 已撤(54) 已成(56) 废单(57) _TERMINAL = {54, 56, 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 _TERMINAL: 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 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 = [] 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): # (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): 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] # 操作按钮 _ICON = 22 # 图标大小,比默认 18 好按 if t.enabled: btns = [ 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=_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)), ] 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=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)) 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.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): # (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间 _C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (65, 0)] H = ["ID", "股票", "市场价", "持仓", "成本", "操作"] 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 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=_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 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): # (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) # ══════════════════════════════════════════════════════════════ # 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 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() # ══════════════════════════════════════════════════════════════ # 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() # 市场状态指示灯 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([ market_status, ft.Row([ ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=20, on_click=lambda e: self._manual_refresh()), self._sidebar_icon, ], spacing=0), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), padding=ft.Padding(10, 10, 10, 5), ) # 网格持仓面板 — 标题 + 数据表,与右侧 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), ], expand=True)) # ══════════════════════════════════════════════════════════ # UI 刷新 # ══════════════════════════════════════════════════════════ def _refresh_ui(self): self._data.refresh_orders() self._data.refresh_trades() 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): 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' # ══════════════════════════════════════════════════════════ # 后台刷新 # ══════════════════════════════════════════════════════════ 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)