diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index af3da3b..624f681 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -831,6 +831,61 @@ class Mt5Bridge: ) raise TradeError(f"#{ticket} {last_detail or '改止损失败'}") + def set_stop_loss(self, ticket: int, new_sl: float) -> Tuple[str, dict]: + """手动改止损价(与保本无关);保留原止盈。返回 (消息, 上报用 dict)。""" + p = self.get_position(int(ticket)) + symbol = str(p.symbol) + pos_type = int(p.type) + digits = int(self.symbol_info(symbol).digits) + min_dist = self.min_stop_distance(symbol) + t = self.tick(symbol) + old_sl = float(p.sl) + new_sl = round(float(new_sl), digits) + if new_sl <= 1e-12: + raise TradeError("止损价必须大于 0") + + if pos_type == mt5.POSITION_TYPE_BUY: + ref = float(t.bid) + if new_sl >= ref - 1e-12: + raise TradeError( + f"多单止损须低于现价:现价 {ref:.{digits}f},止损 {new_sl:.{digits}f}" + ) + if ref - new_sl + 1e-12 < min_dist: + raise TradeError( + f"止损过近:距现价 {ref - new_sl:.{digits}f},至少 {min_dist:.{digits}f}" + ) + else: + ref = float(t.ask) + if new_sl <= ref + 1e-12: + raise TradeError( + f"空单止损须高于现价:现价 {ref:.{digits}f},止损 {new_sl:.{digits}f}" + ) + if new_sl - ref + 1e-12 < min_dist: + raise TradeError( + f"止损过近:距现价 {new_sl - ref:.{digits}f},至少 {min_dist:.{digits}f}" + ) + + applied = self.modify_position_sltp(int(ticket), new_sl, float(p.tp)) + msg = ( + f"#{ticket} 止损已改 " + f"{(f'{old_sl:.{digits}f}' if old_sl > 0 else '无')}→{applied:.{digits}f}" + ) + report = { + "ticket": str(ticket), + "symbol": symbol, + "side": "buy" if pos_type == mt5.POSITION_TYPE_BUY else "sell", + "volume": float(p.volume), + "open_price": float(p.price_open), + "close_price": None, + "open_time": float(p.time), + "close_time": None, + "profit": float(p.profit) + float(p.swap), + "sl": float(applied), + "tp": float(p.tp), + "snapshot": {"event": "modify_sl"}, + } + return msg, report + def break_even_tickets(self, tickets: list, offset_usd: float = 1.0) -> Tuple[str, list]: """保本:多单 SL=开仓价+offset,空单 SL=开仓价-offset。返回 (消息, 成功单号列表)。""" if not tickets: @@ -1204,7 +1259,7 @@ class OrderApp(tk.Tk): ("止损", 12), ("止盈", 12), ("浮盈", 9), - ("操作", 10), + ("操作", 16), ] # 持仓区固定为 5 行可视高度,超出可滚动 pos_wrap = tk.Frame( @@ -1253,7 +1308,7 @@ class OrderApp(tk.Tk): text=( f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · " f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · " - "亏≥5%或盈≥10%禁止开仓 · 保本:价格有利≥3$ 后止损到开仓价±1$(看价格距离,非账户浮盈)" + "亏≥5%或盈≥10%禁止开仓 · 持仓「改止损」可手动设价 · 保本:有利≥3$ 后止损到开仓价±1$" ), bg=COLOR_BG, fg=COLOR_MUTED, @@ -1464,27 +1519,44 @@ class OrderApp(tk.Tk): except Exception: pass - def _mk_close_btn(self, parent, ticket: int) -> tk.Frame: - """红色边框的平仓按钮。""" - border = tk.Frame(parent, bg=COLOR_DANGER, padx=1, pady=1) + def _mk_outline_btn( + self, parent, text: str, color: str, command, padx: int = 8 + ) -> tk.Frame: + border = tk.Frame(parent, bg=color, padx=1, pady=1) btn = tk.Button( border, - text="平仓", - command=lambda t=ticket: self._close_ticket_now(t), - fg=COLOR_DANGER, + text=text, + command=command, + fg=color, bg="#2b2b2b", activeforeground="#ffffff", - activebackground="#a02828", + activebackground=color, relief=tk.FLAT, bd=0, font=("Microsoft YaHei UI", 10, "bold"), - padx=10, + padx=padx, pady=2, cursor="hand2", ) btn.pack() return border + def _mk_close_btn(self, parent, ticket: int) -> tk.Frame: + """红色边框的平仓按钮。""" + return self._mk_outline_btn( + parent, "平仓", COLOR_DANGER, lambda t=ticket: self._close_ticket_now(t) + ) + + def _mk_edit_sl_btn(self, parent, ticket: int) -> tk.Frame: + """手动改止损(与保本独立)。""" + return self._mk_outline_btn( + parent, + "改止损", + COLOR_ACCENT, + lambda t=ticket: self._edit_sl_now(t), + padx=6, + ) + @staticmethod def _pos_profit(p) -> float: return float(p.profit) + float(p.swap) @@ -2106,7 +2178,8 @@ class OrderApp(tk.Tk): btn_wrap = tk.Frame(row, bg=bg) btn_wrap.grid(row=0, column=len(cells), sticky="nsew", padx=4, pady=3) - self._mk_close_btn(btn_wrap, ticket).pack(anchor="center") + self._mk_edit_sl_btn(btn_wrap, ticket).pack(side=tk.LEFT, padx=(0, 4)) + self._mk_close_btn(btn_wrap, ticket).pack(side=tk.LEFT) row.bind("", lambda e, t=ticket: self._toggle_ticket_select(t)) if not live: @@ -2130,6 +2203,71 @@ class OrderApp(tk.Tk): self._run_async(work) + def _edit_sl_now(self, ticket: int) -> None: + """弹窗输入实际止损价,与保本逻辑无关。""" + try: + self.bridge.connect() + p = self.bridge.get_position(int(ticket)) + digits = int(self.bridge.symbol_info(str(p.symbol)).digits) + open_price = float(p.price_open) + cur_sl = float(p.sl) + side = "多" if int(p.type) == mt5.POSITION_TYPE_BUY else "空" + cur_txt = f"{cur_sl:.{digits}f}" if cur_sl > 0 else "无" + prompt = ( + f"单号 #{ticket}({side})\n" + f"开仓价 {open_price:.{digits}f}\n" + f"当前止损 {cur_txt}\n\n" + f"请输入新的止损价(与保本无关,按实际价格设置):" + ) + initial = cur_sl if cur_sl > 0 else open_price + new_sl = simpledialog.askfloat( + "改止损", + prompt, + initialvalue=round(initial, digits), + minvalue=0.01, + parent=self, + ) + if new_sl is None: + return + except TradeError as exc: + self.set_status(str(exc), ok=False) + return + except Exception as exc: # noqa: BLE001 + self.set_status(f"改止损异常:{exc}", ok=False) + return + + def work(): + self.bridge.connect() + return self.bridge.set_stop_loss(int(ticket), float(new_sl)) + + def after_ok(msg: str) -> None: + self.set_status(msg, ok=True) + self._pos_sig = None + try: + symbol = self._symbol() + d = int(self.bridge.symbol_info(symbol).digits) + self._refresh_positions(symbol, d) + except Exception: + pass + + def runner(): + try: + msg, report = work() + self.after(0, lambda m=msg: after_ok(m)) + self.after(0, lambda r=report: self._hub_report(r, with_bars=False)) + except TradeError as exc: + self.after(0, lambda: self.set_status(str(exc), ok=False)) + except Exception as exc: # noqa: BLE001 + self.after(0, lambda: self.set_status(f"改止损异常:{exc}", ok=False)) + finally: + self.after(0, self._done) + + if self._busy: + self.set_status("上一次操作还未完成", ok=False) + return + self._busy = True + threading.Thread(target=runner, daemon=True).start() + def on_buy(self) -> None: self._order(mt5.ORDER_TYPE_BUY, "买入") diff --git a/releases/黄金市价辅助.exe b/releases/黄金市价辅助.exe index 0ce32ab..5bb078f 100644 Binary files a/releases/黄金市价辅助.exe and b/releases/黄金市价辅助.exe differ