Fix false freeze after restart from stale account_risk_state.

Clear expired cooloff on read, never restart timer from invalid future anchors, and reconcile with journaled manual closes when the 1h window already ended.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-18 22:14:58 +08:00
parent 9330e356fc
commit ce172a7cee
3 changed files with 188 additions and 31 deletions
+111 -17
View File
@@ -141,11 +141,11 @@ def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int:
return corrected
def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> int:
"""平仓时刻不得显著晚于当前时间(脏数据/时区混用)"""
def _sanitize_last_close_ms(last_ms: int, now_ms: int) -> Optional[int]:
"""平仓时刻须不晚于当前(允许 1 分钟时钟偏差);显著未来视为无效锚点"""
slack_ms = 60 * 1000
if last_ms > now_ms + slack_ms:
return now_ms
return None
return last_ms
@@ -158,7 +158,7 @@ def _cooloff_hours_value(row) -> float:
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
"""冷静期结束 = last_close + cooloff_hours剩余不得超过配置时长"""
"""冷静期结束 = last_close + cooloff_hours无效/已过期锚点不再重启计时"""
hours = _cooloff_hours_value(row)
journal_h = cooling_hours_manual_journal()
duration_ms = _cooloff_duration_ms(hours)
@@ -166,13 +166,14 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
stored_raw = _cooloff_until_ms(row)
if last_raw is not None:
try:
last_ms = _sanitize_last_close_ms(
_normalize_epoch_ms(int(last_raw), now_ms), now_ms
)
except (TypeError, ValueError):
last_ms = None
if last_ms is not None:
end_ms = last_ms + duration_ms
max_end_ms = now_ms + duration_ms
if end_ms > max_end_ms:
end_ms = max_end_ms
if end_ms > now_ms:
return end_ms
if hours <= journal_h + 1e-6:
@@ -181,12 +182,26 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
if stored_raw is None:
return None
stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms)
max_end_ms = now_ms + duration_ms
if stored_ms > max_end_ms:
stored_ms = max_end_ms
return stored_ms if stored_ms > now_ms else None
def _clear_inactive_cooloff(
conn,
*,
now: Optional[datetime] = None,
) -> None:
"""冷静期已结束或锚点无效时清库,避免重启后误读旧冻结。"""
conn.execute(
"""UPDATE account_risk_state SET
cooloff_until_ms=NULL,
cooloff_hours=NULL,
last_close_at_ms=NULL,
updated_at=?
WHERE id=1""",
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
)
def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str:
journal_h = cooling_hours_manual_journal()
rh = remaining_ms / 3600000.0
@@ -301,6 +316,67 @@ def _set_cooloff_until(
)
def _ms_trading_day_label(ms: int) -> str:
dt = datetime.fromtimestamp(ms / 1000, tz=_app_tz())
return dt.strftime("%Y-%m-%d")
def _parse_journal_close_ms(raw: Any) -> Optional[int]:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
dt = datetime.strptime(s[:19] if len(s) > 16 else s, fmt)
return _now_ms(dt)
except ValueError:
continue
return None
def _latest_journaled_manual_close_ms(conn, trading_day: str) -> Optional[int]:
"""当日最近一条已复盘的手动平仓时刻(journal 有说明)。"""
try:
rows = conn.execute(
"""SELECT close_datetime FROM journal_entries
WHERE early_exit_trigger='手动平仓'
AND early_exit_note IS NOT NULL AND TRIM(early_exit_note) <> ''
ORDER BY close_datetime DESC"""
).fetchall()
except Exception:
return None
td = (trading_day or "").strip()
best: Optional[int] = None
for row in rows:
ms = _parse_journal_close_ms(_row_get(row, "close_datetime"))
if ms is None:
continue
if td and _ms_trading_day_label(ms) != td:
continue
if best is None or ms > best:
best = ms
return best
def _journaled_manual_cooloff_expired(
conn, *, trading_day: str, now_ms: int, pending: Any
) -> bool:
"""当日手动平仓已复盘且 1h 冷静期结束,且无待复盘的新平仓。"""
if pending is not None:
try:
if int(pending) != 0:
return False
except (TypeError, ValueError):
return False
close_ms = _latest_journaled_manual_close_ms(conn, trading_day)
if close_ms is None:
return False
journal_ms = _cooloff_duration_ms(cooling_hours_manual_journal())
return close_ms + journal_ms <= now_ms
def _cooloff_until_ms(row) -> Optional[int]:
raw = _row_get(row, "cooloff_until_ms")
try:
@@ -317,39 +393,46 @@ def _repair_stale_cooloff_row(
resolved_until_ms: Optional[int],
now: Optional[datetime] = None,
) -> None:
"""脏数据(未来 last_close / 超长 until)读时写回修正"""
"""脏数据读时写回:过期/无效则清库,否则对齐 until / last_close"""
last_raw = _row_get(row, "last_close_at_ms")
stored_raw = _cooloff_until_ms(row)
if last_raw is None and stored_raw is None:
return
if resolved_until_ms is None:
if last_raw is not None or stored_raw is not None:
_clear_inactive_cooloff(conn, now=now)
return
dirty = False
new_last: Optional[int] = None
if last_raw is not None:
try:
norm = _normalize_epoch_ms(int(last_raw), now_ms)
sanitized = _sanitize_last_close_ms(norm, now_ms)
if sanitized is None:
dirty = True
else:
new_last = sanitized
if sanitized != int(last_raw):
dirty = True
except (TypeError, ValueError):
new_last = None
dirty = True
if stored_raw is not None:
stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms)
if resolved_until_ms is None:
dirty = True
elif abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
if abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
dirty = True
if not dirty:
return
conn.execute(
"""UPDATE account_risk_state SET
cooloff_until_ms=?,
cooloff_hours=?,
last_close_at_ms=?,
updated_at=?
WHERE id=1""",
(
resolved_until_ms,
new_last if resolved_until_ms else None,
_row_get(row, "cooloff_hours"),
new_last,
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
),
)
@@ -382,6 +465,8 @@ def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int:
_normalize_epoch_ms(int(last_close_ms), now_ms), now_ms
)
except (TypeError, ValueError):
base_ms = None
if base_ms is None:
base_ms = now_ms
else:
base_ms = now_ms
@@ -625,7 +710,16 @@ 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
pending = _row_get(row, "pending_journal_trade_id")
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
if (
not daily_frozen
and cooloff_until_ms is not None
and _journaled_manual_cooloff_expired(
conn, trading_day=trading_day, now_ms=now_ms, pending=pending
)
):
cooloff_until_ms = None
if not daily_frozen:
_repair_stale_cooloff_row(
conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now
@@ -668,7 +762,7 @@ def compute_account_risk_status(
else None,
"manual_close_count": manual_close_count,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"),
"pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
}
+3 -1
View File
@@ -111,7 +111,9 @@ APP_TIMEZONE=Asia/Shanghai
- 样式:`static/account_risk_badge.css`
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
- 服务端会将 `last_close_at_ms` 钳在未来时刻、并将剩余冷静期上限设为配置的 `cooloff_hours`,读时自动写回修正
- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结
- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时
- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常**
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
## 相关代码
+66 -5
View File
@@ -33,6 +33,16 @@ def _mem_conn():
return conn
def _mem_conn_with_journal():
conn = _mem_conn()
conn.execute(
"""CREATE TABLE IF NOT EXISTS journal_entries (
close_datetime TEXT, early_exit_trigger TEXT, early_exit_note TEXT
)"""
)
return conn
def _local_ms(dt_naive: datetime) -> int:
return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000)
@@ -257,6 +267,58 @@ class AccountRiskLibTests(unittest.TestCase):
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
row = conn.execute(
"SELECT cooloff_until_ms, cooloff_hours, last_close_at_ms FROM account_risk_state WHERE id=1"
).fetchone()
self.assertIsNone(row["cooloff_until_ms"])
self.assertIsNone(row["last_close_at_ms"])
def test_corrupted_anchor_cleared_when_journaled_manual_expired(self):
"""上一版误把 last_close 写成近期时刻时,已复盘且 1h 已过的仍应显示正常。"""
conn = _mem_conn_with_journal()
now = datetime(2026, 6, 18, 22, 30, 0)
now_ms = _local_ms(now)
bad_last = now_ms - 60 * 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=?,
pending_journal_trade_id=NULL,
daily_frozen=0
WHERE id=1""",
(bad_last + 3600 * 1000, bad_last),
)
conn.execute(
"INSERT INTO journal_entries (close_datetime, early_exit_trigger, early_exit_note) VALUES (?,?,?)",
("2026-06-18 17:56:00", "手动平仓", "按计划离场"),
)
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_future_last_close_does_not_restart_cooloff(self):
"""脏数据 last_close 在未来时,不应重启 1h/4h 冻结。"""
conn = _mem_conn()
now = datetime(2026, 6, 18, 22, 30, 0)
now_ms = _local_ms(now)
future_close = now_ms + 49 * 60 * 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""",
(future_close + 3600 * 1000, future_close),
)
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()
@@ -313,11 +375,8 @@ class AccountRiskLibTests(unittest.TestCase):
(future_close + 4 * 3600 * 1000, future_close),
)
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_4H)
self.assertLessEqual(st["freeze_remaining_sec"], 4 * 3600 + 2)
self.assertGreater(st["freeze_remaining_sec"], 3 * 3600 + 58 * 60)
row = conn.execute("SELECT last_close_at_ms, cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone()
self.assertLessEqual(int(row["last_close_at_ms"]), now_ms + 60 * 1000)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_legacy_naive_utc_ms_countdown_normalized(self):
conn = _mem_conn()
@@ -366,6 +425,8 @@ class AccountRiskLibTests(unittest.TestCase):
later = datetime(2026, 6, 14, 13, 0, 0)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
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_trading_day_reset_clears_daily_frozen(self):
conn = _mem_conn()