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
+119 -25
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,27 +166,42 @@ def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
stored_raw = _cooloff_until_ms(row)
if last_raw is not None:
last_ms = _sanitize_last_close_ms(
_normalize_epoch_ms(int(last_raw), now_ms), now_ms
)
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
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
if end_ms > now_ms:
return end_ms
if hours <= journal_h + 1e-6:
return None
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)
new_last = sanitized
if sanitized != int(last_raw):
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,
}