diff --git a/account_risk_lib.py b/account_risk_lib.py index 0f1d39f..42f7c3d 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -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, diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index fcd9c4e..ab5f32f 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -6,7 +6,7 @@ - + diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 9547ea0..e36d955 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -6,7 +6,7 @@ - + diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 9547ea0..e36d955 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -6,7 +6,7 @@ - + diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index f5e86e5..de2cd0a 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -6,7 +6,7 @@ - + diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index 163dc72..ec6fc5c 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -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` 表内 **最后一次用户主动平仓** 及其复盘结果 ## 相关代码 diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index fb17567..dd349d8 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -17,7 +17,7 @@ - +
diff --git a/static/account_risk_badge.js b/static/account_risk_badge.js index fa377b6..595c109 100644 --- a/static/account_risk_badge.js +++ b/static/account_risk_badge.js @@ -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 ( `