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 <cursoragent@cursor.com>
This commit is contained in:
+43
-28
@@ -146,26 +146,26 @@ def _cooloff_hours_value(row) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
||||||
"""取仍有效的冷静期结束时刻(多源时用最短未过期时间,避免旧 4h 覆盖复盘后的 1h)。"""
|
"""冷静期结束时刻 = last_close + cooloff_hours;1h 档过期后忽略旧 4h stored。"""
|
||||||
raw_until = _cooloff_until_ms(row)
|
|
||||||
last = _row_get(row, "last_close_at_ms")
|
|
||||||
hours = _cooloff_hours_value(row)
|
hours = _cooloff_hours_value(row)
|
||||||
candidates: list[int] = []
|
journal_h = cooling_hours_manual_journal()
|
||||||
if raw_until is not None:
|
last_raw = _row_get(row, "last_close_at_ms")
|
||||||
try:
|
stored_raw = _cooloff_until_ms(row)
|
||||||
candidates.append(_normalize_epoch_ms(int(raw_until), now_ms))
|
|
||||||
except (TypeError, ValueError):
|
last_ms = (
|
||||||
pass
|
_normalize_epoch_ms(int(last_raw), now_ms) if last_raw is not None else None
|
||||||
if last is not None:
|
)
|
||||||
try:
|
if last_ms is not None:
|
||||||
last_i = _normalize_epoch_ms(int(last), now_ms)
|
end_ms = last_ms + int(hours * 3600 * 1000)
|
||||||
candidates.append(last_i + int(hours * 3600 * 1000))
|
if end_ms > now_ms:
|
||||||
except (TypeError, ValueError):
|
return end_ms
|
||||||
pass
|
if hours <= journal_h + 1e-6:
|
||||||
active = [c for c in candidates if c > now_ms]
|
|
||||||
if not active:
|
|
||||||
return None
|
return None
|
||||||
return min(active)
|
|
||||||
|
if stored_raw is None:
|
||||||
|
return None
|
||||||
|
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:
|
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()
|
td = (trading_day or "").strip()
|
||||||
stored = str(_row_get(row, "trading_day") or "").strip()
|
stored = str(_row_get(row, "trading_day") or "").strip()
|
||||||
if stored != td:
|
if stored != td:
|
||||||
|
now_ms = _now_ms(now)
|
||||||
|
cooloff_active = _resolved_cooloff_until_ms(row, now_ms)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE account_risk_state SET
|
"""UPDATE account_risk_state SET
|
||||||
trading_day=?,
|
trading_day=?,
|
||||||
manual_close_count=0,
|
manual_close_count=0,
|
||||||
daily_frozen=0,
|
daily_frozen=0,
|
||||||
|
cooloff_until_ms=?,
|
||||||
|
cooloff_hours=?,
|
||||||
|
last_close_at_ms=?,
|
||||||
|
pending_journal_trade_id=NULL,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=1""",
|
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)
|
row = _load_state(conn)
|
||||||
return row
|
return row
|
||||||
@@ -270,8 +282,7 @@ def _cooloff_until_ms(row) -> Optional[int]:
|
|||||||
def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool:
|
def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool:
|
||||||
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||||
return False
|
return False
|
||||||
until_ms = _cooloff_until_ms(row)
|
if _resolved_cooloff_until_ms(row, now_ms) is None:
|
||||||
if until_ms is None or until_ms <= now_ms:
|
|
||||||
return False
|
return False
|
||||||
journal_h = cooling_hours_manual_journal()
|
journal_h = cooling_hours_manual_journal()
|
||||||
cooloff_h = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
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:
|
else:
|
||||||
base_ms = now_ms
|
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
|
if until_from_close > now_ms:
|
||||||
current_until = _resolved_cooloff_until_ms(row, now_ms)
|
return until_from_close
|
||||||
if current_until is not None and until_ms > current_until:
|
return now_ms + journal_ms
|
||||||
until_ms = current_until
|
|
||||||
return until_ms
|
|
||||||
|
|
||||||
|
|
||||||
def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
||||||
@@ -445,10 +454,16 @@ def on_journal_saved(
|
|||||||
hours=journal_h,
|
hours=journal_h,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
|
anchor_ms = until_ms - int(journal_h * 3600 * 1000)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1",
|
"""UPDATE account_risk_state SET
|
||||||
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
|
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(
|
def apply_manual_close_journal_cooloff(
|
||||||
|
|||||||
@@ -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`
|
- 样式:`static/account_risk_badge.css`
|
||||||
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||||
- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`)** 计算;倒计时取 `cooloff_until_ms` 与 `last_close + cooloff_hours` 中**更短且未过期**的时间,避免复盘缩短为 1h 后仍显示旧 4h 倒计时
|
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
||||||
- 1h / 4h 标签按**实际剩余时长**判断,不再单独依赖可能过期的 `cooloff_hours` 字段
|
|
||||||
|
|
||||||
## 相关代码
|
## 相关代码
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,66 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 54 * 60, delta=3)
|
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):
|
def test_legacy_naive_utc_ms_countdown_normalized(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user