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(
|
def compute_account_risk_status(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1538,15 +1538,21 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def hub_account_risk_status(conn):
|
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)
|
ensure_account_risk_schema(conn)
|
||||||
return compute_account_risk_status(
|
now = app_now()
|
||||||
|
st = compute_account_risk_status(
|
||||||
conn,
|
conn,
|
||||||
trading_day=get_trading_day(),
|
trading_day=get_trading_day(),
|
||||||
now=app_now(),
|
now=now,
|
||||||
fmt_local_ms=ms_to_app_local_str,
|
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(
|
def hub_user_initiated_close(
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=9"></script>
|
<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/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="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -264,7 +265,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<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="界面主题">
|
<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="暗色主题">
|
<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">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -2047,12 +2048,16 @@ function refreshAccountSnapshot(){
|
|||||||
if (data.risk_status) {
|
if (data.risk_status) {
|
||||||
const badge = document.getElementById("account-risk-badge");
|
const badge = document.getElementById("account-risk-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
const st = data.risk_status.status || "normal";
|
const st = data.risk_status.status || "normal";
|
||||||
badge.className = "risk-status-badge risk-status-" + st;
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
badge.innerText = data.risk_status.status_label || "正常";
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
badge.title = data.risk_status.reason || "";
|
badge.title = data.risk_status.reason || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -2138,6 +2143,7 @@ if(window.ManualOrderRrPreview){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
|
|||||||
@@ -1528,15 +1528,21 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def hub_account_risk_status(conn):
|
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)
|
ensure_account_risk_schema(conn)
|
||||||
return compute_account_risk_status(
|
now = app_now()
|
||||||
|
st = compute_account_risk_status(
|
||||||
conn,
|
conn,
|
||||||
trading_day=get_trading_day(),
|
trading_day=get_trading_day(),
|
||||||
now=app_now(),
|
now=now,
|
||||||
fmt_local_ms=ms_to_app_local_str,
|
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(
|
def hub_user_initiated_close(
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=9"></script>
|
<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/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="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -264,7 +265,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<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="界面主题">
|
<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="暗色主题">
|
<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">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -1973,12 +1974,16 @@ function refreshAccountSnapshot(){
|
|||||||
if (data.risk_status) {
|
if (data.risk_status) {
|
||||||
const badge = document.getElementById("account-risk-badge");
|
const badge = document.getElementById("account-risk-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
const st = data.risk_status.status || "normal";
|
const st = data.risk_status.status || "normal";
|
||||||
badge.className = "risk-status-badge risk-status-" + st;
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
badge.innerText = data.risk_status.status_label || "正常";
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
badge.title = data.risk_status.reason || "";
|
badge.title = data.risk_status.reason || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -2064,6 +2069,7 @@ if(window.ManualOrderRrPreview){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
|
|||||||
@@ -1528,15 +1528,21 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def hub_account_risk_status(conn):
|
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)
|
ensure_account_risk_schema(conn)
|
||||||
return compute_account_risk_status(
|
now = app_now()
|
||||||
|
st = compute_account_risk_status(
|
||||||
conn,
|
conn,
|
||||||
trading_day=get_trading_day(),
|
trading_day=get_trading_day(),
|
||||||
now=app_now(),
|
now=now,
|
||||||
fmt_local_ms=ms_to_app_local_str,
|
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(
|
def hub_user_initiated_close(
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=9"></script>
|
<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/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="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -264,7 +265,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<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="界面主题">
|
<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="暗色主题">
|
<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">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -1973,12 +1974,16 @@ function refreshAccountSnapshot(){
|
|||||||
if (data.risk_status) {
|
if (data.risk_status) {
|
||||||
const badge = document.getElementById("account-risk-badge");
|
const badge = document.getElementById("account-risk-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
const st = data.risk_status.status || "normal";
|
const st = data.risk_status.status || "normal";
|
||||||
badge.className = "risk-status-badge risk-status-" + st;
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
badge.innerText = data.risk_status.status_label || "正常";
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
badge.title = data.risk_status.reason || "";
|
badge.title = data.risk_status.reason || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if (!data.can_trade) {
|
if (!data.can_trade) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -2064,6 +2069,7 @@ if(window.ManualOrderRrPreview){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
|
|||||||
@@ -1517,15 +1517,21 @@ def get_db():
|
|||||||
|
|
||||||
|
|
||||||
def hub_account_risk_status(conn):
|
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)
|
ensure_account_risk_schema(conn)
|
||||||
return compute_account_risk_status(
|
now = app_now()
|
||||||
|
st = compute_account_risk_status(
|
||||||
conn,
|
conn,
|
||||||
trading_day=get_trading_day(),
|
trading_day=get_trading_day(),
|
||||||
now=app_now(),
|
now=now,
|
||||||
fmt_local_ms=ms_to_app_local_str,
|
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(
|
def hub_user_initiated_close(
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<script src="/static/instance_theme.js?v=9"></script>
|
<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/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="theme-color" content="#0b0d14">
|
||||||
<meta name="apple-mobile-web-app-title" content="监控">
|
<meta name="apple-mobile-web-app-title" content="监控">
|
||||||
@@ -264,7 +265,7 @@
|
|||||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
<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="界面主题">
|
<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="暗色主题">
|
<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">
|
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||||
@@ -2003,12 +2004,16 @@ function refreshAccountSnapshot(){
|
|||||||
if (data.risk_status) {
|
if (data.risk_status) {
|
||||||
const badge = document.getElementById("account-risk-badge");
|
const badge = document.getElementById("account-risk-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
if (window.AccountRiskBadge) {
|
||||||
|
AccountRiskBadge.applyToElement(badge, data.risk_status);
|
||||||
|
} else {
|
||||||
const st = data.risk_status.status || "normal";
|
const st = data.risk_status.status || "normal";
|
||||||
badge.className = "risk-status-badge risk-status-" + st;
|
badge.className = "risk-status-badge risk-status-" + st;
|
||||||
badge.innerText = data.risk_status.status_label || "正常";
|
badge.innerText = data.risk_status.status_label || "正常";
|
||||||
badge.title = data.risk_status.reason || "";
|
badge.title = data.risk_status.reason || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let canTradeText = "可开仓";
|
let canTradeText = "可开仓";
|
||||||
if(!data.can_trade){
|
if(!data.can_trade){
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -2117,6 +2122,7 @@ if(window.ManualOrderRrPreview){
|
|||||||
});
|
});
|
||||||
|
|
||||||
refreshAccountSnapshot();
|
refreshAccountSnapshot();
|
||||||
|
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
|
||||||
const _journalFormEl = document.getElementById("journal-form");
|
const _journalFormEl = document.getElementById("journal-form");
|
||||||
if(_journalFormEl){
|
if(_journalFormEl){
|
||||||
_journalFormEl.addEventListener("submit", function(ev){
|
_journalFormEl.addEventListener("submit", function(ev){
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
| 状态 | 含义 |
|
| 状态 | 含义 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 正常 | 可新开仓 |
|
| 正常 | 可新开仓 |
|
||||||
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) |
|
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时);徽章显示剩余倒计时 |
|
||||||
| 4h冻结 | 冷静期中(默认 4 小时) |
|
| 4h冻结 | 冷静期中(默认 4 小时);徽章显示剩余倒计时 |
|
||||||
| 日冻结 | 当日禁止一切新开仓 |
|
| 日冻结 | 当日禁止一切新开仓;徽章显示至下一交易日切点的倒计时 |
|
||||||
|
|
||||||
## 什么算「手动平仓」(计入风控)
|
## 什么算「手动平仓」(计入风控)
|
||||||
|
|
||||||
|
|||||||
@@ -529,6 +529,7 @@ STATIC_DIR = DIR / "static"
|
|||||||
_REPO_STATIC = _REPO_ROOT / "static"
|
_REPO_STATIC = _REPO_ROOT / "static"
|
||||||
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
|
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
|
||||||
_ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
|
_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")
|
@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")
|
@app.get("/assets/ai_review_render.js")
|
||||||
def hub_ai_review_render_js():
|
def hub_ai_review_render_js():
|
||||||
"""与四所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。"""
|
"""与四所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。"""
|
||||||
|
|||||||
@@ -514,6 +514,7 @@
|
|||||||
|
|
||||||
function formatRiskStatusBadge(riskStatus) {
|
function formatRiskStatusBadge(riskStatus) {
|
||||||
if (!riskStatus || typeof riskStatus !== "object") return "";
|
if (!riskStatus || typeof riskStatus !== "object") return "";
|
||||||
|
if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc);
|
||||||
const st = riskStatus.status || "normal";
|
const st = riskStatus.status || "normal";
|
||||||
const label = esc(riskStatus.status_label || "正常");
|
const label = esc(riskStatus.status_label || "正常");
|
||||||
const title = esc(riskStatus.reason || "");
|
const title = esc(riskStatus.reason || "");
|
||||||
@@ -4469,4 +4470,5 @@
|
|||||||
setActiveNav();
|
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'" />
|
<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>
|
<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/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" />
|
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -689,6 +690,6 @@
|
|||||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></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/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=2"></script>
|
||||||
<script src="/assets/app.js?v=20260618-macro-calendar"></script>
|
<script src="/assets/app.js?v=20260618-risk-countdown"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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):
|
def test_parse_mood_issues_filters_unknown(self):
|
||||||
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
|
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):
|
def test_disabled_risk_control(self):
|
||||||
os.environ["RISK_CONTROL_ENABLED"] = "0"
|
os.environ["RISK_CONTROL_ENABLED"] = "0"
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
|
|||||||
Reference in New Issue
Block a user