From c73944581cb2ecd0f898c6866ab4742462997e48 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 18 Jun 2026 22:04:14 +0800 Subject: [PATCH] fix(risk): stop stale 4h cooloff after 1h journal expires Anchor last_close on journal save, ignore leftover stored until when 1h window ended, and clear expired cooloff on trading-day rollover. Co-authored-by: Cursor --- account_risk_lib.py | 71 ++++++++++++++++++++-------------- docs/account-risk-cooldown.md | 5 +-- tests/test_account_risk_lib.py | 60 ++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 31 deletions(-) diff --git a/account_risk_lib.py b/account_risk_lib.py index 1f82945..0f1d39f 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -146,26 +146,26 @@ def _cooloff_hours_value(row) -> float: def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]: - """取仍有效的冷静期结束时刻(多源时用最短未过期时间,避免旧 4h 覆盖复盘后的 1h)。""" - raw_until = _cooloff_until_ms(row) - last = _row_get(row, "last_close_at_ms") + """冷静期结束时刻 = last_close + cooloff_hours;1h 档过期后忽略旧 4h stored。""" hours = _cooloff_hours_value(row) - candidates: list[int] = [] - if raw_until is not None: - try: - candidates.append(_normalize_epoch_ms(int(raw_until), now_ms)) - except (TypeError, ValueError): - pass - if last is not None: - try: - last_i = _normalize_epoch_ms(int(last), now_ms) - candidates.append(last_i + int(hours * 3600 * 1000)) - except (TypeError, ValueError): - pass - active = [c for c in candidates if c > now_ms] - if not active: + journal_h = cooling_hours_manual_journal() + 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 end_ms > now_ms: + return end_ms + if hours <= journal_h + 1e-6: + return None + + if stored_raw is None: return None - return min(active) + stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms) + return stored_ms if stored_ms > now_ms else None def _freeze_tier_from_remaining_ms(remaining_ms: int) -> str: @@ -195,14 +195,26 @@ def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) -> td = (trading_day or "").strip() stored = str(_row_get(row, "trading_day") or "").strip() if stored != td: + now_ms = _now_ms(now) + cooloff_active = _resolved_cooloff_until_ms(row, now_ms) conn.execute( """UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0, + cooloff_until_ms=?, + cooloff_hours=?, + last_close_at_ms=?, + pending_journal_trade_id=NULL, updated_at=? WHERE id=1""", - (td, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), + ( + td, + cooloff_active, + _row_get(row, "cooloff_hours") if cooloff_active else None, + _row_get(row, "last_close_at_ms") if cooloff_active else None, + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), ) row = _load_state(conn) return row @@ -270,8 +282,7 @@ def _cooloff_until_ms(row) -> Optional[int]: def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool: if int(_row_get(row, "daily_frozen") or 0) == 1: return False - until_ms = _cooloff_until_ms(row) - if until_ms is None or until_ms <= now_ms: + if _resolved_cooloff_until_ms(row, now_ms) is None: return False journal_h = cooling_hours_manual_journal() cooloff_h = float(_row_get(row, "cooloff_hours") or cooling_hours_manual()) @@ -297,11 +308,9 @@ def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: else: base_ms = now_ms until_from_close = base_ms + journal_ms - until_ms = until_from_close if until_from_close > now_ms else now_ms + journal_ms - current_until = _resolved_cooloff_until_ms(row, now_ms) - if current_until is not None and until_ms > current_until: - until_ms = current_until - return until_ms + if until_from_close > now_ms: + return until_from_close + return now_ms + journal_ms def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: @@ -445,10 +454,16 @@ def on_journal_saved( hours=journal_h, now=now, ) + anchor_ms = until_ms - int(journal_h * 3600 * 1000) conn.execute( - "UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1", - ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + """UPDATE account_risk_state SET + pending_journal_trade_id=NULL, + last_close_at_ms=?, + updated_at=? + WHERE id=1""", + (int(anchor_ms), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")), ) + return def apply_manual_close_journal_cooloff( diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index 87e8a49..d4bf3d7 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -98,11 +98,10 @@ APP_TIMEZONE=Asia/Shanghai ## 前端倒计时 -- 共用脚本:`static/account_risk_badge.js`(实例 `/static/…`,中控 `/assets/account_risk_badge.js`) +- 共用脚本:`static/account_risk_badge.js?v=2` - 样式:`static/account_risk_badge.css` - 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 -- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`)** 计算;倒计时取 `cooloff_until_ms` 与 `last_close + cooloff_hours` 中**更短且未过期**的时间,避免复盘缩短为 1h 后仍显示旧 4h 倒计时 -- 1h / 4h 标签按**实际剩余时长**判断,不再单独依赖可能过期的 `cooloff_hours` 字段 +- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果 ## 相关代码 diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index fbc1cb6..df558ef 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -236,6 +236,66 @@ class AccountRiskLibTests(unittest.TestCase): self.assertEqual(st["status"], STATUS_FREEZE_1H) self.assertAlmostEqual(st["freeze_remaining_sec"], 54 * 60, delta=3) + def test_stale_4h_ignored_after_1h_journal_expired(self): + """复盘已降为 1h 且窗口结束后,不应再读库内旧 4h until。""" + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 17, 56, 0) + now = datetime(2026, 6, 18, 21, 50, 0) + close_ms = _local_ms(close_at) + stale_4h_until = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (stale_4h_until, close_ms), + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_active_4h_countdown_matches_tier(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 21, 46, 0) + now = datetime(2026, 6, 18, 21, 52, 0) + close_ms = _local_ms(close_at) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + closed_at_ms=close_ms, + trading_day="2026-06-18", + now=close_at, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 3 * 3600 + 54 * 60, delta=5) + + def test_trading_day_reset_clears_expired_stale_cooloff(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 18, 17, 56, 0) + close_ms = _local_ms(close_at) + stale_4h_until = close_ms + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-18', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (stale_4h_until, close_ms), + ) + next_day = datetime(2026, 6, 19, 9, 0, 0) + st = compute_account_risk_status(conn, trading_day="2026-06-19", now=next_day) + self.assertEqual(st["status"], STATUS_NORMAL) + row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone() + self.assertIsNone(row["cooloff_until_ms"]) + def test_legacy_naive_utc_ms_countdown_normalized(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0)