diff --git a/account_risk_lib.py b/account_risk_lib.py index 42f7c3d..5807a0f 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -141,11 +141,11 @@ 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: - """平仓时刻不得显著晚于当前时间(脏数据/时区混用)。""" +def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> Optional[int]: + """平仓时刻须不晚于当前(允许 1 分钟时钟偏差);显著未来视为无效锚点。""" slack_ms = 60 * 1000 if last_ms > now_ms + slack_ms: - return now_ms + return None return last_ms @@ -158,7 +158,7 @@ def _cooloff_hours_value(row) -> float: def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]: - """冷静期结束 = last_close + cooloff_hours;剩余不得超过配置时长。""" + """冷静期结束 = last_close + cooloff_hours;无效/已过期锚点不再重启计时。""" hours = _cooloff_hours_value(row) journal_h = cooling_hours_manual_journal() duration_ms = _cooloff_duration_ms(hours) @@ -166,27 +166,42 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]: stored_raw = _cooloff_until_ms(row) 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 + try: + last_ms = _sanitize_last_close_ms( + _normalize_epoch_ms(int(last_raw), now_ms), now_ms + ) + except (TypeError, ValueError): + last_ms = None + if last_ms is not None: + end_ms = last_ms + duration_ms + if end_ms > now_ms: + return end_ms if hours <= journal_h + 1e-6: return None 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 _clear_inactive_cooloff( + conn, + *, + now: Optional[datetime] = None, +) -> None: + """冷静期已结束或锚点无效时清库,避免重启后误读旧冻结。""" + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=NULL, + cooloff_hours=NULL, + last_close_at_ms=NULL, + updated_at=? + WHERE id=1""", + ((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),), + ) + + def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str: journal_h = cooling_hours_manual_journal() rh = remaining_ms / 3600000.0 @@ -301,6 +316,67 @@ def _set_cooloff_until( ) +def _ms_trading_day_label(ms: int) -> str: + dt = datetime.fromtimestamp(ms / 1000, tz=_app_tz()) + return dt.strftime("%Y-%m-%d") + + +def _parse_journal_close_ms(raw: Any) -> Optional[int]: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(s[:19] if len(s) > 16 else s, fmt) + return _now_ms(dt) + except ValueError: + continue + return None + + +def _latest_journaled_manual_close_ms(conn, trading_day: str) -> Optional[int]: + """当日最近一条已复盘的手动平仓时刻(journal 有说明)。""" + try: + rows = conn.execute( + """SELECT close_datetime FROM journal_entries + WHERE early_exit_trigger='手动平仓' + AND early_exit_note IS NOT NULL AND TRIM(early_exit_note) <> '' + ORDER BY close_datetime DESC""" + ).fetchall() + except Exception: + return None + td = (trading_day or "").strip() + best: Optional[int] = None + for row in rows: + ms = _parse_journal_close_ms(_row_get(row, "close_datetime")) + if ms is None: + continue + if td and _ms_trading_day_label(ms) != td: + continue + if best is None or ms > best: + best = ms + return best + + +def _journaled_manual_cooloff_expired( + conn, *, trading_day: str, now_ms: int, pending: Any +) -> bool: + """当日手动平仓已复盘且 1h 冷静期结束,且无待复盘的新平仓。""" + if pending is not None: + try: + if int(pending) != 0: + return False + except (TypeError, ValueError): + return False + close_ms = _latest_journaled_manual_close_ms(conn, trading_day) + if close_ms is None: + return False + journal_ms = _cooloff_duration_ms(cooling_hours_manual_journal()) + return close_ms + journal_ms <= now_ms + + def _cooloff_until_ms(row) -> Optional[int]: raw = _row_get(row, "cooloff_until_ms") try: @@ -317,39 +393,46 @@ def _repair_stale_cooloff_row( resolved_until_ms: Optional[int], now: Optional[datetime] = None, ) -> None: - """脏数据(未来 last_close / 超长 until)读时写回修正。""" + """脏数据读时写回:过期/无效则清库,否则对齐 until / last_close。""" 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 + if resolved_until_ms is None: + if last_raw is not None or stored_raw is not None: + _clear_inactive_cooloff(conn, now=now) + 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): + if sanitized is None: dirty = True + else: + new_last = sanitized + if sanitized != int(last_raw): + dirty = True except (TypeError, ValueError): - new_last = None + dirty = True 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: + if 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=?, + cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""", ( resolved_until_ms, - new_last if resolved_until_ms else None, + _row_get(row, "cooloff_hours"), + new_last, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), ), ) @@ -382,6 +465,8 @@ def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: _normalize_epoch_ms(int(last_close_ms), now_ms), now_ms ) except (TypeError, ValueError): + base_ms = None + if base_ms is None: base_ms = now_ms else: base_ms = now_ms @@ -625,7 +710,16 @@ def compute_account_risk_status( row = _sync_trading_day(conn, trading_day, now=now) now_ms = _now_ms(now) daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1 + pending = _row_get(row, "pending_journal_trade_id") cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) + if ( + not daily_frozen + and cooloff_until_ms is not None + and _journaled_manual_cooloff_expired( + conn, trading_day=trading_day, now_ms=now_ms, pending=pending + ) + ): + cooloff_until_ms = None if not daily_frozen: _repair_stale_cooloff_row( conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now @@ -668,7 +762,7 @@ def compute_account_risk_status( else None, "manual_close_count": manual_close_count, "daily_frozen": daily_frozen, - "pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"), + "pending_journal_trade_id": pending, "freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0, } diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index ec6fc5c..2c47e7c 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -111,7 +111,9 @@ APP_TIMEZONE=Asia/Shanghai - 样式:`static/account_risk_badge.css` - 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 - 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差 -- 服务端会将 `last_close_at_ms` 钳在未来时刻、并将剩余冷静期上限设为配置的 `cooloff_hours`,读时自动写回修正 +- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结 +- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时 +- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常** - 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果 ## 相关代码 diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index cf4f328..b5749e9 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -33,6 +33,16 @@ def _mem_conn(): return conn +def _mem_conn_with_journal(): + conn = _mem_conn() + conn.execute( + """CREATE TABLE IF NOT EXISTS journal_entries ( + close_datetime TEXT, early_exit_trigger TEXT, early_exit_note TEXT + )""" + ) + return conn + + def _local_ms(dt_naive: datetime) -> int: return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000) @@ -257,6 +267,58 @@ class AccountRiskLibTests(unittest.TestCase): st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now) self.assertEqual(st["status"], STATUS_NORMAL) self.assertTrue(st["can_trade"]) + row = conn.execute( + "SELECT cooloff_until_ms, cooloff_hours, last_close_at_ms FROM account_risk_state WHERE id=1" + ).fetchone() + self.assertIsNone(row["cooloff_until_ms"]) + self.assertIsNone(row["last_close_at_ms"]) + + def test_corrupted_anchor_cleared_when_journaled_manual_expired(self): + """上一版误把 last_close 写成近期时刻时,已复盘且 1h 已过的仍应显示正常。""" + conn = _mem_conn_with_journal() + now = datetime(2026, 6, 18, 22, 30, 0) + now_ms = _local_ms(now) + bad_last = now_ms - 60 * 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=?, + pending_journal_trade_id=NULL, + daily_frozen=0 + WHERE id=1""", + (bad_last + 3600 * 1000, bad_last), + ) + conn.execute( + "INSERT INTO journal_entries (close_datetime, early_exit_trigger, early_exit_note) VALUES (?,?,?)", + ("2026-06-18 17:56:00", "手动平仓", "按计划离场"), + ) + 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_future_last_close_does_not_restart_cooloff(self): + """脏数据 last_close 在未来时,不应重启 1h/4h 冻结。""" + conn = _mem_conn() + now = datetime(2026, 6, 18, 22, 30, 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=1, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (future_close + 3600 * 1000, future_close), + ) + 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() @@ -313,11 +375,8 @@ class AccountRiskLibTests(unittest.TestCase): (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) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) def test_legacy_naive_utc_ms_countdown_normalized(self): conn = _mem_conn() @@ -366,6 +425,8 @@ class AccountRiskLibTests(unittest.TestCase): later = datetime(2026, 6, 14, 13, 0, 0) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later) 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_trading_day_reset_clears_daily_frozen(self): conn = _mem_conn()