From 99eb16dbd8d219f0364be3f5e546540596a0fb22 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 00:23:40 +0800 Subject: [PATCH] fix: Gate force-close badge and classify midnight closes correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrow executing window to 15 minutes after the hour, show countdown when flat, run force_close before reconcile, and map 00:00 synced closes to 强制清仓 instead of 手动平仓. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 9 +- crypto_monitor_gate/app.py | 36 ++++++-- crypto_monitor_okx/app.py | 9 +- lib/trade/force_close_lib.py | 157 ++++++++++++++++++++++++++++++++-- tests/test_force_close_lib.py | 60 ++++++++++++- 5 files changed, 250 insertions(+), 21 deletions(-) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 616ca82..ca9b01b 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -121,6 +121,7 @@ from lib.trade.time_close_lib import ( time_close_settings_from_row, ) from lib.trade.force_close_lib import ( + apply_force_close_display_result, apply_force_close_to_payload, enrich_orders_force_close, force_close_template_context, @@ -2147,6 +2148,12 @@ def to_effective_trade_dict(row): item.get("effective_result"), item.get("effective_pnl_amount"), ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) return item @@ -6913,10 +6920,10 @@ def background_task(): try: auto_transfer_once_per_day() conn = get_db() + force_close_before_reset() reconcile_external_closes(conn) conn.commit() conn.close() - force_close_before_reset() check_fib_key_monitors() check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 06d732e..5430b04 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -123,6 +123,8 @@ from lib.trade.time_close_lib import ( ) from lib.trade.force_close_lib import ( apply_force_close_to_payload, + apply_force_close_display_result, + coerce_force_close_result, enrich_orders_force_close, force_close_template_context, ) @@ -2106,6 +2108,12 @@ def to_effective_trade_dict(row): item.get("effective_result"), item.get("effective_pnl_amount"), ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) return item @@ -4084,6 +4092,17 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m 交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。 返回 (result, pnl_amount, closed_at_str, miss_reason)。 """ + + def _finish(result, pnl_amount, closed_at_str, miss_reason): + res, note = coerce_force_close_result( + result, + closed_at_str, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + miss_reason=miss_reason, + ) + return res, pnl_amount, closed_at_str, note + direction = row["direction"] sym = row["symbol"] trigger_price = row["trigger_price"] @@ -4109,7 +4128,7 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m if pnl_hist is not None: note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏" res = "手动平仓" if prefer_manual else "外部平仓" - return (res, float(pnl_hist), closed_at_str, note) + return _finish(res, float(pnl_hist), closed_at_str, note) if exit_px is None or exit_px <= 0: p = get_price(sym) @@ -4117,13 +4136,13 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) if guessed: pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) - return ( + return _finish( normalize_result_with_pnl(guessed, pnl), pnl, closed_at_str, "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", ) - return ( + return _finish( "外部平仓", 0.0, closed_at_str, @@ -4133,20 +4152,20 @@ def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_m result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) if prefer_manual: - return ( + return _finish( "手动平仓", pnl, closed_at_str, "中控平仓后按交易所成交记录同步", ) if result: - return ( + return _finish( normalize_result_with_pnl(result, pnl), pnl, closed_at_str, "按交易所成交记录同步为止盈/止损平仓", ) - return ( + return _finish( "外部平仓", pnl, closed_at_str, @@ -6553,10 +6572,10 @@ def background_task(): try: auto_transfer_once_per_day() conn = get_db() + force_close_before_reset() reconcile_external_closes(conn) conn.commit() conn.close() - force_close_before_reset() check_fib_key_monitors() check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") @@ -7092,6 +7111,7 @@ def render_main_page(page="trade", embed_mode=None): FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, now_ms=int(app_now().timestamp() * 1000), + has_active_positions=bool(order_list), ), **embed_context_extras("gate"), ) @@ -7194,6 +7214,7 @@ def api_account_snapshot(): FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000), + has_active_positions=position_limit_count > 0, ), }) @@ -7535,6 +7556,7 @@ def api_price_snapshot(): **force_close_template_context( FORCE_CLOSE_ENABLED, FORCE_CLOSE_BJ_HOUR, + has_active_positions=bool(order_prices), ), }) diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c512037..c81c1ff 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -122,6 +122,7 @@ from lib.trade.time_close_lib import ( time_close_settings_from_row, ) from lib.trade.force_close_lib import ( + apply_force_close_display_result, apply_force_close_to_payload, enrich_orders_force_close, force_close_template_context, @@ -2054,6 +2055,12 @@ def to_effective_trade_dict(row): item.get("effective_result"), item.get("effective_pnl_amount"), ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) return item @@ -6353,10 +6360,10 @@ def background_task(): try: auto_transfer_once_per_day() conn = get_db() + force_close_before_reset() reconcile_external_closes(conn) conn.commit() conn.close() - force_close_before_reset() check_fib_key_monitors() check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") diff --git a/lib/trade/force_close_lib.py b/lib/trade/force_close_lib.py index b1ad406..97f4f07 100644 --- a/lib/trade/force_close_lib.py +++ b/lib/trade/force_close_lib.py @@ -8,6 +8,7 @@ from typing import Any, Optional from zoneinfo import ZoneInfo FORCE_CLOSE_RESULT = "强制清仓" +FORCE_CLOSE_GRACE_MINUTES = 15 def app_timezone_name() -> str: @@ -42,10 +43,138 @@ def is_force_close_active_hour( *, now_ms: Optional[int] = None, tz_name: Optional[str] = None, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, +) -> 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: int = FORCE_CLOSE_GRACE_MINUTES, ) -> bool: - """当前是否处于整点强制清仓执行窗口(该北京时间整点小时内)。""" hour = normalize_force_close_bj_hour(bj_hour) - return _now_dt(now_ms=now_ms, tz_name=tz_name).hour == 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 + end = target + timedelta(minutes=max(1, int(grace_minutes))) + return now < end + + +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: int = FORCE_CLOSE_GRACE_MINUTES, + 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 < max(1, int(grace_minutes)) + + +def infer_force_close_result( + closed_at: Any, + *, + enabled: bool, + bj_hour: Any, + grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + 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: int = FORCE_CLOSE_GRACE_MINUTES, + 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: int = FORCE_CLOSE_GRACE_MINUTES, + 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( @@ -58,9 +187,8 @@ def compute_next_force_close_at_ms( 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.hour > hour or now.hour == hour: - if now.hour > hour: - target += timedelta(days=1) + if now >= target: + target += timedelta(days=1) return int(target.timestamp() * 1000) @@ -96,6 +224,7 @@ def build_force_close_state( *, now_ms: Optional[int] = None, tz_name: Optional[str] = None, + has_active_positions: Optional[bool] = None, ) -> dict[str, Any]: """实例级强制清仓状态(模板 / API 共用)。""" if not enabled: @@ -110,7 +239,8 @@ def build_force_close_state( "active": False, } hour = normalize_force_close_bj_hour(bj_hour) - active = is_force_close_active_hour(hour, now_ms=now_ms, tz_name=tz_name) + executing = is_force_close_executing(hour, now_ms=now_ms, tz_name=tz_name) + 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 { @@ -131,10 +261,15 @@ def force_close_template_context( *, 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 + enabled, + bj_hour, + now_ms=now_ms, + tz_name=tz_name, + has_active_positions=has_active_positions, ) } @@ -148,7 +283,13 @@ def apply_force_close_to_payload( tz_name: Optional[str] = None, ) -> None: """为 active 持仓 JSON 附加整点强制清仓倒计时。""" - state = build_force_close_state(enabled, bj_hour, now_ms=now_ms, tz_name=tz_name) + 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"] diff --git a/tests/test_force_close_lib.py b/tests/test_force_close_lib.py index 8131986..54d3397 100644 --- a/tests/test_force_close_lib.py +++ b/tests/test_force_close_lib.py @@ -2,11 +2,16 @@ from datetime import datetime from zoneinfo import ZoneInfo from lib.trade.force_close_lib import ( + apply_force_close_display_result, build_force_close_state, + coerce_force_close_result, compute_next_force_close_at_ms, force_close_label, format_force_close_countdown, + infer_force_close_result, + is_close_at_force_close_window, is_force_close_active_hour, + is_force_close_executing, ) TZ = ZoneInfo("Asia/Shanghai") @@ -36,14 +41,35 @@ def test_next_force_close_same_day_before_hour(): ) -def test_active_hour_and_countdown(): - now = _ms(2026, 7, 6, 0, 15) +def test_next_force_close_after_trigger_same_day(): + now = _ms(2026, 7, 7, 0, 18) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 8, 0, 0 + ) + + +def test_executing_window_and_countdown(): + now = _ms(2026, 7, 6, 0, 14) + assert is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai") assert is_force_close_active_hour(0, now_ms=now, tz_name="Asia/Shanghai") - state = build_force_close_state(True, 0, now_ms=now, tz_name="Asia/Shanghai") + state = build_force_close_state( + True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=True + ) assert state["enabled"] is True assert state["active"] is True assert state["countdown"] == "执行中" - assert state["remaining_sec"] == 0 + assert state["next_at_ms"] == _ms(2026, 7, 7, 0, 0) + + +def test_not_executing_after_grace_without_positions(): + now = _ms(2026, 7, 7, 0, 18) + assert not is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai") + state = build_force_close_state( + True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=False + ) + assert state["active"] is False + assert state["countdown"] != "执行中" + assert state["next_at_ms"] == _ms(2026, 7, 8, 0, 0) def test_disabled_state(): @@ -55,3 +81,29 @@ def test_disabled_state(): def test_format_countdown(): assert format_force_close_countdown(3661) == "01:01:01" assert format_force_close_countdown(0, active=True) == "执行中" + + +def test_infer_force_close_from_closed_at(): + assert is_close_at_force_close_window("2026-07-07 00:00", 0) + assert infer_force_close_result("2026-07-07 00:00", enabled=True, bj_hour=0) == "强制清仓" + assert infer_force_close_result("2026-07-07 00:20", enabled=True, bj_hour=0) is None + + +def test_coerce_and_display_external_close_at_midnight(): + res, note = coerce_force_close_result( + "外部平仓", + "2026-07-07 00:00", + enabled=True, + bj_hour=0, + ) + assert res == "强制清仓" + assert "00:00" in note + assert ( + apply_force_close_display_result( + "手动平仓", + "2026-07-07 00:00", + enabled=True, + bj_hour=0, + ) + == "强制清仓" + )