feat: 持仓快照盈亏比与交易所止损已保本标识

盈亏比固定用开仓 initial_stop_loss 计算,人工改委托后不变化;轮询交易所止损触发价相对成交价判定已保本,四所实例与中控统一显示绿色标识。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 16:31:03 +08:00
parent e265c1b31a
commit cf3e2ee1c9
13 changed files with 486 additions and 52 deletions
+19 -12
View File
@@ -93,6 +93,10 @@ from key_monitor_lib import (
rs_break_from_direction, rs_break_from_direction,
run_rs_level_alert_tick, run_rs_level_alert_tick,
) )
from order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed from hub_auth import request_allowed as hub_request_allowed
from history_window_lib import ( from history_window_lib import (
@@ -2544,12 +2548,7 @@ def enrich_order_item(raw_item, current_capital):
ratio = round(margin / current_capital * 100, 2) if current_capital else 0 ratio = round(margin / current_capital * 100, 2) if current_capital else 0
item["notional_value"] = notional item["notional_value"] = notional
item["position_ratio"] = ratio item["position_ratio"] = ratio
item["rr_ratio"] = calc_rr_ratio( enrich_order_display_fields(item, calc_rr_ratio)
item.get("direction") or "long",
item.get("trigger_price"),
item.get("initial_stop_loss") or item.get("stop_loss"),
item.get("take_profit"),
)
try: try:
be = item.get("breakeven_enabled") be = item.get("breakeven_enabled")
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
@@ -6132,7 +6131,7 @@ def api_price_snapshot():
entry = float(r["trigger_price"] or 0) entry = float(r["trigger_price"] or 0)
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0 pnl_pct = round((pnl / margin * 100), 2) if margin > 0 else 0
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) exchange_tpsl = {"sl": None, "tp": None}
ex_sym = resolve_monitor_exchange_symbol(r) ex_sym = resolve_monitor_exchange_symbol(r)
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
lev_row = r["leverage"] if "leverage" in r.keys() else None lev_row = r["leverage"] if "leverage" in r.keys() else None
@@ -6144,7 +6143,6 @@ def api_price_snapshot():
"price_display": format_price_for_symbol(ex_sym, price), "price_display": format_price_for_symbol(ex_sym, price),
"float_pnl": round(pnl, FUNDS_DECIMALS), "float_pnl": round(pnl, FUNDS_DECIMALS),
"float_pct": pnl_pct, "float_pct": pnl_pct,
"rr_ratio": rr_ratio,
"plan_margin": round(margin, FUNDS_DECIMALS) if margin else None, "plan_margin": round(margin, FUNDS_DECIMALS) if margin else None,
"exchange_initial_margin": None, "exchange_initial_margin": None,
"exchange_notional": None, "exchange_notional": None,
@@ -6170,11 +6168,20 @@ def api_price_snapshot():
) )
if exchange_private_api_configured(): if exchange_private_api_configured():
try: try:
payload["exchange_tpsl"] = fetch_exchange_tpsl_slots(ex_sym, r["direction"]) exchange_tpsl = fetch_exchange_tpsl_slots(ex_sym, r["direction"])
except Exception: except Exception:
payload["exchange_tpsl"] = {"sl": None, "tp": None} exchange_tpsl = {"sl": None, "tp": None}
else: payload["exchange_tpsl"] = exchange_tpsl
payload["exchange_tpsl"] = {"sl": None, "tp": None} apply_order_price_display_fields(
payload,
direction=r["direction"],
entry_price=entry,
initial_stop_loss=r["initial_stop_loss"],
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
calc_rr_ratio_fn=calc_rr_ratio,
exchange_tpsl=exchange_tpsl,
)
order_prices.append(payload) order_prices.append(payload)
return jsonify({ return jsonify({
@@ -178,6 +178,7 @@
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff} .pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799} .pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} .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-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-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
@@ -491,6 +492,7 @@
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}"> <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 %} {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span> </span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div> </div>
<div class="pos-grid"> <div class="pos-grid">
<div class="pos-cell"> <div class="pos-cell">
@@ -1781,6 +1783,12 @@ function formatRrRatio(rr){
return `${body}:1`; return `${body}:1`;
} }
function paintBreakevenBadge(orderId, secured){
const wrap = document.getElementById(`order-be-wrap-${orderId}`);
if(!wrap) return;
wrap.style.display = secured ? "inline-flex" : "none";
}
function paintPriceTrend(el, key, value){ function paintPriceTrend(el, key, value){
if(!el) return; if(!el) return;
const prev = lastPriceMap[key]; const prev = lastPriceMap[key];
@@ -1865,6 +1873,7 @@ function refreshPriceSnapshot(){
if(rrEl){ if(rrEl){
rrEl.innerText = formatRrRatio(o.rr_ratio); rrEl.innerText = formatRrRatio(o.rr_ratio);
} }
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
}); });
}).catch(()=>{}); }).catch(()=>{});
@@ -2074,6 +2083,7 @@ function refreshPriceSnapshotConditional(){
} }
const rrEl = document.getElementById(`order-rr-${o.id}`); const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
}); });
} }
+19 -12
View File
@@ -94,6 +94,10 @@ from key_monitor_lib import (
rs_break_from_direction, rs_break_from_direction,
run_rs_level_alert_tick, run_rs_level_alert_tick,
) )
from order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed from hub_auth import request_allowed as hub_request_allowed
from history_window_lib import ( from history_window_lib import (
@@ -2270,12 +2274,7 @@ def enrich_order_item(raw_item, current_capital):
ratio = round(margin / current_capital * 100, 2) if current_capital else 0 ratio = round(margin / current_capital * 100, 2) if current_capital else 0
item["notional_value"] = notional item["notional_value"] = notional
item["position_ratio"] = ratio item["position_ratio"] = ratio
item["rr_ratio"] = calc_rr_ratio( enrich_order_display_fields(item, calc_rr_ratio)
item.get("direction") or "long",
item.get("trigger_price"),
item.get("initial_stop_loss") or item.get("stop_loss"),
item.get("take_profit"),
)
try: try:
be = item.get("breakeven_enabled") be = item.get("breakeven_enabled")
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
@@ -6253,7 +6252,7 @@ def api_price_snapshot():
entry = float(r["trigger_price"] or 0) entry = float(r["trigger_price"] or 0)
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) exchange_tpsl = {"sl": None, "tp": None}
ex_sym = resolve_monitor_exchange_symbol(r) ex_sym = resolve_monitor_exchange_symbol(r)
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
lev_row = r["leverage"] if "leverage" in r.keys() else None lev_row = r["leverage"] if "leverage" in r.keys() else None
@@ -6263,7 +6262,6 @@ def api_price_snapshot():
"symbol": r["symbol"], "symbol": r["symbol"],
"float_pnl": round(pnl, 2), "float_pnl": round(pnl, 2),
"float_pct": pnl_pct, "float_pct": pnl_pct,
"rr_ratio": rr_ratio,
"plan_margin": round(margin, 2) if margin else None, "plan_margin": round(margin, 2) if margin else None,
"exchange_initial_margin": None, "exchange_initial_margin": None,
"exchange_notional": None, "exchange_notional": None,
@@ -6298,16 +6296,25 @@ def api_price_snapshot():
payload["price_display"] = px_disp payload["price_display"] = px_disp
if exchange_private_api_configured(): if exchange_private_api_configured():
try: try:
payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( exchange_tpsl = fetch_exchange_tpsl_slots(
ex_sym, ex_sym,
r["direction"], r["direction"],
plan_sl=r["stop_loss"], plan_sl=r["stop_loss"],
plan_tp=r["take_profit"], plan_tp=r["take_profit"],
) )
except Exception: except Exception:
payload["exchange_tpsl"] = {"sl": None, "tp": None} exchange_tpsl = {"sl": None, "tp": None}
else: payload["exchange_tpsl"] = exchange_tpsl
payload["exchange_tpsl"] = {"sl": None, "tp": None} apply_order_price_display_fields(
payload,
direction=r["direction"],
entry_price=entry,
initial_stop_loss=r["initial_stop_loss"],
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
calc_rr_ratio_fn=calc_rr_ratio,
exchange_tpsl=exchange_tpsl,
)
order_prices.append(payload) order_prices.append(payload)
return jsonify({ return jsonify({
+10
View File
@@ -178,6 +178,7 @@
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff} .pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799} .pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} .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-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-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
@@ -491,6 +492,7 @@
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}"> <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 %} {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span> </span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div> </div>
<div class="pos-grid"> <div class="pos-grid">
<div class="pos-cell"> <div class="pos-cell">
@@ -1781,6 +1783,12 @@ function formatRrRatio(rr){
return `${body}:1`; return `${body}:1`;
} }
function paintBreakevenBadge(orderId, secured){
const wrap = document.getElementById(`order-be-wrap-${orderId}`);
if(!wrap) return;
wrap.style.display = secured ? "inline-flex" : "none";
}
function paintPriceTrend(el, key, value){ function paintPriceTrend(el, key, value){
if(!el) return; if(!el) return;
const prev = lastPriceMap[key]; const prev = lastPriceMap[key];
@@ -1865,6 +1873,7 @@ function refreshPriceSnapshot(){
if(rrEl){ if(rrEl){
rrEl.innerText = formatRrRatio(o.rr_ratio); rrEl.innerText = formatRrRatio(o.rr_ratio);
} }
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
}); });
}).catch(()=>{}); }).catch(()=>{});
@@ -2074,6 +2083,7 @@ function refreshPriceSnapshotConditional(){
} }
const rrEl = document.getElementById(`order-rr-${o.id}`); const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
}); });
} }
+164 -8
View File
@@ -37,6 +37,10 @@ if _REPO_ROOT not in sys.path:
from ai_client import ai_generate, ai_review, ai_short_advice from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
)
from journal_chart_lib import ( from journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF1,
@@ -2299,12 +2303,7 @@ def enrich_order_item(raw_item, current_capital):
ratio = round(margin / current_capital * 100, 2) if current_capital else 0 ratio = round(margin / current_capital * 100, 2) if current_capital else 0
item["notional_value"] = notional item["notional_value"] = notional
item["position_ratio"] = ratio item["position_ratio"] = ratio
item["rr_ratio"] = calc_rr_ratio( enrich_order_display_fields(item, calc_rr_ratio)
item.get("direction") or "long",
item.get("trigger_price"),
item.get("initial_stop_loss") or item.get("stop_loss"),
item.get("take_profit"),
)
try: try:
be = item.get("breakeven_enabled") be = item.get("breakeven_enabled")
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
@@ -3218,6 +3217,143 @@ def cancel_gate_swap_trigger_orders(exchange_symbol):
pass pass
def _gate_list_trigger_open_orders(exchange_symbol):
params = _gate_swap_trigger_order_params()
try:
return exchange.fetch_open_orders(exchange_symbol, params=params) or []
except Exception:
return []
def _gate_order_trigger_price(order):
for key in ("stopPrice", "triggerPrice", "price"):
try:
v = float(order.get(key) or 0)
if v > 0:
return v
except Exception:
pass
info = order.get("info") or {}
if isinstance(info, dict):
trig = info.get("trigger")
if isinstance(trig, dict):
try:
v = float(trig.get("price") or 0)
if v > 0:
return v
except Exception:
pass
for key in ("trigger_price", "triggerPrice", "stopPrice", "price"):
try:
v = float(info.get(key) or 0)
if v > 0:
return v
except Exception:
pass
return None
def _gate_tpsl_role_from_order(order, direction):
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower()
if "take" in ot and "profit" in ot:
return "tp"
if "stop" in ot and "loss" in ot:
return "sl"
trig = info.get("trigger")
rule = None
if isinstance(trig, dict) and trig.get("rule") is not None:
try:
rule = int(trig["rule"])
except Exception:
rule = None
if rule is None:
try:
rule = int(info.get("rule"))
except Exception:
rule = None
if rule is not None:
if direction == "long":
return "sl" if rule == 2 else ("tp" if rule == 1 else None)
return "sl" if rule == 1 else ("tp" if rule == 2 else None)
if order.get("stopLossPrice"):
return "sl"
if order.get("takeProfitPrice"):
return "tp"
typ = str(order.get("type") or "").upper()
if "TAKE" in typ:
return "tp"
if "STOP" in typ:
return "sl"
return None
def _gate_tpsl_slot_from_order(order, exchange_symbol):
trig = _gate_order_trigger_price(order)
try:
amt = float(order.get("amount") or order.get("remaining") or 0)
except Exception:
amt = None
if amt is not None and amt <= 0:
amt = None
oid = order.get("id")
if oid is None and isinstance(order.get("info"), dict):
oid = order["info"].get("id") or order["info"].get("order_id")
disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-"
return {
"order_id": str(oid) if oid is not None else "",
"channel": "gate_trigger",
"trigger_price": trig,
"trigger_display": disp,
"amount": amt,
"type": str(order.get("type") or ""),
}
def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None):
slots = {"sl": None, "tp": None}
if not exchange_symbol:
return slots
ok, _ = ensure_exchange_live_ready()
if not ok:
return slots
try:
ensure_markets_loaded()
ambiguous = []
for order in _gate_list_trigger_open_orders(exchange_symbol):
role = _gate_tpsl_role_from_order(order, direction)
slot = _gate_tpsl_slot_from_order(order, exchange_symbol)
if role in ("sl", "tp"):
if slots[role] is None:
slots[role] = slot
continue
ambiguous.append(slot)
for slot in ambiguous:
trig = slot.get("trigger_price")
if trig is None:
continue
try:
plan_sl_f = float(plan_sl) if plan_sl is not None else None
plan_tp_f = float(plan_tp) if plan_tp is not None else None
except Exception:
plan_sl_f = plan_tp_f = None
if plan_sl_f is not None and plan_tp_f is not None:
role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp"
elif plan_sl_f is not None:
role = "sl"
elif plan_tp_f is not None:
role = "tp"
else:
continue
if slots[role] is None:
slots[role] = slot
except Exception:
pass
return slots
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)
@@ -5626,7 +5762,7 @@ def api_price_snapshot():
entry = float(r["trigger_price"] or 0) entry = float(r["trigger_price"] or 0)
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
rr_ratio = calc_rr_ratio(r["direction"], entry, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]) exchange_tpsl = {"sl": None, "tp": None}
ex_sym = resolve_monitor_exchange_symbol(r) ex_sym = resolve_monitor_exchange_symbol(r)
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
lev_row = r["leverage"] if "leverage" in r.keys() else None lev_row = r["leverage"] if "leverage" in r.keys() else None
@@ -5637,7 +5773,6 @@ def api_price_snapshot():
"price": round(price, 6), "price": round(price, 6),
"float_pnl": round(pnl, 6), "float_pnl": round(pnl, 6),
"float_pct": pnl_pct, "float_pct": pnl_pct,
"rr_ratio": rr_ratio,
"plan_margin": round(margin, 4) if margin else None, "plan_margin": round(margin, 4) if margin else None,
"exchange_initial_margin": None, "exchange_initial_margin": None,
"exchange_notional": None, "exchange_notional": None,
@@ -5658,6 +5793,27 @@ def api_price_snapshot():
payload["float_pct"] = ( payload["float_pct"] = (
round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct
) )
if exchange_private_api_configured():
try:
exchange_tpsl = fetch_exchange_tpsl_slots(
ex_sym,
r["direction"],
plan_sl=r["stop_loss"],
plan_tp=r["take_profit"],
)
except Exception:
exchange_tpsl = {"sl": None, "tp": None}
payload["exchange_tpsl"] = exchange_tpsl
apply_order_price_display_fields(
payload,
direction=r["direction"],
entry_price=entry,
initial_stop_loss=r["initial_stop_loss"],
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
calc_rr_ratio_fn=calc_rr_ratio,
exchange_tpsl=exchange_tpsl,
)
order_prices.append(payload) order_prices.append(payload)
return jsonify({ return jsonify({
@@ -120,6 +120,7 @@
.plan-card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:1rem;font-weight:700;color:#f0f2ff} .plan-card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:1rem;font-weight:700;color:#f0f2ff}
.plan-card-meta{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:10px} .plan-card-meta{font-size:.76rem;color:#8892b0;line-height:1.55;margin-bottom:10px}
.plan-card-meta .accent{color:#6ab8ff} .plan-card-meta .accent{color:#6ab8ff}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f;margin-left:6px}
.plan-card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 14px;margin-bottom:10px} .plan-card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 14px;margin-bottom:10px}
@media (max-width:720px){ @media (max-width:720px){
.plan-card-grid{grid-template-columns:1fr} .plan-card-grid{grid-template-columns:1fr}
@@ -363,6 +364,7 @@
<div class="plan-card-meta"> <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 来源: 下单监控 风格: {{ 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 %} {% 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>
<div class="plan-card-grid"> <div class="plan-card-grid">
<div class="plan-cell"> <div class="plan-cell">
@@ -1471,6 +1473,12 @@ function paintPriceTrend(el, key, value){
lastPriceMap[key] = value; lastPriceMap[key] = value;
} }
function paintBreakevenBadge(orderId, secured){
const wrap = document.getElementById(`order-be-wrap-${orderId}`);
if(!wrap) return;
wrap.style.display = secured ? "inline-flex" : "none";
}
function refreshPriceSnapshot(){ function refreshPriceSnapshot(){
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");
@@ -1534,6 +1542,7 @@ function refreshPriceSnapshot(){
if(rrEl){ if(rrEl){
rrEl.innerText = (typeof o.rr_ratio !== "undefined" && o.rr_ratio !== null) ? `${Number(o.rr_ratio).toFixed(2)}:1` : "-"; 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);
}); });
}).catch(()=>{}); }).catch(()=>{});
} }
+19 -19
View File
@@ -94,6 +94,10 @@ from key_monitor_lib import (
rs_break_from_direction, rs_break_from_direction,
run_rs_level_alert_tick, run_rs_level_alert_tick,
) )
from order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed from hub_auth import request_allowed as hub_request_allowed
from history_window_lib import ( from history_window_lib import (
@@ -2126,13 +2130,7 @@ def enrich_order_item(raw_item, current_capital):
ratio = round(margin / current_capital * 100, 2) if current_capital else 0 ratio = round(margin / current_capital * 100, 2) if current_capital else 0
item["notional_value"] = notional item["notional_value"] = notional
item["position_ratio"] = ratio item["position_ratio"] = ratio
item["rr_ratio"] = calc_planned_rr_ratio( enrich_order_display_fields(item, calc_rr_ratio)
item.get("direction") or "long",
item.get("trigger_price"),
item.get("stop_loss"),
item.get("initial_stop_loss"),
item.get("take_profit"),
)
try: try:
be = item.get("breakeven_enabled") be = item.get("breakeven_enabled")
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
@@ -5934,13 +5932,7 @@ def api_price_snapshot():
entry = float(r["trigger_price"] or 0) entry = float(r["trigger_price"] or 0)
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0 pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
rr_ratio = calc_planned_rr_ratio( exchange_tpsl = {"sl": None, "tp": None}
r["direction"],
entry,
r["stop_loss"],
r["initial_stop_loss"],
r["take_profit"],
)
ex_sym = resolve_monitor_exchange_symbol(r) ex_sym = resolve_monitor_exchange_symbol(r)
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
lev_row = r["leverage"] if "leverage" in r.keys() else None lev_row = r["leverage"] if "leverage" in r.keys() else None
@@ -5950,7 +5942,6 @@ def api_price_snapshot():
"symbol": r["symbol"], "symbol": r["symbol"],
"float_pnl": round(pnl, 2), "float_pnl": round(pnl, 2),
"float_pct": pnl_pct, "float_pct": pnl_pct,
"rr_ratio": rr_ratio,
"plan_margin": round(margin, 2) if margin else None, "plan_margin": round(margin, 2) if margin else None,
"exchange_initial_margin": None, "exchange_initial_margin": None,
"exchange_notional": None, "exchange_notional": None,
@@ -5985,16 +5976,25 @@ def api_price_snapshot():
payload["price_display"] = px_disp payload["price_display"] = px_disp
if exchange_private_api_configured(): if exchange_private_api_configured():
try: try:
payload["exchange_tpsl"] = fetch_exchange_tpsl_slots( exchange_tpsl = fetch_exchange_tpsl_slots(
ex_sym, ex_sym,
r["direction"], r["direction"],
plan_sl=r["stop_loss"], plan_sl=r["stop_loss"],
plan_tp=r["take_profit"], plan_tp=r["take_profit"],
) )
except Exception: except Exception:
payload["exchange_tpsl"] = {"sl": None, "tp": None} exchange_tpsl = {"sl": None, "tp": None}
else: payload["exchange_tpsl"] = exchange_tpsl
payload["exchange_tpsl"] = {"sl": None, "tp": None} apply_order_price_display_fields(
payload,
direction=r["direction"],
entry_price=entry,
initial_stop_loss=r["initial_stop_loss"],
stop_loss=r["stop_loss"],
take_profit=r["take_profit"],
calc_rr_ratio_fn=calc_rr_ratio,
exchange_tpsl=exchange_tpsl,
)
order_prices.append(payload) order_prices.append(payload)
return jsonify({ return jsonify({
+10
View File
@@ -178,6 +178,7 @@
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff} .pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799} .pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} .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-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-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
@@ -500,6 +501,7 @@
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}"> <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 %} {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span> </span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div> </div>
<div class="pos-grid"> <div class="pos-grid">
<div class="pos-cell"> <div class="pos-cell">
@@ -1791,6 +1793,12 @@ function formatRrRatio(rr){
return `${body}:1`; return `${body}:1`;
} }
function paintBreakevenBadge(orderId, secured){
const wrap = document.getElementById(`order-be-wrap-${orderId}`);
if(!wrap) return;
wrap.style.display = secured ? "inline-flex" : "none";
}
function paintPriceTrend(el, key, value){ function paintPriceTrend(el, key, value){
if(!el) return; if(!el) return;
const prev = lastPriceMap[key]; const prev = lastPriceMap[key];
@@ -1875,6 +1883,7 @@ function refreshPriceSnapshot(){
if(rrEl){ if(rrEl){
rrEl.innerText = formatRrRatio(o.rr_ratio); rrEl.innerText = formatRrRatio(o.rr_ratio);
} }
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
}); });
}).catch(()=>{}); }).catch(()=>{});
@@ -2116,6 +2125,7 @@ function refreshPriceSnapshotConditional(){
} }
const rrEl = document.getElementById(`order-rr-${o.id}`); const rrEl = document.getElementById(`order-rr-${o.id}`);
if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio);
paintBreakevenBadge(o.id, o.sl_breakeven_secured);
paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
}); });
} }
+27
View File
@@ -601,6 +601,31 @@ def _find_exchange_tpsl_for_position(
return None return None
def _merge_flask_order_price_fields(hub_mon: dict | None, snap: dict | None) -> None:
"""将 price_snapshot 中的快照盈亏比、已保本状态合并进 hub_monitor.orders。"""
if not isinstance(hub_mon, dict) or not isinstance(snap, dict):
return
order_prices = snap.get("order_prices") or []
op_by_id = {
op.get("id"): op
for op in order_prices
if isinstance(op, dict) and op.get("id") is not None
}
orders = hub_mon.get("orders") or []
if not isinstance(orders, list):
return
for o in orders:
if not isinstance(o, dict):
continue
op = op_by_id.get(o.get("id"))
if not isinstance(op, dict):
continue
if op.get("rr_ratio") is not None:
o["rr_ratio"] = op["rr_ratio"]
if "sl_breakeven_secured" in op:
o["sl_breakeven_secured"] = bool(op["sl_breakeven_secured"])
def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict | None) -> None: def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict | None) -> None:
"""子代理挂单为空时,用实例 Flask 已算好的 exchange_tpsl 补全展示。""" """子代理挂单为空时,用实例 Flask 已算好的 exchange_tpsl 补全展示。"""
ag = agent_row.get("agent") ag = agent_row.get("agent")
@@ -656,6 +681,8 @@ async def _assemble_board_row(
client: httpx.AsyncClient, ex: dict, agent_row: dict client: httpx.AsyncClient, ex: dict, agent_row: dict
) -> dict: ) -> dict:
hub_mon, meta, key_prices, snap = await _fetch_exchange_flask_bundle(client, ex) hub_mon, meta, key_prices, snap = await _fetch_exchange_flask_bundle(client, ex)
if isinstance(hub_mon, dict):
_merge_flask_order_price_fields(hub_mon, snap)
_merge_flask_exchange_tpsl(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None) _merge_flask_exchange_tpsl(agent_row, snap, hub_mon if isinstance(hub_mon, dict) else None)
flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False
raw_review = (ex.get("review_url") or "").strip() raw_review = (ex.get("review_url") or "").strip()
+11
View File
@@ -847,6 +847,17 @@ body.market-chart-fs-open {
color: var(--muted); color: var(--muted);
} }
.hub-pos-card .pos-breakeven-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
background: #1a3d2e;
color: #4cd97f;
}
.hub-pos-card .pos-grid { .hub-pos-card .pos-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
+27 -1
View File
@@ -403,6 +403,28 @@
return reward / risk; return reward / risk;
} }
function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored) {
if (tpMonitored) return null;
const snap = mo && mo.rr_ratio;
if (snap != null && snap !== "") {
const n = Number(snap);
if (Number.isFinite(n)) return n;
}
const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss);
return calcRrRatio(side, entry, initSl || sl, tp);
}
function isBreakevenSecured(side, entry, monitorOrder, cond) {
const mo = monitorOrder || {};
if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true;
const { sl } = pickExTpslOrders(cond);
const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN;
const e = Number(entry);
if (!Number.isFinite(trig) || !Number.isFinite(e)) return false;
if ((side || "long").toLowerCase() === "short") return trig <= e;
return trig >= e;
}
async function loadMonitorBoard() { async function loadMonitorBoard() {
const box = document.getElementById("monitor-grid"); const box = document.getElementById("monitor-grid");
const showLoading = !lastMonitorRows.length; const showLoading = !lastMonitorRows.length;
@@ -932,7 +954,8 @@
const sl = tpsl.sl; const sl = tpsl.sl;
const tp = tpsl.tp; const tp = tpsl.tp;
const tpMonitored = tpsl.tp_monitored; const tpMonitored = tpsl.tp_monitored;
const rr = tpMonitored ? null : calcRrRatio(side, entry, sl, tp); const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored);
const beSecured = isBreakevenSecured(side, entry, mo, cond);
const upnl = pos.unrealized_pnl; const upnl = pos.unrealized_pnl;
let pnlText = fmt(upnl, 2) + "U"; let pnlText = fmt(upnl, 2) + "U";
if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) { if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) {
@@ -956,6 +979,9 @@
meta.push( meta.push(
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>` `<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
); );
if (beSecured) {
meta.push(`<span class="pos-meta-item"><span class="pos-breakeven-badge">已保本</span></span>`);
}
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `<div class="pos-card hub-pos-card"> return `<div class="pos-card hub-pos-card">
<div class="pos-card-head"> <div class="pos-card-head">
+111
View File
@@ -0,0 +1,111 @@
"""实时持仓展示:开仓快照盈亏比、交易所止损是否已保本。"""
from __future__ import annotations
from typing import Any, Callable, Optional
def _positive_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v > 0 else None
except (TypeError, ValueError):
return None
def snapshot_stop_loss(initial_stop_loss: Any, stop_loss: Any) -> Optional[float]:
"""展示盈亏比时优先用开仓时止损快照。"""
sl = _positive_float(initial_stop_loss)
if sl is not None:
return sl
return _positive_float(stop_loss)
def snapshot_rr(
calc_rr_ratio_fn: Callable[..., Optional[float]],
direction: str,
trigger_price: Any,
initial_stop_loss: Any,
stop_loss: Any,
take_profit: Any,
) -> Optional[float]:
entry = _positive_float(trigger_price)
sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
tp = _positive_float(take_profit)
if entry is None or sl is None or tp is None:
return None
return calc_rr_ratio_fn(direction or "long", entry, sl, tp)
def tpsl_slot_trigger_price(slot: Any) -> Optional[float]:
if not isinstance(slot, dict):
return None
for key in ("trigger_price", "trigger_display"):
v = _positive_float(slot.get(key))
if v is not None:
return v
return None
def is_sl_breakeven_secured(direction: str, entry_price: Any, exchange_sl_price: Any) -> bool:
"""
交易所当前止损相对开仓成交价是否已保本
做多止损 >= 成交价做空止损 <= 成交价
"""
entry = _positive_float(entry_price)
sl = _positive_float(exchange_sl_price)
if entry is None or sl is None:
return False
d = (direction or "long").strip().lower()
if d == "short":
return sl <= entry
return sl >= entry
def sl_breakeven_from_exchange_tpsl(
direction: str,
entry_price: Any,
exchange_tpsl: Any,
) -> bool:
if not isinstance(exchange_tpsl, dict):
return False
sl_px = tpsl_slot_trigger_price(exchange_tpsl.get("sl"))
if sl_px is None:
return False
return is_sl_breakeven_secured(direction, entry_price, sl_px)
def enrich_order_display_fields(item: dict[str, Any], calc_rr_ratio_fn: Callable[..., Optional[float]]) -> dict[str, Any]:
item["rr_ratio"] = snapshot_rr(
calc_rr_ratio_fn,
item.get("direction") or "long",
item.get("trigger_price"),
item.get("initial_stop_loss"),
item.get("stop_loss"),
item.get("take_profit"),
)
return item
def apply_order_price_display_fields(
payload: dict[str, Any],
*,
direction: str,
entry_price: Any,
initial_stop_loss: Any,
stop_loss: Any,
take_profit: Any,
calc_rr_ratio_fn: Callable[..., Optional[float]],
exchange_tpsl: Any = None,
) -> dict[str, Any]:
payload["rr_ratio"] = snapshot_rr(
calc_rr_ratio_fn,
direction,
entry_price,
initial_stop_loss,
stop_loss,
take_profit,
)
payload["sl_breakeven_secured"] = sl_breakeven_from_exchange_tpsl(
direction, entry_price, exchange_tpsl
)
return payload
+50
View File
@@ -0,0 +1,50 @@
from order_monitor_display_lib import (
is_sl_breakeven_secured,
sl_breakeven_from_exchange_tpsl,
snapshot_rr,
snapshot_stop_loss,
)
def _calc_rr(direction, entry, sl, tp):
if direction == "long":
risk = entry - sl
reward = tp - entry
else:
risk = sl - entry
reward = entry - tp
if risk <= 0 or reward <= 0:
return None
return round(reward / risk, 4)
def test_snapshot_stop_loss_prefers_initial():
assert snapshot_stop_loss(2.45, 2.6) == 2.45
assert snapshot_stop_loss(None, 2.6) == 2.6
def test_snapshot_rr_ignores_current_stop_after_manual_move():
rr = snapshot_rr(_calc_rr, "long", 2.726, 2.45, 2.65, 3.3)
assert rr is not None
assert rr > 2.0
def test_breakeven_long():
assert is_sl_breakeven_secured("long", 2.726, 2.726) is True
assert is_sl_breakeven_secured("long", 2.726, 2.75) is True
assert is_sl_breakeven_secured("long", 2.726, 2.45) is False
def test_breakeven_short():
assert is_sl_breakeven_secured("short", 72.73, 72.73) is True
assert is_sl_breakeven_secured("short", 72.73, 72.0) is True
assert is_sl_breakeven_secured("short", 72.73, 74.0) is False
def test_sl_breakeven_from_exchange_tpsl():
ok = sl_breakeven_from_exchange_tpsl(
"long",
2.726,
{"sl": {"trigger_price": 2.735}, "tp": {"trigger_price": 3.3}},
)
assert ok is True