增加一键保本
This commit is contained in:
@@ -72,6 +72,7 @@ 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,
|
||||
@@ -6119,6 +6120,80 @@ 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": "偏移%须在 0~10 之间"}), 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):
|
||||
|
||||
@@ -194,6 +194,13 @@
|
||||
.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}
|
||||
@@ -464,7 +471,11 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
<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>
|
||||
<div class="panel-scroll pos-list pos-list-live">
|
||||
{% for o in order %}
|
||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||
@@ -492,6 +503,15 @@
|
||||
{% 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>
|
||||
@@ -1733,6 +1753,96 @@ 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('偏移%须在 0~10 之间');
|
||||
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;
|
||||
@@ -2072,6 +2182,7 @@ function refreshPriceSnapshotConditional(){
|
||||
}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
initManualBreakevenUi();
|
||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -73,6 +73,7 @@ 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,
|
||||
@@ -6207,6 +6208,80 @@ 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": "偏移%须在 0~10 之间"}), 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):
|
||||
|
||||
@@ -194,6 +194,13 @@
|
||||
.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}
|
||||
@@ -464,7 +471,11 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
<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>
|
||||
<div class="panel-scroll pos-list pos-list-live">
|
||||
{% for o in order %}
|
||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||
@@ -492,6 +503,15 @@
|
||||
{% 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>
|
||||
@@ -1733,6 +1753,96 @@ 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('偏移%须在 0~10 之间');
|
||||
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;
|
||||
@@ -2072,6 +2182,7 @@ function refreshPriceSnapshotConditional(){
|
||||
}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
initManualBreakevenUi();
|
||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -53,6 +53,7 @@ from journal_chart_lib import (
|
||||
trade_review_fetch_window,
|
||||
trim_rows_for_trade_review,
|
||||
)
|
||||
from order_manual_breakeven_lib import apply_order_manual_breakeven
|
||||
from hub_auth import request_allowed as hub_request_allowed
|
||||
from history_window_lib import (
|
||||
PRESET_CUSTOM,
|
||||
@@ -5416,6 +5417,74 @@ def api_account_snapshot():
|
||||
})
|
||||
|
||||
|
||||
@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": "偏移%须在 0~10 之间"}), 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"]
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/price_snapshot")
|
||||
@login_required
|
||||
def api_price_snapshot():
|
||||
|
||||
@@ -131,6 +131,13 @@
|
||||
.plan-cell .val.pnl-neutral,.plan-cell .val .pnl-neutral{color:#cfd3ef}
|
||||
.btn-close-plan{padding:7px 14px;background:#5c1e2a;color:#ffb4b4;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}
|
||||
.btn-close-plan:hover{filter:brightness(1.08)}
|
||||
.pos-be-global{font-size:.74rem;font-weight:400;color:#8892b0;margin-left:8px;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:.76rem}
|
||||
.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:.76rem;color:#b8c4e8;cursor:pointer}
|
||||
.pos-be-label input{width:15px;height:15px}
|
||||
.pos-be-offset{width:52px;padding:4px 6px;background:#0d1119;border:1px solid #3a4460;border-radius:6px;color:#e8ecf5;font-size:.76rem}
|
||||
.pos-be-status{font-size:.72rem;color:#6ab88a}
|
||||
.records-card{grid-column:1/-1}
|
||||
.review-card{grid-column:1/-1}
|
||||
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
|
||||
@@ -348,11 +355,15 @@
|
||||
<button type="submit">开仓(以损定仓)</button>
|
||||
</form>
|
||||
<div class="order-live-positions">
|
||||
<h3 style="margin:0 0 2px;font-size:.95rem;color:#b8c4ff">实时持仓</h3>
|
||||
<h3 style="margin:0 0 2px;font-size:.95rem;color:#b8c4ff;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">%
|
||||
</span>
|
||||
</h3>
|
||||
<div class="running-plans-stack">
|
||||
{% for o in order %}
|
||||
{% set osym = o.exchange_symbol or o.symbol %}
|
||||
<div class="plan-position-card">
|
||||
<div class="plan-position-card" id="order-row-{{ o.id }}" data-order-id="{{ o.id }}" data-direction="{{ o.direction }}">
|
||||
<div class="plan-card-head">
|
||||
<div class="plan-card-title">
|
||||
<span>{{ osym }}</span>
|
||||
@@ -360,6 +371,15 @@
|
||||
</div>
|
||||
<a href="/del_order/{{ o.id }}" class="btn-close-plan" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
||||
</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">%
|
||||
<span class="pos-be-status" id="pos-be-status-{{ o.id }}">{% if o.breakeven_armed %}已保本{% endif %}</span>
|
||||
</div>
|
||||
<div class="plan-card-meta">
|
||||
来源: 下单监控 | 风格: {{ o.trade_style or 'trend' }} | 风险: {% if o.risk_percent is not none %}{{ o.risk_percent }}%{% else %}—{% endif %}≈{{ money_fmt(o.risk_amount) }}U
|
||||
| {% if o.breakeven_enabled %}<span class="accent">移动保本: 开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(osym, o.breakeven_price) }}</span>{% else %}移动保本: 关{% endif %}
|
||||
@@ -1633,6 +1653,92 @@ if(_journalFormEl){
|
||||
syncJournalEntryReasonOtherUi();
|
||||
}
|
||||
refreshOrderDefaults();
|
||||
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 = document.getElementById(`order-row-${orderId}`);
|
||||
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;
|
||||
const cells = card.querySelectorAll('.plan-card-grid .plan-cell');
|
||||
if(cells[1]){
|
||||
const val = cells[1].querySelector('.val');
|
||||
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('偏移%须在 0~10 之间');
|
||||
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);
|
||||
}).catch(()=>{
|
||||
alert('保本请求失败');
|
||||
sw.checked = sw.dataset.armed === '1';
|
||||
sw.disabled = false;
|
||||
});
|
||||
}
|
||||
initManualBreakevenUi();
|
||||
refreshPriceSnapshot();
|
||||
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
||||
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
|
||||
|
||||
@@ -73,6 +73,7 @@ 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,
|
||||
@@ -5846,6 +5847,80 @@ 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": "偏移%须在 0~10 之间"}), 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):
|
||||
|
||||
@@ -194,6 +194,13 @@
|
||||
.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}
|
||||
@@ -473,7 +480,11 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||
<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>
|
||||
<div class="panel-scroll pos-list pos-list-live">
|
||||
{% for o in order %}
|
||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||
@@ -501,6 +512,15 @@
|
||||
{% 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>
|
||||
@@ -1743,6 +1763,96 @@ 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('偏移%须在 0~10 之间');
|
||||
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;
|
||||
@@ -2114,6 +2224,7 @@ function refreshPriceSnapshotConditional(){
|
||||
}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
initManualBreakevenUi();
|
||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""中控台持仓:一键保本(成交价 ± 偏移%)。"""
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
|
||||
def calc_manual_breakeven_stop(direction: str, entry_price: float, offset_pct: float) -> Optional[float]:
|
||||
try:
|
||||
e = float(entry_price)
|
||||
pct = float(offset_pct)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if e <= 0 or pct < 0:
|
||||
return None
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
return e * (1.0 - pct / 100.0)
|
||||
return e * (1.0 + pct / 100.0)
|
||||
|
||||
|
||||
def apply_order_manual_breakeven(
|
||||
row: Any,
|
||||
offset_pct: float,
|
||||
*,
|
||||
calc_stop_fn: Callable[..., Optional[float]],
|
||||
round_price_fn: Callable[[str, float], Any],
|
||||
resolve_ex_sym_fn: Callable[[Any], str],
|
||||
get_position_fn: Callable[[str, str], Any],
|
||||
replace_tpsl_fn: Callable[[Any, float, float], None],
|
||||
entry_price_key: str = "trigger_price",
|
||||
) -> Tuple[bool, Optional[str], Optional[float]]:
|
||||
if (row["status"] or "").strip() != "active":
|
||||
return False, "持仓已结束", None
|
||||
entry = float(row[entry_price_key] or 0)
|
||||
if entry <= 0:
|
||||
return False, "缺少有效成交价", None
|
||||
direction = (row["direction"] or "long").lower()
|
||||
take_profit = float(row["take_profit"] or 0)
|
||||
if take_profit <= 0:
|
||||
return False, "缺少有效止盈价,无法更新交易所委托", None
|
||||
ex_sym = resolve_ex_sym_fn(row)
|
||||
pos = get_position_fn(ex_sym, direction)
|
||||
if pos is None or float(pos) <= 0:
|
||||
return False, "交易所当前无该方向持仓", None
|
||||
new_sl_raw = calc_stop_fn(direction, entry, offset_pct)
|
||||
if new_sl_raw is None:
|
||||
new_sl_raw = calc_manual_breakeven_stop(direction, entry, offset_pct)
|
||||
if new_sl_raw is None:
|
||||
return False, "保本价计算失败", None
|
||||
new_sl = round_price_fn(ex_sym, new_sl_raw)
|
||||
if new_sl is None:
|
||||
return False, "保本价经交易所精度舍入后无效", None
|
||||
new_sl = float(new_sl)
|
||||
cur_sl = float(row["stop_loss"] or 0)
|
||||
if direction == "long":
|
||||
if cur_sl > 0 and new_sl <= cur_sl:
|
||||
return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)", None
|
||||
else:
|
||||
if cur_sl > 0 and new_sl >= cur_sl:
|
||||
return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)", None
|
||||
try:
|
||||
replace_tpsl_fn(row, new_sl, take_profit)
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
return True, None, new_sl
|
||||
Reference in New Issue
Block a user