fix(gate-bot): show orphan exchange positions and relink monitor
When the exchange still has a position but order_monitors is not active, surface it on the trade page and allow restoring the latest stopped record.
This commit is contained in:
@@ -3601,6 +3601,88 @@ def get_live_position_exchange_metrics(exchange_symbol, direction):
|
|||||||
return parse_ccxt_position_metrics(p)
|
return parse_ccxt_position_metrics(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_all_swap_positions_live():
|
||||||
|
if not exchange_private_api_configured():
|
||||||
|
return []
|
||||||
|
ensure_markets_loaded()
|
||||||
|
try:
|
||||||
|
return exchange.fetch_positions(None, {"settle": "usdt"}) or []
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return exchange.fetch_positions() or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _active_monitor_position_keys(active_orders):
|
||||||
|
covered = set()
|
||||||
|
for o in active_orders or []:
|
||||||
|
sym = (o.get("symbol") or "").strip()
|
||||||
|
ex = (o.get("exchange_symbol") or normalize_exchange_symbol(sym)).strip()
|
||||||
|
direction = (o.get("direction") or "long").lower()
|
||||||
|
for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)):
|
||||||
|
if s:
|
||||||
|
covered.add((s, direction))
|
||||||
|
return covered
|
||||||
|
|
||||||
|
|
||||||
|
def collect_orphan_exchange_positions(active_orders):
|
||||||
|
"""交易所有持仓但未匹配 order_monitors.status=active(与中控 Agent 持仓对齐提示)。"""
|
||||||
|
from hub_position_metrics import (
|
||||||
|
parse_position_mark_price,
|
||||||
|
position_contracts,
|
||||||
|
position_side_from_ccxt,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = _fetch_all_swap_positions_live()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
covered = _active_monitor_position_keys(active_orders)
|
||||||
|
orphans = []
|
||||||
|
seen = set()
|
||||||
|
for p in rows:
|
||||||
|
contracts = position_contracts(p)
|
||||||
|
if abs(contracts) < 1e-12:
|
||||||
|
continue
|
||||||
|
ex_sym = (p.get("symbol") or "").strip()
|
||||||
|
if not ex_sym:
|
||||||
|
continue
|
||||||
|
direction = position_side_from_ccxt(p, contracts)
|
||||||
|
match_keys = (
|
||||||
|
(ex_sym, direction),
|
||||||
|
(_unified_symbol_for_match(ex_sym), direction),
|
||||||
|
)
|
||||||
|
if any(k in covered for k in match_keys):
|
||||||
|
continue
|
||||||
|
dedupe = (ex_sym, direction)
|
||||||
|
if dedupe in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dedupe)
|
||||||
|
metrics = parse_ccxt_position_metrics(p) or {}
|
||||||
|
info = p.get("info") or {}
|
||||||
|
entry = _coerce_float(
|
||||||
|
p.get("entryPrice"),
|
||||||
|
p.get("entry_price"),
|
||||||
|
info.get("entry_price"),
|
||||||
|
info.get("avgEntryPrice"),
|
||||||
|
)
|
||||||
|
mark = parse_position_mark_price(p) or metrics.get("mark_price")
|
||||||
|
sym = normalize_symbol_input(ex_sym.split(":")[0] if ":" in ex_sym else ex_sym)
|
||||||
|
orphans.append(
|
||||||
|
{
|
||||||
|
"symbol": sym,
|
||||||
|
"exchange_symbol": ex_sym,
|
||||||
|
"direction": direction,
|
||||||
|
"contracts": round(abs(float(contracts)), 6),
|
||||||
|
"entry_price": entry,
|
||||||
|
"mark_price": mark,
|
||||||
|
"unrealized_pnl": metrics.get("unrealized_pnl"),
|
||||||
|
"initial_margin": metrics.get("initial_margin"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return orphans
|
||||||
|
|
||||||
|
|
||||||
def _unified_symbol_for_match(symbol_str):
|
def _unified_symbol_for_match(symbol_str):
|
||||||
"""统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。"""
|
"""统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。"""
|
||||||
x = (symbol_str or "").strip().upper()
|
x = (symbol_str or "").strip().upper()
|
||||||
@@ -5543,6 +5625,12 @@ def render_main_page(page="trade"):
|
|||||||
default_risk_percent=float(RISK_PERCENT),
|
default_risk_percent=float(RISK_PERCENT),
|
||||||
count_active_trends=lambda c, ta=trend_active: int(ta or 0),
|
count_active_trends=lambda c, ta=trend_active: int(ta or 0),
|
||||||
)
|
)
|
||||||
|
orphan_positions: list = []
|
||||||
|
if page == "trade":
|
||||||
|
try:
|
||||||
|
orphan_positions = collect_orphan_exchange_positions(order_list)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[render_main_page] orphan positions: {exc}")
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -5551,6 +5639,7 @@ def render_main_page(page="trade"):
|
|||||||
key_history=key_history,
|
key_history=key_history,
|
||||||
stats_bundle=stats_bundle,
|
stats_bundle=stats_bundle,
|
||||||
order=order_list,
|
order=order_list,
|
||||||
|
orphan_positions=orphan_positions,
|
||||||
record=records,
|
record=records,
|
||||||
total=total,
|
total=total,
|
||||||
miss_count=miss_count,
|
miss_count=miss_count,
|
||||||
@@ -5982,6 +6071,55 @@ def api_symbol_liquidity_rank():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/order/relink_orphan", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_order_relink_orphan():
|
||||||
|
"""交易所有仓但本地无 active 监控时,恢复最近一条已停止的同向监控记录。"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
symbol = normalize_symbol_input(data.get("symbol"))
|
||||||
|
direction = (data.get("direction") or "long").strip().lower()
|
||||||
|
if not symbol:
|
||||||
|
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
|
||||||
|
if direction not in ("long", "short"):
|
||||||
|
direction = "long"
|
||||||
|
ok, reason = ensure_exchange_live_ready()
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "msg": reason}), 400
|
||||||
|
exchange_symbol = normalize_exchange_symbol(symbol)
|
||||||
|
contracts = get_live_position_contracts(exchange_symbol, direction)
|
||||||
|
if contracts is None or float(contracts) <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "交易所当前无该方向持仓,无法恢复监控"}), 400
|
||||||
|
conn = get_db()
|
||||||
|
active = conn.execute(
|
||||||
|
"SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1",
|
||||||
|
(symbol, direction),
|
||||||
|
).fetchone()
|
||||||
|
if active:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": True, "msg": "已有运行中的监控", "order_id": int(active["id"])})
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM order_monitors
|
||||||
|
WHERE symbol=? AND direction=? AND status IN ('stopped', 'error')
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(symbol, direction),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"msg": "未找到可恢复的历史监控记录,请在中控核对持仓或联系管理员",
|
||||||
|
}
|
||||||
|
), 404
|
||||||
|
conn.execute("UPDATE order_monitors SET status='active' WHERE id=?", (int(row["id"]),))
|
||||||
|
conn.commit()
|
||||||
|
oid = int(row["id"])
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": True, "msg": "已恢复本地监控", "order_id": oid})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/order_defaults")
|
@app.route("/api/order_defaults")
|
||||||
@login_required
|
@login_required
|
||||||
def api_order_defaults():
|
def api_order_defaults():
|
||||||
|
|||||||
@@ -246,6 +246,10 @@
|
|||||||
.pos-value.price-flat{color:#e8ecf4}
|
.pos-value.price-flat{color:#e8ecf4}
|
||||||
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
|
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
|
||||||
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
|
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
|
||||||
|
.pos-card-orphan{border-color:#6a5528;background:#1a1810}
|
||||||
|
.pos-orphan-banner{font-size:.78rem;color:#eac147;background:#2a2418;border:1px solid #6a5528;border-radius:8px;padding:8px 10px;margin-bottom:10px;line-height:1.45}
|
||||||
|
.pos-relink-btn{padding:6px 12px;background:#3d4a2a;color:#d4e8a8;border:none;border-radius:8px;font-size:.82rem;cursor:pointer}
|
||||||
|
.pos-relink-btn:hover{filter:brightness(1.08)}
|
||||||
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
|
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
.stats-card{grid-column:1/-1;margin-top:14px}
|
.stats-card{grid-column:1/-1;margin-top:14px}
|
||||||
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
|
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
|
||||||
@@ -434,6 +438,12 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:8px">实时持仓</h2>
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
<div class="panel-scroll pos-list pos-list-live">
|
<div class="panel-scroll pos-list pos-list-live">
|
||||||
|
{% if orphan_positions %}
|
||||||
|
<div class="pos-orphan-banner">交易所有持仓,但本地 <code>order_monitors</code> 无 active 记录(中控仍可能显示交易所实盘)。可点「恢复监控」接回最近已停止记录,或在中控全平。</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not order and not orphan_positions %}
|
||||||
|
<div class="pos-empty">暂无持仓</div>
|
||||||
|
{% endif %}
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
<div class="pos-card" id="order-row-{{ o.id }}"
|
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||||
data-monitor-id="{{ o.id }}"
|
data-monitor-id="{{ o.id }}"
|
||||||
@@ -513,8 +523,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<div class="pos-empty">暂无持仓</div>
|
{% for op in orphan_positions %}
|
||||||
|
<div class="pos-card pos-card-orphan" id="orphan-row-{{ op.symbol }}-{{ op.direction }}">
|
||||||
|
<div class="pos-card-head">
|
||||||
|
<div class="pos-card-symbol">
|
||||||
|
<strong>{{ op.exchange_symbol or op.symbol }}</strong>
|
||||||
|
<span class="pos-side-badge {{ 'pos-side-long' if op.direction == 'long' else 'pos-side-short' }}">{{ '做多' if op.direction == 'long' else '做空' }}</span>
|
||||||
|
<span class="badge miss" style="margin-left:4px">仅交易所</span>
|
||||||
|
</div>
|
||||||
|
<div class="pos-head-actions">
|
||||||
|
<button type="button" class="pos-relink-btn" onclick="relinkOrphanPosition('{{ op.symbol }}','{{ op.direction }}')">恢复监控</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pos-meta">
|
||||||
|
<span class="pos-meta-item">张数: {{ op.contracts }}</span>
|
||||||
|
<span class="pos-meta-item">开仓价: {{ price_fmt(op.symbol, op.entry_price) }}</span>
|
||||||
|
<span class="pos-meta-item">标记价: {{ price_fmt(op.symbol, op.mark_price) }}</span>
|
||||||
|
{% if op.unrealized_pnl is not none %}
|
||||||
|
<span class="pos-meta-item">浮盈亏: {{ money_fmt(op.unrealized_pnl) }}U</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1682,6 +1712,22 @@ function submitTpslEntrust(){
|
|||||||
post();
|
post();
|
||||||
}).catch(()=>alert('无法校验盈亏比'));
|
}).catch(()=>alert('无法校验盈亏比'));
|
||||||
}
|
}
|
||||||
|
function relinkOrphanPosition(symbol, direction){
|
||||||
|
if(!confirm(`恢复 ${symbol} ${direction} 的本地监控?(接回最近一条已停止记录)`)) return;
|
||||||
|
fetch("/api/order/relink_orphan", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({ symbol, direction }),
|
||||||
|
})
|
||||||
|
.then(r=>r.json())
|
||||||
|
.then(data=>{
|
||||||
|
if(!data.ok){ alert(data.msg || "恢复失败"); return; }
|
||||||
|
alert(data.msg || "已恢复");
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(()=>alert("恢复请求失败"));
|
||||||
|
}
|
||||||
|
|
||||||
function cancelExchangeTpsl(orderId, role){
|
function cancelExchangeTpsl(orderId, role){
|
||||||
const label = role === 'sl' ? '止损' : '止盈';
|
const label = role === 'sl' ? '止损' : '止盈';
|
||||||
if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return;
|
if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user