From a268a93027acb663c87d455fdbf7efc6b94a0a66 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 6 Jul 2026 02:17:47 +0800 Subject: [PATCH] feat: show force-close badge and countdown when FORCE_CLOSE is enabled Add shared lib/UI for midnight force-close indicator on instance and hub pages, and document Gate intraday 0-point exit alignment. Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 30 ++ crypto_monitor_binance/templates/index.html | 16 +- crypto_monitor_gate/app.py | 30 ++ crypto_monitor_gate/templates/index.html | 14 +- crypto_monitor_okx/app.py | 30 ++ crypto_monitor_okx/templates/index.html | 16 +- docs/strategy/README.md | 12 +- docs/strategy/gate-intraday.md | 277 ++++++++++++++++++ lib/common/static/instance_theme.css | 47 +++ lib/common/static/time_close_ui.js | 100 ++++++- .../templates/embed_boot_scripts.html | 11 + .../templates/embed_page_fragment.html | 1 + lib/instance/templates/embed_shell.html | 3 +- .../templates/force_close_header_badge.html | 8 + .../templates/force_close_order_badge.html | 8 + lib/trade/force_close_lib.py | 179 +++++++++++ manual_trading_hub/hub.py | 8 + manual_trading_hub/static/app.css | 21 ++ manual_trading_hub/static/app.js | 22 +- manual_trading_hub/static/index.html | 2 +- manual_trading_hub/static/time_close_ui.js | 100 ++++++- tests/test_force_close_lib.py | 57 ++++ 22 files changed, 974 insertions(+), 18 deletions(-) create mode 100644 docs/strategy/gate-intraday.md create mode 100644 lib/instance/templates/force_close_header_badge.html create mode 100644 lib/instance/templates/force_close_order_badge.html create mode 100644 lib/trade/force_close_lib.py create mode 100644 tests/test_force_close_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 290bff6..4b13310 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -120,6 +120,11 @@ from lib.trade.time_close_lib import ( time_close_label, time_close_settings_from_row, ) +from lib.trade.force_close_lib import ( + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) from lib.trade.manual_sltp_lib import ( normalize_open_sltp_mode, resolve_entrust_sltp_prices, @@ -7135,6 +7140,12 @@ def render_main_page(page="trade", embed_mode=None): raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") if plan.records_rows: raw_records = conn.execute( @@ -7279,6 +7290,11 @@ def render_main_page(page="trade", embed_mode=None): kline_timeframe=KLINE_TIMEFRAME, **strategy_extra, **embed_context_extras("binance"), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), ) if embed_mode == "fragment": return render_template("embed_page_fragment.html", **template_ctx) @@ -7358,6 +7374,11 @@ def api_account_snapshot(): "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day, "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), }) @@ -7599,6 +7620,11 @@ def api_price_snapshot(): funds_decimals=FUNDS_DECIMALS, ) apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None @@ -7637,6 +7663,10 @@ def api_price_snapshot(): "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), "orphan_live_positions": orphan_live_positions, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), }) diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index c7d8a73..dab4642 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -278,6 +278,7 @@ {% if trade_policy.badge_text %} {{ trade_policy.badge_text }} {% endif %} + {% include 'force_close_header_badge.html' %} {{ risk_status.status_label|default('正常') }}
@@ -789,7 +791,7 @@ - + @@ -1711,6 +1713,9 @@ function refreshPriceSnapshot(){ if(data.updated_at && updatedEl){ updatedEl.innerText = data.updated_at; } + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ @@ -1782,6 +1787,7 @@ function refreshPriceSnapshot(){ if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); }); renderOrphanRecoverBanner(data.orphan_live_positions); }).catch(()=>{}); @@ -1847,6 +1853,9 @@ function refreshAccountSnapshot(){ } } } + if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) { + TimeCloseUI.paintForceCloseHeader(data.force_close); + } let canTradeText = "可开仓"; if (!data.can_trade) { const parts = []; @@ -2019,6 +2028,9 @@ function refreshPriceSnapshotConditional(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } if(page === "key_monitor"){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); @@ -2068,6 +2080,8 @@ function refreshPriceSnapshotConditional(){ paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); const holdEl = document.getElementById(`order-hold-duration-${o.id}`); if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){ holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms)); diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index a305c42..47cdd62 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -121,6 +121,11 @@ from lib.trade.time_close_lib import ( time_close_label, time_close_settings_from_row, ) +from lib.trade.force_close_lib import ( + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) from lib.trade.manual_sltp_lib import ( normalize_open_sltp_mode, resolve_entrust_sltp_prices, @@ -6910,6 +6915,12 @@ def render_main_page(page="trade", embed_mode=None): raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) exchange_pnl_sync = {} if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( "fragment", @@ -7062,6 +7073,11 @@ def render_main_page(page="trade", embed_mode=None): kline_timeframe=KLINE_TIMEFRAME, exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), **embed_context_extras("gate"), ) if embed_mode == "fragment": @@ -7159,6 +7175,11 @@ def api_account_snapshot(): "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day, "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), }) @@ -7425,6 +7446,11 @@ def api_price_snapshot(): funds_decimals=FUNDS_DECIMALS, ) apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None @@ -7460,6 +7486,10 @@ def api_price_snapshot(): "order_prices": order_prices, "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), }) diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index f5e458e..d83b5f9 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -277,6 +277,7 @@ {% if trade_policy.badge_text %} {{ trade_policy.badge_text }} {% endif %} + {% include 'force_close_header_badge.html' %} {{ risk_status.status_label|default('正常') }}
@@ -755,7 +757,7 @@ - + @@ -1637,6 +1639,9 @@ function refreshPriceSnapshot(){ if(data.updated_at && updatedEl){ updatedEl.innerText = data.updated_at; } + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ @@ -1772,6 +1777,9 @@ function refreshAccountSnapshot(){ } } } + if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) { + TimeCloseUI.paintForceCloseHeader(data.force_close); + } let canTradeText = "可开仓"; if (!data.can_trade) { const parts = []; @@ -1944,6 +1952,9 @@ function refreshPriceSnapshotConditional(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } if(page === "key_monitor"){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); @@ -1993,6 +2004,7 @@ function refreshPriceSnapshotConditional(){ paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); const holdEl = document.getElementById(`order-hold-duration-${o.id}`); if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){ holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms)); diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index cfea055..b2b8df0 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -121,6 +121,11 @@ from lib.trade.time_close_lib import ( time_close_label, time_close_settings_from_row, ) +from lib.trade.force_close_lib import ( + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) from lib.trade.manual_sltp_lib import ( normalize_open_sltp_mode, resolve_entrust_sltp_prices, @@ -6471,6 +6476,12 @@ def render_main_page(page="trade", embed_mode=None): raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) exchange_pnl_sync = {} if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( "fragment", @@ -6625,6 +6636,11 @@ def render_main_page(page="trade", embed_mode=None): exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, **embed_context_extras("okx"), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), ) if embed_mode == "fragment": return render_template("embed_page_fragment.html", **template_ctx) @@ -6722,6 +6738,11 @@ def api_account_snapshot(): "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "trading_day": trading_day, "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), }) @@ -7028,6 +7049,11 @@ def api_price_snapshot(): funds_decimals=FUNDS_DECIMALS, ) apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None @@ -7063,6 +7089,10 @@ def api_price_snapshot(): "order_prices": order_prices, "position_marks": position_marks, "positions_raw_count": len(all_swap_positions), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), }) diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 955ef8a..f10ae06 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -277,6 +277,7 @@ {% if trade_policy.badge_text %} {{ trade_policy.badge_text }} {% endif %} + {% include 'force_close_header_badge.html' %} {{ risk_status.status_label|default('正常') }}
@@ -784,7 +786,7 @@ - + @@ -1666,6 +1668,9 @@ function refreshPriceSnapshot(){ if(data.updated_at && updatedEl){ updatedEl.innerText = data.updated_at; } + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ @@ -1737,6 +1742,7 @@ function refreshPriceSnapshot(){ if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); }); }).catch(()=>{}); } @@ -1801,6 +1807,9 @@ function refreshAccountSnapshot(){ } } } + if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) { + TimeCloseUI.paintForceCloseHeader(data.force_close); + } let canTradeText = "可开仓"; if(!data.can_trade){ const parts = []; @@ -1996,6 +2005,9 @@ function refreshPriceSnapshotConditional(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } if(page === "key_monitor"){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); @@ -2045,6 +2057,8 @@ function refreshPriceSnapshotConditional(){ paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); const holdEl = document.getElementById(`order-hold-duration-${o.id}`); if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){ holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms)); diff --git a/docs/strategy/README.md b/docs/strategy/README.md index c76beb9..ba18860 100644 --- a/docs/strategy/README.md +++ b/docs/strategy/README.md @@ -6,16 +6,19 @@ |------|------|------| | [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.2 | | [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.2 | -| gate-intraday.md | Gate·BTC/ETH 日内 | **待定** | +| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 | ## 约定 -- **不写盈亏比**:止盈/止损随行情人工设定,不写入策略 MD。 -- **界面短标签**:`大分歧A` / `大分歧B` / `小分歧`;全称在 option `title` / 策略 MD 说明。 +- **不写盈亏比(趋势户)**:币安/OKX 止盈/止损随行情人工设定,趋势 MD 不量化 RR。 +- **日内例外**:Gate 日内 **最低 1:1** 才开仓,持仓目标可动态调整(见 [gate-intraday.md](./gate-intraday.md))。 +- **界面短标签(趋势户)**:`大分歧A` / `大分歧B` / `小分歧`;全称在 option `title` / 策略 MD。 +- **界面短标签(日内户)**:`假破` / `结构突破`。 - **多空共用三字**:方向由「做多/做空」表达,不复用为「大分歧A多」等。 - **自动联动**:大分歧 A/B → 趋势单;**小分歧 → 波段单**;平仓写入交易记录 `entry_reason`;复盘「填入」自动带入。 - **杠杆默认**:BTC/ETH **10x**,其它 **5x**;与开仓类型无关(env `BTC_LEVERAGE` / `ALT_LEVERAGE`)。 -- **日内 profile 独立**:env 启用 `TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启时,**不显示**上述三项(Gate 当前);日内开仓类型后续单独定义。 +- **日内 profile 独立**:env 启用 `TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启时,**不显示** 大分歧三项(Gate);开仓类型为 **假破 / 结构突破**(见 gate-intraday.md)。 +- **日内 0 点出场**:策略称「0 点平仓」;程序为 `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR=0`,交易记录 `result=强制清仓`(与表单 1h/2h/4h `time_close` 无关)。 - **策略模块独立**:趋势回调、顺势加仓不走上述三项。 ## 系统实现 @@ -23,6 +26,7 @@ - 库:`lib/trade/entry_model_lib.py` - 趋势户表单:`lib/instance/templates/order_entry_model_fields.html` - 日内判定:`is_intraday_trading_profile()`(白名单仅含 BTC/ETH) +- 0 点强平:`force_close_before_reset()`(三所 `app.py`);env `FORCE_CLOSE_ENABLED` / `FORCE_CLOSE_BJ_HOUR` ## 相关文档 diff --git a/docs/strategy/gate-intraday.md b/docs/strategy/gate-intraday.md new file mode 100644 index 0000000..de76db2 --- /dev/null +++ b/docs/strategy/gate-intraday.md @@ -0,0 +1,277 @@ +# Gate·BTC 日内账户 + +> **状态**:v0.2(策略定稿;**0 点强平已实现**;日内 UI 隐藏平仓/委托/移动保本 **待实现**) + +--- + +## 1. 账户定位 + +| 项 | 说明 | +|----|------| +| 交易所 | Gate 合约 | +| 品种 | **仅 BTC** | +| 方向 | **多空都做**(由过滤条件决定,非手选方向) | +| 计仓 | `POSITION_SIZING_MODE=full_margin`(全仓杠杆) | +| UI profile | **日内户**(`TRADE_SYMBOL_WHITELIST=BTC,ETH` 且限制开启;本策略只交易 BTC) | +| 与趋势户关系 | **不使用** 大分歧 A/B/小分歧;**不使用**「趋势单 / 波段单」手选 | + +### 资金与杠杆(执行约定) + +| 项 | 说明 | +|----|------| +| 账户规模 | 约 300U(测试阶段) | +| 日交易基数 | **50U**(早 8:00 重置为 50U,不延续前日阶梯) | +| 单笔阶梯 | 上一笔 **+10U / −10U** 调节下一笔基数(赢 60U / 亏 40U 等) | +| 杠杆 | **10× 全仓** | +| 一次一单 | 同时仅 **1** 个 Gate 仓位 | +| 止损带宽 | **0.4%~1.5%**(结构要求更宽则 **不做**) | + +--- + +## 2. 周期分层 + +自上而下,**先定能不能做、再做哪一类**: + +| 层级 | 周期 | 作用 | +|------|------|------| +| 方向过滤 | **1H** | 大方向;**不得与 15m 排列反向** | +| 均线 + 结构 | **15m** | 21/55/144 排列、顶底分型、结构识别、**B 类收盘突破** | +| 触发 | **5m** | **A 类**:N 字形突破(配合 15m 分型) | +| 方法 | 裸 K + 三均线 | 形态确认、入场与止损锚点 | + +**不做「趋势单」概念**:持仓以 **小时** 计,当日了结;与币安/OKX 多日趋势户区分。 + +--- + +## 3. 方向过滤(必过) + +### 3.1 15m 三均线(21 / 55 / 144) + +| 15m 排列 | 只允许 | +|----------|--------| +| **多头排列**(21 > 55 > 144) | **只做多** | +| **空头排列**(21 < 55 < 144) | **只做空** | +| 纠缠、粘合、不符合 | **不做** | + +### 3.2 与 1H 同向 + +- **做多**:15m 多头排列,且 **1H 不得为空头排列**(1H 均线不能与 15m 方向相反)。 +- **做空**:15m 空头排列,且 **1H 不得为多头排列**。 +- 1H/15m 方向冲突 → **当日该方向不做**。 + +### 3.3 21 均线关系(才允许开仓) + +入场须与 **21 均线** 发生有效关系,避免 distant 追单: + +| 方向 | 要求(定性) | +|------|----------------| +| **做多** | 多头排列下,**回踩 21 附近获支撑** 或 **站稳 21 上方** 后再按 playbook 入场 | +| **做空** | 空头排列下,**反弹 21 附近承压** 或 **压在 21 下方** 后再按 playbook 入场 | + +--- + +## 4. 开仓类型(仅两类) + +界面日后仅两个短标签(全称见下表 hover / 本文): + +| 界面标签 | 存储 code(建议) | 本质 | +|----------|-------------------|------| +| **假破** | `liquidity_false_break` | 流动性扫单 → 假突破验证 → **5m N 字** → **15m 顶/底分型** | +| **结构突破** | `structure_breakout` | **15m 结构有效突破**(**收盘确认**) | + +子结构 **不单独占主下拉**,可在复盘备注或二级标签中记录。 + +--- + +## 5. A 类:假破(流动性 / 假突破) + +**适用**:关键位附近 **扫止损** 后价格 **回到结构内**,陷阱确认后再反向做。 + +### 5.1 流程 + +```text +1H/15m 方向 + 21 均线过滤通过 + → 假突破出现(扫高/扫低) + → 验证为「假」(收回结构内 / 反向裸 K 确认) + → 5m 走出 N 字(二次探底/探顶后,沿允许方向突破) + → 15m 出现底分型(多)或顶分型(空) + → 入场 +``` + +### 5.2 做多 / 做空(对称) + +| 步骤 | 做多 | 做空 | +|------|------|------| +| 假破 | 向下扫低后快速拉回支撑/箱上 | 向上扫高后跌回阻力/箱下 | +| 5m N 字 | 扫低 → 反弹 → 不破前低 → 向上突破 | 扫高 → 回落 → 不过前高 → 向下突破 | +| 15m 确认 | **底分型** | **顶分型** | +| 止损 | 假破极值或 N 字低点 **外侧**(仍须落在 0.4%~1.5%) | 对称 | +| 目标 | **最低 1:1**;之后 **随行情动态** 部分止盈、移动止损或延伸 | 对称 | + +### 5.3 注意 + +- **须等假破验证完成**,扫完不追。 +- **5m N + 15m 分型** 为入场必要条件,缺一不可。 +- 与大级别 **宽幅震荡(S1)** 叠加时假信号多,优先 **降频或不做**。 + +--- + +## 6. B 类:结构突破 + +**适用**:15m 上结构清晰,方向与均线排列一致,**收盘突破** 后顺势做。 + +### 6.1 子结构(均属 B 类) + +双顶、双底、头肩顶/底、收敛(三角/楔形)、箱体等——**统一记为「结构突破」**。 + +### 6.2 突破确认 + +- **以 15m K 线收盘价为准** 突破关键位(颈线、箱边、收敛边界等)。 +- **仅刺破、未收盘站稳** → **不算** 有效突破,不做。 +- 可选:**收盘突破后回踩** 再进(裸 K 确认),仍须满足 21 均线关系与 **≥1:1** 空间。 + +### 6.3 止损与目标 + +| 项 | 说明 | +|----|------| +| 止损 | 结构另一侧或突破位回退点 **外侧**(0.4%~1.5%,超出则不做) | +| 目标 | 下单前 **至少 1:1**;到位后 **随行情动态** 调整,不写死固定 RR | +| 空间不足 | 最近阻力/支撑导致 **达不到 1:1** → **不做** | + +--- + +## 7. 行情状态(辅助过滤) + +| 状态 | 特征 | Gate 动作 | +|------|------|-----------| +| **S0 趋势** | 1H/15m 排列清晰,高低点有序 | 正常:A/B 均可 | +| **S1 宽幅震荡** | 大箱横盘多日、均线反复穿 | **降频或不做** | +| **S2 末期/选边** | 贴边收敛、刚突破或假破频发 | 优先 **A 假破** 或 **B 收敛突破** | + +--- + +## 8. 一日节奏与笔数 + +| 项 | 规则 | +|----|------| +| 周末 | **不开新仓** | +| 早窗 | 约 **9:00**(8:00~12:00 内),**计划内第 1 笔** | +| 下午 | **默认不开新仓**(持仓可保留至晚窗) | +| 晚窗 | 约 **21:00**(20:00~23:00 内),**计划内第 2 笔** | +| 第 3 笔 | 仅当 **未连错 2 笔**,且 **早/晚有一笔为止损出场**,可 **补 1 笔** | +| 日上限 | **最多 3 笔** | +| **连错 2 笔** | **当日不再开新仓**(第 3 笔名额作废) | + +**连错计数**: + +| 出场 | 是否算「错 1 笔」 | +|------|------------------| +| **计划止损**触发 | ✅ 算 | +| **0 点强制清仓**(系统结果 `强制清仓`)且亏损 | ✅ 算 | +| 止盈 / ≥1:1 按计划平 | ❌ 不算 | +| 0 点强制清仓且盈利或平推 | ❌ 不算 | + +--- + +## 9. 出场与统计纪律 + +### 9.1 盈亏比 + +- 开仓前:**第一目标空间 ≥ 止损距离(最低 1:1)**。 +- 持仓中:目标 **随行情动态** 调整;本文档 **不量化** 固定止盈比例。 + +### 9.2 禁止「手动止损」 + +- **亏损出场** 必须来自 **开仓时设定的计划止损**(交易所或监控等价执行)。 +- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则)。 +- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计。 + +### 9.3 时间出场:仅 0 点(程序已实现) + +- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平)。 +- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关。 +- **程序兜底**(三所共用,Gate 已启用): + +| env | 说明 | +|-----|------| +| `FORCE_CLOSE_ENABLED=true` | 开启整点强制清仓 | +| `FORCE_CLOSE_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:00~00:59)执行 | + +- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用)。 +- 行为:对该小时仍 **active** 的 `order_monitors` **市价全平**,取消交易所触发单,写交易记录。 +- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」。 +- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分。 + +> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计、8 点前禁开等),**不会**自动平仓。 + +### 9.4 允许的出场类型(统计用) + +| 策略说法 | 系统 `result` | 说明 | +|----------|---------------|------| +| 止盈 | 止盈 / 移动止盈 / 保本止盈 等 | 计划止盈或 ≥1:1 后按计划/动态平 | +| 止损 | 止损 | 仅 **计划止损** 触发 | +| 0 点平仓 | **强制清仓** | 整点风控兜底(§9.3) | +| ~~手动平仓~~ | 手动平仓 | **策略禁止**(除极端技术故障等,须复盘说明) | + +--- + +## 10. A / B 如何选择(当日) + +| 盘面 | 优先 | +|------|------| +| 刚扫流动性、回到箱内 | **A 假破** | +| 结构清晰、排列已顺、收敛末端 | **B 结构突破** | +| 大箱乱扫、均线粘合 | **不做** | + +早/晚窗 **有形态才做**,无形态 = **0 笔**,不占额度。 + +--- + +## 11. 与其它账户边界 + +| 账户 | 周期 | 持仓 | 本户勿混 | +|------|------|------|----------| +| 币安 | 日线/4H 事件 | 数天~数周 | 不要用 Gate 扛隔夜趋势 | +| OKX | 4H 波段滚仓 | 数小时~数天 | 勿与 Gate 同向同结构叠隔夜 | +| **Gate 日内** | 1H 过滤 + 15m/5m | **当日 0 点前** | 见上文 | + +--- + +## 12. 系统对接 + +### 12.1 已实现 + +| 项 | 说明 | +|----|------| +| 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) | +| 0 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** | +| UI 标识 | 顶栏 **强制清仓 已开启** 徽章 + 持仓卡片 **倒计时**(三所 + 中控) | +| 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge | +| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**将来改日内只需各所 `.env` 打开,无需改代码** | + +Gate 当前建议 env(节选): + +```env +FORCE_CLOSE_ENABLED=true +FORCE_CLOSE_BJ_HOUR=0 +TRADING_DAY_RESET_HOUR=8 +``` + +### 12.2 待实现(UI / 纪律) + +| 项 | 说明 | +|----|------| +| 开仓类型 | 界面 **`假破` / `结构突破`**(code:`liquidity_false_break` / `structure_breakout`);**无** trend/swing 手选 | +| 写入字段 | `trade_records.entry_model` / 复盘下拉同两项 | +| 隐藏操作 | 日内 profile 下 **隐藏** 平仓、委托、移动保本(**实例页 + 中控**,共用 lib 判断) | +| 隐藏表单项 | 不展示 1h/2h/4h 时间平仓、移动保本勾选(避免与 §9.3 混用) | +| 后端可选 | 严格模式下拒绝 `del_order` / 改委托 API | + +--- + +## 13. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v0.1 | 2026-07-06 | 定稿:BTC 日内;1H+15m 均线;A 假破(5m N+15m 分型);B 结构突破(15m 收盘);早1晚1/最多3笔/连错2停;禁手动止损;仅 0 点强平 | +| v0.2 | 2026-07-06 | §9.3/§12:对齐 `FORCE_CLOSE_*` 与系统结果「强制清仓」;区分 `time_close` / `TRADING_DAY_RESET_HOUR`;Gate 已启用说明 | diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 25f1be4..21b3a28 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -590,6 +590,53 @@ html[data-theme="light"] .pos-meta-item::after { font-variant-numeric: tabular-nums; letter-spacing: 0.03em; } +.force-close-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + font-weight: 600; + color: #ffc870; + background: #2a2218; + border: 1px solid #6a5020; + padding: 4px 12px; + border-radius: 999px; + letter-spacing: 0.02em; + white-space: nowrap; +} +.force-close-badge .force-close-header-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +.pos-force-close-meta { + color: #ffc870; +} +.pos-symbol-force-close { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 500; + color: #ffc870; + padding: 1px 6px; + border-radius: 4px; + background: rgba(255, 200, 112, 0.12); + white-space: nowrap; +} +.pos-symbol-force-close .pos-force-close-cd { + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; +} +html[data-theme="light"] .force-close-badge { + color: #9a6200; + background: #fff6e8; + border-color: #d4a84a; +} +html[data-theme="light"] .pos-symbol-force-close, +html[data-theme="light"] .pos-force-close-meta { + color: #9a6200; + background: rgba(212, 168, 74, 0.14); +} .key-time-close-wrap.is-disabled > label, .order-time-close-wrap.is-disabled > label { opacity: 0.72; diff --git a/lib/common/static/time_close_ui.js b/lib/common/static/time_close_ui.js index 99ba6b8..7061f61 100644 --- a/lib/common/static/time_close_ui.js +++ b/lib/common/static/time_close_ui.js @@ -1,5 +1,5 @@ /** - * 时间平仓:表单开关 + 持仓倒计时。 + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。 */ (function (global) { "use strict"; @@ -16,6 +16,15 @@ return pad2(h) + ":" + pad2(m) + ":" + pad2(r); } + function isForceCloseActive(wrap) { + if (!wrap) return false; + const raw = + wrap.dataset.forceCloseActive || + wrap.getAttribute("data-force-close-active") || + ""; + return raw === "1" || raw === "true"; + } + function bindTimeCloseForm(checkboxId, selectId, wrapId) { const cb = document.getElementById(checkboxId); const sel = document.getElementById(selectId); @@ -37,6 +46,15 @@ sync(); } + function paintCountdownEl(cd, rem, active) { + if (!cd) return; + if (active) { + cd.textContent = "执行中"; + return; + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + } + function paintOrderTimeClose(order) { if (!order || order.id == null) return; const wrap = document.getElementById("order-time-close-wrap-" + order.id); @@ -59,10 +77,71 @@ if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); } - cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + paintCountdownEl(cd, rem, false); wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; } + function paintOrderForceClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-force-close-wrap-" + order.id); + const cd = document.getElementById("order-force-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!order.force_close_enabled; + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = order.force_close_label || "强制清仓"; + const labelEl = wrap.querySelector(".pos-force-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.force_close_remaining_sec != null + ? Number(order.force_close_remaining_sec) + : null; + const atMs = order.force_close_at_ms; + if ((rem == null || !Number.isFinite(rem)) && atMs) { + rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000)); + } + const active = !!order.force_close_active; + paintCountdownEl(cd, rem, active); + wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : ""; + wrap.dataset.forceCloseActive = active ? "1" : "0"; + } + + function paintForceCloseHeader(state) { + const wrap = document.getElementById("force-close-header-badge"); + if (!wrap) return; + if (!state || !state.enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = state.label || "强制清仓"; + const labelPrefix = label + " 已开启 · "; + let prefixNode = wrap.querySelector(".force-close-header-prefix"); + if (!prefixNode) { + wrap.textContent = ""; + prefixNode = document.createElement("span"); + prefixNode.className = "force-close-header-prefix"; + prefixNode.textContent = labelPrefix; + wrap.appendChild(prefixNode); + const cd = document.createElement("span"); + cd.className = "force-close-header-cd"; + wrap.appendChild(cd); + } else { + prefixNode.textContent = labelPrefix; + } + const cd = wrap.querySelector(".force-close-header-cd"); + let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null; + if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) { + rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, !!state.active); + wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : ""; + wrap.dataset.forceCloseActive = state.active ? "1" : "0"; + } + function tickLocalCountdowns() { document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; @@ -73,10 +152,23 @@ const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); cd.textContent = formatCountdown(rem); }); + document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = + wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + paintCountdownEl(cd, rem, isForceCloseActive(wrap)); + }); } function paintOrders(orders) { - (orders || []).forEach(paintOrderTimeClose); + (orders || []).forEach(function (order) { + paintOrderTimeClose(order); + paintOrderForceClose(order); + }); } function syncKeyTimeCloseVisibility(show) { @@ -88,6 +180,8 @@ global.TimeCloseUI = { bindTimeCloseForm: bindTimeCloseForm, paintOrderTimeClose: paintOrderTimeClose, + paintOrderForceClose: paintOrderForceClose, + paintForceCloseHeader: paintForceCloseHeader, paintOrders: paintOrders, tickLocalCountdowns: tickLocalCountdowns, syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index d5e9db3..94e9576 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -944,6 +944,9 @@ function refreshPriceSnapshot(){ if(data.updated_at && updatedEl){ updatedEl.innerText = data.updated_at; } + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); if(pEl){ @@ -1015,6 +1018,7 @@ function refreshPriceSnapshot(){ if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); }); }).catch(()=>{}); } @@ -1074,6 +1078,9 @@ function refreshAccountSnapshot(){ } } } + if (data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader) { + TimeCloseUI.paintForceCloseHeader(data.force_close); + } let canTradeText = "可开仓"; if (!data.can_trade) { const parts = []; @@ -1247,6 +1254,9 @@ function refreshPriceSnapshotConditional(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ + TimeCloseUI.paintForceCloseHeader(data.force_close); + } if(page === "key_monitor"){ (data.key_prices || []).forEach(k=>{ const pEl = document.getElementById(`key-price-${k.id}`); @@ -1296,6 +1306,7 @@ function refreshPriceSnapshotConditional(){ paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); paintPlanTpslDisplay(o.id, o); if(window.TimeCloseUI) TimeCloseUI.paintOrderTimeClose(o); + if(window.TimeCloseUI) TimeCloseUI.paintOrderForceClose(o); const holdEl = document.getElementById(`order-hold-duration-${o.id}`); if(holdEl && o.opened_at_ms != null && o.opened_at_ms !== ""){ holdEl.setAttribute("data-order-opened-ms", String(o.opened_at_ms)); diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 9ada0b0..35b8ee0 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -95,6 +95,7 @@ · --:--:-- {% endif %} + {% include 'force_close_order_badge.html' %} {{ '做多' if o.direction == 'long' else '做空' }}
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 2366512..c72e03f 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -31,6 +31,7 @@ {% if trade_policy.badge_text %} {{ trade_policy.badge_text }} {% endif %} + {% include 'force_close_header_badge.html' %} {{ risk_status.status_label|default('正常') }}
${tcSymBadge}${symBeBadge} + ${tcSymBadge}${fcSymBadge}${symBeBadge} ${sideCn}
@@ -3132,8 +3145,9 @@ .map((o) => { const sym = o.exchange_symbol || o.symbol || ""; const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : ""; + const fcBadge = o.force_close_enabled ? forceCloseSymbolBadgeHtml(o) : ""; return `
-
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}
+
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge}${fcBadge} · ${renderDirectionHtml(o.direction)}
触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
`; }) @@ -3202,6 +3216,8 @@ const mo = monitorOrder || {}; const tcBadge = !isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; + const fcBadge = + !isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : ""; const actionCell = `
@@ -3210,7 +3226,7 @@ ? `${fmt(x.unrealized_pnl, 2)}` : ""; return ` - ${tcBadge}${symBeBadge} + ${tcBadge}${fcBadge}${symBeBadge} ${renderDirectionHtml(x.side)} ${fmtEntryPrice(x, tickMap)} ${fmtMarkPrice(x, tickMap)} diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index e554848..28a5a66 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1114,7 +1114,7 @@ - + diff --git a/manual_trading_hub/static/time_close_ui.js b/manual_trading_hub/static/time_close_ui.js index 99ba6b8..7061f61 100644 --- a/manual_trading_hub/static/time_close_ui.js +++ b/manual_trading_hub/static/time_close_ui.js @@ -1,5 +1,5 @@ /** - * 时间平仓:表单开关 + 持仓倒计时。 + * 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。 */ (function (global) { "use strict"; @@ -16,6 +16,15 @@ return pad2(h) + ":" + pad2(m) + ":" + pad2(r); } + function isForceCloseActive(wrap) { + if (!wrap) return false; + const raw = + wrap.dataset.forceCloseActive || + wrap.getAttribute("data-force-close-active") || + ""; + return raw === "1" || raw === "true"; + } + function bindTimeCloseForm(checkboxId, selectId, wrapId) { const cb = document.getElementById(checkboxId); const sel = document.getElementById(selectId); @@ -37,6 +46,15 @@ sync(); } + function paintCountdownEl(cd, rem, active) { + if (!cd) return; + if (active) { + cd.textContent = "执行中"; + return; + } + cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + } + function paintOrderTimeClose(order) { if (!order || order.id == null) return; const wrap = document.getElementById("order-time-close-wrap-" + order.id); @@ -59,10 +77,71 @@ if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) { rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000)); } - cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--"; + paintCountdownEl(cd, rem, false); wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : ""; } + function paintOrderForceClose(order) { + if (!order || order.id == null) return; + const wrap = document.getElementById("order-force-close-wrap-" + order.id); + const cd = document.getElementById("order-force-close-cd-" + order.id); + if (!wrap || !cd) return; + const enabled = !!order.force_close_enabled; + if (!enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = order.force_close_label || "强制清仓"; + const labelEl = wrap.querySelector(".pos-force-close-label"); + if (labelEl) labelEl.textContent = label; + let rem = + order.force_close_remaining_sec != null + ? Number(order.force_close_remaining_sec) + : null; + const atMs = order.force_close_at_ms; + if ((rem == null || !Number.isFinite(rem)) && atMs) { + rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000)); + } + const active = !!order.force_close_active; + paintCountdownEl(cd, rem, active); + wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : ""; + wrap.dataset.forceCloseActive = active ? "1" : "0"; + } + + function paintForceCloseHeader(state) { + const wrap = document.getElementById("force-close-header-badge"); + if (!wrap) return; + if (!state || !state.enabled) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const label = state.label || "强制清仓"; + const labelPrefix = label + " 已开启 · "; + let prefixNode = wrap.querySelector(".force-close-header-prefix"); + if (!prefixNode) { + wrap.textContent = ""; + prefixNode = document.createElement("span"); + prefixNode.className = "force-close-header-prefix"; + prefixNode.textContent = labelPrefix; + wrap.appendChild(prefixNode); + const cd = document.createElement("span"); + cd.className = "force-close-header-cd"; + wrap.appendChild(cd); + } else { + prefixNode.textContent = labelPrefix; + } + const cd = wrap.querySelector(".force-close-header-cd"); + let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null; + if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) { + rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000)); + } + paintCountdownEl(cd, rem, !!state.active); + wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : ""; + wrap.dataset.forceCloseActive = state.active ? "1" : "0"; + } + function tickLocalCountdowns() { document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) { const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || ""; @@ -73,10 +152,23 @@ const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); cd.textContent = formatCountdown(rem); }); + document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) { + const closeAtRaw = + wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || ""; + const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd"); + if (!cd) return; + const closeAt = Number(closeAtRaw); + if (!closeAt) return; + const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000)); + paintCountdownEl(cd, rem, isForceCloseActive(wrap)); + }); } function paintOrders(orders) { - (orders || []).forEach(paintOrderTimeClose); + (orders || []).forEach(function (order) { + paintOrderTimeClose(order); + paintOrderForceClose(order); + }); } function syncKeyTimeCloseVisibility(show) { @@ -88,6 +180,8 @@ global.TimeCloseUI = { bindTimeCloseForm: bindTimeCloseForm, paintOrderTimeClose: paintOrderTimeClose, + paintOrderForceClose: paintOrderForceClose, + paintForceCloseHeader: paintForceCloseHeader, paintOrders: paintOrders, tickLocalCountdowns: tickLocalCountdowns, syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility, diff --git a/tests/test_force_close_lib.py b/tests/test_force_close_lib.py new file mode 100644 index 0000000..8131986 --- /dev/null +++ b/tests/test_force_close_lib.py @@ -0,0 +1,57 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from lib.trade.force_close_lib import ( + build_force_close_state, + compute_next_force_close_at_ms, + force_close_label, + format_force_close_countdown, + is_force_close_active_hour, +) + +TZ = ZoneInfo("Asia/Shanghai") + + +def _ms(y, m, d, hh, mm=0): + return int(datetime(y, m, d, hh, mm, tzinfo=TZ).timestamp() * 1000) + + +def test_force_close_label(): + assert force_close_label(0) == "强制清仓 00:00" + assert force_close_label(8) == "强制清仓 08:00" + + +def test_next_force_close_at_midnight(): + # 2026-07-05 23:30 -> next 2026-07-06 00:00 + now = _ms(2026, 7, 5, 23, 30) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 6, 0, 0 + ) + + +def test_next_force_close_same_day_before_hour(): + now = _ms(2026, 7, 6, 15, 0) + assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms( + 2026, 7, 7, 0, 0 + ) + + +def test_active_hour_and_countdown(): + now = _ms(2026, 7, 6, 0, 15) + 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") + assert state["enabled"] is True + assert state["active"] is True + assert state["countdown"] == "执行中" + assert state["remaining_sec"] == 0 + + +def test_disabled_state(): + state = build_force_close_state(False, 0) + assert state["enabled"] is False + assert state["next_at_ms"] is None + + +def test_format_countdown(): + assert format_force_close_countdown(3661) == "01:01:01" + assert format_force_close_countdown(0, active=True) == "执行中"