feat(risk): show live countdown on freeze status badges

Expose freeze_until_ms from risk API and tick hub/instance badges with remaining 1h/4h/daily time.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-18 17:41:04 +08:00
parent 0280b4f065
commit 97370926d6
15 changed files with 272 additions and 41 deletions
+38
View File
@@ -405,6 +405,44 @@ def apply_manual_close_journal_cooloff(
)
def _next_trading_day_reset_ms(now: datetime, reset_hour: int) -> int:
from datetime import timedelta
h = max(0, min(23, int(reset_hour)))
candidate = now.replace(hour=h, minute=0, second=0, microsecond=0)
if now >= candidate:
candidate = candidate + timedelta(days=1)
return _now_ms(candidate)
def enrich_risk_status_countdown(
st: dict[str, Any],
*,
now: Optional[datetime] = None,
daily_reset_hour: int = 8,
) -> dict[str, Any]:
"""补充 freeze_until_ms / freeze_remaining_sec,供前端倒计时展示。"""
if not st.get("enabled", True):
return st
dt = now or datetime.now()
now_ms = _now_ms(dt)
until_ms: Optional[int] = None
if st.get("daily_frozen"):
until_ms = _next_trading_day_reset_ms(dt, daily_reset_hour)
elif st.get("cooloff_until_ms"):
try:
until_ms = int(st["cooloff_until_ms"])
except (TypeError, ValueError):
until_ms = None
if until_ms is not None and until_ms > now_ms:
st["freeze_until_ms"] = until_ms
st["freeze_remaining_sec"] = max(0, (until_ms - now_ms) // 1000)
else:
st["freeze_until_ms"] = None
st["freeze_remaining_sec"] = 0
return st
def compute_account_risk_status(
conn,
*,
+9 -3
View File
@@ -1538,15 +1538,21 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema
from account_risk_lib import (
compute_account_risk_status,
enrich_risk_status_countdown,
ensure_account_risk_schema,
)
ensure_account_risk_schema(conn)
return compute_account_risk_status(
now = app_now()
st = compute_account_risk_status(
conn,
trading_day=get_trading_day(),
now=app_now(),
now=now,
fmt_local_ms=ms_to_app_local_str,
)
return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
def hub_user_initiated_close(
+12 -6
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=9"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=2">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3">
<script src="/static/account_risk_badge.js?v=1"></script>
<meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控">
@@ -264,7 +265,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}">{{ risk_status.status_label|default('正常') }}</span>
<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="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -2047,10 +2048,14 @@ function refreshAccountSnapshot(){
if (data.risk_status) {
const badge = document.getElementById("account-risk-badge");
if (badge) {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
if (window.AccountRiskBadge) {
AccountRiskBadge.applyToElement(badge, data.risk_status);
} else {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
}
}
}
let canTradeText = "可开仓";
@@ -2138,6 +2143,7 @@ if(window.ManualOrderRrPreview){
});
refreshAccountSnapshot();
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
const _journalFormEl = document.getElementById("journal-form");
if(_journalFormEl){
_journalFormEl.addEventListener("submit", function(ev){
+9 -3
View File
@@ -1528,15 +1528,21 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema
from account_risk_lib import (
compute_account_risk_status,
enrich_risk_status_countdown,
ensure_account_risk_schema,
)
ensure_account_risk_schema(conn)
return compute_account_risk_status(
now = app_now()
st = compute_account_risk_status(
conn,
trading_day=get_trading_day(),
now=app_now(),
now=now,
fmt_local_ms=ms_to_app_local_str,
)
return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
def hub_user_initiated_close(
+12 -6
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=9"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=2">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3">
<script src="/static/account_risk_badge.js?v=1"></script>
<meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控">
@@ -264,7 +265,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}">{{ risk_status.status_label|default('正常') }}</span>
<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="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -1973,10 +1974,14 @@ function refreshAccountSnapshot(){
if (data.risk_status) {
const badge = document.getElementById("account-risk-badge");
if (badge) {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
if (window.AccountRiskBadge) {
AccountRiskBadge.applyToElement(badge, data.risk_status);
} else {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
}
}
}
let canTradeText = "可开仓";
@@ -2064,6 +2069,7 @@ if(window.ManualOrderRrPreview){
});
refreshAccountSnapshot();
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
const _journalFormEl = document.getElementById("journal-form");
if(_journalFormEl){
_journalFormEl.addEventListener("submit", function(ev){
+9 -3
View File
@@ -1528,15 +1528,21 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema
from account_risk_lib import (
compute_account_risk_status,
enrich_risk_status_countdown,
ensure_account_risk_schema,
)
ensure_account_risk_schema(conn)
return compute_account_risk_status(
now = app_now()
st = compute_account_risk_status(
conn,
trading_day=get_trading_day(),
now=app_now(),
now=now,
fmt_local_ms=ms_to_app_local_str,
)
return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
def hub_user_initiated_close(
+12 -6
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=9"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=2">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3">
<script src="/static/account_risk_badge.js?v=1"></script>
<meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控">
@@ -264,7 +265,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}">{{ risk_status.status_label|default('正常') }}</span>
<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="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -1973,10 +1974,14 @@ function refreshAccountSnapshot(){
if (data.risk_status) {
const badge = document.getElementById("account-risk-badge");
if (badge) {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
if (window.AccountRiskBadge) {
AccountRiskBadge.applyToElement(badge, data.risk_status);
} else {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
}
}
}
let canTradeText = "可开仓";
@@ -2064,6 +2069,7 @@ if(window.ManualOrderRrPreview){
});
refreshAccountSnapshot();
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
const _journalFormEl = document.getElementById("journal-form");
if(_journalFormEl){
_journalFormEl.addEventListener("submit", function(ev){
+9 -3
View File
@@ -1517,15 +1517,21 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema
from account_risk_lib import (
compute_account_risk_status,
enrich_risk_status_countdown,
ensure_account_risk_schema,
)
ensure_account_risk_schema(conn)
return compute_account_risk_status(
now = app_now()
st = compute_account_risk_status(
conn,
trading_day=get_trading_day(),
now=app_now(),
now=now,
fmt_local_ms=ms_to_app_local_str,
)
return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
def hub_user_initiated_close(
+12 -6
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=9"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=2">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3">
<script src="/static/account_risk_badge.js?v=1"></script>
<meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控">
@@ -264,7 +265,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}">{{ risk_status.status_label|default('正常') }}</span>
<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="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -2003,10 +2004,14 @@ function refreshAccountSnapshot(){
if (data.risk_status) {
const badge = document.getElementById("account-risk-badge");
if (badge) {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
if (window.AccountRiskBadge) {
AccountRiskBadge.applyToElement(badge, data.risk_status);
} else {
const st = data.risk_status.status || "normal";
badge.className = "risk-status-badge risk-status-" + st;
badge.innerText = data.risk_status.status_label || "正常";
badge.title = data.risk_status.reason || "";
}
}
}
let canTradeText = "可开仓";
@@ -2117,6 +2122,7 @@ if(window.ManualOrderRrPreview){
});
refreshAccountSnapshot();
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
const _journalFormEl = document.getElementById("journal-form");
if(_journalFormEl){
_journalFormEl.addEventListener("submit", function(ev){
+3 -3
View File
@@ -10,9 +10,9 @@
| 状态 | 含义 |
|------|------|
| 正常 | 可新开仓 |
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) |
| 4h冻结 | 冷静期中(默认 4 小时) |
| 日冻结 | 当日禁止一切新开仓 |
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时);徽章显示剩余倒计时 |
| 4h冻结 | 冷静期中(默认 4 小时);徽章显示剩余倒计时 |
| 日冻结 | 当日禁止一切新开仓;徽章显示至下一交易日切点的倒计时 |
## 什么算「手动平仓」(计入风控)
+12
View File
@@ -529,6 +529,7 @@ STATIC_DIR = DIR / "static"
_REPO_STATIC = _REPO_ROOT / "static"
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
@app.get("/assets/account_risk_badge.css")
@@ -542,6 +543,17 @@ def hub_account_risk_badge_css():
)
@app.get("/assets/account_risk_badge.js")
def hub_account_risk_badge_js():
"""与四所实例共用仓库根 static/account_risk_badge.js。"""
if not _ACCOUNT_RISK_BADGE_JS.is_file():
raise HTTPException(status_code=404, detail="account_risk_badge.js not found")
return FileResponse(
str(_ACCOUNT_RISK_BADGE_JS),
media_type="application/javascript; charset=utf-8",
)
@app.get("/assets/ai_review_render.js")
def hub_ai_review_render_js():
"""与四所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。"""
+2
View File
@@ -514,6 +514,7 @@
function formatRiskStatusBadge(riskStatus) {
if (!riskStatus || typeof riskStatus !== "object") return "";
if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc);
const st = riskStatus.status || "normal";
const label = esc(riskStatus.status_label || "正常");
const title = esc(riskStatus.reason || "");
@@ -4469,4 +4470,5 @@
setActiveNav();
});
});
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
})();
+3 -2
View File
@@ -16,7 +16,8 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260618-macro-calendar" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=1" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=3" />
<script src="/assets/account_risk_badge.js?v=1"></script>
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
</head>
<body>
@@ -689,6 +690,6 @@
<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/app.js?v=20260618-macro-calendar"></script>
<script src="/assets/app.js?v=20260618-risk-countdown"></script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
/**
* 账户风控徽章倒计时 四所实例 + 中控共用
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = Number(riskStatus && riskStatus.freeze_until_ms);
if (!Number.isFinite(until) || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
el.textContent = label;
if (el.dataset) delete el.dataset.freezeUntilMs;
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
if (riskStatus.freeze_until_ms) {
el.dataset.freezeUntilMs = String(riskStatus.freeze_until_ms);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = riskStatus.freeze_until_ms;
const untilAttr =
until != null && until !== ""
? ` data-freeze-until-ms="${safe(String(until))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
+32
View File
@@ -248,6 +248,38 @@ class AccountRiskLibTests(unittest.TestCase):
def test_parse_mood_issues_filters_unknown(self):
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
def test_enrich_countdown_for_daily_and_cooloff(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_user_initiated_close(
conn,
source=CLOSE_SOURCE_USER_INSTANCE,
closed_at_ms=close_ms,
trading_day="2026-06-14",
now=now,
)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
from account_risk_lib import enrich_risk_status_countdown
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8)
self.assertGreater(st["freeze_remaining_sec"], 0)
self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"])
on_journal_saved(
conn,
early_exit_trigger="止损",
early_exit_note="",
mood_issues_raw="扛单",
trading_day="2026-06-14",
now=now,
)
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
st2 = enrich_risk_status_countdown(st2, now=now, daily_reset_hour=8)
self.assertTrue(st2["daily_frozen"])
self.assertGreater(st2["freeze_remaining_sec"], 0)
self.assertIsNotNone(st2["freeze_until_ms"])
def test_disabled_risk_control(self):
os.environ["RISK_CONTROL_ENABLED"] = "0"
conn = _mem_conn()