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:
@@ -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"))
|
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"))
|
DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5"))
|
||||||
RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2"))
|
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_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
|
||||||
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
|
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
|
||||||
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0"))
|
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
|
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):
|
def cancel_all_open_orders_for_symbol(exchange_symbol):
|
||||||
"""策略结束时:尽量撤掉该合约下条件单与普通挂单。"""
|
"""策略结束时:尽量撤掉该合约下条件单与普通挂单。"""
|
||||||
cancel_gate_swap_trigger_orders(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,
|
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||||||
active_count=active_count,
|
active_count=active_count,
|
||||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||||
|
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||||
can_trade=can_trade,
|
can_trade=can_trade,
|
||||||
trend_plans=trend_plans,
|
trend_plans=trend_plans,
|
||||||
preview_snapshots=preview_snapshots,
|
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)
|
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)
|
recommended_capital = round(get_recommended_capital(current_capital), 2)
|
||||||
active_count = get_active_position_count(conn)
|
active_count = get_active_position_count(conn)
|
||||||
|
trend_active = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
|
||||||
|
).fetchone()[0]
|
||||||
conn.close()
|
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()
|
available_trading_usdt = get_available_trading_usdt()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"funding_usdt": funding_usdt,
|
"funding_usdt": funding_usdt,
|
||||||
@@ -5653,6 +5697,7 @@ def api_account_snapshot():
|
|||||||
"active_count": active_count,
|
"active_count": active_count,
|
||||||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||||
"can_trade": can_trade,
|
"can_trade": can_trade,
|
||||||
|
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||||
"trading_day": trading_day
|
"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")
|
@app.route("/api/symbol_liquidity_rank")
|
||||||
@login_required
|
@login_required
|
||||||
def api_symbol_liquidity_rank():
|
def api_symbol_liquidity_rank():
|
||||||
@@ -6212,12 +6351,11 @@ def add_order():
|
|||||||
conn.close()
|
conn.close()
|
||||||
flash("价格参数必须大于0")
|
flash("价格参数必须大于0")
|
||||||
return redirect("/")
|
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)
|
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()
|
conn.close()
|
||||||
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
|
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("/")
|
return redirect("/")
|
||||||
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
||||||
if risk_fraction is None:
|
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_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT,
|
||||||
"trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS,
|
"trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS,
|
||||||
"trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT,
|
"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"))),
|
"max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,8 @@
|
|||||||
.table-wrap{overflow-x:auto}
|
.table-wrap{overflow-x:auto}
|
||||||
.trade-dashboard{grid-column:1/-1;display:flex;flex-direction:column;gap:14px}
|
.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}
|
.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 .card{min-height:320px;display:flex;flex-direction:column}
|
||||||
.strategy-trading-grid .panel-scroll{flex:1;overflow:auto;max-height:78vh}
|
.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}
|
.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 .panel-item{max-height:none;height:auto;min-height:280px}
|
||||||
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
|
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
|
||||||
@media (min-width: 1440px){
|
@media (min-width: 1440px){
|
||||||
|
.panel-scroll,.pos-list{max-height:420px}
|
||||||
.order-card .order-live-positions{max-height:420px}
|
.order-card .order-live-positions{max-height:420px}
|
||||||
.records-card .table-wrap{max-height:620px;overflow:auto}
|
.records-card .table-wrap{max-height:620px;overflow:auto}
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,7 @@
|
|||||||
@media (max-width: 1100px){
|
@media (max-width: 1100px){
|
||||||
.grid{grid-template-columns:1fr}
|
.grid{grid-template-columns:1fr}
|
||||||
.trade-dashboard,.records-card,.review-card{grid-column:auto}
|
.trade-dashboard,.records-card,.review-card{grid-column:auto}
|
||||||
|
.dual-panel-grid{grid-template-columns:1fr}
|
||||||
.panel-list{grid-template-columns:1fr}
|
.panel-list{grid-template-columns:1fr}
|
||||||
}
|
}
|
||||||
@media (max-width:1200px){
|
@media (max-width:1200px){
|
||||||
@@ -197,6 +201,52 @@
|
|||||||
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
|
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
|
||||||
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
|
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
|
||||||
.key-history .list{max-height:200px}
|
.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{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}
|
||||||
.stats-card.collapsed .stats-content{display:none}
|
.stats-card.collapsed .stats-content{display:none}
|
||||||
@@ -214,7 +264,7 @@
|
|||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-page="{{ page }}">
|
||||||
{% macro period_metrics_cells(s) %}
|
{% 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.opens_count }}</div></div>
|
||||||
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_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">
|
<div class="grid">
|
||||||
{% if page == 'trade' %}
|
{% if page == 'trade' %}
|
||||||
<div class="trade-dashboard">
|
<div class="dual-panel-grid" style="grid-column:1/-1">
|
||||||
<div class="trade-panels-row">
|
<div class="card">
|
||||||
<div class="card order-card">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
<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>
|
<h2 style="margin-bottom:0">机器人下单监控(单仓)</h2>
|
||||||
{% if focus_order_id %}
|
{% if focus_order_id %}
|
||||||
@@ -322,9 +371,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip" id="order-rule-tip">
|
<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 %};
|
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
|
||||||
按风险比例自动计算仓位
|
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-tip">
|
<div class="rule-tip">
|
||||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||||
@@ -365,7 +414,9 @@
|
|||||||
<option value="trend">趋势单</option>
|
<option value="trend">趋势单</option>
|
||||||
<option value="swing">波段单</option>
|
<option value="swing">波段单</option>
|
||||||
</select>
|
</select>
|
||||||
|
{% if position_sizing_mode != 'full_margin' %}
|
||||||
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
|
<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">
|
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
|
||||||
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
|
||||||
</label>
|
</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">
|
<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>
|
<button type="submit">{{ open_position_button_label }}</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="order-live-positions">
|
</div>
|
||||||
<h3 style="margin:0 0 2px;font-size:.95rem;color:#b8c4ff">实时持仓</h3>
|
<div class="card">
|
||||||
<div class="running-plans-stack">
|
<h2 style="margin-bottom:8px">实时持仓</h2>
|
||||||
|
<div class="panel-scroll pos-list pos-list-live">
|
||||||
{% for o in order %}
|
{% for o in order %}
|
||||||
{% set osym = o.exchange_symbol or o.symbol %}
|
<div class="pos-card" id="order-row-{{ o.id }}"
|
||||||
<div class="plan-position-card">
|
data-monitor-id="{{ o.id }}"
|
||||||
<div class="plan-card-head">
|
data-symbol="{{ o.symbol }}"
|
||||||
<div class="plan-card-title">
|
data-direction="{{ o.direction }}"
|
||||||
<span>{{ osym }}</span>
|
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
|
||||||
<span class="badge {{ 'direction-long' if o.direction == 'long' else 'direction-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
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>
|
</div>
|
||||||
<a href="/del_order/{{ o.id }}" class="btn-close-plan" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
<div class="pos-head-actions">
|
||||||
</div>
|
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
|
||||||
<div class="plan-card-meta">
|
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
|
||||||
来源: 下单监控 | 风格: {{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-card-meta" style="margin-bottom:0">
|
<div class="pos-meta">
|
||||||
保证金: <span id="order-ex-margin-{{ o.id }}">-</span>
|
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
|
||||||
| 计划基数: {{ money_fmt(o.margin_capital) }}U
|
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
|
||||||
| 杠杆: {{ o.leverage }}x
|
<span class="pos-meta-item">风险: {{ o.risk_percent or '-' }}%≈{{ money_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U</span>
|
||||||
| 仓位占比: {{ o.position_ratio }}%
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="plan-position-card" style="color:#8892b0;text-align:center;padding:16px">暂无机器人持仓</div>
|
<div class="pos-empty">暂无持仓</div>
|
||||||
{% endfor %}
|
{% 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>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
|
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
|
||||||
{% set can_trade_trend = can_trade %}
|
{% 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/重定向后仍显示旧缓存
|
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if(document.getElementById("journal-list")) loadJournals();
|
if(document.getElementById("journal-list")) loadJournals();
|
||||||
@@ -1484,6 +1577,132 @@ setTimeout(() => {
|
|||||||
|
|
||||||
let latestAvailableUsdt = null;
|
let latestAvailableUsdt = null;
|
||||||
const lastPriceMap = {};
|
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){
|
function formatSigned(v, digits=4){
|
||||||
if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
|
if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
|
||||||
@@ -1491,6 +1710,13 @@ function formatSigned(v, digits=4){
|
|||||||
const sign = n > 0 ? "+" : "";
|
const sign = n > 0 ? "+" : "";
|
||||||
return `${sign}${n.toFixed(digits)}`;
|
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){
|
function paintPriceTrend(el, key, value){
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -1512,71 +1738,71 @@ function paintBreakevenBadge(orderId, secured){
|
|||||||
wrap.style.display = secured ? "inline-flex" : "none";
|
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=>{
|
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||||
const updatedEl = document.getElementById("price-last-updated");
|
const updatedEl = document.getElementById("price-last-updated");
|
||||||
if(data.updated_at && updatedEl){
|
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||||
updatedEl.innerText = data.updated_at;
|
if(page === "key_monitor"){
|
||||||
}
|
(data.key_prices || []).forEach(k=>{
|
||||||
(data.key_prices || []).forEach(k=>{
|
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
if(pEl){
|
||||||
if(pEl){
|
pEl.innerText = Number(k.price).toFixed(6);
|
||||||
pEl.innerText = Number(k.price).toFixed(6);
|
paintPriceTrend(pEl, `k-${k.id}`, Number(k.price));
|
||||||
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) ? "无仓数据" : "-";
|
|
||||||
}
|
}
|
||||||
}
|
const upEl = document.getElementById(`key-up-diff-${k.id}`);
|
||||||
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
|
if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`;
|
||||||
if(pnlEl){
|
const lowEl = document.getElementById(`key-low-diff-${k.id}`);
|
||||||
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
|
if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`;
|
||||||
pnlEl.classList.remove("pnl-profit","pnl-loss","pnl-neutral");
|
const gateEl = document.getElementById(`key-gate-${k.id}`);
|
||||||
const fp = Number(o.float_pnl);
|
if(gateEl){
|
||||||
if(fp > 0) pnlEl.classList.add("pnl-profit");
|
gateEl.innerText = k.gate_summary || "-";
|
||||||
else if(fp < 0) pnlEl.classList.add("pnl-loss");
|
gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f";
|
||||||
else pnlEl.classList.add("pnl-neutral");
|
}
|
||||||
}
|
const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`);
|
||||||
const rrEl = document.getElementById(`order-rr-${o.id}`);
|
if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || "";
|
||||||
if(rrEl){
|
});
|
||||||
rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `${Number(o.rr_ratio).toFixed(2)}:1` : "-";
|
}
|
||||||
}
|
if(page === "trade"){
|
||||||
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
|
(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(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1620,11 +1846,12 @@ function refreshAccountSnapshot(){
|
|||||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
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 tip = document.getElementById("order-rule-tip");
|
||||||
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
|
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){
|
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(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
@@ -1676,10 +1903,65 @@ if(_journalFormEl){
|
|||||||
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi);
|
||||||
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();
|
refreshOrderDefaults();
|
||||||
refreshPriceSnapshot();
|
refreshPriceSnapshotConditional();
|
||||||
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});
|
||||||
setInterval(refreshPriceSnapshot, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user