{{ k.symbol }}
+ {% if k.direction == 'watch' %}
+ 双向
+ {% else %}
{{ '做多' if k.direction == 'long' else '做空' }}
+ {% endif %}
{{ k.monitor_type }}
@@ -1406,6 +1410,7 @@ if(journalForm){
function syncKeyMonitorFormFields(){
const typeEl = document.querySelector('#key-form [name="type"]');
+ const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
@@ -1413,8 +1418,15 @@ function syncKeyMonitorFormFields(){
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破","收敛突破"]);
const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]);
+ const rsTypes = new Set(["关键阻力位","关键支撑位"]);
const showAuto = autoTypes.has(t);
const showBe = showAuto || fibTypes.has(t);
+ const showDir = !rsTypes.has(t);
+ if(dirEl){
+ dirEl.style.display = showDir ? "" : "none";
+ dirEl.required = showDir;
+ if(!showDir) dirEl.value = "";
+ }
if(modeEl) modeEl.style.display = showAuto ? "" : "none";
if(manualTp){
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
diff --git a/crypto_monitor_binance/关键位自动下单说明.md b/crypto_monitor_binance/关键位自动下单说明.md
index 230d87e..87d98ff 100644
--- a/crypto_monitor_binance/关键位自动下单说明.md
+++ b/crypto_monitor_binance/关键位自动下单说明.md
@@ -1,101 +1,142 @@
-# 关键位自动下单说明
+# 关键位监控说明(自动开仓 + 人工盯盘)
-**适用仓库:`crypto_monitor_binance`|交易所:Binance U 本位永续**(Gate 版见同名的 `crypto_monitor_gate` 目录。)
+**适用:`crypto_monitor_binance`(Binance U 本位永续)**
+Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。
-本文档与 `.env`、`app.check_key_monitors`、`app.add_key`、`_market_open_for_key_monitor` 的实现一致。
+本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。
---
-## 结构与是否自动开仓
+## 一、监控类型总览
-| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 |
-|---------------------------------------|----------|------------|
-| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** |
-| **收敛突破** | 是(同上) | 同上 |
-| **关键阻力位** | 否 | 企业微信 **1 次** → `close_reason=key_level_alert_only` → **失效** |
-| **关键支撑位** | 否 | 同上 |
+| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 |
+|----------|--------------|--------------|------------|
+| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** |
+| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 |
+| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` |
+| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
+| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
-触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。
+**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。
---
-## 录入限制(`/add_key`)
+## 二、关键阻力位 / 关键支撑位(人工盯盘)
-- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。
-- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。
-- **4h EMA55 与所选方向逆势**:**不拦截**;添加成功后 **Flash** 提示。
-- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。
+### 2.1 录入
----
+- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空)。
+- 页面 **不显示、不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿)。
-## 环境与参数(`.env`)
+### 2.2 触发(极简)
-| 变量 | 含义 | 默认 |
+- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线)。
+- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒。
+- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒。
+- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿、下沿谁先满足用谁,同根 K 仅可能满足一侧)。
+
+**不参与:** 量能、二确 K、越过幅度下限、日成交排名(运行时)、计划 RR、自动开仓。
+
+### 2.3 微信提醒次数
+
+| 配置 | 默认 | 含义 |
|------|------|------|
-| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` |
-| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1−p/100)`;空:`高×(1+p/100)`) | `0.5` |
+| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 |
+| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 |
-**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME`、`RISK_PERCENT`、`LIVE_TRADING_ENABLED`、`BREAKEVEN_*`、`DAILY_OPEN_ALERT_THRESHOLD`,以及 **`BINANCE_*`**(密钥、`BINANCE_MARGIN_MODE`、`BINANCE_POSITION_MODE`、`BINANCE_TRIGGER_WORKING_TYPE` 等)。资金字段舍入端口径与 **`FUNDS_DECIMALS`** 一致。
+- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件)。
+- 第 2、3 次:仅按间隔推送(**不要求**价格仍在箱外)。
+- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**。
+
+### 2.4 与箱体/收敛的区别
+
+| 项目 | 阻力/支撑 | 箱体/收敛 |
+|------|-----------|-----------|
+| 方向 | 程序推断 | 人工选择 |
+| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) |
+| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 |
---
-## 计价与下单口径
+## 三、箱体突破 / 收敛突破(自动开仓)
-| 用途 | 价格 |
-|------|------|
-| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** |
-| **实际开仓** | **市价**(`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** |
-| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(保证金等 **`FUNDS_DECIMALS`** 舍入,与 `/add_order` 一致) |
+### 3.1 K 线结构(默认索引)
-- 开仓成功后:`order_monitors.monitor_type` 为 **关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**。
-- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。
-- **本仓库止盈止损挂单**:开仓后由 **`_binance_place_tp_sl_orders`** 挂载(与手动一致:U 本位条件/Algo 类触发单;具体类型以 ccxt / 交易所为准)。
+| 角色 | 环境变量 | 默认 | 含义 |
+|------|----------|------|------|
+| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K |
+| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K |
----
+### 3.2 硬门控(须全部通过)
-## 自动单止盈 / 止损(仅箱体突破、收敛突破)
+1. **有效突破(收盘越界)**
+ - 多:`突破 K 收盘 > upper`
+ - 空:`突破 K 收盘 < lower`
-添加关键位时在页面选择 **止盈止损方案**(写入 `key_monitors.sl_tp_mode`)。确认 K 收盘 **E**,箱体高 **H = |upper − lower|`**。
+2. **突破越过幅度(仅下限)**
+ - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**)
+ - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上
+ - **无上限**;突破过猛由 **计划 RR** 过滤。
+ - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**。
+
+3. **确认 K 不进箱体**
+ - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内)
+ - 空:确认 K 收盘 **`< lower`**
+
+4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3)
+
+5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)
+
+6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓
+
+### 3.3 止损 / 止盈(确认 K 收盘为 E)
+
+箱体高 **H = |upper − lower|**。止损锚在 **突破 K 极值** 外侧:
+
+| 方向 | 止损(标准/趋势方案) |
+|------|------------------------|
+| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) |
+| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) |
+
+止盈方案见下表(与改版前一致):
| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP |
|------|--------------|-------------|-------------|
-| 标准突破(默认) | `standard` | 突破 K 低 × (1−`KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) / **E+H** | 突破 K 高 × (1+外侧%) / **E−H** |
-| 箱体1R·止盈1.5H | `box_1p5` | **E−H** / **E+1.5×H**(RR≈1.5) | **E+H** / **E−1.5×H** |
-| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高 × (1+外侧%) / **录入止盈** |
+| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** |
+| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** |
+| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** |
-计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None` 或 **RR ≤ `KEY_AUTO_MIN_PLANNED_RR`** → **不下单**,走 `rr_insufficient` 结案。
-
-**移动保本:** 添加时可勾选(默认关);开仓写入 `order_monitors.breakeven_enabled` 与勾选一致。详见仓库根目录 `关键位止盈止损与移动保本更新说明.md`。
-
----
-
-## 一次性结案(`close_reason`)
-
-以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。
+### 3.4 一次性结案(`close_reason`)
| `close_reason` | 含义 |
|----------------|------|
-| `rr_insufficient` | 门控通过,但计划 RR 未达标或 SL/TP / RR **几何无效** |
-| `exchange_failed` | 计划 RR 达标,但未开实盘、`LIVE_TRADING_ENABLED=false`、风控、保证金或 **交易所报错** 等导致 **开仓失败** |
-| `auto_opened` | 计划 RR 达标且 **市价开仓成功**(已写 `order_monitors`,并已挂止盈止损) |
-| `key_level_alert_only` | 阻力/支撑位 **仅推送**结案 |
+| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
+| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
+| `auto_opened` | RR 达标且市价开仓成功 |
+| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 |
---
-## 与企业微信推送
+## 四、环境与参数(`.env` 摘要)
-每种结案路径 **至多一条**主业务推送(RR 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。
-
-旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count`、`max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。
+| 变量 | 箱体/收敛 | 阻力/支撑 |
+|------|-----------|-----------|
+| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 |
+| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 |
+| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 |
+| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 |
+| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) |
+| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** |
---
-## 相关代码位置(通用)
+## 五、相关代码
-| 说明 | 符号 |
+| 说明 | 位置 |
|------|------|
-| 门控与主循环 | `check_key_monitors` |
-| 录入、有仓拦截、4h Flash | `add_key` |
-| 市价开仓 + 写 `order_monitors` | `_market_open_for_key_monitor` |
-| 计划 RR | `calc_rr_ratio(direction, E, SL, TP)` |
-| 价格精度 | `round_price_to_exchange` |
+| 共享判定 | `key_monitor_lib.py` |
+| 主循环 | `check_key_monitors` |
+| 自动门控 | `_key_hard_checks` |
+| 阻力/支撑提醒 | `_process_key_rs_level_alert` |
+| 录入 | `add_key` |
+| 开仓 | `_market_open_for_key_monitor` |
diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example
index fd4f819..cf59823 100644
--- a/crypto_monitor_gate/.env.example
+++ b/crypto_monitor_gate/.env.example
@@ -94,8 +94,12 @@ KEY_CONFIRM_BAR=-1
KEY_VOLUME_MA_BARS=20
KEY_VOLUME_RATIO_MIN=1.3
# 【突破K实体幅度】占开盘价百分比区间
+# 【箱体/收敛】突破K收盘越过关键位下限%;无上限(过猛由计划RR过滤)
KEY_BREAKOUT_AMP_MIN_PCT=0.03
KEY_BREAKOUT_AMP_MAX_PCT=0.5
+# 【阻力/支撑】突破后微信提醒
+KEY_ALERT_MAX_TIMES=3
+KEY_ALERT_INTERVAL_MINUTES=5
# 【日成交量排名】品种须在该排名前 N 名
KEY_DAILY_VOLUME_RANK_MAX=30
# 【关键位自动开仓盈亏比】严格大于该值才市价开仓
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 96cf117..614e997 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -55,6 +55,19 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
+from key_monitor_lib import (
+ KEY_DIRECTION_WATCH,
+ KEY_MONITOR_ALERT_ONLY_TYPES,
+ KEY_MONITOR_AUTO_TYPES,
+ KEY_MONITOR_RS_TYPES,
+ auto_amp_ok,
+ auto_confirm_ok,
+ detect_rs_box_break,
+ format_auto_amp_line,
+ format_auto_confirm_line,
+ notify_interval_elapsed,
+ rs_break_from_direction,
+)
from hub_auth import request_allowed as hub_request_allowed
from history_window_lib import (
PRESET_CUSTOM,
@@ -181,8 +194,7 @@ EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
_LAST_EXCHANGE_PNL_SYNC_AT = 0.0
-KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
-KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
+# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib
AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true"
AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30"))
AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding")
@@ -4016,19 +4028,17 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1)
vol_break = float(breakout[5])
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
- open_b = float(breakout[1])
close_b = float(breakout[4])
high_b = float(breakout[2])
low_b = float(breakout[3])
- amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0
- amp_ok = (amp_pct > KEY_BREAKOUT_AMP_MIN_PCT) and (amp_pct < KEY_BREAKOUT_AMP_MAX_PCT)
cfm_close = float(confirm[4])
- # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿
edge = float(upper) if direction == "long" else float(lower)
breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower))
- confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge)
- # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y”
+ amp_ok, amp_pct = auto_amp_ok(
+ direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT
+ )
amp_ok = amp_ok and breakout_ok
+ confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower))
confirm_ok = confirm_ok_raw and breakout_ok
rank, total = _daily_volume_rank(symbol)
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
@@ -4090,13 +4100,130 @@ def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason):
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
+def _fetch_last_closed_bar(symbol):
+ """最近一根闭合 K:[ts, o, h, l, c, v] 或 None。"""
+ ex_sym = normalize_exchange_symbol(symbol)
+ bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or []
+ if len(bars) < 2:
+ return None
+ closed = bars[:-1]
+ return closed[-1] if closed else None
+
+
+def build_wechat_rs_level_message(
+ symbol,
+ monitor_type,
+ trigger_time,
+ upper,
+ lower,
+ trigger_close,
+ break_info,
+ notify_index,
+ notify_max,
+):
+ lines = [
+ f"# 📌 {symbol} 关键位突破提醒({notify_index}/{notify_max})",
+ f"**账户:{_wechat_account_label()}**",
+ "",
+ "---",
+ "",
+ "### 突破判定(5m 收盘)",
+ f"- 类型:**{monitor_type}**",
+ f"- 触发时间:`{trigger_time}`",
+ f"- 上沿:`{upper}`|下沿:`{lower}`",
+ f"- 触发收盘:`{format_price_for_symbol(symbol, trigger_close)}`",
+ f"- **{break_info['break_label']}**(程序推断:**{_wechat_direction_text(break_info['direction'])}**)",
+ f"- 突破价位:`{format_price_for_symbol(symbol, break_info['edge_price'])}`",
+ "",
+ "### 说明",
+ "- 本条为**人工盯盘**用途:录入时**不选多空**,由上/下沿突破方向自动判定。",
+ f"- 共推送 **{notify_max}** 次(间隔约 {KEY_ALERT_INTERVAL_MINUTES} 分钟),推送完毕后本条监控结案。",
+ "- **不参与**自动开仓、量能/二确/盈亏比门控。",
+ ]
+ return "\n".join(lines)
+
+
+def _key_rs_gate_preview(symbol, upper, lower):
+ """页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线。"""
+ bar = _fetch_last_closed_bar(symbol)
+ if not bar:
+ return {"summary": "5m数据不足", "metrics": ""}
+ close = float(bar[4])
+ br = detect_rs_box_break(close, upper, lower)
+ if br:
+ return {
+ "summary": f"已越线:{br['break_label']}",
+ "metrics": f"收盘:{format_price_for_symbol(symbol, close)}",
+ }
+ return {
+ "summary": "待突破",
+ "metrics": f"收盘:{format_price_for_symbol(symbol, close)}",
+ }
+
+
+def _process_key_rs_level_alert(conn, row):
+ """关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次。"""
+ sym = row["symbol"]
+ typ = (row["monitor_type"] or "").strip()
+ up, low = float(row["upper"]), float(row["lower"])
+ if up <= low:
+ return
+ bar = _fetch_last_closed_bar(sym)
+ if not bar:
+ return
+ close = float(bar[4])
+ ts = bar[0]
+ count = int(row["notification_count"] or 0)
+ max_n = max(1, int(row["max_notify"] or KEY_ALERT_MAX_TIMES))
+ interval = max(1, int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES))
+ now_dt = app_now()
+
+ if count == 0:
+ br = detect_rs_box_break(close, up, low)
+ if not br:
+ return
+ else:
+ if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt):
+ return
+ br = rs_break_from_direction(row["direction"], up, low)
+ if not br:
+ return
+
+ trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str()
+ notify_index = count + 1
+ msg = build_wechat_rs_level_message(
+ symbol=sym,
+ monitor_type=typ,
+ trigger_time=trigger_time,
+ upper=up,
+ lower=low,
+ trigger_close=close,
+ break_info=br,
+ notify_index=notify_index,
+ notify_max=max_n,
+ )
+ send_wechat_msg(msg)
+ conn.execute(
+ "UPDATE key_monitors SET direction=?, notification_count=?, last_notified_at=?, last_alert_message=? WHERE id=?",
+ (br["direction"], notify_index, app_now_str(), msg, row["id"]),
+ )
+ if notify_index >= max_n:
+ hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone()
+ if hist_row:
+ insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done")
+ conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
+
+
def _key_hard_lines_from_checks(checks):
+ direction = (checks.get("direction") or "long").lower()
return [
f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)",
f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})",
- f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)",
- f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})",
- f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)",
+ format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT),
+ format_auto_confirm_line(
+ checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction
+ ),
+ f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})",
]
@@ -4705,7 +4832,7 @@ def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, br
return True, None
-# 关键位监控(箱体/收敛可自动开仓;阻力/支撑位仅单次提醒结案)
+# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒)
def check_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
@@ -4714,7 +4841,16 @@ def check_key_monitors():
typ = (typ_raw or "").strip()
if is_fib_key_monitor_type(typ):
continue
+ if typ in KEY_MONITOR_RS_TYPES:
+ try:
+ _process_key_rs_level_alert(conn, r)
+ except Exception:
+ pass
+ continue
+
direction = (r["direction"] or "long").lower()
+ if direction == KEY_DIRECTION_WATCH:
+ continue
try:
checks = _key_hard_checks(sym, direction, up, low, typ)
except Exception:
@@ -4732,31 +4868,7 @@ def check_key_monitors():
hard_lines = _key_hard_lines_from_checks(checks)
trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str()
- alert_only = typ in KEY_MONITOR_ALERT_ONLY_TYPES or (
- typ not in KEY_MONITOR_AUTO_TYPES and typ not in KEY_MONITOR_ALERT_ONLY_TYPES
- )
-
- if alert_only:
- op_lines = [
- "- 本条为关键阻力/支撑或非标类型:**仅单次推送**,不进行自动开仓。",
- "- 本条关键位将在推送后记入历史并从监控列表移除。",
- ]
- msg = build_wechat_key_monitor_message(
- symbol=sym,
- direction=direction,
- monitor_type=typ,
- trigger_time=trigger_time,
- key_price=key_price,
- confirm_close=checks["confirm_close"],
- hard_lines=hard_lines,
- btc8h_status=btc8h_status,
- coin4h_status=coin4h_status,
- swing4h_pct=checks.get("swing4h_pct") or 0.0,
- op_lines=op_lines,
- risk_tip=risk_tip,
- )
- send_wechat_msg(msg)
- _finalize_key_monitor_one_shot(conn, r, msg, "key_level_alert_only")
+ if typ not in KEY_MONITOR_AUTO_TYPES:
continue
plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks)
@@ -5670,11 +5782,10 @@ def render_main_page(page="trade"):
active_count = len(order_list)
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
key_gate_rule_text = (
- f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|"
- f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"
- f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|"
- f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"
- f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"
+ f"【箱体/收敛】{KLINE_TIMEFRAME} 两根闭合K|突破越过关键位 > {KEY_BREAKOUT_AMP_MIN_PCT}%|"
+ f"确认K收于箱外|量能>前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"
+ f"RR>{KEY_AUTO_MIN_PLANNED_RR}|日成交前{KEY_DAILY_VOLUME_RANK_MAX}|"
+ f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓"
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
@@ -5885,9 +5996,22 @@ def api_price_snapshot():
gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}"
if _sqlite_row_val(r, "fib_limit_order_id"):
gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}"
+ elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
+ try:
+ prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
+ gate_summary = prev.get("summary") or "-"
+ gate_metrics = prev.get("metrics") or ""
+ except Exception:
+ gate_summary = "-"
else:
try:
- gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"])
+ gate = _key_hard_checks(
+ r["symbol"],
+ (r["direction"] or "long").lower(),
+ r["upper"],
+ r["lower"],
+ r["monitor_type"],
+ )
except Exception:
gate = None
if gate:
@@ -6380,11 +6504,13 @@ def add_key():
if not symbol:
flash("symbol 不能为空")
return redirect("/key_monitor")
- direction_sel = (d.get("direction") or "").strip().lower()
- if direction_sel not in ("long", "short"):
- flash("请选择做多或做空")
- return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
+ direction_sel = (d.get("direction") or "").strip().lower()
+ if mt in KEY_MONITOR_RS_TYPES:
+ direction_sel = KEY_DIRECTION_WATCH
+ elif direction_sel not in ("long", "short"):
+ flash("箱体/收敛突破请选择做多或做空")
+ return redirect("/key_monitor")
allowed_types = (
tuple(KEY_MONITOR_AUTO_TYPES)
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
@@ -6428,6 +6554,11 @@ def add_key():
return redirect("/key_monitor")
upper_px = round_price_to_exchange(ex_sym_key, upper_raw)
lower_px = round_price_to_exchange(ex_sym_key, lower_raw)
+ if float(upper_px) <= float(lower_px):
+ conn.close()
+ conn = None
+ flash("上沿必须大于下沿")
+ return redirect("/key_monitor")
be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled"))
if is_fib_key_monitor_type(mt):
ok_fib, err_fib = _add_fib_key_monitor(
@@ -6471,12 +6602,32 @@ def add_key():
mtpx = round_price_to_exchange(ex_sym_key, manual_tp)
if mtpx is not None:
manual_tp = float(mtpx)
- conn.execute(
- "INSERT INTO key_monitors "
- "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) "
- "VALUES (?,?,?,?,?,?,?,?)",
- (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag),
- )
+ if mt in KEY_MONITOR_RS_TYPES:
+ conn.execute(
+ "INSERT INTO key_monitors "
+ "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
+ "max_notify,notify_interval_min) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?)",
+ (
+ symbol,
+ mt,
+ direction_sel,
+ upper_px,
+ lower_px,
+ sl_tp_mode,
+ manual_tp,
+ be_flag,
+ KEY_ALERT_MAX_TIMES,
+ KEY_ALERT_INTERVAL_MINUTES,
+ ),
+ )
+ else:
+ conn.execute(
+ "INSERT INTO key_monitors "
+ "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) "
+ "VALUES (?,?,?,?,?,?,?,?)",
+ (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag),
+ )
conn.commit()
conn.close()
conn = None
@@ -6491,7 +6642,13 @@ def add_key():
extra = ""
if mt in KEY_MONITOR_AUTO_TYPES:
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}"
- flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
+ if mt in KEY_MONITOR_RS_TYPES:
+ flash(
+ f"添加成功({symbol} 日成交量排名 {rank}/{total})|阻力/支撑:双向监控上/下沿,"
+ f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)"
+ )
+ else:
+ flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
if ctr:
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html
index bce99a2..237fb59 100644
--- a/crypto_monitor_gate/templates/index.html
+++ b/crypto_monitor_gate/templates/index.html
@@ -150,11 +150,11 @@
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
- .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap}
- .pos-close-btn:hover{background:#d66565;color:#fff}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
+ .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
+ .pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
@@ -199,14 +199,14 @@
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ signed_usdt_fmt(s.net_pnl_u) }}
-
亏损额合计(U)
{{ usdt_fmt(s.loss_sum_u) }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ signed_usdt_fmt(s.max_single_loss) }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ usdt_fmt(s.max_single_profit) }}{% else %}-{% endif %}
-
最大回撤(U)
{{ usdt_fmt(s.max_drawdown_u) }}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
当前连续亏损笔数
{{ s.consecutive_losses }}
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ signed_usdt_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
{% endmacro %}
@@ -253,9 +253,9 @@