feat(gate-bot): align order monitor with Gate main site

Dual-panel trade UI, exchange TP/SL entrust modal, and place/cancel_tpsl APIs so bot manual trading matches Gate.
This commit is contained in:
dekun
2026-06-04 16:09:17 +08:00
parent f9301b92b9
commit e327f1b1fb
2 changed files with 558 additions and 138 deletions
+143 -5
View File
@@ -237,6 +237,7 @@ ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5"))
RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0"))
@@ -3344,6 +3345,41 @@ def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=
return slots
def cancel_gate_tpsl_slot(exchange_symbol, slot):
if not slot or not exchange_symbol:
return
ensure_markets_loaded()
oid = slot.get("order_id")
if not oid:
return
params = _gate_swap_trigger_order_params()
exchange.cancel_order(str(oid), exchange_symbol, params)
def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data):
sltp_mode = (sltp_mode or "price").strip().lower()
if sltp_mode == "pct":
sl_pct = float(data.get("sl_pct") or 0)
tp_pct = float(data.get("tp_pct") or 0)
if sl_pct <= 0 or tp_pct <= 0:
raise ValueError("百分比止盈止损须为正数")
sl_ratio = sl_pct / 100.0
tp_ratio = tp_pct / 100.0
entry = float(live_price)
if direction == "short":
stop_loss = entry * (1 + sl_ratio)
take_profit = entry * (1 - tp_ratio)
else:
stop_loss = entry * (1 - sl_ratio)
take_profit = entry * (1 + tp_ratio)
else:
stop_loss = float(data.get("sl") or data.get("stop_loss") or 0)
take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0)
if stop_loss <= 0 or take_profit <= 0:
raise ValueError("止盈止损价格须大于 0")
return stop_loss, take_profit
def cancel_all_open_orders_for_symbol(exchange_symbol):
"""策略结束时:尽量撤掉该合约下条件单与普通挂单。"""
cancel_gate_swap_trigger_orders(exchange_symbol)
@@ -5537,6 +5573,7 @@ def render_main_page(page="trade"):
price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
can_trade=can_trade,
trend_plans=trend_plans,
preview_snapshots=preview_snapshots,
@@ -5642,8 +5679,15 @@ def api_account_snapshot():
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(get_recommended_capital(current_capital), 2)
active_count = get_active_position_count(conn)
trend_active = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
conn.close()
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
can_trade = (
trading_day_reset_allows_new_open(now)
and active_count < MAX_ACTIVE_POSITIONS
and int(trend_active or 0) == 0
)
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -5653,6 +5697,7 @@ def api_account_snapshot():
"active_count": active_count,
"max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -5821,6 +5866,100 @@ def api_price_snapshot():
})
@app.route("/api/order/<int:order_id>/cancel_tpsl", methods=["POST"])
@login_required
def api_order_cancel_tpsl(order_id):
data = request.get_json(silent=True) or {}
role = (data.get("role") or "").strip().lower()
if role not in ("sl", "tp"):
return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400
conn = get_db()
row = conn.execute(
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
(order_id,),
).fetchone()
conn.close()
if not row:
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
ok, reason = ensure_exchange_live_ready()
if not ok:
return jsonify({"ok": False, "msg": reason}), 400
ex_sym = resolve_monitor_exchange_symbol(row)
slots = fetch_exchange_tpsl_slots(
ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]
)
slot = slots.get(role)
if not slot:
return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404
try:
cancel_gate_tpsl_slot(ex_sym, slot)
slots = fetch_exchange_tpsl_slots(
ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]
)
return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots})
except Exception as e:
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
@app.route("/api/order/<int:order_id>/place_tpsl", methods=["POST"])
@login_required
def api_order_place_tpsl(order_id):
data = request.get_json(silent=True) or {}
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
symbol = row["symbol"]
direction = row["direction"]
live_price = get_price(symbol)
if live_price is None:
conn.close()
return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400
try:
sltp_mode = (data.get("sltp_mode") or "price").strip().lower()
stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data)
except Exception as e:
conn.close()
return jsonify({"ok": False, "msg": str(e)}), 400
planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR:
conn.close()
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
return jsonify(
{
"ok": False,
"msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1",
}
), 400
try:
replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit)
except Exception as e:
conn.close()
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
conn.execute(
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?",
(stop_loss, take_profit, order_id),
)
conn.commit()
ex_sym = resolve_monitor_exchange_symbol(row)
slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)
conn.close()
return jsonify(
{
"ok": True,
"msg": "已先撤后挂止盈止损",
"stop_loss": stop_loss,
"take_profit": take_profit,
"planned_rr": planned_rr,
"exchange_tpsl": slots,
}
)
@app.route("/api/symbol_liquidity_rank")
@login_required
def api_symbol_liquidity_rank():
@@ -6212,12 +6351,11 @@ def add_order():
conn.close()
flash("价格参数必须大于0")
return redirect("/")
_min_rr = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
if planned_rr_manual is None or planned_rr_manual < _min_rr:
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
conn.close()
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {_min_rr}:1")
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
return redirect("/")
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
if risk_fraction is None:
@@ -7684,7 +7822,7 @@ def _hub_meta_bundle():
"trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
"trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS,
"trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
"manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")),
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))),
}
+415 -133
View File
@@ -113,6 +113,8 @@
.table-wrap{overflow-x:auto}
.trade-dashboard{grid-column:1/-1;display:flex;flex-direction:column;gap:14px}
.trade-panels-row,.dual-panel-grid,.strategy-trading-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.strategy-trading-grid .card{min-height:320px;display:flex;flex-direction:column}
.strategy-trading-grid .panel-scroll{flex:1;overflow:auto;max-height:78vh}
.trade-panels-row > .card{min-height:0;height:100%;display:flex;flex-direction:column;box-sizing:border-box}
@@ -157,6 +159,7 @@
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
@media (min-width: 1440px){
.panel-scroll,.pos-list{max-height:420px}
.order-card .order-live-positions{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@@ -174,6 +177,7 @@
@media (max-width: 1100px){
.grid{grid-template-columns:1fr}
.trade-dashboard,.records-card,.review-card{grid-column:auto}
.dual-panel-grid{grid-template-columns:1fr}
.panel-list{grid-template-columns:1fr}
}
@media (max-width:1200px){
@@ -197,6 +201,52 @@
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
.key-history .list{max-height:200px}
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
.pos-meta-item{display:inline-flex;align-items:center}
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
.pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
.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}
.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}
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
.tpsl-modal .form-row{margin-bottom:10px}
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.pos-label{font-size:.72rem;color:#7d8799}
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
.pos-val-dash{opacity:.75;color:#8b95a8}
.pos-value.price-up{color:#4cd97f}
.pos-value.price-down{color:#ff6666}
.pos-value.price-flat{color:#e8ecf4}
.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}
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
.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.collapsed .stats-content{display:none}
@@ -214,7 +264,7 @@
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
</head>
<body>
<body data-page="{{ page }}">
{% macro period_metrics_cells(s) %}
<div class="stat-item"><div class="label">开单次数</div><div class="value">{{ s.opens_count }}</div></div>
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_count }}</div></div>
@@ -310,9 +360,8 @@
<div class="grid">
{% if page == 'trade' %}
<div class="trade-dashboard">
<div class="trade-panels-row">
<div class="card order-card">
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">机器人下单监控(单仓)</h2>
{% if focus_order_id %}
@@ -322,9 +371,9 @@
{% endif %}
</div>
<div class="rule-tip" id="order-rule-tip">
规则:最大同时持仓 {{ max_active_positions }}与 Gate 主站 MAX_ACTIVE_POSITIONS 一致,当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00{% endif %}
按风险比例自动计算仓位
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
<div class="rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
@@ -365,7 +414,9 @@
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
{% if position_sizing_mode != 'full_margin' %}
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
@@ -379,64 +430,120 @@
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
<div class="order-live-positions">
<h3 style="margin:0 0 2px;font-size:.95rem;color:#b8c4ff">实时持仓</h3>
<div class="running-plans-stack">
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
<div class="panel-scroll pos-list pos-list-live">
{% for o in order %}
{% set osym = o.exchange_symbol or o.symbol %}
<div class="plan-position-card">
<div class="plan-card-head">
<div class="plan-card-title">
<span>{{ osym }}</span>
<span class="badge {{ 'direction-long' if o.direction == 'long' else 'direction-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
<div class="pos-card" id="order-row-{{ o.id }}"
data-monitor-id="{{ o.id }}"
data-symbol="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
data-plan-tp="{% if o.take_profit %}{{ price_fmt(o.symbol, o.take_profit) }}{% endif %}"
data-entry="{% if o.trigger_price %}{{ price_fmt(o.symbol, o.trigger_price) }}{% endif %}">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ o.exchange_symbol or o.symbol }}</strong>
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div>
<a href="/del_order/{{ o.id }}" class="btn-close-plan" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
</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 %}
<span id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div>
<div class="plan-card-grid">
<div class="plan-cell">
<span class="lbl">成交价</span>
<span class="val">{{ price_fmt(osym, o.trigger_price) }}</span>
</div>
<div class="plan-cell">
<span class="lbl">止损</span>
<span class="val">{{ price_fmt(osym, o.stop_loss) }}</span>
</div>
<div class="plan-cell">
<span class="lbl">止盈</span>
<span class="val">{{ price_fmt(osym, o.take_profit) }}</span>
</div>
<div class="plan-cell">
<span class="lbl">盈亏比</span>
<span class="val"><span id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%.2f'|format(o.rr_ratio) }}:1{% else %}—{% endif %}</span></span>
</div>
<div class="plan-cell">
<span class="lbl">标记价</span>
<span class="val"><span id="order-price-{{ o.id }}">-</span></span>
</div>
<div class="plan-cell">
<span class="lbl">浮盈亏</span>
<span class="val"><span id="order-pnl-{{ o.id }}">-</span></span>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
</div>
</div>
<div class="plan-card-meta" style="margin-bottom:0">
保证金: <span id="order-ex-margin-{{ o.id }}">-</span>
计划基数: {{ money_fmt(o.margin_capital) }}U
杠杆: {{ o.leverage }}x
仓位占比: {{ o.position_ratio }}%
<div class="pos-meta">
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
<span class="pos-meta-item">风险: {{ o.risk_percent or '-' }}%≈{{ money_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U</span>
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div>
<div class="pos-grid">
<div class="pos-cell">
<span class="pos-label">成交价</span>
<span class="pos-value">{{ price_fmt(o.symbol, o.trigger_price) }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">止损</span>
{% if o.stop_loss %}
<span class="pos-value">{{ price_fmt(o.symbol, o.stop_loss) }}</span>
{% else %}
<span class="pos-value pos-val-dash"></span>
{% endif %}
</div>
<div class="pos-cell">
<span class="pos-label">止盈</span>
{% if o.take_profit %}
<span class="pos-value">{{ price_fmt(o.symbol, o.take_profit) }}</span>
{% else %}
<span class="pos-value pos-val-dash"></span>
{% endif %}
</div>
<div class="pos-cell">
<span class="pos-label">盈亏比</span>
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
</div>
<div class="pos-cell">
<span class="pos-label">标记价</span>
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
</div>
<div class="pos-cell">
<span class="pos-label">浮盈亏</span>
<span class="pos-value" id="order-pnl-{{ o.id }}">-</span>
</div>
</div>
<div class="pos-footer">
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
<span>计划基数: {{ money_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
<span>杠杆: {{ o.leverage or '-' }}x</span>
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
</div>
</div>
</div>
{% else %}
<div class="plan-position-card" style="color:#8892b0;text-align:center;padding:16px">暂无机器人持仓</div>
<div class="pos-empty">暂无持仓</div>
{% endfor %}
</div>
</div>
<div id="tpsl-modal" class="tpsl-modal-backdrop" onclick="if(event.target===this)closeTpslEntrustModal()">
<div class="tpsl-modal" onclick="event.stopPropagation()">
<h3 id="tpsl-modal-title">挂止盈止损</h3>
<p style="font-size:.78rem;color:#8892b0;margin:0 0 10px">将先撤销该合约已有 TP/SL,再按下列价格重挂。</p>
<div class="form-row">
<select id="tpsl-modal-mode" onchange="toggleTpslModalMode()">
<option value="price">价格模式</option>
<option value="pct">百分比模式</option>
</select>
</div>
<div class="form-row">
<input id="tpsl-modal-sl" step="any" placeholder="止损价格">
<input id="tpsl-modal-tp" step="any" placeholder="止盈价格">
</div>
<div class="form-row">
<input id="tpsl-modal-sl-pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="tpsl-modal-tp-pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
</div>
<div class="tpsl-modal-actions">
<button type="button" class="tpsl-modal-cancel" onclick="closeTpslEntrustModal()">取消</button>
<button type="button" class="tpsl-modal-submit" onclick="submitTpslEntrust()">先撤后挂</button>
</div>
</div>
</div>
</div>
</div>
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% set can_trade_trend = can_trade %}
@@ -1462,20 +1569,6 @@ if(keyForm){
});
});
}
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
if(addOrderForm.dataset.submitOnce === "1"){
addOrderForm.dataset.submitOnce = "0";
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
addOrderForm.dataset.submitOnce = "1";
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(addOrderForm, "开仓提交中…");
else addOrderForm.submit();
});
}
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
setTimeout(() => {
if(document.getElementById("journal-list")) loadJournals();
@@ -1484,6 +1577,132 @@ setTimeout(() => {
let latestAvailableUsdt = null;
const lastPriceMap = {};
const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }};
function calcClientRr(direction, entry, sl, tp){
const e = Number(entry), s = Number(sl), t = Number(tp);
if(!Number.isFinite(e) || !Number.isFinite(s) || !Number.isFinite(t)) return null;
if(direction === 'short'){
if(s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if(s >= e || t <= e) return null;
return (t - e) / (e - s);
}
function calcClientRrFromPct(slPct, tpPct){
const sl = Number(slPct), tp = Number(tpPct);
if(!Number.isFinite(sl) || !Number.isFinite(tp) || sl <= 0 || tp <= 0) return null;
return tp / sl;
}
function rejectManualOrderRr(rr){
if(rr !== null && rr >= MANUAL_MIN_PLANNED_RR) return false;
alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`);
return true;
}
let tpslEntrustMonitorId = null;
function formatExTpslLine(role, slot){
const label = role === 'sl' ? '止损' : '止盈';
if(!slot || !slot.order_id) return label + ':未挂单';
const px = slot.trigger_display || slot.trigger_price || '-';
const amt = slot.amount != null && !Number.isNaN(Number(slot.amount)) ? ` 数量 ${Number(slot.amount)}` : '';
return `${label}:触发 ${px}${amt}`;
}
function paintExchangeTpslRow(orderId, tpsl){
const data = tpsl || {};
const slText = document.getElementById(`ex-sl-text-${orderId}`);
const tpText = document.getElementById(`ex-tp-text-${orderId}`);
const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`);
const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`);
if(slText) slText.innerText = formatExTpslLine('sl', data.sl);
if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp);
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
}
function toggleTpslModalMode(){
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
const pct = mode === 'pct';
['tpsl-modal-sl','tpsl-modal-tp'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'none':''; });
['tpsl-modal-sl-pct','tpsl-modal-tp-pct'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display=pct?'':'none'; });
}
function openTpslEntrustModal(orderId){
const card = document.getElementById(`order-row-${orderId}`);
if(!card) return;
tpslEntrustMonitorId = orderId;
const slEl = document.getElementById('tpsl-modal-sl');
const tpEl = document.getElementById('tpsl-modal-tp');
if(slEl) slEl.value = formatPriceForInput(card.getAttribute('data-plan-sl') || '');
if(tpEl) tpEl.value = formatPriceForInput(card.getAttribute('data-plan-tp') || '');
const modeEl = document.getElementById('tpsl-modal-mode');
if(modeEl) modeEl.value = 'price';
toggleTpslModalMode();
const title = document.getElementById('tpsl-modal-title');
if(title) title.innerText = `挂止盈止损 · ${card.getAttribute('data-symbol')||''}`;
const modal = document.getElementById('tpsl-modal');
if(modal) modal.classList.add('open');
}
function closeTpslEntrustModal(){
tpslEntrustMonitorId = null;
const modal = document.getElementById('tpsl-modal');
if(modal) modal.classList.remove('open');
}
function submitTpslEntrust(){
const orderId = tpslEntrustMonitorId;
if(!orderId) return;
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
const body = { sltp_mode: mode };
if(mode === 'pct'){
body.sl_pct = Number((document.getElementById('tpsl-modal-sl-pct')||{}).value);
body.tp_pct = Number((document.getElementById('tpsl-modal-tp-pct')||{}).value);
if(rejectManualOrderRr(calcClientRrFromPct(body.sl_pct, body.tp_pct))) return;
}else{
body.sl = (document.getElementById('tpsl-modal-sl')||{}).value;
body.tp = (document.getElementById('tpsl-modal-tp')||{}).value;
}
const card = document.getElementById(`order-row-${orderId}`);
const direction = (card && card.getAttribute('data-direction')) || 'long';
const post = ()=>{
fetch(`/api/order/${orderId}/place_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
.then(r=>r.json()).then(data=>{
if(!data.ok){ alert(data.msg || '委托失败'); return; }
alert(data.msg || '已提交');
closeTpslEntrustModal();
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
refreshPriceSnapshotConditional();
}).catch(()=>alert('委托请求失败'));
};
if(mode === 'pct'){ post(); return; }
const sl = Number(body.sl), tp = Number(body.tp);
let entry = sl;
const sym = (card && card.getAttribute('data-symbol')) || '';
if(!sym){ if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return; post(); return; }
fetch(`/api/order_defaults?symbol=${encodeURIComponent(sym)}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json()).then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
post();
}).catch(()=>alert('无法校验盈亏比'));
}
function cancelExchangeTpsl(orderId, role){
const label = role === 'sl' ? '止损' : '止盈';
if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return;
fetch(`/api/order/${orderId}/cancel_tpsl`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role }) })
.then(r=>r.json()).then(data=>{
if(!data.ok){ alert(data.msg || '撤单失败'); return; }
if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl);
else refreshPriceSnapshotConditional();
}).catch(()=>alert('撤单请求失败'));
}
function allowManualOrderSubmit(form){
form.dataset.rrOk = "1";
if(window.FormSubmitGuard){
if(FormSubmitGuard.isLocked(form)){
FormSubmitGuard.setSubmitLabel(form, "开仓提交中…");
} else {
FormSubmitGuard.lock(form, "开仓提交中…");
}
}
form.submit();
}
function formatSigned(v, digits=4){
if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
@@ -1491,6 +1710,13 @@ function formatSigned(v, digits=4){
const sign = n > 0 ? "+" : "";
return `${sign}${n.toFixed(digits)}`;
}
function formatRrRatio(rr){
if(rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if(Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return `${body}:1`;
}
function paintPriceTrend(el, key, value){
if(!el) return;
@@ -1512,71 +1738,71 @@ function paintBreakevenBadge(orderId, secured){
wrap.style.display = secured ? "inline-flex" : "none";
}
function refreshPriceSnapshot(){
function refreshPriceSnapshotConditional(){
const page = document.body.getAttribute("data-page") || "";
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
const updatedEl = document.getElementById("price-last-updated");
if(data.updated_at && updatedEl){
updatedEl.innerText = data.updated_at;
}
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){
pEl.innerText = Number(k.price).toFixed(6);
paintPriceTrend(pEl, `k-${k.id}`, Number(k.price));
}
const upEl = document.getElementById(`key-up-diff-${k.id}`);
if(upEl){
upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
}
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
if(lowEl){
lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
}
const gateEl = document.getElementById(`key-gate-${k.id}`);
if(gateEl){
gateEl.innerText = k.gate_summary || "-";
gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f";
}
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
if(gateMetricEl){
gateMetricEl.innerText = k.gate_metrics || "";
}
});
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
const decimals = hasMark ? 8 : 6;
pEl.innerText = px.toFixed(decimals);
paintPriceTrend(pEl, `o-${o.id}`, px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)){
exM.innerText = `${mn.toFixed(2)}U`;
} else {
const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null;
exM.innerText = (prc === 0) ? "无仓数据" : "-";
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
if(page === "key_monitor"){
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){
pEl.innerText = Number(k.price).toFixed(6);
paintPriceTrend(pEl, `k-${k.id}`, Number(k.price));
}
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("pnl-profit","pnl-loss","pnl-neutral");
const fp = Number(o.float_pnl);
if(fp > 0) pnlEl.classList.add("pnl-profit");
else if(fp < 0) pnlEl.classList.add("pnl-loss");
else pnlEl.classList.add("pnl-neutral");
}
const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl){
rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `${Number(o.rr_ratio).toFixed(2)}:1` : "-";
}
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
});
const upEl = document.getElementById(`key-up-diff-${k.id}`);
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
const gateEl = document.getElementById(`key-gate-${k.id}`);
if(gateEl){
gateEl.innerText = k.gate_summary || "-";
gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f";
}
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
});
}
if(page === "trade"){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
let disp = "";
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
else if(o.price_display) disp = o.price_display;
else {
const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
disp = Number.isFinite(px) ? px.toFixed(hasMark ? 8 : 6) : "-";
}
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
else {
const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null;
exM.innerText = (prc === 0) ? "无仓数据" : "-";
}
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat","pnl-profit","pnl-loss","pnl-neutral");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
else pnlEl.classList.add("price-flat");
}
const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
});
}
}).catch(()=>{});
}
@@ -1620,11 +1846,12 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
const canTradeText = data.can_trade ? "可开仓" : "不可开仓(持仓或未到北京时间 {{ reset_hour }}:00";
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}}、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00`;
const tip = document.getElementById("order-rule-tip");
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
const minRr = data.manual_min_planned_rr != null ? data.manual_min_planned_rr : MANUAL_MIN_PLANNED_RR;
if(tip){
tip.innerText = `规则:单仓BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
tip.innerText = `规则:最大同时持仓 ${data.max_active_positions || {{ max_active_positions }}}(当前 active ${data.active_count||0});与「趋势回调」计划互斥BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail};人工开仓盈亏比不得低于 ${minRr}:1`;
}
}).catch(()=>{});
}
@@ -1676,10 +1903,65 @@ if(_journalFormEl){
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
syncJournalEntryReasonOtherUi();
}
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
if(addOrderForm.dataset.rrOk === "1"){
addOrderForm.dataset.rrOk = "0";
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim();
if(mode === "pct"){
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
const rr = calcClientRrFromPct(
(document.getElementById("order-sl-pct")||{}).value,
(document.getElementById("order-tp-pct")||{}).value
);
if(rejectManualOrderRr(rr)){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
const sl = Number((document.getElementById("order-sl")||{}).value);
const tp = Number((document.getElementById("order-tp")||{}).value);
let entry = sl;
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
if(!symbol){
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
fetch(`/api/order_defaults?symbol=${encodeURIComponent(symbol)}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json())
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
})
.catch(()=>{
alert("无法校验盈亏比,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
});
});
}
refreshOrderDefaults();
refreshPriceSnapshot();
refreshPriceSnapshotConditional();
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
</script>
</body>
</html>