fix(risk): align freeze countdown with latest close not stale 4h until
Pick the shortest active cooloff end and derive 1h/4h label from remaining time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+14
-7
@@ -146,6 +146,7 @@ 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)。"""
|
||||||
raw_until = _cooloff_until_ms(row)
|
raw_until = _cooloff_until_ms(row)
|
||||||
last = _row_get(row, "last_close_at_ms")
|
last = _row_get(row, "last_close_at_ms")
|
||||||
hours = _cooloff_hours_value(row)
|
hours = _cooloff_hours_value(row)
|
||||||
@@ -161,10 +162,18 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
|||||||
candidates.append(last_i + int(hours * 3600 * 1000))
|
candidates.append(last_i + int(hours * 3600 * 1000))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
if not candidates:
|
active = [c for c in candidates if c > now_ms]
|
||||||
|
if not active:
|
||||||
return None
|
return None
|
||||||
end_ms = max(candidates)
|
return min(active)
|
||||||
return end_ms if end_ms > now_ms else None
|
|
||||||
|
|
||||||
|
def _freeze_tier_from_remaining_ms(remaining_ms: int) -> str:
|
||||||
|
journal_h = cooling_hours_manual_journal()
|
||||||
|
rh = remaining_ms / 3600000.0
|
||||||
|
if rh <= journal_h + (5 / 60):
|
||||||
|
return STATUS_FREEZE_1H
|
||||||
|
return STATUS_FREEZE_4H
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -524,7 +533,6 @@ def compute_account_risk_status(
|
|||||||
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 = _resolved_cooloff_until_ms(row, now_ms)
|
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)
|
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
|
||||||
|
|
||||||
status = STATUS_NORMAL
|
status = STATUS_NORMAL
|
||||||
@@ -533,9 +541,8 @@ def compute_account_risk_status(
|
|||||||
status = STATUS_DAILY
|
status = STATUS_DAILY
|
||||||
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
|
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
|
||||||
elif cooloff_until_ms is not None:
|
elif cooloff_until_ms is not None:
|
||||||
h = float(cooloff_hours or cooling_hours_manual())
|
remaining_ms = cooloff_until_ms - now_ms
|
||||||
journal_h = cooling_hours_manual_journal()
|
status = _freeze_tier_from_remaining_ms(remaining_ms)
|
||||||
status = STATUS_FREEZE_1H if h <= journal_h + 1e-6 else STATUS_FREEZE_4H
|
|
||||||
until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None
|
until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None
|
||||||
label = STATUS_LABELS[status]
|
label = STATUS_LABELS[status]
|
||||||
reason = f"账户{label}中"
|
reason = f"账户{label}中"
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ APP_TIMEZONE=Asia/Shanghai
|
|||||||
- 共用脚本:`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`
|
||||||
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||||
- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`)** 计算,与实例 `app_now()` 一致;旧版 naive-as-UTC 写入的库内毫秒会自动修正
|
- 时间戳按 **`APP_TIMEZONE`(默认 `Asia/Shanghai`)** 计算;倒计时取 `cooloff_until_ms` 与 `last_close + cooloff_hours` 中**更短且未过期**的时间,避免复盘缩短为 1h 后仍显示旧 4h 倒计时
|
||||||
|
- 1h / 4h 标签按**实际剩余时长**判断,不再单独依赖可能过期的 `cooloff_hours` 字段
|
||||||
|
|
||||||
## 相关代码
|
## 相关代码
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,28 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||||
self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000)
|
self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000)
|
||||||
|
|
||||||
|
def test_stale_4h_until_with_1h_hours_uses_shorter_end(self):
|
||||||
|
"""库内 cooloff_hours=1 但 cooloff_until_ms 仍为旧 4h 时,应按 last_close+1h 倒计时。"""
|
||||||
|
conn = _mem_conn()
|
||||||
|
now = datetime(2026, 6, 14, 12, 6, 0)
|
||||||
|
now_ms = _local_ms(now)
|
||||||
|
close_ms = now_ms - 6 * 60 * 1000
|
||||||
|
stale_until_4h = close_ms + 4 * 3600 * 1000
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE account_risk_state SET
|
||||||
|
trading_day='2026-06-14',
|
||||||
|
manual_close_count=1,
|
||||||
|
cooloff_until_ms=?,
|
||||||
|
cooloff_hours=1,
|
||||||
|
last_close_at_ms=?,
|
||||||
|
daily_frozen=0
|
||||||
|
WHERE id=1""",
|
||||||
|
(stale_until_4h, close_ms),
|
||||||
|
)
|
||||||
|
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"], 54 * 60, delta=3)
|
||||||
|
|
||||||
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