diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index e85fa87..b085a57 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -190,6 +190,8 @@ AUTO_TRANSFER_BJ_HOUR=8 FORCE_CLOSE_BJ_HOUR=0 # 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false +# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓) +FORCE_CLOSE_GRACE_MINUTES=5 # 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 5d5f57b..961f582 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -3307,6 +3307,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital): def precheck_risk(conn, symbol, direction): now = app_now() from lib.trade.account_risk_lib import account_risk_blocks_trading + from lib.trade.force_close_lib import force_close_blocks_new_open ok_risk, risk_reason = account_risk_blocks_trading( conn, @@ -3316,6 +3317,13 @@ def precheck_risk(conn, symbol, direction): ) if not ok_risk: return False, risk_reason + fc_block, fc_note = force_close_blocks_new_open( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ) + if fc_block: + return False, fc_note or "强制清仓窗口内暂不可开仓" if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" from lib.trade.account_risk_lib import position_limit_reached @@ -6930,8 +6938,10 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) - if now.hour != FORCE_CLOSE_BJ_HOUR: + # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 + from lib.trade.force_close_lib import is_force_close_executing + + if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): return conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -7303,14 +7313,22 @@ def render_main_page(page="trade", embed_mode=None): position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] key_rule_ctx = key_monitor_rule_template_context( kline_timeframe=KLINE_TIMEFRAME, key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, @@ -7377,6 +7395,7 @@ def render_main_page(page="trade", embed_mode=None): price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=position_limit_count, can_trade=can_trade, + open_block_note=open_block_note, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, @@ -7564,14 +7583,22 @@ def api_account_snapshot(): header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) conn.close() - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None @@ -7597,6 +7624,7 @@ def api_account_snapshot(): "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, + "open_block_note": open_block_note, "opens_today": opens_today, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index 49b0744..220a2c3 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -192,6 +192,8 @@ AUTO_TRANSFER_BJ_HOUR=8 FORCE_CLOSE_BJ_HOUR=0 # 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false +# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓) +FORCE_CLOSE_GRACE_MINUTES=5 # 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index a00dfd7..6f18812 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2971,6 +2971,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital): def precheck_risk(conn, symbol, direction): now = app_now() from lib.trade.account_risk_lib import account_risk_blocks_trading + from lib.trade.force_close_lib import force_close_blocks_new_open ok_risk, risk_reason = account_risk_blocks_trading( conn, @@ -2980,6 +2981,13 @@ def precheck_risk(conn, symbol, direction): ) if not ok_risk: return False, risk_reason + fc_block, fc_note = force_close_blocks_new_open( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ) + if fc_block: + return False, fc_note or "强制清仓窗口内暂不可开仓" if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" from lib.trade.account_risk_lib import position_limit_reached @@ -6553,8 +6561,10 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) - if now.hour != FORCE_CLOSE_BJ_HOUR: + # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 + from lib.trade.force_close_lib import is_force_close_executing + + if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): return conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -7075,14 +7085,22 @@ def render_main_page(page="trade", embed_mode=None): position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] key_rule_ctx = key_monitor_rule_template_context( kline_timeframe=KLINE_TIMEFRAME, key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, @@ -7146,6 +7164,7 @@ def render_main_page(page="trade", embed_mode=None): price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=position_limit_count, can_trade=can_trade, + open_block_note=open_block_note, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, @@ -7354,14 +7373,22 @@ def api_account_snapshot(): header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) conn.close() - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None @@ -7390,6 +7417,7 @@ def api_account_snapshot(): "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, + "open_block_note": open_block_note, "opens_today": opens_today, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index a38d5ef..2cd4ee2 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -262,6 +262,8 @@ AUTO_TRANSFER_BJ_HOUR=8 FORCE_CLOSE_BJ_HOUR=0 # 是否启用强制清仓(默认关闭,true 才会在整点执行) FORCE_CLOSE_ENABLED=false +# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓) +FORCE_CLOSE_GRACE_MINUTES=5 # 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 7c58d3e..5270451 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -2729,6 +2729,7 @@ def trading_day_reset_allows_new_open(now, conn=None): def precheck_risk(conn, symbol, direction): now = app_now() from lib.trade.account_risk_lib import account_risk_blocks_trading + from lib.trade.force_close_lib import force_close_blocks_new_open ok_risk, risk_reason = account_risk_blocks_trading( conn, @@ -2738,6 +2739,13 @@ def precheck_risk(conn, symbol, direction): ) if not ok_risk: return False, risk_reason + fc_block, fc_note = force_close_blocks_new_open( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ) + if fc_block: + return False, fc_note or "强制清仓窗口内暂不可开仓" if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" from lib.trade.account_risk_lib import position_limit_reached @@ -6399,8 +6407,10 @@ def force_close_before_reset(): if not FORCE_CLOSE_ENABLED: return now = app_now() - # 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) - if now.hour != FORCE_CLOSE_BJ_HOUR: + # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 + from lib.trade.force_close_lib import is_force_close_executing + + if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): return conn = get_db() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() @@ -6700,14 +6710,22 @@ def render_main_page(page="trade", embed_mode=None): open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now, conn), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] key_rule_ctx = {} if page in ("key_monitor", "trade") or page in ( "strategy", @@ -6794,6 +6812,7 @@ def render_main_page(page="trade", embed_mode=None): price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=position_limit_count, can_trade=can_trade, + open_block_note=open_block_note, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, @@ -7050,14 +7069,22 @@ def api_account_snapshot(): header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) conn.close() open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR - can_trade = can_trade_new_open( + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( time_allows=trading_day_reset_allows_new_open(now), active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=not risk_status.get("can_trade", True), + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] available_trading_usdt = get_available_trading_usdt() unrealized_pnl = None @@ -7120,6 +7147,7 @@ def api_account_snapshot(): "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, + "open_block_note": open_block_note, "opens_today": opens_today, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, diff --git a/lib/common/static/form_submit_guard.js b/lib/common/static/form_submit_guard.js index 25b56e0..e84807c 100644 --- a/lib/common/static/form_submit_guard.js +++ b/lib/common/static/form_submit_guard.js @@ -35,7 +35,8 @@ delete form.dataset.submitGuard; form.classList.remove("is-form-submitting"); submitButtons(form).forEach(function (btn) { - btn.disabled = false; + // 风控灰显(开仓门禁)保持禁用 + btn.disabled = btn.classList.contains("is-blocked"); var orig = btn.dataset.submitGuardOrig; if (orig !== undefined) { if (btn.tagName === "BUTTON") btn.textContent = orig; diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index ebeaea6..c0db29f 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -36,8 +36,21 @@ .order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none} .order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef} .order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px} - .order-monitor-form .om-row-action{padding-top:2px} + .order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px} .order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600} + .order-monitor-form .om-submit.is-blocked, + .order-monitor-form .om-submit:disabled{ + opacity:.45; + cursor:not-allowed; + filter:grayscale(.35); + pointer-events:none; + } + .order-monitor-form .om-open-block-note{ + color:var(--danger,#ff7b7b); + font-size:13px; + line-height:1.4; + max-width:min(28rem,100%); + } .order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem} #add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} .order-preview-risk{color:#ff6b6b} diff --git a/lib/common/static/open_submit_gate.js b/lib/common/static/open_submit_gate.js new file mode 100644 index 0000000..aaf7bdf --- /dev/null +++ b/lib/common/static/open_submit_gate.js @@ -0,0 +1,55 @@ +/** + * 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等). + */ +(function (global) { + function apply(data) { + const d = data || {}; + const btn = + document.getElementById("om-submit-btn") || + document.querySelector("#add-order-form button.om-submit"); + const noteEl = document.getElementById("om-open-block-note"); + if (!btn && !noteEl) return; + + const canTrade = d.can_trade !== false; + let note = (d.open_block_note || "").trim(); + const fc = d.force_close || {}; + const rs = d.risk_status || {}; + if (!note && fc.enabled && fc.executing) { + const grace = fc.grace_minutes != null ? fc.grace_minutes : 5; + note = + "强制清仓窗口内(北京时间 " + + (fc.hour_label || "--:--") + + " 起 " + + grace + + " 分钟),暂不可开仓"; + } + if (!note && rs.can_trade === false && rs.reason) { + note = String(rs.reason); + } + if (!note && !canTrade) { + note = "当前不可开仓"; + } + + if (btn) { + btn.disabled = !canTrade; + btn.classList.toggle("is-blocked", !canTrade); + btn.setAttribute("aria-disabled", canTrade ? "false" : "true"); + if (!canTrade) { + btn.title = note || "当前不可开仓"; + } else { + btn.removeAttribute("title"); + } + } + if (noteEl) { + if (!canTrade && note) { + noteEl.hidden = false; + noteEl.textContent = note; + } else { + noteEl.hidden = true; + noteEl.textContent = ""; + } + } + } + + global.OpenSubmitGate = { apply: apply }; +})(window); diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index a2f43f0..2375aa9 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -76,6 +76,7 @@ HOT_RELOAD_EXACT = frozenset({ "TRANSFER_CCY", "FORCE_CLOSE_ENABLED", "FORCE_CLOSE_BJ_HOUR", + "FORCE_CLOSE_GRACE_MINUTES", "BTC_LEVERAGE", "ALT_LEVERAGE", "DAILY_START_CAPITAL", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 7125b1c..9dff06e 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -83,6 +83,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ ("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"), ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""), ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""), + ("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"), ], }, { diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index 017b301..5a3e12b 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -152,7 +152,10 @@ def build_instance_settings_view( "title": "整点强制清仓", "rows": [ _row("强制清仓", _on_off(force_close_on)), - _row("执行时刻", f"北京时间 {force_close_hour}:00 起 15 分钟内"), + _row( + "执行时刻", + f"北京时间 {force_close_hour}:00 起 {_env_int('FORCE_CLOSE_GRACE_MINUTES', 5)} 分钟内", + ), ], } ) diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 1d02345..96cb39f 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1230,11 +1230,15 @@ function applyAccountSnapshot(data){ if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ TimeCloseUI.paintForceCloseHeader(data.force_close); } + if(window.OpenSubmitGate) OpenSubmitGate.apply(data); let canTradeText = "可开仓"; if(!data.can_trade){ const parts = []; + if(data.open_block_note) parts.push(data.open_block_note); if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){ - parts.push(data.risk_status.reason); + if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){ + parts.push(data.risk_status.reason); + } } const ac = Number(data.active_count || 0); const max = Number(data.max_active_positions || {{ max_active_positions }}); @@ -1242,9 +1246,8 @@ function applyAccountSnapshot(data){ const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const opens = Number(data.opens_today); if(hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`); - if(!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`); - else parts.push(`或未到北京时间 {{ reset_hour }}:00`); - canTradeText = `不可开仓(${parts.join(";")})`; + if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`); + canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓"; } const opensToday = Number(data.opens_today); const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index b25fa33..498f3a5 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -10,6 +10,7 @@ + {{ pwa_app_name }} diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index a3836d9..890907c 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -9,6 +9,7 @@ + @@ -1707,11 +1708,15 @@ function applyAccountSnapshot(data){ if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ TimeCloseUI.paintForceCloseHeader(data.force_close); } + if(window.OpenSubmitGate) OpenSubmitGate.apply(data); let canTradeText = "可开仓"; if(!data.can_trade){ const parts = []; + if(data.open_block_note) parts.push(data.open_block_note); if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){ - parts.push(data.risk_status.reason); + if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){ + parts.push(data.risk_status.reason); + } } if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`); const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); diff --git a/lib/instance/templates/order_monitor_open_form.html b/lib/instance/templates/order_monitor_open_form.html index 47e9bd7..20fd3b3 100644 --- a/lib/instance/templates/order_monitor_open_form.html +++ b/lib/instance/templates/order_monitor_open_form.html @@ -70,7 +70,8 @@
- + + {% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %}
{% include 'order_plan_preview_bar.html' %} diff --git a/lib/strategy/templates/order_monitor_rule_tips_binance.html b/lib/strategy/templates/order_monitor_rule_tips_binance.html index 45c75a7..ec63b41 100644 --- a/lib/strategy/templates/order_monitor_rule_tips_binance.html +++ b/lib/strategy/templates/order_monitor_rule_tips_binance.html @@ -7,6 +7,23 @@ 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1 +
+ 平仓 / 委托 / 强制清仓 +
+ +
+
计仓与保本说明
diff --git a/lib/strategy/templates/order_monitor_rule_tips_gate.html b/lib/strategy/templates/order_monitor_rule_tips_gate.html index 1ff7139..7719874 100644 --- a/lib/strategy/templates/order_monitor_rule_tips_gate.html +++ b/lib/strategy/templates/order_monitor_rule_tips_gate.html @@ -15,11 +15,11 @@
  • 手动平仓:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。
  • 强制清仓: {% if force_close is defined and force_close.enabled %} - 已开启 · 北京时间 {{ force_close.hour_label }} 整点起该小时内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」。 + 已开启 · 北京时间 {{ force_close.hour_label }} 整点起 {{ force_close.grace_minutes|default(5) }} 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。 {% else %} - 当前已关闭(FORCE_CLOSE_ENABLED=false);开启后按 FORCE_CLOSE_BJ_HOUR 在北京时间该整点小时内清掉 active 监控仓。 + 当前已关闭(FORCE_CLOSE_ENABLED=false);开启后按 FORCE_CLOSE_BJ_HOUR 在北京时间该整点起 FORCE_CLOSE_GRACE_MINUTES(默认 5)分钟内清掉 active 监控仓。 {% endif %} - 仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。 + 冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。
  • diff --git a/lib/strategy/templates/order_monitor_rule_tips_okx.html b/lib/strategy/templates/order_monitor_rule_tips_okx.html index b9d695c..737327a 100644 --- a/lib/strategy/templates/order_monitor_rule_tips_okx.html +++ b/lib/strategy/templates/order_monitor_rule_tips_okx.html @@ -7,6 +7,23 @@ 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
    +
    + 平仓 / 委托 / 强制清仓 +
    + +
    +
    计仓与保本说明
    diff --git a/lib/trade/force_close_lib.py b/lib/trade/force_close_lib.py index 46165e0..ef7c1db 100644 --- a/lib/trade/force_close_lib.py +++ b/lib/trade/force_close_lib.py @@ -8,7 +8,22 @@ from typing import Any, Optional from zoneinfo import ZoneInfo FORCE_CLOSE_RESULT = "强制清仓" -FORCE_CLOSE_GRACE_MINUTES = 15 +# 默认宽限(分钟);运行时优先读 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: @@ -43,7 +58,7 @@ def is_force_close_active_hour( *, now_ms: Optional[int] = None, tz_name: Optional[str] = None, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + grace_minutes: Optional[int] = None, ) -> bool: """当前是否处于整点强制清仓执行窗口(整点起 grace 分钟内).""" return is_force_close_executing( @@ -59,17 +74,41 @@ def is_force_close_executing( *, now_ms: Optional[int] = None, tz_name: Optional[str] = None, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + 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 - end = target + timedelta(minutes=max(1, int(grace_minutes))) + 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, *, @@ -93,7 +132,7 @@ def is_close_at_force_close_window( closed_at: Any, bj_hour: Any, *, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + grace_minutes: Optional[int] = None, tz_name: Optional[str] = None, ) -> bool: """平仓时刻是否落在北京时间整点强制清仓窗口内.""" @@ -103,7 +142,7 @@ def is_close_at_force_close_window( hour = normalize_force_close_bj_hour(bj_hour) if dt.hour != hour: return False - return dt.minute < max(1, int(grace_minutes)) + return dt.minute < force_close_grace_minutes(grace_minutes) def infer_force_close_result( @@ -111,7 +150,7 @@ def infer_force_close_result( *, enabled: bool, bj_hour: Any, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + grace_minutes: Optional[int] = None, tz_name: Optional[str] = None, ) -> Optional[str]: if not enabled: @@ -130,7 +169,7 @@ def coerce_force_close_result( enabled: bool, bj_hour: Any, miss_reason: Optional[str] = None, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + grace_minutes: Optional[int] = None, tz_name: Optional[str] = None, ) -> tuple[str, str]: """同步平仓归类:整点窗口内优先记为强制清仓.""" @@ -158,7 +197,7 @@ def apply_force_close_display_result( *, enabled: bool, bj_hour: Any, - grace_minutes: int = FORCE_CLOSE_GRACE_MINUTES, + grace_minutes: Optional[int] = None, tz_name: Optional[str] = None, ) -> str: """展示层:外部平仓/手动平仓若落在整点窗口,显示为强制清仓.""" @@ -227,19 +266,24 @@ def build_force_close_state( 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) + 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 @@ -248,10 +292,12 @@ def build_force_close_state( "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, } diff --git a/lib/trade/open_trade_gate_lib.py b/lib/trade/open_trade_gate_lib.py new file mode 100644 index 0000000..be298d5 --- /dev/null +++ b/lib/trade/open_trade_gate_lib.py @@ -0,0 +1,57 @@ +"""三所开仓门禁:账户风控 + 强制清仓窗口 + 仓位/日开仓上限.""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.trade.daily_open_limit_lib import can_trade_new_open +from lib.trade.force_close_lib import force_close_blocks_new_open + + +def resolve_manual_open_gate( + *, + time_allows: bool, + active_count: int, + max_active_positions: int, + opens_today: int, + hard_limit: int, + risk_status: Optional[dict[str, Any]], + force_close_enabled: bool, + force_close_bj_hour: Any, + now_ms: Optional[int] = None, + reset_hour: int = 8, +) -> dict[str, Any]: + """汇总是否可开仓及按钮旁说明文案.""" + rs = risk_status if isinstance(risk_status, dict) else {} + risk_can = bool(rs.get("can_trade", True)) + fc_block, fc_note = force_close_blocks_new_open( + bool(force_close_enabled), + force_close_bj_hour, + now_ms=now_ms, + ) + can_trade = can_trade_new_open( + time_allows=time_allows, + active_count=active_count, + max_active_positions=max_active_positions, + opens_today=opens_today, + hard_limit=hard_limit, + extra_blocks=(not risk_can) or fc_block, + ) + note = "" + if fc_block and fc_note: + note = fc_note + elif not risk_can: + note = str(rs.get("reason") or "账户冷静期/日冻结中,暂不可开仓") + elif not time_allows: + note = f"未到北京时间 {int(reset_hour)}:00,暂不可开仓" + elif int(active_count) >= int(max_active_positions): + note = f"已达最大持仓数({int(active_count)}/{int(max_active_positions)}),暂不可开仓" + elif int(hard_limit) > 0 and int(opens_today) >= int(hard_limit): + note = ( + f"本交易日开仓已达上限({int(opens_today)}/{int(hard_limit)})," + f"次日北京时间 {int(reset_hour)}:00 后恢复" + ) + return { + "can_trade": can_trade, + "open_block_note": note if not can_trade else "", + "force_close_blocks": fc_block, + } diff --git a/tests/test_force_close_lib.py b/tests/test_force_close_lib.py index 54d3397..6555a33 100644 --- a/tests/test_force_close_lib.py +++ b/tests/test_force_close_lib.py @@ -49,7 +49,8 @@ def test_next_force_close_after_trigger_same_day(): def test_executing_window_and_countdown(): - now = _ms(2026, 7, 6, 0, 14) + # 默认宽限 5 分钟:00:04 仍在执行窗 + now = _ms(2026, 7, 6, 0, 4) 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( @@ -57,17 +58,21 @@ def test_executing_window_and_countdown(): ) assert state["enabled"] is True assert state["active"] is True + assert state["executing"] is True + assert state["grace_minutes"] == 5 assert state["countdown"] == "执行中" 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) + # 默认宽限 5 分钟:00:05 起已离开执行窗 + now = _ms(2026, 7, 7, 0, 5) 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["executing"] is False assert state["countdown"] != "执行中" assert state["next_at_ms"] == _ms(2026, 7, 8, 0, 0) @@ -85,6 +90,8 @@ def test_format_countdown(): def test_infer_force_close_from_closed_at(): assert is_close_at_force_close_window("2026-07-07 00:00", 0) + assert is_close_at_force_close_window("2026-07-07 00:04", 0) + assert not is_close_at_force_close_window("2026-07-07 00:05", 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 diff --git a/tests/test_open_trade_gate_lib.py b/tests/test_open_trade_gate_lib.py new file mode 100644 index 0000000..ca137c2 --- /dev/null +++ b/tests/test_open_trade_gate_lib.py @@ -0,0 +1,43 @@ +from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + +def _base(**kwargs): + cfg = dict( + time_allows=True, + active_count=0, + max_active_positions=1, + opens_today=0, + hard_limit=10, + risk_status={"can_trade": True, "reason": ""}, + force_close_enabled=False, + force_close_bj_hour=0, + now_ms=None, + reset_hour=8, + ) + cfg.update(kwargs) + return resolve_manual_open_gate(**cfg) + + +def test_open_when_clear(): + g = _base() + assert g["can_trade"] is True + assert g["open_block_note"] == "" + + +def test_block_cooloff_note(): + g = _base(risk_status={"can_trade": False, "reason": "账户冷静期中"}) + assert g["can_trade"] is False + assert "冷静期" in g["open_block_note"] + + +def test_block_force_close_window(): + # 2026-07-07 00:02 Asia/Shanghai + from datetime import datetime + from zoneinfo import ZoneInfo + + now_ms = int(datetime(2026, 7, 7, 0, 2, tzinfo=ZoneInfo("Asia/Shanghai")).timestamp() * 1000) + g = _base(force_close_enabled=True, force_close_bj_hour=0, now_ms=now_ms) + assert g["can_trade"] is False + assert g["force_close_blocks"] is True + assert "强制清仓" in g["open_block_note"] + assert "5 分钟" in g["open_block_note"]