fix(risk): correct freeze countdown timezone (Asia/Shanghai)
Treat naive app datetimes as local time, normalize legacy UTC-ms rows, and resolve cooloff end from stored until or last_close+duration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+70
-12
@@ -55,6 +55,16 @@ def _env_hours(key: str, default: float) -> float:
|
||||
return max(0.0, v)
|
||||
|
||||
|
||||
def _app_tz():
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
name = (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def risk_control_enabled() -> bool:
|
||||
return _env_bool("RISK_CONTROL_ENABLED", True)
|
||||
|
||||
@@ -111,10 +121,52 @@ def _row_get(row, key, default=None):
|
||||
def _now_ms(now: Optional[datetime] = None) -> int:
|
||||
dt = now or datetime.now()
|
||||
if dt.tzinfo is None:
|
||||
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||
dt = dt.replace(tzinfo=_app_tz())
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
def _normalize_epoch_ms(ms: int, ref_now_ms: Optional[int] = None) -> int:
|
||||
"""修正旧版把北京时间 naive 当作 UTC 写入的 epoch 毫秒。"""
|
||||
tz = _app_tz()
|
||||
off = datetime.now(tz).utcoffset()
|
||||
if not off:
|
||||
return int(ms)
|
||||
offset_ms = int(off.total_seconds() * 1000)
|
||||
if offset_ms == 0:
|
||||
return int(ms)
|
||||
ref = int(ref_now_ms) if ref_now_ms is not None else _now_ms(datetime.now(tz))
|
||||
corrected = int(ms) - offset_ms
|
||||
if abs(int(ms) - ref) <= abs(corrected - ref):
|
||||
return int(ms)
|
||||
return corrected
|
||||
|
||||
|
||||
def _cooloff_hours_value(row) -> float:
|
||||
return float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
|
||||
|
||||
|
||||
def _resolved_cooloff_until_ms(row, now_ms: int) -> Optional[int]:
|
||||
raw_until = _cooloff_until_ms(row)
|
||||
last = _row_get(row, "last_close_at_ms")
|
||||
hours = _cooloff_hours_value(row)
|
||||
candidates: list[int] = []
|
||||
if raw_until is not None:
|
||||
try:
|
||||
candidates.append(_normalize_epoch_ms(int(raw_until), now_ms))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if last is not None:
|
||||
try:
|
||||
last_i = _normalize_epoch_ms(int(last), now_ms)
|
||||
candidates.append(last_i + int(hours * 3600 * 1000))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not candidates:
|
||||
return None
|
||||
end_ms = max(candidates)
|
||||
return end_ms if end_ms > now_ms else None
|
||||
|
||||
|
||||
def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]:
|
||||
if ms is None:
|
||||
return None
|
||||
@@ -228,11 +280,17 @@ def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool:
|
||||
def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int:
|
||||
journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000)
|
||||
last_close_ms = _row_get(row, "last_close_at_ms")
|
||||
base_ms = int(last_close_ms) if last_close_ms else now_ms
|
||||
if last_close_ms:
|
||||
try:
|
||||
base_ms = _normalize_epoch_ms(int(last_close_ms), now_ms)
|
||||
except (TypeError, ValueError):
|
||||
base_ms = now_ms
|
||||
else:
|
||||
base_ms = now_ms
|
||||
until_from_close = base_ms + journal_ms
|
||||
until_ms = until_from_close if until_from_close > now_ms else now_ms + journal_ms
|
||||
current_until = _cooloff_until_ms(row)
|
||||
if current_until is not None and current_until > now_ms and until_ms > current_until:
|
||||
current_until = _resolved_cooloff_until_ms(row, now_ms)
|
||||
if current_until is not None and until_ms > current_until:
|
||||
until_ms = current_until
|
||||
return until_ms
|
||||
|
||||
@@ -465,11 +523,7 @@ 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
|
||||
cooloff_until_ms = _row_get(row, "cooloff_until_ms")
|
||||
try:
|
||||
cooloff_until_ms = int(cooloff_until_ms) if cooloff_until_ms is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cooloff_until_ms = None
|
||||
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)
|
||||
|
||||
@@ -478,7 +532,7 @@ def compute_account_risk_status(
|
||||
if daily_frozen:
|
||||
status = STATUS_DAILY
|
||||
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
|
||||
elif cooloff_until_ms is not None and cooloff_until_ms > now_ms:
|
||||
elif cooloff_until_ms is not None:
|
||||
h = float(cooloff_hours or cooling_hours_manual())
|
||||
journal_h = cooling_hours_manual_journal()
|
||||
status = STATUS_FREEZE_1H if h <= journal_h + 1e-6 else STATUS_FREEZE_4H
|
||||
@@ -489,19 +543,23 @@ def compute_account_risk_status(
|
||||
reason += f",至 {until_str}"
|
||||
|
||||
can_trade = status == STATUS_NORMAL
|
||||
freeze_remaining_sec = (
|
||||
max(0, (cooloff_until_ms - now_ms) // 1000) if cooloff_until_ms is not None else 0
|
||||
)
|
||||
return {
|
||||
"enabled": True,
|
||||
"status": status,
|
||||
"status_label": STATUS_LABELS[status],
|
||||
"can_trade": can_trade,
|
||||
"reason": reason,
|
||||
"cooloff_until_ms": cooloff_until_ms if cooloff_until_ms and cooloff_until_ms > now_ms else None,
|
||||
"cooloff_until_ms": cooloff_until_ms,
|
||||
"cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms)
|
||||
if fmt_local_ms and cooloff_until_ms and cooloff_until_ms > now_ms
|
||||
if fmt_local_ms and cooloff_until_ms
|
||||
else None,
|
||||
"manual_close_count": manual_close_count,
|
||||
"daily_frozen": daily_frozen,
|
||||
"pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"),
|
||||
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user