feat: show force-close badge and countdown when FORCE_CLOSE is enabled

Add shared lib/UI for midnight force-close indicator on instance and hub pages, and document Gate intraday 0-point exit alignment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-06 02:17:47 +08:00
parent 7fb87d6030
commit a268a93027
22 changed files with 974 additions and 18 deletions
+8
View File
@@ -1784,6 +1784,13 @@ _ORDER_PRICE_MERGE_KEYS = (
"time_close_label",
"time_close_countdown",
"time_close_remaining_sec",
"force_close_enabled",
"force_close_bj_hour",
"force_close_at_ms",
"force_close_label",
"force_close_countdown",
"force_close_remaining_sec",
"force_close_active",
)
@@ -2141,6 +2148,7 @@ async def _assemble_board_row(
"available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None,
"account_ok": acct_ok,
"day_stats": _day_stats_from_trades_body(trades_today),
"force_close": snap.get("force_close") if isinstance(snap, dict) else None,
}
+21
View File
@@ -1605,6 +1605,27 @@ body.market-chart-fs-open {
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
}
.hub-pos-card .pos-symbol-force-close,
.hub-mini-title .pos-symbol-force-close,
.td-symbol .pos-symbol-force-close {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.72rem;
font-weight: 500;
color: #ffc870;
padding: 1px 6px;
border-radius: 4px;
background: rgba(255, 200, 112, 0.12);
white-space: nowrap;
vertical-align: middle;
}
.hub-pos-card .pos-symbol-force-close .pos-force-close-cd,
.hub-mini-title .pos-symbol-force-close .pos-force-close-cd,
.td-symbol .pos-symbol-force-close .pos-force-close-cd {
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
}
.hub-pos-card .pos-card-symbol strong {
font-size: 14px;
color: var(--text);
+19 -3
View File
@@ -2792,6 +2792,18 @@
);
}
function forceCloseSymbolBadgeHtml(item) {
if (!item || !item.force_close_enabled) return "";
const fcLabel = item.force_close_label || "强制清仓";
const fcCd = item.force_close_countdown || "--:--:--";
const fcAt = item.force_close_at_ms != null ? String(item.force_close_at_ms) : "";
const fcActive = item.force_close_active ? "1" : "0";
return (
`<span class="pos-symbol-force-close pos-force-close-meta" data-force-close-at-ms="${esc(fcAt)}" data-force-close-active="${esc(fcActive)}">` +
`${esc(fcLabel)} · <span class="pos-force-close-cd">${esc(fcCd)}</span></span>`
);
}
function renderTrendDcaTable(t, tickMap) {
const levels = resolveTrendDcaLevels(t);
if (!levels.length) return "";
@@ -3043,11 +3055,12 @@
}
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
const fcSymBadge = !isTrend && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `<div class="pos-card hub-pos-card">
<div class="pos-card-head">
<div class="pos-card-symbol">
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${symBeBadge}
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${fcSymBadge}${symBeBadge}
<span class="pos-side-badge ${sideCls}">${sideCn}</span>
</div>
<div class="pos-head-actions">
@@ -3132,8 +3145,9 @@
.map((o) => {
const sym = o.exchange_symbol || o.symbol || "";
const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : "";
const fcBadge = o.force_close_enabled ? forceCloseSymbolBadgeHtml(o) : "";
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}</div>
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge}${fcBadge} · ${renderDirectionHtml(o.direction)}</div>
<div class="hub-mini-line">触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
</div>`;
})
@@ -3202,6 +3216,8 @@
const mo = monitorOrder || {};
const tcBadge =
!isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
const fcBadge =
!isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : "";
const actionCell = `<div class="pos-action-group">
<button type="button" class="btn-place-tpsl btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
<button type="button" class="btn-close-pos btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
@@ -3210,7 +3226,7 @@
? `<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>`
: "";
return `<tr>
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${tcBadge}${symBeBadge}</td>
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${tcBadge}${fcBadge}${symBeBadge}</td>
<td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td>
<td class="td-entry">${fmtEntryPrice(x, tickMap)}</td>
<td>${fmtMarkPrice(x, tickMap)}</td>
+1 -1
View File
@@ -1114,7 +1114,7 @@
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260704-monitor-stats-v2"></script>
</body>
+97 -3
View File
@@ -1,5 +1,5 @@
/**
* 时间平仓表单开关 + 持仓倒计时
* 时间平仓 + 整点强制清仓表单开关 + 持仓/顶栏倒计时
*/
(function (global) {
"use strict";
@@ -16,6 +16,15 @@
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function isForceCloseActive(wrap) {
if (!wrap) return false;
const raw =
wrap.dataset.forceCloseActive ||
wrap.getAttribute("data-force-close-active") ||
"";
return raw === "1" || raw === "true";
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
@@ -37,6 +46,15 @@
sync();
}
function paintCountdownEl(cd, rem, active) {
if (!cd) return;
if (active) {
cd.textContent = "执行中";
return;
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
@@ -59,10 +77,71 @@
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
paintCountdownEl(cd, rem, false);
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function paintOrderForceClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
const cd = document.getElementById("order-force-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!order.force_close_enabled;
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = order.force_close_label || "强制清仓";
const labelEl = wrap.querySelector(".pos-force-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.force_close_remaining_sec != null
? Number(order.force_close_remaining_sec)
: null;
const atMs = order.force_close_at_ms;
if ((rem == null || !Number.isFinite(rem)) && atMs) {
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
}
const active = !!order.force_close_active;
paintCountdownEl(cd, rem, active);
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
wrap.dataset.forceCloseActive = active ? "1" : "0";
}
function paintForceCloseHeader(state) {
const wrap = document.getElementById("force-close-header-badge");
if (!wrap) return;
if (!state || !state.enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = state.label || "强制清仓";
const labelPrefix = label + " 已开启 · ";
let prefixNode = wrap.querySelector(".force-close-header-prefix");
if (!prefixNode) {
wrap.textContent = "";
prefixNode = document.createElement("span");
prefixNode.className = "force-close-header-prefix";
prefixNode.textContent = labelPrefix;
wrap.appendChild(prefixNode);
const cd = document.createElement("span");
cd.className = "force-close-header-cd";
wrap.appendChild(cd);
} else {
prefixNode.textContent = labelPrefix;
}
const cd = wrap.querySelector(".force-close-header-cd");
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, !!state.active);
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
@@ -73,10 +152,23 @@
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
const closeAtRaw =
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
});
}
function paintOrders(orders) {
(orders || []).forEach(paintOrderTimeClose);
(orders || []).forEach(function (order) {
paintOrderTimeClose(order);
paintOrderForceClose(order);
});
}
function syncKeyTimeCloseVisibility(show) {
@@ -88,6 +180,8 @@
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrderForceClose: paintOrderForceClose,
paintForceCloseHeader: paintForceCloseHeader,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,