fix: Gate force-close badge and classify midnight closes correctly
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
== "强制清仓"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user