Add standalone take-profit edit beside stop-loss on open positions.

Keep existing SL when changing TP, and verify both fields after broker modify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-22 18:58:20 +08:00
parent ca117f7dd6
commit c16dc2e061
2 changed files with 155 additions and 25 deletions
+155 -25
View File
@@ -756,8 +756,10 @@ class Mt5Bridge:
}
return int(retcode) in ok
def modify_position_sltp(self, ticket: int, sl: float, tp: Optional[float] = None) -> float:
"""修改持仓止损/止盈;成功返回实际止损价,失败抛 TradeError。"""
def modify_position_sltp(
self, ticket: int, sl: float, tp: Optional[float] = None
) -> Tuple[float, float]:
"""修改持仓止损/止盈;成功返回 (实际止损, 实际止盈),失败抛 TradeError。"""
p = self.get_position(int(ticket))
symbol = str(p.symbol)
self.ensure_symbol(symbol)
@@ -771,15 +773,19 @@ class Mt5Bridge:
raise TradeError("账户不允许交易")
digits = int(self.symbol_info(symbol).digits)
tol = max(10 ** (-digits), 0.01)
sl = round(float(sl), digits)
old_sl = float(p.sl)
old_tp = float(p.tp)
if tp is None:
tp_val = float(p.tp)
tp_val = old_tp
else:
tp_val = float(tp)
tp_val = round(tp_val, digits) if tp_val > 1e-12 else 0.0
old_sl = float(p.sl)
# 多组请求:部分券商带 magic / 不带 magic 表现不同;TP=0 与保留原 TP 也分开试
# 目标止盈与当前相同 → 视为「只改止损」,可尝试 TP=0 兜底(部分券商)
keep_tp = abs(tp_val - old_tp) <= tol or (tp_val <= 1e-12 and old_tp <= 1e-12)
variants: list[dict] = []
base = {
"action": mt5.TRADE_ACTION_SLTP,
@@ -789,9 +795,8 @@ class Mt5Bridge:
}
variants.append({**base, "tp": tp_val})
variants.append({**base, "tp": tp_val, "magic": MAGIC})
if tp_val > 1e-12:
variants.append({**base, "tp": 0.0}) # 仅改 SL
# 去重
if keep_tp and tp_val > 1e-12:
variants.append({**base, "tp": 0.0}) # 仅改 SL 时的兜底,勿用于改 TP
seen = set()
uniq: list[dict] = []
for req in variants:
@@ -801,6 +806,11 @@ class Mt5Bridge:
seen.add(key)
uniq.append(req)
def _match(a: float, b: float) -> bool:
if b <= 1e-12:
return a <= 1e-12
return abs(a - b) <= tol
last_detail = ""
for request in uniq:
check = mt5.order_check(request)
@@ -809,7 +819,6 @@ class Mt5Bridge:
mt5.TRADE_RETCODE_DONE,
):
last_detail = f"校验失败 {check.comment} ({check.retcode})"
# 校验失败仍尝试 send,部分终端 check 偏严
result = mt5.order_send(request)
if result is None:
last_detail = str(mt5.last_error())
@@ -817,20 +826,28 @@ class Mt5Bridge:
if not self._sltp_retcode_ok(result.retcode):
last_detail = f"{result.comment} ({result.retcode})"
continue
# 回读确认(券商可能延迟)
for _ in range(8):
time.sleep(0.2)
try:
p2 = self.get_position(int(ticket))
except TradeError:
continue
if abs(float(p2.sl) - sl) <= max(10 ** (-digits), 0.01):
return float(p2.sl)
cur_sl = float(p2.sl)
cur_tp = float(p2.tp)
# 兜底请求把 TP 清零时,只核验止损
req_tp = float(request.get("tp") or 0)
if keep_tp and req_tp <= 1e-12:
if _match(cur_sl, sl):
return cur_sl, cur_tp
elif _match(cur_sl, sl) and _match(cur_tp, tp_val):
return cur_sl, cur_tp
p3 = self.get_position(int(ticket))
last_detail = (
f"回报成功但止损未变(原 {old_sl:.{digits}f} → 目标 {sl:.{digits}f},"
f"当前仍为 {float(self.get_position(int(ticket)).sl):.{digits}f})"
f"回报成功但未生效(目标 SL {sl:.{digits}f} / TP {tp_val:.{digits}f},"
f"当前 SL {float(p3.sl):.{digits}f} / TP {float(p3.tp):.{digits}f};"
f"原 SL {old_sl:.{digits}f} / TP {old_tp:.{digits}f})"
)
raise TradeError(f"#{ticket} {last_detail or '改止损失败'}")
raise TradeError(f"#{ticket} {last_detail or '改止损/止盈失败'}")
def set_stop_loss(self, ticket: int, new_sl: float) -> Tuple[str, dict]:
"""手动改止损价(与保本无关);保留原止盈。返回 (消息, 上报用 dict)。"""
@@ -841,6 +858,7 @@ class Mt5Bridge:
min_dist = self.min_stop_distance(symbol)
t = self.tick(symbol)
old_sl = float(p.sl)
old_tp = float(p.tp)
new_sl = round(float(new_sl), digits)
if new_sl <= 1e-12:
raise TradeError("止损价必须大于 0")
@@ -866,10 +884,10 @@ class Mt5Bridge:
f"止损过近:距现价 {new_sl - ref:.{digits}f},至少 {min_dist:.{digits}f}"
)
applied = self.modify_position_sltp(int(ticket), new_sl, float(p.tp))
applied_sl, applied_tp = self.modify_position_sltp(int(ticket), new_sl, old_tp)
msg = (
f"#{ticket} 止损已改 "
f"{(f'{old_sl:.{digits}f}' if old_sl > 0 else '无')}→{applied:.{digits}f}"
f"{(f'{old_sl:.{digits}f}' if old_sl > 0 else '无')}→{applied_sl:.{digits}f}"
)
report = {
"ticket": str(ticket),
@@ -881,12 +899,68 @@ class Mt5Bridge:
"open_time": float(p.time),
"close_time": None,
"profit": float(p.profit) + float(p.swap),
"sl": float(applied),
"tp": float(p.tp),
"sl": float(applied_sl),
"tp": float(applied_tp),
"snapshot": {"event": "modify_sl"},
}
return msg, report
def set_take_profit(self, ticket: int, new_tp: 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)
old_tp = float(p.tp)
new_tp = round(float(new_tp), digits)
if new_tp <= 1e-12:
raise TradeError("止盈价必须大于 0")
if pos_type == mt5.POSITION_TYPE_BUY:
ref = float(t.bid)
if new_tp <= ref + 1e-12:
raise TradeError(
f"多单止盈须高于现价:现价 {ref:.{digits}f},止盈 {new_tp:.{digits}f}"
)
if new_tp - ref + 1e-12 < min_dist:
raise TradeError(
f"止盈过近:距现价 {new_tp - ref:.{digits}f},至少 {min_dist:.{digits}f}"
)
else:
ref = float(t.ask)
if new_tp >= ref - 1e-12:
raise TradeError(
f"空单止盈须低于现价:现价 {ref:.{digits}f},止盈 {new_tp:.{digits}f}"
)
if ref - new_tp + 1e-12 < min_dist:
raise TradeError(
f"止盈过近:距现价 {ref - new_tp:.{digits}f},至少 {min_dist:.{digits}f}"
)
applied_sl, applied_tp = self.modify_position_sltp(int(ticket), old_sl, new_tp)
msg = (
f"#{ticket} 止盈已改 "
f"{(f'{old_tp:.{digits}f}' if old_tp > 0 else '无')}→{applied_tp:.{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_sl),
"tp": float(applied_tp),
"snapshot": {"event": "modify_tp"},
}
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:
@@ -930,7 +1004,7 @@ class Mt5Bridge:
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
)
new_sl = self.modify_position_sltp(int(ticket), sl, float(p.tp))
new_sl, _new_tp = self.modify_position_sltp(int(ticket), sl, float(p.tp))
ok_tickets.append(int(ticket))
details.append(
f"#{ticket} SL {old_sl:.{digits}f}→{new_sl:.{digits}f}"
@@ -1310,7 +1384,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,
@@ -1559,6 +1633,16 @@ class OrderApp(tk.Tk):
padx=6,
)
def _mk_edit_tp_btn(self, parent, ticket: int) -> tk.Frame:
"""手动改止盈(保留原止损)。"""
return self._mk_outline_btn(
parent,
"改止盈",
COLOR_ACCENT,
lambda t=ticket: self._edit_tp_now(t),
padx=6,
)
@staticmethod
def _pos_profit(p) -> float:
return float(p.profit) + float(p.swap)
@@ -2185,6 +2269,7 @@ 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_edit_sl_btn(btn_wrap, ticket).pack(side=tk.LEFT, padx=(0, 4))
self._mk_edit_tp_btn(btn_wrap, ticket).pack(side=tk.LEFT, padx=(0, 4))
self._mk_close_btn(btn_wrap, ticket).pack(side=tk.LEFT)
row.bind("<Button-1>", lambda e, t=ticket: self._toggle_ticket_select(t))
@@ -2242,10 +2327,52 @@ class OrderApp(tk.Tk):
self.set_status(f"改止损异常:{exc}", ok=False)
return
def work():
self.bridge.connect()
return self.bridge.set_stop_loss(int(ticket), float(new_sl))
self._run_modify_sltp(
ticket,
lambda: self.bridge.set_stop_loss(int(ticket), float(new_sl)),
err_prefix="改止损",
)
def _edit_tp_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_tp = float(p.tp)
side = "多" if int(p.type) == mt5.POSITION_TYPE_BUY else "空"
cur_txt = f"{cur_tp:.{digits}f}" if cur_tp > 0 else "无"
prompt = (
f"单号 #{ticket}({side})\n"
f"开仓价 {open_price:.{digits}f}\n"
f"当前止盈 {cur_txt}\n\n"
f"请输入新的止盈价(保留原止损,按实际价格设置):"
)
initial = cur_tp if cur_tp > 0 else open_price
new_tp = simpledialog.askfloat(
"改止盈",
prompt,
initialvalue=round(initial, digits),
minvalue=0.01,
parent=self,
)
if new_tp 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
self._run_modify_sltp(
ticket,
lambda: self.bridge.set_take_profit(int(ticket), float(new_tp)),
err_prefix="改止盈",
)
def _run_modify_sltp(self, ticket: int, work, err_prefix: str) -> None:
def after_ok(msg: str) -> None:
self.set_status(msg, ok=True)
self._pos_sig = None
@@ -2258,13 +2385,16 @@ class OrderApp(tk.Tk):
def runner():
try:
self.bridge.connect()
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))
self.after(
0, lambda: self.set_status(f"{err_prefix}异常:{exc}", ok=False)
)
finally:
self.after(0, self._done)
Binary file not shown.