fix(risk): correct freeze countdown timezone (Asia/Shanghai)

Treat naive app datetimes as local time, normalize legacy UTC-ms rows, and resolve cooloff end from stored until or last_close+duration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-18 18:00:40 +08:00
parent f8e760961e
commit ff8caf7f8d
3 changed files with 118 additions and 28 deletions
+70 -12
View File
@@ -55,6 +55,16 @@ def _env_hours(key: str, default: float) -> float:
return max(0.0, v) 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: def risk_control_enabled() -> bool:
return _env_bool("RISK_CONTROL_ENABLED", True) 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: def _now_ms(now: Optional[datetime] = None) -> int:
dt = now or datetime.now() dt = now or datetime.now()
if dt.tzinfo is None: 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) 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]: def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]:
if ms is None: if ms is None:
return 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: def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int:
journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000) journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000)
last_close_ms = _row_get(row, "last_close_at_ms") 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_from_close = base_ms + journal_ms
until_ms = until_from_close if until_from_close > now_ms else now_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) current_until = _resolved_cooloff_until_ms(row, now_ms)
if current_until is not None and current_until > now_ms and until_ms > current_until: if current_until is not None and until_ms > current_until:
until_ms = current_until until_ms = current_until
return until_ms return until_ms
@@ -465,11 +523,7 @@ def compute_account_risk_status(
row = _sync_trading_day(conn, trading_day, now=now) row = _sync_trading_day(conn, trading_day, now=now)
now_ms = _now_ms(now) now_ms = _now_ms(now)
daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1 daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1
cooloff_until_ms = _row_get(row, "cooloff_until_ms") cooloff_until_ms = _resolved_cooloff_until_ms(row, now_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_hours = _row_get(row, "cooloff_hours") cooloff_hours = _row_get(row, "cooloff_hours")
manual_close_count = int(_row_get(row, "manual_close_count") or 0) manual_close_count = int(_row_get(row, "manual_close_count") or 0)
@@ -478,7 +532,7 @@ def compute_account_risk_status(
if daily_frozen: if daily_frozen:
status = STATUS_DAILY status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)" 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()) h = float(cooloff_hours or cooling_hours_manual())
journal_h = cooling_hours_manual_journal() journal_h = cooling_hours_manual_journal()
status = STATUS_FREEZE_1H if h <= journal_h + 1e-6 else STATUS_FREEZE_4H 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}" reason += f",至 {until_str}"
can_trade = status == STATUS_NORMAL 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 { return {
"enabled": True, "enabled": True,
"status": status, "status": status,
"status_label": STATUS_LABELS[status], "status_label": STATUS_LABELS[status],
"can_trade": can_trade, "can_trade": can_trade,
"reason": reason, "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) "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, else None,
"manual_close_count": manual_close_count, "manual_close_count": manual_close_count,
"daily_frozen": daily_frozen, "daily_frozen": daily_frozen,
"pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"), "pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"),
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
} }
+3 -1
View File
@@ -70,6 +70,7 @@ RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8 TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai
``` ```
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。 `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.js`(实例 `/static/…`,中控 `/assets/account_risk_badge.js`
- 样式:`static/account_risk_badge.css` - 样式:`static/account_risk_badge.css`
- 中控 `app.js``formatRiskStatusBadge()` 与实例 `refreshAccountSnapshot()` 均调用 `AccountRiskBadge` - 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`** 计算,与实例 `app_now()` 一致;旧版 naive-as-UTC 写入的库内毫秒会自动修正
## 相关代码 ## 相关代码
+45 -15
View File
@@ -1,8 +1,9 @@
import os import os
import sqlite3 import sqlite3
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime
from unittest import mock from unittest import mock
from zoneinfo import ZoneInfo
from account_risk_lib import ( from account_risk_lib import (
CLOSE_SOURCE_USER_HUB, CLOSE_SOURCE_USER_HUB,
@@ -14,6 +15,7 @@ from account_risk_lib import (
STATUS_NORMAL, STATUS_NORMAL,
account_risk_blocks_trading, account_risk_blocks_trading,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
on_journal_saved, on_journal_saved,
on_manual_close, on_manual_close,
@@ -21,6 +23,8 @@ from account_risk_lib import (
parse_mood_issues, parse_mood_issues,
) )
APP_TZ = ZoneInfo("Asia/Shanghai")
def _mem_conn(): def _mem_conn():
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
@@ -29,6 +33,10 @@ def _mem_conn():
return conn return conn
def _local_ms(dt_naive: datetime) -> int:
return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000)
class AccountRiskLibTests(unittest.TestCase): class AccountRiskLibTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.env_patch = mock.patch.dict(os.environ, {}, clear=False) 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_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2" os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1" os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
def tearDown(self): def tearDown(self):
self.env_patch.stop() self.env_patch.stop()
@@ -45,7 +54,7 @@ class AccountRiskLibTests(unittest.TestCase):
def test_user_instance_sets_4h_cooloff(self): def test_user_instance_sets_4h_cooloff(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, conn,
source=CLOSE_SOURCE_USER_INSTANCE, 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) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_4H) self.assertEqual(st["status"], STATUS_FREEZE_4H)
self.assertFalse(st["can_trade"]) self.assertFalse(st["can_trade"])
self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2)
def test_invalid_source_ignored(self): def test_invalid_source_ignored(self):
conn = _mem_conn() conn = _mem_conn()
@@ -73,7 +83,7 @@ class AccountRiskLibTests(unittest.TestCase):
def test_second_user_close_daily_freeze(self): def test_second_user_close_daily_freeze(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now 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): def test_hub_close_all_count(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, conn,
source=CLOSE_SOURCE_USER_HUB, 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) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["manual_close_count"], 1) self.assertEqual(st["manual_close_count"], 1)
self.assertEqual(st["status"], STATUS_FREEZE_4H) 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): def test_journal_manual_with_note_reduces_to_1h(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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_manual_close(conn, trade_record_id=9, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
on_journal_saved( on_journal_saved(
conn, conn,
@@ -127,11 +138,12 @@ class AccountRiskLibTests(unittest.TestCase):
) )
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_1H) 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): def test_journal_hub_close_without_pending_reduces_to_1h(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, conn,
source=CLOSE_SOURCE_USER_HUB, 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): def test_journal_reduces_when_manual_count_cleared_but_cooloff_active(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 15, 10, 0, 0) 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 close_ms = now_ms - 3600 * 1000
until_ms = close_ms + 4 * 3600 * 1000 until_ms = close_ms + 4 * 3600 * 1000
conn.execute( conn.execute(
@@ -181,7 +193,7 @@ class AccountRiskLibTests(unittest.TestCase):
def test_journal_late_save_still_gets_1h_from_now(self): def test_journal_late_save_still_gets_1h_from_now(self):
conn = _mem_conn() conn = _mem_conn()
close_at = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, conn,
source=CLOSE_SOURCE_USER_INSTANCE, 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) st = compute_account_risk_status(conn, trading_day="2026-06-14", now=journal_at)
self.assertEqual(st["status"], STATUS_FREEZE_1H) self.assertEqual(st["status"], STATUS_FREEZE_1H)
self.assertEqual( self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000)
st["cooloff_until_ms"],
int(journal_at.replace(tzinfo=timezone.utc).timestamp() * 1000) + 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): def test_journal_mood_issues_daily_freeze(self):
conn = _mem_conn() conn = _mem_conn()
@@ -222,7 +254,7 @@ class AccountRiskLibTests(unittest.TestCase):
def test_cooloff_expired_returns_normal(self): def test_cooloff_expired_returns_normal(self):
conn = _mem_conn() conn = _mem_conn()
start = datetime(2026, 6, 14, 8, 0, 0) 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( on_user_initiated_close(
conn, source=CLOSE_SOURCE_USER_INSTANCE, closed_at_ms=close_ms, trading_day="2026-06-14", now=start 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): def test_enrich_countdown_for_daily_and_cooloff(self):
conn = _mem_conn() conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0) 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( on_user_initiated_close(
conn, conn,
source=CLOSE_SOURCE_USER_INSTANCE, source=CLOSE_SOURCE_USER_INSTANCE,
@@ -260,8 +292,6 @@ class AccountRiskLibTests(unittest.TestCase):
now=now, now=now,
) )
st = compute_account_risk_status(conn, trading_day="2026-06-14", 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) st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8)
self.assertGreater(st["freeze_remaining_sec"], 0) self.assertGreater(st["freeze_remaining_sec"], 0)
self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"]) self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"])