This commit is contained in:
2026-06-24 14:02:08 +08:00
parent 6494f43ddd
commit 363efd6d2d
9 changed files with 99 additions and 3221 deletions
+1
View File
@@ -9,3 +9,4 @@ example.db.bak
venv/ venv/
flet_desktop/ flet_desktop/
.flet/ .flet/
sfgrid.log
-836
View File
@@ -1,836 +0,0 @@
"""
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 = []
_TERMINAL = {54, 56, 57}
for o in self._orders:
if _plain(getattr(o, 'stock_code', '')) != stock_code:
continue
if getattr(o, 'order_status', 0) in _TERMINAL:
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 去重
_TERMINAL = {54, 56, 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 _TERMINAL:
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)
+94 -1
View File
@@ -474,6 +474,7 @@ class _DrawerPanel:
self._dataset_table = ft.ListView(expand=True) self._dataset_table = ft.ListView(expand=True)
self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True) self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
self._scoring_table = ft.ListView(expand=True) self._scoring_table = ft.ListView(expand=True)
self._backtest_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
bar = ft.TabBar(tabs=[ bar = ft.TabBar(tabs=[
ft.Tab(label="实时价格监控"), ft.Tab(label="实时价格监控"),
@@ -666,10 +667,15 @@ class _DrawerPanel:
def _build_dataset_tab(self) -> ft.Control: def _build_dataset_tab(self) -> ft.Control:
self._dataset_status = ft.Text("就绪", size=12, color='#888888') 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([ return ft.Column([
self._dataset_status, self._dataset_status,
self._dataset_col, self._dataset_col,
], expand=True, spacing=6) 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): def _run_sync(self, targets: list):
# 线程锁 # 线程锁
@@ -712,6 +718,92 @@ class _DrawerPanel:
threading.Thread(target=_do, daemon=True).start() 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): def _set_sync_btns_disabled(self, disabled: bool):
for btn in self._sync_btns: for btn in self._sync_btns:
btn.disabled = disabled btn.disabled = disabled
@@ -776,6 +868,7 @@ class _DrawerPanel:
rows.append(ft.Divider(height=1, color='#f0f0f0')) rows.append(ft.Divider(height=1, color='#f0f0f0'))
self._dataset_col.controls = rows self._dataset_col.controls = rows
self._refresh_backtest()
# ── Tab 6: 每日评分 ── # ── Tab 6: 每日评分 ──
def _build_scoring_tab(self) -> ft.Control: def _build_scoring_tab(self) -> ft.Control:
View File
-142
View File
@@ -1,142 +0,0 @@
import tkinter as tk
from tkinter import ttk
from core.logger import LogLevel, LogData, PrintLog
from core.ui.tkinter.sfgrid_view import TradeTargetUI
# 检测运行环境,决定使用真实或模拟 QMT
def get_qmt_module():
try:
# 尝试导入真实 QMT,如果失败则使用模拟
from core.qmt import qmtv
return qmtv
except ImportError:
from core.qmt_dummy import qmtv
return qmtv
qmtv = get_qmt_module()
from core.eventbus import EventPrintLog
from core.eventbus import event_bus as eBus
class MainWindow:
def __init__(self, configLogLevel:str, progress=None):
self.root = tk.Tk()
self.root.title("神之一手 - 交易系统")
self.root.geometry("1400x700")
self.logLevel = LogLevel[configLogLevel]
PrintLog(LogLevel.DEBUG, f"系统启动成功 {self.logLevel.name}")
# 存储各个Frame的引用
self.strategy_frames = {}
# 日志面板可见性标志
self.log_visible = False
self.create_ui(progress)
eBus.subscribe(EventPrintLog, self.on_log_event)
def create_ui(self, progress=None):
"""创建UI界面"""
# 主容器
main_container = ttk.Frame(self.root)
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 中间主体区域
content_area = ttk.Frame(main_container)
content_area.pack(fill=tk.BOTH, expand=True)
# 右侧内容区域容器
self.content_container = ttk.Frame(content_area)
self.content_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# 创建策略Frame
strategy_names = ["网格"]
self.create_strategy_frames(strategy_names, progress)
# 创建全局日志面板(默认隐藏)
self.create_global_log_panel(main_container)
# 默认显示第一个策略
self.show_strategy_frame(0)
def create_global_log_panel(self, parent):
"""创建全局日志面板"""
# 日志区域(默认隐藏)
self.log_frame = ttk.LabelFrame(parent, text="操作日志", padding=10)
# 默认不显示,通过工具栏按钮控制
# 创建日志表格
columns = ("timestamp", "level", "message")
self.log_table = ttk.Treeview(self.log_frame, columns=columns, show='headings', height=8)
log_column_configs = {
"timestamp": ("时间", 100),
"level": ("级别", 50),
"message": ("消息", 1150) # 调整宽度适应全局布局
}
for col in columns:
title, width = log_column_configs[col]
self.log_table.heading(col, text=title)
self.log_table.column(col, width=width, anchor=tk.W)
# 添加初始日志
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.log_table.insert('', tk.END, values=(timestamp, "INFO", "系统启动成功"))
# 滚动条
scrollbar = ttk.Scrollbar(self.log_frame, orient=tk.VERTICAL, command=self.log_table.yview)
self.log_table.configure(yscrollcommand=scrollbar.set)
self.log_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def on_log_event(self, event:LogData):
if self.logLevel.value <= event.level.value:
self.add_log(event.level, event.message)
def add_log(self, level:LogLevel, message):
"""添加日志记录 - 全局方法"""
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.log_table.insert('', 0, values=(timestamp, level.name, message))
def clear_logs(self):
"""清空日志记录"""
# 删除所有日志项
for item in self.log_table.get_children():
self.log_table.delete(item)
def create_strategy_frames(self, strategy_names, progress=None):
"""创建各个策略的Frame"""
frame = TradeTargetUI(self.content_container, progress=progress)
self.strategy_frames[0] = frame
def show_strategy_frame(self, index):
"""显示策略Frame"""
if index in self.strategy_frames:
self.strategy_frames[index].pack(fill=tk.BOTH, expand=True)
def toggle_log_panel(self):
"""切换日志面板的显示/隐藏"""
if self.log_visible:
self.log_frame.pack_forget()
self.log_visible = False
else:
self.log_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
self.log_visible = True
def on_exit(self):
"""退出程序"""
from tkinter import messagebox
result = messagebox.askyesno("确认退出", "确定要退出系统吗?")
if result:
self.root.destroy()
def run(self):
"""运行程序"""
self.root.mainloop()
File diff suppressed because it is too large Load Diff
-112
View File
@@ -1,112 +0,0 @@
"""
启动进度窗口 — 无边框小窗口,负责整个初始化流程。
"""
import time
import tkinter as tk
from tkinter import ttk, messagebox
class SplashWindow:
"""初始化进度窗口,所有者启动逻辑"""
def __init__(self):
self.root = tk.Tk()
self.root.title("神之一手")
self.root.geometry("380x120")
self.root.resizable(False, False)
self.root.overrideredirect(True)
self.root.update_idletasks()
sw = self.root.winfo_screenwidth()
sh = self.root.winfo_screenheight()
w, h = 380, 120
self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
frame = ttk.Frame(self.root, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="神之一手", font=('Microsoft YaHei', 14, 'bold')).pack(pady=(0, 5))
self._status = ttk.Label(frame, text="正在初始化...", font=('Microsoft YaHei', 9))
self._status.pack(pady=(0, 10))
self._bar = ttk.Progressbar(frame, mode='determinate', length=340)
self._bar.pack()
self.root.update()
def progress(self, text: str, pct: float):
self._status.configure(text=text)
self._bar.configure(value=pct)
self.root.update()
def _destroy(self):
self.root.destroy()
def run(self):
"""执行完整启动流程,成功返回主窗口,失败返回 None"""
from core.qmt_real import RealQmtV, qmtv as selected_qmtv
while True:
_t_total = time.time()
# 步骤1: 探测 QMT 环境
self.progress("正在检查 QMT 环境...", 10)
_t = time.time()
try:
discovered = RealQmtV._discover_qmt_port()
except Exception:
discovered = 0
print(f'[计时] 步骤1-探测QMT环境: {time.time() - _t:.2f}s')
if not discovered:
self._destroy()
messagebox.showerror(
"启动失败",
"未能自动探测到 QMT 环境。\n\n"
"请确认:\n"
"1. 极简QMT(GJQMT)已启动并登录\n"
"2. XtMiniQmt.exe 和 miniquote.exe 进程在运行"
)
return None
# 步骤2: 初始化交易器
self.progress("正在初始化交易器...", 35)
_t = time.time()
selected_qmtv.init_qmtv()
print(f'[计时] 步骤2-初始化交易器: {time.time() - _t:.2f}s')
# 步骤3: 连接 QMT
self.progress("正在连接 QMT...", 55)
_t = time.time()
connected = selected_qmtv.connect()
print(f'[计时] 步骤3-连接QMT: {time.time() - _t:.2f}s')
if not connected:
self._destroy()
option = messagebox.askokcancel(
"连接失败",
"QMT 连接失败。\n\n"
"请确认极简QMT 已启动并登录交易账号。\n"
"点击「确定」重试,或「取消」退出。"
)
if not option:
return None
# 重试:重新创建进度窗口
self.__init__()
continue
# 步骤4: 加载主界面
self.progress("正在加载持仓与策略...", 75)
_t = time.time()
from core.ui.tkinter.main_window import MainWindow
window = MainWindow('INFO', progress=lambda t, p: self.progress(t, 75 + p * 0.2))
print(f'[计时] 步骤4-主界面加载: {time.time() - _t:.2f}s')
window.root.update()
# 步骤5: 完成
self.progress("启动完成", 100)
self.root.update()
self.root.after(300, self._destroy)
print(f'[计时] 总启动耗时: {time.time() - _t_total:.2f}s')
return window
-259
View File
@@ -1,259 +0,0 @@
from kuanke.wizard import *
from jqdata import *
import pandas as pd
import numpy as np
# ==================== 初始化 ====================
def initialize(context):
set_params(context)
# 开启防未来函数
set_option('avoid_future_data', True)
# 用真实价格交易
set_option('use_real_price', True)
# 过滤order中低于error级别的日志
log.set_level('order', 'error')
log.set_level('system', 'error')
log.set_level('strategy', 'debug')
set_benchmark('000001.XSHG')
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.0002, close_commission=0.0002, min_commission=5), type='stock')
set_slippage(FixedSlippage(0.01))
run_daily(before_trading, '9:30')
# -------------------- 参数设置 --------------------
def set_params(context):
context.max_price = 6
context.min_price = 5.01
context.grid_base_min = 1 # 最小价格
context.grid_base_max = 5 # 建仓价格
context.grid_interval = 0.5 # 下跌n元加仓
context.profit_target = 0.5 # 上涨n元清仓
context.min_stocks = 10
context.max_stocks = 25
context.base_max_stocks = 25
context.max_layers = 7
context.base_position_pct = 0.15
context.max_position_pct = 0.15
context.target_usage = 0.98
context.reserve_ratio = 0.02
context.first_round_max = 10
context.add_batch_size = 3
context.add_cash_threshold = 0.4
g.stock_pool = []
g.grid_info = {}
g.monitoring_stocks = set()
g.first_round_done = False
# ==================== 盘前 ====================
def before_trading(context):
january_clear(context)
if context.current_dt.month == 1:
return
stock_pool = get_stock_pool(context)
g.stock_pool = stock_pool
g.monitoring_stocks.update([s for s in stock_pool if s not in g.grid_info])
g.first_round_done = len(g.grid_info) >= context.first_round_max
# -------------------- 股票池 --------------------
def get_stock_pool(context):
# 1. 全部 A 股(不含退市)
df_sec = get_all_securities(types=['stock'], date=context.previous_date)
codes = list(df_sec.index)
# 2. 过滤 ST、科创板、北交所
def is_valid(code):
name = df_sec.loc[code, 'display_name']
if 'ST' in name or '退' in name or 'st' in name:
return False
if code.startswith('688'): # 科创板
return False
if code.startswith('83') or code.startswith('87') or code.startswith('9'): # 北交所
return False
return True
codes = [c for c in codes if is_valid(c)]
if not codes:
return []
# 3. 过滤停牌 & 价格区间
try:
price_df = get_price(codes,
end_date=context.current_dt,
count=1,
fields=['pre_close'],
panel=False)
if price_df is None or price_df.empty:
return []
# 过滤价格区间
price_df = price_df[
(price_df['pre_close'].notna()) &
(price_df['pre_close'] >= context.min_price) &
(price_df['pre_close'] <= context.max_price)
]
valid_codes = price_df['code'].tolist()
except Exception as e:
log.error(f"获取价格数据失败: {e}")
return []
if not valid_codes:
return []
# 4. 过滤停牌(开盘价缺失)
try:
open_df = get_price(valid_codes,
end_date=context.current_dt,
count=1,
fields=['open'],
panel=False)
if open_df is None or open_df.empty:
return []
# 过滤掉开盘价为空的股票
open_df = open_df[open_df['open'].notna()]
final_codes = open_df['code'].tolist()
except Exception as e:
log.error(f"获取开盘价数据失败: {e}")
return []
return final_codes
# -------------------- 一月清仓 --------------------
def january_clear(context):
if context.current_dt.month == 1:
log.info("进入1月,执行年度清仓...")
for stock in list(context.portfolio.positions.keys()):
order_target(stock, 0)
if stock in g.grid_info:
del g.grid_info[stock]
g.monitoring_stocks.add(stock)
# ==================== 盘中 ====================
def handle_data(context, data):
if context.current_dt.month == 1:
return
manage_positions(context, data)
usage = (context.portfolio.total_value - context.portfolio.available_cash) / context.portfolio.total_value
dynamic_max = get_dynamic_max_stocks(context)
if len(g.grid_info) < dynamic_max and usage < context.target_usage:
try_build_new(context, data)
# -------------------- 动态上限 --------------------
def get_dynamic_max_stocks(context):
return context.max_stocks if g.first_round_done else context.first_round_max
# -------------------- 建仓 --------------------
def try_build_new(context, data):
position_pct = context.max_position_pct
dynamic_max = get_dynamic_max_stocks(context)
count = 0
for stock in list(g.monitoring_stocks):
if len(g.grid_info) >= dynamic_max or count >= 3:
break
price = data[stock].close
if context.grid_base_min <= price <= context.grid_base_max:
total_value = context.portfolio.total_value
stock_amount = total_value * position_pct
grid = GridInfo(price, stock_amount, context.max_layers, context.grid_interval, context.profit_target)
layer_amount = grid.get_layer_amount(0)
buy_amount = int(layer_amount / price / 100) * 100
if buy_amount > 0:
order(stock, buy_amount)
grid.add_position(price, buy_amount, 0)
g.grid_info[stock] = grid
g.monitoring_stocks.discard(stock)
count += 1
log.info(f"[建仓] {stock} 价格{price:.2f} 数量{buy_amount}")
# -------------------- 管理持仓 --------------------
def manage_positions(context, data):
for stock, grid in list(g.grid_info.items()):
price = data[stock].close
# 止盈
sellable = grid.get_sellable_positions(price)
if sellable:
for idx, pos in reversed(sellable):
order(stock, -pos['amount'])
grid.remove_position(idx)
profit = (price - pos['price']) * pos['amount']
log.info(f"[止盈] {stock} 盈利{profit:.2f}")
# 加仓
layer = grid.should_add_layer(price)
if layer is not None:
layer_amount = grid.get_layer_amount(layer)
buy_amount = int(layer_amount / price / 100) * 100
if buy_amount > 0:
order(stock, buy_amount)
grid.add_position(price, buy_amount, layer)
log.info(f"[加仓] {stock} 层级{layer} 数量{buy_amount}")
else:
log.info(f"[加仓失败] {stock} 层级{layer} 金额不足")
# 清仓
if len(grid.positions) == 0:
del g.grid_info[stock]
g.monitoring_stocks.add(stock)
log.info(f"[清仓] {stock}")
# ==================== 盘后 ====================
def after_trading_end(context):
log.info(f"持仓数:{len(g.grid_info)},监控数:{len(g.monitoring_stocks)}")
# ==================== 网格类 ====================
class GridInfo:
def __init__(self, base_price, total_amount, max_layers, interval, profit_target):
self.base_price = float(base_price)
self.total_amount = float(total_amount)
self.max_layers = int(max_layers)
self.interval = float(interval)
self.profit_target = float(profit_target)
self.layer_prices = {i: base_price - i * interval for i in range(self.max_layers)}
self.layer_weights = self._calc_weights()
self.positions = []
def _calc_weights(self):
weights = {i: 1.0 + 0.05 * i for i in range(self.max_layers)}
total = sum(list(weights.values()))
return {k: v / total for k, v in weights.items()}
def get_layer_amount(self, layer):
return self.total_amount * self.layer_weights[layer]
def add_position(self, price, amount, layer):
self.positions.append({'price': price, 'amount': amount, 'layer': layer})
def get_sellable_positions(self, current_price):
return [(i, p) for i, p in enumerate(self.positions) if current_price >= p['price'] + self.profit_target]
def remove_position(self, index):
return self.positions.pop(index)
def should_add_layer(self, current_price):
for layer in range(self.max_layers):
target = self.layer_prices[layer]
diff = abs(current_price - target)
# 获取该层级的所有持仓
layer_positions = [p for p in self.positions if p['layer'] == layer]
has_position = len(layer_positions) > 0
if diff <= 0.1 and not has_position:
return layer
return None
+2 -19
View File
@@ -1,7 +1,6 @@
# coding:utf-8 # coding:utf-8
""" """
启动入口 — 默认使用 Flet2 UI 启动入口 — Flet UI
使用 --tk 参数切换到 Tkinter UI。
""" """
import sys import sys
import os import os
@@ -10,7 +9,6 @@ import ssl
import traceback import traceback
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题 # 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题
# 原始 Popen 不带 CREATE_NO_WINDOW 标志,会为每个子进程创建控制台窗口
_original_popen = subprocess.Popen _original_popen = subprocess.Popen
class Popen(_original_popen): class Popen(_original_popen):
@@ -22,14 +20,11 @@ class Popen(_original_popen):
subprocess.Popen = Popen subprocess.Popen = Popen
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端 # PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
# 避免从 GitHub 下载
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# 运行在打包后的 exe 中 base_path = sys._MEIPASS
base_path = sys._MEIPASS # PyInstaller 解压到的临时目录
flet_client_path = os.path.join(base_path, '.flet', 'client', 'flet-desktop-full-0.85.3') flet_client_path = os.path.join(base_path, '.flet', 'client', 'flet-desktop-full-0.85.3')
flet_exe = os.path.join(flet_client_path, 'flet', 'flet.exe') flet_exe = os.path.join(flet_client_path, 'flet', 'flet.exe')
# 写入日志便于调试
log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt') log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt')
with open(log_file, 'w') as f: with open(log_file, 'w') as f:
f.write(f'base_path: {base_path}\n') f.write(f'base_path: {base_path}\n')
@@ -41,12 +36,10 @@ if getattr(sys, 'frozen', False):
os.environ['FLET_VIEW_PATH'] = flet_client_path os.environ['FLET_VIEW_PATH'] = flet_client_path
f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n') f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n')
# 禁用 SSL 验证,避免证书问题
if hasattr(ssl, '_create_unverified_context'): if hasattr(ssl, '_create_unverified_context'):
ssl._create_default_https_context = ssl._create_unverified_context ssl._create_default_https_context = ssl._create_unverified_context
def excepthook(type, value, tb): def excepthook(type, value, tb):
"""捕获未处理的异常,写入日志"""
log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt') log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt')
with open(log_file, 'w') as f: with open(log_file, 'w') as f:
f.write(''.join(traceback.format_exception(type, value, tb))) f.write(''.join(traceback.format_exception(type, value, tb)))
@@ -55,15 +48,5 @@ def excepthook(type, value, tb):
sys.excepthook = excepthook sys.excepthook = excepthook
if __name__ == '__main__': if __name__ == '__main__':
if '--tk' in sys.argv:
from core.ui.tkinter.splash import SplashWindow
from tkinter import messagebox
try:
window = SplashWindow().run()
if window:
window.run()
except Exception as e:
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
else:
from core.ui.flet.app_v2 import run from core.ui.flet.app_v2 import run
run() run()