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:
+70
-12
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 写入的库内毫秒会自动修正
|
||||||
|
|
||||||
## 相关代码
|
## 相关代码
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user