feat(okx): auto-open on box/convergence key breakout like Gate/Binance

Replace alert-only check_key_monitors with market open flow, add _market_open_for_key_monitor, and update docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 16:08:32 +08:00
parent a5f4ad8e97
commit e265c1b31a
4 changed files with 361 additions and 70 deletions
+356 -65
View File
@@ -4163,7 +4163,6 @@ def _process_key_rs_level_alert(conn, row):
notify_index=notify_index,
notify_max=max_n,
interval_min=interval,
extra_note="OKX 本实例为提醒模式,不自动市价开仓",
)
send_wechat_msg(msg)
conn.execute(
@@ -4639,6 +4638,196 @@ def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, br
return True, None
def _market_open_for_key_monitor(
conn,
symbol,
direction,
exchange_symbol,
stop_loss,
take_profit,
key_signal_type=None,
breakeven_enabled=0,
):
"""
与手动实盘下单对齐的市价开仓与 order_monitors 写入OKX 永续
返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict])
"""
now = app_now()
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
return False, f"风控拒绝下单:{reason}", None
ok_live, reason_live = ensure_exchange_live_ready()
if not ok_live:
return False, reason_live, None
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
leverage = int(default_leverage) if default_leverage else 5
if leverage <= 0:
leverage = 5
trading_day = get_trading_day(now)
opens_today_before = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
(trading_day,),
).fetchone()[0]
session_row = ensure_session(conn, trading_day)
_, trading_capital_live = get_exchange_capitals(force=True)
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
if trade_style not in ("trend", "swing"):
trade_style = "trend"
available_usdt = get_available_trading_usdt()
live_price = get_price(symbol)
if live_price is None:
return False, "获取交易所实时价格失败(以损定仓需要当前价)", None
try:
ensure_markets_loaded()
except Exception:
pass
lp_r = round_price_to_exchange(exchange_symbol, live_price)
if lp_r is not None:
live_price = lp_r
sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss))
tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit))
if sl_adj is not None:
stop_loss = float(sl_adj)
if tp_adj is not None:
take_profit = float(tp_adj)
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
if risk_fraction is None:
return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None
risk_percent = max(0.01, float(RISK_PERCENT))
risk_amount = round(capital_base * risk_percent / 100.0, 4)
notional_value = round(risk_amount / risk_fraction, 4)
margin_capital = round(notional_value / leverage, 4)
if capital_base and margin_capital > capital_base:
return False, "以损定仓后保证金超过当前交易资金", None
if available_usdt is not None:
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
if margin_capital > max_margin:
return (
False,
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
None,
)
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
try:
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
contract_size = get_contract_size(exchange_symbol)
base_amount = round(float(amount) * contract_size, 8)
order_resp = place_exchange_order(
exchange_symbol, direction, amount, leverage,
stop_loss=stop_loss, take_profit=take_profit,
)
open_order_id = order_resp.get("id", "")
tpsl_attached = bool(order_resp.get("tpsl_attached"))
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
except Exception as e:
return False, friendly_okx_error(e, available_usdt=available_usdt), None
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
opened_at_bj = app_now_str()
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
if risk_amount_final is None:
risk_amount_final = risk_amount
else:
try:
risk_amount_final = round(float(risk_amount_final), 4)
except (TypeError, ValueError):
risk_amount_final = risk_amount
if direction == "short":
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
else:
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
conn.execute(
"INSERT INTO order_monitors "
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
symbol,
exchange_symbol,
direction,
trigger_price,
stop_loss,
stop_loss,
take_profit,
margin_capital,
leverage,
trade_style,
risk_percent,
risk_amount_final,
breakeven_rr_trigger,
breakeven_offset_pct,
breakeven_step_r,
0,
breakeven_price,
be_enabled,
notional_value,
position_ratio,
base_amount,
amount,
open_order_id,
opened_at_bj,
opened_at_ms,
trading_day,
ORDER_MONITOR_TYPE_KEY_AUTO,
stored_key_signal_type(key_signal_type),
),
)
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
opens_today_after = conn.execute(
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
(trading_day,),
).fetchone()[0]
return True, None, {
"new_order_id": new_order_id,
"open_order_id": open_order_id,
"trigger_price": trigger_price,
"planned_rr_fill": planned_rr,
"risk_amount_final": risk_amount_final,
"margin_capital": margin_capital,
"leverage": leverage,
"amount": amount,
"base_amount": base_amount,
"notional_value": notional_value,
"position_ratio": position_ratio,
"tpsl_attached": tpsl_attached,
"opens_today_before": opens_today_before,
"opens_today_after": opens_today_after,
"trading_day": trading_day,
"risk_percent": risk_percent,
"breakeven_rr_trigger": breakeven_rr_trigger,
"breakeven_price": breakeven_price,
"capital_base_at_open": capital_base,
}
def can_notify_key_monitor(row, now_dt):
max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES)
if int(row["notification_count"] or 0) >= max_notify:
@@ -4664,7 +4853,7 @@ def breakout_too_far(p, edge_price, limit_pct):
return False
# 关键位监控(箱体/收敛:硬门控后提醒;阻力/支撑5m 双向突破 + 三次提醒)
# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒)
def check_key_monitors():
conn = get_db()
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
@@ -4679,85 +4868,175 @@ def check_key_monitors():
except Exception as e:
print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}")
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
try:
checks = _key_hard_checks(sym, direction, up, low, typ)
except Exception:
checks = {"ok": False}
if not checks.get("ok"):
continue
btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h")
coin4h_status, _, _ = _status_by_ema55(sym, "4h")
risk_tip = None
if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"):
risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。"
key_price = float(low) if direction == "long" else float(up)
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 = _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)
rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
op_lines = [
f"录入方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_on else ''}",
sl_tp_plan_summary_text(
sl_tp_mode, direction, E, sl_raw, tp_raw, box_h,
outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
),
f"计划 SL`{format_wechat_scalar_2dp(sl_raw)}`|计划 TP`{format_price_for_symbol(sym, tp_raw)}`|计划 RRE):{rr_txt}:1",
"说明:OKX 本实例为提醒模式,不自动市价开仓;请按方案自行下单。",
]
else:
op_lines = [
f"录入方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_on else ''}",
"计划 SL/TP 几何无效,请检查上下沿或趋势单止盈价。",
]
trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str()
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)
new_count = int(r["notification_count"] or 0) + 1
max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES)
conn.execute(
"UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?",
(new_count, app_now_str(), r["id"]),
)
if new_count >= max_n:
insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete")
conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],))
send_wechat_msg(
"\n".join(
[
f"# 🧾 {r['symbol']} 关键位监控结束",
f"**账户:{_wechat_account_label()}**",
"",
f"- 原因:已满 {max_n} 次提醒",
"- 状态:已自动结束并记入历史",
]
)
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)
if not plan_tuple:
fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)"
rr_msg = (
f"# ⚠️ {sym} 关键位自动单:计划无效\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n"
f"- 方向:**{_wechat_direction_text(direction)}**\n"
f"- 触发时间:`{trigger_time}`\n"
f"- 确认K收盘(E)`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n"
f"- **{fmt_rr}**(未开仓)\n"
"---\n"
"### 硬条件\n"
+ "\n".join(f"- {x}" for x in hard_lines)
)
if risk_tip:
rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
send_wechat_msg(rr_msg)
_finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
continue
E, sl_raw, tp_raw, box_h = plan_tuple
exchange_symbol = normalize_okx_symbol(sym)
try:
ensure_markets_loaded()
except Exception:
pass
sl_px = round_price_to_exchange(exchange_symbol, sl_raw)
tp_px = round_price_to_exchange(exchange_symbol, tp_raw)
if sl_px is not None:
sl_raw = float(sl_px)
if tp_px is not None:
tp_raw = float(tp_px)
planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw)
rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR
if not rr_ok:
fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)"
plan_line = sl_tp_plan_summary_text(
sl_tp_mode, direction, E, sl_raw, tp_raw, box_h,
outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
)
rr_msg = (
f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{typ}{plan_line}\n"
f"- 方向:**{_wechat_direction_text(direction)}**\n"
f"- 触发时间:`{trigger_time}`\n"
f"- 确认K收盘(E)`{format_price_for_symbol(sym, E)}`\n"
f"- 箱体高 H`{format_price_for_symbol(sym, box_h)}`\n"
f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n"
"---\n"
"### 硬条件\n"
+ "\n".join(f"- {x}" for x in hard_lines)
)
if risk_tip:
rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
send_wechat_msg(rr_msg)
_finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
continue
key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None
be_on = breakeven_enabled_from_row(r, 0)
ok_trade, trade_err, det = _market_open_for_key_monitor(
conn,
sym,
direction,
exchange_symbol,
sl_raw,
tp_raw,
key_signal_type=key_sig,
breakeven_enabled=1 if be_on else 0,
)
planned_rr_txt = (
format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
)
if not ok_trade:
fail_msg = (
f"# ❌ {sym} 关键位自动单失败\n"
f"**账户:{_wechat_account_label()}**\n"
f"- 类型:{typ}\n"
f"- 方向:**{_wechat_direction_text(direction)}**\n"
f"- 触发时间:`{trigger_time}`\n"
f"- 确认K收盘(E)`{format_price_for_symbol(sym, E)}`\n"
f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n"
f"- **失败原因:{trade_err}**\n"
"---\n"
"### 硬条件\n"
+ "\n".join(f"- {x}" for x in hard_lines)
)
if risk_tip:
fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
send_wechat_msg(fail_msg)
_finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed")
continue
tpsl_txt = (
"已在交易所挂止盈/止损触发单(OKX 条件单)"
if det.get("tpsl_attached")
else "⚠️ 条件单挂接状态异常或未挂上"
)
rr_fill = det.get("planned_rr_fill")
rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-"
succ_msg_lines = [
f"# ✅ {sym} 关键位自动开仓成功",
f"**账户:{_wechat_account_label()}**",
f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)",
f"- 页面订单 ID**{det['new_order_id']}**",
f"- 交易所订单 ID`{det.get('open_order_id') or '-'}`",
f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_on else ''}",
f"- 方向:**{_wechat_direction_text(direction)}**",
f"- 触发时间:`{trigger_time}`",
f"- 确认K收盘(E){format_price_for_symbol(sym, E)}RR 阈值按此计价)",
f"- **计划 RRE):{planned_rr_txt}:1**",
f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**",
f"- **成交价侧计划 RR**{rr_fill_txt}:1",
f"- 止损:{format_wechat_scalar_2dp(sl_raw)}",
f"- 止盈:{format_price_for_symbol(sym, tp_raw)}",
f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x",
f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}",
f"- **{tpsl_txt}**",
f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}",
f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)",
]
succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines])
if risk_tip:
succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"])
succ_msg = "\n".join(succ_msg_lines)
send_wechat_msg(succ_msg)
_finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened")
if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0):
advice = ai_short_advice(
f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。"
f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。"
f"用户自述“上头了”。请给克制提醒。"
)
if advice:
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}")
conn.commit()
conn.close()
@@ -5335,8 +5614,8 @@ def render_main_page(page="trade"):
key_gate_rule_text = (
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} 分),不选方向"
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"):
@@ -6216,6 +6495,14 @@ def add_key():
extra = ""
if mt in KEY_MONITOR_AUTO_TYPES:
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_flag else ''}"
ctr = False
try:
coin4h_status, _, _ = _status_by_ema55(symbol, "4h")
ctr = (direction_sel == "long" and coin4h_status == "空头") or (
direction_sel == "short" and coin4h_status == "多头"
)
except Exception:
pass
if mt in KEY_MONITOR_RS_TYPES:
flash(
f"添加成功({symbol} 日成交量排名 {rank}/{total})|阻力/支撑:双向监控上/下沿,"
@@ -6223,6 +6510,10 @@ def add_key():
)
else:
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}{extra}")
if ctr and mt in KEY_MONITOR_AUTO_TYPES:
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"])
@@ -1,7 +1,7 @@
# 关键位监控说明(自动开仓 + 人工盯盘)
**适用:`crypto_monitor_okx`OKX 永续)**
本实例 **箱体/收敛** 为微信提醒 + 自行下单,**不自动市价开仓**;阻力/支撑规则与 Binance/Gate 相同。共享逻辑见 `key_monitor_lib.py`
箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py`
本文档与 `.env``check_key_monitors``add_key``_key_hard_checks``_process_key_rs_level_alert` 一致。
+1 -1
View File
@@ -70,7 +70,7 @@
## 与 Gate 的差异(其余)
- 无独立「关键位监控」导航页(斐波在 **交易执行** 页添加)。
- 箱体/收敛仍为 **提醒** 模式,不自动市价开仓(Gate/Binance 主站为自动开仓)。
- 箱体/收敛与 Gate/Binance 相同:**门控 + RR 达标后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。
## 交易所已实现盈亏(与 Gate 一致)