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_MUTED = "#8a8a9c"
|
||||||
COLOR_TEXT = "#f0f0f5"
|
COLOR_TEXT = "#f0f0f5"
|
||||||
COLOR_ACCENT = "#4cc2ff"
|
COLOR_ACCENT = "#4cc2ff"
|
||||||
|
BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本(非账户浮盈)
|
||||||
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
|
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$
|
||||||
# 有利方向至少走出 offset + 缓冲,保证新止损相对现价合法(不再用固定 3$ 门槛)
|
|
||||||
BE_EXTRA_BUFFER_USD = 0.20
|
|
||||||
SPREAD_WARN_USD = 0.50 # 点差超过该美金时,买卖按钮区提示(不禁开)
|
SPREAD_WARN_USD = 0.50 # 点差超过该美金时,买卖按钮区提示(不禁开)
|
||||||
|
|
||||||
|
|
||||||
@@ -652,84 +651,146 @@ class Mt5Bridge:
|
|||||||
}
|
}
|
||||||
return f"已平 #{ticket}({volume:.2f} 手)", report
|
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]:
|
def break_even_tickets(self, tickets: list, offset_usd: float = 1.0) -> Tuple[str, list]:
|
||||||
"""保本:多单 SL=开仓价+offset,空单 SL=开仓价-offset。返回 (消息, 成功单号列表)。"""
|
"""保本:多单 SL=开仓价+offset,空单 SL=开仓价-offset。返回 (消息, 成功单号列表)。"""
|
||||||
if not tickets:
|
if not tickets:
|
||||||
raise TradeError("没有可保本的持仓")
|
raise TradeError("没有可保本的持仓")
|
||||||
ok_tickets: list = []
|
ok_tickets: list = []
|
||||||
errors: list = []
|
errors: list = []
|
||||||
done_ok = {
|
details: list = []
|
||||||
mt5.TRADE_RETCODE_DONE,
|
|
||||||
getattr(mt5, "TRADE_RETCODE_DONE_PARTIAL", -1),
|
|
||||||
getattr(mt5, "TRADE_RETCODE_PLACED", -1),
|
|
||||||
}
|
|
||||||
for ticket in tickets:
|
for ticket in tickets:
|
||||||
try:
|
try:
|
||||||
p = self.get_position(int(ticket))
|
p = self.get_position(int(ticket))
|
||||||
symbol = str(p.symbol)
|
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)
|
pos_type = int(p.type)
|
||||||
open_price = float(p.price_open)
|
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)
|
digits = int(self.symbol_info(symbol).digits)
|
||||||
min_dist = self.min_stop_distance(symbol)
|
min_dist = self.min_stop_distance(symbol)
|
||||||
t = self.tick(symbol)
|
t = self.tick(symbol)
|
||||||
|
old_sl = float(p.sl)
|
||||||
|
|
||||||
if pos_type == mt5.POSITION_TYPE_BUY:
|
if pos_type == mt5.POSITION_TYPE_BUY:
|
||||||
sl = round(open_price + offset_usd, digits)
|
sl = round(open_price + offset_usd, digits)
|
||||||
ref = float(t.bid)
|
ref = float(t.bid)
|
||||||
need = sl + max(min_dist, 0.0)
|
move = ref - open_price
|
||||||
if ref + 1e-12 < need:
|
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||||||
raise TradeError(
|
raise TradeError(
|
||||||
f"#{ticket} 多单现价 {ref:.{digits}f},需高于 {need:.{digits}f} "
|
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||||
f"才能挂保本止损 {sl:.{digits}f}"
|
)
|
||||||
|
if sl + max(min_dist, 0.0) > ref + 1e-12:
|
||||||
|
raise TradeError(
|
||||||
|
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sl = round(open_price - offset_usd, digits)
|
sl = round(open_price - offset_usd, digits)
|
||||||
ref = float(t.ask)
|
ref = float(t.ask)
|
||||||
need = sl - max(min_dist, 0.0)
|
move = open_price - ref
|
||||||
if ref - 1e-12 > need:
|
if move + 1e-12 < BE_MIN_MOVE_USD:
|
||||||
raise TradeError(
|
raise TradeError(
|
||||||
f"#{ticket} 空单现价 {ref:.{digits}f},需低于 {need:.{digits}f} "
|
f"#{ticket} 有利方向仅 {move:.2f}$,需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||||
f"才能挂保本止损 {sl:.{digits}f}"
|
)
|
||||||
|
if ref > sl - max(min_dist, 0.0) + 1e-12:
|
||||||
|
raise TradeError(
|
||||||
|
f"#{ticket} 现价 {ref:.{digits}f} 距保本价 {sl:.{digits}f} 过近"
|
||||||
)
|
)
|
||||||
|
|
||||||
request = {
|
new_sl = self.modify_position_sltp(int(ticket), sl, float(p.tp))
|
||||||
"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})"
|
|
||||||
)
|
|
||||||
ok_tickets.append(int(ticket))
|
ok_tickets.append(int(ticket))
|
||||||
|
details.append(
|
||||||
|
f"#{ticket} SL {old_sl:.{digits}f}→{new_sl:.{digits}f}"
|
||||||
|
)
|
||||||
except TradeError as exc:
|
except TradeError as exc:
|
||||||
errors.append(str(exc))
|
errors.append(str(exc))
|
||||||
|
|
||||||
if not ok_tickets:
|
if not ok_tickets:
|
||||||
raise TradeError("保本失败:" + (";".join(errors[:3]) if errors else "无可用持仓"))
|
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:
|
if errors:
|
||||||
msg += f";跳过 {len(errors)}:{errors[0]}"
|
msg += f";失败 {len(errors)}:{errors[0]}"
|
||||||
return msg, ok_tickets
|
return msg, ok_tickets
|
||||||
|
|
||||||
def close_positions(self, symbol: str, pos_type: Optional[int] = None):
|
def close_positions(self, symbol: str, pos_type: Optional[int] = None):
|
||||||
@@ -1088,7 +1149,7 @@ class OrderApp(tk.Tk):
|
|||||||
text=(
|
text=(
|
||||||
f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · "
|
f"最多同时 {MAX_OPEN_POSITIONS} 笔 · 同向≤{MAX_SIDE_LOTS:.2f} 手 · "
|
||||||
f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · "
|
f"点差>{SPREAD_WARN_USD:.2f} 买卖提示 · "
|
||||||
"亏≥5%或盈≥10%禁止开仓 · 保本:有利方向足够后止损到开仓价±1$(看价格,非账户浮盈)"
|
"亏≥5%或盈≥10%禁止开仓 · 保本:价格有利≥3$ 后止损到开仓价±1$(看价格距离,非账户浮盈)"
|
||||||
),
|
),
|
||||||
bg=COLOR_BG,
|
bg=COLOR_BG,
|
||||||
fg=COLOR_MUTED,
|
fg=COLOR_MUTED,
|
||||||
@@ -1337,26 +1398,16 @@ class OrderApp(tk.Tk):
|
|||||||
return float(tick.bid) - open_price
|
return float(tick.bid) - open_price
|
||||||
return open_price - float(tick.ask)
|
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:
|
def _be_candidate_tickets(self, positions, tick=None) -> list:
|
||||||
"""现价已能挂开仓价±1$ 止损、且尚未保本的单号。"""
|
"""价格朝有利方向≥3$ 且尚未保本的单号。"""
|
||||||
tickets = []
|
tickets = []
|
||||||
if not positions:
|
if not positions:
|
||||||
return tickets
|
return tickets
|
||||||
symbol = str(positions[0].symbol)
|
|
||||||
if tick is None:
|
if tick is None:
|
||||||
try:
|
try:
|
||||||
tick = self.bridge.tick(symbol)
|
tick = self.bridge.tick(str(positions[0].symbol))
|
||||||
except Exception:
|
except Exception:
|
||||||
return tickets
|
return tickets
|
||||||
need = self._be_needed_move_usd(symbol)
|
|
||||||
for p in positions:
|
for p in positions:
|
||||||
ticket = int(p.ticket)
|
ticket = int(p.ticket)
|
||||||
if ticket in self._be_done_tickets:
|
if ticket in self._be_done_tickets:
|
||||||
@@ -1364,7 +1415,7 @@ class OrderApp(tk.Tk):
|
|||||||
if self._is_sl_at_breakeven(p):
|
if self._is_sl_at_breakeven(p):
|
||||||
self._be_done_tickets.add(ticket)
|
self._be_done_tickets.add(ticket)
|
||||||
continue
|
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)
|
tickets.append(ticket)
|
||||||
return tickets
|
return tickets
|
||||||
|
|
||||||
@@ -1378,7 +1429,6 @@ class OrderApp(tk.Tk):
|
|||||||
tick = self.bridge.tick(symbol)
|
tick = self.bridge.tick(symbol)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"无法读报价:{exc}"
|
return f"无法读报价:{exc}"
|
||||||
need = self._be_needed_move_usd(symbol)
|
|
||||||
best = -1e9
|
best = -1e9
|
||||||
for p in positions:
|
for p in positions:
|
||||||
ticket = int(p.ticket)
|
ticket = int(p.ticket)
|
||||||
@@ -1387,11 +1437,11 @@ class OrderApp(tk.Tk):
|
|||||||
best = max(best, self._favorable_move_usd(p, tick))
|
best = max(best, self._favorable_move_usd(p, tick))
|
||||||
if best < -1e8:
|
if best < -1e8:
|
||||||
return "持仓已保本或无需再拉"
|
return "持仓已保本或无需再拉"
|
||||||
lack = need - best
|
lack = BE_MIN_MOVE_USD - best
|
||||||
if lack > 1e-8:
|
if lack > 1e-8:
|
||||||
return (
|
return (
|
||||||
f"有利方向仅 {best:.2f}$,保本需 ≥ {need:.2f}$"
|
f"有利方向仅 {best:.2f}$,保本需 ≥ {BE_MIN_MOVE_USD:.0f}$"
|
||||||
f"(开仓价±{BE_OFFSET_USD:.0f}$),还差 {lack:.2f}$"
|
f"(之后止损拉到开仓价±{BE_OFFSET_USD:.0f}$),还差 {lack:.2f}$"
|
||||||
)
|
)
|
||||||
return "可保本"
|
return "可保本"
|
||||||
|
|
||||||
@@ -1920,7 +1970,32 @@ class OrderApp(tk.Tk):
|
|||||||
self._be_done_tickets.update(done)
|
self._be_done_tickets.update(done)
|
||||||
return msg
|
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:
|
def _hub_heartbeat(self) -> None:
|
||||||
"""约每 2 秒 ping 中控,供总览秒级在线状态。"""
|
"""约每 2 秒 ping 中控,供总览秒级在线状态。"""
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user