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:
@@ -590,6 +590,53 @@ html[data-theme="light"] .pos-meta-item::after {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.force-close-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #ffc870;
|
||||
background: #2a2218;
|
||||
border: 1px solid #6a5020;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.force-close-badge .force-close-header-cd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.pos-force-close-meta {
|
||||
color: #ffc870;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.pos-symbol-force-close .pos-force-close-cd {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
html[data-theme="light"] .force-close-badge {
|
||||
color: #9a6200;
|
||||
background: #fff6e8;
|
||||
border-color: #d4a84a;
|
||||
}
|
||||
html[data-theme="light"] .pos-symbol-force-close,
|
||||
html[data-theme="light"] .pos-force-close-meta {
|
||||
color: #9a6200;
|
||||
background: rgba(212, 168, 74, 0.14);
|
||||
}
|
||||
.key-time-close-wrap.is-disabled > label,
|
||||
.order-time-close-wrap.is-disabled > label {
|
||||
opacity: 0.72;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -944,6 +944,9 @@ function refreshPriceSnapshot(){
|
||||
if(data.updated_at && updatedEl){
|
||||
updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
if(pEl){
|
||||
@@ -1015,6 +1018,7 @@ function refreshPriceSnapshot(){
|
||||
if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl);
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
});
|
||||
}).catch(()=>{});
|
||||
}
|
||||
@@ -1074,6 +1078,9 @@ function refreshAccountSnapshot(){
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) {
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
@@ -1247,6 +1254,9 @@ function refreshPriceSnapshotConditional(){
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at;
|
||||
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
|
||||
TimeCloseUI.paintForceCloseHeader(data.force_close);
|
||||
}
|
||||
if(page === "key_monitor"){
|
||||
(data.key_prices || []).forEach(k=>{
|
||||
const pEl = document.getElementById(`key-price-${k.id}`);
|
||||
@@ -1296,6 +1306,7 @@ function refreshPriceSnapshotConditional(){
|
||||
paintExchangeTpslRow(o.id, o.exchange_tpsl || {});
|
||||
paintPlanTpslDisplay(o.id, o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o);
|
||||
if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o);
|
||||
const holdEl = document.getElementById(`order-hold-duration-${o.id}`);
|
||||
if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){
|
||||
holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms));
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_order_badge.html' %}
|
||||
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
|
||||
</div>
|
||||
<div class="pos-head-actions">
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
{% if trade_policy.badge_text %}
|
||||
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
|
||||
{% endif %}
|
||||
{% include 'force_close_header_badge.html' %}
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
@@ -118,7 +119,7 @@
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=3"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
<script src="/static/form_submit_guard.js?v=2"></script>
|
||||
<script src="/static/order_entry_model.js?v=3"></script>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="force-close-badge" id="force-close-header-badge" role="status"
|
||||
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
|
||||
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if force_close.active else '0' }}">
|
||||
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% if force_close.enabled %}
|
||||
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
|
||||
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
|
||||
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
|
||||
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
|
||||
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
FORCE_CLOSE_RESULT = "强制清仓"
|
||||
|
||||
|
||||
def app_timezone_name() -> str:
|
||||
return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
|
||||
def normalize_force_close_bj_hour(value: Any) -> int:
|
||||
try:
|
||||
h = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, min(23, h))
|
||||
|
||||
|
||||
def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime:
|
||||
tz = ZoneInfo(tz_name or app_timezone_name())
|
||||
if now_ms is None:
|
||||
return datetime.now(tz)
|
||||
return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz)
|
||||
|
||||
|
||||
def force_close_hour_label(bj_hour: Any) -> str:
|
||||
return f"{normalize_force_close_bj_hour(bj_hour):02d}:00"
|
||||
|
||||
|
||||
def force_close_label(bj_hour: Any) -> str:
|
||||
return f"强制清仓 {force_close_hour_label(bj_hour)}"
|
||||
|
||||
|
||||
def is_force_close_active_hour(
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""当前是否处于整点强制清仓执行窗口(该北京时间整点小时内)。"""
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
return _now_dt(now_ms=now_ms, tz_name=tz_name).hour == hour
|
||||
|
||||
|
||||
def compute_next_force_close_at_ms(
|
||||
*,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""下一次强制清仓时刻(北京时间整点)的 epoch 毫秒。"""
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
|
||||
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if now.hour > hour or now.hour == hour:
|
||||
if now.hour > hour:
|
||||
target += timedelta(days=1)
|
||||
return int(target.timestamp() * 1000)
|
||||
|
||||
|
||||
def force_close_remaining_seconds(
|
||||
close_at_ms: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
try:
|
||||
close_at = int(close_at_ms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return max(0, int((close_at - now) / 1000))
|
||||
|
||||
|
||||
def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str:
|
||||
if active:
|
||||
return "执行中"
|
||||
try:
|
||||
sec = max(0, int(seconds))
|
||||
except (TypeError, ValueError):
|
||||
return "--:--:--"
|
||||
h = sec // 3600
|
||||
m = (sec % 3600) // 60
|
||||
s = sec % 60
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def build_force_close_state(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""实例级强制清仓状态(模板 / API 共用)。"""
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"bj_hour": normalize_force_close_bj_hour(bj_hour),
|
||||
"hour_label": force_close_hour_label(bj_hour),
|
||||
"label": force_close_label(bj_hour),
|
||||
"next_at_ms": None,
|
||||
"remaining_sec": None,
|
||||
"countdown": "",
|
||||
"active": False,
|
||||
}
|
||||
hour = normalize_force_close_bj_hour(bj_hour)
|
||||
active = is_force_close_active_hour(hour, now_ms=now_ms, tz_name=tz_name)
|
||||
next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name)
|
||||
rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None
|
||||
return {
|
||||
"enabled": True,
|
||||
"bj_hour": hour,
|
||||
"hour_label": force_close_hour_label(hour),
|
||||
"label": force_close_label(hour),
|
||||
"next_at_ms": next_at_ms,
|
||||
"remaining_sec": rem,
|
||||
"countdown": format_force_close_countdown(rem, active=active),
|
||||
"active": active,
|
||||
}
|
||||
|
||||
|
||||
def force_close_template_context(
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"force_close": build_force_close_state(
|
||||
enabled, bj_hour, now_ms=now_ms, tz_name=tz_name
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def apply_force_close_to_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""为 active 持仓 JSON 附加整点强制清仓倒计时。"""
|
||||
state = build_force_close_state(enabled, bj_hour, now_ms=now_ms, tz_name=tz_name)
|
||||
payload["force_close_enabled"] = bool(state["enabled"])
|
||||
payload["force_close_bj_hour"] = state["bj_hour"]
|
||||
payload["force_close_at_ms"] = state["next_at_ms"]
|
||||
payload["force_close_label"] = state["label"] if state["enabled"] else ""
|
||||
payload["force_close_remaining_sec"] = state["remaining_sec"]
|
||||
payload["force_close_countdown"] = state["countdown"]
|
||||
payload["force_close_active"] = bool(state["active"])
|
||||
|
||||
|
||||
def enrich_orders_force_close(
|
||||
orders: list[dict[str, Any]],
|
||||
enabled: bool,
|
||||
bj_hour: Any,
|
||||
*,
|
||||
now_ms: Optional[int] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> None:
|
||||
if not enabled or not orders:
|
||||
return
|
||||
for item in orders:
|
||||
if isinstance(item, dict):
|
||||
apply_force_close_to_payload(
|
||||
item,
|
||||
enabled=enabled,
|
||||
bj_hour=bj_hour,
|
||||
now_ms=now_ms,
|
||||
tz_name=tz_name,
|
||||
)
|
||||
Reference in New Issue
Block a user