864 lines
38 KiB
Python
864 lines
38 KiB
Python
"""
|
||
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)
|