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:
dekun
2026-06-18 22:11:09 +08:00
parent deb240d4eb
commit 9330e356fc
9 changed files with 148 additions and 24 deletions
+99 -11
View File
@@ -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_hours1h 档过期后忽略旧 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,