Files
crypto_monitor/lib/trade/force_close_lib.py
T
dekun 7f22bffbc6 Shorten force-close window to 5m and grey-out open during blocks.
Unify Gate/OKX/Binance: disable the open button with a side note during force-close, cooloff, and daily freeze, and enforce the same gate server-side.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 08:38:00 +08:00

367 lines
10 KiB
Python

"""整点强制清仓(FORCE_CLOSE_*):UI 标识与持仓倒计时."""
from __future__ import annotations
import os
import time
from datetime import datetime, timedelta
from typing import Any, Optional
from zoneinfo import ZoneInfo
FORCE_CLOSE_RESULT = "强制清仓"
# 默认宽限(分钟);运行时优先读 FORCE_CLOSE_GRACE_MINUTES
FORCE_CLOSE_GRACE_MINUTES = 5
def force_close_grace_minutes(override: Any = None) -> int:
"""整点强制清仓执行窗口长度(分钟)."""
if override is not None and str(override).strip() != "":
raw = override
else:
raw = os.getenv("FORCE_CLOSE_GRACE_MINUTES")
if raw is None or str(raw).strip() == "":
raw = FORCE_CLOSE_GRACE_MINUTES
try:
return max(1, int(raw))
except (TypeError, ValueError):
return max(1, int(FORCE_CLOSE_GRACE_MINUTES))
def app_timezone_name() -> str:
return (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
def normalize_force_close_bj_hour(value: Any) -> int:
try:
h = int(value)
except (TypeError, ValueError):
return 0
return max(0, min(23, h))
def _now_dt(*, now_ms: Optional[int] = None, tz_name: Optional[str] = None) -> datetime:
tz = ZoneInfo(tz_name or app_timezone_name())
if now_ms is None:
return datetime.now(tz)
return datetime.fromtimestamp(int(now_ms) / 1000, tz=tz)
def force_close_hour_label(bj_hour: Any) -> str:
return f"{normalize_force_close_bj_hour(bj_hour):02d}:00"
def force_close_label(bj_hour: Any) -> str:
return f"强制清仓 {force_close_hour_label(bj_hour)}"
def is_force_close_active_hour(
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
grace_minutes: Optional[int] = None,
) -> bool:
"""当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内)."""
return is_force_close_executing(
bj_hour,
now_ms=now_ms,
tz_name=tz_name,
grace_minutes=grace_minutes,
)
def is_force_close_executing(
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
grace_minutes: Optional[int] = None,
) -> bool:
hour = normalize_force_close_bj_hour(bj_hour)
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if now < target:
return False
grace = force_close_grace_minutes(grace_minutes)
end = target + timedelta(minutes=grace)
return now < end
def force_close_blocks_new_open(
enabled: bool,
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
grace_minutes: Optional[int] = None,
) -> tuple[bool, str]:
"""强制清仓执行窗口内禁止新开仓.返回 (是否拦截, 说明文案)."""
if not enabled:
return False, ""
grace = force_close_grace_minutes(grace_minutes)
if not is_force_close_executing(
bj_hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace
):
return False, ""
label = force_close_hour_label(bj_hour)
return (
True,
f"强制清仓窗口内(北京时间 {label}{grace} 分钟),暂不可开仓",
)
def parse_closed_at_dt(
closed_at: Any,
*,
tz_name: Optional[str] = None,
) -> Optional[datetime]:
if closed_at is None:
return None
text = str(closed_at).strip()
if not text:
return None
tz = ZoneInfo(tz_name or app_timezone_name())
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=tz)
except ValueError:
continue
return None
def is_close_at_force_close_window(
closed_at: Any,
bj_hour: Any,
*,
grace_minutes: Optional[int] = None,
tz_name: Optional[str] = None,
) -> bool:
"""平仓时刻是否落在北京时间整点强制清仓窗口内."""
dt = parse_closed_at_dt(closed_at, tz_name=tz_name)
if dt is None:
return False
hour = normalize_force_close_bj_hour(bj_hour)
if dt.hour != hour:
return False
return dt.minute < force_close_grace_minutes(grace_minutes)
def infer_force_close_result(
closed_at: Any,
*,
enabled: bool,
bj_hour: Any,
grace_minutes: Optional[int] = None,
tz_name: Optional[str] = None,
) -> Optional[str]:
if not enabled:
return None
if is_close_at_force_close_window(
closed_at, bj_hour, grace_minutes=grace_minutes, tz_name=tz_name
):
return FORCE_CLOSE_RESULT
return None
def coerce_force_close_result(
result: Optional[str],
closed_at: Any,
*,
enabled: bool,
bj_hour: Any,
miss_reason: Optional[str] = None,
grace_minutes: Optional[int] = None,
tz_name: Optional[str] = None,
) -> tuple[str, str]:
"""同步平仓归类:整点窗口内优先记为强制清仓."""
res = (result or "").strip()
note = (miss_reason or "").strip()
if res == FORCE_CLOSE_RESULT:
return res, note
fc = infer_force_close_result(
closed_at,
enabled=enabled,
bj_hour=bj_hour,
grace_minutes=grace_minutes,
tz_name=tz_name,
)
if not fc:
return res, note
if not note:
note = f"北京时间 {force_close_hour_label(bj_hour)} 整点风控清仓"
return fc, note
def apply_force_close_display_result(
result: Optional[str],
closed_at: Any,
*,
enabled: bool,
bj_hour: Any,
grace_minutes: Optional[int] = None,
tz_name: Optional[str] = None,
) -> str:
"""展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓."""
res = (result or "").strip()
if res == FORCE_CLOSE_RESULT:
return res
fc = infer_force_close_result(
closed_at,
enabled=enabled,
bj_hour=bj_hour,
grace_minutes=grace_minutes,
tz_name=tz_name,
)
if fc and (res in ("", "外部平仓", "手动平仓") or res.startswith("外部平仓")):
return fc
return res
def compute_next_force_close_at_ms(
*,
bj_hour: Any,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
) -> Optional[int]:
"""下一次强制清仓时刻(北京时间整点)的 epoch 毫秒."""
hour = normalize_force_close_bj_hour(bj_hour)
now = _now_dt(now_ms=now_ms, tz_name=tz_name)
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
if now >= target:
target += timedelta(days=1)
return int(target.timestamp() * 1000)
def force_close_remaining_seconds(
close_at_ms: Any,
*,
now_ms: Optional[int] = None,
) -> Optional[int]:
try:
close_at = int(close_at_ms)
except (TypeError, ValueError):
return None
now = int(now_ms if now_ms is not None else time.time() * 1000)
return max(0, int((close_at - now) / 1000))
def format_force_close_countdown(seconds: Any, *, active: bool = False) -> str:
if active:
return "执行中"
try:
sec = max(0, int(seconds))
except (TypeError, ValueError):
return "--:--:--"
h = sec // 3600
m = (sec % 3600) // 60
s = sec % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def build_force_close_state(
enabled: bool,
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
has_active_positions: Optional[bool] = None,
) -> dict[str, Any]:
"""实例级强制清仓状态(模板 / API 共用)."""
grace = force_close_grace_minutes()
if not enabled:
return {
"enabled": False,
"bj_hour": normalize_force_close_bj_hour(bj_hour),
"hour_label": force_close_hour_label(bj_hour),
"label": force_close_label(bj_hour),
"grace_minutes": grace,
"next_at_ms": None,
"remaining_sec": None,
"countdown": "",
"active": False,
"executing": False,
}
hour = normalize_force_close_bj_hour(bj_hour)
executing = is_force_close_executing(
hour, now_ms=now_ms, tz_name=tz_name, grace_minutes=grace
)
active = executing and (has_active_positions is not False)
next_at_ms = compute_next_force_close_at_ms(bj_hour=hour, now_ms=now_ms, tz_name=tz_name)
rem = force_close_remaining_seconds(next_at_ms, now_ms=now_ms) if next_at_ms else None
return {
"enabled": True,
"bj_hour": hour,
"hour_label": force_close_hour_label(hour),
"label": force_close_label(hour),
"grace_minutes": grace,
"next_at_ms": next_at_ms,
"remaining_sec": rem,
"countdown": format_force_close_countdown(rem, active=active),
"active": active,
"executing": executing,
}
def force_close_template_context(
enabled: bool,
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
has_active_positions: Optional[bool] = None,
) -> dict[str, dict[str, Any]]:
return {
"force_close": build_force_close_state(
enabled,
bj_hour,
now_ms=now_ms,
tz_name=tz_name,
has_active_positions=has_active_positions,
)
}
def apply_force_close_to_payload(
payload: dict[str, Any],
*,
enabled: bool,
bj_hour: Any,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
) -> None:
"""为 active 持仓 JSON 附加整点强制清仓倒计时."""
state = build_force_close_state(
enabled,
bj_hour,
now_ms=now_ms,
tz_name=tz_name,
has_active_positions=True,
)
payload["force_close_enabled"] = bool(state["enabled"])
payload["force_close_bj_hour"] = state["bj_hour"]
payload["force_close_at_ms"] = state["next_at_ms"]
payload["force_close_label"] = state["label"] if state["enabled"] else ""
payload["force_close_remaining_sec"] = state["remaining_sec"]
payload["force_close_countdown"] = state["countdown"]
payload["force_close_active"] = bool(state["active"])
def enrich_orders_force_close(
orders: list[dict[str, Any]],
enabled: bool,
bj_hour: Any,
*,
now_ms: Optional[int] = None,
tz_name: Optional[str] = None,
) -> None:
if not enabled or not orders:
return
for item in orders:
if isinstance(item, dict):
apply_force_close_to_payload(
item,
enabled=enabled,
bj_hour=bj_hour,
now_ms=now_ms,
tz_name=tz_name,
)