837 lines
37 KiB
Python
837 lines
37 KiB
Python
"""
|
||
Flet UI — 完整对齐 Tkinter 版布局、数据流、刷新机制。
|
||
"""
|
||
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, STRATEGY_TYPE_UNCLASSIFIED
|
||
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:
|
||
"""格式化 QMT 时间为 HH:MM:SS(北京时间,Unix timestamp → 本地时间)"""
|
||
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
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# QmtApp
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class QmtApp:
|
||
"""Flet 版 QMT 交易界面,布局、数据流对齐 core/ui/tkinter/sfgrid_view.py"""
|
||
|
||
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
|
||
|
||
# ── 状态(对齐 Tkinter TradeTargetUI) ──
|
||
self.tradeTargetData: dict[int, SFGridTradeTarget] = {}
|
||
self.stockCodeIdMap: dict[str, int] = {}
|
||
self.strategy_ctrl: dict[int, SFGridStrategy] = {}
|
||
self.targetMarketPrice: dict[int, float] = {}
|
||
self.targetPreClose: dict[int, float] = {} # 昨收
|
||
self.targetAvgPrice: dict[int, float] = {}
|
||
self.marketData: dict[str, dict] = {} # stock_code → {stock_name, last_price, time}
|
||
self.listening_stock: list = []
|
||
self.monitor_price: float = 10.0
|
||
self._market_active: bool = qmtv.isMarketActive
|
||
self._refresh_cycle: int = 0
|
||
self._drawer_open: bool = False
|
||
self._selected_target = None
|
||
self._prices_loaded: bool = False
|
||
self._orders: list = []
|
||
self._trades: list = []
|
||
|
||
self._run_startup()
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 启动流程(对齐 tkinter/splash.py)
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _run_startup(self):
|
||
"""启动进度 — 先渲染 splash,再异步执行启动步骤"""
|
||
bar = ft.ProgressBar(width=340, value=0, color='#0078d4')
|
||
self._splash_status = ft.Text("正在初始化...", size=13)
|
||
self._splash_bar = bar
|
||
splash = ft.Container(
|
||
ft.Column([
|
||
ft.Text("神之一手", size=22, weight=ft.FontWeight.BOLD, color='#0078d4'),
|
||
ft.Text("交易系统", size=14, color='#666666'),
|
||
ft.Container(height=20),
|
||
self._splash_status,
|
||
ft.Container(height=8),
|
||
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,
|
||
)
|
||
self.page.add(ft.Container(
|
||
content=splash,
|
||
alignment=ft.Alignment.CENTER, expand=True,
|
||
bgcolor='#F5F5F5',
|
||
))
|
||
self.page.update()
|
||
|
||
# 异步执行启动,确保 splash 先渲染
|
||
asyncio.ensure_future(self._do_startup())
|
||
|
||
async def _do_startup(self):
|
||
"""异步启动流程 — splash 已渲染,逐步执行并更新进度"""
|
||
# 给渲染一帧的时间
|
||
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._init_data()),
|
||
("正在构建界面...", 0.85, lambda: None),
|
||
("正在初始化策略...", 0.92, lambda: self._init_strategies()),
|
||
]
|
||
for text, pct, action in steps:
|
||
self._splash_status.value = text
|
||
self._splash_bar.value = pct
|
||
self.page.update()
|
||
try:
|
||
result = action()
|
||
if result is False:
|
||
self._show_error(f"启动失败: {text}")
|
||
return
|
||
except Exception as e:
|
||
self._show_error(f"启动异常: {text}\n{e}")
|
||
return
|
||
|
||
self._splash_status.value = "启动完成"
|
||
self._splash_bar.value = 1.0
|
||
self.page.update()
|
||
await asyncio.sleep(0.3)
|
||
|
||
self.page.clean()
|
||
self._build_main_ui()
|
||
self.page.update()
|
||
|
||
# 主动拉取市价(不等行情推送)
|
||
self._pull_prices()
|
||
# 加载委托/成交数据
|
||
self._refresh_orders()
|
||
self._refresh_trades()
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
|
||
# 订阅事件 + 后台刷新
|
||
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
||
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active_switch)
|
||
event_bus.subscribe(EventTradeTargetUpdate, self._on_strategy_update)
|
||
threading.Thread(target=self._refresh_loop, daemon=True).start()
|
||
|
||
def _show_error(self, msg: str):
|
||
self.page.clean()
|
||
self.page.add(ft.Container(
|
||
content=ft.Column([
|
||
ft.Icon(ft.Icons.ERROR_OUTLINE, size=48, color=ft.Colors.RED),
|
||
ft.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()
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 数据初始化(对齐 Tkinter init_trade_target_pool)
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _init_data(self):
|
||
positions = qmtv.getAllPositions()
|
||
PrintLog(LogLevel.INFO, f'[Flet] 持仓: {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,
|
||
)
|
||
|
||
# 获取昨收价(需要带后缀的完整代码)
|
||
try:
|
||
from xtquant import xtdata
|
||
for stock_code, pos in positions.items():
|
||
full_code = stock_code
|
||
if '.' not in stock_code:
|
||
c = stock_code
|
||
full_code = f'{c}.SH' if c.startswith(('6', '5', '9')) else f'{c}.SZ'
|
||
detail = xtdata.get_instrument_detail(full_code)
|
||
if detail:
|
||
pre_close = detail.get('PreClose', 0) if isinstance(detail, dict) else getattr(detail, 'PreClose', 0)
|
||
if pre_close > 0:
|
||
self.targetPreClose[stock_code] = float(pre_close)
|
||
PrintLog(LogLevel.INFO, f'[Flet] 已获取 {len(self.targetPreClose)} 个标的昨收价')
|
||
except Exception as e:
|
||
PrintLog(LogLevel.DEBUG, f'[Flet] 昨收价获取异常: {e}')
|
||
|
||
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.tradeTargetData[tid] = t
|
||
self.stockCodeIdMap[t.stock_code] = tid
|
||
if pos is not None:
|
||
self.targetAvgPrice[tid] = float(getattr(pos, 'avg_price', 0) or 0)
|
||
|
||
def _init_strategies(self):
|
||
from core.sfgrid.model import STRATEGY_TYPE_GRID
|
||
for tid, t in self.tradeTargetData.items():
|
||
if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled:
|
||
self.strategy_ctrl[tid] = SFGridStrategy(t)
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 主界面构建(对齐 Tkinter create_tables_area)
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _build_main_ui(self):
|
||
# ── 右侧面板内容 ──
|
||
self._tab_orders = ft.Tab(label="当前委托")
|
||
self._tab_trades = ft.Tab(label="当日成交")
|
||
right_bar = ft.TabBar(tabs=[
|
||
ft.Tab(label="实时价格监控"),
|
||
self._tab_orders,
|
||
self._tab_trades,
|
||
ft.Tab(label="未分类持仓"),
|
||
])
|
||
self._uncl_list = ft.ListView([self._build_unclassified_table()], expand=True)
|
||
self._right_view = ft.TabBarView(controls=[
|
||
self._build_market_view(),
|
||
self._build_order_view(),
|
||
self._build_trade_view(),
|
||
self._uncl_list,
|
||
], expand=True)
|
||
panel_content = ft.Container(
|
||
content=ft.Column([
|
||
ft.Container(ft.Text("监控面板", size=14, weight=ft.FontWeight.BOLD), padding=ft.Padding(10, 10, 10, 5)),
|
||
ft.Tabs(ft.Column([right_bar, self._right_view], expand=True), length=4, expand=True),
|
||
], expand=True),
|
||
width=700, bgcolor=ft.Colors.SURFACE,
|
||
)
|
||
|
||
# ── 遮罩层(点击关闭) ──
|
||
backdrop = ft.Container(
|
||
bgcolor='#44000000', expand=True,
|
||
on_click=lambda e: self._hide_overlay(),
|
||
)
|
||
|
||
# ── overlay 行:遮罩 + 面板 ──
|
||
self._overlay = ft.Container(
|
||
ft.Row([backdrop, panel_content], spacing=0),
|
||
visible=False, expand=True,
|
||
)
|
||
|
||
# ── 标题栏(始终可见,选中行后显示操作按钮) ──
|
||
self._sidebar_icon = _PanelIcon('sidebar', active=False, on_click=lambda e: self._toggle_overlay())
|
||
self._sel_actions = ft.Row([], spacing=4) # 动态操作按钮
|
||
self._sel_info = ft.Text("", size=12, color='#666666')
|
||
grid_title = ft.Container(
|
||
ft.Row([
|
||
ft.Row([
|
||
ft.Text("网格策略持仓", size=13, weight=ft.FontWeight.BOLD),
|
||
self._sel_info,
|
||
self._sel_actions,
|
||
]),
|
||
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),
|
||
)
|
||
|
||
# ── 表格(Stack 内,可被 overlay 覆盖) ──
|
||
self._grid_list = self._build_grid_table() # 回到 DataTable
|
||
grid_body = ft.Container(
|
||
content=self._grid_list, expand=True,
|
||
padding=ft.Padding(10, 0, 10, 10),
|
||
)
|
||
|
||
self.page.add(ft.Column([
|
||
grid_title,
|
||
ft.Stack([grid_body, self._overlay], expand=True),
|
||
], expand=True))
|
||
|
||
# ── 表格工具 ──
|
||
|
||
def _dt(self, cols: list[str], rows: list[list[str]], col_widths: list = None) -> ft.Control:
|
||
"""构建 DataTable"""
|
||
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
|
||
data_rows = []
|
||
for r in rows:
|
||
cells = []
|
||
for i, c in enumerate(r):
|
||
w = col_widths[i] if col_widths and i < len(col_widths) 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.ListView([ft.DataTable(
|
||
columns=data_cols, rows=data_rows,
|
||
width=float('inf'),
|
||
heading_row_height=36, data_row_min_height=32,
|
||
)], expand=True)
|
||
|
||
# ── 各表格 ──
|
||
|
||
def _pending_tags(self, stock_code: str) -> list:
|
||
"""返回该标的下挂单的方向标签列表:'多'(买单) / '空'(卖单)"""
|
||
tags = []
|
||
_CANCELED = {54, 57}
|
||
for o in self._orders:
|
||
if _plain(getattr(o, 'stock_code', '')) != stock_code:
|
||
continue
|
||
if getattr(o, 'order_status', 0) in _CANCELED:
|
||
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
|
||
|
||
def _tag_badge(self, text: str, color: str) -> ft.Container:
|
||
return ft.Container(
|
||
ft.Text(text, size=10, color='white', weight=ft.FontWeight.BOLD),
|
||
bgcolor=color, border_radius=4, padding=ft.Padding(3, 1, 3, 1),
|
||
)
|
||
|
||
def _on_grid_row_select(self, target):
|
||
"""DataRow 选中回调 — 在标题栏显示操作按钮"""
|
||
self._selected_target = target
|
||
name = f'{target.stock_code} {target.stock_name}'
|
||
self._sel_info.value = f" | 已选: {name}"
|
||
actions = []
|
||
if target.enabled:
|
||
actions.append(ft.ElevatedButton("⏸ 暂停", on_click=lambda e, t=target: self._on_stop_trade(t), height=28))
|
||
else:
|
||
actions.append(ft.ElevatedButton("▶ 启动", on_click=lambda e, t=target: self._on_start_trade(t), height=28))
|
||
actions.append(ft.ElevatedButton("⚙ 设置", on_click=lambda e, t=target: self._open_grid_config(t), height=28))
|
||
self._sel_actions.controls = actions
|
||
self.page.update()
|
||
|
||
def _build_grid_table(self) -> ft.Control:
|
||
"""网格表格 — DataTable + on_select_change"""
|
||
cols = ["ID", "股票", "市场价", "持仓", "成本", "网格基准", "状态"]
|
||
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
|
||
data_rows = []
|
||
is_sel = self._selected_target is not None
|
||
sel_id = self._selected_target.get_id() if self._selected_target else -1
|
||
for tid, t in self.tradeTargetData.items():
|
||
if t.strategy_type != 1:
|
||
continue
|
||
pg = t.getPriceGrid()
|
||
idx = t.grid_index
|
||
grid_base = pg[idx] if 0 <= idx < len(pg) else 0
|
||
mp = self.targetMarketPrice.get(tid, 0) or 0
|
||
pre_close = self.targetPreClose.get(t.stock_code, 0) or 0
|
||
up = mp > pre_close and pre_close > 0
|
||
down = mp < pre_close and mp > 0 and pre_close > 0
|
||
pcolor = '#CC0000' if up else '#009900' if down else None
|
||
gtext = ft.Text(f'{grid_base:.2f}', weight=ft.FontWeight.BOLD)
|
||
gparts = [gtext]
|
||
if mp > grid_base > 0:
|
||
gparts.append(ft.Text(' ▲', color='#CC0000', weight=ft.FontWeight.BOLD))
|
||
elif 0 < mp < grid_base:
|
||
gparts.append(ft.Text(' ▼', color='#009900', weight=ft.FontWeight.BOLD))
|
||
for tag in self._pending_tags(t.stock_code):
|
||
gparts.append(self._tag_badge(tag, '#E67E22' if tag == '多' else '#3498DB'))
|
||
gcell = ft.Row(gparts, spacing=3) if len(gparts) > 1 else gtext
|
||
dr = ft.DataRow(cells=[
|
||
ft.DataCell(ft.Text(str(tid))),
|
||
ft.DataCell(ft.Text(f'{t.stock_code} {t.stock_name}')),
|
||
ft.DataCell(ft.Text(f'{mp:.3f}', color=pcolor, weight=ft.FontWeight.BOLD)),
|
||
ft.DataCell(ft.Text(str(t.current_position))),
|
||
ft.DataCell(ft.Text(f'{self.targetAvgPrice.get(tid, 0):.3f}')),
|
||
ft.DataCell(gcell),
|
||
ft.DataCell(ft.Text('▶运行中' if t.enabled else '⏸已暂停')),
|
||
], selected=(is_sel and tid == sel_id))
|
||
dr.on_select_change = lambda e, t=t: self._on_grid_row_select(t)
|
||
data_rows.append(dr)
|
||
if not data_rows:
|
||
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
|
||
return ft.ListView([ft.DataTable(columns=data_cols, rows=data_rows,
|
||
width=float('inf'),
|
||
heading_row_height=36, data_row_min_height=32)], expand=True)
|
||
|
||
def _build_unclassified_table(self) -> ft.Control:
|
||
cols = ["ID", "股票", "市场价", "当前持仓", "平均成本"]
|
||
rows = []
|
||
for tid, t in self.tradeTargetData.items():
|
||
if t.strategy_type == STRATEGY_TYPE_GRID:
|
||
continue
|
||
mp = self.targetMarketPrice.get(tid, 0) or 0
|
||
rows.append([
|
||
str(tid),
|
||
f'{t.stock_code} {t.stock_name}',
|
||
f'{mp:.3f}',
|
||
str(t.current_position),
|
||
f'{self.targetAvgPrice.get(tid, 0):.3f}元',
|
||
])
|
||
return self._dt(cols, rows)
|
||
|
||
def _build_market_view(self) -> ft.Control:
|
||
"""实时价格监控 — 监控配置 + 表格"""
|
||
price_input = ft.TextField(value=str(self.monitor_price), width=80, height=32,
|
||
text_size=13, content_padding=ft.Padding(4, 0, 4, 0))
|
||
confirm_btn = ft.ElevatedButton("确认", on_click=lambda e: self._set_monitor_price(price_input.value), height=32)
|
||
|
||
self._market_table = self._dt(["时间", "股票名称", "最新价格"], [])
|
||
return ft.Column([
|
||
ft.Row([
|
||
ft.Text("监控配置", size=13), ft.Text("价格", size=13),
|
||
price_input, confirm_btn,
|
||
]),
|
||
ft.Container(content=self._market_table, expand=True),
|
||
], expand=True)
|
||
|
||
def _build_order_view(self) -> ft.Control:
|
||
self._order_table = self._dt(
|
||
["时间", "代码", "名称", "方向", "委托价", "委托量", "已成交", "均价", "状态"], [],
|
||
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])
|
||
return self._order_table
|
||
|
||
def _build_trade_view(self) -> ft.Control:
|
||
self._trade_table = self._dt(
|
||
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], [],
|
||
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])
|
||
return self._trade_table
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 事件回调(对齐 Tkinter onMarketDataUpdated)
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _on_market_data(self, data: dict):
|
||
"""行情数据回调 — 来自 QMT 推送"""
|
||
need_rebuild = not self._prices_loaded
|
||
updated_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.tradeTargetData:
|
||
self.targetMarketPrice[tid] = lp
|
||
self.tradeTargetData[tid].market_price = lp
|
||
updated_count += 1
|
||
else:
|
||
# 非目标标的:监控价格触发时记录
|
||
if lp == self.monitor_price or stock_code in self.listening_stock:
|
||
if stock_code not in self.listening_stock:
|
||
self.listening_stock.append(stock_code)
|
||
t_str = time.strftime("%H:%M:%S")
|
||
name = qmtv.getInstrumentName(stock_code)
|
||
self.marketData[stock_code] = {'stock_name': name, 'last_price': lp, 'time': t_str}
|
||
|
||
if need_rebuild and not self._prices_loaded and updated_count > 0:
|
||
self._prices_loaded = True
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
|
||
def _on_market_active_switch(self, is_active: bool):
|
||
self._market_active = is_active
|
||
|
||
def _on_strategy_update(self, target):
|
||
"""策略数据变更 — 成交后立即刷新表格"""
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 刷新循环(对齐 Tkinter refresh_loop)
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _pull_prices(self):
|
||
"""主动拉取缺失的市价(对齐 Tkinter refresh_loop)"""
|
||
for tid, t in self.tradeTargetData.items():
|
||
if tid not in self.targetMarketPrice or self.targetMarketPrice[tid] == 0:
|
||
price = qmtv.getLastPrice(t.stock_code)
|
||
if price > 0:
|
||
self.targetMarketPrice[tid] = price
|
||
t.market_price = price
|
||
|
||
def _manual_refresh(self):
|
||
self._pull_prices()
|
||
self._refresh_positions()
|
||
self._refresh_orders()
|
||
self._refresh_trades()
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
|
||
def _refresh_positions(self):
|
||
positions = qmtv.getAllPositions()
|
||
for t in self.tradeTargetData.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 _rebuild_tables(self):
|
||
"""重建所有表格数据"""
|
||
self._grid_list.controls = [self._build_grid_table()]
|
||
if not self._selected_target:
|
||
self._sel_info.value = ""
|
||
self._sel_actions.controls = []
|
||
self._uncl_list.controls = [self._build_unclassified_table()]
|
||
|
||
# 委托 — 过滤已撤/废单,按 order_id 去重(保留最后一条即最新状态)
|
||
_CANCELED = {54, 57}
|
||
o_map = {} # order_id → latest order
|
||
for o in self._orders:
|
||
oid = str(getattr(o, 'order_id', ''))
|
||
if not oid:
|
||
continue
|
||
o_map[oid] = o # 后面的覆盖前面的
|
||
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
|
||
o_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, '未知'),
|
||
])
|
||
self._tab_orders.label = f"当前委托 ({len(o_rows)})" if o_rows else "当前委托"
|
||
self._order_table.controls = [self._dt(
|
||
["时间", "代码", "名称", "方向", "委托价", "已成交/委托量", "均价", "状态"], o_rows,
|
||
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])]
|
||
|
||
# 成交 — 按 traded_id 去重(保留最后一条)
|
||
t_map = {}
|
||
for t in self._trades:
|
||
tid = str(getattr(t, 'traded_id', ''))
|
||
if not tid:
|
||
continue
|
||
t_map[tid] = t
|
||
t_rows = []
|
||
for t in t_map.values():
|
||
t_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}",
|
||
])
|
||
self._tab_trades.label = f"当日成交 ({len(t_rows)})" if t_rows else "当日成交"
|
||
self._trade_table.controls = [self._dt(
|
||
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], t_rows,
|
||
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])]
|
||
|
||
# 市场监控
|
||
m_rows = []
|
||
for sc, d in self.marketData.items():
|
||
m_rows.append([d['time'], f"{d['stock_name']}-{sc}", f"{d['last_price']:.3f}"])
|
||
self._market_table.controls = [self._dt(["时间", "股票名称", "最新价格"], m_rows)]
|
||
|
||
def _refresh_loop(self):
|
||
"""后台定时刷新 — 对齐 Tkinter: 5s 拉价 + 30s 委托/成交"""
|
||
while True:
|
||
time.sleep(5)
|
||
self._refresh_cycle += 1
|
||
try:
|
||
self._pull_prices()
|
||
self._refresh_positions()
|
||
if self._refresh_cycle % 6 == 0:
|
||
self._refresh_orders()
|
||
self._refresh_trades()
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
except Exception:
|
||
pass
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# 工具栏按钮
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
def _on_start_trade(self, target):
|
||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 启动按钮被点击: {target.stock_code}')
|
||
if target.enabled:
|
||
self._show_toast("该标的正运行中")
|
||
return
|
||
name = f'{target.stock_code} {target.stock_name}'
|
||
dlg = ft.AlertDialog(
|
||
title=ft.Text("确认启动"),
|
||
content=ft.Text(f"确定要启动交易吗?\n\n{name}"),
|
||
actions=[
|
||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||
ft.TextButton("确定", on_click=lambda e, t=target: self._do_start(t)),
|
||
],
|
||
)
|
||
self.page.show_dialog(dlg)
|
||
|
||
def _do_start(self, target):
|
||
self.page.pop_dialog()
|
||
target.enabled = True
|
||
target.save()
|
||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
||
self.strategy_ctrl[target.get_id()] = SFGridStrategy(target)
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
PrintLog(LogLevel.INFO, f'[Flet] 启动交易: {target.targetName()}')
|
||
|
||
def _on_stop_trade(self, target):
|
||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 暂停按钮被点击: {target.stock_code}')
|
||
if not target.enabled:
|
||
self._show_toast("该标的已暂停")
|
||
return
|
||
name = f'{target.stock_code} {target.stock_name}'
|
||
dlg = ft.AlertDialog(
|
||
title=ft.Text("确认暂停"),
|
||
content=ft.Text(f"确定要暂停交易吗?\n\n{name}"),
|
||
actions=[
|
||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||
ft.TextButton("确定", on_click=lambda e, t=target: self._do_stop(t)),
|
||
],
|
||
)
|
||
self.page.show_dialog(dlg)
|
||
|
||
def _do_stop(self, target):
|
||
self.page.pop_dialog()
|
||
target.enabled = False
|
||
target.save()
|
||
ctrl = self.strategy_ctrl.pop(target.get_id(), None)
|
||
if ctrl:
|
||
ctrl.enabledTrading(False)
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
PrintLog(LogLevel.INFO, f'[Flet] 暂停交易: {target.targetName()}')
|
||
|
||
def _open_grid_config(self, target):
|
||
"""网格配置对话框 — 对齐 Tkinter create_grid_config_window"""
|
||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 设置按钮被点击: {target.stock_code}')
|
||
base = ft.TextField(label="基准价格", value=str(target.grid_start_price), width=120, text_size=13)
|
||
gsize = ft.TextField(label="网格大小", value=str(target.grid_size), width=120, text_size=13)
|
||
gvol = ft.TextField(label="网格交易量(手)", value=str(target.grid_volume), width=120, text_size=13)
|
||
gupper = ft.TextField(label="上方网格数", value=str(target.grid_upper_count), width=120, text_size=13)
|
||
glower = ft.TextField(label="下方网格数", value=str(target.grid_lower_count), width=120, text_size=13)
|
||
gidx = ft.TextField(label="当前网格层级", value=str(target.grid_index), width=120, text_size=13)
|
||
|
||
col1 = ft.Column([base, gsize, gvol], spacing=8)
|
||
col2 = ft.Column([gupper, glower, gidx], spacing=8)
|
||
grid_preview = ft.Text("", size=11, italic=True)
|
||
|
||
def _preview(e):
|
||
try:
|
||
bp = float(base.value)
|
||
gs = float(gsize.value)
|
||
up = int(gupper.value)
|
||
lo = int(glower.value)
|
||
prices = []
|
||
for i in range(up, 0, -1):
|
||
prices.append(f"{bp + gs * i:.2f}(卖{up - i + 1})")
|
||
prices.append(f"→{bp:.2f}←(基准)")
|
||
for i in range(1, lo + 1):
|
||
p = bp - gs * i
|
||
if p > 0:
|
||
prices.append(f"{p:.2f}(买{i})")
|
||
grid_preview.value = " ".join(prices)
|
||
grid_preview.update()
|
||
except ValueError:
|
||
grid_preview.value = "请输入有效数字"
|
||
grid_preview.update()
|
||
|
||
def _save(e):
|
||
try:
|
||
target.grid_start_price = float(base.value)
|
||
target.grid_size = float(gsize.value)
|
||
target.grid_volume = int(gvol.value)
|
||
target.grid_upper_count = int(gupper.value)
|
||
target.grid_lower_count = int(glower.value)
|
||
target.grid_index = int(gidx.value)
|
||
target.save()
|
||
self._close_dialog()
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
PrintLog(LogLevel.INFO, f'[Flet] 网格配置已保存: {target.targetName()}')
|
||
except ValueError:
|
||
self._show_toast("请输入有效的数值")
|
||
|
||
dlg = ft.AlertDialog(
|
||
title=ft.Text(f"网格配置 - {target.stock_code} {target.stock_name}"),
|
||
content=ft.Column([
|
||
ft.Row([col1, col2], spacing=20),
|
||
ft.ElevatedButton("预览网格序列", on_click=_preview),
|
||
grid_preview,
|
||
], spacing=10, tight=True, height=320),
|
||
actions=[
|
||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||
ft.ElevatedButton("保存", on_click=_save),
|
||
],
|
||
)
|
||
self.page.show_dialog(dlg)
|
||
|
||
def _show_toast(self, msg: str):
|
||
dlg = ft.AlertDialog(title=ft.Text("提示"), content=ft.Text(msg),
|
||
actions=[ft.TextButton("确定", on_click=lambda e: self._close_dialog(dlg))])
|
||
self.page.show_dialog(dlg)
|
||
|
||
def _close_dialog(self, dlg=None):
|
||
self.page.pop_dialog()
|
||
self.page.update()
|
||
|
||
def _toggle_overlay(self):
|
||
self._drawer_open = not self._drawer_open
|
||
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 _set_monitor_price(self, val: str):
|
||
try:
|
||
self.monitor_price = float(val)
|
||
self.marketData.clear()
|
||
self.listening_stock.clear()
|
||
self._rebuild_tables()
|
||
self.page.update()
|
||
except ValueError:
|
||
pass
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# PanelIcon — 对齐 Tkinter 版 Canvas 手绘图标
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class _PanelIcon(ft.Container):
|
||
"""VSCode 风格面板切换图标 — 两个色块拼成的分栏图标"""
|
||
|
||
_SIZE = 22
|
||
_M = 3
|
||
_COLORS = {
|
||
'light': {'bg': '#f0f0f0', 'hover': '#d4d4d4', 'off': '#b0b0b0', 'on': '#808080', 'active': '#0078d4'},
|
||
'dark': {'bg': '#3c3c3c', 'hover': '#505050', 'off': '#6a6a6a', 'on': '#a0a0a0', 'active': '#ffffff'},
|
||
}
|
||
|
||
def __init__(self, kind: str, active: bool = True, on_click=None):
|
||
self._kind = kind
|
||
self._active = active
|
||
c = self._COLORS['light'] # 默认亮色,后续可扩展暗色检测
|
||
self._bg = c['bg']
|
||
self._hover_bg = c['hover']
|
||
self._off = c['off']
|
||
self._on = c['on']
|
||
self._active_color = c['active']
|
||
|
||
rects = self._build_rects()
|
||
super().__init__(
|
||
content=rects,
|
||
width=self._SIZE, height=self._SIZE,
|
||
bgcolor=self._bg, border_radius=3,
|
||
ink=True, on_click=on_click,
|
||
padding=ft.Padding(self._M, self._M, self._M, self._M),
|
||
)
|
||
|
||
def _build_rects(self):
|
||
off, on, act = self._off, self._on, self._active_color
|
||
bar_w, bar_h = 6, self._SIZE - self._M * 2 - 2
|
||
|
||
if self._kind == 'sidebar':
|
||
c1 = on if self._active else off
|
||
c2 = act if self._active else off
|
||
return ft.Row([
|
||
ft.Container(width=bar_w, height=bar_h, bgcolor=c1, border_radius=1),
|
||
ft.Container(width=2), # gap
|
||
ft.Container(width=bar_w, height=bar_h, bgcolor=c2, border_radius=1),
|
||
], spacing=0)
|
||
else:
|
||
c1 = on if self._active else off
|
||
c2 = act if self._active else off
|
||
return ft.Column([
|
||
ft.Container(width=bar_h, height=bar_w, bgcolor=c1, border_radius=1),
|
||
ft.Container(height=2), # gap
|
||
ft.Container(width=bar_h, height=bar_w, bgcolor=c2, border_radius=1),
|
||
], spacing=0)
|
||
|
||
def set_active(self, active: bool):
|
||
self._active = active
|
||
self.content = self._build_rects()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# 入口
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
def main(page: ft.Page):
|
||
QmtApp(page)
|
||
|
||
|
||
def run():
|
||
ft.app(target=main)
|
||
|
||
def run_web():
|
||
ft.app(target=main, view=ft.AppView.WEB_BROWSER, port=8550)
|