Harden break-even SLTP modify with check/retry/verify and UI refresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+140
-65
@@ -73,9 +73,8 @@ COLOR_CARD2 = "#22222e"
|
||||
COLOR_MUTED = "#8a8a9c"
|
||||
COLOR_TEXT = "#f0f0f5"
|
||||
COLOR_ACCENT = "#4cc2ff"
|
||||
BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本(非账户浮盈)
|
||||
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
|
||||
# 有利方向至少走出 offset + 缓冲,保证新止损相对现价合法(不再用固定 3$ 门槛)
|
||||
BE_EXTRA_BUFFER_USD = 0.20
|
||||
SPREAD_WARN_USD = 0.50 # 点差超过该美金时,买卖按钮区提示(不禁开)
|
||||
|
||||
|
||||
@@ -652,84 +651,146 @@ class Mt5Bridge:
|
||||
}
|
||||
return f"已平 #{ticket}({volume:.2f} 手)", report
|
||||
|
||||
def _sltp_retcode_ok(self, retcode: int) -> bool:
|
||||
ok = {
|
||||
mt5.TRADE_RETCODE_DONE,
|
||||
getattr(mt5, "TRADE_RETCODE_DONE_PARTIAL", -1),
|
||||
getattr(mt5, "TRADE_RETCODE_PLACED", -1),
|
||||
}
|
||||
return int(retcode) in ok
|
||||
|
||||
def modify_position_sltp(self, ticket: int, sl: float, tp: Optional[float] = None) -> float:
|
||||
"""修改持仓止损/止盈;成功返回实际止损价,失败抛 TradeError。"""
|
||||
p = self.get_position(int(ticket))
|
||||
symbol = str(p.symbol)
|
||||
self.ensure_symbol(symbol)
|
||||
term = mt5.terminal_info()
|
||||
if term is None or not term.connected:
|
||||
raise TradeError("终端未连接到交易服务器")
|
||||
if not getattr(term, "trade_allowed", True):
|
||||
raise TradeError("终端未开启自动交易(请勾选 Algo Trading)")
|
||||
acc = mt5.account_info()
|
||||
if acc is None or not getattr(acc, "trade_allowed", True):
|
||||
raise TradeError("账户不允许交易")
|
||||
|
||||
digits = int(self.symbol_info(symbol).digits)
|
||||
sl = round(float(sl), digits)
|
||||
if tp is None:
|
||||
tp_val = float(p.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 也分开试
|
||||
variants: list[dict] = []
|
||||
base = {
|
||||
"action": mt5.TRADE_ACTION_SLTP,
|
||||
"symbol": symbol,
|
||||
"position": int(ticket),
|
||||
"sl": sl,
|
||||
}
|
||||
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
|
||||
# 去重
|
||||
seen = set()
|
||||
uniq: list[dict] = []
|
||||
for req in variants:
|
||||
key = (req.get("sl"), req.get("tp"), req.get("magic"))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(req)
|
||||
|
||||
last_detail = ""
|
||||
for request in uniq:
|
||||
check = mt5.order_check(request)
|
||||
if check is not None and int(getattr(check, "retcode", 0)) not in (
|
||||
0,
|
||||
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())
|
||||
continue
|
||||
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)
|
||||
last_detail = (
|
||||
f"回报成功但止损未变(原 {old_sl:.{digits}f} → 目标 {sl:.{digits}f},"
|
||||
f"当前仍为 {float(self.get_position(int(ticket)).sl):.{digits}f})"
|
||||
)
|
||||
raise TradeError(f"#{ticket} {last_detail or '改止损失败'}")
|
||||
|
||||
def break_even_tickets(self, tickets: list, offset_usd: float = 1.0) -> Tuple[str, list]:
|
||||
"""保本:多单 SL=开仓价+offset,空单 SL=开仓价-offset。返回 (消息, 成功单号列表)。"""
|
||||
if not tickets:
|
||||
raise TradeError("没有可保本的持仓")
|
||||
ok_tickets: list = []
|
||||
errors: list = []
|
||||
done_ok = {
|
||||
mt5.TRADE_RETCODE_DONE,
|
||||
getattr(mt5, "TRADE_RETCODE_DONE_PARTIAL", -1),
|
||||
getattr(mt5, "TRADE_RETCODE_PLACED", -1),
|
||||
}
|
||||
details: list = []
|
||||
for ticket in tickets:
|
||||
try:
|
||||
p = self.get_position(int(ticket))
|
||||
symbol = str(p.symbol)
|
||||
term = mt5.terminal_info()
|
||||
if term is None or not term.connected:
|
||||
raise TradeError("终端未连接到交易服务器")
|
||||
if not term.trade_allowed:
|
||||
raise TradeError("终端未开启自动交易(请勾选 Algo Trading)")
|
||||
acc = mt5.account_info()
|
||||
if acc is None or not acc.trade_allowed:
|
||||
raise TradeError("账户不允许交易")
|
||||
pos_type = int(p.type)
|
||||
open_price = float(p.price_open)
|
||||
tp = float(p.tp) if float(p.tp) > 0 else 0.0
|
||||
digits = int(self.symbol_info(symbol).digits)
|
||||
min_dist = self.min_stop_distance(symbol)
|
||||
t = self.tick(symbol)
|
||||
old_sl = float(p.sl)
|
||||
|
||||
if pos_type == mt5.POSITION_TYPE_BUY:
|
||||
sl = round(open_price + offset_usd, digits)
|
||||
ref = float(t.bid)
|
||||
need = sl + max(min_dist, 0.0)
|
||||
if ref + 1e-12 < need:
|
||||
move = ref - open_price
|
||||
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||||
raise TradeError(
|
||||
f"#{ticket} 多单现价 {ref:.{digits}f},需高于 {need:.{digits}f} "
|
||||
f"才能挂保本止损 {sl:.{digits}f}"
|
||||
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||
)
|
||||
if sl + max(min_dist, 0.0) > ref + 1e-12:
|
||||
raise TradeError(
|
||||
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||||
)
|
||||
else:
|
||||
sl = round(open_price - offset_usd, digits)
|
||||
ref = float(t.ask)
|
||||
need = sl - max(min_dist, 0.0)
|
||||
if ref - 1e-12 > need:
|
||||
move = open_price - ref
|
||||
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||||
raise TradeError(
|
||||
f"#{ticket} 空单现价 {ref:.{digits}f},需低于 {need:.{digits}f} "
|
||||
f"才能挂保本止损 {sl:.{digits}f}"
|
||||
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||
)
|
||||
if ref > sl - max(min_dist, 0.0) + 1e-12:
|
||||
raise TradeError(
|
||||
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||||
)
|
||||
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_SLTP,
|
||||
"symbol": symbol,
|
||||
"position": int(ticket),
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"magic": MAGIC,
|
||||
}
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
raise TradeError(f"#{ticket} {mt5.last_error()}")
|
||||
if int(result.retcode) not in done_ok:
|
||||
raise TradeError(f"#{ticket} {result.comment} ({result.retcode})")
|
||||
# 回读确认止损已改(部分券商异步)
|
||||
time.sleep(0.15)
|
||||
p2 = self.get_position(int(ticket))
|
||||
if abs(float(p2.sl) - sl) > 10 ** (-digits):
|
||||
raise TradeError(
|
||||
f"#{ticket} 服务器未接受保本止损(当前 SL={float(p2.sl):.{digits}f},目标 {sl:.{digits}f})"
|
||||
)
|
||||
new_sl = 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}"
|
||||
)
|
||||
except TradeError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if not ok_tickets:
|
||||
raise TradeError("保本失败:" + (";".join(errors[:3]) if errors else "无可用持仓"))
|
||||
msg = f"已保本 {len(ok_tickets)} 笔(开仓价 ± {offset_usd:.0f} 美金)"
|
||||
msg = f"已保本 {len(ok_tickets)} 笔:" + ",".join(details)
|
||||
if errors:
|
||||
msg += f";跳过 {len(errors)}:{errors[0]}"
|
||||
msg += f";失败 {len(errors)}:{errors[0]}"
|
||||
return msg, ok_tickets
|
||||
|
||||
def close_positions(self, symbol: str, pos_type: Optional[int] = None):
|
||||
@@ -1088,7 +1149,7 @@ class OrderApp(tk.Tk):
|
||||
text=(
|
||||
f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · "
|
||||
f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · "
|
||||
"亏≥5%或盈≥10%禁止开仓 · 保本:有利方向足够后止损到开仓价±1$(看价格,非账户浮盈)"
|
||||
"亏≥5%或盈≥10%禁止开仓 · 保本:价格有利≥3$ 后止损到开仓价±1$(看价格距离,非账户浮盈)"
|
||||
),
|
||||
bg=COLOR_BG,
|
||||
fg=COLOR_MUTED,
|
||||
@@ -1337,26 +1398,16 @@ class OrderApp(tk.Tk):
|
||||
return float(tick.bid) - open_price
|
||||
return open_price - float(tick.ask)
|
||||
|
||||
def _be_needed_move_usd(self, symbol: str) -> float:
|
||||
"""保本所需的最小有利价格距离(美金)。"""
|
||||
try:
|
||||
min_dist = self.bridge.min_stop_distance(symbol)
|
||||
except Exception:
|
||||
min_dist = 0.0
|
||||
return BE_OFFSET_USD + max(float(min_dist), 0.0) + BE_EXTRA_BUFFER_USD
|
||||
|
||||
def _be_candidate_tickets(self, positions, tick=None) -> list:
|
||||
"""现价已能挂开仓价±1$ 止损、且尚未保本的单号。"""
|
||||
"""价格朝有利方向≥3$ 且尚未保本的单号。"""
|
||||
tickets = []
|
||||
if not positions:
|
||||
return tickets
|
||||
symbol = str(positions[0].symbol)
|
||||
if tick is None:
|
||||
try:
|
||||
tick = self.bridge.tick(symbol)
|
||||
tick = self.bridge.tick(str(positions[0].symbol))
|
||||
except Exception:
|
||||
return tickets
|
||||
need = self._be_needed_move_usd(symbol)
|
||||
for p in positions:
|
||||
ticket = int(p.ticket)
|
||||
if ticket in self._be_done_tickets:
|
||||
@@ -1364,7 +1415,7 @@ class OrderApp(tk.Tk):
|
||||
if self._is_sl_at_breakeven(p):
|
||||
self._be_done_tickets.add(ticket)
|
||||
continue
|
||||
if self._favorable_move_usd(p, tick) + 1e-12 >= need:
|
||||
if self._favorable_move_usd(p, tick) + 1e-12 >= BE_MIN_MOVE_USD:
|
||||
tickets.append(ticket)
|
||||
return tickets
|
||||
|
||||
@@ -1378,7 +1429,6 @@ class OrderApp(tk.Tk):
|
||||
tick = self.bridge.tick(symbol)
|
||||
except Exception as exc:
|
||||
return f"无法读报价:{exc}"
|
||||
need = self._be_needed_move_usd(symbol)
|
||||
best = -1e9
|
||||
for p in positions:
|
||||
ticket = int(p.ticket)
|
||||
@@ -1387,11 +1437,11 @@ class OrderApp(tk.Tk):
|
||||
best = max(best, self._favorable_move_usd(p, tick))
|
||||
if best < -1e8:
|
||||
return "持仓已保本或无需再拉"
|
||||
lack = need - best
|
||||
lack = BE_MIN_MOVE_USD - best
|
||||
if lack > 1e-8:
|
||||
return (
|
||||
f"有利方向仅 {best:.2f}$,保本需 ≥ {need:.2f}$"
|
||||
f"(开仓价±{BE_OFFSET_USD:.0f}$),还差 {lack:.2f}$"
|
||||
f"有利方向仅 {best:.2f}$,保本需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||
f"(之后止损拉到开仓价±{BE_OFFSET_USD:.0f}$),还差 {lack:.2f}$"
|
||||
)
|
||||
return "可保本"
|
||||
|
||||
@@ -1920,7 +1970,32 @@ class OrderApp(tk.Tk):
|
||||
self._be_done_tickets.update(done)
|
||||
return msg
|
||||
|
||||
self._run_async(work)
|
||||
def after_ok(msg: str) -> None:
|
||||
self.set_status(msg, ok=True)
|
||||
# 强制重绘持仓行,避免止损列仍显示旧值
|
||||
self._pos_sig = None
|
||||
try:
|
||||
digits = int(self.bridge.symbol_info(symbol).digits)
|
||||
except Exception:
|
||||
digits = 3
|
||||
self._refresh_positions(symbol, digits)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
msg = work()
|
||||
self.after(0, lambda m=msg: after_ok(m))
|
||||
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 _hub_heartbeat(self) -> None:
|
||||
"""约每 2 秒 ping 中控,供总览秒级在线状态。"""
|
||||
|
||||
Reference in New Issue
Block a user