关键位箱体突破调整

This commit is contained in:
dekun
2026-05-23 18:06:06 +08:00
parent a4c13fd8cd
commit 1b0bd41e3b
13 changed files with 1221 additions and 467 deletions
+5 -3
View File
@@ -93,9 +93,13 @@ KEY_CONFIRM_BAR=-1
# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%
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
# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1
@@ -104,8 +108,6 @@ KEY_AUTO_MIN_PLANNED_RR=1.5
KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%)
KEY_TREND_STOP_OUTSIDE_PCT=1
KEY_ALERT_MAX_TIMES=3
KEY_ALERT_INTERVAL_MINUTES=5
# =============================================================================
# 交易执行 / 人工风控(页面「实盘下单」)
+210 -54
View File
@@ -54,6 +54,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,
@@ -175,7 +188,7 @@ KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true"
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib
# 与币安 App「仓位历史-实现盈亏」对齐:默认仅 REALIZED_PNL(手续费另计;避免与 COMMISSION 重复扣)
BINANCE_APP_PNL_INCOME_TYPES = frozenset({"REALIZED_PNL"})
BINANCE_APP_PNL_INCOME_WITH_FEE = frozenset({"REALIZED_PNL", "COMMISSION"})
@@ -187,7 +200,6 @@ BINANCE_PNL_INCLUDE_FUNDING = os.getenv("BINANCE_PNL_INCLUDE_FUNDING", "false").
"true",
"yes",
)
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
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")
@@ -4145,19 +4157,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)
@@ -4219,13 +4229,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})",
]
@@ -4836,7 +4963,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()
@@ -4845,7 +4972,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:
@@ -4863,31 +4999,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)
@@ -5665,11 +5777,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"):
@@ -5856,9 +5967,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:
@@ -6330,11 +6454,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)
@@ -6369,6 +6495,10 @@ def add_key():
lw = round_price_to_exchange(ex_sym_key, float(d["lower"]))
upper_px = float(uh) if uh is not None else float(d["upper"])
lower_px = float(lw) if lw is not None else float(d["lower"])
if upper_px <= lower_px:
conn.close()
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(
@@ -6408,12 +6538,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()
ctr = False
@@ -6427,7 +6577,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 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
+13 -1
View File
@@ -281,7 +281,7 @@
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
@@ -304,7 +304,11 @@
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ k.symbol }}</strong>
{% if k.direction == 'watch' %}
<span class="pos-side-badge" style="background:#2a3152;color:#9ab">双向</span>
{% else %}
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
{% endif %}
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
</div>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
@@ -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";
@@ -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 极值向外 **百分比**(多:`低×(1p/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+外侧%) / **EH** |
| 箱体1R·止盈1.5H | `box_1p5` | **EH** / **E+1.5×H**RR≈1.5 | **E+H** / **E1.5×H** |
| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高 × (1+外侧%) / **录入止盈** |
| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **EH** |
| 箱体 1R·止盈 1.5H | `box_1p5` | **EH** / **E+1.5×H** | **E+H** / **E1.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` |
+4
View File
@@ -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
# 【关键位自动开仓盈亏比】严格大于该值才市价开仓
+211 -54
View File
@@ -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 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
+62 -93
View File
@@ -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 @@
<div class="stat-item"><div class="label">开单次数</div><div class="value">{{ s.opens_count }}</div></div>
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_count }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value">{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">净盈亏(U)</div><div class="value">{{ signed_usdt_fmt(s.net_pnl_u) }}</div></div>
<div class="stat-item"><div class="label">亏损额合计(U)</div><div class="value">{{ usdt_fmt(s.loss_sum_u) }}</div></div>
<div class="stat-item"><div class="label">单笔最大亏损(U)</div><div class="value">{% if s.max_single_loss is not none %}{{ signed_usdt_fmt(s.max_single_loss) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">单笔最大盈利(U)</div><div class="value">{% if s.max_single_profit is not none %}{{ usdt_fmt(s.max_single_profit) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">最大回撤(U)</div><div class="value">{{ usdt_fmt(s.max_drawdown_u) }}</div></div>
<div class="stat-item"><div class="label">净盈亏(U)</div><div class="value">{{ funds_fmt(s.net_pnl_u) }}</div></div>
<div class="stat-item"><div class="label">亏损额合计(U)</div><div class="value">{{ funds_fmt(s.loss_sum_u) }}</div></div>
<div class="stat-item"><div class="label">单笔最大亏损(U)</div><div class="value">{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">单笔最大盈利(U)</div><div class="value">{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">最大回撤(U)</div><div class="value">{{ funds_fmt(s.max_drawdown_u) }}</div></div>
<div class="stat-item"><div class="label">当前连续亏损笔数</div><div class="value">{{ s.consecutive_losses }}</div></div>
<div class="stat-item"><div class="label">最长连续亏损(交易日)</div><div class="value">{{ s.max_loss_streak_days }} 天</div></div>
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}{{ signed_usdt_fmt(s.worst_day_pnl) }}U{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}{{ funds_fmt(s.worst_day_pnl) }}U{% else %}-{% endif %}</div></div>
</div>
</div>
{% endmacro %}
@@ -253,9 +253,9 @@
<div class="stat-item"><div class="label">总交易</div><div class="value">{{ total }}</div></div>
<div class="stat-item"><div class="label">错过次数</div><div class="value">{{ miss_count }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value">{{ rate }}%</div></div>
<div class="stat-item"><div class="label">资金账户(USDT)</div><div class="value" id="total-capital">{% if funding_usdt is not none %}{{ usdt_fmt(funding_usdt) }}U{% else %}—{% endif %}</div></div>
<div class="stat-item"><div class="label">资金账户(USDT)</div><div class="value" id="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div></div>
<div class="stat-item"><div class="label">交易日</div><div class="value">{{ trading_day }}</div></div>
<div class="stat-item"><div class="label">当日资金(交易账户)</div><div class="value" id="current-capital">{{ usdt_fmt(current_capital) }}U</div></div>
<div class="stat-item"><div class="label">当日资金(交易账户)</div><div class="value" id="current-capital">{{ funds_fmt(current_capital) }}U</div></div>
</div>
<div class="rule-tip">实时价格更新时间:<span id="price-last-updated">--</span>(北京时间 UTC+8</div>
@@ -281,7 +281,7 @@
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
@@ -304,7 +304,11 @@
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ k.symbol }}</strong>
{% if k.direction == 'watch' %}
<span class="pos-side-badge" style="background:#2a3152;color:#9ab">双向</span>
{% else %}
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
{% endif %}
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
</div>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
@@ -444,13 +448,13 @@
</div>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
</div>
</div>
<div class="pos-meta">
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
<span class="pos-meta-item">风险: {{ o.risk_percent or '-' }}%≈{% if o.risk_amount is not none %}{{ usdt_fmt(o.risk_amount) }}{% else %}-{% endif %}U</span>
<span class="pos-meta-item">风险: {{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U</span>
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span>
@@ -491,7 +495,7 @@
</div>
<div class="pos-footer">
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
<span>计划基数: {% if o.margin_capital is not none %}{{ usdt_fmt(o.margin_capital) }}{% else %}-{% endif %}U</span>
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
<span>杠杆: {{ o.leverage or '-' }}x</span>
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
</div>
@@ -548,16 +552,6 @@
{% if page == 'records' %}
<div class="card full records-card">
<h2>交易记录 & 错过机会</h2>
<div class="rule-tip" style="margin-bottom:8px;font-size:.78rem">
盈亏U:<span style="color:#6ab88a"></span>=交易所平仓历史,
<span style="color:#8892b0"></span>=本地估算。
{% if exchange_pnl_sync %}
{% if exchange_pnl_sync.skipped %}25秒内已同步,可点右侧按钮强制){% else %}
本轮:平仓历史 {{ exchange_pnl_sync.hist_count or 0 }} 条,对齐 {{ exchange_pnl_sync.matched or 0 }} 笔{% if exchange_pnl_sync.reason %} — {{ exchange_pnl_sync.reason }}{% endif %}
{% endif %}
{% endif %}
<button type="button" id="sync-exchange-pnl-btn" style="margin-left:8px;padding:4px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer">立即同步</button>
</div>
<div class="form-row" style="margin-bottom:10px;gap:8px">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input id="review-mode-toggle" type="checkbox">
@@ -578,13 +572,13 @@
{% set tp_show = r.effective_take_profit or r.take_profit %}
<td>{{ price_fmt(r.symbol, stop_show) }}</td>
<td>{{ price_fmt(r.symbol, tp_show) }}</td>
<td>{% if r.margin_capital is not none %}{{ usdt_fmt(r.margin_capital) }}{% else %}-{% endif %}</td>
<td>{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}</td>
<td>{{ r.leverage or '-' }}</td>
<td>{{ r.effective_hold_minutes or 0 }}</td>
<td>{{ (r.effective_opened_at or '-')[:16] }}</td>
<td>{{ (r.effective_closed_at or r.created_at or '-')[:16] }}</td>
{% set pnl_val = (r.effective_pnl_amount or 0)|float %}
<td><span class="{{ 'pnl-profit' if pnl_val > 0 else ('pnl-loss' if pnl_val < 0 else '') }}">{{ signed_usdt_fmt(r.effective_pnl_amount or 0) }}</span>{% if r.display_pnl_source == 'exchange' %}<span style="font-size:.68rem;color:#6ab88a"></span>{% elif r.display_pnl_source != 'reviewed' %}<span style="font-size:.68rem;color:#8892b0"></span>{% endif %}</td>
<td><span class="{{ 'pnl-profit' if pnl_val > 0 else ('pnl-loss' if pnl_val < 0 else '') }}">{{ funds_fmt(r.effective_pnl_amount or 0) }}</span>{% if r.display_pnl_source == 'exchange' %}<span style="font-size:.68rem;color:#6ab88a"></span>{% elif r.display_pnl_source != 'reviewed' %}<span style="font-size:.68rem;color:#8892b0"></span>{% endif %}</td>
<td>
{% set effective_result = r.effective_result %}
{% if effective_result in ["止盈","保本止盈","移动止盈"] %}<span class="badge profit">{{ effective_result }}</span>
@@ -858,7 +852,7 @@ function openJournalDetail(id){
`开仓时间:${o.open_datetime || "-"}`,
`平仓时间:${o.close_datetime || "-"}`,
`持仓时长:${o.hold_duration || "-"}`,
`盈亏:${formatJournalPnlUi(o.pnl)}U`,
`盈亏:${o.pnl || "-"}U`,
`开仓类型:${o.entry_reason || "无"}`,
`平仓/离场:${formatJournalExitOneLine(o)}`,
`预期RR${o.expect_rr || "-"}`,
@@ -941,7 +935,7 @@ function editTradeRecordReview(t){
if(stopLoss === null) return;
const takeProfit = prompt("止盈价格(核对后用于统计)", formatPriceForInput(t.take_profit));
if(takeProfit === null) return;
const pnl = prompt("最终盈亏(可手工核对后填写)", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : (Number.isFinite(Number(t.pnl_amount)) ? Number(t.pnl_amount).toFixed(2) : String(t.pnl_amount)));
const pnl = prompt("最终盈亏(可手工核对后填写)", String(t.pnl_amount ?? ""));
if(pnl === null) return;
const result = prompt("结果(止盈/止损/保本止盈/移动止盈/手动平仓)", String(t.result || ""));
if(result === null) return;
@@ -1049,7 +1043,7 @@ function loadJournals(){
journalCache[o.id] = o;
const moodTags = (o.mood_issues || []).join(",") || "无";
html += `<div class="entry">
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${formatJournalPnlUi(o.pnl)}U</div>
<div><strong>${o.coin||"-"} ${o.tf||"-"}</strong> | 盈亏:${o.pnl||"-"}U</div>
<div>开:${o.open_datetime||"-"} 平:${o.close_datetime||"-"} 持仓:${o.hold_duration||"-"}</div>
<div>心态标签:${moodTags}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">
@@ -1231,7 +1225,7 @@ function fillJournalFromTrade(t){
setJournalField("close_datetime", toDatetimeLocalFromBeijing(t.closed_at));
setJournalField("coin", coinFromSymbol(t.symbol));
setJournalField("tf", "5m");
setJournalField("pnl", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : (Number.isFinite(Number(t.pnl_amount)) ? Number(t.pnl_amount).toFixed(2) : String(t.pnl_amount)));
setJournalField("pnl", (t.pnl_amount === null || typeof t.pnl_amount === "undefined") ? "" : String(t.pnl_amount));
const rr = calcExpectedRrFromTrade(t);
setJournalField("expect_rr", rr);
let realRr = rr;
@@ -1242,7 +1236,7 @@ function fillJournalFromTrade(t){
}
setJournalField("real_rr", realRr);
const riskHint = document.getElementById("risk-amount-hint");
if(riskHint){ riskHint.value = (Number.isFinite(riskAmount) && riskAmount > 0) ? riskAmount.toFixed(2) : ""; }
if(riskHint){ riskHint.value = (Number.isFinite(riskAmount) && riskAmount > 0) ? String(riskAmount) : ""; }
const entryPx = formatPriceForInput(t.trigger_price);
const slPx = formatPriceForInput(t.stop_loss);
const tpPx = formatPriceForInput(t.take_profit);
@@ -1416,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");
@@ -1423,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";
@@ -1598,28 +1600,13 @@ function allowManualOrderSubmit(form){
let latestAvailableUsdt = null;
const lastPriceMap = {};
function formatSigned(v, digits=4){
function formatSigned(v, digits=2){
if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return `${sign}${n.toFixed(digits)}`;
}
function formatUsdt2(v){
if(v === null || typeof v === "undefined" || v === "") return "-";
const n = Number(v);
if(Number.isNaN(n)) return "-";
return n.toFixed(2);
}
function formatSignedUsdt2(v){
if(v === null || typeof v === "undefined" || v === "" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if(n === 0) return "0.00";
const sign = n > 0 ? "+" : "";
return `${sign}${n.toFixed(2)}`;
}
function formatRrRatio(rr){
if(rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
@@ -1628,15 +1615,6 @@ function formatRrRatio(rr){
return `${body}:1`;
}
function formatJournalPnlUi(v){
if(v === null || typeof v === "undefined" || v === "") return "-";
const raw = String(v).trim();
if(!raw) return "-";
const n = Number(raw.replace(/,/g, ""));
if(Number.isFinite(n)) return formatSignedUsdt2(n);
return raw;
}
function paintPriceTrend(el, key, value){
if(!el) return;
const prev = lastPriceMap[key];
@@ -1660,7 +1638,7 @@ function refreshPriceSnapshot(){
(data.key_prices || []).forEach(k=>{
const pEl = document.getElementById(`key-price-${k.id}`);
if(pEl){
pEl.innerText = (k.price_display && k.price_display !== "-") ? k.price_display : Number(k.price).toFixed(6);
pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-");
paintPriceTrend(pEl, `k-${k.id}`, Number(k.price));
}
const upEl = document.getElementById(`key-up-diff-${k.id}`);
@@ -1684,19 +1662,26 @@ function refreshPriceSnapshot(){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const pxd = (o.price_display && o.price_display !== "-") ? o.price_display : null;
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
const decimals = hasMark ? 8 : 6;
pEl.innerText = pxd !== null ? pxd : px.toFixed(decimals);
paintPriceTrend(pEl, `o-${o.id}`, pxd !== null ? Number(pxd) : px);
let disp = "";
if(hasMark && o.exchange_mark_price_display){
disp = o.exchange_mark_price_display;
} else if(o.price_display){
disp = o.price_display;
} else {
const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
disp = Number.isFinite(px) ? px.toFixed(6) : "-";
}
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)){
exM.innerText = `${formatUsdt2(mn)}U`;
exM.innerText = `${mn.toFixed(2)}U`;
} else {
const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null;
exM.innerText = (prc === 0) ? "无仓数据" : "-";
@@ -1704,7 +1689,7 @@ function refreshPriceSnapshot(){
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSignedUsdt2(o.float_pnl)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
@@ -1750,20 +1735,20 @@ function refreshAccountSnapshot(){
fetch("/api/account_snapshot").then(r=>r.json()).then(data=>{
if (typeof data.funding_usdt !== "undefined") {
const el = document.getElementById("total-capital");
if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${formatUsdt2(data.funding_usdt)}U`;
if(el) el.innerText = (data.funding_usdt === null || data.funding_usdt === undefined) ? "—" : `${Number(data.funding_usdt).toFixed(2)}U`;
}
if (typeof data.current_capital !== "undefined") {
const el = document.getElementById("current-capital");
if(el) el.innerText = `${formatUsdt2(data.current_capital)}U`;
if(el) el.innerText = `${Number(data.current_capital).toFixed(2)}U`;
}
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00`;
const tip = document.getElementById("order-rule-tip");
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约 ${formatUsdt2(latestAvailableUsdt)}U` : "";
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
if(tip){
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail};人工开仓盈亏比不得低于 ${data.manual_min_planned_rr || {{ manual_min_planned_rr }}}:1`;
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
}
}).catch(()=>{});
}
@@ -1882,23 +1867,25 @@ function refreshPriceSnapshotConditional(){
(data.order_prices || []).forEach(o=>{
const pEl = document.getElementById(`order-price-${o.id}`);
if(pEl){
const pxd = (o.price_display && o.price_display !== "-") ? o.price_display : null;
const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })();
const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
const decimals = hasMark ? 8 : 6;
pEl.innerText = pxd !== null ? pxd : px.toFixed(decimals);
paintPriceTrend(pEl, `o-${o.id}`, pxd !== null ? Number(pxd) : px);
let disp = "";
if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display;
else if(o.price_display) disp = o.price_display;
else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; }
pEl.innerText = disp;
const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price);
paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px);
}
const exM = document.getElementById(`order-ex-margin-${o.id}`);
if(exM){
const mv = o.exchange_initial_margin;
const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv);
if(!Number.isNaN(mn)) exM.innerText = `${formatUsdt2(mn)}U`;
if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`;
else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; }
}
const pnlEl = document.getElementById(`order-pnl-${o.id}`);
if(pnlEl){
pnlEl.innerText = `${formatSignedUsdt2(o.float_pnl)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`;
pnlEl.classList.remove("price-up","price-down","price-flat");
if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up");
else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down");
@@ -1912,24 +1899,6 @@ function refreshPriceSnapshotConditional(){
}).catch(()=>{});
}
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
const syncExchangePnlBtn = document.getElementById("sync-exchange-pnl-btn");
if(syncExchangePnlBtn){
syncExchangePnlBtn.addEventListener("click", ()=>{
syncExchangePnlBtn.disabled = true;
syncExchangePnlBtn.innerText = "同步中…";
fetch("/api/sync_exchange_pnl").then(r=>r.json()).then(data=>{
const msg = data.ok
? `平仓历史 ${data.hist_count||0} 条,对齐 ${data.matched||0}${data.reason?("\n"+data.reason):""}`
: (data.reason || "同步失败");
alert(msg);
if((data.matched||0) > 0) location.reload();
}).catch(()=>alert("同步请求失败")).finally(()=>{
syncExchangePnlBtn.disabled = false;
syncExchangePnlBtn.innerText = "立即同步";
});
});
}
</script>
</body>
</html>
@@ -1,98 +1,142 @@
# 关键位自动下单说明
**适用仓库`crypto_monitor_gate`|交易所:Gate.io USDT 永续**Binance 版见同名的 `crypto_monitor_binance` 目录。)
本文档与 `.env``app.check_key_monitors``app.add_key``_market_open_for_key_monitor` 的实现一致。
---
## 结构与是否自动开仓
| `key_monitors.monitor_type`(录入类型) | 自动下单 | 触发后处置 |
|---------------------------------------|----------|------------|
| **箱体突破** | 是(满足全部条件) | **一次性结案**:写 `key_monitor_history` → 从 `key_monitors` **删除** |
| **收敛突破** | 是(同上) | 同上 |
| **关键阻力位** | 否 | 企业微信 **1 次**`close_reason=key_level_alert_only` **失效** |
| **关键支撑** | 否 | 同上 |
触发条件:**5m 收线硬门控** `_key_hard_checks`(量能、突破幅度、第二根收盘确认、日成交量前 30 等)。
---
## 录入限制(`/add_key`
- 存在 **`order_monitors.status='active'`** 时:**禁止添加** 「箱体突破」「收敛突破」。
- **关键阻力位 / 关键支撑位**:不受上条限制;触发后 **仅单次微信提醒**,然后结案。
- **4h EMA55 与所选方向逆势****不拦截**;添加成功后 **Flash** 提示。
- 上下沿入库前经 **`round_price_to_exchange`** 按合约 **价格精度** 取整。
---
## 环境与参数(`.env`
| 变量 | 含义 | 默认 |
|------|------|------|
| `KEY_AUTO_MIN_PLANNED_RR` | 计划 RR 阈值:**仅当严格大于该值** 才自动开仓(按下方 `E` 计算) | `1.5` |
| `KEY_STOP_OUTSIDE_BREAKOUT_PCT` | 止损:突破 K 极值向外 **百分比**(多:`低×(1p/100)`;空:`高×(1+p/100)` | `0.5` |
**其余与本仓库手动实盘一致:** `KLINE_TIMEFRAME``RISK_PERCENT``LIVE_TRADING_ENABLED``BREAKEVEN_*``DAILY_OPEN_ALERT_THRESHOLD`,以及 **`GATE_*`**(密钥、止盈止损触发、`GATE_TPSL_*` 等)
---
## 计价与下单口径
| 用途 | 价格 |
|------|------|
| 企业微信展示、**与 RR 门槛比较的计划 RR** | 确认 K(第二根闭合 5m)收盘 **`E`** |
| **实际开仓** | **市价**`place_exchange_order`,与 `/add_order` 一致);成交价可能与 `E` **滑点** |
| **以损定仓** | `calc_risk_fraction(direction, 当前市价, 止损)` + `RISK_PERCENT`(与 `/add_order` 一致) |
- 开仓成功后:`order_monitors.monitor_type`**关键位监控**;持仓卡片「来源」显示之。手动开仓为 **下单监控**
- 持仓列表中的 **盈亏比**:按 **实际成交价** 相对 SL/TP 重算,可与「按 `E` 算的计划 RR」略有偏差。
- **本仓库止盈止损挂单**:开仓后由 **`_gate_place_tp_sl_orders`** 挂载(仓位类 `price_orders` 或备选条件路径,逻辑与手动一致);细节受 `GATE_TPSL_USE_POSITION_ORDER``GATE_TPSL_PRICE_TYPE` 等影响。
---
## 自动单止盈 / 止损(仅箱体突破、收敛突破)
设箱体高度 **`H = |upper lower|`**(录入上下沿)。
| 方向 | 止损 SL | 止盈 TP |
|------|---------|---------|
| 多 | 突破 K **最低价** × (1 `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E + 1×H`** |
| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`/100) | **`E 1×H`** |
计划 **`RR = calc_rr_ratio(direction, E, SL, TP)`**。若为 `None`**RR ≤ `KEY_AUTO_MIN_PLANNED_RR`****不下单**,走 `rr_insufficient` 结案。
---
## 一次性结案(`close_reason`
以下任一发生:**按需发微信** → **`key_monitor_history`** → **从 `key_monitors` 删除**;**不会对同一条关键位重复轮询重试开仓**。
| `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 不足 / 下单失败 / 开仓成功 / 阻力支撑仅提醒)。
旧版「满 `KEY_ALERT_MAX_TIMES` 次再归档」对已触发结案的路径 **不再适用**;表中 `notification_count``max_notify` 等字段仍可能存在,以 **导出、兼容** 为主。
---
## 相关代码位置(通用)
| 说明 | 符号 |
|------|------|
| 门控与主循环 | `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` |
# 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_gate`Gate U 本位永续**
Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
---
## 一、监控类型总览
| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 |
|----------|--------------|--------------|------------|
| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed`**一次性删除** |
| **收敛突破** | **必选** 多/空 | ****(同上) | 同上 |
| **关键阻力** | **不选**`direction=watch` | **否** | 5m 收盘突破上/下沿 → 微信 **3 次**`key_level_alert_done` |
| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) |
| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) |
**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30**;上沿 **>** 下沿。
---
## 二、关键阻力位 / 关键支撑位(人工盯盘)
### 2.1 录入
- 填写 **上沿 `upper`****下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空)。
- 页面 **不显示、不要求** 方向;库中 `direction` 初始为 `watch`**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿)。
### 2.2 触发(极简
- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线)。
- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒。
- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒。
- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿、下沿谁先满足用谁,同根 K 仅可能满足一侧)。
**不参与:** 量能、二确 K、越过幅度下限、日成交排名(运行时)、计划 RR、自动开仓
### 2.3 微信提醒次数
| 配置 | 默认 | 含义 |
|------|------|------|
| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 |
| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 |
- 第 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 次业务推送并结案 |
---
## 三、箱体突破 / 收敛突破(自动开仓)
### 3.1 K 线结构(默认索引)
| 角色 | 环境变量 | 默认 | 含义 |
|------|----------|------|------|
| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K |
| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K |
### 3.2 硬门控(须全部通过)
1. **有效突破(收盘越界)**
- 多:`突破 K 收盘 > upper`
- 空:`突破 K 收盘 < 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 低外侧% / **E+H** | 突破 K 高外侧% / **EH** |
| 箱体 1R·止盈 1.5H | `box_1p5` | **EH** / **E+1.5×H** | **E+H** / **E1.5×H** |
| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** |
### 3.4 一次性结案(`close_reason`
| `close_reason` | 含义 |
|----------------|------|
| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 |
| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 |
| `auto_opened` | RR 达标且市价开仓成功 |
| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 |
---
## 四、环境与参数(`.env` 摘要)
| 变量 | 箱体/收敛 | 阻力/支撑 |
|------|-----------|-----------|
| `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` | 添加时 + 运行时 | **仅添加时** |
---
## 五、相关代码
| 说明 | 位置 |
|------|------|
| 共享判定 | `key_monitor_lib.py` |
| 主循环 | `check_key_monitors` |
| 自动门控 | `_key_hard_checks` |
| 阻力/支撑提醒 | `_process_key_rs_level_alert` |
| 录入 | `add_key` |
| 开仓 | `_market_open_for_key_monitor` |
+4
View File
@@ -149,8 +149,12 @@ KEY_CONFIRM_BREAKOUT_BAR=-2
KEY_CONFIRM_BAR=-1
KEY_VOLUME_MA_BARS=20
KEY_VOLUME_RATIO_MIN=1.3
# 【箱体/收敛】突破K收盘越过关键位下限%
KEY_BREAKOUT_AMP_MIN_PCT=0.03
KEY_BREAKOUT_AMP_MAX_PCT=0.5
# 【阻力/支撑】突破后微信提醒
KEY_ALERT_MAX_TIMES=3
KEY_ALERT_INTERVAL_MINUTES=5
EXCHANGE_DISPLAY_NAME=OKX
OKX_ACCOUNT_LABEL=
+228 -41
View File
@@ -54,6 +54,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,
@@ -182,8 +195,7 @@ BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0"))
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1"))
@@ -3230,31 +3242,34 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
out["reason"] = "5m K线数量不足"
return out
closed = bars[:-1] if len(bars) >= 3 else bars
if len(closed) < 23:
out["reason"] = "闭合K线不足"
min_closed = KEY_VOLUME_MA_BARS + 3
if len(closed) < min_closed:
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
return out
breakout = closed[-2]
confirm = closed[-1]
prev20 = closed[-22:-2]
avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1)
try:
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
confirm = closed[KEY_CONFIRM_BAR]
except IndexError:
out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置"
return out
prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR]
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 * 1.3 if avg20 > 0 else False
open_b = float(breakout[1])
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
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 > 0.03) and (amp_pct < 0.5)
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 <= 30)
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
swing4h_pct = 0.0
try:
seg48 = closed[-48:] if len(closed) >= 48 else closed
@@ -3389,6 +3404,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):
ex_sym = normalize_okx_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} 分钟),推送完毕后本条监控结案。",
"- OKX 本实例为提醒模式,不自动开仓。",
]
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):
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']}",
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})",
]
def get_symbol_mark_price(symbol):
"""斐波失效判定用标记价。"""
ex_sym = normalize_okx_symbol(symbol)
@@ -3831,7 +3970,7 @@ def breakout_too_far(p, edge_price, limit_pct):
return False
# 关键位监控
# 关键位监控(箱体/收敛:硬门控后提醒;阻力/支撑:5m 双向突破 + 三次提醒)
def check_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
@@ -3840,7 +3979,17 @@ 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
if typ not in KEY_MONITOR_AUTO_TYPES:
continue
direction = (r["direction"] or "long").lower()
if direction == KEY_DIRECTION_WATCH:
continue
now_dt = app_now()
if not can_notify_key_monitor(r, now_dt):
continue
@@ -3859,13 +4008,7 @@ def check_key_monitors():
sl_tp_mode = sl_tp_mode_from_row(r, "standard")
be_on = breakeven_enabled_from_row(r, 0)
plan_tuple, _mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks)
hard_lines = [
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",
]
hard_lines = _key_hard_lines_from_checks(checks)
if plan_tuple:
E, sl_raw, tp_raw, box_h = plan_tuple
planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw)
@@ -4413,11 +4556,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}OKX 提醒模式不自动开仓|"
f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向"
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll"):
@@ -4613,9 +4755,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:
@@ -5089,11 +5244,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) + tuple(FIB_KEY_MONITOR_TYPES)
if mt not in allowed_types:
flash("监控类型无效")
@@ -5124,6 +5281,10 @@ def add_key():
lw = round_price_to_exchange(ex_sym_key, float(d["lower"]))
upper_px = float(uh) if uh is not None else float(d["upper"])
lower_px = float(lw) if lw is not None else float(d["lower"])
if upper_px <= lower_px:
conn.close()
flash("上沿必须大于下沿")
return redirect("/")
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(
@@ -5163,18 +5324,44 @@ 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()
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}")
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"])
+13 -1
View File
@@ -281,7 +281,7 @@
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="upper" step="0.0001" placeholder="上沿/阻力" required>
@@ -304,7 +304,11 @@
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ k.symbol }}</strong>
{% if k.direction == 'watch' %}
<span class="pos-side-badge" style="background:#2a3152;color:#9ab">双向</span>
{% else %}
<span class="pos-side-badge {{ 'pos-side-long' if k.direction == 'long' else 'pos-side-short' }}">{{ '做多' if k.direction == 'long' else '做空' }}</span>
{% endif %}
<span class="badge direction" style="margin-left:4px">{{ k.monitor_type }}</span>
</div>
<button type="button" class="pos-close-btn" style="border:none;cursor:pointer" onclick="deleteKeyMonitor({{ k.id }})"></button>
@@ -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";
+102 -61
View File
@@ -1,101 +1,142 @@
# 关键位自动下单说明
# 关键位监控说明(自动开仓 + 人工盯盘)
**适用仓库`crypto_monitor_binance`|交易所:Binance U 本位永续**Gate 版见同名的 `crypto_monitor_gate` 目录。)
**适用:`crypto_monitor_okx`OKX 永续**
本实例 **箱体/收敛** 为微信提醒 + 自行下单,**不自动市价开仓**;阻力/支撑规则与 Binance/Gate 相同。共享逻辑见 `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 极值向外 **百分比**(多:`低×(1p/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+外侧%) / **EH** |
| 箱体1R·止盈1.5H | `box_1p5` | **EH** / **E+1.5×H**RR≈1.5 | **E+H** / **E1.5×H** |
| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高 × (1+外侧%) / **录入止盈** |
| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **EH** |
| 箱体 1R·止盈 1.5H | `box_1p5` | **EH** / **E+1.5×H** | **E+H** / **E1.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` |
+125
View File
@@ -0,0 +1,125 @@
"""
关键位监控阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
KEY_MONITOR_RS_TYPES = frozenset({"关键阻力位", "关键支撑位"})
KEY_MONITOR_ALERT_ONLY_TYPES = KEY_MONITOR_RS_TYPES
KEY_DIRECTION_WATCH = "watch"
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
direction = (direction or "long").strip().lower()
c = float(close)
if direction == "long":
u = float(upper)
if u <= 0 or c <= u:
return 0.0
return (c - u) / u * 100.0
lo = float(lower)
if lo <= 0 or c >= lo:
return 0.0
return (lo - c) / lo * 100.0
def auto_amp_ok(
direction: str,
close_b: float,
upper: float,
lower: float,
min_pct: float,
) -> tuple[bool, float]:
breach = calc_breakout_breach_pct(direction, close_b, upper, lower)
return breach > float(min_pct), breach
def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool:
"""确认 K 收盘须在箱体外(不得回到 [lower, upper] 内)。"""
direction = (direction or "long").strip().lower()
c = float(cfm_close)
if direction == "long":
return c > float(upper)
return c < float(lower)
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""
阻力/支撑人工盯盘最近 5m 收盘突破上沿或下沿严格 > / <
上沿优先同一根 K 不可能同时满足两者
"""
u, lo, c = float(upper), float(lower), float(close)
if c > u:
return {
"break_side": "upper",
"direction": "long",
"edge_price": u,
"key_price": u,
"break_label": "向上突破上沿",
}
if c < lo:
return {
"break_side": "lower",
"direction": "short",
"edge_price": lo,
"key_price": lo,
"break_label": "向下突破下沿",
}
return None
def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""已触发后根据入库方向还原突破边(long=上沿,short=下沿)。"""
d = (direction or "").strip().lower()
if d == "long":
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
if d == "short":
return {
"break_side": "lower",
"direction": "short",
"edge_price": float(lower),
"key_price": float(lower),
"break_label": "向下突破下沿",
}
return None
def notify_interval_elapsed(
last_notified_at: Optional[str],
interval_min: int,
now_dt: datetime,
) -> bool:
if not last_notified_at:
return True
try:
last_dt = datetime.fromisoformat(str(last_notified_at).replace("Z", "+00:00"))
if last_dt.tzinfo is not None:
last_dt = last_dt.replace(tzinfo=None)
except Exception:
return True
return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60
def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str:
return (
f"突破越过幅度:{'通过' if amp_ok else '不通过'}"
f"{round(float(amp_pct), 4)}%,要求 > {min_pct}%"
)
def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str:
side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方"
return (
f"第二根确认:{'通过' if confirm_ok else '不通过'}"
f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price}"
)