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:
+119
-25
@@ -141,11 +141,11 @@ def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int:
|
|||||||
return corrected
|
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
|
slack_ms = 60 * 1000
|
||||||
if last_ms > now_ms + slack_ms:
|
if last_ms > now_ms + slack_ms:
|
||||||
return now_ms
|
return None
|
||||||
return last_ms
|
return last_ms
|
||||||
|
|
||||||
|
|
||||||
@@ -158,7 +158,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]:
|
||||||
"""冷静期结束 = last_close + cooloff_hours;剩余不得超过配置时长。"""
|
"""冷静期结束 = last_close + cooloff_hours;无效/已过期锚点不再重启计时。"""
|
||||||
hours = _cooloff_hours_value(row)
|
hours = _cooloff_hours_value(row)
|
||||||
journal_h = cooling_hours_manual_journal()
|
journal_h = cooling_hours_manual_journal()
|
||||||
duration_ms = _cooloff_duration_ms(hours)
|
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)
|
stored_raw = _cooloff_until_ms(row)
|
||||||
|
|
||||||
if last_raw is not None:
|
if last_raw is not None:
|
||||||
last_ms = _sanitize_last_close_ms(
|
try:
|
||||||
_normalize_epoch_ms(int(last_raw), now_ms), now_ms
|
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
|
except (TypeError, ValueError):
|
||||||
if end_ms > max_end_ms:
|
last_ms = None
|
||||||
end_ms = max_end_ms
|
if last_ms is not None:
|
||||||
if end_ms > now_ms:
|
end_ms = last_ms + duration_ms
|
||||||
return end_ms
|
if end_ms > now_ms:
|
||||||
|
return end_ms
|
||||||
if hours <= journal_h + 1e-6:
|
if hours <= journal_h + 1e-6:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if stored_raw is None:
|
if stored_raw is None:
|
||||||
return None
|
return None
|
||||||
stored_ms = _normalize_epoch_ms(int(stored_raw), now_ms)
|
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
|
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:
|
def _freeze_tier_from_remaining_ms(remaining_ms: int, hours: float) -> str:
|
||||||
journal_h = cooling_hours_manual_journal()
|
journal_h = cooling_hours_manual_journal()
|
||||||
rh = remaining_ms / 3600000.0
|
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]:
|
def _cooloff_until_ms(row) -> Optional[int]:
|
||||||
raw = _row_get(row, "cooloff_until_ms")
|
raw = _row_get(row, "cooloff_until_ms")
|
||||||
try:
|
try:
|
||||||
@@ -317,39 +393,46 @@ def _repair_stale_cooloff_row(
|
|||||||
resolved_until_ms: Optional[int],
|
resolved_until_ms: Optional[int],
|
||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""脏数据(未来 last_close / 超长 until)读时写回修正。"""
|
"""脏数据读时写回:过期/无效则清库,否则对齐 until / last_close。"""
|
||||||
last_raw = _row_get(row, "last_close_at_ms")
|
last_raw = _row_get(row, "last_close_at_ms")
|
||||||
stored_raw = _cooloff_until_ms(row)
|
stored_raw = _cooloff_until_ms(row)
|
||||||
if last_raw is None and stored_raw is None:
|
if last_raw is None and stored_raw is None:
|
||||||
return
|
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
|
dirty = False
|
||||||
new_last: Optional[int] = None
|
new_last: Optional[int] = None
|
||||||
if last_raw is not None:
|
if last_raw is not None:
|
||||||
try:
|
try:
|
||||||
norm = _normalize_epoch_ms(int(last_raw), now_ms)
|
norm = _normalize_epoch_ms(int(last_raw), now_ms)
|
||||||
sanitized = _sanitize_last_close_ms(norm, now_ms)
|
sanitized = _sanitize_last_close_ms(norm, now_ms)
|
||||||
new_last = sanitized
|
if sanitized is None:
|
||||||
if sanitized != int(last_raw):
|
|
||||||
dirty = True
|
dirty = True
|
||||||
|
else:
|
||||||
|
new_last = sanitized
|
||||||
|
if sanitized != int(last_raw):
|
||||||
|
dirty = True
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
new_last = None
|
dirty = True
|
||||||
if stored_raw is not None:
|
if stored_raw is not None:
|
||||||
stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms)
|
stored_norm = _normalize_epoch_ms(int(stored_raw), now_ms)
|
||||||
if resolved_until_ms is None:
|
if abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
|
||||||
dirty = True
|
|
||||||
elif abs(stored_norm - int(resolved_until_ms)) > 60 * 1000:
|
|
||||||
dirty = True
|
dirty = True
|
||||||
if not dirty:
|
if not dirty:
|
||||||
return
|
return
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE account_risk_state SET
|
"""UPDATE account_risk_state SET
|
||||||
cooloff_until_ms=?,
|
cooloff_until_ms=?,
|
||||||
|
cooloff_hours=?,
|
||||||
last_close_at_ms=?,
|
last_close_at_ms=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=1""",
|
WHERE id=1""",
|
||||||
(
|
(
|
||||||
resolved_until_ms,
|
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"),
|
(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
|
_normalize_epoch_ms(int(last_close_ms), now_ms), now_ms
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
base_ms = None
|
||||||
|
if base_ms is None:
|
||||||
base_ms = now_ms
|
base_ms = now_ms
|
||||||
else:
|
else:
|
||||||
base_ms = now_ms
|
base_ms = now_ms
|
||||||
@@ -625,7 +710,16 @@ 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
|
||||||
|
pending = _row_get(row, "pending_journal_trade_id")
|
||||||
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
|
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:
|
if not daily_frozen:
|
||||||
_repair_stale_cooloff_row(
|
_repair_stale_cooloff_row(
|
||||||
conn, row, now_ms=now_ms, resolved_until_ms=cooloff_until_ms, now=now
|
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,
|
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": pending,
|
||||||
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
|
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ APP_TIMEZONE=Asia/Shanghai
|
|||||||
- 样式:`static/account_risk_badge.css`
|
- 样式:`static/account_risk_badge.css`
|
||||||
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
|
||||||
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
|
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差
|
||||||
- 服务端会将 `last_close_at_ms` 钳在未来时刻、并将剩余冷静期上限设为配置的 `cooloff_hours`,读时自动写回修正
|
- 服务端在冷静期**已结束**或锚点无效时**自动清库**,避免重启后误读旧 `account_risk_state` 仍显示冻结
|
||||||
|
- 无效的未来 `last_close_at_ms` **不会**被当作「现在」重启计时
|
||||||
|
- 若当日手动平仓**已复盘**(journal 有说明)且 1h 窗口已过,即使 risk 表被误写也会强制恢复 **正常**
|
||||||
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
- 勿与交易记录列表中的历史平仓时间混淆:风控只看 `account_risk_state` 表内 **最后一次用户主动平仓** 及其复盘结果
|
||||||
|
|
||||||
## 相关代码
|
## 相关代码
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ def _mem_conn():
|
|||||||
return 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:
|
def _local_ms(dt_naive: datetime) -> int:
|
||||||
return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000)
|
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)
|
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||||
self.assertTrue(st["can_trade"])
|
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):
|
def test_active_4h_countdown_matches_tier(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
@@ -313,11 +375,8 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
(future_close + 4 * 3600 * 1000, future_close),
|
(future_close + 4 * 3600 * 1000, future_close),
|
||||||
)
|
)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||||
self.assertLessEqual(st["freeze_remaining_sec"], 4 * 3600 + 2)
|
self.assertTrue(st["can_trade"])
|
||||||
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)
|
|
||||||
|
|
||||||
def test_legacy_naive_utc_ms_countdown_normalized(self):
|
def test_legacy_naive_utc_ms_countdown_normalized(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
@@ -366,6 +425,8 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
later = datetime(2026, 6, 14, 13, 0, 0)
|
later = datetime(2026, 6, 14, 13, 0, 0)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
|
||||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
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):
|
def test_trading_day_reset_clears_daily_frozen(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
|
|||||||
Reference in New Issue
Block a user