移除一键保本bug

This commit is contained in:
dekun
2026-05-28 12:43:55 +08:00
parent 96dd4a041c
commit aa92952b2d
11 changed files with 68 additions and 804 deletions
+2
View File
@@ -79,6 +79,8 @@ GATE_TPSL_USE_POSITION_ORDER=true
GATE_TPSL_TRIGGER_EXPIRATION=604800
# 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0)
GATE_TPSL_PRICE_TYPE=0
# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」
GATE_TPSL_LAST_PRICE_GAP_PCT=0.05
# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟)
# EXCHANGE_DISPLAY_NAME=Gate.io
+46 -76
View File
@@ -73,7 +73,6 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
from order_manual_breakeven_lib import apply_order_manual_breakeven
from key_monitor_lib import (
KEY_DIRECTION_WATCH,
KEY_MONITOR_ALERT_ONLY_TYPES,
@@ -190,6 +189,8 @@ GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2:
GATE_TPSL_PRICE_TYPE = 0
GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes")
# 仓位类触发单相对 mark/last 的最小间距(%),避免 Gate 1026 AUTO_TRIGGER_PRICE_*_LAST
GATE_TPSL_LAST_PRICE_GAP_PCT = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05"))
# 页面展示的交易所名称(多实例/多环境时可按需区分)
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io"
_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated"
@@ -2871,6 +2872,40 @@ def _gate_contracts_amount_for_tpsl(order, fallback_amount):
return float(fallback_amount)
def _gate_clamp_tpsl_to_last_price(exchange_symbol, direction, stop_loss, take_profit, *, sl_only=False):
"""
Gate price_orders 规则空仓止损/多仓止盈 trigger>last空仓止盈/多仓止损 trigger<last
计划价可能已穿过现价时按最小间距自动微调并返回说明
"""
ensure_markets_loaded()
last = get_price(exchange_symbol)
if last is None or float(last) <= 0:
return float(stop_loss), float(take_profit), None
last = float(last)
sl = float(stop_loss)
tp = float(take_profit)
gap = max(0.0, float(GATE_TPSL_LAST_PRICE_GAP_PCT)) / 100.0
if gap <= 0:
gap = 0.0005
notes = []
direction = (direction or "long").strip().lower()
if direction == "short":
if sl <= last:
sl = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap)))
notes.append(f"止损触发价须高于现价 {last},已调整为 {sl}")
if not sl_only and tp >= last:
tp = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap)))
notes.append(f"止盈触发价须低于现价 {last},已调整为 {tp}")
else:
if sl >= last:
sl = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap)))
notes.append(f"止损触发价须低于现价 {last},已调整为 {sl}")
if not sl_only and tp <= last:
tp = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap)))
notes.append(f"止盈触发价须高于现价 {last},已调整为 {tp}")
return sl, tp, ("".join(notes) if notes else None)
def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
"""ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。"""
ensure_markets_loaded()
@@ -2900,6 +2935,9 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s
order_type=close-long-position / close-short-position单向全平 close+size=0双向需 auto_size
App 内展示的条件委托一致平仓后仍需 cancel_gate_swap_trigger_orders 避免残留
"""
stop_loss, take_profit, _ = _gate_clamp_tpsl_to_last_price(
exchange_symbol, direction, stop_loss, take_profit
)
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
if not market.get("swap"):
@@ -2972,6 +3010,9 @@ def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_
def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss):
"""Gate 永续:仅挂仓位类止损触发单(趋势回调用)。"""
stop_loss, _, _ = _gate_clamp_tpsl_to_last_price(
exchange_symbol, direction, stop_loss, stop_loss, sl_only=True
)
ensure_markets_loaded()
market = exchange.market(exchange_symbol)
if not market.get("swap"):
@@ -3298,6 +3339,9 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
raise RuntimeError(reason or "实盘未就绪")
ex_sym = resolve_monitor_exchange_symbol(order_row)
direction = order_row["direction"]
sl, tp, adjust_note = _gate_clamp_tpsl_to_last_price(
ex_sym, direction, float(stop_loss), float(take_profit)
)
cancel_gate_swap_trigger_orders(ex_sym)
contracts = get_live_position_contracts(ex_sym, direction)
if contracts is None or float(contracts) <= 0:
@@ -3310,7 +3354,7 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
amt = 0
if amt <= 0:
raise ValueError("无法确定平仓数量")
_gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit))
_gate_place_tp_sl_orders(ex_sym, direction, amt, sl, tp)
def extract_trade_price_from_order(order):
@@ -6208,80 +6252,6 @@ def api_order_cancel_tpsl(order_id):
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
@app.route("/api/order/<int:order_id>/manual_breakeven", methods=["POST"])
@login_required
def api_order_manual_breakeven(order_id):
data = request.get_json(silent=True) or {}
try:
offset_pct = float(
data.get("offset_pct", os.getenv("MANUAL_BREAKEVEN_OFFSET_PCT", "0.2"))
)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "offset_pct 无效"}), 400
if offset_pct < 0 or offset_pct > 10:
return jsonify({"ok": False, "msg": "偏移%须在 010 之间"}), 400
conn = get_db()
row = conn.execute(
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
(order_id,),
).fetchone()
if not row:
conn.close()
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
ok, err, new_sl = apply_order_manual_breakeven(
row,
offset_pct,
calc_stop_fn=calc_trend_manual_breakeven_stop,
round_price_fn=round_price_to_exchange,
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
get_position_fn=get_live_position_contracts,
replace_tpsl_fn=replace_active_monitor_tpsl_on_exchange,
)
if not ok:
conn.close()
return jsonify({"ok": False, "msg": err or "保本失败"}), 400
conn.execute(
"UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?",
(new_sl, new_sl, order_id),
)
conn.commit()
sym = row["symbol"]
direction = row["direction"]
ex_sym = resolve_monitor_exchange_symbol(row)
take_profit = float(row["take_profit"] or 0)
slots = fetch_exchange_tpsl_slots(
ex_sym, direction, plan_sl=new_sl, plan_tp=take_profit
)
conn.close()
try:
entry = float(row["trigger_price"] or 0)
send_wechat_msg(
"\n".join(
[
f"# ✅ {sym} 一键保本",
f"**账户:{_wechat_account_label()}**",
f"- 方向:{'做多' if direction == 'long' else '做空'}",
f"- 成交价:{format_price_for_symbol(sym, entry)}",
f"- 偏移:{offset_pct}%(相对成交价)",
f"- 新止损:{format_price_for_symbol(sym, new_sl)}",
"- 交易所:已更新止盈止损",
]
)
)
except Exception:
pass
return jsonify(
{
"ok": True,
"msg": "已一键保本",
"stop_loss": new_sl,
"stop_loss_display": format_price_for_symbol(sym, new_sl),
"breakeven_armed": True,
"exchange_tpsl": slots,
}
)
@app.route("/api/order/<int:order_id>/place_tpsl", methods=["POST"])
@login_required
def api_order_place_tpsl(order_id):
+1 -112
View File
@@ -194,13 +194,6 @@
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
.pos-be-global{font-size:.76rem;font-weight:400;color:#8892b0;margin-left:10px;display:inline-flex;align-items:center;gap:4px}
.pos-be-global input{width:52px;padding:3px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem}
.pos-be-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:#1a2030;border-radius:8px;border:1px solid #2a3348}
.pos-be-label{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:#b8c4e8;cursor:pointer;user-select:none}
.pos-be-label input{width:15px;height:15px;accent-color:#6eb5ff}
.pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.78rem}
.pos-be-status{font-size:.74rem;color:#6ab88a}
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
.tpsl-modal-backdrop.open{display:flex}
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
@@ -471,11 +464,7 @@
</form>
</div>
<div class="card">
<h2 style="margin-bottom:8px;display:flex;align-items:center;flex-wrap:wrap;gap:6px">实时持仓
<span class="pos-be-global">一键保本默认偏移
<input type="number" id="manual-be-offset-global" min="0" max="10" step="0.01" value="0.2" title="相对成交价,多仓为+、空仓为-">%
</span>
</h2>
<h2 style="margin-bottom:8px">实时持仓</h2>
<div class="panel-scroll pos-list pos-list-live">
{% for o in order %}
<div class="pos-card" id="order-row-{{ o.id }}"
@@ -503,15 +492,6 @@
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span>
</div>
<div class="pos-be-row">
<label class="pos-be-label" title="将止损移至成交价±偏移%,并同步交易所止盈止损">
<input type="checkbox" class="pos-be-switch" data-order-id="{{ o.id }}"
{% if o.breakeven_armed %}checked data-armed="1"{% endif %}>
一键保本
</label>
<input type="number" class="pos-be-offset" min="0" max="10" step="0.01" value="0.2" title="相对成交价偏移%">%
<span class="pos-be-status" id="pos-be-status-{{ o.id }}">{% if o.breakeven_armed %}已保本{% endif %}</span>
</div>
<div class="pos-grid">
<div class="pos-cell">
<span class="pos-label">成交价</span>
@@ -1753,96 +1733,6 @@ function submitTpslEntrust(){
post();
}).catch(()=>alert('无法校验盈亏比'));
}
const MANUAL_BE_OFFSET_KEY = 'manualBreakevenOffsetPct';
function getManualBeOffsetPct(){
const g = document.getElementById('manual-be-offset-global');
if(g && g.value !== ''){
const n = parseFloat(g.value);
if(Number.isFinite(n)) return n;
}
const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY);
const n = saved ? parseFloat(saved) : 0.2;
return Number.isFinite(n) ? n : 0.2;
}
function initManualBreakevenUi(){
const g = document.getElementById('manual-be-offset-global');
if(g){
const saved = localStorage.getItem(MANUAL_BE_OFFSET_KEY);
if(saved) g.value = saved;
g.addEventListener('change', ()=>{
localStorage.setItem(MANUAL_BE_OFFSET_KEY, g.value);
document.querySelectorAll('.pos-be-offset').forEach(inp=>{
if(!inp.dataset.touched) inp.value = g.value;
});
});
}
document.querySelectorAll('.pos-be-offset').forEach(inp=>{
if(!inp.dataset.touched){
inp.value = getManualBeOffsetPct();
}
inp.addEventListener('input', ()=>{ inp.dataset.touched = '1'; });
});
document.querySelectorAll('.pos-be-switch').forEach(sw=>{
if(sw.dataset.bound) return;
sw.dataset.bound = '1';
sw.addEventListener('change', ()=>{
const orderId = sw.getAttribute('data-order-id');
const card = sw.closest('.pos-card');
const offInp = card && card.querySelector('.pos-be-offset');
if(!sw.checked){
if(sw.dataset.armed === '1') sw.checked = true;
return;
}
applyManualBreakeven(orderId, sw, offInp, card);
});
});
}
function updateOrderCardSlDisplay(card, slDisplay){
if(!card || !slDisplay) return;
card.setAttribute('data-plan-sl', slDisplay);
const cells = card.querySelectorAll('.pos-grid .pos-cell');
if(cells[1]){
const val = cells[1].querySelector('.pos-value');
if(val) val.textContent = slDisplay;
}
}
function applyManualBreakeven(orderId, sw, offInp, card){
const offset = offInp ? parseFloat(offInp.value) : getManualBeOffsetPct();
if(!Number.isFinite(offset) || offset < 0 || offset > 10){
alert('偏移%须在 010 之间');
sw.checked = sw.dataset.armed === '1';
return;
}
const dirHint = card && card.getAttribute('data-direction') === 'short' ? '下' : '上';
if(!confirm(`确认一键保本?止损将移至成交价${dirHint}${offset}%(相对成交价)`)){
sw.checked = sw.dataset.armed === '1';
return;
}
sw.disabled = true;
fetch(`/api/order/${orderId}/manual_breakeven`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ offset_pct: offset })
}).then(r=>r.json()).then(data=>{
if(!data.ok){
alert(data.msg || '保本失败');
sw.checked = sw.dataset.armed === '1';
sw.disabled = false;
return;
}
sw.dataset.armed = '1';
sw.checked = true;
const st = document.getElementById(`pos-be-status-${orderId}`);
if(st) st.textContent = '已保本';
if(data.stop_loss_display) updateOrderCardSlDisplay(card, data.stop_loss_display);
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
else refreshPriceSnapshotConditional();
}).catch(()=>{
alert('保本请求失败');
sw.checked = sw.dataset.armed === '1';
sw.disabled = false;
});
}
function cancelExchangeTpsl(orderId, role){
const label = role === 'sl' ? '止损' : '止盈';
if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return;
@@ -2182,7 +2072,6 @@ function refreshPriceSnapshotConditional(){
}
}).catch(()=>{});
}
initManualBreakevenUi();
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
</script>
</body>