1559 lines
69 KiB
Python
1559 lines
69 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, EventPoolMark
|
||
|
||
# ── 工具函数 ──
|
||
_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 = []
|
||
self.poolLog: list[str] = [] # 股票池管理器日志(list,方便 append)
|
||
|
||
# ── 初始化 ──
|
||
|
||
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=弹性比重; 新增排名列(50px)
|
||
_C = [(35, 0), (0, 2), (70, 1), (0, 2), (50, 1), (55, 1), (50, 0), (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')]
|
||
|
||
# 获取最新评分排名
|
||
rank_map = {} # stock_code -> score_rank
|
||
try:
|
||
from core.scoring.models import ScoringResult
|
||
from peewee import fn
|
||
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
|
||
if latest_date:
|
||
scored = (ScoringResult
|
||
.select(ScoringResult.stock_code, ScoringResult.score_rank)
|
||
.where(ScoringResult.trade_date == latest_date)
|
||
.dicts())
|
||
for r in scored:
|
||
rank_map[r['stock_code']] = r['score_rank']
|
||
except Exception:
|
||
pass
|
||
|
||
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]
|
||
|
||
# 排名列
|
||
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
|
||
rank = rank_map.get(plain, None)
|
||
if rank is not None and rank <= 50:
|
||
rank_str = f'#{rank}'
|
||
rank_color = '#4CAF50'
|
||
elif rank is not None and rank <= 100:
|
||
rank_str = f'#{rank}'
|
||
rank_color = '#2196F3'
|
||
elif rank is not None:
|
||
rank_str = f'#{rank}'
|
||
rank_color = '#888888'
|
||
else:
|
||
rank_str = '—'
|
||
rank_color = '#888888'
|
||
|
||
# 操作按钮
|
||
_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}',
|
||
rank_str,
|
||
'▶运行中' 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 i == 6: # 排名用颜色
|
||
content = _text(cells_text[i], color=rank_color, 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, page=None):
|
||
self._data = data
|
||
self._dialogs = dialogs
|
||
self._tab_orders = ft.Tab(label="当前委托")
|
||
self._tab_trades = ft.Tab(label="当日成交")
|
||
self._tab_dataset = ft.Tab(label="数据集管理")
|
||
self._tab_scoring = ft.Tab(label="每日评分")
|
||
self._order_table = None
|
||
self._trade_table = None
|
||
self._uncl_col = None
|
||
self._market_table = None
|
||
self._dataset_table = None
|
||
self._scoring_table = None
|
||
self._sync_lock = False # 同步线程锁
|
||
self._sync_btns = [] # 同步按钮引用列表
|
||
|
||
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)
|
||
self._dataset_table = ft.ListView(expand=True)
|
||
self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
|
||
self._scoring_table = ft.ListView(expand=True)
|
||
self._backtest_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
|
||
|
||
bar = ft.TabBar(tabs=[
|
||
ft.Tab(label="实时价格监控"),
|
||
self._tab_scoring,
|
||
self._tab_orders,
|
||
self._tab_trades,
|
||
self._tab_dataset,
|
||
ft.Tab(label="未分类持仓"),
|
||
])
|
||
view = ft.TabBarView(controls=[
|
||
self._build_market_tab(),
|
||
self._build_scoring_tab(),
|
||
self._order_table,
|
||
self._trade_table,
|
||
self._build_dataset_tab(),
|
||
self._uncl_col,
|
||
], expand=True)
|
||
|
||
panel = ft.Container(
|
||
ft.Column([
|
||
ft.Tabs(ft.Column([bar, view], expand=True), length=6, expand=True),
|
||
], expand=True),
|
||
width=750, 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()
|
||
self._refresh_dataset()
|
||
self._refresh_scoring()
|
||
|
||
def _refresh_grid(self):
|
||
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
|
||
# 新增: 排名列 (50px)
|
||
_C = [(35, 0), (0, 1), (80, 0), (60, 0), (70, 0), (50, 0), (65, 0)]
|
||
H = ["ID", "股票", "市场价", "持仓", "成本", "排名", "操作"]
|
||
|
||
# 获取最新评分排名
|
||
rank_map = {} # stock_code -> score_rank
|
||
try:
|
||
from core.scoring.models import ScoringResult
|
||
from peewee import fn
|
||
latest_date = ScoringResult.select(fn.MAX(ScoringResult.trade_date)).scalar()
|
||
if latest_date:
|
||
rows = (ScoringResult
|
||
.select(ScoringResult.stock_code, ScoringResult.score_rank)
|
||
.where(ScoringResult.trade_date == latest_date)
|
||
.dicts())
|
||
for r in rows:
|
||
rank_map[r['stock_code']] = r['score_rank']
|
||
except Exception:
|
||
pass # 数据库未就绪时忽略
|
||
|
||
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
|
||
plain = t.stock_code.split('.')[0] if '.' in t.stock_code else t.stock_code
|
||
rank = rank_map.get(plain, None)
|
||
rank_str = f"#{rank}" if rank is not None else "—"
|
||
# 排名颜色: top50 绿色, top100 蓝色, 其他灰色
|
||
if rank is not None and rank <= 50:
|
||
rank_color = '#4CAF50'
|
||
elif rank is not None and rank <= 100:
|
||
rank_color = '#2196F3'
|
||
else:
|
||
rank_color = '#888888'
|
||
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]),
|
||
_cell(rank_str, *_C[5], color=rank_color),
|
||
ft.Container(ft.IconButton(ft.Icons.SETTINGS, icon_size=20, tooltip="网格配置",
|
||
on_click=lambda e, tt=t: self._dialogs.open_config(tt)),
|
||
width=_C[6][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:
|
||
op_w, _ = _C[3]
|
||
cells.append(ft.Container(
|
||
ft.Row([ft.Text("已在池", color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)],
|
||
alignment=ft.MainAxisAlignment.CENTER),
|
||
width=op_w, padding=0))
|
||
else:
|
||
cells.append(ft.Container(
|
||
ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓",
|
||
on_click=lambda e, code=sc: self._on_add_from_market(code))],
|
||
alignment=ft.MainAxisAlignment.CENTER),
|
||
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)]
|
||
|
||
# ── Tab 5: 数据集管理 ──
|
||
# 按依赖顺序: kline → stocks → industry → market → sector
|
||
_SYNC_DEPS = {
|
||
'kline': ['kline'],
|
||
'stocks': ['stocks'],
|
||
'industry':['industry'],
|
||
'market': ['market'],
|
||
'sector': ['sector'],
|
||
'all': ['kline', 'stocks', 'industry', 'market', 'sector'],
|
||
}
|
||
_SYNC_LABELS = {
|
||
'kline': '同步K线',
|
||
'stocks': '同步股票信息',
|
||
'industry':'同步行业映射',
|
||
'market': '计算市场状态',
|
||
'sector': '计算行业指数',
|
||
}
|
||
|
||
def _build_dataset_tab(self) -> ft.Control:
|
||
self._dataset_status = ft.Text("就绪", size=12, color='#888888')
|
||
self._backtest_label = ft.Text("v6.7r3 回测曲线", size=11, color='#aaaaaa')
|
||
self._backtest_img = ft.Container(visible=False) # 回测图表占位,运行时替换为 Image
|
||
return ft.Column([
|
||
self._dataset_status,
|
||
self._dataset_col,
|
||
ft.Divider(height=2, color='#444444'),
|
||
self._backtest_label,
|
||
self._backtest_img,
|
||
], expand=True, spacing=6, scroll=ft.ScrollMode.AUTO)
|
||
|
||
def _run_sync(self, targets: list):
|
||
# 线程锁
|
||
if self._sync_lock:
|
||
self._dataset_status.value = "同步中,跳过重复点击"
|
||
self._dataset_status.update()
|
||
return
|
||
self._sync_lock = True
|
||
self._set_sync_btns_disabled(True)
|
||
self._dataset_status.value = "同步中..."
|
||
self._dataset_status.update()
|
||
import threading
|
||
|
||
def _do():
|
||
from core.scoring.sync import (
|
||
KlineStockSync, KlineIndexSync, StocksSync,
|
||
IndustrySync, MarketRegimeSync, SectorFeaturesSync,
|
||
)
|
||
syncs = {
|
||
'kline': [KlineStockSync(count=300), KlineIndexSync(count=300)],
|
||
'stocks': [StocksSync()],
|
||
'industry':[IndustrySync()],
|
||
'market': [MarketRegimeSync()],
|
||
'sector': [SectorFeaturesSync()],
|
||
}
|
||
order = []
|
||
for t in targets:
|
||
for key in self._SYNC_DEPS.get(t, [t]):
|
||
order.extend(syncs.get(key, []))
|
||
for s in order:
|
||
try:
|
||
s.run()
|
||
except Exception:
|
||
pass
|
||
self._sync_lock = False
|
||
self._set_sync_btns_disabled(False)
|
||
self._dataset_status.value = "同步完成"
|
||
self._dataset_status.update()
|
||
self._refresh_dataset()
|
||
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
|
||
def _refresh_backtest(self):
|
||
"""加载 v6.7r3 回测图表,嵌入数据集 Tab"""
|
||
import pandas as pd
|
||
import io, base64, json
|
||
import matplotlib
|
||
matplotlib.use('Agg')
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.dates as mdates
|
||
from pathlib import Path
|
||
|
||
model_dir = Path(__file__).parent.parent.parent.parent / 'models'
|
||
daily_csv = model_dir / 'backtest_v67r3_daily.csv'
|
||
summary_json = model_dir / 'backtest_v67r3_summary.json'
|
||
|
||
if not daily_csv.exists():
|
||
self._backtest_label.value = "回测数据不存在"
|
||
self._backtest_label.update()
|
||
return
|
||
|
||
try:
|
||
df_daily = pd.read_csv(daily_csv, parse_dates=['date'])
|
||
summary = json.loads(summary_json.read_text()) if summary_json.exists() else {}
|
||
|
||
# 更新标题行
|
||
start_val = summary.get('initial_cash', 60000)
|
||
final_val = summary.get('final_total_value', 0)
|
||
self._backtest_label.value = (f"v6.7r3 回测 初始:{start_val:,.0f} → 终值:{final_val:,.0f} "
|
||
f"收益:{summary.get('total_return_pct',0):.1f}% "
|
||
f"夏普:{summary.get('annual_sharpe',0):.2f} "
|
||
f"回撤:{summary.get('max_drawdown_pct',0):.1f}%")
|
||
self._backtest_label.update()
|
||
|
||
# 渲染总资产曲线
|
||
import matplotlib.font_manager as mfont
|
||
# 查找中文字体
|
||
cjk_names = ['Microsoft YaHei', 'SimHei', 'Noto Sans SC', 'WenQuanYi']
|
||
cjk_font = next((f.fname for f in mfont.fontManager.ttflist
|
||
if any(n in f.name for n in cjk_names)), None)
|
||
if cjk_font:
|
||
prop = mfont.FontProperties(fname=cjk_font)
|
||
plt.rcParams['font.family'] = prop.get_name()
|
||
plt.rcParams['axes.unicode_minus'] = False
|
||
fig, ax = plt.subplots(figsize=(7, 3.5), dpi=100)
|
||
fig.patch.set_facecolor('#1e1e1e')
|
||
ax.set_facecolor('#2d2d2d')
|
||
ax.plot(df_daily['date'], df_daily['total_asset'], color='#4CAF50', linewidth=1.5)
|
||
ax.fill_between(df_daily['date'], df_daily['total_asset'], alpha=0.1, color='#4CAF50')
|
||
ax.set_title('总资产曲线 (v6.7r3)', color='#ffffff', fontsize=10)
|
||
ax.set_ylabel('元', color='#cccccc', fontsize=9)
|
||
ax.tick_params(colors='#cccccc', labelsize=8)
|
||
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
|
||
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))
|
||
plt.setp(ax.xaxis.get_majorticklabels(), rotation=30, ha='right')
|
||
for spine in ax.spines.values():
|
||
spine.set_color('#555555')
|
||
ax.grid(True, alpha=0.15, color='#888888')
|
||
# 标注起点终点
|
||
ax.annotate(f'{start_val:,.0f}', xy=(df_daily['date'].iloc[0], start_val),
|
||
xytext=(3, 5), textcoords='offset points', color='#aaaaaa', fontsize=8)
|
||
ax.annotate(f'{final_val:,.0f}', xy=(df_daily['date'].iloc[-1], final_val),
|
||
xytext=(3, 5), textcoords='offset points', color='#4CAF50', fontsize=8, fontweight='bold')
|
||
plt.tight_layout()
|
||
buf = io.BytesIO()
|
||
fig.savefig(buf, format='png', bbox_inches='tight', facecolor=fig.get_facecolor())
|
||
buf.seek(0)
|
||
img_b64 = base64.b64encode(buf.read()).decode()
|
||
plt.close(fig)
|
||
|
||
# 用实际 Image 替换占位的 Container
|
||
new_img = ft.Image(
|
||
src=f'data:image/png;base64,{img_b64}',
|
||
fit=ft.BoxFit.CONTAIN,
|
||
height=240,
|
||
visible=True,
|
||
)
|
||
# 找到 placeholder Container 在 Column 中的位置,替换
|
||
idx = self._backtest_label.parent.controls.index(self._backtest_img)
|
||
self._backtest_label.parent.controls[idx] = new_img
|
||
self._backtest_img = new_img
|
||
self._backtest_label.parent.update()
|
||
|
||
except Exception as ex:
|
||
self._backtest_label.value = f"图表加载失败: {ex}"
|
||
self._backtest_label.color = '#F44336'
|
||
self._backtest_label.update()
|
||
|
||
def _set_sync_btns_disabled(self, disabled: bool):
|
||
for btn in self._sync_btns:
|
||
btn.disabled = disabled
|
||
btn.update()
|
||
|
||
def _refresh_dataset(self):
|
||
from core.scoring.models import (
|
||
KlineStock, KlineIndex, StockInfo, IndustryMapping,
|
||
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
|
||
)
|
||
tables = [
|
||
("kline_stock", KlineStock, "kline", "trade_date"),
|
||
("kline_index", KlineIndex, "kline", "trade_date"),
|
||
("stocks", StockInfo, "stocks", "share_updated_at"),
|
||
("industry", IndustryMapping, "industry","update_date"),
|
||
("market_regime", MarketRegimeDaily, "market", "trade_date"),
|
||
("sector_features", SectorFeaturesDaily, "sector", "trade_date"),
|
||
("ScoringResult", ScoringResult, "all", "trade_date"),
|
||
]
|
||
# 与市场监控Tab一致的列宽布局: (width, expand)
|
||
_C = [(150, 0), (80, 0), (100, 0)]
|
||
H = ["表名", "记录数", "最新日期"]
|
||
|
||
def _hcell(text, w, e):
|
||
if e > 0:
|
||
return ft.Container(
|
||
ft.Text(text, weight=ft.FontWeight.BOLD), padding=4, expand=e)
|
||
return ft.Container(
|
||
ft.Text(text, weight=ft.FontWeight.BOLD), width=w, padding=4)
|
||
|
||
header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0)
|
||
self._sync_btns.clear()
|
||
if self._sync_lock:
|
||
for btn in self._sync_btns:
|
||
btn.disabled = True
|
||
rows = [header, ft.Divider(height=1, color='#e0e0e0')]
|
||
|
||
for name, model_cls, dep_key, date_col in tables:
|
||
try:
|
||
cnt = model_cls.select().count()
|
||
df = getattr(model_cls, date_col, None)
|
||
if df is not None:
|
||
last = (model_cls.select(df).order_by(df.desc()).first())
|
||
date_str = str(getattr(last, date_col, "")) if last else ""
|
||
else:
|
||
date_str = ""
|
||
except Exception:
|
||
cnt, date_str = "ERR", ""
|
||
|
||
def _dcell(text, w, e=0):
|
||
if e > 0:
|
||
return ft.Container(ft.Text(text), padding=4, expand=e)
|
||
return ft.Container(ft.Text(text), width=w, padding=4)
|
||
|
||
cells = [
|
||
_dcell(name, *_C[0]),
|
||
_dcell(str(cnt), *_C[1]),
|
||
_dcell(date_str, *_C[2]),
|
||
]
|
||
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._dataset_col.controls = rows
|
||
self._refresh_backtest()
|
||
|
||
# ── Tab 6: 每日评分 ──
|
||
def _build_scoring_tab(self) -> ft.Control:
|
||
from datetime import date, timedelta
|
||
self._score_cur_date = date.today()
|
||
self._score_date_label = ft.Text(str(self._score_cur_date), size=14, weight=ft.FontWeight.BOLD)
|
||
self._score_status = ft.Text("无评分数据", size=12, color='#888888')
|
||
|
||
def _prev_day(_):
|
||
self._score_cur_date -= timedelta(days=1)
|
||
self._score_date_label.value = str(self._score_cur_date)
|
||
self._score_date_label.update()
|
||
self._refresh_scoring()
|
||
|
||
def _next_day(_):
|
||
self._score_cur_date += timedelta(days=1)
|
||
self._score_date_label.value = str(self._score_cur_date)
|
||
self._score_date_label.update()
|
||
self._refresh_scoring()
|
||
|
||
def _today(_):
|
||
self._score_cur_date = date.today()
|
||
self._score_date_label.value = str(self._score_cur_date)
|
||
self._score_date_label.update()
|
||
self._refresh_scoring()
|
||
|
||
date_row = ft.Row([
|
||
ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day),
|
||
self._score_date_label,
|
||
ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day),
|
||
ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today),
|
||
ft.Container(ft.Divider(height=20), width=2),
|
||
ft.ElevatedButton("同步数据", on_click=lambda e: self._run_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32),
|
||
ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), height=32),
|
||
ft.Container(ft.Divider(height=20), width=2),
|
||
ft.ElevatedButton("沉寂检测", on_click=lambda e: self._data._pool_manager.trigger_slumber(), height=32, tooltip="手动触发沉寂检测(阶段一标记)"),
|
||
ft.ElevatedButton("淘汰检测", on_click=lambda e: self._data._pool_manager.trigger_weekly_elim(), height=32, tooltip="手动触发周度淘汰检测(阶段一标记)"),
|
||
self._score_status,
|
||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||
return ft.Column([
|
||
date_row,
|
||
self._scoring_table,
|
||
], expand=True, spacing=6)
|
||
|
||
def _run_scoring(self):
|
||
self._score_status.value = "评分中..."
|
||
self._score_status.update()
|
||
import threading
|
||
|
||
def _do():
|
||
from core.scoring.inference.scorer import GridSeekerPipeline
|
||
trade_date = self._score_cur_date
|
||
|
||
# 检查当日 K线数据是否已同步
|
||
from peewee import fn
|
||
from core.scoring.models import KlineStock
|
||
latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar()
|
||
if latest_kline is None or latest_kline < trade_date:
|
||
self._score_status.value = f"K线未同步至 {trade_date},请先盘后同步"
|
||
self._score_status.color = '#F44336'
|
||
self._score_status.update()
|
||
return
|
||
|
||
try:
|
||
engine = GridSeekerPipeline()
|
||
rankings = engine.run(trade_date)
|
||
if not rankings.empty:
|
||
engine.persist(rankings, trade_date)
|
||
self._score_status.value = f"{trade_date} 评分完成"
|
||
except FileNotFoundError:
|
||
self._score_status.value = "模型文件缺失"
|
||
except Exception as ex:
|
||
self._score_status.value = f"失败: {ex}"
|
||
self._score_status.update()
|
||
self._refresh_scoring()
|
||
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
|
||
def _refresh_scoring(self):
|
||
from core.scoring.models import ScoringResult
|
||
from core.qmt import qmtv
|
||
|
||
# (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32
|
||
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
|
||
H = ["排名", "代码 / 名称", "Rank轮数", "Top概率", "Stacking概率", "操作"]
|
||
|
||
def _hcell(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)
|
||
|
||
def _dcell(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)
|
||
|
||
header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0)
|
||
rows = [header, ft.Divider(height=1, color='#e0e0e0')]
|
||
|
||
# 用导航日期查询
|
||
trade_date = self._score_cur_date
|
||
scored_rows = list((ScoringResult
|
||
.select()
|
||
.where(ScoringResult.trade_date == trade_date)
|
||
.order_by(ScoringResult.score_rank)
|
||
.limit(200)
|
||
.dicts()))
|
||
|
||
if not scored_rows:
|
||
self._score_status.value = f"{trade_date} 无评分数据"
|
||
self._score_status.color = '#F44336'
|
||
self._scoring_table.controls = rows
|
||
self._score_status.update()
|
||
return
|
||
|
||
shown = 0
|
||
for r in scored_rows:
|
||
code = r['stock_code']
|
||
plain = code.split('.')[0] if '.' in code else code
|
||
# 过滤 ST
|
||
from core.scoring.models import StockInfo
|
||
if plain.startswith(('6', '5', '9')):
|
||
full_code = f'{plain}.SH'
|
||
else:
|
||
full_code = f'{plain}.SZ'
|
||
st = StockInfo.get_or_none(StockInfo.code == full_code)
|
||
if st and st.listing_status == 'ST':
|
||
continue
|
||
name = ''
|
||
try:
|
||
name = qmtv.getInstrumentName(plain)
|
||
except Exception:
|
||
pass
|
||
already_in = plain in self._data.stockCodeIdMap
|
||
shown += 1
|
||
|
||
cells = [
|
||
_dcell(str(r.get("score_rank", "")), _C[0][0], _C[0][1]),
|
||
_dcell(f"{plain} {name}", _C[1][0], _C[1][1]),
|
||
_dcell(f"{r.get("rank_predicted_rounds", 0) or 0:.4f}", _C[2][0], _C[2][1]),
|
||
_dcell(f"{r.get("top_elite_prob", 0) or 0:.4f}", _C[3][0], _C[3][1]),
|
||
_dcell(f"{r.get("stacking_probability", 0) or 0:.4f}", _C[4][0], _C[4][1]),
|
||
]
|
||
if already_in:
|
||
op_w = _C[5][0]
|
||
cells.append(ft.Container(
|
||
ft.Row([ft.Text('已添加', color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)],
|
||
alignment=ft.MainAxisAlignment.CENTER),
|
||
width=op_w, padding=0))
|
||
else:
|
||
op_w = _C[5][0]
|
||
cells.append(ft.Container(
|
||
ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip='加入网格',
|
||
on_click=lambda e, c=plain, n=name: self._on_add_from_scoring(c, n))],
|
||
alignment=ft.MainAxisAlignment.CENTER),
|
||
width=op_w, 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._scoring_table.controls = rows
|
||
self._score_status.value = f"{trade_date} 候选 {shown} 只"
|
||
self._score_status.color = '#4CAF50'
|
||
self._score_status.update()
|
||
|
||
def _on_add_from_scoring(self, stock_code: str, stock_name: str):
|
||
"""+ 按钮:从评分列表添加标的并打开网格配置"""
|
||
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)
|
||
self._refresh_scoring() # 刷新状态(显示"已添加")
|
||
|
||
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.theme_mode = ft.ThemeMode.LIGHT
|
||
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())
|
||
event_bus.subscribe(EventPoolMark, self._on_pool_mark)
|
||
threading.Thread(target=self._refresh_loop, daemon=True).start()
|
||
|
||
# 启动股票池管理器(阶段一:T日标记)
|
||
from core.sfgrid.pool_manager import PoolManager
|
||
self._data._pool_manager = PoolManager()
|
||
self._data._pool_manager.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_pool_mark(self, data: dict):
|
||
"""响应股票池标记事件,在日志区显示"""
|
||
action = data.get('action', '')
|
||
count = data.get('count', 0)
|
||
codes = data.get('stock_codes', [])
|
||
if action == 'scoring':
|
||
msg = f'[池] 评分完成,共 {count} 只候选'
|
||
elif action == 'liquidate':
|
||
msg = f'[池] 沉寂检测完成,标记 {count} 只股: {", ".join(codes)}'
|
||
elif action == 'eliminate':
|
||
msg = f'[池] 周度淘汰检测完成,标记 {count} 只股: {", ".join(codes)}'
|
||
else:
|
||
msg = f'[池] 未知事件: {data}'
|
||
self._data.poolLog.append(msg)
|
||
# 追加到 UI 日志
|
||
if hasattr(self, '_pool_log_text') and self._pool_log_text:
|
||
log = self._pool_log_text
|
||
log.value = '\n'.join(self._data.poolLog[-200:]) if self._data.poolLog else ''
|
||
log.update()
|
||
from core.logger import PrintLog, LogLevel
|
||
PrintLog(LogLevel.INFO, msg)
|
||
|
||
def trigger_pool_slumber(self):
|
||
"""供 UI 按钮手动触发沉寂检测"""
|
||
self._data._pool_manager.trigger_slumber()
|
||
|
||
def trigger_pool_elim(self):
|
||
"""供 UI 按钮手动触淘汰检测"""
|
||
self._data._pool_manager.trigger_weekly_elim()
|
||
|
||
def _on_market_active(self, is_active: bool):
|
||
from core.logger import PrintLog, LogLevel
|
||
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')
|
||
self._update_market_status(is_active)
|
||
self.page.update()
|
||
PrintLog(LogLevel.INFO, f'[UI] 市场状态已更新,_market_label={self._market_label.value}')
|
||
|
||
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)
|