diff --git a/account_risk_lib.py b/account_risk_lib.py index 2bc137e..94e83ea 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -55,6 +55,16 @@ def _env_hours(key: str, default: float) -> float: return max(0.0, v) +def _app_tz(): + from zoneinfo import ZoneInfo + + name = (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + try: + return ZoneInfo(name) + except Exception: + return ZoneInfo("Asia/Shanghai") + + def risk_control_enabled() -> bool: return _env_bool("RISK_CONTROL_ENABLED", True) @@ -111,10 +121,52 @@ def _row_get(row, key, default=None): def _now_ms(now: Optional[datetime] = None) -> int: dt = now or datetime.now() if dt.tzinfo is None: - return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000) + dt = dt.replace(tzinfo=_app_tz()) return int(dt.timestamp() * 1000) +def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int: + """修正旧版把北京时间 naive 当作 UTC 写入的 epoch 毫秒。""" + tz = _app_tz() + off = datetime.now(tz).utcoffset() + if not off: + return int(ms) + offset_ms = int(off.total_seconds() * 1000) + if offset_ms == 0: + return int(ms) + ref = int(ref_now_ms) if ref_now_ms is not None else _now_ms(datetime.now(tz)) + corrected = int(ms) - offset_ms + if abs(int(ms) - ref) <= abs(corrected - ref): + return int(ms) + return corrected + + +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]: + raw_until = _cooloff_until_ms(row) + last = _row_get(row, "last_close_at_ms") + 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 + if not candidates: + return None + end_ms = max(candidates) + return end_ms if end_ms > now_ms else None + + def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]: if ms is None: return None @@ -228,11 +280,17 @@ def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool: def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000) last_close_ms = _row_get(row, "last_close_at_ms") - base_ms = int(last_close_ms) if last_close_ms else now_ms + if last_close_ms: + try: + base_ms = _normalize_epoch_ms(int(last_close_ms), now_ms) + except (TypeError, ValueError): + base_ms = now_ms + 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 = _cooloff_until_ms(row) - if current_until is not None and current_until > now_ms and until_ms > current_until: + 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 @@ -465,11 +523,7 @@ 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 - cooloff_until_ms = _row_get(row, "cooloff_until_ms") - try: - cooloff_until_ms = int(cooloff_until_ms) if cooloff_until_ms is not None else None - except (TypeError, ValueError): - cooloff_until_ms = None + cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) cooloff_hours = _row_get(row, "cooloff_hours") manual_close_count = int(_row_get(row, "manual_close_count") or 0) @@ -478,7 +532,7 @@ def compute_account_risk_status( if daily_frozen: status = STATUS_DAILY reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)" - elif cooloff_until_ms is not None and cooloff_until_ms > now_ms: + elif cooloff_until_ms is not None: h = float(cooloff_hours or cooling_hours_manual()) journal_h = cooling_hours_manual_journal() status = STATUS_FREEZE_1H if h <= journal_h + 1e-6 else STATUS_FREEZE_4H @@ -489,19 +543,23 @@ def compute_account_risk_status( reason += f",至 {until_str}" can_trade = status == STATUS_NORMAL + freeze_remaining_sec = ( + max(0, (cooloff_until_ms - now_ms) // 1000) if cooloff_until_ms is not None else 0 + ) return { "enabled": True, "status": status, "status_label": STATUS_LABELS[status], "can_trade": can_trade, "reason": reason, - "cooloff_until_ms": cooloff_until_ms if cooloff_until_ms and cooloff_until_ms > now_ms else None, + "cooloff_until_ms": cooloff_until_ms, "cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms) - if fmt_local_ms and cooloff_until_ms and cooloff_until_ms > now_ms + if fmt_local_ms and cooloff_until_ms else None, "manual_close_count": manual_close_count, "daily_frozen": daily_frozen, "pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"), + "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 2c09705..db7270b 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -70,6 +70,7 @@ RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MOOD_ISSUES_DAILY_FREEZE=true TRADING_DAY_RESET_HOUR=8 +APP_TIMEZONE=Asia/Shanghai ``` `RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。 @@ -99,7 +100,8 @@ TRADING_DAY_RESET_HOUR=8 - 共用脚本:`static/account_risk_badge.js`(实例 `/static/…`,中控 `/assets/account_risk_badge.js`) - 样式:`static/account_risk_badge.css` -- 中控 `app.js` 的 `formatRiskStatusBadge()` 与实例 `refreshAccountSnapshot()` 均调用 `AccountRiskBadge` +- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 +- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`)** 计算,与实例 `app_now()` 一致;旧版 naive-as-UTC 写入的库内毫秒会自动修正 ## 相关代码 diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index 6ea5f55..25e3191 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -1,8 +1,9 @@ import os import sqlite3 import unittest -from datetime import datetime, timezone +from datetime import datetime from unittest import mock +from zoneinfo import ZoneInfo from account_risk_lib import ( CLOSE_SOURCE_USER_HUB, @@ -14,6 +15,7 @@ from account_risk_lib import ( STATUS_NORMAL, account_risk_blocks_trading, compute_account_risk_status, + enrich_risk_status_countdown, ensure_account_risk_schema, on_journal_saved, on_manual_close, @@ -21,6 +23,8 @@ from account_risk_lib import ( parse_mood_issues, ) +APP_TZ = ZoneInfo("Asia/Shanghai") + def _mem_conn(): conn = sqlite3.connect(":memory:") @@ -29,6 +33,10 @@ def _mem_conn(): return conn +def _local_ms(dt_naive: datetime) -> int: + return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000) + + class AccountRiskLibTests(unittest.TestCase): def setUp(self): self.env_patch = mock.patch.dict(os.environ, {}, clear=False) @@ -38,6 +46,7 @@ class AccountRiskLibTests(unittest.TestCase): os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1" os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2" os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1" + os.environ["APP_TIMEZONE"] = "Asia/Shanghai" def tearDown(self): self.env_patch.stop() @@ -45,7 +54,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_user_instance_sets_4h_cooloff(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_INSTANCE, @@ -57,6 +66,7 @@ class AccountRiskLibTests(unittest.TestCase): st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) self.assertEqual(st["status"], STATUS_FREEZE_4H) self.assertFalse(st["can_trade"]) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) def test_invalid_source_ignored(self): conn = _mem_conn() @@ -73,7 +83,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_second_user_close_daily_freeze(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now ) @@ -86,7 +96,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_hub_close_all_count(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_HUB, @@ -111,11 +121,12 @@ class AccountRiskLibTests(unittest.TestCase): st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) self.assertEqual(st["manual_close_count"], 1) self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) def test_journal_manual_with_note_reduces_to_1h(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_manual_close(conn, trade_record_id=9, closed_at_ms=close_ms, trading_day="2026-06-14", now=now) on_journal_saved( conn, @@ -127,11 +138,12 @@ class AccountRiskLibTests(unittest.TestCase): ) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) self.assertEqual(st["status"], STATUS_FREEZE_1H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 3600, delta=2) def test_journal_hub_close_without_pending_reduces_to_1h(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_HUB, @@ -153,7 +165,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_journal_reduces_when_manual_count_cleared_but_cooloff_active(self): conn = _mem_conn() now = datetime(2026, 6, 15, 10, 0, 0) - now_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + now_ms = _local_ms(now) close_ms = now_ms - 3600 * 1000 until_ms = close_ms + 4 * 3600 * 1000 conn.execute( @@ -181,7 +193,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_journal_late_save_still_gets_1h_from_now(self): conn = _mem_conn() close_at = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(close_at.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(close_at) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_INSTANCE, @@ -200,10 +212,30 @@ class AccountRiskLibTests(unittest.TestCase): ) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=journal_at) self.assertEqual(st["status"], STATUS_FREEZE_1H) - self.assertEqual( - st["cooloff_until_ms"], - int(journal_at.replace(tzinfo=timezone.utc).timestamp() * 1000) + 3600 * 1000, + self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000) + + def test_legacy_naive_utc_ms_countdown_normalized(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + now_ms = _local_ms(now) + offset_ms = 8 * 3600 * 1000 + legacy_close = now_ms + offset_ms + legacy_until = legacy_close + 4 * 3600 * 1000 + conn.execute( + """UPDATE account_risk_state SET + trading_day='2026-06-14', + manual_close_count=1, + cooloff_until_ms=?, + cooloff_hours=4, + last_close_at_ms=?, + daily_frozen=0 + WHERE id=1""", + (legacy_until, legacy_close), ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2) def test_journal_mood_issues_daily_freeze(self): conn = _mem_conn() @@ -222,7 +254,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_cooloff_expired_returns_normal(self): conn = _mem_conn() start = datetime(2026, 6, 14, 8, 0, 0) - close_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(start) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_INSTANCE, closed_at_ms=close_ms, trading_day="2026-06-14", now=start ) @@ -251,7 +283,7 @@ class AccountRiskLibTests(unittest.TestCase): def test_enrich_countdown_for_daily_and_cooloff(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0) - close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + close_ms = _local_ms(now) on_user_initiated_close( conn, source=CLOSE_SOURCE_USER_INSTANCE, @@ -260,8 +292,6 @@ class AccountRiskLibTests(unittest.TestCase): now=now, ) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) - from account_risk_lib import enrich_risk_status_countdown - st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8) self.assertGreater(st["freeze_remaining_sec"], 0) self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"])