Fix freeze countdown exceeding configured cooloff hours.
Clamp future last_close anchors, cap remaining time server-side, prefer freeze_remaining_sec in the badge JS, and auto-repair stale DB rows on read. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+99
-11
@@ -141,22 +141,38 @@ def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int:
|
||||
return corrected
|
||||
|
||||
|
||||
def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> int:
|
||||
"""平仓时刻不得显著晚于当前时间(脏数据/时区混用)。"""
|
||||
slack_ms = 60 * 1000
|
||||
if last_ms > now_ms + slack_ms:
|
||||
return now_ms
|
||||
return last_ms
|
||||
|
||||
|
||||
def _cooloff_duration_ms(hours: float) -> int:
|
||||
return int(max(0.0, float(hours)) * 3600 * 1000)
|
||||
|
||||
|
||||
def _cooloff_hours_value(row) -> float:
|
||||
return float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
|
||||
|
||||
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
||||
"""冷静期结束时刻 = last_close + cooloff_hours;1h 档过期后忽略旧 4h stored。"""
|
||||
"""冷静期结束 = last_close + cooloff_hours;剩余不得超过配置时长。"""
|
||||
hours = _cooloff_hours_value(row)
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
duration_ms = _cooloff_duration_ms(hours)
|
||||
last_raw = _row_get(row, "last_close_at_ms")
|
||||
stored_raw = _cooloff_until_ms(row)
|
||||
|
||||
last_ms = (
|
||||
_normalize_epoch_ms(int(last_raw), now_ms) if last_raw is not None else None
|
||||
)
|
||||
if last_ms is not None:
|
||||
end_ms = last_ms + int(hours * 3600 * 1000)
|
||||
if last_raw is not None:
|
||||
last_ms = _sanitize_last_close_ms(
|
||||
_normalize_epoch_ms(int(last_raw), now_ms), now_ms
|
||||
)
|
||||
end_ms = last_ms + duration_ms
|
||||
max_end_ms = now_ms + duration_ms
|
||||
if end_ms > max_end_ms:
|
||||
end_ms = max_end_ms
|
||||
if end_ms > now_ms:
|
||||
return end_ms
|
||||
if hours <= journal_h + 1e-6:
|
||||
@@ -165,10 +181,13 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
||||
if stored_raw is None:
|
||||
return None
|
||||
stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms)
|
||||
max_end_ms = now_ms + duration_ms
|
||||
if stored_ms > max_end_ms:
|
||||
stored_ms = max_end_ms
|
||||
return stored_ms if stored_ms > now_ms else None
|
||||
|
||||
|
||||
def _freeze_tier_from_remaining_ms(remaining_ms: int) -> str:
|
||||
def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str:
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
rh = remaining_ms / 3600000.0
|
||||
if rh <= journal_h + (5 / 60):
|
||||
@@ -176,6 +195,17 @@ def _freeze_tier_from_remaining_ms(remaining_ms: int) -> str:
|
||||
return STATUS_FREEZE_4H
|
||||
|
||||
|
||||
def _freeze_status_label(hours: float, status: str) -> str:
|
||||
if status == STATUS_FREEZE_1H:
|
||||
return STATUS_LABELS[STATUS_FREEZE_1H]
|
||||
if status == STATUS_FREEZE_4H:
|
||||
h = int(hours) if float(hours) == int(hours) else round(float(hours), 1)
|
||||
if abs(float(hours) - 4.0) < 1e-6:
|
||||
return STATUS_LABELS[STATUS_FREEZE_4H]
|
||||
return f"{h}h冻结"
|
||||
return STATUS_LABELS.get(status, STATUS_LABELS[STATUS_NORMAL])
|
||||
|
||||
|
||||
def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]:
|
||||
if ms is None:
|
||||
return None
|
||||
@@ -279,6 +309,52 @@ def _cooloff_until_ms(row) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _repair_stale_cooloff_row(
|
||||
conn,
|
||||
row,
|
||||
*,
|
||||
now_ms: int,
|
||||
resolved_until_ms: Optional[int],
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""脏数据(未来 last_close / 超长 until)读时写回修正。"""
|
||||
last_raw = _row_get(row, "last_close_at_ms")
|
||||
stored_raw = _cooloff_until_ms(row)
|
||||
if last_raw is None and stored_raw is None:
|
||||
return
|
||||
dirty = False
|
||||
new_last: Optional[int] = None
|
||||
if last_raw is not None:
|
||||
try:
|
||||
norm = _normalize_epoch_ms(int(last_raw), now_ms)
|
||||
sanitized = _sanitize_last_close_ms(norm, now_ms)
|
||||
new_last = sanitized
|
||||
if sanitized != int(last_raw):
|
||||
dirty = True
|
||||
except (TypeError, ValueError):
|
||||
new_last = None
|
||||
if stored_raw is not None:
|
||||
stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms)
|
||||
if resolved_until_ms is None:
|
||||
dirty = True
|
||||
elif abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
|
||||
dirty = True
|
||||
if not dirty:
|
||||
return
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
cooloff_until_ms=?,
|
||||
last_close_at_ms=?,
|
||||
updated_at=?
|
||||
WHERE id=1""",
|
||||
(
|
||||
resolved_until_ms,
|
||||
new_last if resolved_until_ms else None,
|
||||
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool:
|
||||
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||
return False
|
||||
@@ -302,7 +378,9 @@ def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int:
|
||||
last_close_ms = _row_get(row, "last_close_at_ms")
|
||||
if last_close_ms:
|
||||
try:
|
||||
base_ms = _normalize_epoch_ms(int(last_close_ms), now_ms)
|
||||
base_ms = _sanitize_last_close_ms(
|
||||
_normalize_epoch_ms(int(last_close_ms), now_ms), now_ms
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
base_ms = now_ms
|
||||
else:
|
||||
@@ -548,6 +626,12 @@ def compute_account_risk_status(
|
||||
now_ms = _now_ms(now)
|
||||
daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1
|
||||
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
|
||||
if not daily_frozen:
|
||||
_repair_stale_cooloff_row(
|
||||
conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now
|
||||
)
|
||||
row = _load_state(conn)
|
||||
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
|
||||
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
|
||||
|
||||
status = STATUS_NORMAL
|
||||
@@ -557,9 +641,11 @@ def compute_account_risk_status(
|
||||
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
|
||||
elif cooloff_until_ms is not None:
|
||||
remaining_ms = cooloff_until_ms - now_ms
|
||||
status = _freeze_tier_from_remaining_ms(remaining_ms)
|
||||
hours = _cooloff_hours_value(row)
|
||||
status = _freeze_tier_from_remaining_ms(remaining_ms, hours)
|
||||
status_label = _freeze_status_label(hours, status)
|
||||
until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None
|
||||
label = STATUS_LABELS[status]
|
||||
label = status_label
|
||||
reason = f"账户{label}中"
|
||||
if until_str:
|
||||
reason += f",至 {until_str}"
|
||||
@@ -571,7 +657,9 @@ def compute_account_risk_status(
|
||||
return {
|
||||
"enabled": True,
|
||||
"status": status,
|
||||
"status_label": STATUS_LABELS[status],
|
||||
"status_label": _freeze_status_label(_cooloff_hours_value(row), status)
|
||||
if status in (STATUS_FREEZE_1H, STATUS_FREEZE_4H)
|
||||
else STATUS_LABELS[status],
|
||||
"can_trade": can_trade,
|
||||
"reason": reason,
|
||||
"cooloff_until_ms": cooloff_until_ms,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=3">
|
||||
<script src="/static/account_risk_badge.js?v=2"></script>
|
||||
<script src="/static/account_risk_badge.js?v=3"></script>
|
||||
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="监控">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=3">
|
||||
<script src="/static/account_risk_badge.js?v=2"></script>
|
||||
<script src="/static/account_risk_badge.js?v=3"></script>
|
||||
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="监控">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=3">
|
||||
<script src="/static/account_risk_badge.js?v=2"></script>
|
||||
<script src="/static/account_risk_badge.js?v=3"></script>
|
||||
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="监控">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=3">
|
||||
<script src="/static/account_risk_badge.js?v=2"></script>
|
||||
<script src="/static/account_risk_badge.js?v=3"></script>
|
||||
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<meta name="apple-mobile-web-app-title" content="监控">
|
||||
|
||||
@@ -107,9 +107,11 @@ APP_TIMEZONE=Asia/Shanghai
|
||||
|
||||
## 前端倒计时
|
||||
|
||||
- 共用脚本:`static/account_risk_badge.js?v=2`
|
||||
- 共用脚本:`static/account_risk_badge.js?v=3`
|
||||
- 样式:`static/account_risk_badge.css`
|
||||
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
|
||||
- 服务端会将 `last_close_at_ms` 钳在未来时刻、并将剩余冷静期上限设为配置的 `cooloff_hours`,读时自动写回修正
|
||||
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
||||
|
||||
## 相关代码
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<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=3" />
|
||||
<script src="/assets/account_risk_badge.js?v=2"></script>
|
||||
<script src="/assets/account_risk_badge.js?v=3"></script>
|
||||
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -21,10 +21,20 @@
|
||||
return "正常";
|
||||
}
|
||||
|
||||
function resolveFreezeUntilMs(riskStatus) {
|
||||
if (!riskStatus) return null;
|
||||
const sec = Number(riskStatus.freeze_remaining_sec);
|
||||
if (Number.isFinite(sec) && sec > 0) {
|
||||
return Date.now() + sec * 1000;
|
||||
}
|
||||
const until = Number(riskStatus.freeze_until_ms);
|
||||
return Number.isFinite(until) && until > 0 ? until : null;
|
||||
}
|
||||
|
||||
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 until = resolveFreezeUntilMs(riskStatus);
|
||||
if (!until || until <= Date.now()) return label;
|
||||
const cd = formatRemaining((until - Date.now()) / 1000);
|
||||
return cd ? `${label} · ${cd}` : label;
|
||||
}
|
||||
@@ -58,8 +68,9 @@
|
||||
const st = riskStatus.status || "normal";
|
||||
el.className = "risk-status-badge risk-status-" + st;
|
||||
el.dataset.statusLabel = baseLabel(riskStatus, el);
|
||||
if (riskStatus.freeze_until_ms != null && riskStatus.freeze_until_ms !== "") {
|
||||
el.dataset.freezeUntilMs = String(riskStatus.freeze_until_ms);
|
||||
const until = resolveFreezeUntilMs(riskStatus);
|
||||
if (until) {
|
||||
el.dataset.freezeUntilMs = String(until);
|
||||
} else if (el.dataset) {
|
||||
delete el.dataset.freezeUntilMs;
|
||||
}
|
||||
@@ -74,10 +85,10 @@
|
||||
const label = safe(riskStatus.status_label || "正常");
|
||||
const title = safe(riskStatus.reason || "");
|
||||
const text = safe(badgeText(riskStatus));
|
||||
const until = riskStatus.freeze_until_ms;
|
||||
const until = resolveFreezeUntilMs(riskStatus);
|
||||
const untilAttr =
|
||||
until != null && until !== ""
|
||||
? ` data-freeze-until-ms="${safe(String(until))}"`
|
||||
until != null
|
||||
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
|
||||
: "";
|
||||
return (
|
||||
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
|
||||
|
||||
@@ -296,6 +296,29 @@ class AccountRiskLibTests(unittest.TestCase):
|
||||
row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone()
|
||||
self.assertIsNone(row["cooloff_until_ms"])
|
||||
|
||||
def test_remaining_never_exceeds_configured_hours(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 18, 22, 0, 0)
|
||||
now_ms = _local_ms(now)
|
||||
future_close = now_ms + 49 * 60 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=4,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(future_close + 4 * 3600 * 1000, future_close),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertLessEqual(st["freeze_remaining_sec"], 4 * 3600 + 2)
|
||||
self.assertGreater(st["freeze_remaining_sec"], 3 * 3600 + 58 * 60)
|
||||
row = conn.execute("SELECT last_close_at_ms, cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone()
|
||||
self.assertLessEqual(int(row["last_close_at_ms"]), now_ms + 60 * 1000)
|
||||
|
||||
def test_legacy_naive_utc_ms_countdown_normalized(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user