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:
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
| 状态 | 含义 |
|
||||
|------|------|
|
||||
| 正常 | 可新开仓 |
|
||||
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) |
|
||||
| 4h冻结 | 冷静期中(默认 4 小时) |
|
||||
| 日冻结 | 当日禁止一切新开仓 |
|
||||
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时);徽章显示剩余倒计时 |
|
||||
| 4h冻结 | 冷静期中(默认 4 小时);徽章显示剩余倒计时 |
|
||||
| 日冻结 | 当日禁止一切新开仓;徽章显示至下一交易日切点的倒计时 |
|
||||
|
||||
## 什么算「手动平仓」(计入风控)
|
||||
|
||||
|
||||
@@ -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 之前注册)。"""
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user