diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 261ad1f..fe2ffda 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -233,6 +233,7 @@ from lib.trade.order_monitor_display_lib import ( apply_order_price_display_fields, enrich_order_display_fields, order_monitor_tpsl_needs_sync, + stale_breakeven_armed, ) from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from lib.hub.hub_auth import request_allowed as hub_request_allowed @@ -6489,6 +6490,12 @@ def check_order_monitors(): # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) step_r = 1.0 if step_r <= 0 else step_r diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 488d664..15a8dd5 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -235,6 +235,7 @@ from lib.trade.order_monitor_display_lib import ( apply_order_price_display_fields, enrich_order_display_fields, order_monitor_tpsl_needs_sync, + stale_breakeven_armed, ) from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from lib.hub.hub_auth import request_allowed as hub_request_allowed @@ -6163,6 +6164,12 @@ def check_order_monitors(): # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) step_r = 1.0 if step_r <= 0 else step_r diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 23236dc..224e49d 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -231,6 +231,7 @@ from lib.trade.order_monitor_display_lib import ( apply_order_price_display_fields, enrich_order_display_fields, order_monitor_tpsl_needs_sync, + stale_breakeven_armed, ) from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from lib.hub.hub_auth import request_allowed as hub_request_allowed @@ -5968,6 +5969,12 @@ def check_order_monitors(): # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) risk_amount = float(r["risk_amount"] or 0) breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) step_r = 1.0 if step_r <= 0 else step_r diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 79a6b55..03645ae 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -844,9 +844,41 @@ function formatRrRatio(rr){ return `${body}:1`; } -function paintBreakevenBadge(orderId, secured){ +function resolveSlBreakevenSecured(orderId, snap){ + if(!snap) return false; + const et = snap.exchange_tpsl; + const slSlot = et && et.sl ? et.sl : null; + let slPx = NaN; + if(slSlot){ + const raw = slSlot.trigger_price != null ? slSlot.trigger_price : slSlot.trigger_display; + slPx = raw != null && raw !== "" ? Number(raw) : NaN; + } + if(!Number.isFinite(slPx) || slPx <= 0){ + slPx = snap.stop_loss != null && snap.stop_loss !== "" ? Number(snap.stop_loss) : NaN; + } + let entry = snap.avg_entry_price != null && snap.avg_entry_price !== "" ? Number(snap.avg_entry_price) : NaN; + let direction = snap.direction ? String(snap.direction).toLowerCase() : ""; + const card = document.getElementById(`order-row-${orderId}`); + if(card){ + if(!direction) direction = String(card.getAttribute("data-direction") || "long").toLowerCase(); + if(!Number.isFinite(entry)){ + const ent = card.getAttribute("data-entry"); + if(ent){ + const n = Number(String(ent).replace(/,/g, "")); + if(Number.isFinite(n) && n > 0) entry = n; + } + } + } + if(Number.isFinite(slPx) && Number.isFinite(entry) && entry > 0){ + if(direction === "short") return slPx <= entry; + return slPx >= entry; + } + return snap.sl_breakeven_secured === true || snap.sl_breakeven_secured === 1; +} +function paintBreakevenBadge(orderId, snap){ const wrap = document.getElementById(`order-be-wrap-${orderId}`); if(!wrap) return; + const secured = (snap && typeof snap === "object") ? resolveSlBreakevenSecured(orderId, snap) : !!snap; wrap.style.display = secured ? "inline-flex" : "none"; } function paintPlanTpslDisplay(orderId, snap){ @@ -1001,7 +1033,7 @@ function refreshPriceSnapshot(){ paintLatestRiskDisplay(o.id, o); paintContractsDisplay(o.id, o); paintTpProfitDisplay(o.id, o); - paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintBreakevenBadge(o.id, o); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); @@ -1383,7 +1415,7 @@ function refreshPriceSnapshotConditional(){ } const rrEl = document.getElementById(`order-rr-${o.id}`); if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); - paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintBreakevenBadge(o.id, o); paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index a87fb05..2a1b2ce 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1451,9 +1451,41 @@ function formatRrRatio(rr){ return `${body}:1`; } -function paintBreakevenBadge(orderId, secured){ +function resolveSlBreakevenSecured(orderId, snap){ + if(!snap) return false; + const et = snap.exchange_tpsl; + const slSlot = et && et.sl ? et.sl : null; + let slPx = NaN; + if(slSlot){ + const raw = slSlot.trigger_price != null ? slSlot.trigger_price : slSlot.trigger_display; + slPx = raw != null && raw !== "" ? Number(raw) : NaN; + } + if(!Number.isFinite(slPx) || slPx <= 0){ + slPx = snap.stop_loss != null && snap.stop_loss !== "" ? Number(snap.stop_loss) : NaN; + } + let entry = snap.avg_entry_price != null && snap.avg_entry_price !== "" ? Number(snap.avg_entry_price) : NaN; + let direction = snap.direction ? String(snap.direction).toLowerCase() : ""; + const card = document.getElementById(`order-row-${orderId}`); + if(card){ + if(!direction) direction = String(card.getAttribute("data-direction") || "long").toLowerCase(); + if(!Number.isFinite(entry)){ + const ent = card.getAttribute("data-entry"); + if(ent){ + const n = Number(String(ent).replace(/,/g, "")); + if(Number.isFinite(n) && n > 0) entry = n; + } + } + } + if(Number.isFinite(slPx) && Number.isFinite(entry) && entry > 0){ + if(direction === "short") return slPx <= entry; + return slPx >= entry; + } + return snap.sl_breakeven_secured === true || snap.sl_breakeven_secured === 1; +} +function paintBreakevenBadge(orderId, snap){ const wrap = document.getElementById(`order-be-wrap-${orderId}`); if(!wrap) return; + const secured = (snap && typeof snap === "object") ? resolveSlBreakevenSecured(orderId, snap) : !!snap; wrap.style.display = secured ? "inline-flex" : "none"; } function paintPlanTpslDisplay(orderId, snap){ @@ -1638,7 +1670,7 @@ function refreshPriceSnapshot(){ paintLatestRiskDisplay(o.id, o); paintContractsDisplay(o.id, o); paintTpProfitDisplay(o.id, o); - paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintBreakevenBadge(o.id, o); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); @@ -2069,7 +2101,7 @@ function refreshPriceSnapshotConditional(){ paintLatestRiskDisplay(o.id, o); paintContractsDisplay(o.id, o); paintTpProfitDisplay(o.id, o); - paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintBreakevenBadge(o.id, o); paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); diff --git a/lib/strategy/strategy_register.py b/lib/strategy/strategy_register.py index fefc818..3182ee9 100644 --- a/lib/strategy/strategy_register.py +++ b/lib/strategy/strategy_register.py @@ -507,7 +507,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: live_qty = normalize_contracts_qty(live_qty) conn.execute( - "UPDATE order_monitors SET stop_loss=?, order_amount=? WHERE id=?", + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL WHERE id=?""", (new_sl, live_qty, mon["id"]), ) conn.commit() diff --git a/lib/strategy/strategy_roll_monitor_lib.py b/lib/strategy/strategy_roll_monitor_lib.py index a427420..6665ebc 100644 --- a/lib/strategy/strategy_roll_monitor_lib.py +++ b/lib/strategy/strategy_roll_monitor_lib.py @@ -459,7 +459,9 @@ def _execute_pending_roll_leg( except Exception: pass conn.execute( - "UPDATE order_monitors SET stop_loss=?, order_amount=? WHERE id=? AND status='active'", + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL + WHERE id=? AND status='active'""", (sl, live_qty, mon["id"]), ) diff --git a/lib/trade/order_monitor_display_lib.py b/lib/trade/order_monitor_display_lib.py index acbd6fb..9b23cc8 100644 --- a/lib/trade/order_monitor_display_lib.py +++ b/lib/trade/order_monitor_display_lib.py @@ -93,6 +93,25 @@ def tpsl_update_passes_rr_gate( return False, f"计划盈亏比 {rr_txt}:1 低于最低要求 {min_rr}:1(盈利侧保本止损不受此限)" +def resolve_breakeven_entry_price(entry_price: Any, avg_entry_price: Any = None) -> Optional[float]: + """保本判断基准价:有持仓加权均价时优先(滚仓后),否则用首仓成交价.""" + avg = _positive_float(avg_entry_price) + if avg is not None: + return avg + return _positive_float(entry_price) + + +def stale_breakeven_armed(direction: str, entry_price: Any, stop_loss: Any, breakeven_armed: Any) -> bool: + """止损已回到亏损侧时 breakeven_armed 视为过期(如滚仓下移止损).""" + try: + armed = int(breakeven_armed or 0) != 0 + except (TypeError, ValueError): + return False + if not armed: + return False + return not stop_is_profit_protecting(direction, entry_price, stop_loss) + + def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool: """ 交易所当前止损相对开仓成交价是否已保本. @@ -290,8 +309,10 @@ def apply_order_price_display_fields( stop_loss, take_profit, ) + risk_entry = resolve_breakeven_entry_price(entry_price, avg_entry_price) + payload["avg_entry_price"] = risk_entry payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl( - direction, entry_price, exchange_tpsl + direction, risk_entry, exchange_tpsl ) payload["stop_loss"] = disp_sl payload["take_profit"] = disp_tp @@ -310,7 +331,6 @@ def apply_order_price_display_fields( payload["contracts"] = c except (TypeError, ValueError): pass - risk_entry = _positive_float(avg_entry_price) or _positive_float(entry_price) payload["latest_risk_amount"] = calc_latest_risk_amount( direction, risk_entry, diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 4e954b4..d213149 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -2058,14 +2058,21 @@ function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { const mo = monitorOrder || {}; const p = pos || {}; - if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; - if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; const { sl } = pickExTpslOrders(cond); const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; - const e = Number(entry); - if (!Number.isFinite(trig) || !Number.isFinite(e)) return false; - if ((side || "long").toLowerCase() === "short") return trig <= e; - return trig >= e; + const liveEntry = + p.entry_price != null && p.entry_price !== "" + ? Number(p.entry_price) + : mo.avg_entry_price != null && mo.avg_entry_price !== "" + ? Number(mo.avg_entry_price) + : Number(entry); + if (Number.isFinite(trig) && Number.isFinite(liveEntry)) { + if ((side || "long").toLowerCase() === "short") return trig <= liveEntry; + return trig >= liveEntry; + } + if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; + if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; + return false; } function breakevenBadgeHtml() { diff --git a/tests/test_order_monitor_display_lib.py b/tests/test_order_monitor_display_lib.py index 5e65d73..5ec9b3d 100644 --- a/tests/test_order_monitor_display_lib.py +++ b/tests/test_order_monitor_display_lib.py @@ -5,10 +5,12 @@ from lib.trade.order_monitor_display_lib import ( is_sl_breakeven_secured, monitor_open_stop_loss, order_monitor_tpsl_needs_sync, + resolve_breakeven_entry_price, resolve_live_tpsl_prices, sl_breakeven_from_exchange_tpsl, snapshot_rr, snapshot_stop_loss, + stale_breakeven_armed, ) @@ -106,7 +108,7 @@ def test_apply_order_price_display_fields_live_sl(): ) assert payload["stop_loss"] == 1661 assert payload["stop_loss_display"] == "1661.00" - assert payload["sl_breakeven_secured"] is True + assert payload["sl_breakeven_secured"] is False assert payload["rr_ratio"] is not None assert payload["latest_risk_amount"] is not None assert payload["latest_risk_amount"] >= 0 @@ -149,3 +151,30 @@ def test_calc_latest_risk_amount_long(): def test_calc_latest_risk_amount_profit_side_stop(): risk = calc_latest_risk_amount("long", 100, 101, exchange_notional=1000) assert risk == 0.0 + + +def test_resolve_breakeven_entry_price_prefers_avg(): + assert resolve_breakeven_entry_price(1777.39, 1777.2) == 1777.2 + assert resolve_breakeven_entry_price(1777.39, None) == 1777.39 + + +def test_roll_long_not_breakeven_with_avg_entry(): + payload = {} + apply_order_price_display_fields( + payload, + direction="long", + entry_price=1777.39, + initial_stop_loss=1750, + stop_loss=1767, + take_profit=1833, + calc_rr_ratio_fn=_calc_rr, + exchange_tpsl={"sl": {"trigger_price": 1767}, "tp": {"trigger_price": 1833}}, + avg_entry_price=1777.2, + ) + assert payload["sl_breakeven_secured"] is False + + +def test_stale_breakeven_armed_after_roll_down(): + assert stale_breakeven_armed("long", 1777.39, 1767, 1) is True + assert stale_breakeven_armed("long", 1777.39, 1778, 1) is False + assert stale_breakeven_armed("long", 1777.39, 1767, 0) is False