Files
sfgrid/core/ui/flet/app_v2.py
T
2026-06-12 16:25:41 +08:00

864 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)